refactor(mobile-relay): drop unread freshness fields from server workspace lists

The phone reads only the rows, so stale, fetchedAt, totalCount and truncated
were dead wire weight. The desktop still serves a server's last rows while
the server is unreachable.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo-H
2026-10-11 01:48:35 -04:00
parent 91c25ad51e
commit eff410fe30
4 changed files with 45 additions and 106 deletions
@@ -155,7 +155,7 @@ describe('mobile relay hosts: phone -> desktop lists the servers it shows', () =
return { box, contact, phoneCall }
}
it('lists rows live through the desktop, then its last rows as stale once a server is unreachable', async () => {
it('lists rows live through the desktop, then its last rows once a server is unreachable', async () => {
const { box, contact, phoneCall } = await startTopology()
await expect(phoneCall('mobileRelay.hosts.list')).resolves.toEqual({
@@ -168,17 +168,14 @@ describe('mobile relay hosts: phone -> desktop lists the servers it shows', () =
expect(warm).toMatchObject({
worktrees: [
{ worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:box', branch: 'feature/foo' }
],
totalCount: 1,
stale: false
]
})
// The update-needed server still answers the desktop, so its rows are live.
await expect(
phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:old' })
).resolves.toMatchObject({
worktrees: [{ worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:old' }],
stale: false
worktrees: [{ worktreeId: 'repo-1::/tmp/worktree-a', hostId: 'runtime:old' }]
})
await box.stop()
@@ -186,12 +183,10 @@ describe('mobile relay hosts: phone -> desktop lists the servers it shows', () =
await expect(phoneCall('mobileRelay.hosts.list')).resolves.toMatchObject({
hosts: [{ hostId: 'runtime:box', relay: 'unavailable' }, { hostId: 'runtime:old' }]
})
const asRecord = z.record(z.string(), z.unknown())
const offline = asRecord.parse(
await phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:box' })
)
// The last rows exactly as fetched, provenance kept.
expect(offline).toEqual({ ...asRecord.parse(warm), stale: true })
await expect(
phoneCall('mobileRelay.hosts.worktrees', { hostId: 'runtime:box' })
).resolves.toEqual(warm)
})
it('answers an empty list on a desktop that relays nowhere', async () => {
@@ -99,17 +99,7 @@ function fakeHosts(
}
function catalog(hosts: MobileDesktopRelayHosts, labels = new Map()) {
let now = 1_000
return {
catalog: new MobileRelayHostCatalog({
hosts,
hostLabelOverrides: () => labels,
now: () => now
}),
advance: (ms: number) => {
now += ms
}
}
return { catalog: new MobileRelayHostCatalog({ hosts, hostLabelOverrides: () => labels }) }
}
describe('mobile relay host catalog', () => {
@@ -205,11 +195,7 @@ describe('mobile relay host catalog', () => {
{ worktreeId: 'w-local', hostId: 'runtime:env', status: 'working' },
{ worktreeId: 'w-ssh', hostId: 'runtime:env', status: 'inactive' },
{ worktreeId: 'w-legacy', hostId: 'runtime:env', status: 'done' }
],
totalCount: 3,
truncated: false,
fetchedAt: 1_000,
stale: false
]
})
expect(call).toHaveBeenCalledWith(
expect.objectContaining({ environmentId: 'env' }),
@@ -220,34 +206,27 @@ describe('mobile relay host catalog', () => {
)
})
it('keeps the last rows, unchanged and marked stale, once the server stops answering', async () => {
it('keeps the last rows, unchanged, once the server stops answering', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local', status: 'working' }]))
const { catalog: hostCatalog, advance } = catalog(hosts)
const { catalog: hostCatalog } = catalog(hosts)
await hostCatalog.worktrees('runtime:env')
advance(5_000)
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'unreachable') }]
const offline = await hostCatalog.worktrees('runtime:env')
// Never re-fetched while the desktop itself cannot reach it, and never rewritten as idle.
expect(call).toHaveBeenCalledTimes(1)
expect(offline).toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env', status: 'working' }],
totalCount: 1,
truncated: false,
fetchedAt: 1_000,
stale: true
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env', status: 'working' }]
})
// A failed fetch on a server that looks reachable also serves the last rows as stale.
// A failed fetch on a server that looks reachable also serves the last rows.
state.environments = [{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }]
call.mockRejectedValueOnce(new Error('socket closed'))
await expect(hostCatalog.worktrees('runtime:env')).resolves.toMatchObject({
fetchedAt: 1_000,
stale: true
})
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual(offline)
expect(call).toHaveBeenCalledTimes(2)
})
it('omits rows mid-delete, as a phone asking the server itself would never see them', async () => {
@@ -267,10 +246,8 @@ describe('mobile relay host catalog', () => {
},
_meta: { runtimeId: 'runtime-a' }
})
await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toMatchObject({
worktrees: [{ worktreeId: 'kept', hostId: 'runtime:env' }],
totalCount: 4,
truncated: true
await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toEqual({
worktrees: [{ worktreeId: 'kept', hostId: 'runtime:env' }]
})
})
@@ -279,9 +256,8 @@ describe('mobile relay host catalog', () => {
{ id: 'old', name: 'ThinkPad', snapshot: snapshot('old', 'live', false) }
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(catalog(hosts).catalog.worktrees('runtime:old')).resolves.toMatchObject({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:old' }],
stale: false
await expect(catalog(hosts).catalog.worktrees('runtime:old')).resolves.toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:old' }]
})
})
@@ -345,7 +321,7 @@ describe('mobile relay host catalog', () => {
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({ worktrees: null })
})
it('keeps the last rows, stale, for a server manually disconnected but still configured', async () => {
it('keeps the last rows for a server manually disconnected but still configured', async () => {
const { hosts, call, state } = fakeHosts([
{ id: 'env', name: 'Box', snapshot: snapshot('env', 'live') }
])
@@ -361,9 +337,8 @@ describe('mobile relay host catalog', () => {
}
]
state.retire('env')
await expect(hostCatalog.worktrees('runtime:env')).resolves.toMatchObject({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }],
stale: true
await expect(hostCatalog.worktrees('runtime:env')).resolves.toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }]
})
expect(call).toHaveBeenCalledTimes(1)
})
@@ -374,8 +349,7 @@ describe('mobile relay host catalog', () => {
])
call.mockResolvedValue(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(catalog(hosts).catalog.worktrees('runtime:env')).resolves.toMatchObject({
worktrees: [{ worktreeId: 'w' }],
stale: false
worktrees: [{ worktreeId: 'w' }]
})
expect(call).toHaveBeenCalledWith(expect.anything(), 'worktree.ps', expect.anything(), {
timeoutMs: 5_000,
@@ -383,7 +357,7 @@ describe('mobile relay host catalog', () => {
})
})
it('answers with the last rows, marked stale, when the server never answers the fetch', async () => {
it('answers with the last rows when the server never answers the fetch', async () => {
vi.useFakeTimers()
try {
const { hosts, call } = fakeHosts([
@@ -398,10 +372,8 @@ describe('mobile relay host catalog', () => {
await vi.advanceTimersByTimeAsync(4_999)
expect(settled).toBe(false)
await vi.advanceTimersByTimeAsync(1)
await expect(rows).resolves.toMatchObject({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }],
fetchedAt: 1_000,
stale: true
await expect(rows).resolves.toEqual({
worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }]
})
} finally {
vi.useRealTimers()
@@ -419,10 +391,8 @@ describe('mobile relay host catalog', () => {
const second = hostCatalog.worktrees('runtime:env')
await vi.waitFor(() => expect(call).toHaveBeenCalledTimes(1))
answerFetch(psReply([{ worktreeId: 'w', hostId: 'local' }]))
await expect(Promise.all([first, second])).resolves.toMatchObject([
{ stale: false },
{ stale: false }
])
const rows = { worktrees: [{ worktreeId: 'w', hostId: 'runtime:env' }] }
await expect(Promise.all([first, second])).resolves.toEqual([rows, rows])
expect(call).toHaveBeenCalledTimes(1)
})
})
@@ -26,18 +26,11 @@ const SERVER_WORKTREE_LIMIT = 10_000
// Why well under the phone's 30s request timeout: a hung server must answer with its last rows.
const SERVER_FETCH_TIMEOUT_MS = 5_000
const WorktreePsReplySchema = z.object({
worktrees: z.array(z.looseObject({})),
totalCount: z.number(),
truncated: z.boolean()
})
const WorktreePsReplySchema = z.object({ worktrees: z.array(z.looseObject({})) })
type CachedServerWorktrees = {
fence: string
fetchedAt: number
worktrees: MobileRelayServerWorktreeRow[]
totalCount: number
truncated: boolean
}
type DescribedHost = MobileRelayHost & {
@@ -54,13 +47,12 @@ type DescribedHost = MobileRelayHost & {
export class MobileRelayHostCatalog {
private readonly cached = new Map<string, CachedServerWorktrees>()
// Keyed by server identity, so a poll after a re-pair never joins the old server's fetch.
private readonly refreshing = new Map<string, Promise<boolean>>()
private readonly refreshing = new Map<string, Promise<void>>()
constructor(
private readonly options: {
hosts: MobileDesktopRelayHosts
hostLabelOverrides: () => ReadonlyMap<ExecutionHostId, string>
now?: () => number
}
) {}
@@ -84,7 +76,9 @@ export class MobileRelayHostCatalog {
if (!host) {
return { worktrees: null }
}
const refreshed = host.health === 'available' && (await withinFetchBound(this.refresh(host)))
if (host.health === 'available') {
await withinFetchBound(this.refresh(host))
}
const cached = this.cached.get(host.environmentId)
const current = this.options.hosts
.list()
@@ -94,14 +88,7 @@ export class MobileRelayHostCatalog {
return { worktrees: null }
}
// Why: the desktop asks with background-removal support; a phone asking itself never sees these rows.
const worktrees = cached.worktrees.filter((row) => row.removing !== true)
return {
worktrees,
totalCount: cached.totalCount - (cached.worktrees.length - worktrees.length),
truncated: cached.truncated,
fetchedAt: cached.fetchedAt,
stale: !refreshed
}
return { worktrees: cached.worktrees.filter((row) => row.removing !== true) }
}
private describe(): DescribedHost[] {
@@ -146,7 +133,7 @@ export class MobileRelayHostCatalog {
})
}
private refresh(host: DescribedHost): Promise<boolean> {
private refresh(host: DescribedHost): Promise<void> {
const key = `${host.environmentId}\0${fenceOf(host.identity)}`
const inFlight = this.refreshing.get(key)
if (inFlight) {
@@ -157,7 +144,7 @@ export class MobileRelayHostCatalog {
return refresh
}
private async fetchAsDesktop(host: DescribedHost): Promise<boolean> {
private async fetchAsDesktop(host: DescribedHost): Promise<void> {
try {
const response = await this.options.hosts.call(
host,
@@ -168,20 +155,15 @@ export class MobileRelayHostCatalog {
)
const reply = response.ok ? WorktreePsReplySchema.safeParse(response.result) : null
if (!reply?.success) {
return false
return
}
this.cached.set(host.environmentId, {
// Why the identity taken before the fetch: the call's own check cannot see a re-pair after dispatch.
fence: fenceOf(host.identity),
fetchedAt: (this.options.now ?? Date.now)(),
worktrees: stampServerWorktreeRows(host.environmentId, reply.data.worktrees),
totalCount: reply.data.totalCount,
truncated: reply.data.truncated
worktrees: stampServerWorktreeRows(host.environmentId, reply.data.worktrees)
})
return true
} catch {
// An unreachable server keeps its last rows; they are served as stale.
return false
// An unreachable server keeps its last rows.
}
}
}
@@ -190,11 +172,11 @@ function fenceOf(identity: DescribedHost['identity']): string {
return `${identity.pairingRevision}\0${identity.runtimeId}`
}
/** False once the bound passes; the call's own timeout starts only after the desktop's per-server queue. */
async function withinFetchBound(refresh: Promise<boolean>): Promise<boolean> {
/** Settles once the bound passes; the call's own timeout starts only after the desktop's per-server queue. */
async function withinFetchBound(refresh: Promise<void>): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined
const bound = new Promise<false>((resolve) => {
timer = setTimeout(() => resolve(false), SERVER_FETCH_TIMEOUT_MS)
const bound = new Promise<void>((resolve) => {
timer = setTimeout(resolve, SERVER_FETCH_TIMEOUT_MS)
})
try {
return await Promise.race([refresh, bound])
+3 -11
View File
@@ -25,15 +25,7 @@ export const MobileRelayHostWorktreesParamsSchema = z
export type MobileRelayServerWorktreeRow = Record<string, unknown> & { hostId: `runtime:${string}` }
/**
* The server's own `worktree.ps` rows as the desktop last fetched them. `stale` means this call
* could not refresh them; rows are never rewritten beyond their host id.
* The server's own `worktree.ps` rows as the desktop last fetched them, refreshed when the server
* answers; rows are never rewritten beyond their host id.
*/
export type MobileRelayHostWorktreesResult =
| {
worktrees: MobileRelayServerWorktreeRow[]
totalCount: number
truncated: boolean
fetchedAt: number
stale: boolean
}
| { worktrees: null }
export type MobileRelayHostWorktreesResult = { worktrees: MobileRelayServerWorktreeRow[] | null }