mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 08:02:12 +00:00
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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user