refactor(mobile): stop requiring the stats row its own reader guards

Round-2 findings 1 to 3.

`homeHostStatsSchema` required an object that `totalHomeStats` already guards
(`if (!host || typeof host !== 'object') continue`), so the requirement bought
nothing at the read and cost the row upstream: the refusal reached
`fetchMobileHomeStats`'s `.catch`, the per-host slot was never written,
`hostIds.filter` found no host and the Home header drew no stats row where main
drew `0 / 0s / 0`. It takes `.nullish()`, and `HomeStatsRow` admits the
`null | undefined` main always had. The unit pin now says the slot keeps a null
summary and the total skips it, and sums one through `totalHomeStats` to show the
zeroed row survives.

The Home card's `SAFETY:` note claimed the reader proves `worktrees` is an array.
It does not; the `?? []` does. That is the same false sentence round 1 removed
from the agent-history panel, and a reader who believed it would delete the `??`
and reintroduce the white screen.

The `catalogError` branch on `RpcIncompatibleReplyError` had nothing holding it:
no adapter mounts the host screen, so no golden can reach it. One case in the
snapshot client pins the class the `catch` keys on. Mutation-checked by forwarding
the catalog schema as `z.unknown()`, which fails that case alone.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-09-17 02:09:04 -04:00
parent 7124ef4240
commit 0b8bd1c3c7
5 changed files with 59 additions and 22 deletions
+26 -8
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { z } from 'zod'
import { totalHomeStats } from '../stats/home-stats-total'
import { homeHostAccountsSchema, homeHostStatsSchema } from './home-host-reply-schema'
function reads<T>(schema: z.ZodType<T, unknown>, value: unknown): T {
@@ -10,6 +11,14 @@ function reads<T>(schema: z.ZodType<T, unknown>, value: unknown): T {
return parsed.data
}
function readsRow(value: unknown): NonNullable<z.output<typeof homeHostStatsSchema>> {
const row = reads(homeHostStatsSchema, value)
if (!row) {
throw new Error('expected an object row')
}
return row
}
function refuses(schema: z.ZodType<unknown, unknown>, value: unknown): boolean {
return !schema.safeParse(value).success
}
@@ -22,22 +31,31 @@ describe('a stats row is checked as an object and nothing more', () => {
totalAgentTimeMs: 90,
firstEventAt: 1700000000000
}
expect(reads(homeHostStatsSchema, row)).toMatchObject(row)
expect(readsRow(row)).toMatchObject(row)
})
it('reads a host that answers a shape totalHomeStats still sums', () => {
expect(reads(homeHostStatsSchema, { totalWorktrees: 3 })).toMatchObject({ totalWorktrees: 3 })
expect(reads(homeHostStatsSchema, {}).totalAgentsSpawned).toBe(undefined)
expect(readsRow({ totalWorktrees: 3 })).toMatchObject({ totalWorktrees: 3 })
expect(readsRow({}).totalAgentsSpawned).toBe(undefined)
})
it('preserves an explicit null firstEventAt, which the total reads as no events yet', () => {
expect(reads(homeHostStatsSchema, { firstEventAt: null }).firstEventAt).toBe(null)
expect(reads(homeHostStatsSchema, { firstEventAt: 'never' }).firstEventAt).toBe(undefined)
expect(readsRow({ firstEventAt: null }).firstEventAt).toBe(null)
expect(readsRow({ firstEventAt: 'never' }).firstEventAt).toBe(undefined)
})
it('keeps a null or absent summary out of the card slot', () => {
expect(refuses(homeHostStatsSchema, null)).toBe(true)
expect(refuses(homeHostStatsSchema, undefined)).toBe(true)
// Main seated a null or absent summary in the per-host slot and `totalHomeStats` skipped it, so
// the header still drew a zeroed row. Refusing here would empty the row instead of zeroing it.
it('seats a null or absent summary in the card slot, which the total skips', () => {
expect(refuses(homeHostStatsSchema, null)).toBe(false)
expect(refuses(homeHostStatsSchema, undefined)).toBe(false)
expect(reads(homeHostStatsSchema, null)).toBe(null)
expect(totalHomeStats({ 'host-1': reads(homeHostStatsSchema, null) }, ['host-1'])).toEqual({
totalAgentsSpawned: 0,
totalPRsCreated: 0,
totalAgentTimeMs: 0,
firstEventAt: null
})
})
})
+13 -11
View File
@@ -8,21 +8,23 @@ import { salvagedOptional } from '../../../src/shared/zod-salvage'
/**
* One host's lifetime-usage row.
*
* Every member is optional and none is required, because `totalHomeStats` is the reader and it says
* so itself: it skips a non-object row and runs every number through `finiteOrZero`
* (home-stats-total.ts:33-39). What the schema adds is that the stored row is an object at all —
* the card keeps one slot per host for the life of the process, so a null or absent summary used to
* sit in that slot until the host replied again.
* Nothing here is required, not even the object. `totalHomeStats` is the reader and it guards the
* row itself (`if (!host || typeof host !== 'object') continue`, home-stats-total.ts:36), so
* requiring the object would buy nothing at the read and would cost the row upstream: the refusal
* reaches `fetchMobileHomeStats`'s `.catch`, the per-host slot is never written, `hostIds.filter`
* finds no host and the header draws no stats row where main drew a zeroed one.
*
* `firstEventAt` keeps its explicit `null`: that is the host's "no events yet", and the total
* distinguishes it from a number when taking the minimum.
*/
export const homeHostStatsSchema = z.looseObject({
totalAgentsSpawned: salvagedOptional('totalAgentsSpawned', z.number()),
totalPRsCreated: salvagedOptional('totalPRsCreated', z.number()),
totalAgentTimeMs: salvagedOptional('totalAgentTimeMs', z.number()),
firstEventAt: salvagedOptional('firstEventAt', z.number().nullable())
})
export const homeHostStatsSchema = z
.looseObject({
totalAgentsSpawned: salvagedOptional('totalAgentsSpawned', z.number()),
totalPRsCreated: salvagedOptional('totalPRsCreated', z.number()),
totalAgentTimeMs: salvagedOptional('totalAgentTimeMs', z.number()),
firstEventAt: salvagedOptional('firstEventAt', z.number().nullable())
})
.nullish()
/**
* One host's accounts snapshot, forwarded whole.
+6 -2
View File
@@ -13,8 +13,12 @@ export type HomeStatsSummary = {
* Summing only `hostIds` keeps an unpaired desktop out of the total: replies are cached per host
* for the life of the process, so an entry outlives the host it describes.
*/
/** One host's row as the wire carries it: every field may be missing or the wrong type. */
export type HomeStatsRow = Partial<HomeStatsSummary>
/**
* One host's row as the wire carries it: the row itself may be null or absent, and every field may
* be missing or the wrong type. The loop below guards all three, which is why the reader requires
* none of them.
*/
export type HomeStatsRow = Partial<HomeStatsSummary> | null | undefined
export function totalHomeStats(
byHost: Record<string, HomeStatsRow>,
@@ -47,7 +47,7 @@ export function fetchHomeHostWorktreeInfo(
markUnavailable()
return
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reader proves `worktrees` is an array and leaves the rows opaque, because three screens project a row differently. This card reads `status` and the resume pick, both through their own guards, and the `worktree-home-catalog` golden records the row it is given as `{worktreeId, displayName, repo, status}`.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `worktrees` is a salvaged member, so the `?? []` is what makes it an array; the rows stay opaque because three screens project a row differently. This card reads `status` and the resume pick, both through their own guards, and the `worktree-home-catalog` golden records the row it is given as `{worktreeId, displayName, repo, status}`.
const worktrees = (catalog.value.worktrees ?? []) as HomeWorktreeSummary[]
setCachedWorktrees(hostId, worktrees, { proven: true })
const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status))
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { RpcIncompatibleReplyError } from '../transport/rpc-incompatible-reply-error'
import {
admitWorktreeCatalogResponse,
WORKTREE_PS_FULL_LIMIT,
@@ -221,6 +222,18 @@ describe('WorktreeCatalogSnapshotClient', () => {
})
})
// Why this belongs here: `use-host-worktree-catalog.ts:125` keys its `invalid_response` state on
// the error *class*, and no adapter mounts that screen, so this is the only place the class is
// pinned. Deleting the reader leaves a host-payload defect reported to the user as a network one.
it('rejects with RpcIncompatibleReplyError when the host answers a result the reader refuses', async () => {
const client = clientWithResults('all')
const snapshots = new WorktreeCatalogSnapshotClient()
await expect(snapshots.fetch(client, 'host-1')).rejects.toBeInstanceOf(
RpcIncompatibleReplyError
)
})
it('falls back to a generic failure code when the error carries none', async () => {
const client = {
sendRequest: vi.fn(async () => ({ id: 'request', ok: false, error: { message: 'x' } }))