fix(persona): gate runtime projection by repository binding
This commit is contained in:
parent
8412d0e458
commit
ebe12763f1
6 changed files with 111 additions and 15 deletions
|
|
@ -2,19 +2,29 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PersonaRuntimeProjectionPanel } from './PersonaRuntimeProjectionPanel'
|
||||
|
||||
const { loadPersonaRuntimeProjectionMock, trackEventMock } = vi.hoisted(() => ({
|
||||
const { loadPersonaRuntimeProjectionMock, resolvePersonaRepositoryBindingMock, trackEventMock } = vi.hoisted(() => ({
|
||||
loadPersonaRuntimeProjectionMock: vi.fn(),
|
||||
resolvePersonaRepositoryBindingMock: vi.fn(),
|
||||
trackEventMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../lib/personaRuntimeProjection', () => ({
|
||||
loadPersonaRuntimeProjection: loadPersonaRuntimeProjectionMock,
|
||||
}))
|
||||
vi.mock('../lib/personaRepositoryBinding', () => ({
|
||||
resolvePersonaRepositoryBinding: resolvePersonaRepositoryBindingMock,
|
||||
}))
|
||||
vi.mock('../lib/telemetry', () => ({ trackEvent: trackEventMock }))
|
||||
|
||||
describe('PersonaRuntimeProjectionPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resolvePersonaRepositoryBindingMock.mockResolvedValue({
|
||||
phase: 'bound',
|
||||
inspectedRepositoryCount: 1,
|
||||
failures: [],
|
||||
binding: { repositoryPath: '/repo/persona' },
|
||||
})
|
||||
loadPersonaRuntimeProjectionMock.mockResolvedValue({
|
||||
errors: [],
|
||||
inspectedRepositoryCount: 1,
|
||||
|
|
@ -69,6 +79,10 @@ describe('PersonaRuntimeProjectionPanel', () => {
|
|||
expect(screen.getByText('event-chain-verified')).toBeInTheDocument()
|
||||
expect(screen.getByText(/guanghu\.zhuyuan-cognitive-gravity-frame\/v1/)).toBeInTheDocument()
|
||||
expect(screen.getByText('1111111111111111111111111111111111111111')).toBeInTheDocument()
|
||||
expect(loadPersonaRuntimeProjectionMock).toHaveBeenCalledWith({
|
||||
personaId: 'ICE-P-ZY001',
|
||||
repositoryPaths: ['/repo/persona'],
|
||||
})
|
||||
expect(trackEventMock).toHaveBeenCalledWith('persona_runtime_projection_loaded', {
|
||||
error_count: 0,
|
||||
inspected_repository_count: 1,
|
||||
|
|
@ -125,4 +139,43 @@ describe('PersonaRuntimeProjectionPanel', () => {
|
|||
await waitFor(() => expect(loadPersonaRuntimeProjectionMock).toHaveBeenCalledTimes(2))
|
||||
expect(trackEventMock).toHaveBeenCalledWith('persona_runtime_projection_retry')
|
||||
})
|
||||
|
||||
it('does not query runtime receipts when no persona repository is bound', async () => {
|
||||
resolvePersonaRepositoryBindingMock.mockResolvedValue({
|
||||
phase: 'unbound',
|
||||
inspectedRepositoryCount: 1,
|
||||
failures: [{ repositoryPath: '/ordinary', code: 'PERSONA_MANIFEST_READ_FAILED' }],
|
||||
})
|
||||
|
||||
render(
|
||||
<PersonaRuntimeProjectionPanel
|
||||
locale="zh-CN"
|
||||
personaId="ICE-P-ZY001"
|
||||
repositoryPaths={['/ordinary']}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('尚未绑定人格代码仓库')).toBeInTheDocument())
|
||||
expect(screen.getByText('这只说明当前挂载范围没有匹配的完整证据,不能据此判定人格不存在。')).toBeInTheDocument()
|
||||
expect(loadPersonaRuntimeProjectionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuses runtime projection when repository discovery is ambiguous', async () => {
|
||||
resolvePersonaRepositoryBindingMock.mockResolvedValue({
|
||||
phase: 'ambiguous',
|
||||
inspectedRepositoryCount: 2,
|
||||
failures: [],
|
||||
})
|
||||
|
||||
render(
|
||||
<PersonaRuntimeProjectionPanel
|
||||
locale="zh-CN"
|
||||
personaId="ICE-P-ZY001"
|
||||
repositoryPaths={['/persona-a', '/persona-b']}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => expect(screen.getByText('发现多个有效仓库,拒绝自动选择')).toBeInTheDocument())
|
||||
expect(loadPersonaRuntimeProjectionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import {
|
|||
type PersonaRuntimeProjection,
|
||||
type PersonaRuntimeSessionProjection,
|
||||
} from '../lib/personaRuntimeProjection'
|
||||
import {
|
||||
resolvePersonaRepositoryBinding,
|
||||
type PersonaRepositoryBindingResolution,
|
||||
} from '../lib/personaRepositoryBinding'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
|
|
@ -14,7 +18,8 @@ type PersonaRuntimeProjectionPanelProps = {
|
|||
repositoryPaths: readonly string[]
|
||||
}
|
||||
|
||||
type PanelState = PersonaRuntimeProjection | { phase: 'checking' }
|
||||
type RepositoryGateState = Exclude<PersonaRepositoryBindingResolution, { phase: 'bound' }>
|
||||
type PanelState = PersonaRuntimeProjection | RepositoryGateState | { phase: 'checking' }
|
||||
|
||||
function stateTranslationKey(state: string): TranslationKey {
|
||||
if (state.startsWith('DORMANT')) return 'hololake.personaRuntime.state.dormant'
|
||||
|
|
@ -100,10 +105,25 @@ export function PersonaRuntimeProjectionPanel({
|
|||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void loadPersonaRuntimeProjection({
|
||||
personaId,
|
||||
repositoryPaths: repositorySignature ? repositorySignature.split('\u0000') : [],
|
||||
}).then((projection) => {
|
||||
const load = async () => {
|
||||
const bindingResolution = await resolvePersonaRepositoryBinding({
|
||||
personaId,
|
||||
repositoryPaths: repositorySignature ? repositorySignature.split('\u0000') : [],
|
||||
})
|
||||
if (!active) return
|
||||
if (bindingResolution.phase !== 'bound') {
|
||||
setState(bindingResolution)
|
||||
trackEvent('persona_runtime_projection_gated', {
|
||||
failure_count: bindingResolution.failures.length,
|
||||
inspected_repository_count: bindingResolution.inspectedRepositoryCount,
|
||||
phase: bindingResolution.phase,
|
||||
})
|
||||
return
|
||||
}
|
||||
const projection = await loadPersonaRuntimeProjection({
|
||||
personaId,
|
||||
repositoryPaths: [bindingResolution.binding.repositoryPath],
|
||||
})
|
||||
if (!active) return
|
||||
setState(projection)
|
||||
trackEvent('persona_runtime_projection_loaded', {
|
||||
|
|
@ -112,7 +132,8 @@ export function PersonaRuntimeProjectionPanel({
|
|||
phase: projection.phase,
|
||||
session_count: projection.sessions.length,
|
||||
})
|
||||
})
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
|
|
@ -143,10 +164,26 @@ export function PersonaRuntimeProjectionPanel({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{state.phase === 'unbound' ? (
|
||||
<div className="persona-runtime-projection__message">
|
||||
<strong>{translate(locale, 'hololake.personaRepository.unbound')}</strong>
|
||||
<p>{translate(locale, 'hololake.personaRepository.unboundDescription')}</p>
|
||||
<Button variant="outline" onClick={refresh}>{translate(locale, 'hololake.personaRuntime.retry')}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.phase === 'ambiguous' ? (
|
||||
<div className="persona-runtime-projection__message persona-runtime-projection__message--error" role="alert">
|
||||
<strong>{translate(locale, 'hololake.personaRepository.ambiguous')}</strong>
|
||||
<p>{translate(locale, 'hololake.personaRepository.ambiguousDescription')}</p>
|
||||
<Button variant="outline" onClick={refresh}>{translate(locale, 'hololake.personaRuntime.retry')}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.phase === 'error' ? (
|
||||
<div className="persona-runtime-projection__message persona-runtime-projection__message--error" role="alert">
|
||||
<strong>{translate(locale, 'hololake.personaRuntime.error')}</strong>
|
||||
<code>{state.errors[0]?.code}</code>
|
||||
<code>{'code' in state ? state.code : state.errors[0]?.code}</code>
|
||||
<Button variant="outline" onClick={refresh}>{translate(locale, 'hololake.personaRuntime.retry')}</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
|
|
|||
Loading…
Reference in a new issue