fix(persona): gate runtime projection by repository binding

This commit is contained in:
冰朔 2026-08-12 02:42:17 +08:00
commit ebe12763f1
6 changed files with 111 additions and 15 deletions

View file

@ -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()
})
})

View file

@ -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}