diff --git a/mobile/src/components/use-new-workspace-execution-target.ts b/mobile/src/components/use-new-workspace-execution-target.ts index 1a3c7798931..75eb0e14442 100644 --- a/mobile/src/components/use-new-workspace-execution-target.ts +++ b/mobile/src/components/use-new-workspace-execution-target.ts @@ -59,8 +59,7 @@ export function useNewWorkspaceExecutionTarget(args: { if (stale) { return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const state = sshRepoStateRead.interpret(reply) as SshConnectionState | null | undefined + const state = sshRepoStateRead.interpret(reply) setSshState(state ?? fallbackSshState(connectionId, 'disconnected', null)) }) .catch((error) => { @@ -94,8 +93,7 @@ export function useNewWorkspaceExecutionTarget(args: { if (!stale) { setDetectedAgentIdsState({ connectionId, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - ids: detected.accepted ? new Set(detected.value as string[]) : new Set() + ids: detected.accepted ? new Set(detected.value) : new Set() }) } } catch { @@ -121,8 +119,7 @@ export function useNewWorkspaceExecutionTarget(args: { { targetId: connectionId }, { timeoutMs: 120_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const state = sshRepoConnectRun.interpret(reply) as SshConnectionState | null | undefined + const state = sshRepoConnectRun.interpret(reply) setSshState(state ?? fallbackSshState(connectionId, 'connected', null)) } catch (error) { setSshState( diff --git a/mobile/src/files/mobile-file-mutation-ownership.ts b/mobile/src/files/mobile-file-mutation-ownership.ts index 978cb0796d8..634f90f0637 100644 --- a/mobile/src/files/mobile-file-mutation-ownership.ts +++ b/mobile/src/files/mobile-file-mutation-ownership.ts @@ -1,6 +1,5 @@ import { parseExecutionHostId } from '../../../src/shared/execution-host' import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-mutation-ownership' -import type { RuntimeStatus } from '../../../src/shared/runtime-types' import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types' import { fileOwnershipRuntimeStatusRead, @@ -45,11 +44,7 @@ export async function captureMobileFileMutationOwnership( const statusReply = await fileOwnershipRuntimeStatusRead.request(client, undefined, { timeoutMs: FILE_MUTATION_TIMEOUT_MS }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const status = fileOwnershipRuntimeStatusRead.interpret(statusReply) as Pick< - RuntimeStatus, - 'capabilities' - > + const status = fileOwnershipRuntimeStatusRead.interpret(statusReply) assertFileMutationOwnershipCapability(status) const worktreeReply = await fileOwnershipWorktreeRead.request( diff --git a/mobile/src/home/mobile-home-host-requests.ts b/mobile/src/home/mobile-home-host-requests.ts index 6697d7e4962..22c1ea7e3f6 100644 --- a/mobile/src/home/mobile-home-host-requests.ts +++ b/mobile/src/home/mobile-home-host-requests.ts @@ -14,14 +14,6 @@ type HomeTaskSettings = { visibleTaskProviders?: unknown } -type HomePreflightStatus = { - glab?: { installed?: boolean } -} - -type HomeLinearStatus = { - connected?: boolean -} - export type HomeStatsSetter = ( updater: (previous: Record) => Record ) => void @@ -94,15 +86,9 @@ export function fetchMobileHomeTaskProviders( ((settingsResult.value ?? {}) as HomeTaskSettings) : {} const preflightResult = taskPreflightRead.interpret(preflightResponse) - const preflight = preflightResult.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (preflightResult.value as HomePreflightStatus) - : null + const preflight = preflightResult.accepted ? preflightResult.value : null const linearResult = taskLinearStatusRead.interpret(linearResponse) - const linear = linearResult.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (linearResult.value as HomeLinearStatus) - : null + const linear = linearResult.accepted ? linearResult.value : null const providers = filterAvailableTaskProviders( normalizeVisibleTaskProviders(settings.visibleTaskProviders), { diff --git a/mobile/src/tasks/blank-workspace-create.test.ts b/mobile/src/tasks/blank-workspace-create.test.ts index d2bd5e749fa..b7bcbfef5a3 100644 --- a/mobile/src/tasks/blank-workspace-create.test.ts +++ b/mobile/src/tasks/blank-workspace-create.test.ts @@ -343,39 +343,50 @@ describe('createBlankWorkspace', () => { expect(calls).toHaveLength(1) }) - it.each([ - { label: 'worktree.create', supported: false, agent: undefined, reply: { worktree: {} } }, - { - label: 'agent.launch', - supported: true, - agent: 'codex' as const, - reply: { outcome: { kind: 'structured', sessionId: 's-1' } } - } - ])( - 'fails without retrying when an accepted $label reply names no workspace', - async ({ supported, agent, reply }) => { - // The break branch. The host accepted the call but the reply carries no workspace, so there - // is nothing to navigate to and a retry cannot help — a malformed reply is not a name - // collision. Both routes reach it: worktree.create with no `worktree.id`, agent.launch with - // no `worktreeId`. Before the guard, the legacy route read `.worktree.displayName` straight - // off the reply and surfaced a TypeError instead of a message. - const calls: Call[] = [] - const client = fakeClient(() => reply, calls) + // The break branch, and the two routes now answer it differently. + // + // `agent.launch` still reports it as a message: its reader guards `worktreeId` itself and answers + // null, which the retry loop turns into "Failed to create workspace". `worktree.create` does not: + // the create screen reads `result.worktree.id` unguarded into the session route, so the checked + // reader requires it and a reply without one is named as unreadable rather than reported as a + // create that failed. Both surface at the same catch; only the sentence changes. + it('names an accepted worktree.create reply that carries no workspace id', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ worktree: {} }), calls) - const result = await createBlankWorkspace({ + await expect( + createBlankWorkspace({ client, repoId: 'repo-1', baseName: 'octopus', - createdWithAgentId: agent, + createdWithAgentId: undefined, comment: undefined, setupDecision: 'inherit', nameWasGenerated: false, worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, - agentLaunchSupported: supported + agentLaunchSupported: false }) + ).rejects.toThrow('worktree.create') + expect(calls).toHaveLength(1) + }) - expect(result).toEqual({ error: 'Failed to create workspace' }) - expect(calls).toHaveLength(1) - } - ) + it('fails without retrying when an accepted agent.launch reply names no workspace', async () => { + const calls: Call[] = [] + const client = fakeClient(() => ({ outcome: { kind: 'structured', sessionId: 's-1' } }), calls) + + const result = await createBlankWorkspace({ + client, + repoId: 'repo-1', + baseName: 'octopus', + createdWithAgentId: 'codex', + comment: undefined, + setupDecision: 'inherit', + nameWasGenerated: false, + worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT, + agentLaunchSupported: true + }) + + expect(result).toEqual({ error: 'Failed to create workspace' }) + expect(calls).toHaveLength(1) + }) }) diff --git a/mobile/src/tasks/composer-source-base-resolve.ts b/mobile/src/tasks/composer-source-base-resolve.ts index 40419993970..4de4e197a5d 100644 --- a/mobile/src/tasks/composer-source-base-resolve.ts +++ b/mobile/src/tasks/composer-source-base-resolve.ts @@ -9,8 +9,6 @@ export type ComposerHostedBase = Pick< 'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify' > -type HostedBaseResult = ComposerHostedBase | { error: string } - // Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's // select-time resolution. The runtime returns a soft { error } payload rather // than an RPC error for provider failures. @@ -34,12 +32,12 @@ export async function resolveComposerPrBase(args: { }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = worktreePrBaseResolve.interpret(reply) as GitHubPrStartPoint | { error: string } + const result = worktreePrBaseResolve.interpret(reply) if ('error' in result) { throw new Error(result.error) } - return result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolved arm requires `baseBranch`; `compareBaseRef`, `pushTarget`, `branchNameOverride` and `maintainerCanModify` are optional on GitHubPrStartPoint and stay optional here, and unknown members pass through to the create. + return result as GitHubPrStartPoint } // Resolves a GitLab MR's base via worktree.resolveMrBase. @@ -63,10 +61,10 @@ export async function resolveComposerMrBase(args: { }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = worktreeMrBaseResolve.interpret(reply) as HostedBaseResult + const result = worktreeMrBaseResolve.interpret(reply) if ('error' in result) { throw new Error(result.error) } - return result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as the PR resolver above. + return result as ComposerHostedBase } diff --git a/mobile/src/tasks/mobile-task-project-board-operations.ts b/mobile/src/tasks/mobile-task-project-board-operations.ts index 783962ad7d8..ea0c0137ccf 100644 --- a/mobile/src/tasks/mobile-task-project-board-operations.ts +++ b/mobile/src/tasks/mobile-task-project-board-operations.ts @@ -1,11 +1,31 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' -import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import { githubPrRepoSlugSchema } from '../session/github-pr-read-reply-schema' +import { + taskProjectAccessibleListSchema, + taskProjectAssignableUserListSchema, + taskProjectCommentMutationSchema, + taskProjectCommentWriteSchema, + taskProjectIssueTypeListSchema, + taskProjectLabelListSchema, + taskProjectMutationStatusSchema, + taskProjectRefSchema, + taskProjectRowDetailSchema, + taskProjectViewListSchema, + taskProjectViewTableSchema +} from './task-project-board-reply-schema' // The GitHub Projects board. Every `github.project.*` reply is an accepted result carrying its own // `{ ok, error }` envelope, which the board reads itself and whose message it prefers over its own // copy; the acceptance policy only decides whether there is an envelope to read. The board also // sends the plain `github.*` pull-request operations in mobile-task-item-state-operations.ts, // with a `prRepo` the item screen does not send — same method, same acceptance, one operation. +// +// Every reader here is checked against task-project-board-reply-schema.ts, which records the +// consumer line behind each requirement. No acceptance changes: `require-result-or-throw-message` +// still carries a refusal to the site's own catch, and it is that policy — not the reader, which +// only ever answers `compatible: false` — that turns an unreadable envelope into the thrown +// RpcIncompatibleReplyError the site reports. export const githubProjectListRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -13,7 +33,7 @@ export const githubProjectListRead = bindDeferredRpcOperation( method: 'github.project.listAccessible', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-list') + read: rpcResultVariant('github-project-list', taskProjectAccessibleListSchema) }) ) @@ -23,7 +43,7 @@ export const githubProjectViewListRead = bindDeferredRpcOperation( method: 'github.project.listViews', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-views') + read: rpcResultVariant('github-project-views', taskProjectViewListSchema) }) ) @@ -33,7 +53,7 @@ export const githubProjectViewTableRead = bindDeferredRpcOperation( method: 'github.project.viewTable', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-table') + read: rpcResultVariant('github-project-table', taskProjectViewTableSchema) }) ) @@ -45,7 +65,7 @@ export const githubProjectRefResolve = bindDeferredRpcOperation( method: 'github.project.resolveRef', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-ref') + read: rpcResultVariant('github-project-ref', taskProjectRefSchema) }) ) @@ -55,7 +75,7 @@ export const githubProjectRowDetailRead = bindDeferredRpcOperation( method: 'github.project.workItemDetailsBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-row-details') + read: rpcResultVariant('github-project-row-details', taskProjectRowDetailSchema) }) ) @@ -65,7 +85,7 @@ export const githubProjectLabelListRead = bindDeferredRpcOperation( method: 'github.project.listLabelsBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-labels') + read: rpcResultVariant('github-project-labels', taskProjectLabelListSchema) }) ) @@ -75,7 +95,7 @@ export const githubProjectAssignableUserListRead = bindDeferredRpcOperation( method: 'github.project.listAssignableUsersBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-assignable-users') + read: rpcResultVariant('github-project-assignable-users', taskProjectAssignableUserListSchema) }) ) @@ -85,15 +105,18 @@ export const githubProjectIssueTypeListRead = bindDeferredRpcOperation( method: 'github.project.listIssueTypesBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-issue-types') + read: rpcResultVariant('github-project-issue-types', taskProjectIssueTypeListSchema) }) ) /** * A board row's issue edits. Two call sites send it — the metadata sheet's labels and assignees, - * and the row editor's title, body and state — and they disagree about a null reply: the metadata - * sheet reads `result.ok` off it and throws a property-read TypeError, which #20563 left in place - * as recorded behaviour. That difference is in the call sites, not in the acceptance. + * and the row editor's title, body and state — and both read `result.ok` off the payload. + * + * #20563 left a null reply reaching that read as a property-read TypeError, which the + * `project.update-metadata` b2 seed records. The checked reader names it instead: a payload that is + * not an envelope at all is an incompatible `github.project.updateIssueBySlug` reply, and the two + * sites still differ only in the copy their own catch shows. */ export const githubProjectIssueUpdate = bindDeferredRpcOperation( defineRpcOperation({ @@ -101,7 +124,7 @@ export const githubProjectIssueUpdate = bindDeferredRpcOperation( method: 'github.project.updateIssueBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-updated-issue') + read: rpcResultVariant('github-project-updated-issue', taskProjectMutationStatusSchema) }) ) @@ -111,7 +134,7 @@ export const githubProjectPullRequestUpdate = bindDeferredRpcOperation( method: 'github.project.updatePullRequestBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-updated-pull-request') + read: rpcResultVariant('github-project-updated-pull-request', taskProjectMutationStatusSchema) }) ) @@ -121,7 +144,7 @@ export const githubProjectIssueTypeUpdate = bindDeferredRpcOperation( method: 'github.project.updateIssueTypeBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-updated-issue-type') + read: rpcResultVariant('github-project-updated-issue-type', taskProjectMutationStatusSchema) }) ) @@ -131,7 +154,7 @@ export const githubProjectFieldUpdate = bindDeferredRpcOperation( method: 'github.project.updateItemField', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-updated-field') + read: rpcResultVariant('github-project-updated-field', taskProjectMutationStatusSchema) }) ) @@ -141,7 +164,7 @@ export const githubProjectFieldClear = bindDeferredRpcOperation( method: 'github.project.clearItemField', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-cleared-field') + read: rpcResultVariant('github-project-cleared-field', taskProjectMutationStatusSchema) }) ) @@ -151,7 +174,7 @@ export const githubProjectCommentWrite = bindDeferredRpcOperation( method: 'github.project.addIssueCommentBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-issue-comment') + read: rpcResultVariant('github-project-issue-comment', taskProjectCommentWriteSchema) }) ) @@ -161,7 +184,7 @@ export const githubProjectCommentUpdate = bindDeferredRpcOperation( method: 'github.project.updateIssueCommentBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-updated-comment') + read: rpcResultVariant('github-project-updated-comment', taskProjectCommentMutationSchema) }) ) @@ -171,7 +194,7 @@ export const githubProjectCommentDelete = bindDeferredRpcOperation( method: 'github.project.deleteIssueCommentBySlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-project-deleted-comment') + read: rpcResultVariant('github-project-deleted-comment', taskProjectCommentMutationSchema) }) ) @@ -180,7 +203,9 @@ export const githubProjectCommentDelete = bindDeferredRpcOperation( * against Orca repos and must distinguish "this repo has no slug" from "the ask failed", so it * throws and caches the failure for retry; the Smart picker's paste lookup in * mobile-task-source-search-operations.ts caches a refusal as "no slug" and carries on, so there - * a refusal is a skip. One reader serves both. + * a refusal is a skip. One reader serves both — literally: both operations read through + * `githubPrRepoSlugSchema`, the schema the session domain already wrote for this same reply, since + * `github.repoSlug` has one shape and three consumers that all answer null without a slug. */ export const githubProjectRepoSlugRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -188,6 +213,6 @@ export const githubProjectRepoSlugRead = bindDeferredRpcOperation( method: 'github.repoSlug', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('repo-slug') + read: rpcResultVariant('repo-slug', githubPrRepoSlugSchema) }) ) diff --git a/mobile/src/tasks/mobile-task-runtime-operations.ts b/mobile/src/tasks/mobile-task-runtime-operations.ts index 3bc2ecce48c..138d364d2e9 100644 --- a/mobile/src/tasks/mobile-task-runtime-operations.ts +++ b/mobile/src/tasks/mobile-task-runtime-operations.ts @@ -1,10 +1,17 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' import { - rpcUncheckedMemberReader, - rpcUncheckedPayloadReader -} from '../transport/rpc-reader-payload' + taskLinearStatusSchema, + taskPreferenceWriteSchema, + taskPreflightSchema, + taskRuntimeStatusSchema, + taskUiStateSchema +} from './task-runtime-reply-schema' // What the Tasks screen reads once per host to hydrate, and the preferences it writes back. +// +// Readers are checked against task-runtime-reply-schema.ts. The three writes read `z.unknown()` +// there: no call site interprets their body, so a requirement would have no reader behind it. /** * status.get read for task hydration, with its own policy on that method: a refused status stops @@ -17,7 +24,7 @@ export const taskRuntimeStatusRead = bindDeferredRpcOperation( method: 'status.get', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('runtime-status') + read: rpcResultVariant('runtime-status', taskRuntimeStatusSchema) }) ) @@ -31,7 +38,7 @@ export const taskUiStateRead = bindDeferredRpcOperation( method: 'ui.get', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedMemberReader('ui-state-member', 'ui') + read: rpcResultVariant('ui-state-member', taskUiStateSchema) }) ) @@ -42,7 +49,7 @@ export const taskPreflightRead = bindDeferredRpcOperation( method: 'preflight.check', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('task-preflight') + read: rpcResultVariant('task-preflight', taskPreflightSchema) }) ) @@ -53,7 +60,7 @@ export const taskLinearStatusRead = bindDeferredRpcOperation( method: 'linear.status', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('linear-status') + read: rpcResultVariant('linear-status', taskLinearStatusSchema) }) ) @@ -68,7 +75,7 @@ export const taskUiStateWrite = bindDeferredRpcOperation( method: 'ui.set', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('ui-state-written') + read: rpcResultVariant('ui-state-written', taskPreferenceWriteSchema) }) ) @@ -82,7 +89,7 @@ export const taskSettingsWrite = bindDeferredRpcOperation( method: 'settings.update', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('setting-written') + read: rpcResultVariant('setting-written', taskPreferenceWriteSchema) }) ) @@ -93,7 +100,8 @@ export const taskSettingsWrite = bindDeferredRpcOperation( * send without reading the reply, so a refused switch reloads the context exactly as an accepted * one does and only a transport rejection reaches the error copy. Interpreting here would make a * refusal visible for the first time, which is a product change and not this one. See - * unvalidated-rpc-request-port-inventory.ts for the ticket. + * unvalidated-rpc-request-port-inventory.ts for the ticket. The checked reader keeps that: its + * schema is `z.unknown()`, so no payload can make this site fail where main's did not. */ export const linearWorkspaceSelect = bindDeferredRpcOperation( defineRpcOperation({ @@ -101,6 +109,6 @@ export const linearWorkspaceSelect = bindDeferredRpcOperation( method: 'linear.selectWorkspace', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('linear-workspace-selection') + read: rpcResultVariant('linear-workspace-selection', taskPreferenceWriteSchema) }) ) diff --git a/mobile/src/tasks/mobile-task-source-search-operations.ts b/mobile/src/tasks/mobile-task-source-search-operations.ts index 7460c42bfad..59882b26fab 100644 --- a/mobile/src/tasks/mobile-task-source-search-operations.ts +++ b/mobile/src/tasks/mobile-task-source-search-operations.ts @@ -1,10 +1,16 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' -import type { RpcCompatibleReader } from '../transport/rpc-operation-contract' -import { rpcReadUnchecked, rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' -import { extractLinearIssueReadItems } from './linear-mobile-issue-read' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import { githubPrRepoSlugSchema } from '../session/github-pr-read-reply-schema' +import { + taskGitHubWorkItemListSchema, + taskGitLabWorkItemListSchema, + taskLinearIssueListSchema, + taskWorkItemLookupSchema +} from './task-source-search-reply-schema' // The Smart workspace-source picker's provider reads: per-repo search, and the single-item lookups -// a pasted link or number resolves to. Provider-specific fallbacks stay at their own call sites. +// a pasted link or number resolves to. Provider-specific fallbacks stay at their own call sites, +// and the readers are checked against task-source-search-reply-schema.ts. export const githubWorkItemSearchRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -12,7 +18,7 @@ export const githubWorkItemSearchRead = bindDeferredRpcOperation( method: 'github.listWorkItems', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-work-items') + read: rpcResultVariant('github-work-items', taskGitHubWorkItemListSchema) }) ) @@ -23,15 +29,16 @@ export const gitlabWorkItemSearchRead = bindDeferredRpcOperation( method: 'gitlab.listWorkItems', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('gitlab-work-items') + read: rpcResultVariant('gitlab-work-items', taskGitLabWorkItemListSchema) }) ) // Linear replies either as a bare array or as an `{ items }` envelope, and the picker has always // accepted both through this projection. Two operations share it because the empty-query path asks -// a different method, not because the two answers differ. -const linearIssueReader: RpcCompatibleReader = (raw) => - rpcReadUnchecked('linear-issues', extractLinearIssueReadItems(raw)) +// a different method, not because the two answers differ. The union in the schema is what used to +// be linear-mobile-issue-read.ts's hand reader, whose `throw new Error('Unexpected Linear tasks +// response')` reached the screen as unattributed copy; the same payloads now name the method. +const linearIssueReader = rpcResultVariant('linear-issues', taskLinearIssueListSchema) export const linearIssueSearchRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -65,7 +72,7 @@ export const githubRepoSlugRead = bindDeferredRpcOperation( method: 'github.repoSlug', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('repo-slug') + read: rpcResultVariant('repo-slug', githubPrRepoSlugSchema) }) ) @@ -75,7 +82,7 @@ export const githubWorkItemByNumberRead = bindDeferredRpcOperation( method: 'github.workItem', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-work-item') + read: rpcResultVariant('github-work-item', taskWorkItemLookupSchema) }) ) @@ -85,7 +92,7 @@ export const githubWorkItemBySlugRead = bindDeferredRpcOperation( method: 'github.workItemByOwnerRepo', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('github-work-item') + read: rpcResultVariant('github-work-item', taskWorkItemLookupSchema) }) ) @@ -95,6 +102,6 @@ export const gitlabWorkItemByPathRead = bindDeferredRpcOperation( method: 'gitlab.workItemByPath', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('gitlab-work-item') + read: rpcResultVariant('gitlab-work-item', taskWorkItemLookupSchema) }) ) diff --git a/mobile/src/tasks/mobile-tasks-project-workspace-types.ts b/mobile/src/tasks/mobile-tasks-project-workspace-types.ts index 7a334a83e67..4fbadec4650 100644 --- a/mobile/src/tasks/mobile-tasks-project-workspace-types.ts +++ b/mobile/src/tasks/mobile-tasks-project-workspace-types.ts @@ -87,7 +87,8 @@ export type SetupPrompt = { sparseCheckoutOverride?: { directories: string[]; presetId?: string } repoName: string command: string - source: string | null + /** Absent as well as null: `repo.hooks` need not report where the setup script came from. */ + source: string | null | undefined } export type WorkspaceCreateArgs = { diff --git a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts index f91b59e05a4..b04aa492916 100644 --- a/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts +++ b/mobile/src/tasks/mobile-tasks-refactor-parity.test.ts @@ -26,11 +26,20 @@ const hash = (parts: string[] | string): string => // and style counts are all unchanged, and `semantics` is a pure deletion of four lines, none in: // two `rpc:` call signatures and the two method literals they carried. The render-token hash moves // because the picker's handler now names an operation instead of the client. -const SCREEN_RPC_SCREEN_HOOKS = '1b455d87ed00a1e70a5b3cac0110272e818da9a0d245e9043fc9d2649587831f' +// +// Step 7 moves the hook, statement, declaration and semantic hashes and no count. Hooks stay at +// 350 and 28 of them move, every one a body this migration edited; no dependency array changes, +// which is what a hook signature is here to pin. Statements hold at 417 and declarations at 194: +// checked readers delete type assertions, not statements. `semantics` loses exactly four lines, +// and all four are string literals that lived INSIDE the one deleted inline cast type in +// use-mobile-tasks-project-detail-loading.tsx — `'DISMISSED'`, `'VIEWED'`, `'UNVIEWED'` and the +// `['status']` index into GitHubDetailFile. No method literal and no `rpc:` call signature moves, +// which is the property this family exists to hold. +const SCREEN_RPC_SCREEN_HOOKS = '0f015615e608e48f179566606c93b65758093c4a08e853d382eec888d95b79be' const PRE_REFACTOR_DIFF_HOOKS = '93c7189b32bed8456cc51814fffa8ce80cf62011ef968a9d53ddec2b9686f58f' -const SCREEN_RPC_STATEMENTS = '67ea80f265e4a2e25b3d7e7d9b93664a150a27b93dcfbc39cbdd551de7b4a653' -const MAIN_REBASED_DECLARATIONS = '6ad0397123e59fc1047a14049c86ff31d81723673a7a7f5c41677471aec58415' -const SCREEN_RPC_SEMANTICS = '7e40c7efa07993071e57db0fe1d46099a56e3831033b512480dd195d7a1dc24c' +const SCREEN_RPC_STATEMENTS = 'ebd1826ccf1737329ac7270fc71a624f56223ddb90ecde739a30fa25ef92e872' +const MAIN_REBASED_DECLARATIONS = 'f6f5fe2cc09dfd91f8f7048ab0ce11579cd2618e234079d2ab403bea4fb7161d' +const SCREEN_RPC_SEMANTICS = '9bea10a73a501bbb09b825ca62119f808640e40940b3a5f1dce0493aa5452314' const PRE_REFACTOR_STYLES = '1db6af69c791d9963928541ad5310942fcbda6d984b422c90b6eb92b6816579a' const SCREEN_RPC_RENDER_TREE = '46d5a3ce9d71a8281a1e7b17411fb1dd963a4f392a5d095bc126b6a7cff4b92d' @@ -59,7 +68,7 @@ describe('Mobile Tasks refactor parity', () => { it('preserves RPC calls, runtime strings, and JSX host signatures', () => { const semantics = readMobileTasksSemanticSource() - expect(semantics.split('\n')).toHaveLength(3_300) + expect(semantics.split('\n')).toHaveLength(3_296) expect(hash(semantics)).toBe(SCREEN_RPC_SEMANTICS) }) diff --git a/mobile/src/tasks/mobile-tasks-repository-presentation.ts b/mobile/src/tasks/mobile-tasks-repository-presentation.ts index ae263793304..dd4ebf36e83 100644 --- a/mobile/src/tasks/mobile-tasks-repository-presentation.ts +++ b/mobile/src/tasks/mobile-tasks-repository-presentation.ts @@ -29,7 +29,9 @@ export function getRepoBadgeColor(repo: RepoSummary | undefined, fallbackName: s return repo?.badgeColor || repoColor(repo?.displayName ?? fallbackName) } -export function setupSourceLabel(source: string | null): string { +// `undefined` as well as `null`: a checked `repo.hooks` reader does not require `source`, and the +// recorded reply for a repo with no hooks file carries none. +export function setupSourceLabel(source: string | null | undefined): string { if (source === 'orca.yaml') { return 'orca.yaml' } diff --git a/mobile/src/tasks/mobile-workspace-create-operations.ts b/mobile/src/tasks/mobile-workspace-create-operations.ts index 3da90f200ed..8182920b427 100644 --- a/mobile/src/tasks/mobile-workspace-create-operations.ts +++ b/mobile/src/tasks/mobile-workspace-create-operations.ts @@ -1,8 +1,15 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' -import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' +import { + agentLaunchCreateReceiptSchema, + worktreeCreateReceiptSchema, + worktreeHostedBaseSchema +} from './workspace-create-reply-schema' +import { taskRuntimeStatusSchema } from './task-runtime-reply-schema' -// Creating a workspace from a task. Every reply here is one the call site only re-typed, so the -// readers are unchecked: moving a shape check in would be a validation change, not a migration. +// Creating a workspace from a task. Checked against workspace-create-reply-schema.ts; the +// create-time status probe reads through the Tasks screen's own status schema, because the two +// operations differ in acceptance and not in what the host sends. /** * worktree.create. A lost reply is *unknown*, never failed — `worktree-create-retry.ts` replays on @@ -16,7 +23,7 @@ export const worktreeCreateRun = bindDeferredRpcOperation( method: 'worktree.create', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('created-worktree') + read: rpcResultVariant('created-worktree', worktreeCreateReceiptSchema) }) ) @@ -31,7 +38,7 @@ export const agentLaunchRun = bindDeferredRpcOperation( method: 'agent.launch', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('agent-launch-receipt') + read: rpcResultVariant('agent-launch-receipt', agentLaunchCreateReceiptSchema) }) ) @@ -45,7 +52,7 @@ export const worktreePrBaseResolve = bindDeferredRpcOperation( method: 'worktree.resolvePrBase', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('pr-start-point') + read: rpcResultVariant('pr-start-point', worktreeHostedBaseSchema) }) ) @@ -56,7 +63,7 @@ export const worktreeMrBaseResolve = bindDeferredRpcOperation( method: 'worktree.resolveMrBase', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('mr-start-point') + read: rpcResultVariant('mr-start-point', worktreeHostedBaseSchema) }) ) @@ -66,7 +73,7 @@ export const worktreeMrBaseResolve = bindDeferredRpcOperation( * Separately named because the callers disagree about what a refused status means: the * Tasks screen cannot hydrate without it and surfaces the host's message (`taskRuntimeStatusRead`), * while create-time capability probing degrades to "no capabilities" and creates anyway, so here a - * refusal is a skip. One reader serves both — the payload is unchecked in each. + * refusal is a skip. One reader serves both, and it is now the same checked schema in each. */ export const worktreeCreateCapabilityRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -74,6 +81,6 @@ export const worktreeCreateCapabilityRead = bindDeferredRpcOperation( method: 'status.get', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('runtime-status') + read: rpcResultVariant('runtime-status', taskRuntimeStatusSchema) }) ) diff --git a/mobile/src/tasks/mobile-workspace-source-operations.ts b/mobile/src/tasks/mobile-workspace-source-operations.ts index 3126682c9a1..8f7a5420b15 100644 --- a/mobile/src/tasks/mobile-workspace-source-operations.ts +++ b/mobile/src/tasks/mobile-workspace-source-operations.ts @@ -1,13 +1,19 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation' +import { rpcResultVariant } from '../transport/rpc-operation-result-reader' import { - rpcUncheckedMemberReader, - rpcUncheckedPayloadReader -} from '../transport/rpc-reader-payload' + detectedAgentIdsSchema, + repoBaseRefSearchSchema, + repoSetupHooksSchema, + repoSparsePresetListSchema, + repoSparsePresetSaveSchema, + sshConnectionStateSchema +} from './workspace-source-reply-schema' // The repo and SSH reads the workspace-create drawer runs: connection state, agent detection, -// repo-owned setup hooks, sparse presets and base-branch search. +// repo-owned setup hooks, sparse presets and base-branch search. Checked against +// workspace-source-reply-schema.ts. -const sshConnectionStateReader = rpcUncheckedMemberReader('ssh-connection-state', 'state') +const sshConnectionStateReader = rpcResultVariant('ssh-connection-state', sshConnectionStateSchema) /** Connecting an SSH repo before create. The reply's only read field is `state`. */ export const sshRepoConnectRun = bindDeferredRpcOperation( @@ -39,7 +45,7 @@ export const remoteAgentDetectionRead = bindDeferredRpcOperation( method: 'preflight.detectRemoteAgents', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('detected-agent-ids') + read: rpcResultVariant('detected-agent-ids', detectedAgentIdsSchema) }) ) @@ -50,7 +56,7 @@ export const localAgentDetectionRead = bindDeferredRpcOperation( method: 'preflight.detectAgents', acceptance: 'success-result-or-skip', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('detected-agent-ids') + read: rpcResultVariant('detected-agent-ids', detectedAgentIdsSchema) }) ) @@ -61,7 +67,7 @@ export const repoSetupHooksRead = bindDeferredRpcOperation( method: 'repo.hooks', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('repo-hooks') + read: rpcResultVariant('repo-hooks', repoSetupHooksSchema) }) ) @@ -71,7 +77,7 @@ export const repoSparsePresetListRead = bindDeferredRpcOperation( method: 'repo.sparsePresets', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedMemberReader('sparse-presets', 'presets') + read: rpcResultVariant('sparse-presets', repoSparsePresetListSchema) }) ) @@ -81,14 +87,14 @@ export const repoSparsePresetSaveRun = bindDeferredRpcOperation( method: 'repo.saveSparsePreset', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedMemberReader('saved-sparse-preset', 'preset') + read: rpcResultVariant('saved-sparse-preset', repoSparsePresetSaveSchema) }) ) /** - * Base-branch search. The payload is unchecked: both callers — the drawer's picker effect and the - * Smart source picker — spell their own `refDetails ?? refs.map(...)` fallback, and reproducing - * that in the reader would need a type assertion the operation fence rightly bans. + * Base-branch search. Both callers — the drawer's picker effect and the Smart source picker — + * spell their own `refDetails ?? refs.map(...)` fallback, and that stays where it is: the schema + * requires neither member, so the choice between them is still the call site's. */ export const repoBaseRefSearchRead = bindDeferredRpcOperation( defineRpcOperation({ @@ -96,6 +102,6 @@ export const repoBaseRefSearchRead = bindDeferredRpcOperation( method: 'repo.searchRefs', acceptance: 'require-result-or-throw-message', barrier: 'after-caller-barrier', - read: rpcUncheckedPayloadReader('base-ref-search') + read: rpcResultVariant('base-ref-search', repoBaseRefSearchSchema) }) ) diff --git a/mobile/src/tasks/setup-hook-trust.ts b/mobile/src/tasks/setup-hook-trust.ts index e6cb492e617..7342c65ae65 100644 --- a/mobile/src/tasks/setup-hook-trust.ts +++ b/mobile/src/tasks/setup-hook-trust.ts @@ -52,11 +52,16 @@ export async function persistSetupHookTrustApproval(args: { return next } +// Takes a partial record because a checked `repo.hooks` reader requires neither member: the +// recorded reply carries a hooks payload with no setupTrust at all, so the pair is proven here +// rather than declared upstream. The spread keeps whatever else the host sent on the record. export function normalizeSetupHookTrust( - setupTrust: SetupHookTrust | null | undefined + setupTrust: { contentHash?: string; scriptContent?: string } | null | undefined ): SetupHookTrust | null { - if (!setupTrust?.contentHash || !setupTrust.scriptContent) { + const contentHash = setupTrust?.contentHash + const scriptContent = setupTrust?.scriptContent + if (!contentHash || !scriptContent) { return null } - return setupTrust + return { ...setupTrust, contentHash, scriptContent } } diff --git a/mobile/src/tasks/smart-source-paste-intent.ts b/mobile/src/tasks/smart-source-paste-intent.ts index 87715eab039..9918c0a9974 100644 --- a/mobile/src/tasks/smart-source-paste-intent.ts +++ b/mobile/src/tasks/smart-source-paste-intent.ts @@ -124,8 +124,7 @@ export async function findRepoMatchingSlugForPaste( return null } const slug = githubRepoSlugRead.interpret(reply) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - resolved = slug.accepted ? (slug.value as RepoSlug | null) : null + resolved = slug.accepted ? slug.value : null } catch { resolved = null } @@ -147,7 +146,7 @@ export async function lookupGitHubItemByNumber( repo: `id:${repoId}`, number }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires nothing on the item, because the recorded lookup answers `{ number: 12, title: 'twelve' }`; what it adds is that a non-object payload is now a named reply rather than a spread over a string. const item = githubWorkItemByNumberRead.interpret(reply) as GitHubWorkItem | null return item ? { ...item, repoId } : null } @@ -167,7 +166,7 @@ export async function lookupGitHubItemByOwnerRepo( number, type }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above. const item = githubWorkItemBySlugRead.interpret(reply) as GitHubWorkItem | null return item ? { ...item, repoId } : null } @@ -184,7 +183,7 @@ export async function lookupGitLabItemByPath( iid: link.number, type: link.type }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as above; the recorded GitLab lookup answers `{ iid: 7, title: 'seven' }`. const item = gitlabWorkItemByPathRead.interpret(reply) as GitLabWorkItem | null return item ? { ...item, repoId } : null } diff --git a/mobile/src/tasks/smart-source-search-requests.ts b/mobile/src/tasks/smart-source-search-requests.ts index 432e6f1c6bc..3b8e2997952 100644 --- a/mobile/src/tasks/smart-source-search-requests.ts +++ b/mobile/src/tasks/smart-source-search-requests.ts @@ -36,11 +36,11 @@ export async function searchGitHubItems( limit: PER_REPO_FETCH_LIMIT, query: scopeGitHubQuery(query) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const envelope = githubWorkItemSearchRead.interpret(reply) as { items: GitHubWorkItem[] } + const envelope = githubWorkItemSearchRead.interpret(reply) // Stamp repoId so the shared row builder + create flow can attribute each item // to the searched repo (the runtime omits it, like the desktop fetcher). - return (envelope.items ?? []).map((item) => ({ ...item, repoId })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires `items` and types each row's members, but requires none of them: the recorded search success carries rows of `{ number, title }` only, so a requirement here would drop a row main renders. + return envelope.items.map((item) => ({ ...item, repoId })) as GitHubWorkItem[] } export async function searchGitLabItems( @@ -56,15 +56,12 @@ export async function searchGitLabItems( perPage: GITLAB_PER_PAGE, query: query.trim() || undefined }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const envelope = gitlabWorkItemSearchRead.interpret(reply) as { - items: GitLabWorkItem[] - error?: { type?: string; message: string } - } + const envelope = gitlabWorkItemSearchRead.interpret(reply) if (envelope.error?.type && envelope.error.type !== 'not_found') { - throw new Error(envelope.error.message) + throw new Error(envelope.error.message ?? '') } - return (envelope.items ?? []).map((item) => ({ ...item, repoId })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same row rule as the GitHub search above; the recorded GitLab row is `{ iid, title }`. + return envelope.items.map((item) => ({ ...item, repoId })) as GitLabWorkItem[] } export async function searchLinearIssues( @@ -92,7 +89,7 @@ export async function searchLinearIssues( workspaceId: linearWorkspaceId ?? undefined }) ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires each row's `id` and nothing else, because the recorded smart-search success carries rows of `{ id }` alone; the row builder's own reads stay where they are. return issues as LinearIssue[] } @@ -106,11 +103,7 @@ export async function searchBranches( { repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = repoBaseRefSearchRead.interpret(reply) as { - refDetails?: BaseRefSearchResult[] - refs?: string[] - } + const result = repoBaseRefSearchRead.interpret(reply) return ( result.refDetails ?? (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) diff --git a/mobile/src/tasks/task-project-board-reply-schema.test.ts b/mobile/src/tasks/task-project-board-reply-schema.test.ts new file mode 100644 index 00000000000..5515ed999aa --- /dev/null +++ b/mobile/src/tasks/task-project-board-reply-schema.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' +import { + taskProjectAccessibleListSchema, + taskProjectCommentMutationSchema, + taskProjectCommentWriteSchema, + taskProjectIssueTypeListSchema, + taskProjectLabelListSchema, + taskProjectMutationStatusSchema, + taskProjectRefSchema, + taskProjectRowDetailSchema, + taskProjectViewListSchema, + taskProjectViewTableSchema +} from './task-project-board-reply-schema' + +// Pins the decisions task-project-board-reply-schema.ts documents: the one closed enum, the one +// open-vocabulary string beside it, the row drops, and the tri-state the detail pane forwards. + +describe('project envelopes', () => { + it('reads the recorded accessible-project list whole', () => { + const parsed = taskProjectAccessibleListSchema.safeParse({ + ok: true, + projects: [ + { owner: 'owner', ownerType: 'organization', number: 3, title: 'Board', host: 'github.com' } + ], + partialFailures: [] + }) + expect(parsed.success && parsed.data).toEqual({ + ok: true, + projects: [ + { owner: 'owner', ownerType: 'organization', number: 3, title: 'Board', host: 'github.com' } + ], + partialFailures: [] + }) + }) + + it('requires the message the refusal arm is thrown with', () => { + const parsed = taskProjectAccessibleListSchema.safeParse({ + ok: false, + error: { type: 'not_found', message: 'gone' } + }) + expect(parsed.success && parsed.data).toMatchObject({ ok: false, error: { message: 'gone' } }) + }) + + it('refuses an envelope with no ok, which main read as a property access on undefined', () => { + expect(taskProjectAccessibleListSchema.safeParse({ projects: [] }).success).toBe(false) + expect(taskProjectAccessibleListSchema.safeParse(null).success).toBe(false) + }) +}) + +describe('ownerType is a closed enum', () => { + it('takes both arms the host validates', () => { + for (const ownerType of ['organization', 'user'] as const) { + const parsed = taskProjectRefSchema.safeParse({ ok: true, owner: 'o', ownerType, number: 3 }) + expect(parsed.success && parsed.data).toMatchObject({ ownerType }) + } + }) + + it('refuses an arm it does not know, because the value is echoed into listViews params', () => { + const parsed = taskProjectRefSchema.safeParse({ + ok: true, + owner: 'o', + ownerType: 'enterprise', + number: 3 + }) + expect(parsed.success).toBe(false) + }) + + it('drops a project row carrying an unknown ownerType rather than failing the list', () => { + const parsed = taskProjectAccessibleListSchema.safeParse({ + ok: true, + projects: [ + { owner: 'a', ownerType: 'organization', number: 1 }, + { owner: 'b', ownerType: 'enterprise', number: 2 } + ] + }) + expect(parsed.success && parsed.data).toMatchObject({ + projects: [{ owner: 'a', ownerType: 'organization', number: 1 }] + }) + }) +}) + +describe('layout stays an open vocabulary', () => { + it('keeps a view whose layout this build has never heard of', () => { + const parsed = taskProjectViewListSchema.safeParse({ + ok: true, + views: [{ id: 'v1', number: 1, name: 'Timeline', layout: 'TIMELINE_LAYOUT' }] + }) + expect(parsed.success && parsed.data).toMatchObject({ + views: [{ id: 'v1', layout: 'TIMELINE_LAYOUT' }] + }) + }) + + it('drops a view with no id, which nothing could have selected', () => { + const parsed = taskProjectViewListSchema.safeParse({ + ok: true, + views: [{ number: 1, layout: 'TABLE_LAYOUT' }] + }) + expect(parsed.success && parsed.data).toMatchObject({ views: [] }) + }) +}) + +describe('the board table', () => { + it('reads the recorded table, whose project carries only id/title/number', () => { + const parsed = taskProjectViewTableSchema.safeParse({ + ok: true, + data: { + project: { id: 'project-1', title: 'Board', number: 3 }, + selectedView: { id: 'view-1', number: 1, name: 'Table', filter: 'is:open' }, + fields: [], + rows: [] + } + }) + expect(parsed.success).toBe(true) + }) + + it('refuses a table with no selectedView, which was a read on undefined', () => { + const parsed = taskProjectViewTableSchema.safeParse({ + ok: true, + data: { project: { id: 'p' }, rows: [] } + }) + expect(parsed.success).toBe(false) + }) +}) + +describe('the row detail pane preserves reviewDecision as a tri-state', () => { + const detail = (item: unknown) => + taskProjectRowDetailSchema.safeParse({ ok: true, details: { item } }) + + it('keeps an explicit null', () => { + const parsed = detail({ reviewDecision: null }) + expect(parsed.success && parsed.data).toMatchObject({ + details: { item: { reviewDecision: null } } + }) + expect( + parsed.success && + 'reviewDecision' in (parsed.data as { details: { item: object } }).details.item + ).toBe(true) + }) + + it('keeps absence absent rather than collapsing it to null', () => { + const parsed = detail({ labels: [] }) + expect( + parsed.success && + 'reviewDecision' in (parsed.data as { details: { item: object } }).details.item + ).toBe(false) + }) + + it('keeps a decision the host reports', () => { + const parsed = detail({ reviewDecision: 'APPROVED' }) + expect(parsed.success && parsed.data).toMatchObject({ + details: { item: { reviewDecision: 'APPROVED' } } + }) + }) +}) + +describe('the guarded reads require nothing but the container', () => { + it('accepts a label list with no ok and no labels, which main read as a refusal', () => { + expect(taskProjectLabelListSchema.safeParse({}).success).toBe(true) + expect(taskProjectLabelListSchema.safeParse(null).success).toBe(false) + }) + + it('drops a non-string label rather than failing the picker', () => { + const parsed = taskProjectLabelListSchema.safeParse({ ok: true, labels: ['bug', 7] }) + expect(parsed.success && parsed.data).toMatchObject({ labels: ['bug'] }) + }) + + it('drops an issue type with no id, which the write could not have sent', () => { + const parsed = taskProjectIssueTypeListSchema.safeParse({ + ok: true, + types: [{ id: 'type-1', name: 'Bug' }, { name: 'Task' }] + }) + expect(parsed.success && parsed.data).toMatchObject({ types: [{ id: 'type-1', name: 'Bug' }] }) + }) + + it('names the b2 seed null reply instead of reading .ok off it', () => { + expect(taskProjectMutationStatusSchema.safeParse(null).success).toBe(false) + expect(taskProjectMutationStatusSchema.safeParse({ ok: true }).success).toBe(true) + }) +}) + +describe('the comment replies', () => { + it('keeps the recorded numeric comment id', () => { + const parsed = taskProjectCommentWriteSchema.safeParse({ + ok: true, + comment: { id: 906, author: 'You', body: 'a project comment' } + }) + expect(parsed.success && parsed.data).toMatchObject({ comment: { id: 906 } }) + }) + + it('drops a comment with no id rather than appending an unkeyed row', () => { + const parsed = taskProjectCommentWriteSchema.safeParse({ ok: true, comment: { body: 'hi' } }) + expect(parsed.success && (parsed.data as { comment?: unknown }).comment).toBeUndefined() + }) + + it('keeps a bare-string error, which both mutation call sites branch on', () => { + const parsed = taskProjectCommentMutationSchema.safeParse({ ok: false, error: 'nope' }) + expect(parsed.success && parsed.data).toMatchObject({ ok: false, error: 'nope' }) + }) + + it('keeps an enveloped error too', () => { + const parsed = taskProjectCommentMutationSchema.safeParse({ + ok: false, + error: { message: 'nope' } + }) + expect(parsed.success && parsed.data).toMatchObject({ error: { message: 'nope' } }) + }) +}) diff --git a/mobile/src/tasks/task-project-board-reply-schema.ts b/mobile/src/tasks/task-project-board-reply-schema.ts new file mode 100644 index 00000000000..b585ff4c6fb --- /dev/null +++ b/mobile/src/tasks/task-project-board-reply-schema.ts @@ -0,0 +1,300 @@ +import { z } from 'zod' +import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' + +// The sixteen `github.project.*` replies the Projects board reads. Every one of them is an +// accepted result carrying its own `{ ok, error }` envelope, published by +// src/main/runtime/rpc/methods/github-project-methods.ts and typed in +// src/shared/github/project-result-types.ts. +// +// Two shapes appear here, and which one a reply gets is decided by its consumer, not by the host: +// +// - a discriminated union, where the consumer reads a member off BOTH arms without a guard +// (`result.error.message` on the refusal arm and the payload on the success arm). `ok` is +// load-bearing there, so an envelope without it is an incompatible reply rather than the +// property-read TypeError main threw; +// - a flat passthrough object, where the consumer guards every member it reads +// (`result.error?.message ?? '...'`, `result.labels ?? []`). Nothing is required there beyond +// the container, because requiring a member the consumer already defaults would refuse a reply +// main rendered. +// +// Required members are only ever ones a recorded golden shows the host sending AND a consumer +// reads unguarded. Nothing deeper is declared: the project table's rows, for instance, have no +// recorded row to check a deeper requirement against, so requiring one would be a claim about the +// wire that this corpus cannot support. + +/** `owner`/`ownerType`/`number` compose the persisted project key, so every row carries them. */ +const PROJECT_OWNER_TYPE = ['organization', 'user'] as const + +const projectMessage = (name: string) => salvagedOptional(name, z.string()) +const projectCount = (name: string) => salvagedOptional(name, z.number()) + +/** The refusal arm's message, read unguarded as `result.error.message`. */ +const requiredProjectError = z + .looseObject({ message: projectMessage('message') }) + .transform((error) => ({ + ...error, + message: error.message ?? '' + })) + +/** The refusal arm's message where the consumer already spells `?? 'Failed to …'`. */ +const optionalProjectError = salvagedOptional( + 'error', + z.looseObject({ message: projectMessage('message') }) +) + +/** `ok` where the consumer tests it rather than switching on it: absent still reads as refused. */ +const optionalOk = salvagedOptional('ok', z.boolean()) + +/** + * One accessible project. + * + * `owner` is required because githubProjectIdentityKey calls `.toLowerCase()` on it + * (src/shared/github/project-identity.ts:13) for every row the picker stores or compares, and + * `ownerType`/`number` are the rest of that key. A row without them is dropped rather than failing + * the list, which keeps the banner and the remaining projects. Everything else is optional: the + * recorded reply (`tk-project-board-load`, `github.project.listAccessible#1`) carries no `id`, + * `url` or `source`, so requiring what the shared type declares would refuse main's own fixture. + */ +const projectSummary = z.looseObject({ + owner: z.string(), + ownerType: z.enum(PROJECT_OWNER_TYPE), + number: z.number(), + title: projectMessage('title'), + host: projectMessage('host') +}) + +/** + * The accessible-project list. + * + * `projects` is required on the success arm: use-mobile-tasks-project-loading-actions.tsx:69 hands + * it straight to `setGithubProjects`, and `partialFailures` is not, because :70 spells `?? []`. + * `error.message` is required on the refusal arm because :67 throws it unguarded. + */ +export const taskProjectAccessibleListSchema = z.union([ + z.looseObject({ + ok: z.literal(true), + projects: salvagingArray(projectSummary), + partialFailures: salvagedOptional( + 'partialFailures', + salvagingArray( + z.looseObject({ owner: projectMessage('owner'), message: projectMessage('message') }) + ) + ) + }), + z.looseObject({ ok: z.literal(false), error: requiredProjectError }) +]) + +/** + * A project's views. + * + * `views` is required: :91 publishes it and :92 returns it, and :199/:204/:218/:228 run `find` and + * `filter` over the same array with no guard. A view needs the `id` the selection is committed + * under (:233 → githubProjectSettings.lastViewByProject), so a row without one drops. + * + * `layout` is a plain string, not an enum. Every reader is an equality test against + * `'TABLE_LAYOUT'` (:204/:212/:218/:228), so a layout arm this build has not heard of already + * reads as "not supported" — closing the set would instead drop the row and change the count the + * "no supported views" copy is decided by. That is remote-wire-compatibility.md rule 4. + */ +export const taskProjectViewListSchema = z.union([ + z.looseObject({ + ok: z.literal(true), + views: salvagingArray( + z.looseObject({ + id: z.string(), + number: projectCount('number'), + name: projectMessage('name'), + layout: projectMessage('layout') + }) + ) + }), + z.looseObject({ ok: z.literal(false), error: requiredProjectError }) +]) + +/** + * The board table. + * + * `data` and `data.selectedView` are required because :132 reads `data.selectedView.filter` and + * :134-:143 read four more members off it, all unguarded — a reply without the container was a + * property read on undefined. `project.id` is required for the same reason at + * use-mobile-tasks-project-metadata-actions.tsx:170, which sends it as `projectId` on every field + * mutation. + * + * Nothing inside `selectedView` or `rows` is required. The recorded table + * (`tk-project-board-load`, `github.project.viewTable#1`) carries a `project` with no `owner`, + * `ownerType` or `url` and an empty `rows`, so this corpus has no evidence for a deeper claim. + */ +export const taskProjectViewTableSchema = z.union([ + z.looseObject({ + ok: z.literal(true), + data: z.looseObject({ + project: z.looseObject({ id: z.string() }), + selectedView: z.looseObject({ + id: projectMessage('id'), + number: projectCount('number'), + name: projectMessage('name'), + layout: projectMessage('layout'), + filter: projectMessage('filter') + }), + rows: salvagedOptional('rows', salvagingArray(z.looseObject({ id: z.string() }))) + }) + }), + z.looseObject({ + ok: z.literal(false), + error: requiredProjectError, + totalCount: projectCount('totalCount') + }) +]) + +/** + * A pasted project URL or `owner/number`, resolved. + * + * `owner`, `ownerType` and `number` are required: :286-:291 forward all three into + * `selectGitHubProject`, which puts them in the key and in the next `github.project.listViews` + * params. `ownerType` is therefore a CLOSED enum with no fallback — it is echoed into a param, and + * remote-wire-compatibility.md rule 4 forbids a reply-schema fallback from shaping one. The arm + * set is genuinely closed host-side: project-view-listing.ts:23 answers `validation_error` for any + * other value, so degrading an unknown arm would only put a value the host refuses on the wire. + * + * `title` is optional because no mobile consumer reads it, and `viewNumber`/`host` because :290 + * and :292 both default them. + */ +export const taskProjectRefSchema = z.union([ + z.looseObject({ + ok: z.literal(true), + owner: z.string(), + ownerType: z.enum(PROJECT_OWNER_TYPE), + number: z.number(), + title: projectMessage('title'), + host: projectMessage('host'), + viewNumber: projectCount('viewNumber') + }), + z.looseObject({ ok: z.literal(false), error: requiredProjectError }) +]) + +/** + * A board row's detail pane. + * + * `details` is required: use-mobile-tasks-project-detail-loading.tsx:140-:151 reads eleven members + * off it, each defaulted but the container itself never guarded. `error.message` is required + * because :137 throws it. + * + * `item.reviewDecision` is nullable AND optional and stays that way. :144 forwards it verbatim + * into `projectRowDetail`, where `null` ("reviewed, no decision") and absent ("this host does not + * report one") are different states — collapsing either into the other with a `??` here would be + * the null-collapse the session domain shipped and had caught two review rounds later. + */ +export const taskProjectRowDetailSchema = z.union([ + z.looseObject({ + ok: z.literal(true), + details: z.looseObject({ + body: projectMessage('body'), + comments: salvagedOptional('comments', salvagingArray(z.unknown())), + item: salvagedOptional( + 'item', + z.looseObject({ + labels: salvagedOptional('labels', salvagingArray(z.string())), + reviewDecision: salvagedOptional('reviewDecision', z.string().nullable()), + reviewRequests: salvagedOptional('reviewRequests', salvagingArray(z.unknown())), + latestReviews: salvagedOptional('latestReviews', salvagingArray(z.unknown())) + }) + ), + assignees: salvagedOptional('assignees', salvagingArray(z.string())), + headSha: projectMessage('headSha'), + baseSha: projectMessage('baseSha'), + pullRequestId: projectMessage('pullRequestId'), + checks: salvagedOptional('checks', salvagingArray(z.unknown())), + files: salvagedOptional('files', salvagingArray(z.unknown())) + }) + }), + z.looseObject({ ok: z.literal(false), error: requiredProjectError }) +]) + +/** + * The repo label list. + * + * Flat, not a union: use-mobile-tasks-project-metadata-loading.tsx:58 spells + * `result.error?.message ?? 'Failed to load labels'` and :61 spells `result.labels ?? []`, so + * every member is already defaulted and an envelope with no `ok` still reads as refused. What the + * schema adds is the container and the element type — a `labels` that is not an array of strings + * reached the label picker as rendered garbage. + */ +export const taskProjectLabelListSchema = z.looseObject({ + ok: optionalOk, + labels: salvagedOptional('labels', salvagingArray(z.string())), + error: optionalProjectError +}) + +/** The assignable-user list, guarded the same way at :109-:112. Rows pass through because the + * picker renders a host record this module does not re-declare; `login` is what it keys on. */ +export const taskProjectAssignableUserListSchema = z.looseObject({ + ok: optionalOk, + users: salvagedOptional('users', salvagingArray(z.looseObject({ login: z.string() }))), + error: optionalProjectError +}) + +/** The repo issue types, guarded the same way at :165-:168. `id` is the mutation's own param + * (use-mobile-tasks-project-metadata-actions.tsx:245), so a row without one cannot be applied. */ +export const taskProjectIssueTypeListSchema = z.looseObject({ + ok: optionalOk, + types: salvagedOptional( + 'types', + salvagingArray(z.looseObject({ id: z.string(), name: projectMessage('name') })) + ), + error: optionalProjectError +}) + +/** + * The five board mutations whose reply is only a verdict: the issue and pull-request metadata + * writes, the issue-type write, and the field set/clear pair. + * + * Every consumer tests `result.ok === false` and then `result.error?.message ?? '…'` + * (use-mobile-tasks-project-metadata-actions.tsx:64/:174/:251 and + * use-mobile-tasks-project-workspace-comment-actions.tsx:141), so nothing but the container is + * required. The container is the change: `project.update-metadata`'s `b2` seed answers + * `result: null`, which main read as `null.ok` and #20563 left recorded as a TypeError. It is now + * named as an incompatible `github.project.updateIssueBySlug` reply instead. + */ +export const taskProjectMutationStatusSchema = z.looseObject({ + ok: optionalOk, + error: optionalProjectError +}) + +/** + * The added comment. + * + * `comment` is optional because :229 gates on it before appending. It carries `id` and `body` + * because the thread renderer keys and prints them, and because the recorded reply + * (`tk-project-row-comments-issue`) carries both — `id` as a NUMBER there, which is why the + * schema takes either rather than the string the mobile type leads with. + */ +export const taskProjectCommentWriteSchema = z.looseObject({ + ok: optionalOk, + comment: salvagedOptional( + 'comment', + z.looseObject({ + id: z.union([z.string(), z.number()]), + body: projectMessage('body'), + author: projectMessage('author'), + createdAt: projectMessage('createdAt') + }) + ), + error: optionalProjectError +}) + +/** + * The comment edit and delete verdicts. + * + * `error` is a string OR an envelope, because both consumers read it that way + * (use-mobile-tasks-project-workspace-comment-actions.tsx:276 and + * use-mobile-tasks-project-thread-reply-actions.tsx:64 both branch on `typeof result.error === + * 'string'`). Declaring only the envelope would salvage the string away and replace a host message + * the user has always seen with this app's fallback copy. + */ +export const taskProjectCommentMutationSchema = z.looseObject({ + ok: optionalOk, + error: salvagedOptional( + 'error', + z.union([z.string(), z.looseObject({ message: projectMessage('message') })]) + ) +}) diff --git a/mobile/src/tasks/task-runtime-reply-schema.test.ts b/mobile/src/tasks/task-runtime-reply-schema.test.ts new file mode 100644 index 00000000000..4313620ac32 --- /dev/null +++ b/mobile/src/tasks/task-runtime-reply-schema.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import { + taskLinearStatusSchema, + taskPreferenceWriteSchema, + taskPreflightSchema, + taskRuntimeStatusSchema, + taskUiStateSchema +} from './task-runtime-reply-schema' + +// Pins what the hydration reads require and what they deliberately do not. + +describe('the runtime status', () => { + it('reads the recorded capability lists', () => { + for (const capabilities of [['mobile.tasks.v1'], ['files.mutation-ownership.v1']]) { + const parsed = taskRuntimeStatusSchema.safeParse({ capabilities }) + expect(parsed.success && parsed.data).toMatchObject({ capabilities }) + } + }) + + it('requires the container main read `.capabilities` off', () => { + expect(taskRuntimeStatusSchema.safeParse(null).success).toBe(false) + expect(taskRuntimeStatusSchema.safeParse('ok').success).toBe(false) + expect(taskRuntimeStatusSchema.safeParse({}).success).toBe(true) + }) + + it('drops a non-string capability, which `includes` could never have matched', () => { + const parsed = taskRuntimeStatusSchema.safeParse({ capabilities: ['push.v1', 7] }) + expect(parsed.success && parsed.data).toMatchObject({ capabilities: ['push.v1'] }) + }) + + it('leaves worktreeCreateIdempotency untouched in all four states the probe triages', () => { + for (const advertised of [undefined, null, 'nonsense', { dedupeTtlMs: 45_000 }]) { + const parsed = taskRuntimeStatusSchema.safeParse({ worktreeCreateIdempotency: advertised }) + expect(parsed.success).toBe(true) + expect( + parsed.success && + (parsed.data as { worktreeCreateIdempotency?: unknown }).worktreeCreateIdempotency + ).toEqual(advertised) + } + }) +}) + +describe('the persisted UI state', () => { + it('answers the `ui` member, which is what the member reader it replaces did', () => { + const parsed = taskUiStateSchema.safeParse({ + ui: { sortBy: 'name', trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 1 } } } } + }) + expect(parsed.success && parsed.data).toMatchObject({ + sortBy: 'name', + trustedOrcaHooks: { 'repo-1': { all: { approvedAt: 1 } } } + }) + }) + + it('reads the recorded empty ui as an empty record, not as absent', () => { + const parsed = taskUiStateSchema.safeParse({ ui: {} }) + expect(parsed.success && parsed.data).toEqual({}) + }) + + it('answers undefined for a payload with no ui, where main read undefined off the object', () => { + const parsed = taskUiStateSchema.safeParse({}) + expect(parsed.success && parsed.data).toBeUndefined() + }) + + it('names a null payload, where main threw a property read on it', () => { + expect(taskUiStateSchema.safeParse(null).success).toBe(false) + }) +}) + +describe('the two advisory probes', () => { + it('reads the recorded preflight and linear replies', () => { + expect(taskPreflightSchema.safeParse({ glab: { installed: false } }).success).toBe(true) + expect(taskLinearStatusSchema.safeParse({ connected: false }).success).toBe(true) + }) + + it('requires nothing, because every consumer compares the leaf to `true`', () => { + expect(taskPreflightSchema.safeParse({}).success).toBe(true) + expect(taskLinearStatusSchema.safeParse({}).success).toBe(true) + }) + + it('drops a malformed glab rather than reading it as installed', () => { + const parsed = taskPreflightSchema.safeParse({ glab: 'yes' }) + expect(parsed.success && (parsed.data as { glab?: unknown }).glab).toBeUndefined() + }) + + it('passes a host member no mobile consumer reads straight through', () => { + const parsed = taskPreflightSchema.safeParse({ gh: { installed: true, authenticated: true } }) + expect(parsed.success && parsed.data).toMatchObject({ + gh: { installed: true, authenticated: true } + }) + }) +}) + +describe('the three preference writes', () => { + it('accept every payload, because no call site reads their body', () => { + for (const reply of [null, undefined, 0, 'written', { ok: true }, []]) { + expect(taskPreferenceWriteSchema.safeParse(reply).success).toBe(true) + } + }) +}) diff --git a/mobile/src/tasks/task-runtime-reply-schema.ts b/mobile/src/tasks/task-runtime-reply-schema.ts new file mode 100644 index 00000000000..45d6903eda1 --- /dev/null +++ b/mobile/src/tasks/task-runtime-reply-schema.ts @@ -0,0 +1,98 @@ +import { z } from 'zod' +import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' + +// What the Tasks screen reads once per host to hydrate, and the preferences it writes back. +// Checked against src/main/runtime/rpc/methods/status.ts:6-16 (RuntimeStatus, declared in +// src/shared/runtime-session-contracts.ts:64), client-ui.ts:22-74 (the `{ settings }` / `{ ui }` +// envelopes), preflight.ts:17 (PreflightStatus) and linear.ts:35-42 (the connection status). + +/** + * The runtime status, read for a capability list and nothing else. + * + * `capabilities` is the only member any consumer here reaches for, and every one of them guards it + * — `status.capabilities?.includes(…)` in use-mobile-tasks-runtime-hydration.tsx:208 and in + * src/shared/file-mutation-ownership.ts:10, `result.capabilities ?? []` in + * worktree-create-capability.ts:55. So the requirement is the container: main read `.capabilities` + * off whatever the payload was, and a string or a number reply was a property read that answered + * `undefined` and silently downgraded the host to "Tasks unsupported". + * + * Elements are strings and a non-string element drops. `includes` over a mixed array already never + * matched a capability id, so the drop changes no verdict; it is what makes the drop visible in + * the salvage report instead of invisible in an `includes` that quietly answers false. + * + * `worktreeCreateIdempotency` is `unknown` on purpose. worktree-create-capability.ts:59-72 does its + * own `typeof`/`Array.isArray` triage over it and treats absent, null, non-object and object as + * four different answers; a schema that narrowed it would have to pick one of those apart here and + * change which branch a host lands in. + */ +export const taskRuntimeStatusSchema = z.looseObject({ + capabilities: salvagedOptional('capabilities', salvagingArray(z.string())), + worktreeCreateIdempotency: z.unknown().optional(), + hostPlatform: salvagedOptional('hostPlatform', z.string()) +}) + +/** + * Persisted client UI state, answered under a `ui` member. + * + * The reader yields `ui` itself, which is what the member reader it replaces did. Main's + * `rpcPayloadMember` threw on a null or absent payload and read `undefined` off anything else, so + * the container is the requirement and every member under it stays optional: :277 spells + * `uiState?.trustedOrcaHooks ?? {}` and :278 `uiState?.taskResumeState ?? {}`. + * + * Both members are `unknown`, and the call site keeps one narrowing cast over them. They are + * opaque forwards: `trustedOrcaHooks` goes straight into state, and `taskResumeState` is the + * screen's whole persisted view state, re-read field by field with its own defaults at :279 and + * across use-mobile-tasks-client-settings-actions.tsx. Declaring either here would restate a + * twelve-member union that nothing in this reader reads. + */ +export const taskUiStateSchema = z + .looseObject({ + ui: salvagedOptional( + 'ui', + z.looseObject({ + taskResumeState: z.unknown().optional(), + trustedOrcaHooks: z.unknown().optional() + }) + ) + }) + .transform((reply) => reply.ui) + +/** + * The provider preflight, read only for whether `glab` is installed. + * + * Three consumers and all three guard to the leaf: `preflight?.glab?.installed === true` + * (use-mobile-tasks-runtime-hydration.tsx:305, mobile-home-host-requests.ts:107) and the + * `readProbeMember` pair in use-new-workspace-runtime-context.ts:92. `git` and `gh` are declared + * non-optional by PreflightStatus but no mobile consumer reads them, so they pass through. + */ +export const taskPreflightSchema = z.looseObject({ + glab: salvagedOptional( + 'glab', + z.looseObject({ installed: salvagedOptional('installed', z.boolean()) }) + ) +}) + +/** + * Whether Linear is connected. + * + * `connected` is compared to `true` at use-mobile-tasks-runtime-hydration.tsx:304, + * mobile-home-host-requests.ts:108 and use-new-workspace-runtime-context.ts:96, so absence and + * `false` already mean the same thing and nothing is required. `workspaces` is not declared: the + * picker reads it through a different operation on this same method, and listing a member ahead of + * a reader is how a schema starts refusing replies no consumer here would have noticed. + */ +export const taskLinearStatusSchema = z.looseObject({ + connected: salvagedOptional('connected', z.boolean()) +}) + +/** + * The three writes whose reply body no call site reads. + * + * `ui.set` is interpreted for its verdict alone and the value discarded + * (use-mobile-tasks-client-settings-actions.tsx:201, setup-hook-trust.ts:49); `settings.update` is + * fire-and-forget at five sites and never interpreted; `linear.selectWorkspace` chains the context + * reload off the send without interpreting it. Declaring a member on any of them would be a + * requirement with no reader, and would make a refusal visible for the first time at a site whose + * whole documented behaviour is that it is not. + */ +export const taskPreferenceWriteSchema = z.unknown() diff --git a/mobile/src/tasks/task-source-search-reply-schema.test.ts b/mobile/src/tasks/task-source-search-reply-schema.test.ts new file mode 100644 index 00000000000..d8a89045537 --- /dev/null +++ b/mobile/src/tasks/task-source-search-reply-schema.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import { + taskGitHubWorkItemListSchema, + taskGitLabWorkItemListSchema, + taskLinearIssueListSchema, + taskWorkItemLookupSchema +} from './task-source-search-reply-schema' + +// Pins the one requirement each provider read carries, and the recorded rows that decide how thin +// the row schemas are allowed to be. + +describe('the provider lists require items and nothing else', () => { + it('reads the recorded GitHub search row, which carries only number and title', () => { + const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [{ number: 1, title: 'one' }] }) + expect(parsed.success && parsed.data).toMatchObject({ items: [{ number: 1, title: 'one' }] }) + }) + + it('reads the recorded GitLab row, whose number lives under iid', () => { + const parsed = taskGitLabWorkItemListSchema.safeParse({ items: [{ iid: 2, title: 'two' }] }) + expect(parsed.success && parsed.data).toMatchObject({ items: [{ iid: 2, title: 'two' }] }) + }) + + it('names a list with no items, which the task screen mapped unguarded', () => { + expect(taskGitHubWorkItemListSchema.safeParse({}).success).toBe(false) + expect(taskGitLabWorkItemListSchema.safeParse({ error: { type: 'quota' } }).success).toBe(false) + }) + + it('keeps items alongside an in-band provider error, as the recorded reply does', () => { + const parsed = taskGitLabWorkItemListSchema.safeParse({ + items: [{ iid: 2 }], + error: { type: 'not_found', message: 'missing' } + }) + expect(parsed.success && parsed.data).toMatchObject({ + items: [{ iid: 2 }], + error: { type: 'not_found', message: 'missing' } + }) + }) + + it('passes the source banner members through without typing them', () => { + const parsed = taskGitHubWorkItemListSchema.safeParse({ + items: [], + sources: { issues: 'upstream' }, + issueSourceFellBack: true + }) + expect(parsed.success && parsed.data).toMatchObject({ + sources: { issues: 'upstream' }, + issueSourceFellBack: true + }) + }) + + it('drops a row that is not an object rather than failing the page', () => { + const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [{ number: 1 }, 'nope'] }) + expect(parsed.success && parsed.data).toMatchObject({ items: [{ number: 1 }] }) + }) +}) + +describe('a work-item row preserves author as a tri-state', () => { + it('keeps an explicit null, which the row renders as "no author"', () => { + const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [{ author: null }] }) + expect(parsed.success && parsed.data).toMatchObject({ items: [{ author: null }] }) + }) + + it('keeps absence absent rather than collapsing it to null', () => { + const parsed = taskGitHubWorkItemListSchema.safeParse({ items: [{ number: 1 }] }) + const rows = parsed.success ? (parsed.data.items as object[]) : [] + expect('author' in rows[0]!).toBe(false) + }) +}) + +describe('the Linear issue list takes both shapes the picker has always accepted', () => { + it('reads a bare array, which linear.searchIssues answers', () => { + const parsed = taskLinearIssueListSchema.safeParse([{ id: 'issue-3' }]) + expect(parsed.success && parsed.data).toEqual([{ id: 'issue-3' }]) + }) + + it('reads an items envelope, which linear.listIssues answers', () => { + const parsed = taskLinearIssueListSchema.safeParse({ items: [{ id: 'issue-1' }] }) + expect(parsed.success && parsed.data).toEqual([{ id: 'issue-1' }]) + }) + + it('reads the full recorded issue whole', () => { + const issue = { + id: 'issue-1', + identifier: 'ENG-1', + title: 'A Linear issue', + url: '', + description: '', + state: { name: 'Todo', type: 'unstarted', color: '#000' }, + team: { id: 'team-1', key: 'ENG', name: 'Engineering' }, + labels: [], + priority: 0, + updatedAt: '2020-01-01T00:00:00.000Z', + workspaceId: 'linear-workspace' + } + expect(taskLinearIssueListSchema.safeParse({ items: [issue] })).toMatchObject({ + success: true, + data: [issue] + }) + }) + + it('drops an issue with no id, which nothing could key a row on', () => { + const parsed = taskLinearIssueListSchema.safeParse([{ identifier: 'ENG-9' }]) + expect(parsed.success && parsed.data).toEqual([]) + }) + + it('names a payload that is neither shape, where main threw its own copy', () => { + expect(taskLinearIssueListSchema.safeParse({ issues: [] }).success).toBe(false) + expect(taskLinearIssueListSchema.safeParse('nope').success).toBe(false) + expect(taskLinearIssueListSchema.safeParse(null).success).toBe(false) + }) +}) + +describe('a single work-item lookup', () => { + it('keeps the host null for an item that does not resolve', () => { + expect(taskWorkItemLookupSchema.safeParse(null)).toMatchObject({ success: true, data: null }) + }) + + it('reads the recorded lookups, which carry no identity members', () => { + expect(taskWorkItemLookupSchema.safeParse({ number: 12, title: 'twelve' }).success).toBe(true) + expect(taskWorkItemLookupSchema.safeParse({ iid: 7, title: 'seven' }).success).toBe(true) + }) + + it('names a payload that is not an item at all', () => { + expect(taskWorkItemLookupSchema.safeParse('twelve').success).toBe(false) + }) +}) diff --git a/mobile/src/tasks/task-source-search-reply-schema.ts b/mobile/src/tasks/task-source-search-reply-schema.ts new file mode 100644 index 00000000000..b4dae8da3f0 --- /dev/null +++ b/mobile/src/tasks/task-source-search-reply-schema.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' + +// The Smart workspace-source picker's provider reads: per-repo search, and the single-item lookups +// a pasted link or number resolves to. Checked against +// src/main/runtime/rpc/methods/github-repo-work-item-methods.ts:30-81 (ListWorkItemsResult, whose +// declared invariant is that `items` always carries whatever succeeded), gitlab.ts:40-49 and +// gitlab.ts:180-190, and linear.ts:50-60. +// +// Work-item rows are deliberately thin. `tw-smart-search-all-providers` records +// `github.listWorkItems` answering `{ items: [{ number: 1, title: 'one' }] }` and +// `gitlab.workItemByPath` answering `{ iid: 7, title: 'seven' }`, so the identity members the +// shared types declare non-optional are not on the wire in this corpus. Requiring one would drop +// the row out of a partition main renders — the schema types what is there and requires nothing +// the recorded success does not carry. + +const itemText = (name: string) => salvagedOptional(name, z.string()) +const itemCount = (name: string) => salvagedOptional(name, z.number()) + +/** A provider work-item row, typed but not required. `repoId` is stamped by the caller, never + * read off the reply. */ +const workItemRow = z.looseObject({ + id: itemText('id'), + type: itemText('type'), + number: itemCount('number'), + title: itemText('title'), + state: itemText('state'), + url: itemText('url'), + updatedAt: itemText('updatedAt'), + labels: salvagedOptional('labels', salvagingArray(z.string())), + // Tri-state and preserved: GitHubWorkItem/GitLabWorkItem declare `author: string | null`, and + // the row renderer shows an explicit null differently from a host that never reported one. + author: salvagedOptional('author', z.string().nullable()) +}) + +/** + * The GitHub work-item list. + * + * `items` is required. use-mobile-tasks-provider-load-actions.tsx:138 maps it with no guard, and + * the host's own envelope declares the invariant ("`items` always contains whatever succeeded", + * src/shared/github/work-item-types.ts:96) — so an absent `items` is a reply this app cannot read, + * not an empty page. The smart picker's own `?? []` at smart-source-search-requests.ts:43 stays + * where it is; it now only covers the empty array. + * + * `sources`, `errors` and `issueSourceFellBack` pass through untyped beyond their container: the + * banner extractors read them member by member with their own guards, and `sources.issues` is a + * bare string in the recorded `tk-provider-load` reply where the shared type declares an + * owner/repo object. + */ +export const taskGitHubWorkItemListSchema = z.looseObject({ + items: salvagingArray(workItemRow), + sources: z.unknown().optional(), + errors: z.unknown().optional(), + issueSourceFellBack: z.unknown().optional() +}) + +/** + * The GitLab work-item list. + * + * `items` is required for the same reason: use-mobile-tasks-task-list-loading.tsx:182 maps it + * unguarded. `error` is the provider's in-band failure and stays optional — both consumers test + * `envelope.error?.type` before reading `.message`, and `tw-smart-search-all-providers` records a + * reply carrying items AND a `not_found` error at once, which the list renders rather than raises. + */ +export const taskGitLabWorkItemListSchema = z.looseObject({ + items: salvagingArray(workItemRow), + error: salvagedOptional( + 'error', + z.looseObject({ type: itemText('type'), message: itemText('message') }) + ) +}) + +/** + * A Linear issue list, in either shape the picker has always accepted. + * + * The host answers a bare array from `linear.searchIssues` and an `{ items }` envelope from + * `linear.listIssues`, and `tasks.smart-source-search` records both. The union replaces the hand + * reader in linear-mobile-issue-read.ts, whose `throw new Error('Unexpected Linear tasks + * response')` reached the screen as its own copy; the same payloads are now named as an + * incompatible `linear.searchIssues` / `linear.listIssues` reply. + * + * `id` is the only requirement, because it is the row key (mobile-tasks-item-mapping.ts:293) and + * because it is the ONLY member the recorded smart-search success carries: that fixture's issues + * are `{ id: 'issue-1' }`. `state` and `team` are read unguarded downstream + * (mobile-tasks-item-mapping.ts:296-:297) but are left optional for exactly that reason — a + * requirement here would drop every row out of a partition main renders. + */ +const linearIssueRow = z.looseObject({ + id: z.string(), + identifier: itemText('identifier'), + title: itemText('title'), + url: itemText('url'), + updatedAt: itemText('updatedAt'), + priority: itemCount('priority'), + state: salvagedOptional( + 'state', + z.looseObject({ name: itemText('name'), color: itemText('color'), type: itemText('type') }) + ), + team: salvagedOptional( + 'team', + z.looseObject({ id: itemText('id'), name: itemText('name'), key: itemText('key') }) + ) +}) + +export const taskLinearIssueListSchema = z.union([ + salvagingArray(linearIssueRow), + z.looseObject({ items: salvagingArray(linearIssueRow) }).transform((reply) => reply.items) +]) + +/** + * A single work item, or `null` when the provider has none. + * + * Null is preserved rather than refused: both `github.workItem` and `gitlab.workItemByPath` answer + * it for a number that does not resolve, and every consumer already spells `item ? … : null` + * (smart-source-paste-intent.ts:152/:172/:189). Nothing inside is required, because the recorded + * `tw-paste-lookup-resolved` items are `{ number: 12, title: 'twelve' }` and `{ iid: 7, title: + * 'seven' }` — the GitLab one does not even carry `number`. + */ +export const taskWorkItemLookupSchema = workItemRow.extend({ iid: itemCount('iid') }).nullable() diff --git a/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx b/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx index 21ccad359ef..81ecf464dea 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-detail-loading.tsx @@ -102,53 +102,34 @@ export function useMobileTasksProjectDetailLoading(model: ItemDetailLoadingModel if (stale) { return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectRowDetailRead.interpret(response) as - | { - ok: true - details: { - body?: string - comments?: DetailComment[] - item?: { - labels?: string[] - reviewDecision?: string | null - reviewRequests?: GitHubAssignableUser[] - latestReviews?: GitHubPRReviewSummary[] - } - assignees?: string[] - headSha?: string - baseSha?: string - pullRequestId?: string - checks?: GitHubDetailCheck[] - files?: Array<{ - path: string - oldPath?: string - status?: GitHubDetailFile['status'] - additions?: number - deletions?: number - isBinary?: boolean - viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' - }> - } - } - | { ok: false; error: { message: string } } + const result = githubProjectRowDetailRead.interpret(response) if (!result.ok) { throw new Error(result.error.message) } + // Why the casts and not a narrower schema: comments, reviews, checks and files are host + // records this pane renders whole, and the recorded detail carries every one of them empty + // — so a member requirement here has nothing behind it and would drop a row main showed. + // `reviewDecision` is forwarded with no coalesce: explicit null and absent are different + // answers to "has this been reviewed", and collapsing either is a product change. setProjectRowDetail({ provider: 'github', body: result.details.body ?? '', - comments: result.details.comments ?? [], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above; the thread renderer owns the comment shape. + comments: (result.details.comments ?? []) as DetailComment[], labels: result.details.item?.labels ?? projectRowItem.content.labels.map((l) => l.name), assignees: result.details.assignees ?? [], reviewDecision: result.details.item?.reviewDecision, - reviewRequests: result.details.item?.reviewRequests ?? [], - latestReviews: result.details.item?.latestReviews ?? [], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above; the reviewer strip owns these two shapes. + reviewRequests: (result.details.item?.reviewRequests ?? []) as GitHubAssignableUser[], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above. + latestReviews: (result.details.item?.latestReviews ?? []) as GitHubPRReviewSummary[], headSha: result.details.headSha, baseSha: result.details.baseSha, pullRequestId: result.details.pullRequestId, - checks: result.details.checks ?? [], - files: result.details.files ?? [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above; the checks panel owns this shape. + checks: (result.details.checks ?? []) as GitHubDetailCheck[], + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above; the files list owns this shape. + files: (result.details.files ?? []) as GitHubDetailFile[] }) }) .catch((err) => { diff --git a/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx index 5af884d7c99..44ac9a56cae 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-loading-actions.tsx @@ -1,6 +1,5 @@ import type { TaskPaginationActionsModel } from './use-mobile-tasks-task-pagination-actions' import { - type GitHubProjectOwnerType, type GitHubProjectPartialFailure, type GitHubProjectRef, type GitHubProjectSettings, @@ -55,19 +54,14 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions setGithubProjectError('') setGithubProjectPartialFailures([]) const reply = await githubProjectListRead.request(client, { host: 'github.com' }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectListRead.interpret(reply) as - | { - ok: true - projects: GitHubProjectSummary[] - partialFailures?: GitHubProjectPartialFailure[] - } - | { ok: false; error: { message: string } } + const result = githubProjectListRead.interpret(reply) if (!result.ok) { throw new Error(result.error.message) } - setGithubProjects(result.projects) - setGithubProjectPartialFailures(result.partialFailures ?? []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires the owner/ownerType/number a project is keyed by and types the rest; `id`, `url` and `source` are declared non-optional by GitHubProjectSummary but absent from the reply main records, so defaulting them here would put bytes in the picker's state the host never sent. + setGithubProjects(result.projects as GitHubProjectSummary[]) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: same shape rule for the per-org banner rows. + setGithubProjectPartialFailures((result.partialFailures ?? []) as GitHubProjectPartialFailure[]) }, [client, connState, tasksSupported]) const loadGitHubProjectViews = useCallback( @@ -81,15 +75,14 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions ownerType: project.ownerType, projectNumber: project.number }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectViewListRead.interpret(reply) as - | { ok: true; views: GitHubProjectViewSummary[] } - | { ok: false; error: { message: string } } + const result = githubProjectViewListRead.interpret(reply) if (!result.ok) { throw new Error(result.error.message) } - setGithubProjectViews(result.views) - return result.views + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires the `id` a view is selected by and types `number`/`name`/`layout` without requiring them, because a layout arm this build has not heard of must reach the "no supported views" test rather than drop the row. + const views = result.views as GitHubProjectViewSummary[] + setGithubProjectViews(views) + return views }, [client, connState, taskStateHydrated, tasksSupported] ) @@ -121,25 +114,24 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions }, { timeoutMs: 60_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectViewTableRead.interpret(reply) as - | { ok: true; data: GitHubProjectTable } - | { ok: false; error: { message: string }; totalCount?: number } + const result = githubProjectViewTableRead.interpret(reply) if (!result.ok) { throw new Error(result.error.message) } - setGithubProjectTable(result.data) - setGithubProjectSearch(options.queryOverride ?? result.data.selectedView.filter ?? '') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires `project.id` and the `selectedView` container the five reads below go through; nothing deeper, because the recorded table carries an empty `rows` and a `project` with no owner, so this corpus has no evidence for a deeper requirement. + const data = result.data as GitHubProjectTable + setGithubProjectTable(data) + setGithubProjectSearch(options.queryOverride ?? data.selectedView.filter ?? '') setGithubProjectViews((current) => - current.some((view) => view.id === result.data.selectedView.id) + current.some((view) => view.id === data.selectedView.id) ? current : [ ...current, { - id: result.data.selectedView.id, - number: result.data.selectedView.number, - name: result.data.selectedView.name, - layout: result.data.selectedView.layout + id: data.selectedView.id, + number: data.selectedView.number, + name: data.selectedView.name, + layout: data.selectedView.layout } ] ) @@ -264,18 +256,7 @@ export function useMobileTasksProjectLoadingActions(model: TaskPaginationActions input, host: githubProjectHost(parsed.host) }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectRefResolve.interpret(reply) as - | { - ok: true - owner: string - ownerType: GitHubProjectOwnerType - number: number - title: string - host?: string - viewNumber?: number - } - | { ok: false; error: { message: string } } + const result = githubProjectRefResolve.interpret(reply) if (!result.ok) { setGithubProjectPasteError(result.error.message) return diff --git a/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx index 2d628130314..2a89b30b356 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-metadata-actions.tsx @@ -59,11 +59,7 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectIssueUpdate.interpret(reply) as { - ok?: boolean - error?: { message?: string } - } + const result = githubProjectIssueUpdate.interpret(reply) if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update GitHub item') } @@ -182,8 +178,7 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc { timeoutMs: 30_000 } ) ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = written as { ok?: boolean; error?: { message?: string } } + const result = written if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update project field') } @@ -246,11 +241,7 @@ export function useMobileTasksProjectMetadataActions(model: ProjectThreadReplyAc }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectIssueTypeUpdate.interpret(reply) as { - ok?: boolean - error?: { message?: string } - } + const result = githubProjectIssueTypeUpdate.interpret(reply) if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update issue type') } diff --git a/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx b/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx index df01eba05a2..4814c595cb2 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-metadata-loading.tsx @@ -52,10 +52,7 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading if (stale) { return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectLabelListRead.interpret(response) as - | { ok: true; labels?: string[] } - | { ok: false; error?: { message?: string } } + const result = githubProjectLabelListRead.interpret(response) if (!result.ok) { throw new Error(result.error?.message ?? 'Failed to load labels') } @@ -105,14 +102,12 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading if (stale) { return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectAssignableUserListRead.interpret(response) as - | { ok: true; users?: GitHubAssignableUser[] } - | { ok: false; error?: { message?: string } } + const result = githubProjectAssignableUserListRead.interpret(response) if (!result.ok) { throw new Error(result.error?.message ?? 'Failed to load assignees') } - setProjectAssignableUsers(result.users ?? []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires the `login` the picker keys on and passes the rest of each row through, because the assignee sheet renders a host record this reader does not re-declare. + setProjectAssignableUsers((result.users ?? []) as GitHubAssignableUser[]) }) .catch((err) => { if (!stale) { @@ -161,14 +156,12 @@ export function useMobileTasksProjectMetadataLoading(model: ProjectDetailLoading if (stale) { return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectIssueTypeListRead.interpret(response) as - | { ok: true; types?: GitHubIssueType[] } - | { ok: false; error?: { message?: string } } + const result = githubProjectIssueTypeListRead.interpret(response) if (!result.ok) { throw new Error(result.error?.message ?? 'Failed to load issue types') } - setProjectIssueTypes(result.types ?? []) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires the `id` the issue-type write sends and types `name`; `color` and `description` are declared non-nullable by GitHubIssueType but absent from the recorded reply, so requiring them would drop a row main renders. + setProjectIssueTypes((result.types ?? []) as GitHubIssueType[]) }) .catch((err) => { if (!stale) { diff --git a/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx b/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx index f9495ad908f..db74b8eaf0b 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-repository-resolution.tsx @@ -1,10 +1,5 @@ import type { ProjectProjectionModel } from './use-mobile-tasks-project-projection' -import { - type GitHubOwnerRepo, - githubProjectKey, - useEffect, - useMemo -} from './mobile-tasks-dependencies' +import { githubProjectKey, useEffect, useMemo } from './mobile-tasks-dependencies' import { GITHUB_REPO_CONCURRENCY, getGitHubReviewerSeedUsers, @@ -64,8 +59,7 @@ export function useMobileTasksProjectRepositoryResolution(model: ProjectProjecti { repo: `id:${repo.id}` }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectRepoSlugRead.interpret(reply) as GitHubOwnerRepo | null + const result = githubProjectRepoSlugRead.interpret(reply) return { repoId: repo.id, entry: { path: repo.path, repository: result } } } catch { // Cached so readiness settles; `failed` marks it for retry on refresh. diff --git a/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx index 584cea4d128..f4e3c42e29c 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-thread-reply-actions.tsx @@ -56,11 +56,7 @@ export function useMobileTasksProjectThreadReplyActions( }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectCommentDelete.interpret(reply) as { - ok?: boolean - error?: string | { message?: string } - } + const result = githubProjectCommentDelete.interpret(reply) if (result.ok === false) { throw new Error( typeof result.error === 'string' diff --git a/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx b/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx index a71bc23980d..c33d41b33bd 100644 --- a/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-project-workspace-comment-actions.tsx @@ -138,8 +138,7 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre { timeoutMs: 30_000 } ) ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = updated as { ok?: boolean; error?: { message?: string } } + const result = updated if (result.ok === false) { throw new Error(result.error?.message ?? 'Failed to update GitHub item') } @@ -218,10 +217,7 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectCommentWrite.interpret(reply) as - | { ok: true; comment?: DetailComment } - | { ok: false; error?: { message?: string } } + const result = githubProjectCommentWrite.interpret(reply) if (!result.ok) { throw new Error(result.error?.message ?? 'Failed to add comment') } @@ -229,7 +225,8 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre if (result.comment) { setProjectRowDetail((current) => current?.provider === 'github' - ? { ...current, comments: [...current.comments, result.comment as DetailComment] } + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires the comment's `id` and types its body/author/createdAt; the thread renderer owns the rest of the record, and the recorded reply carries `id` as a NUMBER, which DetailComment permits and a narrower requirement would refuse. + { ...current, comments: [...current.comments, result.comment as DetailComment] } : current ) } @@ -268,11 +265,7 @@ export function useMobileTasksProjectWorkspaceCommentActions(model: WorkspaceCre }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = githubProjectCommentUpdate.interpret(reply) as { - ok?: boolean - error?: string | { message?: string } - } + const result = githubProjectCommentUpdate.interpret(reply) if (result.ok === false) { throw new Error( typeof result.error === 'string' diff --git a/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx b/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx index 30e905f9b73..ad7d4c8b164 100644 --- a/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-provider-load-actions.tsx @@ -127,7 +127,7 @@ export function useMobileTasksProviderLoadActions(model: RuntimeHydrationModel) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `before` is the undeclared key described above; every other field matches the schema. pageParams as RpcSendParams<'github.listWorkItems'> ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires `items` and types each row without requiring a member, because the recorded smart-search success carries rows of `{ number, title }`; `sources`/`errors` stay opaque because the two banner extractors below read them member by member with their own guards. const envelope = githubWorkItemSearchRead.interpret(reply) as { items: Array> sources?: GitHubRepoSources diff --git a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx index 2372ea272c9..365b864970f 100644 --- a/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx +++ b/mobile/src/tasks/use-mobile-tasks-runtime-hydration.tsx @@ -18,10 +18,8 @@ import { } from './mobile-tasks-dependencies' import { EMPTY_GITHUB_PROJECT_SETTINGS, - type LinearStatusResponse, type RuntimeTaskSettings, type TaskResumeState, - type TaskRuntimeStatus, getTaskPresetQuery, githubKindFromQuery, isTaskProvider, @@ -203,8 +201,7 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel } // The guard stays between the request and the interpretation: a screen that has moved on // must not raise a refusal it no longer owns. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const status = taskRuntimeStatusRead.interpret(statusReply) as TaskRuntimeStatus + const status = taskRuntimeStatusRead.interpret(statusReply) if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) { // Why: Tasks is additive RPC surface, so old desktop builds can still // pair but must not receive the newer task-specific method calls. @@ -275,7 +272,7 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel setRuntimeTaskSettings(settings) const uiRead = taskUiStateRead.interpret(uiReply) const uiState = uiRead.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema checks the `{ ui }` container and leaves both members `unknown`, because each is forwarded whole and re-read field by field with its own defaults downstream. (uiRead.value as | { taskResumeState?: TaskResumeState @@ -289,15 +286,9 @@ export function useMobileTasksRuntimeHydration(model: ClientSettingsActionsModel setGithubProjectHiddenFieldIdsByView(resume.githubProjectHiddenFieldIdsByView ?? {}) const preflightRead = taskPreflightRead.interpret(preflightReply) - const preflight = preflightRead.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (preflightRead.value as { glab?: { installed?: boolean } }) - : null + const preflight = preflightRead.accepted ? preflightRead.value : null const linearRead = taskLinearStatusRead.interpret(linearStatusReply) - const linearStatus = linearRead.accepted - ? // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (linearRead.value as LinearStatusResponse) - : null + const linearStatus = linearRead.accepted ? linearRead.value : null const preferredProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) const linearIsConnected = linearStatus?.connected === true const availableProviders = filterAvailableTaskProviders(preferredProviders, { diff --git a/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx b/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx index 12fca82b038..26be0fcbdfc 100644 --- a/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx +++ b/mobile/src/tasks/use-mobile-tasks-task-list-loading.tsx @@ -172,7 +172,7 @@ export function useMobileTasksTaskListLoading(model: ProviderLoadActionsModel) { perPage: GITLAB_PER_PAGE, query: appliedQuery.trim() || undefined }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires `items` and types each row without requiring a member, because the recorded GitLab row is `{ iid, title }`; the row builder's own reads are unchanged. const envelope = gitlabWorkItemSearchRead.interpret(reply) as { items: Array> error?: { type?: string; message: string } @@ -228,7 +228,7 @@ export function useMobileTasksTaskListLoading(model: ProviderLoadActionsModel) { workspaceId: selectedLinearWorkspaceId ?? undefined }) ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires each row's `id` and nothing else, because the recorded smart-search success carries rows of `{ id }` alone; the team filter and sort below keep their own reads. const issues = found as LinearIssue[] const filtered = selectedLinearTeamIds.size > 0 diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx index 0cfaa2e3d42..0f0a9dd1993 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-create-actions.tsx @@ -181,14 +181,12 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = worktreePrBaseResolve.interpret(reply) as - | { baseBranch: string; pushTarget?: GitPushTarget } - | { error: string } + const result = worktreePrBaseResolve.interpret(reply) if ('error' in result) { throw new Error(result.error) } - prStartPoint = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolved arm requires `baseBranch` and passes the rest of the start point through, because the create params spread the record and the host reads what it recognises. + prStartPoint = result as { baseBranch: string; pushTarget?: GitPushTarget } } params = buildTaskWorkspaceCreateParams({ item, @@ -224,14 +222,12 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod }, { timeoutMs: 30_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = worktreeMrBaseResolve.interpret(reply) as - | { baseBranch: string; pushTarget?: GitPushTarget } - | { error: string } + const result = worktreeMrBaseResolve.interpret(reply) if ('error' in result) { throw new Error(result.error) } - mrStartPoint = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: as the PR arm above. + mrStartPoint = result as { baseBranch: string; pushTarget?: GitPushTarget } } params = buildTaskWorkspaceCreateParams({ item, @@ -263,11 +259,7 @@ export function useMobileTasksWorkspaceCreateActions(model: WorkspaceSshStateMod const createReply = await worktreeCreateRun.request(client, params, { timeoutMs: WORKTREE_CREATE_TIMEOUT_MS }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = worktreeCreateRun.interpret(createReply) as { - worktree: { id: string; displayName?: string } - warning?: string - } + const result = worktreeCreateRun.interpret(createReply) setActionItem(null) setWorkspaceCreateDraft(null) setSetupPrompt(null) diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx index ea780216170..ef00672f11a 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-source-effects.tsx @@ -1,5 +1,5 @@ import type { WorkspaceCreateProjectionModel } from './use-mobile-tasks-workspace-create-projection' -import { type BaseRefSearchResult, type SparsePreset, useEffect } from './mobile-tasks-dependencies' +import { type SparsePreset, useEffect } from './mobile-tasks-dependencies' import { repoBaseRefSearchRead, repoSparsePresetListRead @@ -54,9 +54,8 @@ export function useMobileTasksWorkspaceSourceEffects(model: WorkspaceCreateProje if (stale) { return } - const presets = - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (repoSparsePresetListRead.interpret(reply) as SparsePreset[] | undefined) ?? [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires each preset's `id` and types the rest; see the save path for why the three members SparsePreset declares non-optional are not required here. + const presets = repoSparsePresetListRead.interpret(reply) as SparsePreset[] setWorkspaceSparsePresets(presets) setWorkspaceSparsePresetsLoaded(true) setWorkspaceSparsePresetId((current) => @@ -124,11 +123,7 @@ export function useMobileTasksWorkspaceSourceEffects(model: WorkspaceCreateProje if (stale) { return } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = repoBaseRefSearchRead.interpret(reply) as { - refDetails?: BaseRefSearchResult[] - refs?: string[] - } + const result = repoBaseRefSearchRead.interpret(reply) setWorkspaceBaseBranchResults( result.refDetails ?? (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx index ef2ecabd4fe..cf557a6ec1f 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-sparse-actions.tsx @@ -1,10 +1,5 @@ import type { WorkspaceSourceEffectsModel } from './use-mobile-tasks-workspace-source-effects' -import { - type SparsePreset, - type SshConnectionState, - useCallback, - useEffect -} from './mobile-tasks-dependencies' +import { type SparsePreset, useCallback, useEffect } from './mobile-tasks-dependencies' import { sortSparsePresetsByName } from './mobile-tasks-legacy-foundation' import { repoSparsePresetSaveRun, sshRepoStateRead } from './mobile-workspace-source-operations' @@ -89,7 +84,7 @@ export function useMobileTasksWorkspaceSparseActions(model: WorkspaceSourceEffec name: workspaceSparseDraftName, directories: workspaceSparseDraftParsed.directories }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the schema requires the preset's `id` and types the rest; `repoId`, `createdAt` and `updatedAt` are declared non-optional by SparsePreset but absent from the recorded preset, so defaulting them here would put numbers in the drawer's state the host never sent. const saved = repoSparsePresetSaveRun.interpret(reply) as SparsePreset | undefined if (!saved) { throw new Error('Failed to save sparse preset.') @@ -135,9 +130,7 @@ export function useMobileTasksWorkspaceSparseActions(model: WorkspaceSourceEffec if (stale) { return } - const state = - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (sshRepoStateRead.interpret(reply) as SshConnectionState | null | undefined) ?? null + const state = sshRepoStateRead.interpret(reply) ?? null setWorkspaceSshState( state ?? { targetId: workspaceCreateTargetConnectionId, diff --git a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx index 5e0966ca9ba..d88e2f316c7 100644 --- a/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx +++ b/mobile/src/tasks/use-mobile-tasks-workspace-ssh-state.tsx @@ -1,6 +1,5 @@ import type { WorkspaceSparseActionsModel } from './use-mobile-tasks-workspace-sparse-actions' import { - type SshConnectionState, normalizeSetupHookTrust, pickWorkspaceAgent, resolveWorkspaceAgentSelection, @@ -58,8 +57,7 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod { targetId: workspaceCreateTargetConnectionId }, { timeoutMs: 120_000 } ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const state = sshRepoConnectRun.interpret(reply) as SshConnectionState | null | undefined + const state = sshRepoConnectRun.interpret(reply) setWorkspaceSshState( state ?? { targetId: workspaceCreateTargetConnectionId, @@ -92,9 +90,7 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod return } const reply = await sshRepoStateRead.request(client, { targetId: repo.connectionId }) - const state = - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - (sshRepoStateRead.interpret(reply) as SshConnectionState | null | undefined) ?? null + const state = sshRepoStateRead.interpret(reply) ?? null if (state) { setWorkspaceSshState(state) } @@ -132,10 +128,7 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod return } const detected = detection.operation.interpret(reply) - setWorkspaceDetectedAgentIds( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - detected.accepted ? new Set(detected.value as string[]) : new Set() - ) + setWorkspaceDetectedAgentIds(detected.accepted ? new Set(detected.value) : new Set()) }) .catch(() => { if (!stale) { @@ -191,7 +184,7 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod | { kind: 'prompt' command: string - source: string | null + source: string | null | undefined setupTrust?: RepoHooksResponse['setupTrust'] } > => { @@ -199,8 +192,7 @@ export function useMobileTasksWorkspaceSshState(model: WorkspaceSparseActionsMod return { kind: 'decision', decision: override ?? 'inherit' } } const reply = await repoSetupHooksRead.request(client, { repo: `id:${repo.id}` }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = repoSetupHooksRead.interpret(reply) as RepoHooksResponse + const result = repoSetupHooksRead.interpret(reply) const setupCommand = result.hooks?.scripts?.setup?.trim() const setupTrust = normalizeSetupHookTrust(result.setupTrust) ?? undefined if (!setupCommand) { diff --git a/mobile/src/tasks/workspace-create-reply-schema.test.ts b/mobile/src/tasks/workspace-create-reply-schema.test.ts new file mode 100644 index 00000000000..ace3e59edd4 --- /dev/null +++ b/mobile/src/tasks/workspace-create-reply-schema.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { + agentLaunchCreateReceiptSchema, + worktreeCreateReceiptSchema, + worktreeHostedBaseSchema +} from './workspace-create-reply-schema' + +// Pins the one requirement on a create receipt, the arm order of the hosted-base union, and the +// two paths that must stay reachable: an empty worktree id and an empty soft-error message. + +describe('the worktree create receipt', () => { + it('reads every recorded create', () => { + for (const result of [ + { worktree: { id: 'wt-1', displayName: 'ORC-1 Recorded issue' } }, + { worktree: { id: 'wt-2' }, warning: 'shallow clone' }, + { worktree: { id: 'repo-1::/w' }, warning: ' startup terminal failed ' } + ]) { + expect(worktreeCreateReceiptSchema.safeParse(result)).toMatchObject({ success: true }) + } + }) + + it('keeps the warning untrimmed, because both readers trim it themselves', () => { + const parsed = worktreeCreateReceiptSchema.safeParse({ + worktree: { id: 'w' }, + warning: ' startup terminal failed ' + }) + expect(parsed.success && parsed.data).toMatchObject({ warning: ' startup terminal failed ' }) + }) + + it('leaves the empty-id arm reachable rather than making it a decode failure', () => { + const parsed = worktreeCreateReceiptSchema.safeParse({ worktree: { id: '' } }) + expect(parsed.success && parsed.data).toMatchObject({ worktree: { id: '' } }) + }) + + it('names a receipt with no worktree record, which routed the phone to /session/undefined', () => { + expect(worktreeCreateReceiptSchema.safeParse({ warning: 'x' }).success).toBe(false) + expect(worktreeCreateReceiptSchema.safeParse({ worktree: {} }).success).toBe(false) + expect(worktreeCreateReceiptSchema.safeParse(null).success).toBe(false) + }) +}) + +describe('the agent.launch receipt', () => { + it('reads the recorded structured receipt whole', () => { + const result = { + outcome: { kind: 'structured', sessionId: 'sess-1', handle: 't-1' }, + worktreeId: 'repo-1::/w', + warning: ' startup terminal failed ', + receipt: { + mode: 'structured', + preferred: 'structured', + reason: 'user_default', + detail: 'Structured session created.' + } + } + expect(agentLaunchCreateReceiptSchema.safeParse(result)).toMatchObject({ + success: true, + data: result + }) + }) + + it('requires nothing under the container, because the reader guards every member', () => { + expect(agentLaunchCreateReceiptSchema.safeParse({}).success).toBe(true) + }) + + it('names a receipt that is not an object at all', () => { + for (const result of [null, 'launched', 7, true]) { + expect(agentLaunchCreateReceiptSchema.safeParse(result).success).toBe(false) + } + }) +}) + +describe('the hosted base resolvers', () => { + it('reads both recorded successes', () => { + expect(worktreeHostedBaseSchema.safeParse({ baseBranch: 'main' }).success).toBe(true) + expect( + worktreeHostedBaseSchema.safeParse({ baseBranch: 'main', compareBaseRef: 'origin/main' }) + .success + ).toBe(true) + }) + + it('takes the error arm first, so a soft failure is never read as a base branch', () => { + const parsed = worktreeHostedBaseSchema.safeParse({ + error: 'pull request not found', + baseBranch: 'main' + }) + expect(parsed.success && parsed.data).toMatchObject({ error: 'pull request not found' }) + }) + + it('keeps an empty soft-error message, which the create replaces with its own copy', () => { + const parsed = worktreeHostedBaseSchema.safeParse({ error: '' }) + expect(parsed.success && parsed.data).toMatchObject({ error: '' }) + }) + + it('forwards the rest of a start point the create spreads into its params', () => { + const parsed = worktreeHostedBaseSchema.safeParse({ + baseBranch: 'main', + pushTarget: { kind: 'fork', remote: 'origin' }, + branchNameOverride: 'pr-12', + maintainerCanModify: false + }) + expect(parsed.success && parsed.data).toMatchObject({ + pushTarget: { kind: 'fork', remote: 'origin' }, + branchNameOverride: 'pr-12', + maintainerCanModify: false + }) + }) + + it('names a reply that is neither arm, where `in` was a TypeError', () => { + expect(worktreeHostedBaseSchema.safeParse({}).success).toBe(false) + expect(worktreeHostedBaseSchema.safeParse('main').success).toBe(false) + expect(worktreeHostedBaseSchema.safeParse(null).success).toBe(false) + }) +}) diff --git a/mobile/src/tasks/workspace-create-reply-schema.ts b/mobile/src/tasks/workspace-create-reply-schema.ts new file mode 100644 index 00000000000..40ab28ed207 --- /dev/null +++ b/mobile/src/tasks/workspace-create-reply-schema.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' +import { salvagedOptional } from '../../../src/shared/zod-salvage' + +// Creating a workspace from a task. Checked against src/main/runtime/rpc/methods/worktree.ts:76-208 +// (RuntimeWorktreeCreateResult, and the `GitHubPrStartPoint | { error }` pair the two base +// resolvers answer) and agent-launch.ts (AgentLaunchResult, src/shared/agent-launch-intent.ts:88). + +const createText = (name: string) => salvagedOptional(name, z.string()) + +/** + * A created workspace. + * + * `worktree.id` is the requirement: use-mobile-tasks-workspace-create-actions.tsx:276 routes to + * `/session/${result.worktree.id}` and :271 reads `result.worktree.displayName`, neither guarded, + * so a reply without the record navigated the phone to `/session/undefined`. Every recorded create + * carries it — `tw-create-retry-created`, `tw-create-retry-warning-kept`, + * `settings-task-workspace-create-linear` and its pr-start-point sibling. + * + * `z.string()` and not `.min(1)`: worktree-create-retry.ts:159 rejects an empty id itself and + * answers "Failed to create workspace", and that arm stays reachable rather than becoming a decode + * failure. + * + * `warning` is optional and untrimmed. Both readers trim it themselves (:273 and + * worktree-create-retry.ts:165), and `tw-create-retry-warning-kept` records the host sending + * `" startup terminal failed "` — normalising it here would move that golden. + */ +export const worktreeCreateReceiptSchema = z.looseObject({ + worktree: z.looseObject({ id: z.string(), displayName: createText('displayName') }), + warning: createText('warning') +}) + +/** + * An `agent.launch` receipt for a create. + * + * Nothing under the container is required, because readAgentLaunchCreateOutcome already guards + * every member it reads — `'worktreeId' in result` and a `typeof`/`trim` pair + * (agent-launch-worktree-create.ts:55-:64) — and answers `null` when either fails, which + * worktree-create-retry.ts:123 turns into "Failed to create workspace". That arm is preserved. + * + * What the container adds is the reply main could not name: a string, a number or `null` receipt + * reached the same "Failed to create workspace" copy as a receipt that simply had no id, so a host + * answering the wrong shape was indistinguishable from one that could not create. + * + * `outcome`, `receipt` and `prompt` are `unknown`. The reader is deliberately mode-blind — the + * host has already published and activated the surface before answering — so nothing here branches + * on them and declaring them would be a requirement with no reader. + */ +export const agentLaunchCreateReceiptSchema = z.looseObject({ + worktreeId: createText('worktreeId'), + warning: createText('warning'), + outcome: z.unknown().optional(), + receipt: z.unknown().optional(), + prompt: z.unknown().optional() +}) + +/** + * A linked pull request's or merge request's start point. + * + * A union, because the consumer's own test is `'error' in result` + * (composer-source-base-resolve.ts:39/:68, use-mobile-tasks-workspace-create-actions.tsx:187/:230) + * and `in` on a non-object was a TypeError. The soft-error arm comes first for the same reason + * main's branch does, and it keeps an empty message verbatim — `tw-hosted-base-soft-error` records + * the host answering `{ error: '' }`, which the create surfaces as its own copy. + * + * `baseBranch` is required on the resolved arm: it is the whole point of the call, and every + * recorded success carries it (`tw-hosted-base-resolved`, `settings-task-workspace-create-pr-start- + * point`). Everything beside it is optional and passes through, because the create params spread + * the record and the host reads what it recognises. + */ +export type WorktreeHostedBaseReply = + | { error: string } + | { + baseBranch: string + compareBaseRef?: string + branchNameOverride?: string + headSha?: string + maintainerCanModify?: boolean + pushTarget?: unknown + } + +// Annotated rather than inferred so `'error' in result` narrows at the four call sites: a +// `looseObject`'s index signature puts `error` on both arms as far as the checker is concerned. +// The runtime object still carries every member the host sent, which is what the create spreads. +export const worktreeHostedBaseSchema: z.ZodType = z.union([ + z.looseObject({ error: z.string() }), + z.looseObject({ + baseBranch: z.string(), + compareBaseRef: createText('compareBaseRef'), + branchNameOverride: createText('branchNameOverride'), + headSha: createText('headSha'), + maintainerCanModify: salvagedOptional('maintainerCanModify', z.boolean()), + pushTarget: z.unknown().optional() + }) +]) diff --git a/mobile/src/tasks/workspace-source-reply-schema.test.ts b/mobile/src/tasks/workspace-source-reply-schema.test.ts new file mode 100644 index 00000000000..22d5924eb34 --- /dev/null +++ b/mobile/src/tasks/workspace-source-reply-schema.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + detectedAgentIdsSchema, + repoBaseRefSearchSchema, + repoSetupHooksSchema, + repoSparsePresetListSchema, + repoSparsePresetSaveSchema, + sshConnectionStateSchema +} from './workspace-source-reply-schema' + +// Pins the SSH status degrade, the error tri-state beside it, and the preset requirement. + +const connected = { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0 +} + +describe('the SSH connection record', () => { + it('reads the recorded connected state whole', () => { + expect(sshConnectionStateSchema.safeParse({ state: connected })).toMatchObject({ + success: true, + data: connected + }) + }) + + it('keeps the connectionGeneration the file-mutation owner check reads', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { ...connected, connectionGeneration: 3 } + }) + expect(parsed.success && parsed.data).toMatchObject({ connectionGeneration: 3 }) + }) + + it('forwards members no consumer in this domain declares', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { ...connected, providerEpoch: 'epoch-1', remotePlatform: 'linux' } + }) + expect(parsed.success && parsed.data).toMatchObject({ + providerEpoch: 'epoch-1', + remotePlatform: 'linux' + }) + }) + + it('answers undefined for a payload with no state member', () => { + expect(sshConnectionStateSchema.safeParse({})).toMatchObject({ success: true, data: undefined }) + }) + + it('keeps an explicit null state, which the drawer falls back from', () => { + expect(sshConnectionStateSchema.safeParse({ state: null })).toMatchObject({ + success: true, + data: null + }) + }) +}) + +describe('status is an open enum that degrades to disconnected', () => { + it('takes every arm this build knows', () => { + for (const status of [ + 'disconnected', + 'connecting', + 'auth-failed', + 'deploying-relay', + 'connected', + 'reconnecting', + 'reconnection-failed', + 'error' + ]) { + const parsed = sshConnectionStateSchema.safeParse({ state: { ...connected, status } }) + expect(parsed.success && parsed.data).toMatchObject({ status }) + } + }) + + it('degrades an arm it has never heard of, keeping the record and the Connect affordance', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { ...connected, status: 'handshaking-v2' } + }) + expect(parsed.success && parsed.data).toMatchObject({ + targetId: 'ssh-1', + status: 'disconnected' + }) + }) + + it('never degrades a newer arm to connected', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { ...connected, status: 'whatever' } + }) + expect(parsed.success && (parsed.data as { status: string }).status).not.toBe('connected') + }) + + it('stays fatal for a non-string status, which is the wrong type and not a newer arm', () => { + const parsed = sshConnectionStateSchema.safeParse({ state: { ...connected, status: 7 } }) + expect(parsed.success && parsed.data).toBeUndefined() + }) +}) + +describe('error is a tri-state the drawer renders', () => { + it('keeps an explicit null', () => { + const parsed = sshConnectionStateSchema.safeParse({ state: connected }) + expect(parsed.success && (parsed.data as { error: unknown }).error).toBeNull() + }) + + it('keeps the host message', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { ...connected, status: 'error', error: 'auth failed' } + }) + expect(parsed.success && parsed.data).toMatchObject({ error: 'auth failed' }) + }) + + it('drops the record when error is absent, because the drawer renders it unguarded', () => { + const parsed = sshConnectionStateSchema.safeParse({ + state: { targetId: 'ssh-1', status: 'connected', reconnectAttempt: 0 } + }) + expect(parsed.success && parsed.data).toBeUndefined() + }) +}) + +describe('detected agent ids', () => { + it('reads the recorded probe answers', () => { + expect(detectedAgentIdsSchema.safeParse(['codex', 'claude'])).toMatchObject({ + success: true, + data: ['codex', 'claude'] + }) + }) + + it('drops a non-string id, which no agent comparison could have matched', () => { + expect(detectedAgentIdsSchema.safeParse(['codex', 7])).toMatchObject({ data: ['codex'] }) + }) + + it('names a payload the drawer would have built a Set from', () => { + expect(detectedAgentIdsSchema.safeParse(7).success).toBe(false) + expect(detectedAgentIdsSchema.safeParse({ agents: [] }).success).toBe(false) + }) +}) + +describe('the orca.yaml hooks require nothing', () => { + it('reads the recorded reply for a repo with no setup script and no source', () => { + expect(repoSetupHooksSchema.safeParse({ hooks: { scripts: {} } }).success).toBe(true) + }) + + it('reads the recorded reply with an explicit null setupTrust', () => { + const parsed = repoSetupHooksSchema.safeParse({ + hooks: { scripts: { setup: 'pnpm install' } }, + source: 'repo', + setupRunPolicy: 'ask', + setupTrust: null + }) + expect(parsed.success && parsed.data).toMatchObject({ setupTrust: null }) + }) + + it('keeps a setupRunPolicy this build has never heard of on the skip arm', () => { + const parsed = repoSetupHooksSchema.safeParse({ setupRunPolicy: 'prompt-twice' }) + expect(parsed.success && parsed.data).toMatchObject({ setupRunPolicy: 'prompt-twice' }) + }) + + it('keeps the untrimmed setup script the recorded reply carries', () => { + const parsed = repoSetupHooksSchema.safeParse({ hooks: { scripts: { setup: ' pnpm i ' } } }) + expect(parsed.success && parsed.data).toMatchObject({ + hooks: { scripts: { setup: ' pnpm i ' } } + }) + }) +}) + +describe('sparse presets', () => { + it('reads the recorded preset, which carries no repoId or timestamps', () => { + const parsed = repoSparsePresetListSchema.safeParse({ + presets: [{ id: 'p1', name: 'docs', directories: ['docs'] }] + }) + expect(parsed.success && parsed.data).toEqual([ + { id: 'p1', name: 'docs', directories: ['docs'] } + ]) + }) + + it('drops a preset with no id, which the picker could not select', () => { + const parsed = repoSparsePresetListSchema.safeParse({ presets: [{ name: 'docs' }] }) + expect(parsed.success && parsed.data).toEqual([]) + }) + + it('keeps the save path that answers no preset at all', () => { + expect(repoSparsePresetSaveSchema.safeParse({})).toMatchObject({ + success: true, + data: undefined + }) + }) +}) + +describe('base-ref search requires neither member', () => { + it('reads both recorded shapes', () => { + expect(repoBaseRefSearchSchema.safeParse({ refs: ['main'] }).success).toBe(true) + expect( + repoBaseRefSearchSchema.safeParse({ + refDetails: [{ refName: 'origin/main', localBranchName: 'main' }] + }).success + ).toBe(true) + }) + + it('drops a non-string ref rather than rendering it as a branch row', () => { + const parsed = repoBaseRefSearchSchema.safeParse({ refs: ['main', 7] }) + expect(parsed.success && parsed.data).toMatchObject({ refs: ['main'] }) + }) +}) diff --git a/mobile/src/tasks/workspace-source-reply-schema.ts b/mobile/src/tasks/workspace-source-reply-schema.ts new file mode 100644 index 00000000000..a4eda2083e9 --- /dev/null +++ b/mobile/src/tasks/workspace-source-reply-schema.ts @@ -0,0 +1,165 @@ +import { z } from 'zod' +import { openEnum, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage' + +// The repo and SSH reads the workspace-create drawer runs. Checked against +// src/main/runtime/rpc/methods/ssh.ts:30-46 (getPublicSshState, SshConnectionState in +// src/shared/ssh-types.ts:187), preflight.ts:22-30 (both agent probes answer a bare `string[]`), +// and repo.ts:87-103/:184-192 (the sparse preset envelopes, the ref search and the orca.yaml +// hooks). + +const SSH_CONNECTION_STATUS = [ + 'disconnected', + 'connecting', + 'auth-failed', + 'deploying-relay', + 'connected', + 'reconnecting', + 'reconnection-failed', + 'error' +] as const + +const sourceText = (name: string) => salvagedOptional(name, z.string()) + +/** + * The SSH connection record, answered under a `state` member by both `ssh.connect` and + * `ssh.getState`. + * + * All four members SshConnectionState declares are required, and every recorded reply carries + * them (`tw-workspace-ssh-connected`, `tw-workspace-ssh-not-ready`, `tw-workspace-sparse-saved`, + * `files-ownership-ssh`). They have to be: the drawer publishes the record into state and renders + * it, and `error` is a tri-state the UI shows — `null` means "connected cleanly", a string is the + * failure text, and the two are not interchangeable. + * + * `status` is an OPEN enum. It is a wire surface (remote-wire-compatibility.md rule 4), so an arm + * this build has not heard of must not refuse the record or drop it. It degrades to + * `'disconnected'`, which is main's own answer for a state it did not receive + * (use-new-workspace-execution-target.ts:64, use-mobile-tasks-workspace-sparse-actions.tsx:145): + * the readiness gate is an equality test against `'connected'`, so the degrade never grants a + * create it should not, and it leaves the Connect affordance the user needs. + * + * The whole member stays nullable and optional because that is what the two call sites read: + * `state ?? fallback…` at use-mobile-tasks-workspace-ssh-state.tsx:63 and :97. + * + * `providerEpoch`, `supportsFolderDownload` and `remotePlatform` are NOT declared. Nothing in this + * domain reads them, and a loose object forwards them to the file-mutation owner check and the + * download gate exactly as main did — listing a member ahead of its reader is how a schema starts + * refusing replies no consumer here would have noticed. + */ +export const sshConnectionStateSchema = z + .looseObject({ + state: salvagedOptional( + 'state', + z + .looseObject({ + targetId: z.string(), + status: openEnum(SSH_CONNECTION_STATUS, 'disconnected'), + error: z.string().nullable(), + reconnectAttempt: z.number(), + connectionGeneration: salvagedOptional('connectionGeneration', z.number()) + }) + .nullable() + ) + }) + .transform((reply) => reply.state) + +/** + * The agent ids a host reports, local or remote. + * + * A bare array of strings, which is what both handlers return. The drawer builds a `Set` from it + * (use-mobile-tasks-workspace-ssh-state.tsx:134, use-new-workspace-execution-target.ts:96), so a + * non-iterable payload was a TypeError inside a `.then` and a number reply was a silent + * `Set { 7 }` that matched no agent. A non-string element drops rather than failing the probe: + * every reader compares the id to a known agent, so a dropped element and a kept non-string agree + * on every verdict, and the drop is the one that says so in the salvage report. + */ +export const detectedAgentIdsSchema = salvagingArray(z.string()) + +/** + * The repo's orca.yaml hooks. + * + * Nothing is required. use-mobile-tasks-workspace-ssh-state.tsx:204 spells + * `result.hooks?.scripts?.setup?.trim()`, :206 defaults `setupRunPolicy`, and + * `normalizeSetupHookTrust` rejects a `setupTrust` without both members — and the recorded + * `tw-workspace-ssh-not-ready` reply is `{ hooks: { scripts: {} } }` with no `source` and no + * policy at all, so a requirement on either would refuse a reply main handled. `setupTrust` is + * nullable because `components-setup-ask` records an explicit `null` there. + * + * `setupRunPolicy` stays a plain string. :207 tests it against `'ask'` and :211 against + * `'run-by-default'`, so an unknown policy already lands on the `skip` arm; closing the set would + * drop it to the schema's fallback instead and change which arm a newer host reaches. + */ +export const repoSetupHooksSchema = z.looseObject({ + hooks: salvagedOptional( + 'hooks', + z + .looseObject({ + scripts: salvagedOptional('scripts', z.looseObject({ setup: sourceText('setup') })) + }) + .nullable() + ), + source: salvagedOptional('source', z.string().nullable()), + setupRunPolicy: sourceText('setupRunPolicy'), + setupTrust: salvagedOptional( + 'setupTrust', + z + .looseObject({ + contentHash: sourceText('contentHash'), + scriptContent: sourceText('scriptContent') + }) + .nullable() + ) +}) + +/** + * One saved sparse-checkout preset. + * + * `id` alone is required: it is what the picker selects by and what the save path dedupes on + * (use-mobile-tasks-workspace-sparse-actions.tsx:96/:101). `repoId`, `createdAt` and `updatedAt` + * are declared non-optional by SparsePreset but absent from the recorded preset + * (`tw-workspace-source-presets`, `{ id: 'p1', name: 'docs', directories: ['docs'] }`), so they + * are typed and optional — defaulting them would put numbers in the drawer's recorded state that + * main never had. + */ +const sparsePreset = z.looseObject({ + id: z.string(), + name: sourceText('name'), + directories: salvagedOptional('directories', salvagingArray(z.string())), + repoId: sourceText('repoId'), + createdAt: salvagedOptional('createdAt', z.number()), + updatedAt: salvagedOptional('updatedAt', z.number()) +}) + +/** The preset list, answered under `presets`. Required, because :59 defaults it with `?? []` only + * after the member read — main read `.presets` off whatever arrived. */ +export const repoSparsePresetListSchema = z + .looseObject({ presets: salvagingArray(sparsePreset) }) + .transform((reply) => reply.presets) + +/** + * The preset a save answers with. + * + * Optional, and deliberately: `tw-workspace-sparse-missing-preset` records the host answering + * `{}`, which main turned into its own "Failed to save sparse preset." error. That path is kept. + */ +export const repoSparsePresetSaveSchema = z + .looseObject({ preset: salvagedOptional('preset', sparsePreset) }) + .transform((reply) => reply.preset) + +/** + * Base-branch search. + * + * Neither member is required: both call sites spell the same + * `refDetails ?? refs.map(…)` fallback (use-mobile-tasks-workspace-source-effects.tsx:130, + * smart-source-search-requests.ts:115), and the two recorded replies carry one member each — + * `{ refs: [...] }` in `tw-workspace-source-presets` and `{ refDetails: [...] }` in + * `tw-smart-search-gitlab-provider-error`. The fallback stays at the call sites, where it was; + * what the schema adds is that a `refs` full of numbers no longer reaches the picker as rows + * whose `refName` renders as a number. + */ +export const repoBaseRefSearchSchema = z.looseObject({ + refs: salvagedOptional('refs', salvagingArray(z.string())), + refDetails: salvagedOptional( + 'refDetails', + salvagingArray(z.looseObject({ refName: z.string(), localBranchName: z.string() })) + ) +}) diff --git a/mobile/src/tasks/worktree-create-capability.ts b/mobile/src/tasks/worktree-create-capability.ts index 3b91052bcf5..daf338336d6 100644 --- a/mobile/src/tasks/worktree-create-capability.ts +++ b/mobile/src/tasks/worktree-create-capability.ts @@ -47,11 +47,7 @@ export async function readNewWorktreeRuntimeCapabilities( if (!status.accepted) { return UNSUPPORTED_CAPABILITIES } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const result = status.value as { - capabilities?: string[] - worktreeCreateIdempotency?: unknown - } + const result = status.value const capabilities = result.capabilities ?? [] const supportsIdempotency = capabilities.includes( MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY diff --git a/mobile/src/tasks/worktree-create-retry.ts b/mobile/src/tasks/worktree-create-retry.ts index 955f6d63a1a..f796a07e93b 100644 --- a/mobile/src/tasks/worktree-create-retry.ts +++ b/mobile/src/tasks/worktree-create-retry.ts @@ -150,16 +150,15 @@ function readCreateResult( if (launched) { return readAgentLaunchCreateOutcome(agentLaunchRun.interpret(response)) } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary. - const created = worktreeCreateRun.interpret(response) as { - worktree?: { id?: unknown; displayName?: unknown } - warning?: unknown - } | null - const worktreeId = created?.worktree?.id - if (typeof worktreeId !== 'string' || !worktreeId) { + const created = worktreeCreateRun.interpret(response) + // The empty-id arm stays reachable: the schema types `worktree.id` as a string without a minimum + // length, so a host answering `''` still reports "Failed to create workspace" rather than + // reading as an unreadable reply. + const worktreeId = created.worktree.id + if (!worktreeId) { return null } - const displayName = created?.worktree?.displayName + const displayName = created.worktree.displayName // Why: a create can succeed with the startup terminal failing (pty exhaustion); dropping // `warning` here is what lands the phone on an unexplained empty session. const warning = typeof created?.warning === 'string' ? created.warning.trim() : '' diff --git a/mobile/src/transport/unchecked-rpc-reader-inventory.ts b/mobile/src/transport/unchecked-rpc-reader-inventory.ts index be4dd3977ee..a1eabf44408 100644 --- a/mobile/src/transport/unchecked-rpc-reader-inventory.ts +++ b/mobile/src/transport/unchecked-rpc-reader-inventory.ts @@ -75,13 +75,6 @@ export const UNCHECKED_RPC_READERS: readonly UncheckedRpcReaderEntry[] = [ { file: 'src/tasks/mobile-task-item-detail-operations.ts', readers: 8 }, { file: 'src/tasks/mobile-task-item-state-operations.ts', readers: 17 }, { file: 'src/tasks/mobile-task-list-operations.ts', readers: 6 }, - { file: 'src/tasks/mobile-task-project-board-operations.ts', readers: 17 }, - { file: 'src/tasks/mobile-task-runtime-operations.ts', readers: 7 }, - { file: 'src/tasks/mobile-task-source-search-operations.ts', readers: 7 }, - // #19850 brought the fifth (`agent-launch-receipt`): mobile's create routes through agent.launch - // when the host advertises it, and that reply is re-typed exactly as the four beside it are. - { file: 'src/tasks/mobile-workspace-create-operations.ts', readers: 5 }, - { file: 'src/tasks/mobile-workspace-source-operations.ts', readers: 7 }, // terminal { file: 'src/terminal/mobile-terminal-operations.ts', readers: 4 }, // transport