refactor(mobile): checked reply readers for the tasks domain's board, runtime, search and create

Forty-three unchecked reply readers across five files become checked zod readers,
so a malformed host reply surfaces as one readable error naming the method
instead of a downstream TypeError, a rendered `undefined`, or a screen left ready
over garbage. Deliberately a behaviour change on malformed replies only.

Five schema modules, each recording the consumer line behind every requirement
and the host handler it was checked against:

- `task-project-board-reply-schema.ts` — the sixteen `github.project.*`
  envelopes. Where a consumer reads a member off BOTH arms unguarded the schema
  is a union on `ok`; where it guards everything (`result.error?.message ?? '…'`,
  `result.labels ?? []`) it is a flat passthrough and requires only the
  container, because a requirement on a member the consumer already defaults
  would refuse a reply main rendered.
- `task-runtime-reply-schema.ts` — the hydration reads. The three preference
  writes read `z.unknown()`: no call site interprets their body.
- `task-source-search-reply-schema.ts` — the provider searches and the pasted
  single-item lookups. The Linear union replaces the hand reader in
  linear-mobile-issue-read.ts, whose own copy reached the screen unattributed.
- `workspace-source-reply-schema.ts` — SSH state, agent detection, orca.yaml
  hooks, sparse presets and base-ref search.
- `workspace-create-reply-schema.ts` — the create receipt, the launch receipt and
  the hosted-base union.

Requirements are exactly the members a consumer reads unguarded AND a recorded
golden shows the host sending. That second half is load-bearing: the recorded
GitHub search row is `{ number, title }`, the recorded Linear issue is `{ id }`,
the recorded project is missing `id`/`url`/`source` and the recorded sparse
preset is missing `repoId`/`createdAt`/`updatedAt` — requiring what the shared
types declare would have dropped rows main renders. Where the value therefore
stays looser than the screen's own state type, the call site keeps one narrowing
cast with that reason on it rather than a default that would fabricate state.

Two enum decisions, both pinned:

- `ownerType` is CLOSED with no fallback. It is echoed into the next
  `github.project.listViews` params, and remote-wire-compatibility.md rule 4
  forbids a reply-schema fallback from shaping a param; the host's own listing
  handler answers `validation_error` for any other value.
- `ssh` `status` is OPEN and degrades to `disconnected`, main's own answer for a
  state it did not receive. The readiness gate is an equality test against
  `connected`, so an arm this build has not heard of can never grant a create,
  and the record survives with its Connect affordance.
- Every other host vocabulary a consumer equality-tests — the project view
  `layout`, the `setupRunPolicy` — stays `z.string()` for the same rule.

Tri-states are preserved, not collapsed: the row detail's `reviewDecision`, a
work item's `author` and the SSH record's `error` each keep explicit `null`
distinct from absent, with a unit pin on each.

`blank-workspace-create.test.ts` splits one `it.each` in two. The two create
routes now answer a workspace-less reply differently: `agent.launch` still
reports "Failed to create workspace", because its reader guards `worktreeId`
itself, while `worktree.create` is named as unreadable, because the create screen
reads `result.worktree.id` unguarded into the session route. Both reach the same
catch; only the sentence changes.

`mobile-tasks-refactor-parity.test.ts` moves four hashes and no count. Hooks hold
at 350 with 28 bodies edited and no dependency array moved; statements hold at
417 and declarations at 194; `semantics` loses exactly four lines, all four
string literals that lived inside the one deleted inline cast type. No method
literal and no `rpc:` call signature moves.

The inventory loses its five tasks lines; the boundary test stays green.

Goldens are refreshed in the next commit, which is where the disclosed behaviour
change is proved.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-17 00:32:35 -04:00
parent 8a509170ae
commit d4cfac98b4
43 changed files with 1821 additions and 370 deletions
@@ -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(
@@ -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(
+2 -16
View File
@@ -14,14 +14,6 @@ type HomeTaskSettings = {
visibleTaskProviders?: unknown
}
type HomePreflightStatus = {
glab?: { installed?: boolean }
}
type HomeLinearStatus = {
connected?: boolean
}
export type HomeStatsSetter = (
updater: (previous: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => 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),
{
+36 -25
View File
@@ -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)
})
})
@@ -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
}
@@ -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)
})
)
@@ -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)
})
)
@@ -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<unknown, 'linear-issues', unknown> = (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)
})
)
@@ -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 = {
@@ -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)
})
@@ -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'
}
@@ -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)
})
)
@@ -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)
})
)
+8 -3
View File
@@ -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 }
}
@@ -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
}
@@ -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 }))
@@ -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' } })
})
})
@@ -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') })])
)
})
@@ -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)
}
})
})
@@ -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()
@@ -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)
})
})
@@ -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()
@@ -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) => {
@@ -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
@@ -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')
}
@@ -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) {
@@ -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.
@@ -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'
@@ -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'
@@ -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<Omit<GitHubWorkItem, 'repoId' | 'repoName'>>
sources?: GitHubRepoSources
@@ -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, {
@@ -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<Omit<GitLabWorkItem, 'repoId' | 'repoName'>>
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
@@ -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)
@@ -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 }))
@@ -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,
@@ -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) {
@@ -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)
})
})
@@ -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<WorktreeHostedBaseReply, unknown> = 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()
})
])
@@ -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'] })
})
})
@@ -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() }))
)
})
@@ -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
+7 -8
View File
@@ -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() : ''
@@ -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