mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
feat(mobile): validate the source-control domain's RPC replies at arrival
Replaces all 17 unchecked readers in mobile/src/source-control/ with `rpcResultVariant(variant, schema)`, so a malformed reply is an `RpcIncompatibleReplyError` naming the operation instead of a TypeError three frames downstream. The inventory drops 201 -> 184 and the five source-control operations files leave it entirely. This is a behaviour change, scoped to malformed replies. Six reply-matrix goldens move; every named-scenario golden and every `normal` partition is byte-identical, which is the parity claim. Schemas live one module per reply domain, beside the operations that read them: git-status, git-compare, git-history, hosted-review and worktree-metadata. A member is required only where a consumer reads it unguarded, and each schema records the consumer line that justifies it. Nothing is `.strict()`; every reply a consumer publishes verbatim keeps `z.looseObject` so an undeclared host member still passes through. Six replies have no reader anywhere in mobile and get `z.unknown()`, which is the honest schema for them, not a holdout. Three readers stay total by construction, because their contract is that an unreadable reply is a value rather than an error: the `git.status` projection (a null status three screens route on), the `session.tabs.list` reveal (a null list means poll again) and the generated commit message (a screen's copy, never a decode error in a text field). They gain the salvage report, not a verdict. Consumers take the schema's output type, so `MobileGitStatusResult` and the branch-compare aliases now name what mobile reads rather than the desktop aggregate, and seven call-site casts are gone. Three requirements came from the goldens, not from the host types: `git.history` sends `timestamp: null`, `hostedReview.getCreationEligibility` sends a `reviewLookupOutcome` the shared union does not list, and the `git.status` projection writes an absent member as a present `undefined`. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -122,7 +122,7 @@ export async function loadMobileDiffReviewSnapshot(
|
||||
const normalizedReviewState = normalizeMobileDiffReviewState(metadata.mobileDiffReview)
|
||||
const branchEntries =
|
||||
branch.result && canOpenMobileBranchCompareDiff(branch.result.summary)
|
||||
? branch.result.entries
|
||||
? (branch.result.entries ?? [])
|
||||
: []
|
||||
const queue = buildMobileDiffReviewQueue({
|
||||
worktreeId,
|
||||
|
||||
@@ -119,7 +119,7 @@ export function useMobileDiffReviewController(input: ControllerInput) {
|
||||
}
|
||||
const branchEntries =
|
||||
screenState.branchCompare && canOpenMobileBranchCompareDiff(screenState.branchCompare.summary)
|
||||
? screenState.branchCompare.entries
|
||||
? (screenState.branchCompare.entries ?? [])
|
||||
: []
|
||||
return buildMobileDiffReviewQueue({
|
||||
worktreeId,
|
||||
|
||||
@@ -6,13 +6,13 @@ import type { ConnectionState } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useForceReconnect } from '../transport/client-context'
|
||||
import { gitCommitCompareRead } from './mobile-git-read-operations'
|
||||
import type { MobileGitChangedFile } from './git-compare-reply-schema'
|
||||
import {
|
||||
fetchMobileGitHistory,
|
||||
mapMobileCommitRows,
|
||||
type MobileCommitRow
|
||||
} from './mobile-git-history'
|
||||
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
|
||||
import type { GitBranchChangeEntry } from '../../../src/shared/git-diff-compare-types'
|
||||
|
||||
type Props = {
|
||||
client: RpcClient | null
|
||||
@@ -42,7 +42,7 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [reloadNonce, setReloadNonce] = useState(0)
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
|
||||
const [filesById, setFilesById] = useState<Record<string, MobileGitChangedFile[] | 'loading'>>({})
|
||||
|
||||
// Host or worktree identity change must wipe history immediately — even while
|
||||
// disconnected — so a kept-mounted hub segment never shows another tree's commits.
|
||||
@@ -110,8 +110,7 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
|
||||
.request(client, { worktree: `id:${worktreeId}`, commitId })
|
||||
.then((reply) => {
|
||||
const compared = gitCommitCompareRead.interpret(reply)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
const entries = compared.accepted ? (compared.value as GitBranchChangeEntry[]) : []
|
||||
const entries = compared.accepted ? compared.value.entries : []
|
||||
if (!stale) {
|
||||
setFilesById((prev) => ({ ...prev, [commitId]: entries }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { z } from 'zod'
|
||||
import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
|
||||
|
||||
// The three compare replies the Changes screen and the history list read: `git.branchCompare`,
|
||||
// `git.commitCompare` and `git.branchDiff`. Checked against GitBranchCompareResult,
|
||||
// GitCommitCompareResult and GitDiffResult in src/shared/git-diff-compare-types.ts, which
|
||||
// src/main/runtime/rpc/methods/git.ts returns from the runtime compare calls verbatim.
|
||||
|
||||
const GIT_BRANCH_CHANGE_STATUS = ['modified', 'added', 'deleted', 'renamed', 'copied'] as const
|
||||
const GIT_BRANCH_COMPARE_STATUS = [
|
||||
'ready',
|
||||
'invalid-base',
|
||||
'unborn-head',
|
||||
'no-merge-base',
|
||||
'loading',
|
||||
'error'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* One changed file as the commit list reads it: `path` only.
|
||||
*
|
||||
* MobileGitHistoryList.tsx:171 keys the row by `path` and :176-177 renders `added`/`removed`
|
||||
* behind a truthy check. It never reads `status`, so this list does not require it — the two
|
||||
* compare replies share a host type but not a set of readers.
|
||||
*/
|
||||
export const gitChangedFileSchema = z.looseObject({
|
||||
path: z.string(),
|
||||
status: z.enum(GIT_BRANCH_CHANGE_STATUS).optional(),
|
||||
oldPath: z.string().optional(),
|
||||
added: z.number().optional(),
|
||||
removed: z.number().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The same file as the branch list reads it, which additionally colours and labels the row by
|
||||
* `status` with no guard (MobileSourceControlFileRows.tsx:219-220) and sorts on `path`
|
||||
* (mobile-branch-compare.ts:27).
|
||||
*/
|
||||
export const gitBranchChangeEntrySchema = gitChangedFileSchema.extend({
|
||||
status: z.enum(GIT_BRANCH_CHANGE_STATUS)
|
||||
})
|
||||
|
||||
/**
|
||||
* The compare summary. `baseRef`, `changedFiles` and `status` are required: formatMobileBranch-
|
||||
* CompareSummary reads all three unguarded (mobile-branch-compare.ts:38-45), and
|
||||
* use-mobile-source-control-state.ts:149 reads `summary.status` again. `headOid` and `mergeBase`
|
||||
* gate the branch-diff open (:50) and are nullable in the host type, so both stay nullable here.
|
||||
*/
|
||||
export const gitBranchCompareSummarySchema = z.looseObject({
|
||||
baseRef: z.string(),
|
||||
changedFiles: z.number(),
|
||||
status: z.enum(GIT_BRANCH_COMPARE_STATUS),
|
||||
baseOid: z.string().nullable().optional(),
|
||||
compareRef: z.string().optional(),
|
||||
headOid: z.string().nullable().optional(),
|
||||
mergeBase: z.string().nullable().optional(),
|
||||
commitsAhead: salvagedOptional('commitsAhead', z.number()),
|
||||
errorMessage: salvagedOptional('errorMessage', z.string())
|
||||
})
|
||||
|
||||
/**
|
||||
* `summary` is required: use-mobile-source-control-state.ts:131/134/149 and
|
||||
* MobileSourceControlFileRows.tsx:190 reach it with only the result null-checked. `entries` is
|
||||
* optional because :127 reads `branchCompareResult?.entries ?? []` — a reply without a list is an
|
||||
* empty section today, and making it fatal would turn that into a full-screen error.
|
||||
*/
|
||||
export const gitBranchCompareResultSchema = z.looseObject({
|
||||
summary: gitBranchCompareSummarySchema,
|
||||
entries: salvagingArray(gitBranchChangeEntrySchema).optional()
|
||||
})
|
||||
|
||||
/** The commit-compare list. Only `entries` has a reader: MobileGitHistoryList.tsx:114-115. */
|
||||
export const gitCommitCompareResultSchema = z.looseObject({
|
||||
entries: salvagingArray(gitChangedFileSchema)
|
||||
})
|
||||
|
||||
/**
|
||||
* A single file's diff.
|
||||
*
|
||||
* Two declared shapes rather than one: mobile renders only `kind: 'text'` and throws its own copy
|
||||
* on anything else (use-mobile-source-control-openers.ts:264-267). So the text shape requires the
|
||||
* two contents it renders, and every other kind needs nothing but a `kind` to route on — including
|
||||
* a kind a newer host adds, which must reach that same throw rather than an incompatible reply.
|
||||
*/
|
||||
const gitDiffTextSchema = z.looseObject({
|
||||
kind: z.literal('text'),
|
||||
originalContent: z.string(),
|
||||
modifiedContent: z.string()
|
||||
})
|
||||
|
||||
// Renamed to one discriminant so the consumer's `kind !== 'text'` still narrows; the host's own
|
||||
// kind rides along for a diagnostic rather than being thrown away.
|
||||
const gitDiffOtherKindSchema = z
|
||||
.object({ kind: z.string() })
|
||||
.transform((value) => ({ kind: 'not-text' as const, hostKind: value.kind }))
|
||||
|
||||
export const gitDiffResultSchema = z.union([gitDiffTextSchema, gitDiffOtherKindSchema])
|
||||
|
||||
export type MobileGitChangedFile = z.output<typeof gitChangedFileSchema>
|
||||
export type MobileGitBranchChangeEntry = z.output<typeof gitBranchChangeEntrySchema>
|
||||
export type MobileGitBranchCompareSummary = z.output<typeof gitBranchCompareSummarySchema>
|
||||
export type MobileGitBranchCompareReply = z.output<typeof gitBranchCompareResultSchema>
|
||||
export type MobileGitCommitCompareReply = z.output<typeof gitCommitCompareResultSchema>
|
||||
export type MobileGitDiffReply = z.output<typeof gitDiffResultSchema>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod'
|
||||
import { salvagingArray } from '../../../src/shared/zod-salvage'
|
||||
|
||||
// `git.history` reply. Checked against GitHistoryResult in src/shared/git-history-types.ts, which
|
||||
// src/main/runtime/rpc/methods/git.ts returns from getRuntimeGitHistory verbatim.
|
||||
//
|
||||
// The host publishes nine members; mobile reads one. `currentRef`, `remoteRef`, `baseRef`,
|
||||
// `mergeBase`, `hasIncomingChanges`, `hasOutgoingChanges`, `hasMore` and `limit` have no reader in
|
||||
// mobile/, so they are undeclared and pass through rather than becoming eight ways for an older
|
||||
// host to fail.
|
||||
|
||||
/**
|
||||
* One commit row.
|
||||
*
|
||||
* `id` and `parentIds` are required: mobile-git-history.ts:48 calls `item.id.slice(0, 7)` and :51
|
||||
* indexes `item.parentIds[0]`, both of which throw on absence. `subject`, `author`, `displayId` and
|
||||
* `timestamp` are read through `||`, `??` or a nullish check on the same lines, so they are
|
||||
* optional here even where the host type declares them required — an older host that omits one
|
||||
* must still render a history list.
|
||||
*/
|
||||
export const gitHistoryItemSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
parentIds: z.array(z.string()),
|
||||
subject: z.string().optional(),
|
||||
displayId: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
// Nullish, not optional: the host sends `timestamp: null` for a commit with no date, and
|
||||
// formatCommitTime's guard is `== null`. A number-or-absent schema would drop the whole row.
|
||||
timestamp: z.number().nullish()
|
||||
})
|
||||
|
||||
/**
|
||||
* `items` is required and fatal on absence: mapMobileCommitRows maps it with no guard, which is the
|
||||
* `Cannot read properties of null (reading 'items')` main records for every malformed partition.
|
||||
* A salvaging array so one unreadable commit drops out of the list instead of emptying the screen.
|
||||
*/
|
||||
export const gitHistoryResultSchema = z.looseObject({
|
||||
items: salvagingArray(gitHistoryItemSchema)
|
||||
})
|
||||
|
||||
export type MobileGitHistoryResult = z.output<typeof gitHistoryResultSchema>
|
||||
export type MobileGitHistoryItem = z.output<typeof gitHistoryItemSchema>
|
||||
@@ -0,0 +1,168 @@
|
||||
import { z } from 'zod'
|
||||
import { salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
|
||||
|
||||
// Schemas for the two `git.status` replies mobile reads. Checked against the host's published
|
||||
// shape in src/shared/git-status-types.ts (GitStatusResult / GitUncommittedEntry), which is what
|
||||
// src/main/runtime/rpc/methods/git.ts returns from getRuntimeGitStatus verbatim.
|
||||
//
|
||||
// A member is required only where a consumer reads it without a guard. Everything the host may
|
||||
// send and mobile does not read is undeclared and passes through: a schema that required a field
|
||||
// an older host omits would turn every reply from that host into an incompatible error.
|
||||
|
||||
const GIT_FILE_STATUS = ['modified', 'added', 'deleted', 'renamed', 'untracked', 'copied'] as const
|
||||
const GIT_STAGING_AREA = ['staged', 'unstaged', 'untracked'] as const
|
||||
const GIT_CONFLICT_STATUS = ['unresolved', 'resolved_locally'] as const
|
||||
const GIT_CONFLICT_SOURCE = ['git', 'session'] as const
|
||||
const GIT_CONFLICT_KIND = [
|
||||
'both_modified',
|
||||
'both_added',
|
||||
'both_deleted',
|
||||
'added_by_us',
|
||||
'added_by_them',
|
||||
'deleted_by_us',
|
||||
'deleted_by_them'
|
||||
] as const
|
||||
const GIT_CONFLICT_OPERATION = ['merge', 'rebase', 'cherry-pick', 'unknown'] as const
|
||||
|
||||
/**
|
||||
* One working-tree entry.
|
||||
*
|
||||
* `path`, `status` and `area` are required because every list read reaches them unguarded:
|
||||
* use-mobile-source-control-state.ts:122 sections by `entry.area`, mobile-git-status.ts:63 sorts on
|
||||
* `entry.path`, and MOBILE_GIT_STATUS_LABELS is indexed by `entry.status`. The rest are optional in
|
||||
* the host type and read through a guard or a default, so they stay optional here.
|
||||
*/
|
||||
export const gitStatusEntrySchema = z.looseObject({
|
||||
path: z.string(),
|
||||
status: z.enum(GIT_FILE_STATUS),
|
||||
area: z.enum(GIT_STAGING_AREA),
|
||||
oldPath: z.string().optional(),
|
||||
conflictKind: z.enum(GIT_CONFLICT_KIND).optional(),
|
||||
conflictStatus: z.enum(GIT_CONFLICT_STATUS).optional(),
|
||||
conflictStatusSource: z.enum(GIT_CONFLICT_SOURCE).optional(),
|
||||
added: z.number().optional(),
|
||||
removed: z.number().optional()
|
||||
})
|
||||
|
||||
export const gitUpstreamStatusSchema = z.looseObject({
|
||||
hasUpstream: z.boolean(),
|
||||
ahead: z.number(),
|
||||
behind: z.number(),
|
||||
upstreamName: z.string().optional(),
|
||||
hasConfiguredPushTarget: z.boolean().optional(),
|
||||
behindCommitsArePatchEquivalent: z.boolean().optional()
|
||||
})
|
||||
|
||||
/**
|
||||
* The verbatim host payload the Changes screen publishes into its ready state.
|
||||
*
|
||||
* Only `entries` is required: mobile-hosted-review-create-intent.ts:70 reads
|
||||
* `status?.entries.some(...)`, which throws on a status object without it. Every other consumer
|
||||
* optional-chains — MobileSourceControlPanel.tsx:126/274, use-mobile-source-control-state.ts:163.
|
||||
* A salvaging array so one unreadable row drops instead of failing the whole screen's reply, and
|
||||
* salvagedOptional so a malformed optional reads as absent rather than as an incompatible reply.
|
||||
*/
|
||||
export const gitStatusHostPayloadSchema = z.looseObject({
|
||||
entries: salvagingArray(gitStatusEntrySchema),
|
||||
conflictOperation: salvagedOptional('conflictOperation', z.enum(GIT_CONFLICT_OPERATION)),
|
||||
branch: salvagedOptional('branch', z.string()),
|
||||
head: salvagedOptional('head', z.string()),
|
||||
upstreamStatus: salvagedOptional('upstreamStatus', gitUpstreamStatusSchema)
|
||||
})
|
||||
|
||||
export type MobileGitStatusEntry = z.output<typeof gitStatusEntrySchema>
|
||||
export type MobileGitStatusHostPayload = z.output<typeof gitStatusHostPayloadSchema>
|
||||
export type MobileGitUpstreamStatus = z.output<typeof gitUpstreamStatusSchema>
|
||||
type MobileProjectedUpstreamStatus = z.output<typeof projectedUpstreamStatusSchema>
|
||||
type GitConflictOperation = (typeof GIT_CONFLICT_OPERATION)[number]
|
||||
|
||||
/** What readMobileGitStatusResult publishes: the host payload narrowed to five members. */
|
||||
export type MobileGitStatusProjection = {
|
||||
entries: MobileGitStatusEntry[]
|
||||
conflictOperation: GitConflictOperation
|
||||
branch: string | undefined
|
||||
head: string | undefined
|
||||
upstreamStatus: MobileProjectedUpstreamStatus | undefined
|
||||
}
|
||||
|
||||
// The projection's own shapes: the fields readMobileGitStatusResult kept, nothing else, and every
|
||||
// one of them written out even when absent. Not loose, and not zod's own optional-key omission —
|
||||
// main built these objects by hand, so an absent member is a present `undefined`, which is what
|
||||
// the recorded projections carry.
|
||||
const projectedUpstreamStatusSchema = z
|
||||
.object({
|
||||
hasUpstream: z.boolean(),
|
||||
ahead: z.number().finite(),
|
||||
behind: z.number().finite(),
|
||||
upstreamName: salvagedOptional('upstreamName', z.string()),
|
||||
hasConfiguredPushTarget: salvagedOptional('hasConfiguredPushTarget', z.boolean()),
|
||||
behindCommitsArePatchEquivalent: salvagedOptional(
|
||||
'behindCommitsArePatchEquivalent',
|
||||
z.boolean()
|
||||
)
|
||||
})
|
||||
.transform((value) => ({
|
||||
hasUpstream: value.hasUpstream,
|
||||
upstreamName: value.upstreamName,
|
||||
ahead: value.ahead,
|
||||
behind: value.behind,
|
||||
hasConfiguredPushTarget: value.hasConfiguredPushTarget,
|
||||
behindCommitsArePatchEquivalent: value.behindCommitsArePatchEquivalent
|
||||
}))
|
||||
|
||||
const projectedEntrySchema = z.object({
|
||||
// `.min(1)` because main's `!path` drop is falsy, not nullish: an empty path was never a row.
|
||||
path: z.string().min(1),
|
||||
status: z.enum(GIT_FILE_STATUS),
|
||||
area: z.enum(GIT_STAGING_AREA),
|
||||
oldPath: salvagedOptional('oldPath', z.string()),
|
||||
conflictStatus: salvagedOptional('conflictStatus', z.enum(GIT_CONFLICT_STATUS)),
|
||||
conflictStatusSource: salvagedOptional('conflictStatusSource', z.enum(GIT_CONFLICT_SOURCE)),
|
||||
added: salvagedOptional('added', z.number().finite()),
|
||||
removed: salvagedOptional('removed', z.number().finite())
|
||||
})
|
||||
|
||||
/**
|
||||
* The normalized projection hosted-review preparation and the diff-review loaders read.
|
||||
*
|
||||
* `.catch(null)` keeps this reader's verdict exactly where main put it: a payload that is not a
|
||||
* record, or whose `entries` is not an array, is a decoded `null`, not an incompatible reply.
|
||||
* Three call sites route on that null — a refused status must leave their screens alone — so
|
||||
* tightening it is a product decision with its own expectation, not part of this step.
|
||||
* The gain here is the salvage report: a dropped row now names its index instead of vanishing.
|
||||
*/
|
||||
export const gitStatusProjectionSchema: z.ZodType<MobileGitStatusProjection | null, unknown> = z
|
||||
.object({
|
||||
entries: salvagingArray(projectedEntrySchema),
|
||||
conflictOperation: z.unknown().optional(),
|
||||
branch: salvagedOptional('branch', z.string()),
|
||||
head: salvagedOptional('head', z.string()),
|
||||
upstreamStatus: salvagedOptional('upstreamStatus', projectedUpstreamStatusSchema)
|
||||
})
|
||||
.transform((value): MobileGitStatusProjection => ({
|
||||
entries: value.entries.map((entry) => ({
|
||||
path: entry.path,
|
||||
status: entry.status,
|
||||
area: entry.area,
|
||||
oldPath: entry.oldPath,
|
||||
// Never projected: main dropped the host's conflictKind and stamped undefined instead.
|
||||
conflictKind: undefined,
|
||||
conflictStatus: entry.conflictStatus,
|
||||
conflictStatusSource: entry.conflictStatusSource,
|
||||
added: entry.added,
|
||||
removed: entry.removed
|
||||
})),
|
||||
conflictOperation: readProjectedConflictOperation(value.conflictOperation),
|
||||
branch: value.branch,
|
||||
head: value.head,
|
||||
upstreamStatus: value.upstreamStatus
|
||||
}))
|
||||
.nullable()
|
||||
.catch(null)
|
||||
|
||||
// Main coerced an unreadable operation to 'unknown' rather than dropping the reply; four screens
|
||||
// render off that value, so the coercion is the behaviour, not a defect.
|
||||
function readProjectedConflictOperation(value: unknown): GitConflictOperation {
|
||||
const parsed = z.enum(GIT_CONFLICT_OPERATION).safeParse(value)
|
||||
return parsed.success ? parsed.data : 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { z } from 'zod'
|
||||
import { salvagedOptional } from '../../../src/shared/zod-salvage'
|
||||
|
||||
// `hostedReview.getCreationEligibility` and `hostedReview.create`. Checked against
|
||||
// HostedReviewCreationEligibility and CreateHostedReviewResult in src/shared/hosted-review.ts,
|
||||
// which src/main/runtime/rpc/methods/hosted-review.ts:31-57 returns from the runtime service
|
||||
// verbatim. Both answer in-band, so an accepted reply can still say no.
|
||||
|
||||
const HOSTED_REVIEW_PROVIDER = [
|
||||
'github',
|
||||
'gitlab',
|
||||
'bitbucket',
|
||||
'azure-devops',
|
||||
'gitea',
|
||||
'unsupported'
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Creation eligibility.
|
||||
*
|
||||
* `provider` is the one required member: mobile-hosted-review-service.ts:120 puts it in the
|
||||
* prefill's required `provider` slot, and hostedReviewCopy() routes the whole compose form's copy
|
||||
* off it. Everything on :121-126 is read through `||` or lands in an optional prefill slot.
|
||||
*
|
||||
* The three routing tokens are strings, not the shared type's closed unions, because the host's
|
||||
* vocabulary is already wider than the type: the recorded `sc-eligibility-fetched` reply carries
|
||||
* `reviewLookupOutcome: 'none'`, which HostedReviewLookupOutcome does not list. Mobile only ever
|
||||
* compares them to a handful of literals, so passing an unrecognised token through is both what
|
||||
* main did and what keeps a newer host from blocking create on this screen.
|
||||
*/
|
||||
export const hostedReviewEligibilitySchema = z.looseObject({
|
||||
provider: z.enum(HOSTED_REVIEW_PROVIDER),
|
||||
canCreate: salvagedOptional('canCreate', z.boolean()),
|
||||
blockedReason: salvagedOptional('blockedReason', z.string().nullable()),
|
||||
nextAction: salvagedOptional('nextAction', z.string().nullable()),
|
||||
reviewLookupOutcome: salvagedOptional('reviewLookupOutcome', z.string()),
|
||||
defaultBaseRef: salvagedOptional('defaultBaseRef', z.string().nullable()),
|
||||
title: salvagedOptional('title', z.string().nullable()),
|
||||
body: salvagedOptional('body', z.string().nullable())
|
||||
})
|
||||
|
||||
const hostedReviewSummarySchema = z.looseObject({ url: z.string(), number: z.number().optional() })
|
||||
|
||||
/**
|
||||
* Creation outcome, declared as the host's own two arms.
|
||||
*
|
||||
* The success arm requires `number` and `url` because :262 hands both to the link step, which
|
||||
* writes `number` into worktree metadata. The failure arm requires `error`: :205 returns it as the
|
||||
* form's message and :208 calls `.replace` on it, which is where main threw a TypeError on a
|
||||
* refusal that omitted it. `existingReview` is probed with `?.` on :264, so it stays optional.
|
||||
*/
|
||||
export const hostedReviewCreateOkSchema = z.looseObject({
|
||||
ok: z.literal(true),
|
||||
number: z.number(),
|
||||
url: z.string()
|
||||
})
|
||||
|
||||
export const hostedReviewCreateFailedSchema = z.looseObject({
|
||||
ok: z.literal(false),
|
||||
error: z.string(),
|
||||
code: z.string().optional(),
|
||||
existingReview: salvagedOptional('existingReview', hostedReviewSummarySchema)
|
||||
})
|
||||
|
||||
export type MobileHostedReviewEligibilityReply = z.output<typeof hostedReviewEligibilitySchema>
|
||||
export type MobileHostedReviewCreateOk = z.output<typeof hostedReviewCreateOkSchema>
|
||||
export type MobileHostedReviewCreateFailed = z.output<typeof hostedReviewCreateFailedSchema>
|
||||
@@ -1,12 +1,13 @@
|
||||
import type {
|
||||
GitBranchChangeEntry,
|
||||
GitBranchCompareResult,
|
||||
GitBranchCompareSummary
|
||||
} from '../../../src/shared/git-diff-compare-types'
|
||||
MobileGitBranchChangeEntry,
|
||||
MobileGitBranchCompareReply,
|
||||
MobileGitBranchCompareSummary
|
||||
} from './git-compare-reply-schema'
|
||||
|
||||
export type MobileGitBranchChangeEntry = GitBranchChangeEntry
|
||||
export type MobileGitBranchCompareSummary = GitBranchCompareSummary
|
||||
export type MobileGitBranchCompareResult = GitBranchCompareResult
|
||||
// The shapes mobile reads off the compare replies are the reply schemas' outputs, not the desktop
|
||||
// aggregates: a member with no reader in mobile/ is stripped rather than re-declared here.
|
||||
export type { MobileGitBranchChangeEntry, MobileGitBranchCompareSummary }
|
||||
export type MobileGitBranchCompareResult = MobileGitBranchCompareReply
|
||||
|
||||
export type MobileBranchCompareSection<
|
||||
TEntry extends MobileGitBranchChangeEntry = MobileGitBranchChangeEntry
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
|
||||
import type { MobileHostedReviewEligibilityReply } from './hosted-review-reply-schema'
|
||||
import { supportsHostedReviewCreation } from '../../../src/shared/hosted-review-creation-providers'
|
||||
import { hostedReviewCopy } from './hosted-review-copy'
|
||||
import { getMobilePrCreateBlockMessage } from './mobile-pr-create'
|
||||
|
||||
export type MobileCreatePrEligibilityState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading'; eligibility: HostedReviewCreationEligibility | null }
|
||||
| { kind: 'ready'; eligibility: HostedReviewCreationEligibility }
|
||||
| { kind: 'loading'; eligibility: MobileHostedReviewEligibilityReply | null }
|
||||
| { kind: 'ready'; eligibility: MobileHostedReviewEligibilityReply }
|
||||
| { kind: 'error' }
|
||||
|
||||
export type MobileCreatePrAction = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitHistoryItem, GitHistoryResult } from '../../../src/shared/git-history-types'
|
||||
import type { MobileGitHistoryItem, MobileGitHistoryResult } from './git-history-reply-schema'
|
||||
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
|
||||
import { gitHistoryRead } from './mobile-git-read-operations'
|
||||
import type { RpcOperationSender } from '../transport/rpc-operation-sender'
|
||||
@@ -14,7 +14,7 @@ export type MobileCommitRow = {
|
||||
|
||||
// Short relative time for a commit list (just now / Xm / Xh / Xd / Xmo / Xy).
|
||||
// `timestampMs` is epoch ms, the unit GitHistoryItem.timestamp already carries.
|
||||
export function formatCommitTime(timestampMs: number | undefined, nowMs: number): string {
|
||||
export function formatCommitTime(timestampMs: number | null | undefined, nowMs: number): string {
|
||||
// Nullish — not falsy — so a real epoch-0 timestamp still formats.
|
||||
if (timestampMs == null) {
|
||||
return ''
|
||||
@@ -42,7 +42,7 @@ export function formatCommitTime(timestampMs: number | undefined, nowMs: number)
|
||||
return `${Math.floor(months / 12)}y`
|
||||
}
|
||||
|
||||
export function toMobileCommitRow(item: GitHistoryItem, nowMs: number): MobileCommitRow {
|
||||
export function toMobileCommitRow(item: MobileGitHistoryItem, nowMs: number): MobileCommitRow {
|
||||
return {
|
||||
id: item.id,
|
||||
shortId: item.displayId ?? item.id.slice(0, 7),
|
||||
@@ -53,7 +53,10 @@ export function toMobileCommitRow(item: GitHistoryItem, nowMs: number): MobileCo
|
||||
}
|
||||
}
|
||||
|
||||
export function mapMobileCommitRows(result: GitHistoryResult, nowMs: number): MobileCommitRow[] {
|
||||
export function mapMobileCommitRows(
|
||||
result: MobileGitHistoryResult,
|
||||
nowMs: number
|
||||
): MobileCommitRow[] {
|
||||
return result.items.map((item) => toMobileCommitRow(item, nowMs))
|
||||
}
|
||||
|
||||
@@ -61,12 +64,11 @@ export async function fetchMobileGitHistory(
|
||||
client: RpcOperationSender,
|
||||
worktreeId: string,
|
||||
limit = 50
|
||||
): Promise<GitHistoryResult> {
|
||||
): Promise<MobileGitHistoryResult> {
|
||||
// Not inside the try: a transport rejection must reach the caller as the original error object.
|
||||
const reply = await gitHistoryRead.request(client, { worktree: `id:${worktreeId}`, limit })
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
return gitHistoryRead.interpret(reply) as GitHistoryResult
|
||||
return gitHistoryRead.interpret(reply)
|
||||
} catch (error) {
|
||||
throw new Error(refusedRpcMessageOrFallback(error, 'Failed to load commit history'))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
|
||||
import { rpcReadUnchecked, rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload'
|
||||
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
|
||||
|
||||
// Mirrors the host GenerateCommitMessageResult (src/main/text-generation/
|
||||
// commit-message-text-generation.ts) — a single resolved result, not a stream.
|
||||
@@ -11,32 +11,40 @@ export type MobileGenerateCommitMessageResult =
|
||||
// Host-state changes. A lost reply here is unknown, never failed: none of these operations
|
||||
// interprets a transport rejection, so the delivery-unknown marker reaches the caller intact.
|
||||
|
||||
/** Exactly `result?.key`, so a null or absent commit payload reads as absent, not as a throw. */
|
||||
function optionalPayloadMember(raw: unknown, key: string): unknown {
|
||||
return raw == null ? undefined : Object(raw)[key]
|
||||
}
|
||||
|
||||
const gitCommitOutcomeReader: RpcCompatibleReader<
|
||||
unknown,
|
||||
'commit-outcome',
|
||||
{ success: unknown; error: unknown }
|
||||
> = (raw) =>
|
||||
rpcReadUnchecked('commit-outcome', {
|
||||
success: optionalPayloadMember(raw, 'success'),
|
||||
error: optionalPayloadMember(raw, 'error')
|
||||
/**
|
||||
* git.commit answers in-band: an accepted reply can still carry `success: false`.
|
||||
*
|
||||
* Nullish and every member optional because that is exactly what the consumer tolerates —
|
||||
* mobile-hosted-review-git-preparation.ts:107 compares `outcome.success === true` and :109 passes
|
||||
* `outcome.error` through hostReplyErrorTextOrFallback, which already reads a non-string as absent.
|
||||
* An absent or null payload stays a failed commit carrying the screen's copy, which is main's
|
||||
* documented contract for this reply ("reads as absent, not as a throw"), not an accident.
|
||||
* A present but non-boolean `success` is a malformed reply and now says so.
|
||||
*/
|
||||
const gitCommitOutcomeSchema = z
|
||||
.object({
|
||||
success: z.boolean().optional(),
|
||||
error: z.unknown().optional()
|
||||
})
|
||||
|
||||
/** git.commit answers in-band: an accepted reply can still carry `success: false`. */
|
||||
.nullish()
|
||||
.transform((value) => ({ success: value?.success, error: value?.error }))
|
||||
|
||||
export const gitCommitRun = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'git.commit-staged',
|
||||
method: 'git.commit',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: gitCommitOutcomeReader
|
||||
read: rpcResultVariant('commit-outcome', gitCommitOutcomeSchema)
|
||||
})
|
||||
)
|
||||
|
||||
// Three replies with no reader anywhere in mobile: the caller needs acceptance and nothing else.
|
||||
// `z.unknown()` is the honest schema for that, not a holdout — there is no member to require, and
|
||||
// requiring a shape mobile never looks at would reject hosts for no gain.
|
||||
const unreadPayload = z.unknown()
|
||||
|
||||
/** Publish, push and force-with-lease are one operation; only the params differ. */
|
||||
export const gitPushRun = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
@@ -44,7 +52,7 @@ export const gitPushRun = bindDeferredRpcOperation(
|
||||
method: 'git.push',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('push-accepted')
|
||||
read: rpcResultVariant('push-accepted', unreadPayload)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -54,37 +62,64 @@ export const gitBulkStageRun = bindDeferredRpcOperation(
|
||||
method: 'git.bulkStage',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('stage-accepted')
|
||||
read: rpcResultVariant('stage-accepted', unreadPayload)
|
||||
})
|
||||
)
|
||||
|
||||
const GENERATE_FAILED = 'Failed to generate commit message'
|
||||
|
||||
// Normalizes the host GenerateCommitMessageResult into the discriminated result the UI switches
|
||||
// on. A malformed `{ success:false }` could leave `error` undefined, which breaks that contract,
|
||||
// so the message is always coerced to a non-empty string.
|
||||
const generatedCommitMessageReader: RpcCompatibleReader<
|
||||
unknown,
|
||||
'generated-commit-message',
|
||||
MobileGenerateCommitMessageResult
|
||||
> = (raw) => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return rpcReadUnchecked('generated-commit-message', { success: false, error: GENERATE_FAILED })
|
||||
}
|
||||
const result: { success?: unknown; message?: unknown; error?: unknown; canceled?: unknown } = raw
|
||||
if (result.success === true && typeof result.message === 'string' && result.message.length > 0) {
|
||||
return rpcReadUnchecked('generated-commit-message', { success: true, message: result.message })
|
||||
}
|
||||
const hostError =
|
||||
result.success === false && typeof result.error === 'string' && result.error.length > 0
|
||||
? result.error
|
||||
: 'No commit message generated'
|
||||
return rpcReadUnchecked('generated-commit-message', {
|
||||
success: false,
|
||||
error: hostError,
|
||||
...(result.success === false && result.canceled ? { canceled: true } : {})
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Normalizes the host GenerateCommitMessageResult into the discriminated result the UI switches on.
|
||||
*
|
||||
* Always compatible by construction: every shape maps to a declared outcome, because a malformed
|
||||
* reply here must show the screen's copy rather than a decode error in a commit-message field.
|
||||
* The arms are main's four branches in main's order, including the one the recorded
|
||||
* `sc-commit-message-canceled` scenario takes — `{ success: false, error: '', canceled: true }`
|
||||
* keeps its cancel mark while its empty error falls back to the screen's copy.
|
||||
*/
|
||||
const NO_MESSAGE_GENERATED = 'No commit message generated'
|
||||
|
||||
const generatedCommitMessageSchema: z.ZodType<MobileGenerateCommitMessageResult, unknown> = z
|
||||
.union([
|
||||
z
|
||||
.object({ success: z.literal(true), message: z.string().min(1) })
|
||||
|
||||
.transform((value): MobileGenerateCommitMessageResult => ({
|
||||
success: true,
|
||||
message: value.message
|
||||
})),
|
||||
z
|
||||
.object({
|
||||
success: z.literal(false),
|
||||
error: z.string().min(1),
|
||||
canceled: z.unknown().optional()
|
||||
})
|
||||
|
||||
.transform((value): MobileGenerateCommitMessageResult => ({
|
||||
success: false,
|
||||
error: value.error,
|
||||
...(value.canceled ? { canceled: true } : {})
|
||||
})),
|
||||
z
|
||||
.object({ success: z.literal(false), canceled: z.unknown().optional() })
|
||||
|
||||
.transform((value): MobileGenerateCommitMessageResult => ({
|
||||
success: false,
|
||||
error: NO_MESSAGE_GENERATED,
|
||||
...(value.canceled ? { canceled: true } : {})
|
||||
})),
|
||||
// Main split its fallback in two: a non-object reply says the generation failed, while an
|
||||
// object it could not read says none was generated. `typeof null === 'object'` is why the
|
||||
// guard is falsy rather than nullish.
|
||||
z
|
||||
.unknown()
|
||||
.refine((value) => !value || typeof value !== 'object')
|
||||
.transform((): MobileGenerateCommitMessageResult => ({
|
||||
success: false,
|
||||
error: GENERATE_FAILED
|
||||
}))
|
||||
])
|
||||
.catch({ success: false, error: NO_MESSAGE_GENERATED })
|
||||
|
||||
export const gitGenerateCommitMessageRun = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
@@ -92,7 +127,7 @@ export const gitGenerateCommitMessageRun = bindDeferredRpcOperation(
|
||||
method: 'git.generateCommitMessage',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: generatedCommitMessageReader
|
||||
read: rpcResultVariant('generated-commit-message', generatedCommitMessageSchema)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -103,6 +138,6 @@ export const gitCancelGenerateCommitMessageRun = bindDeferredRpcOperation(
|
||||
method: 'git.cancelGenerateCommitMessage',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('cancel-accepted')
|
||||
read: rpcResultVariant('cancel-accepted', unreadPayload)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
|
||||
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
|
||||
import {
|
||||
rpcUncheckedMemberReader,
|
||||
rpcUncheckedPayloadReader
|
||||
} from '../transport/rpc-reader-payload'
|
||||
import { readMobileGitStatusResult } from '../session/mobile-diff-review-rpc'
|
||||
gitBranchCompareResultSchema,
|
||||
gitCommitCompareResultSchema,
|
||||
gitDiffResultSchema
|
||||
} from './git-compare-reply-schema'
|
||||
import { gitHistoryResultSchema } from './git-history-reply-schema'
|
||||
import { gitStatusHostPayloadSchema, gitStatusProjectionSchema } from './git-status-reply-schema'
|
||||
import type { MobileGitStatusResult } from './mobile-git-status'
|
||||
|
||||
// Source-control reads. Every one of these replies used to be re-typed with a cast at the call
|
||||
// site; the reader below is now the only place that says what the payload is.
|
||||
// Source-control reads. Every reply below is validated against the members its consumer actually
|
||||
// reads; the schema module beside each one records which consumer line justifies each requirement.
|
||||
|
||||
/**
|
||||
* git.status, first of two readers. The Changes screen publishes the host payload verbatim.
|
||||
@@ -25,21 +28,22 @@ export const gitStatusHostPayloadRead = bindDeferredRpcOperation(
|
||||
method: 'git.status',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('host-status-payload')
|
||||
read: rpcResultVariant('host-status-payload', gitStatusHostPayloadSchema)
|
||||
})
|
||||
)
|
||||
|
||||
/** Shared with the session's branch-context read, which wants the same projection under a skip. */
|
||||
/**
|
||||
* Shared with the session's branch-context read, which wants the same projection under a skip.
|
||||
*
|
||||
* Still always compatible: the projection's own contract is that an unreadable payload is a null
|
||||
* status, which three screens route on. What the schema adds is the salvage report — a dropped
|
||||
* entry now arrives as `salvage.droppedPaths` instead of silently thinning the list.
|
||||
*/
|
||||
export const gitStatusProjectionReader: RpcCompatibleReader<
|
||||
unknown,
|
||||
'normalized-status',
|
||||
MobileGitStatusResult | null
|
||||
> = (raw) => ({
|
||||
compatible: true,
|
||||
variant: 'normalized-status',
|
||||
value: readMobileGitStatusResult(raw),
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
})
|
||||
> = rpcResultVariant('normalized-status', gitStatusProjectionSchema)
|
||||
|
||||
/** git.status, second reader: the normalized projection hosted-review preparation reads. */
|
||||
export const gitStatusProjectionRead = bindDeferredRpcOperation(
|
||||
@@ -58,14 +62,14 @@ export const gitHistoryRead = bindDeferredRpcOperation(
|
||||
method: 'git.history',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('history-page')
|
||||
read: rpcResultVariant('history-page', gitHistoryResultSchema)
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* A refused compare leaves the row's file list untouched, so refusal is a skip, not a throw. The
|
||||
* member read keeps the property-read exception a null result throws, which is what leaves an
|
||||
* already-loaded file list alone.
|
||||
* A refused compare leaves the row's file list untouched, so refusal is a skip, not a throw. A
|
||||
* reply that carries no readable `entries` is now an incompatible reply rather than an undefined
|
||||
* list: the row's `.catch` resolves it to "No file changes" instead of spinning forever.
|
||||
*/
|
||||
export const gitCommitCompareRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
@@ -73,7 +77,7 @@ export const gitCommitCompareRead = bindDeferredRpcOperation(
|
||||
method: 'git.commitCompare',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedMemberReader('commit-compare-entries', 'entries')
|
||||
read: rpcResultVariant('commit-compare', gitCommitCompareResultSchema)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -83,7 +87,7 @@ export const gitBranchCompareRead = bindDeferredRpcOperation(
|
||||
method: 'git.branchCompare',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('branch-compare')
|
||||
read: rpcResultVariant('branch-compare', gitBranchCompareResultSchema)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -93,6 +97,6 @@ export const gitBranchDiffRead = bindDeferredRpcOperation(
|
||||
method: 'git.branchDiff',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('branch-diff')
|
||||
read: rpcResultVariant('branch-diff', gitDiffResultSchema)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import type {
|
||||
GitFileStatus,
|
||||
GitStagingArea,
|
||||
GitStatusEntry,
|
||||
GitStatusResult,
|
||||
GitUpstreamStatus
|
||||
} from '../../../src/shared/git-status-types'
|
||||
import type { GitFileStatus, GitStagingArea } from '../../../src/shared/git-status-types'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import type {
|
||||
MobileGitStatusEntry,
|
||||
MobileGitStatusHostPayload,
|
||||
MobileGitUpstreamStatus
|
||||
} from './git-status-reply-schema'
|
||||
|
||||
export type MobileGitFileStatus = GitFileStatus
|
||||
export type MobileGitStagingArea = GitStagingArea
|
||||
export type MobileGitStatusEntry = GitStatusEntry
|
||||
export type MobileGitUpstreamStatus = GitUpstreamStatus
|
||||
export type MobileGitStatusResult = GitStatusResult
|
||||
export type { MobileGitStatusEntry, MobileGitUpstreamStatus }
|
||||
|
||||
// The shape mobile reads off `git.status`, which is the reply schema's output, not the desktop
|
||||
// aggregate: every member mobile does not read is stripped rather than re-declared here.
|
||||
export type MobileGitStatusResult = MobileGitStatusHostPayload
|
||||
|
||||
export type MobileSourceControlSection<TEntry extends MobileGitStatusEntry = MobileGitStatusEntry> =
|
||||
{
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload'
|
||||
import { rpcResultVariant, rpcResultVariants } from '../transport/rpc-operation-result-reader'
|
||||
import {
|
||||
hostedReviewCreateFailedSchema,
|
||||
hostedReviewCreateOkSchema,
|
||||
hostedReviewEligibilitySchema,
|
||||
type MobileHostedReviewCreateFailed,
|
||||
type MobileHostedReviewCreateOk
|
||||
} from './hosted-review-reply-schema'
|
||||
|
||||
export type MobileHostedReviewCreateReply =
|
||||
| MobileHostedReviewCreateOk
|
||||
| MobileHostedReviewCreateFailed
|
||||
|
||||
/**
|
||||
* Eligibility is advisory: when the host cannot answer, mobile fails closed on its own rather
|
||||
@@ -11,17 +22,25 @@ export const hostedReviewEligibilityRead = bindDeferredRpcOperation(
|
||||
method: 'hostedReview.getCreationEligibility',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('creation-eligibility')
|
||||
read: rpcResultVariant('creation-eligibility', hostedReviewEligibilitySchema)
|
||||
})
|
||||
)
|
||||
|
||||
/** Creation answers in-band too: an accepted reply can carry `ok: false` plus an existing review. */
|
||||
/**
|
||||
* Creation answers in-band too: an accepted reply can carry `ok: false` plus an existing review.
|
||||
* Two variants rather than one schema because the host's own result is a discriminated union and
|
||||
* the arms require different members; the success arm is declared first so a reply carrying both
|
||||
* `ok: true` and a stray `error` reads as the success it is.
|
||||
*/
|
||||
export const hostedReviewCreateRun = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
name: 'hostedReview.create',
|
||||
method: 'hostedReview.create',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('create-result')
|
||||
read: rpcResultVariants<'create-succeeded' | 'create-refused', MobileHostedReviewCreateReply>([
|
||||
rpcResultVariant('create-succeeded', hostedReviewCreateOkSchema),
|
||||
rpcResultVariant('create-refused', hostedReviewCreateFailedSchema)
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import type {
|
||||
CreateHostedReviewResult,
|
||||
HostedReviewCreationBlockedReason,
|
||||
HostedReviewCreationEligibility,
|
||||
HostedReviewCreationNextAction,
|
||||
HostedReviewLookupOutcome,
|
||||
HostedReviewProvider
|
||||
} from '../../../src/shared/hosted-review'
|
||||
import type { HostedReviewProvider } from '../../../src/shared/hosted-review'
|
||||
import type { MobileHostedReviewEligibilityReply } from './hosted-review-reply-schema'
|
||||
import type { RpcSendParams } from '../transport/rpc-params-contract'
|
||||
import { refusedRpcMessageOrFallback } from '../transport/rpc-refusal-message'
|
||||
import { hostedReviewCopy } from './hosted-review-copy'
|
||||
import {
|
||||
hostedReviewCreateRun,
|
||||
hostedReviewEligibilityRead
|
||||
hostedReviewEligibilityRead,
|
||||
type MobileHostedReviewCreateReply
|
||||
} from './mobile-hosted-review-operations'
|
||||
import { pushMobileHostedReviewBranch } from './mobile-hosted-review-git-preparation'
|
||||
import { linkMobileHostedReview } from './mobile-pr-link'
|
||||
@@ -40,7 +35,7 @@ export async function fetchMobileHostedReviewEligibility(
|
||||
client: RpcOperationSender,
|
||||
worktreeId: string,
|
||||
input: MobileHostedReviewEligibilityInput
|
||||
): Promise<HostedReviewCreationEligibility | null> {
|
||||
): Promise<MobileHostedReviewEligibilityReply | null> {
|
||||
const reply = await hostedReviewEligibilityRead.request(client, {
|
||||
repo: mobileRepoSelectorFromWorktreeId(worktreeId),
|
||||
worktree: `id:${worktreeId}`,
|
||||
@@ -56,8 +51,7 @@ export async function fetchMobileHostedReviewEligibility(
|
||||
linkedGitLabMR: input.linkedGitLabMR ?? null
|
||||
})
|
||||
const eligibility = hostedReviewEligibilityRead.interpret(reply)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
return eligibility.accepted ? (eligibility.value as HostedReviewCreationEligibility) : null
|
||||
return eligibility.accepted ? eligibility.value : null
|
||||
}
|
||||
|
||||
export type MobileHostedReviewPrefill = {
|
||||
@@ -66,12 +60,14 @@ export type MobileHostedReviewPrefill = {
|
||||
title: string
|
||||
body: string
|
||||
canCreate?: boolean
|
||||
blockedReason?: HostedReviewCreationBlockedReason
|
||||
nextAction?: HostedReviewCreationNextAction
|
||||
// Strings, not the shared closed unions: the host publishes tokens those unions do not list, and
|
||||
// mobile only compares them to the handful it acts on. See hosted-review-reply-schema.ts.
|
||||
blockedReason?: string | null
|
||||
nextAction?: string | null
|
||||
// Why: mobile lacks the desktop refresh/review-lookup signals, so it fails
|
||||
// closed on ambiguity. When the host could not prove the branch has no review
|
||||
// (`unavailable`), create — including the Push & Create path — stays blocked.
|
||||
reviewLookupOutcome?: HostedReviewLookupOutcome
|
||||
reviewLookupOutcome?: string
|
||||
}
|
||||
|
||||
// Resolve the mobile compose prefill from the same hosted-review eligibility
|
||||
@@ -194,7 +190,7 @@ async function pushMobileBranchBeforeCreate(
|
||||
}
|
||||
|
||||
function formatMobileHostedReviewCreateError(
|
||||
result: CreateHostedReviewResult,
|
||||
result: MobileHostedReviewCreateReply,
|
||||
pushed: boolean,
|
||||
shortLabel: string
|
||||
): string {
|
||||
@@ -248,10 +244,9 @@ export async function createMobileHostedReview(
|
||||
client,
|
||||
buildMobileHostedReviewCreateParams(worktreeId, input)
|
||||
)
|
||||
let result: CreateHostedReviewResult
|
||||
let result: MobileHostedReviewCreateReply
|
||||
try {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
result = hostedReviewCreateRun.interpret(reply) as CreateHostedReviewResult
|
||||
result = hostedReviewCreateRun.interpret(reply)
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
@@ -16,21 +16,21 @@ import type { MobileDiffLine } from '../session/mobile-diff-lines'
|
||||
import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
|
||||
import type {
|
||||
MobileGitBranchChangeEntry,
|
||||
MobileGitBranchCompareResult,
|
||||
MobileGitBranchCompareSummary
|
||||
} from './mobile-branch-compare'
|
||||
MobileGitBranchCompareReply
|
||||
} from './git-compare-reply-schema'
|
||||
import type { MobileGitBranchCompareSummary } from './mobile-branch-compare'
|
||||
import type { MobileGitStatusHostPayload } from './git-status-reply-schema'
|
||||
import {
|
||||
canOpenMobileGitStatusEntry,
|
||||
isMobileGitDiscardableEntry,
|
||||
isMobileGitStageableEntry,
|
||||
type MobileGitFileStatus,
|
||||
type MobileGitStatusEntry,
|
||||
type MobileGitStatusResult
|
||||
type MobileGitStatusEntry
|
||||
} from './mobile-git-status'
|
||||
|
||||
export type ScreenState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; status: MobileGitStatusResult }
|
||||
| { kind: 'ready'; status: MobileGitStatusHostPayload }
|
||||
| { kind: 'unavailable'; message: string }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
@@ -77,7 +77,7 @@ export function buildMobileGitStatusEntryViews(
|
||||
export type MobileBranchCompareState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'ready'; result: MobileGitBranchCompareResult }
|
||||
| { kind: 'ready'; result: MobileGitBranchCompareReply }
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export type MobileBranchEntryView = MobileGitBranchChangeEntry & {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { z } from 'zod'
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
|
||||
import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload'
|
||||
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
|
||||
|
||||
// Opening a file from the Changes list. Neither reply's payload is read: the tab arrives over the
|
||||
// session stream, and the caller only needs to know the host accepted.
|
||||
// Opening a file from the Changes list. Neither open reply's payload is read: the tab arrives over
|
||||
// the session stream, and the caller only needs to know the host accepted. `z.unknown()` is the
|
||||
// honest schema for a payload with no reader, not a holdout.
|
||||
const unreadPayload = z.unknown()
|
||||
|
||||
export const sourceFileDiffOpenRun = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
@@ -11,7 +13,7 @@ export const sourceFileDiffOpenRun = bindDeferredRpcOperation(
|
||||
method: 'files.openDiff',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('diff-tab-opened')
|
||||
read: rpcResultVariant('diff-tab-opened', unreadPayload)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -22,40 +24,43 @@ export const sourceFileOpenRun = bindDeferredRpcOperation(
|
||||
method: 'files.open',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('edit-tab-opened')
|
||||
read: rpcResultVariant('edit-tab-opened', unreadPayload)
|
||||
})
|
||||
)
|
||||
|
||||
export type MobileSessionFileTabCandidate = {
|
||||
readonly id: string
|
||||
readonly type: string
|
||||
readonly mode?: unknown
|
||||
readonly relativePath?: unknown
|
||||
readonly diffSource?: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function isSessionFileTabCandidate(value: unknown): value is MobileSessionFileTabCandidate {
|
||||
return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string'
|
||||
}
|
||||
|
||||
const sessionFileTabsReader: RpcCompatibleReader<
|
||||
unknown,
|
||||
'session-file-tabs',
|
||||
{ tabs: MobileSessionFileTabCandidate[] } | null
|
||||
> = (raw) => ({
|
||||
compatible: true,
|
||||
variant: 'session-file-tabs',
|
||||
value:
|
||||
isRecord(raw) && Array.isArray(raw.tabs) && raw.tabs.every(isSessionFileTabCandidate)
|
||||
? { tabs: raw.tabs }
|
||||
: null,
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
/**
|
||||
* One candidate tab from `session.tabs.list`.
|
||||
*
|
||||
* `id` and `type` are required because the reveal filters on `tab.type` and returns the candidate
|
||||
* by identity (reveal-mobile-source-control-session-diff.ts:70). `mode`, `relativePath` and
|
||||
* `diffSource` stay `unknown`: :72-81 compares each to a literal or to null, so a host that sends
|
||||
* a shape mobile does not recognise must simply not match, never fail the list.
|
||||
*/
|
||||
const sessionFileTabSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
mode: z.unknown().optional(),
|
||||
relativePath: z.unknown().optional(),
|
||||
diffSource: z.unknown().optional()
|
||||
})
|
||||
|
||||
export type MobileSessionFileTabCandidate = z.output<typeof sessionFileTabSchema>
|
||||
|
||||
/**
|
||||
* The reveal polls this list, so an unreadable reply must read as "not yet", not as a failure:
|
||||
* :64 treats a null value exactly like a refusal and polls again. `.catch(null)` keeps that,
|
||||
* and it is also what main did — one bad tab in the array made the whole list null.
|
||||
*/
|
||||
const sessionFileTabListSchema: z.ZodType<
|
||||
{ tabs: MobileSessionFileTabCandidate[] } | null,
|
||||
unknown
|
||||
> = z
|
||||
.object({ tabs: z.array(sessionFileTabSchema) })
|
||||
|
||||
.transform((value) => ({ tabs: value.tabs }))
|
||||
.nullable()
|
||||
.catch(null)
|
||||
|
||||
/** A refused list means poll again, so refusal is a skip rather than the end of the reveal. */
|
||||
export const sessionFileTabListRead = bindDeferredRpcOperation(
|
||||
defineRpcOperation({
|
||||
@@ -63,6 +68,6 @@ export const sessionFileTabListRead = bindDeferredRpcOperation(
|
||||
method: 'session.tabs.list',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: sessionFileTabsReader
|
||||
read: rpcResultVariant('session-file-tabs', sessionFileTabListSchema)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
import { z } from 'zod'
|
||||
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
|
||||
import type { RpcCompatibleReader } from '../transport/rpc-operation-contract'
|
||||
import { rpcUncheckedPayloadReader } from '../transport/rpc-reader-payload'
|
||||
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
|
||||
import { worktreeSummaryReplySchema } from './worktree-metadata-reply-schema'
|
||||
|
||||
export type MobileWorktreeSummary = {
|
||||
readonly baseRef: string | null
|
||||
readonly linkedPR: number | null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* One reader for both worktree.show consumers. Branch compare read `worktree.baseRef` behind an
|
||||
* `isRecord` guard and the PR sidebar read `worktree.linkedPR` through optional chaining; both
|
||||
* yield null on the same inputs, so the fields merge without changing either answer.
|
||||
*
|
||||
* Total on purpose: both callers treat a missing summary as "no hint" and fall back to another
|
||||
* source, so a reply this cannot read is a null summary rather than an incompatible reply. The
|
||||
* schema is what turns a `baseRef` of the wrong type into that null instead of into a string the
|
||||
* branch-compare request would then send to the host.
|
||||
*/
|
||||
const worktreeSummaryReader: RpcCompatibleReader<
|
||||
unknown,
|
||||
'worktree-summary',
|
||||
MobileWorktreeSummary | null
|
||||
> = (raw) => {
|
||||
const worktree = isRecord(raw) ? raw.worktree : undefined
|
||||
return {
|
||||
compatible: true,
|
||||
variant: 'worktree-summary',
|
||||
value: isRecord(worktree)
|
||||
? {
|
||||
baseRef: typeof worktree.baseRef === 'string' ? worktree.baseRef : null,
|
||||
linkedPR: typeof worktree.linkedPR === 'number' ? worktree.linkedPR : null
|
||||
}
|
||||
: null,
|
||||
salvage: { droppedPaths: [], droppedCount: 0 }
|
||||
}
|
||||
}
|
||||
const worktreeSummarySchema: z.ZodType<MobileWorktreeSummary | null, unknown> =
|
||||
worktreeSummaryReplySchema
|
||||
.transform((value): MobileWorktreeSummary | null =>
|
||||
value.worktree
|
||||
? { baseRef: value.worktree.baseRef ?? null, linkedPR: value.worktree.linkedPR ?? null }
|
||||
: null
|
||||
)
|
||||
.catch(null)
|
||||
|
||||
/** A refused show is a missing hint, not a failure: both callers fall back to another source. */
|
||||
export const worktreeSummaryRead = bindDeferredRpcOperation(
|
||||
@@ -42,7 +34,7 @@ export const worktreeSummaryRead = bindDeferredRpcOperation(
|
||||
method: 'worktree.show',
|
||||
acceptance: 'success-result-or-skip',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: worktreeSummaryReader
|
||||
read: rpcResultVariant('worktree-summary', worktreeSummarySchema)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -53,6 +45,6 @@ export const worktreeLinkSet = bindDeferredRpcOperation(
|
||||
method: 'worktree.set',
|
||||
acceptance: 'require-result-or-throw-message',
|
||||
barrier: 'after-caller-barrier',
|
||||
read: rpcUncheckedPayloadReader('link-accepted')
|
||||
read: rpcResultVariant('link-accepted', z.unknown())
|
||||
})
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { HostedReviewCreationEligibility } from '../../../src/shared/hosted-review'
|
||||
import type { MobileHostedReviewEligibilityReply } from './hosted-review-reply-schema'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
@@ -144,7 +144,7 @@ export function useMobileHostedReviewEligibility(
|
||||
behind
|
||||
}
|
||||
void fetchMobileHostedReviewEligibility(client, worktreeId, requestInput)
|
||||
.then((eligibility: HostedReviewCreationEligibility | null) => {
|
||||
.then((eligibility: MobileHostedReviewEligibilityReply | null) => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8,10 +8,9 @@ import { gitBranchCompareRead, gitStatusHostPayloadRead } from './mobile-git-rea
|
||||
import {
|
||||
isMobileGitTransientRefreshError,
|
||||
isMobileGitUnavailableReply,
|
||||
readMobileGitRefusal,
|
||||
type MobileGitStatusResult
|
||||
readMobileGitRefusal
|
||||
} from './mobile-git-status'
|
||||
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
|
||||
import type { MobileGitBranchCompareReply } from './git-compare-reply-schema'
|
||||
import {
|
||||
SELECTOR_RETRY_COUNT,
|
||||
SELECTOR_RETRY_DELAY_MS,
|
||||
@@ -138,17 +137,13 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
|
||||
})
|
||||
return false
|
||||
}
|
||||
let compared: unknown
|
||||
let compared: MobileGitBranchCompareReply
|
||||
try {
|
||||
compared = gitBranchCompareRead.interpret(reply)
|
||||
} catch (error) {
|
||||
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load committed changes'))
|
||||
}
|
||||
setBranchCompareState({
|
||||
kind: 'ready',
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
result: compared as MobileGitBranchCompareResult
|
||||
})
|
||||
setBranchCompareState({ kind: 'ready', result: compared })
|
||||
return true
|
||||
} catch (err) {
|
||||
if (!isCurrentLoad()) {
|
||||
@@ -214,8 +209,7 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
|
||||
// refusal's code, which no acceptance policy carries through.
|
||||
const refusal = readMobileGitRefusal(reply)
|
||||
if (!refusal) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
const result = gitStatusHostPayloadRead.interpret(reply) as MobileGitStatusResult
|
||||
const result = gitStatusHostPayloadRead.interpret(reply)
|
||||
setScreenState({ kind: 'ready', status: result })
|
||||
void loadBranchCompare({ preserveReadyOnFailure: true })
|
||||
if (options?.clearActionErrorOnSuccess !== false) {
|
||||
|
||||
@@ -23,10 +23,10 @@ import { sourceFileDiffOpenRun, sourceFileOpenRun } from './mobile-source-file-o
|
||||
import { buildMobileReviewFileRoute } from './mobile-review-route'
|
||||
import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff'
|
||||
import type {
|
||||
GitDiffTextResult,
|
||||
MobileBranchCompareState,
|
||||
MobileBranchDiffPreviewState
|
||||
} from './mobile-source-control-screen-state'
|
||||
import type { MobileGitDiffReply } from './git-compare-reply-schema'
|
||||
|
||||
type Params = {
|
||||
client: RpcClient | null
|
||||
@@ -253,14 +253,12 @@ export function useMobileSourceControlOpeners(params: Params) {
|
||||
mergeBase: summary.mergeBase
|
||||
}
|
||||
})
|
||||
let interpreted: unknown
|
||||
let result: MobileGitDiffReply
|
||||
try {
|
||||
interpreted = gitBranchDiffRead.interpret(reply)
|
||||
result = gitBranchDiffRead.interpret(reply)
|
||||
} catch (error) {
|
||||
throw new Error(refusedRpcMessageOrFallback(error, 'Unable to load committed diff'))
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
|
||||
const result = interpreted as GitDiffTextResult | { kind: 'binary' }
|
||||
if (result.kind !== 'text') {
|
||||
throw new Error('Binary branch diff preview unavailable on mobile')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
import { salvagedOptional } from '../../../src/shared/zod-salvage'
|
||||
|
||||
/**
|
||||
* The `worktree.show` members the two source-control readers take: branch compare wants
|
||||
* `worktree.baseRef` and the PR sidebar wants `worktree.linkedPR`.
|
||||
*
|
||||
* Both are salvaged optionals, and the wrapper is nullable, because both callers fall back to
|
||||
* another source when the hint is missing — mobile-branch-base-ref.ts:25 and mobile-pr-link.ts:119.
|
||||
* The host's worktree record carries dozens of members mobile never reads; declaring only these two
|
||||
* keeps a host that renames an unrelated field from breaking the base-ref chain.
|
||||
*/
|
||||
export const worktreeSummaryReplySchema = z.object({
|
||||
worktree: z
|
||||
.object({
|
||||
baseRef: salvagedOptional('baseRef', z.string()),
|
||||
linkedPR: salvagedOptional('linkedPR', z.number())
|
||||
})
|
||||
|
||||
.nullable()
|
||||
.optional()
|
||||
})
|
||||
|
||||
export type MobileWorktreeSummaryReply = z.output<typeof worktreeSummaryReplySchema>
|
||||
@@ -28,7 +28,7 @@ export type UncheckedRpcReaderEntry = {
|
||||
/**
|
||||
* Files holding at least one unchecked reader, grouped by the feature area that owns them.
|
||||
*
|
||||
* The reason is shared by every line and is stated once here instead of 42 times: the reply has no
|
||||
* The reason is shared by every line and is stated once here instead of 37 times: the reply has no
|
||||
* schema, so the operation declares what the payload is by assertion. Writing one schema per
|
||||
* consumed member — required exactly where the consumer reads it unguarded, optional everywhere
|
||||
* else, never `.strict()` — turns the assertion into a check and deletes the line.
|
||||
@@ -67,12 +67,6 @@ export const UNCHECKED_RPC_READERS: readonly UncheckedRpcReaderEntry[] = [
|
||||
{ file: 'src/session/mobile-session-launch-operations.ts', readers: 7 },
|
||||
{ file: 'src/session/mobile-session-read-operations.ts', readers: 10 },
|
||||
{ file: 'src/session/mobile-session-write-operations.ts', readers: 8 },
|
||||
// source-control
|
||||
{ file: 'src/source-control/mobile-git-mutation-operations.ts', readers: 7 },
|
||||
{ file: 'src/source-control/mobile-git-read-operations.ts', readers: 5 },
|
||||
{ file: 'src/source-control/mobile-hosted-review-operations.ts', readers: 2 },
|
||||
{ file: 'src/source-control/mobile-source-file-open-operations.ts', readers: 2 },
|
||||
{ file: 'src/source-control/mobile-worktree-metadata-operations.ts', readers: 1 },
|
||||
// tasks
|
||||
{ file: 'src/tasks/mobile-task-item-comment-operations.ts', readers: 7 },
|
||||
{ file: 'src/tasks/mobile-task-item-detail-operations.ts', readers: 8 },
|
||||
|
||||
Reference in New Issue
Block a user