fix(runtime): carry the blocked cause through the host-status snapshot

The owner collapses seven distinct RPC failure codes onto one `blocked`
verification, and a client-initiated disconnect publishes the same value via
dispose(). A consumer routing a blocker taxonomy on `blocked` would tell the
user "version or auth skew" about a host they disconnected themselves.

Record the failure code as `blockedCode` on the snapshot (client-local state,
never exchanged with the host) and clear it once the host verifies again. Add a
pure renderer reader that maps a map entry, a capability and the host's
effective admission onto one verdict, defining absence of an entry as unknown —
the catalog/pairing-revision guard, a re-pair, and an entry without a snapshot
all leave a healthy host unrepresented. Retirement outranks a recorded cause,
an answer older than the prober's 60s TTL reads as unknown, and a host that
publishes no admission reads as supported with unknown policy, never disabled.
This commit is contained in:
Merge Sim
2026-09-11 01:37:55 -07:00
parent 22d12388a5
commit 635d40e226
5 changed files with 369 additions and 2 deletions
@@ -0,0 +1,258 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { create } from 'zustand'
import {
createRuntimeStatusSlice,
clearRuntimeEnvironmentConnectionGenerationsForTests,
type RuntimeStatusSlice
} from '../store/slices/runtime-status'
import type { RuntimeEnvironmentStatus } from '../store/slices/runtime-status-types'
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status'
import { RuntimeHostStatusOwner } from '../../../shared/runtime-host-status-owner'
import {
runtimeHostStatusFailure,
type RuntimeHostStatusResponse
} from '../../../shared/runtime-host-status'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type { RuntimeStatus } from '../../../shared/runtime-types'
import {
resolveStructuredChatHostVerdict,
isUnknownStructuredChatHostVerdict,
STRUCTURED_CHAT_HOST_VERDICT_STALE_MS,
type StructuredChatHostAdmission,
type StructuredChatHostVerdict
} from './structured-chat-host-verdict'
vi.mock('sonner', () => ({ toast: { warning: vi.fn(), dismiss: vi.fn() } }))
vi.mock('@/runtime/restored-client-hosted-browser-host-attach', () => ({
ensureBrowserClientHostsForRestoredPages: vi.fn(),
ensureBrowserClientHostForRestartedRuntime: vi.fn()
}))
vi.mock('@/runtime/client-hosted-browser-close-intent-replay', () => ({
replayClientHostedBrowserCloseIntents: vi.fn()
}))
const CAPABILITY = STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
const NOW = 1_000_000
beforeEach(() => {
clearRuntimeEnvironmentConnectionGenerationsForTests()
vi.clearAllMocks()
})
function snapshot(patch: Partial<RuntimeHostStatusSnapshot> = {}): RuntimeHostStatusSnapshot {
return {
environmentId: 'env-a',
pairingRevision: 1,
sequence: 1,
checkedAt: NOW,
transport: 'ready',
verification: 'verified',
status: { runtimeId: 'rt-1', capabilities: [CAPABILITY] } as unknown as RuntimeStatus,
...patch
}
}
function entryOf(patch: Partial<RuntimeHostStatusSnapshot> = {}): RuntimeEnvironmentStatus {
const value = snapshot(patch)
return { snapshot: value, status: value.status, checkedAt: value.checkedAt }
}
function verdictOf(
entry: RuntimeEnvironmentStatus | undefined,
admission: StructuredChatHostAdmission = { enabled: true }
): StructuredChatHostVerdict {
return resolveStructuredChatHostVerdict({ entry, capability: CAPABILITY, admission, now: NOW })
}
describe('verdict rows', () => {
const rows: [string, StructuredChatHostVerdict, () => StructuredChatHostVerdict][] = [
[
'a probe in flight',
'unknown-checking',
() => verdictOf(entryOf({ verification: 'checking' }))
],
['no map entry at all', 'unknown-no-entry', () => verdictOf(undefined)],
[
'an entry that carries no snapshot',
'unknown-no-entry',
() => verdictOf({ status: null, checkedAt: NOW })
],
[
'a host that did not answer',
'unknown-unavailable',
() => verdictOf(entryOf({ verification: 'unavailable', status: null }))
],
['a verified host that admits structured chat', 'supported', () => verdictOf(entryOf())],
[
'a verified host that publishes no admission',
'supported',
() => verdictOf(entryOf(), undefined)
],
[
'a verified host whose policy is off',
'host-policy-disabled',
() => verdictOf(entryOf(), { enabled: false })
],
[
'a host that never advertised the capability',
'host-refuses-capability',
() =>
verdictOf(
entryOf({ status: { runtimeId: 'rt-1', capabilities: [] } as unknown as RuntimeStatus })
)
],
[
'a host with no capability list at all',
'host-refuses-capability',
() => verdictOf(entryOf({ status: { runtimeId: 'rt-1' } as unknown as RuntimeStatus }))
],
[
'a retired host',
'host-disconnected',
() =>
verdictOf(entryOf({ retired: true, verification: 'blocked', transport: 'disconnected' }))
],
[
'a blocked host with a failure code',
'version-or-auth-skew',
() => verdictOf(entryOf({ verification: 'blocked', blockedCode: 'unauthorized' }))
],
[
'an answer older than the prober TTL',
'unknown-stale',
() => verdictOf(entryOf({ checkedAt: NOW - STRUCTURED_CHAT_HOST_VERDICT_STALE_MS - 1 }))
]
]
it.each(rows)('reads %s as %s', (_label, expected, resolve) => {
expect(resolve()).toBe(expected)
})
it('keeps an answer exactly at the staleness bound usable', () => {
expect(verdictOf(entryOf({ checkedAt: NOW - STRUCTURED_CHAT_HOST_VERDICT_STALE_MS }))).toBe(
'supported'
)
})
it('lets retirement outrank a blocked cause recorded before the disconnect', () => {
expect(
verdictOf(entryOf({ retired: true, verification: 'blocked', blockedCode: 'unauthorized' }))
).toBe('host-disconnected')
})
it('never accuses a host of skew when blocked carries no cause', () => {
expect(verdictOf(entryOf({ verification: 'blocked' }))).toBe('unknown-unavailable')
})
it('groups exactly the ask-again verdicts as unknown', () => {
const verdicts: StructuredChatHostVerdict[] = [
'unknown-checking',
'unknown-no-entry',
'unknown-unavailable',
'unknown-stale',
'supported',
'host-refuses-capability',
'host-policy-disabled',
'host-disconnected',
'version-or-auth-skew'
]
expect(verdicts.filter(isUnknownStructuredChatHostVerdict)).toEqual([
'unknown-checking',
'unknown-no-entry',
'unknown-unavailable',
'unknown-stale'
])
})
})
const environment = {
id: 'env-a',
name: 'Host',
createdAt: 1,
pairingRevision: 1,
endpoints: [],
preferredEndpointId: ''
} as unknown as PublicKnownRuntimeEnvironment
function store() {
const value = create<RuntimeStatusSlice>()((...args) =>
createRuntimeStatusSlice(...(args as unknown as Parameters<typeof createRuntimeStatusSlice>))
)
value.getState().setRuntimeEnvironments([environment])
return value
}
function storeVerdict(viewer: ReturnType<typeof store>): StructuredChatHostVerdict {
return verdictOf(viewer.getState().runtimeStatusByEnvironmentId.get('env-a'))
}
describe('windows that leave a healthy host without a usable entry', () => {
it('reads a snapshot dropped by the pairing-revision guard as unknown, not as a refusal', () => {
const viewer = store()
viewer.getState().applyRuntimeHostStatusSnapshot(snapshot({ pairingRevision: 2 }))
expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false)
expect(storeVerdict(viewer)).toBe('unknown-no-entry')
})
it('reads the gap after a re-pair deletes the replaced entry as unknown', () => {
const viewer = store()
viewer.getState().applyRuntimeHostStatusSnapshot(snapshot())
expect(storeVerdict(viewer)).toBe('supported')
viewer.getState().setRuntimeEnvironments([{ ...environment, pairingRevision: 2 }])
expect(viewer.getState().runtimeStatusByEnvironmentId.has('env-a')).toBe(false)
expect(storeVerdict(viewer)).toBe('unknown-no-entry')
})
it('reads an entry published without a snapshot as unknown', () => {
const viewer = store()
viewer.getState().setRuntimeEnvironmentStatus('env-a', {
status: { runtimeId: 'rt-1', capabilities: [CAPABILITY] } as unknown as RuntimeStatus,
checkedAt: NOW
})
expect(viewer.getState().runtimeStatusByEnvironmentId.get('env-a')?.snapshot).toBeUndefined()
expect(storeVerdict(viewer)).toBe('unknown-no-entry')
})
})
function ownerSnapshot(response: RuntimeHostStatusResponse): {
owner: RuntimeHostStatusOwner
read: () => RuntimeHostStatusSnapshot
} {
const published: RuntimeHostStatusSnapshot[] = []
const owner = new RuntimeHostStatusOwner({
environmentId: 'env-a',
pairingRevision: 1,
request: () => Promise.resolve(response),
verified: () => false,
publish: (value) => published.push(value)
})
return { owner, read: () => published.at(-1) as RuntimeHostStatusSnapshot }
}
describe('owner-published snapshots', () => {
it('tells a disconnect the client caused apart from host version or auth skew', async () => {
const skewed = ownerSnapshot(
runtimeHostStatusFailure('protocol_version_mismatch', 'Host is too old.')
)
skewed.owner.activate()
await vi.waitFor(() => expect(skewed.read().verification).toBe('blocked'))
const skewEntry: RuntimeEnvironmentStatus = {
snapshot: skewed.read(),
status: null,
checkedAt: skewed.read().checkedAt
}
const disconnected = ownerSnapshot(runtimeHostStatusFailure('unauthorized', 'Pair again.'))
disconnected.owner.dispose()
const disconnectedEntry: RuntimeEnvironmentStatus = {
snapshot: disconnected.read(),
status: null,
checkedAt: disconnected.read().checkedAt
}
expect(disconnectedEntry.snapshot?.verification).toBe('blocked')
expect(verdictOf(skewEntry)).toBe('version-or-auth-skew')
expect(verdictOf(disconnectedEntry)).toBe('host-disconnected')
skewed.owner.dispose()
})
})
@@ -0,0 +1,69 @@
import type { RuntimeCapability } from '../../../shared/protocol-version'
import type { RuntimeEnvironmentStatus } from '../store/slices/runtime-status-types'
/** Matches the async capability prober's status TTL in runtime-rpc-client.ts. */
export const STRUCTURED_CHAT_HOST_VERDICT_STALE_MS = 60_000
/**
* Every reason a client may or may not open structured chat against a host.
* The four `unknown-*` members mean "ask again", never "the host said no".
*/
export type StructuredChatHostVerdict =
| 'unknown-checking'
| 'unknown-no-entry'
| 'unknown-unavailable'
| 'unknown-stale'
| 'supported'
| 'host-refuses-capability'
| 'host-policy-disabled'
| 'host-disconnected'
| 'version-or-auth-skew'
/** Effective admission published by the host; absent on hosts that predate publishing it. */
export type StructuredChatHostAdmission = { enabled: boolean } | undefined
export type StructuredChatHostVerdictInput = {
entry: RuntimeEnvironmentStatus | undefined
capability: RuntimeCapability
admission: StructuredChatHostAdmission
now?: number
}
export function isUnknownStructuredChatHostVerdict(verdict: StructuredChatHostVerdict): boolean {
return verdict.startsWith('unknown-')
}
export function resolveStructuredChatHostVerdict({
entry,
capability,
admission,
now = Date.now()
}: StructuredChatHostVerdictInput): StructuredChatHostVerdict {
const snapshot = entry?.snapshot
// A dropped catalog/pairing-revision snapshot and a re-paired entry are both
// silent windows on a healthy host, so absence is defined as unknown.
if (!snapshot) {
return 'unknown-no-entry'
}
if (snapshot.retired) {
// The client disconnected this host; that outranks whatever blocked the last probe.
return 'host-disconnected'
}
if (snapshot.verification === 'blocked') {
return snapshot.blockedCode ? 'version-or-auth-skew' : 'unknown-unavailable'
}
if (snapshot.verification !== 'verified') {
return snapshot.verification === 'checking' ? 'unknown-checking' : 'unknown-unavailable'
}
if (!snapshot.status) {
return 'unknown-unavailable'
}
if (now - snapshot.checkedAt > STRUCTURED_CHAT_HOST_VERDICT_STALE_MS) {
return 'unknown-stale'
}
if (snapshot.status.capabilities?.includes(capability) !== true) {
return 'host-refuses-capability'
}
// An older host publishes no admission at all; that is unknown policy, not a refusal.
return admission?.enabled === false ? 'host-policy-disabled' : 'supported'
}
@@ -205,3 +205,34 @@ it.each(['unknown', 'ready'] as const)(
})
}
)
it.each([
['protocol_version_mismatch', 'blocked', 'protocol_version_mismatch'],
['unauthorized', 'blocked', 'unauthorized'],
['runtime_unavailable', 'unavailable', undefined]
] as const)('records %s as the cause behind %s', async (code, verification, blockedCode) => {
const { owner, request } = createOwner()
request.mockResolvedValueOnce(runtimeHostStatusFailure(code, 'nope'))
await owner.refresh()
expect(owner.read().verification).toBe(verification)
expect(owner.read().blockedCode).toBe(blockedCode)
})
it('clears a recorded cause once the host verifies again', async () => {
const { owner, request } = createOwner()
request.mockResolvedValueOnce(runtimeHostStatusFailure('runtime_unavailable', 'offline'))
await owner.refresh()
expect(owner.read().blockedCode).toBeUndefined()
owner.acceptVerified(success())
expect(owner.read()).toMatchObject({ verification: 'verified', blockedCode: undefined })
})
it('keeps an authentication rejection distinguishable from a client-side disconnect', () => {
const { owner } = createOwner()
owner.authenticationRejected()
expect(owner.read()).toMatchObject({ verification: 'blocked', blockedCode: 'unauthorized' })
const disconnected = createOwner()
disconnected.owner.dispose()
expect(disconnected.owner.read().retired).toBe(true)
expect(disconnected.owner.read().blockedCode).toBeUndefined()
})
+9 -2
View File
@@ -218,12 +218,19 @@ export class RuntimeHostStatusOwner {
this.response = response
if (response.ok) {
this.attempt = 0
this.update({ status: response.result, checkedAt: Date.now(), verification: 'verified' })
this.update({
status: response.result,
checkedAt: Date.now(),
verification: 'verified',
blockedCode: undefined
})
this.persistent = this.options.verified(response, this.active)
} else {
const blocked = isRuntimeHostStatusBlocked(response)
this.update({
checkedAt: Date.now(),
verification: isRuntimeHostStatusBlocked(response) ? 'blocked' : 'unavailable'
verification: blocked ? 'blocked' : 'unavailable',
blockedCode: blocked ? response.error.code : undefined
})
this.scheduleRetry()
}
+2
View File
@@ -15,6 +15,8 @@ export type RuntimeHostStatusSnapshot = {
transport: 'unknown' | 'connecting' | 'ready' | 'disconnected'
remoteControl?: RemoteRuntimeSharedConnectionDiagnostics | null
retired?: true
/** RPC failure code behind `verification: 'blocked'`; the seven codes are not one cause. */
blockedCode?: string
}
export type RuntimeHostStatusResponse = RuntimeRpcResponse<RuntimeStatus>