diff --git a/.env.example b/.env.example index af6f9e698e..eba642d13d 100644 --- a/.env.example +++ b/.env.example @@ -136,6 +136,16 @@ OPENHUMAN_TEMPERATURE=0.7 # [optional] Allow bearer-authenticated RPC callers to invoke update.apply/update.run # Disable on exposed server deployments unless you explicitly want remote self-upgrade. # OPENHUMAN_AUTO_UPDATE_RPC_MUTATIONS_ENABLED=true +# [optional] YouPet Core REST API base URL for the workbench bridge (default: http://127.0.0.1:8000). +# YOUPET_CORE_API_URL=http://127.0.0.1:8000 +# [optional] YouPet Core service token. Prefer config.toml/keyring-backed config for local use. +# YOUPET_SERVICE_TOKEN= +# [optional] Actor label sent in X-Actor-Id for YouPet workbench requests (default: openhuman-workbench). +# YOUPET_WORKBENCH_ACTOR_ID=openhuman-workbench +# [required for YouPet Workbench alert actions] Core users.id UUID for the operator. +# YOUPET_OPERATOR_USER_ID= +# [required for ActionRequest list/inbox] Core tenant UUID (or pass tenantId RPC override). +# YOUPET_TENANT_ID= # --------------------------------------------------------------------------- # Runtime flags diff --git a/app/package.json b/app/package.json index 6beb957983..0cce7b317c 100644 --- a/app/package.json +++ b/app/package.json @@ -127,6 +127,7 @@ "three": "^0.183.2", "tw-animate-css": "^1.4.0", "util": "^0.12.5", + "zod": "4.3.6", "zustand": "^5.0.15" }, "devDependencies": { diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index f908eaa373..8d73a26aa3 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -244,6 +244,7 @@ if should_run_suite "navigation"; then run "test/e2e/specs/navigation-settings-panels.spec.ts" "navigation-settings" "navigation" run "test/e2e/specs/command-palette.spec.ts" "command-palette" "navigation" run "test/e2e/specs/channels-smoke.spec.ts" "channels-smoke" "navigation" + run "test/e2e/specs/action-request-inbox.spec.ts" "action-request-inbox" "navigation" run "test/e2e/specs/insights-dashboard.spec.ts" "insights-dashboard" "navigation" run "test/e2e/specs/guided-tour-gates.spec.ts" "guided-tour-gates" "navigation" _mini_summary "navigation" diff --git a/app/src/AppRoutes.guards.test.tsx b/app/src/AppRoutes.guards.test.tsx index 4fa4245fa2..be8c9af9ee 100644 --- a/app/src/AppRoutes.guards.test.tsx +++ b/app/src/AppRoutes.guards.test.tsx @@ -178,7 +178,8 @@ describe('AppRoutes — the whole route table stays classified', () => { '/callback/:kind': 'none', '/callback/:kind/:status': 'none', '/onboarding/*': 'protected', - '/home': 'redirect', + '/home': 'protected', + '/registries': 'protected', '/human': 'protected', '/brain': 'protected', '/flows': 'protected', @@ -198,6 +199,8 @@ describe('AppRoutes — the whole route table stays classified', () => { '/routines': 'redirect', '/rewards': 'protected', '/workflows': 'protected', + '/workbench': 'protected', + '/action-requests': 'protected', '/webhooks': 'redirect', '/settings/*': 'protected', '/ptt-overlay': 'none', diff --git a/app/src/AppRoutes.redirects.test.tsx b/app/src/AppRoutes.redirects.test.tsx index 22583aa305..4dcf7425b3 100644 --- a/app/src/AppRoutes.redirects.test.tsx +++ b/app/src/AppRoutes.redirects.test.tsx @@ -85,7 +85,6 @@ function landingFor(entry: string) { // is added there and not here, `every redirect in the route table is covered` // below fails — the list cannot silently fall behind the code. const REDIRECTS: Array<{ from: string; pathname: string; search: string }> = [ - { from: '/home', pathname: '/chat', search: '' }, { from: '/activity', pathname: '/settings/notifications', search: '' }, { from: '/intelligence', pathname: '/settings/notifications', search: '' }, { from: '/skills', pathname: '/connections', search: '' }, diff --git a/app/src/AppRoutes.test.tsx b/app/src/AppRoutes.test.tsx new file mode 100644 index 0000000000..4bd21d9d21 --- /dev/null +++ b/app/src/AppRoutes.test.tsx @@ -0,0 +1,135 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('./lib/platform', () => ({ getIsMobile: () => false })); +vi.mock('./AppRoutesIOS', () => ({ default: () =>
ios
})); +vi.mock('./components/DefaultRedirect', () => ({ + default: () =>
redirect
, +})); +vi.mock('./components/ProtectedRoute', () => ({ + default: ({ children, requireAuth }: { children: ReactNode; requireAuth?: boolean }) => ( +
+ {children} +
+ ), +})); +vi.mock('./components/PublicRoute', () => ({ + default: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); +vi.mock('./features/human/HumanPage', () => ({ + default: () =>
human
, +})); +vi.mock('./features/coreRegistries/CoreRegistriesPage', () => ({ + default: () =>
core-registries
, +})); +vi.mock('./pages/Accounts', () => ({ + default: () =>
accounts
, +})); +vi.mock('./components/routing/ForwardSearch', () => ({ + default: ({ to }: { to: string }) =>
{to}
, +})); +vi.mock('./pages/Activity', () => ({ + default: () =>
activity
, +})); +vi.mock('./pages/Brain', () => ({ default: () =>
brain
})); +vi.mock('./pages/dev/AgentInsightsPreview', () => ({ + default: () =>
agent-insights
, +})); +vi.mock('./pages/dev/assistant-ui-demo', () => ({ + default: () =>
assistant-ui-demo
, +})); +vi.mock('./pages/dev/UiGallery', () => ({ + default: () =>
ui-gallery
, +})); +vi.mock('./pages/FlowCanvasPage', () => ({ + default: () =>
flow-canvas
, + FlowCanvasDraftPage: () =>
flow-canvas-draft
, +})); +vi.mock('./pages/FlowsPage', () => ({ default: () =>
flows
})); +vi.mock('./pages/Home', () => ({ default: () =>
home
})); +vi.mock('./pages/Invites', () => ({ + default: () =>
invites
, +})); +vi.mock('./pages/Notifications', () => ({ + default: () =>
notifications
, +})); +vi.mock('./pages/onboarding/Onboarding', () => ({ + default: () =>
onboarding
, +})); +vi.mock('./pages/PttOverlayPage', () => ({ + PttOverlayPage: () =>
ptt-overlay
, +})); +vi.mock('./pages/Rewards', () => ({ + default: () =>
rewards
, +})); +vi.mock('./pages/Settings', () => ({ + default: () =>
settings
, +})); +vi.mock('./pages/Skills', () => ({ default: () =>
skills
})); +vi.mock('./pages/WebCallbackPage', () => ({ + default: () =>
callback
, +})); +vi.mock('./pages/Welcome', () => ({ + default: () =>
welcome
, +})); +vi.mock('./pages/Workbench', () => ({ + default: () =>
workbench
, +})); +vi.mock('./pages/ActionRequestInbox', () => ({ + default: () =>
action-requests
, +})); +vi.mock('./pages/WorkflowsRun', () => ({ + default: () =>
workflows-run
, +})); + +const AppRoutes = (await import('./AppRoutes')).default; + +describe('AppRoutes', () => { + it('registers the Home route behind the protected desktop shell', () => { + render( + + + + ); + + expect(screen.getByTestId('page-home')).toBeInTheDocument(); + expect(screen.getByTestId('protected-route')).toHaveAttribute('data-require-auth', 'true'); + }); + + it('registers the Core Registries route behind the protected desktop shell', () => { + render( + + + + ); + + expect(screen.getByTestId('page-core-registries')).toBeInTheDocument(); + expect(screen.getByTestId('protected-route')).toHaveAttribute('data-require-auth', 'true'); + }); + + it('preserves the Workbench route behind the protected desktop shell', () => { + render( + + + + ); + + expect(screen.getByTestId('page-workbench')).toBeInTheDocument(); + expect(screen.getByTestId('protected-route')).toHaveAttribute('data-require-auth', 'true'); + }); + + it('preserves the ActionRequest inbox behind the protected desktop shell', () => { + render( + + + + ); + + expect(screen.getByTestId('page-action-request-inbox')).toBeInTheDocument(); + expect(screen.getByTestId('protected-route')).toHaveAttribute('data-require-auth', 'true'); + }); +}); diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index a225414d56..2770fd33d3 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -5,9 +5,11 @@ import DefaultRedirect from './components/DefaultRedirect'; import ProtectedRoute from './components/ProtectedRoute'; import PublicRoute from './components/PublicRoute'; import ForwardSearch from './components/routing/ForwardSearch'; +import CoreRegistriesPage from './features/coreRegistries/CoreRegistriesPage'; import HumanPage from './features/human/HumanPage'; import { getIsMobile } from './lib/platform'; import Accounts from './pages/Accounts'; +import ActionRequestInbox from './pages/ActionRequestInbox'; import Activity from './pages/Activity'; import Brain from './pages/Brain'; import AgentInsightsPreview from './pages/dev/AgentInsightsPreview'; @@ -15,6 +17,7 @@ import AssistantUiDemoPage from './pages/dev/assistant-ui-demo'; import UiGallery from './pages/dev/UiGallery'; import FlowCanvasPage, { FlowCanvasDraftPage } from './pages/FlowCanvasPage'; import FlowsPage from './pages/FlowsPage'; +import Home from './pages/Home'; import Invites from './pages/Invites'; import Notifications from './pages/Notifications'; import Onboarding from './pages/onboarding/Onboarding'; @@ -24,6 +27,7 @@ import Settings from './pages/Settings'; import Skills from './pages/Skills'; import WebCallbackPage from './pages/WebCallbackPage'; import Welcome from './pages/Welcome'; +import Workbench from './pages/Workbench'; import WorkflowsRun from './pages/WorkflowsRun'; interface AppRoutesProps { @@ -71,9 +75,23 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { /> {/* Protected routes */} - {/* Home is merged into the unified chat surface — /home redirects to /chat - (the chat's empty "new window" state is the former Home greeting). */} - } /> + + + + } + /> + + + + + } + /> {/* Human — the dedicated full-bleed mascot stage. The chat surface carries the same mascot docked on its composer; both read one set of mascot @@ -211,6 +229,24 @@ const AppRoutes = ({ location }: AppRoutesProps = {}) => { } /> + + + + } + /> + + + + + } + /> + {/* Back-compat: /routines was an orphaned dead page. Workflows is now a first-level module — redirect surviving deep links to /flows. */} } /> diff --git a/app/src/features/coreRegistries/CoreRegistriesPage.test.tsx b/app/src/features/coreRegistries/CoreRegistriesPage.test.tsx new file mode 100644 index 0000000000..1278919761 --- /dev/null +++ b/app/src/features/coreRegistries/CoreRegistriesPage.test.tsx @@ -0,0 +1,981 @@ +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; + +import ReadOnlyJson from './ReadOnlyJson'; +import RegistryDetailDrawer from './RegistryDetailDrawer'; +import RegistryDetailPane from './RegistryDetailPane'; +import type { RegistryInspectionState } from './types'; + +const openDetailMock = vi.hoisted(() => vi.fn()); +const setTabMock = vi.hoisted(() => vi.fn()); +const refreshActiveTabMock = vi.hoisted(() => vi.fn()); +const loadMoreCollectionMock = vi.hoisted(() => vi.fn()); +const retryCollectionMock = vi.hoisted(() => vi.fn()); +const useRegistryInspectionMock = vi.hoisted(() => vi.fn()); + +vi.mock('./useRegistryInspection', () => ({ + useRegistryInspection: () => useRegistryInspectionMock(), +})); + +const CoreRegistriesPage = (await import('./CoreRegistriesPage')).default; + +let viewportWidth = 1440; + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn((query: string) => ({ + matches: query === '(min-width: 1280px)' ? viewportWidth >= 1280 : false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +const baseState: RegistryInspectionState = { + urlState: { tab: 'agents', detail: null }, + surfaceError: null, + tabs: { + agents: { + generation: 1, + observedAt: '2026-09-02T04:05:00Z', + summaryState: 'fresh', + detail: { kind: 'none' }, + collections: { + agents: { + items: [ + { + id: 'agent-row', + agentKey: 'agent.alpha', + version: 7, + lifecycleState: 'active', + configurationFingerprint: 'a'.repeat(64), + ownerActorType: 'service', + ownerActorId: 'registry-reader', + createdAt: '2026-09-01T12:00:00Z', + }, + ], + nextCursor: null, + observation: { kind: 'loaded', observedAt: '2026-09-02T04:05:00Z', stale: false }, + lastObservedAt: '2026-09-02T04:05:00Z', + successGeneration: 1, + restartGeneration: null, + restartAttemptedGeneration: null, + }, + }, + }, + tools: { + generation: 1, + observedAt: '2026-09-02T04:06:00Z', + summaryState: 'partial', + detail: { kind: 'none' }, + collections: { + toolDefinitions: { + items: [ + { + toolKey: 'tool.alpha', + version: 3, + lifecycleState: 'active', + definitionFingerprint: 'b'.repeat(64), + schemaVersion: 1, + displayName: 'Tool Alpha', + description: 'Reads data', + toolEffectClass: 'read_only', + abstractAuthScopes: ['scope.read'], + createdAt: '2026-09-01T12:05:00Z', + }, + { + toolKey: 'tool.beta', + version: 2, + lifecycleState: 'active', + definitionFingerprint: 'c'.repeat(64), + schemaVersion: 1, + displayName: 'Tool Beta', + description: 'Does not have an enablement yet', + toolEffectClass: 'effectful', + abstractAuthScopes: [], + createdAt: '2026-09-01T12:08:00Z', + }, + ], + nextCursor: 'tool-definition-cursor-2', + observation: { + kind: 'stale', + observedAt: '2026-09-02T04:06:00Z', + error: { kind: 'YouPetCoreTransport' }, + }, + lastObservedAt: '2026-09-02T04:06:00Z', + successGeneration: 1, + restartGeneration: null, + restartAttemptedGeneration: null, + }, + toolEnablements: { + items: [ + { + toolKey: 'tool.alpha', + version: 5, + lifecycleState: 'disabled', + generation: 12, + timeoutCapMs: 5000, + approvalRequired: false, + allowTtlSeconds: null, + auditMode: 'metadata_only', + updatedAt: '2026-09-01T12:06:00Z', + }, + ], + observation: { kind: 'loaded', observedAt: '2026-09-02T04:06:00Z', stale: false }, + lastObservedAt: '2026-09-02T04:06:00Z', + successGeneration: 1, + restartGeneration: null, + restartAttemptedGeneration: null, + }, + }, + }, + connectors: { + generation: 1, + observedAt: '2026-09-02T04:07:00Z', + summaryState: 'fresh', + detail: { kind: 'none' }, + collections: { + connectorTypes: { + items: [ + { + connectorKey: 'connector.wecom', + version: 4, + lifecycleState: 'active', + sourceType: 'wecom', + connectorTypeFingerprint: 'd'.repeat(64), + capabilities: ['messages.read'], + createdAt: '2026-09-01T12:20:00Z', + }, + ], + nextCursor: null, + observation: { kind: 'loaded', observedAt: '2026-09-02T04:07:00Z', stale: false }, + lastObservedAt: '2026-09-02T04:07:00Z', + successGeneration: 1, + restartGeneration: null, + restartAttemptedGeneration: null, + }, + connectorBindings: { + items: [ + { + bindingKey: 'binding.ops-primary', + version: 2, + lifecycleState: 'active', + connectorTypeKey: 'connector.wecom', + connectorTypeVersion: 4, + connectorTypeFingerprint: 'd'.repeat(64), + enabledCapabilities: ['messages.read'], + bindingFingerprint: 'e'.repeat(64), + createdAt: '2026-09-01T12:21:00Z', + }, + ], + nextCursor: null, + observation: { kind: 'loaded', observedAt: '2026-09-02T04:07:00Z', stale: false }, + lastObservedAt: '2026-09-02T04:07:00Z', + successGeneration: 1, + restartGeneration: null, + restartAttemptedGeneration: null, + }, + }, + }, + }, +}; + +function cloneState(overrides?: Partial): RegistryInspectionState { + return { + ...baseState, + ...overrides, + urlState: overrides?.urlState ?? baseState.urlState, + tabs: overrides?.tabs ?? baseState.tabs, + }; +} + +function readSource(relativePath: string): string { + return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); +} + +describe('CoreRegistriesPage', () => { + it('keeps registry inspection copy in locale tables instead of hard-coded source literals', () => { + const homeSource = readSource('../../pages/Home.tsx'); + const pageSource = readSource('./CoreRegistriesPage.tsx'); + const collectionSource = readSource('./RegistryCollectionPane.tsx'); + const detailSource = readSource('./RegistryDetailPane.tsx'); + const drawerSource = readSource('./RegistryDetailDrawer.tsx'); + + for (const [source, forbidden] of [ + [ + homeSource, + [ + "title: 'Core Registries'", + "title: 'Core integration required'", + "description: 'Inspect exact Agent, Tool, and Connector records from Core.'", + "description: 'Complete the desktop Core integration before inspecting registries.'", + "description: 'Repair the desktop Core integration before inspecting registries.'", + "description: 'Reconnect the local Core bridge before inspecting registries.'", + "description: 'Reconnect to the internet before inspecting registries.'", + ], + ], + [ + pageSource, + [ + 'Registry Views', + 'Core Registries', + 'Read-only inspection for exact agent, tool, and connector records backed by Core.', + 'This screen never writes configuration, secrets, or runtime state.', + 'This screen is read-only and cannot write configuration for you.', + 'Fix the Core connection in the existing integration flow, then retry inspection here.', + 'Published agent records. No record is auto-selected.', + 'Published tool contracts, distinct from permission enablements.', + 'Permission gates and runtime approval limits for tools.', + 'Bound provider accounts and capability selections.', + ], + ], + [ + collectionSource, + [ + 'Waiting for the first observation.', + 'Refreshing the exact records from Core.', + 'Observed with no records returned.', + 'No exact records available in this collection yet.', + ], + ], + [ + detailSource, + [ + 'Copy full fingerprint', + 'Logical references need follow-up outside this read-only view.', + 'Select a record to inspect its exact registry version.', + 'Loading exact registry detail...', + 'Core reported that this exact record version no longer exists.', + 'Core could not load this exact record right now.', + ], + ], + [drawerSource, ['Registry Detail', 'aria-label="Close"']], + ] as const) { + for (const value of forbidden) { + expect(source).not.toContain(value); + } + } + }); + + it('renders registry tabs and does not auto-select a detail record', () => { + useRegistryInspectionMock.mockReturnValue({ + state: cloneState(), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByRole('heading', { name: 'Core Registries' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Agents' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Tools' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Connectors' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /agent\.alpha/i })).toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: /registry detail/i })).not.toBeInTheDocument(); + expect( + screen.getByText('Select a record to inspect its exact registry version.') + ).toBeInTheDocument(); + }); + + it('keeps Definitions and Enablements separate and distinguishes missing enablement from disabled', async () => { + const user = userEvent.setup(); + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ urlState: { tab: 'tools', detail: null } }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByRole('tab', { name: 'Tools' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('heading', { name: 'Definitions' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Enablements' })).toBeInTheDocument(); + expect(screen.getByText('Tools · Partial')).toBeInTheDocument(); + expect(screen.getByText('Stale')).toBeInTheDocument(); + expect(screen.getAllByText('Disabled')).toHaveLength(1); + expect(screen.getAllByText('No tenant enablement returned')).toHaveLength(2); + + await user.click(screen.getByRole('button', { name: /load more definitions/i })); + expect(loadMoreCollectionMock).toHaveBeenCalledWith('toolDefinitions'); + }); + + it('disables Retry until Retry-After expires and re-enables it without auto-retrying', async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-09-02T04:06:00Z')); + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + urlState: { tab: 'tools', detail: null }, + tabs: { + ...baseState.tabs, + tools: { + ...baseState.tabs.tools, + collections: { + ...baseState.tabs.tools.collections, + toolDefinitions: { + ...baseState.tabs.tools.collections.toolDefinitions, + observation: { + kind: 'stale', + observedAt: '2026-09-02T04:06:00Z', + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 2, + }, + }, + retryDisabledUntil: Date.parse('2026-09-02T04:06:02Z'), + }, + }, + }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + const definitionsPane = screen + .getByRole('heading', { name: 'Definitions' }) + .closest('section') as HTMLElement; + expect(within(definitionsPane).getByRole('button', { name: 'Retry' })).toBeDisabled(); + + await act(async () => { + vi.advanceTimersByTime(2_000); + }); + + expect(within(definitionsPane).getByRole('button', { name: 'Retry' })).toBeEnabled(); + expect(retryCollectionMock).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('disables Refresh and Load More controls while Retry-After is active', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date('2026-09-02T04:06:00Z')); + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + urlState: { tab: 'tools', detail: null }, + tabs: { + ...baseState.tabs, + tools: { + ...baseState.tabs.tools, + collections: { + ...baseState.tabs.tools.collections, + toolDefinitions: { + ...baseState.tabs.tools.collections.toolDefinitions, + retryDisabledUntil: Date.parse('2026-09-02T04:06:02Z'), + }, + }, + }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled(); + expect(screen.getByRole('button', { name: /load more definitions/i })).toBeDisabled(); + } finally { + vi.useRealTimers(); + } + }); + + it('bounds oversized Retry-After wake-up timers without retrying early', async () => { + vi.useFakeTimers(); + const setTimeoutSpy = vi.spyOn(window, 'setTimeout'); + try { + vi.setSystemTime(new Date('2026-09-02T04:06:00Z')); + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + urlState: { tab: 'tools', detail: null }, + tabs: { + ...baseState.tabs, + tools: { + ...baseState.tabs.tools, + collections: { + ...baseState.tabs.tools.collections, + toolDefinitions: { + ...baseState.tabs.tools.collections.toolDefinitions, + observation: { + kind: 'stale', + observedAt: '2026-09-02T04:06:00Z', + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 3_000_000, + }, + }, + retryDisabledUntil: Date.now() + 3_000_000_000, + }, + }, + }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + const retry = within( + screen.getByRole('heading', { name: 'Definitions' }).closest('section') as HTMLElement + ).getByRole('button', { name: 'Retry' }); + expect(retry).toBeDisabled(); + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_147_483_647); + + await act(async () => { + vi.advanceTimersByTime(1); + }); + + expect(retry).toBeDisabled(); + expect(retryCollectionMock).not.toHaveBeenCalled(); + } finally { + setTimeoutSpy.mockRestore(); + vi.useRealTimers(); + } + }); + + it('labels a Connector Binding version separately from its Connector Type version', () => { + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ urlState: { tab: 'connectors', detail: null } }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByText('v2 · connector.wecom v4')).toBeInTheDocument(); + }); + + it('renders the actual detail pane on wide screens and keeps the drawer narrow-only', () => { + viewportWidth = 1440; + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + urlState: { tab: 'agents', detail: { kind: 'agent', key: 'agent.alpha', version: 7 } }, + tabs: { + ...baseState.tabs, + agents: { + ...baseState.tabs.agents, + detail: { + kind: 'loaded', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: { + id: 'agent-row', + agentKey: 'agent.alpha', + version: 7, + lifecycleState: 'active', + configurationFingerprint: 'a'.repeat(64), + ownerActorType: 'service', + ownerActorId: 'registry-reader', + createdAt: '2026-09-01T12:00:00Z', + configuration: { + schemaVersion: 1, + domainKey: 'ops', + owner: { actorType: 'service', actorId: 'registry-reader' }, + allowedToolRefs: [], + knowledgeScopeRefs: [], + riskPolicyRef: null, + }, + }, + }, + }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByRole('heading', { name: 'agent.alpha v7' })).toBeInTheDocument(); + expect(screen.queryByRole('dialog', { name: /registry detail/i })).not.toBeInTheDocument(); + }); + + it('renders the detail drawer only on narrow screens', () => { + viewportWidth = 1024; + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + urlState: { tab: 'agents', detail: { kind: 'agent', key: 'agent.alpha', version: 7 } }, + tabs: { + ...baseState.tabs, + agents: { + ...baseState.tabs.agents, + detail: { + kind: 'loaded', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: { + id: 'agent-row', + agentKey: 'agent.alpha', + version: 7, + lifecycleState: 'active', + configurationFingerprint: 'a'.repeat(64), + ownerActorType: 'service', + ownerActorId: 'registry-reader', + createdAt: '2026-09-01T12:00:00Z', + configuration: { + schemaVersion: 1, + domainKey: 'ops', + owner: { actorType: 'service', actorId: 'registry-reader' }, + allowedToolRefs: [], + knowledgeScopeRefs: [], + riskPolicyRef: null, + }, + }, + }, + }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByRole('dialog', { name: 'agent.alpha v7' })).toBeInTheDocument(); + }); + + it('links tabs to their tabpanel and supports arrow-key tab navigation with a polite live region', async () => { + const user = userEvent.setup(); + useRegistryInspectionMock.mockReturnValue({ + state: cloneState(), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + const agentsTab = screen.getByRole('tab', { name: 'Agents' }); + const toolsTab = screen.getByRole('tab', { name: 'Tools' }); + const tabpanel = screen.getByRole('tabpanel'); + const live = screen.getByRole('status'); + + expect(agentsTab).toHaveAttribute('aria-controls', tabpanel.id); + expect(tabpanel).toHaveAttribute('aria-labelledby', agentsTab.id); + expect(live).toHaveAttribute('aria-live', 'polite'); + + agentsTab.focus(); + await user.keyboard('{ArrowRight}'); + expect(setTabMock).toHaveBeenCalledWith('tools'); + expect(toolsTab).toHaveFocus(); + }); + + it('supports keyboard row navigation inside a collection and preserves Enter activation', async () => { + const user = userEvent.setup(); + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + tabs: { + ...baseState.tabs, + agents: { + ...baseState.tabs.agents, + collections: { + agents: { + ...baseState.tabs.agents.collections.agents, + items: [ + baseState.tabs.agents.collections.agents.items[0], + { + ...baseState.tabs.agents.collections.agents.items[0], + id: 'agent-row-2', + agentKey: 'agent.beta', + version: 8, + }, + ], + }, + }, + }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + const rows = screen.getAllByRole('button', { name: /agent\.(alpha|beta)/i }); + rows[0]?.focus(); + + await user.keyboard('{ArrowDown}'); + expect(rows[1]).toHaveFocus(); + + await user.keyboard('{Home}'); + expect(rows[0]).toHaveFocus(); + + await user.keyboard('{End}'); + expect(rows[1]).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(openDetailMock).toHaveBeenCalledWith({ kind: 'agent', key: 'agent.beta', version: 8 }); + }); + + it.each([ + [ + { kind: 'YouPetConfigMissing' as const }, + 'Core integration required', + 'No Core integration configuration was found for registry inspection.', + ], + [ + { kind: 'YouPetConfigInvalid' as const }, + 'Core integration invalid', + 'The current Core integration configuration is invalid for registry inspection.', + ], + [ + { kind: 'YouPetCoreHttpError' as const, httpStatus: 401, coreCode: 'unauthorized' }, + 'Core authentication required', + 'Registry inspection could not authenticate with Core for this session.', + ], + [ + { kind: 'YouPetCoreHttpError' as const, httpStatus: 403, coreCode: 'forbidden_actor' }, + 'Registry inspection forbidden', + 'Core rejected this actor for tenant registry inspection.', + ], + [ + { + kind: 'YouPetCoreHttpError' as const, + httpStatus: 503, + coreCode: 'kernel_tenant_unavailable', + }, + 'Tenant unavailable', + 'Core reported that this tenant is temporarily unavailable for registry inspection.', + ], + [ + { + kind: 'YouPetCoreHttpError' as const, + httpStatus: 503, + coreCode: 'kernel_tenant_invariant_violation', + }, + 'Tenant invariant violation', + 'Core reported a tenant invariant violation for registry inspection.', + ], + ])('shows distinct read-only blocker copy for %j', (surfaceError, heading, description) => { + useRegistryInspectionMock.mockReturnValue({ + state: cloneState({ + surfaceError, + tabs: { + ...baseState.tabs, + agents: { ...baseState.tabs.agents, summaryState: 'blocked' }, + tools: { ...baseState.tabs.tools, summaryState: 'blocked' }, + connectors: { ...baseState.tabs.connectors, summaryState: 'blocked' }, + }, + }), + setTab: setTabMock, + refreshActiveTab: refreshActiveTabMock, + loadMoreCollection: loadMoreCollectionMock, + openDetail: openDetailMock, + retryCollection: retryCollectionMock, + }); + + render(); + + expect(screen.getByRole('heading', { name: heading })).toBeInTheDocument(); + expect(screen.getByText(description)).toBeInTheDocument(); + expect( + screen.getByText('This screen is read-only and cannot write configuration for you.') + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /save|connect|apply/i })).not.toBeInTheDocument(); + }); +}); + +describe('RegistryDetailPane', () => { + it('shows exact cross-links, family fingerprint labels, lifecycle explanations, and inert JSON', async () => { + const writeText = vi.fn(); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + + render( + + ); + + expect(screen.getByText('Agent lifecycle')).toBeInTheDocument(); + expect( + screen.getByText('Active records are publish states, not runtime permission grants.') + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /tool\.alpha v3/i })).toBeInTheDocument(); + expect(screen.getByText('Unresolved: tool.missing v9')).toBeInTheDocument(); + expect( + screen.getByText('Logical references need follow-up outside this read-only view.') + ).toBeInTheDocument(); + expect(screen.getByText(/"domainKey": "ops"/)).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /copy full fingerprint/i })); + expect(writeText).toHaveBeenCalledWith('a'.repeat(64)); + expect(screen.getByText('Configuration fingerprint · aaaaaaaaaaaa')).toBeInTheDocument(); + }); + + it('keeps tool definition enablement matching exact by {toolKey, version} and summarizes schemas before exposing raw JSON', () => { + render( + + ); + + expect(screen.getByText('No tenant enablement returned')).toBeInTheDocument(); + expect(screen.getByText('Input schema')).toBeInTheDocument(); + expect(screen.getByText('Output schema')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'View raw JSON' })).toBeInTheDocument(); + expect(screen.getByText('Definition fingerprint · bbbbbbbbbbbb')).toBeInTheDocument(); + }); + + it('keeps exact Agent tool links actionable before the Tools tab is first observed', () => { + const lazyToolsState = cloneState({ + tabs: { + ...baseState.tabs, + tools: { + ...baseState.tabs.tools, + generation: 0, + observedAt: null, + summaryState: 'idle', + collections: { + ...baseState.tabs.tools.collections, + toolDefinitions: { + items: [], + nextCursor: null, + observation: { kind: 'not_loaded' }, + lastObservedAt: null, + successGeneration: null, + restartGeneration: null, + restartAttemptedGeneration: null, + }, + }, + }, + }, + }); + + render( + + ); + + expect(screen.getByRole('button', { name: 'tool.lazy v1' })).toBeInTheDocument(); + }); + + it('shows connector contract summaries and logical reference warnings without exposing secrets', () => { + render( + + ); + + expect(screen.getByText('Binding fingerprint · eeeeeeeeeeee')).toBeInTheDocument(); + expect(screen.getByText('wechat:acct-primary')).toBeInTheDocument(); + expect(screen.getByText('Logical reference only; secret not displayed.')).toBeInTheDocument(); + }); + + it('shows scalar Connector delivery behavior in the structured summary', () => { + render( + + ); + + expect(screen.getByText(/mode: push/)).toBeInTheDocument(); + expect(screen.getByText(/channel: events/)).toBeInTheDocument(); + }); +}); + +describe('RegistryDetailDrawer', () => { + it('traps focus, closes on Escape, and restores focus to the launching control', async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + + render(); + const launcher = screen.getByRole('button', { name: 'Launcher' }); + launcher.focus(); + + const drawer = render( + + + + ); + + const dialog = screen.getByRole('dialog', { name: 'Registry detail' }); + expect(dialog).toBeInTheDocument(); + + expect(within(dialog).getByRole('button', { name: 'Close' })).toHaveFocus(); + + await user.tab(); + expect(within(dialog).getByRole('button', { name: 'Focusable detail action' })).toHaveFocus(); + + await user.keyboard('{Escape}'); + expect(handleClose).toHaveBeenCalledTimes(1); + drawer.unmount(); + expect(launcher).toHaveFocus(); + }); +}); + +describe('ReadOnlyJson', () => { + it('renders structured JSON in an inert viewer', () => { + render(); + + expect(screen.getByText(/"schemaVersion": 1/)).toBeInTheDocument(); + expect(screen.getByText(/"toolKey": "tool.alpha"/)).toBeInTheDocument(); + expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); + }); +}); diff --git a/app/src/features/coreRegistries/CoreRegistriesPage.tsx b/app/src/features/coreRegistries/CoreRegistriesPage.tsx new file mode 100644 index 0000000000..ccb35a71f8 --- /dev/null +++ b/app/src/features/coreRegistries/CoreRegistriesPage.tsx @@ -0,0 +1,636 @@ +import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { RegistryBridgeErrorMeta } from '../../services/api/coreRegistriesClient'; +import RegistryCollectionPane, { type RegistryCollectionPaneItem } from './RegistryCollectionPane'; +import RegistryDetailDrawer from './RegistryDetailDrawer'; +import RegistryDetailPane from './RegistryDetailPane'; +import { REGISTRY_TABS, type RegistryTab } from './types'; +import { useRegistryInspection } from './useRegistryInspection'; + +type TranslateFn = (key: string, fallback?: string) => string; + +function formatLiteral(value: string): string { + return value + .split(/[_-]/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function shortFingerprint(value: string): string { + return value.slice(0, 12); +} + +function fingerprintLabel( + t: TranslateFn, + family: 'config' | 'definition' | 'type' | 'binding', + value: string +): string { + return t(`registries.items.fingerprint.${family}`).replace( + '{fingerprint}', + shortFingerprint(value) + ); +} + +function tabLabel(t: TranslateFn, tab: RegistryTab): string { + return t(`registries.tab.${tab}`); +} + +function summaryStateLabel(t: TranslateFn, summaryState: string): string { + return t(`registries.summaryState.${summaryState}`); +} + +function tabSummary(t: TranslateFn, tab: RegistryTab, summaryState: string) { + const label = summaryStateLabel(t, summaryState); + if (summaryState === 'fresh') { + return `${tabLabel(t, tab)} · ${t('registries.summaryState.observed')}`; + } + return `${tabLabel(t, tab)} · ${label}`; +} + +function describeBlocker(t: TranslateFn, error: RegistryBridgeErrorMeta) { + if (error.kind === 'YouPetConfigMissing' || error.kind === 'YouPetConfigInvalid') { + return error.kind === 'YouPetConfigMissing' + ? { + title: t('registries.blocker.configRequiredTitle'), + description: t('registries.blocker.configMissingDescription'), + } + : { + title: t('registries.blocker.configInvalidTitle'), + description: t('registries.blocker.configInvalidDescription'), + }; + } + + if (error.kind === 'YouPetCoreHttpError' && error.httpStatus === 401) { + return { + title: t('registries.blocker.authRequiredTitle'), + description: t('registries.blocker.authRequiredDescription'), + }; + } + + if ( + error.kind === 'YouPetCoreHttpError' && + error.httpStatus === 403 && + error.coreCode === 'forbidden_actor' + ) { + return { + title: t('registries.blocker.forbiddenTitle'), + description: t('registries.blocker.forbiddenDescription'), + }; + } + + if ( + error.kind === 'YouPetCoreHttpError' && + error.httpStatus === 503 && + error.coreCode === 'kernel_tenant_unavailable' + ) { + return { + title: t('registries.blocker.tenantUnavailableTitle'), + description: t('registries.blocker.tenantUnavailableDescription'), + }; + } + + if ( + error.kind === 'YouPetCoreHttpError' && + error.httpStatus === 503 && + error.coreCode === 'kernel_tenant_invariant_violation' + ) { + return { + title: t('registries.blocker.tenantInvariantTitle'), + description: t('registries.blocker.tenantInvariantDescription'), + }; + } + + return { + title: t('registries.blocker.configRequiredTitle'), + description: t('registries.blocker.genericDescription'), + }; +} + +function hasMore(nextCursor: string | null) { + return typeof nextCursor === 'string' && nextCursor.length > 0; +} + +function isRetryDisabled(retryDisabledUntil: number | null | undefined): boolean { + return typeof retryDisabledUntil === 'number' && retryDisabledUntil > Date.now(); +} + +const MAX_BROWSER_TIMEOUT_MS = 2_147_483_647; + +function useMinWidth(query: string): boolean { + const getMatches = () => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia(query).matches + : false; + const [matches, setMatches] = useState(getMatches); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return; + } + + const mediaQuery = window.matchMedia(query); + const update = (event?: MediaQueryListEvent) => + setMatches(event?.matches ?? mediaQuery.matches); + + update(); + mediaQuery.addEventListener?.('change', update); + mediaQuery.addListener?.(update); + return () => { + mediaQuery.removeEventListener?.('change', update); + mediaQuery.removeListener?.(update); + }; + }, [query]); + + return matches; +} + +function tabId(tab: RegistryTab): string { + return `registry-tab-${tab}`; +} + +function tabPanelId(tab: RegistryTab): string { + return `registry-panel-${tab}`; +} + +export default function CoreRegistriesPage() { + const { t } = useT(); + const { state, setTab, refreshActiveTab, loadMoreCollection, openDetail, retryCollection } = + useRegistryInspection(); + const activeTab = state.urlState.tab; + const detailState = state.tabs[activeTab].detail; + const isWideLayout = useMinWidth('(min-width: 1280px)'); + const tabRefs = useRef>({ + agents: null, + tools: null, + connectors: null, + }); + const blocker = state.surfaceError ? describeBlocker(t, state.surfaceError) : null; + const [retryClockTick, setRetryClockTick] = useState(0); + + const activeRetryDisabledUntilValues = useMemo(() => { + if (activeTab === 'agents') { + return [state.tabs.agents.collections.agents.retryDisabledUntil]; + } + + if (activeTab === 'tools') { + return [ + state.tabs.tools.collections.toolDefinitions.retryDisabledUntil, + state.tabs.tools.collections.toolEnablements.retryDisabledUntil, + ]; + } + + return [ + state.tabs.connectors.collections.connectorTypes.retryDisabledUntil, + state.tabs.connectors.collections.connectorBindings.retryDisabledUntil, + ]; + }, [activeTab, state.tabs]); + + const activeTabRetryDisabled = activeRetryDisabledUntilValues.some(isRetryDisabled); + + useEffect(() => { + const now = Date.now(); + const nextRetryWakeAt = activeRetryDisabledUntilValues + .filter((value): value is number => typeof value === 'number' && value > now) + .sort((left, right) => left - right)[0]; + + if (!nextRetryWakeAt) { + return; + } + + const timer = window.setTimeout( + () => { + setRetryClockTick(current => current + 1); + }, + Math.min(nextRetryWakeAt - now, MAX_BROWSER_TIMEOUT_MS) + ); + + return () => window.clearTimeout(timer); + }, [activeRetryDisabledUntilValues, retryClockTick]); + + const agentItems = useMemo( + () => + state.tabs.agents.collections.agents.items.map(agent => ({ + id: agent.id, + title: agent.agentKey, + subtitle: `v${agent.version} · ${formatLiteral(agent.ownerActorType)} · ${agent.ownerActorId}`, + meta: [ + t('registries.items.created').replace( + '{date}', + new Date(agent.createdAt).toLocaleDateString() + ), + ], + statusLabel: formatLiteral(agent.lifecycleState), + fingerprintLabel: fingerprintLabel(t, 'config', agent.configurationFingerprint), + onSelect: () => + void openDetail({ kind: 'agent', key: agent.agentKey, version: agent.version }), + })), + [openDetail, state.tabs.agents.collections.agents.items, t] + ); + + const toolItems = useMemo( + () => + state.tabs.tools.collections.toolDefinitions.items.map(definition => { + const enablement = state.tabs.tools.collections.toolEnablements.items.find( + item => item.toolKey === definition.toolKey && item.version === definition.version + ); + const statusLabel = enablement + ? enablement.lifecycleState === 'enabled' + ? t('common.enabled') + : t('common.disabled') + : t('registries.items.status.noTenantEnablement'); + + return { + id: `${definition.toolKey}:${definition.version}`, + title: definition.displayName, + subtitle: `${definition.toolKey} v${definition.version}`, + meta: [formatLiteral(definition.toolEffectClass)], + statusLabel, + fingerprintLabel: fingerprintLabel(t, 'definition', definition.definitionFingerprint), + onSelect: () => + void openDetail({ + kind: 'tool-definition', + key: definition.toolKey, + version: definition.version, + }), + }; + }), + [ + openDetail, + state.tabs.tools.collections.toolDefinitions.items, + state.tabs.tools.collections.toolEnablements.items, + t, + ] + ); + + const enablementItems = useMemo( + () => + state.tabs.tools.collections.toolEnablements.items.map(enablement => ({ + id: `${enablement.toolKey}:${enablement.version}`, + title: enablement.toolKey, + subtitle: `v${enablement.version} · generation ${enablement.generation}`, + meta: [ + enablement.auditMode + ? formatLiteral(enablement.auditMode) + : t('registries.items.meta.noAuditMode'), + enablement.approvalRequired + ? t('registries.items.meta.approvalRequired') + : t('registries.items.meta.noApprovalGate'), + ], + statusLabel: formatLiteral(enablement.lifecycleState), + onSelect: () => + void openDetail({ + kind: 'tool-enablement', + key: enablement.toolKey, + version: enablement.version, + }), + })), + [openDetail, state.tabs.tools.collections.toolEnablements.items, t] + ); + + const connectorTypeItems = useMemo( + () => + state.tabs.connectors.collections.connectorTypes.items.map(connectorType => ({ + id: `${connectorType.connectorKey}:${connectorType.version}`, + title: connectorType.connectorKey, + subtitle: `v${connectorType.version} · ${connectorType.sourceType}`, + meta: connectorType.capabilities, + statusLabel: formatLiteral(connectorType.lifecycleState), + fingerprintLabel: fingerprintLabel(t, 'type', connectorType.connectorTypeFingerprint), + onSelect: () => + void openDetail({ + kind: 'connector-type', + key: connectorType.connectorKey, + version: connectorType.version, + }), + })), + [openDetail, state.tabs.connectors.collections.connectorTypes.items, t] + ); + + const connectorBindingItems = useMemo( + () => + state.tabs.connectors.collections.connectorBindings.items.map(binding => ({ + id: `${binding.bindingKey}:${binding.version}`, + title: binding.bindingKey, + subtitle: `v${binding.version} · ${binding.connectorTypeKey} v${binding.connectorTypeVersion}`, + meta: binding.enabledCapabilities, + statusLabel: formatLiteral(binding.lifecycleState), + fingerprintLabel: fingerprintLabel(t, 'binding', binding.bindingFingerprint), + onSelect: () => + void openDetail({ + kind: 'connector-binding', + key: binding.bindingKey, + version: binding.version, + }), + })), + [openDetail, state.tabs.connectors.collections.connectorBindings.items, t] + ); + + const selectedDetailTitle = + detailState.kind === 'loaded' || detailState.kind === 'loading' + ? `${detailState.detail.key} v${detailState.detail.version}` + : t('registries.drawer.title'); + + const closeDetail = () => { + void setTab(activeTab); + }; + + const liveMessage = blocker + ? t('registries.live.blocked') + .replace('{title}', blocker.title) + .replace('{description}', blocker.description) + : detailState.kind === 'loaded' + ? t('registries.live.loaded') + .replace('{tab}', tabLabel(t, activeTab)) + .replace('{key}', detailState.detail.key) + .replace('{version}', String(detailState.detail.version)) + : detailState.kind === 'loading' + ? t('registries.live.loading') + .replace('{tab}', tabLabel(t, activeTab)) + .replace('{key}', detailState.detail.key) + .replace('{version}', String(detailState.detail.version)) + : t('registries.live.idle').replace('{tab}', tabLabel(t, activeTab)); + + const handleTabKeyDown = async (event: KeyboardEvent, tab: RegistryTab) => { + const currentIndex = REGISTRY_TABS.indexOf(tab); + if (currentIndex === -1) { + return; + } + + let nextIndex: number | null = null; + switch (event.key) { + case 'ArrowRight': + case 'ArrowDown': + nextIndex = (currentIndex + 1) % REGISTRY_TABS.length; + break; + case 'ArrowLeft': + case 'ArrowUp': + nextIndex = (currentIndex - 1 + REGISTRY_TABS.length) % REGISTRY_TABS.length; + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = REGISTRY_TABS.length - 1; + break; + case 'Enter': + case ' ': + event.preventDefault(); + await setTab(tab); + return; + default: + return; + } + + event.preventDefault(); + const nextTab = REGISTRY_TABS[nextIndex]; + if (!nextTab) { + return; + } + tabRefs.current[nextTab]?.focus(); + await setTab(nextTab); + }; + + return ( +
+
+
+ {liveMessage} +
+ +
+
+
+

+ {t('registries.page.eyebrow')} +

+

+ {t('registries.page.title')} +

+

+ {t('registries.page.description')} {t('registries.page.readOnly')} +

+
+ + +
+
+ + {state.surfaceError ? ( +
+

{blocker?.title}

+

{blocker?.description}

+

{t('registries.blocker.readOnly')}

+

{t('registries.blocker.fixFlow')}

+ +
+ ) : ( + <> +
+
+ {REGISTRY_TABS.map(tab => { + const selected = tab === activeTab; + return ( + + ); + })} +
+ +
+ {REGISTRY_TABS.map(tab => ( + + {tabSummary(t, tab, state.tabs[tab].summaryState)} + + ))} +
+
+ +
+
+ {activeTab === 'agents' ? ( + { + void loadMoreCollection('agents'); + }} + onRetry={() => { + void retryCollection('agents'); + }} + retryDisabled={isRetryDisabled( + state.tabs.agents.collections.agents.retryDisabledUntil + )} + loadMoreDisabled={isRetryDisabled( + state.tabs.agents.collections.agents.retryDisabledUntil + )} + /> + ) : null} + + {activeTab === 'tools' ? ( +
+ { + void loadMoreCollection('toolDefinitions'); + }} + onRetry={() => { + void retryCollection('toolDefinitions'); + }} + retryDisabled={isRetryDisabled( + state.tabs.tools.collections.toolDefinitions.retryDisabledUntil + )} + loadMoreDisabled={isRetryDisabled( + state.tabs.tools.collections.toolDefinitions.retryDisabledUntil + )} + /> + { + void retryCollection('toolEnablements'); + }} + retryDisabled={isRetryDisabled( + state.tabs.tools.collections.toolEnablements.retryDisabledUntil + )} + /> +
+ ) : null} + + {activeTab === 'connectors' ? ( +
+ { + void loadMoreCollection('connectorTypes'); + }} + onRetry={() => { + void retryCollection('connectorTypes'); + }} + retryDisabled={isRetryDisabled( + state.tabs.connectors.collections.connectorTypes.retryDisabledUntil + )} + loadMoreDisabled={isRetryDisabled( + state.tabs.connectors.collections.connectorTypes.retryDisabledUntil + )} + /> + { + void loadMoreCollection('connectorBindings'); + }} + onRetry={() => { + void retryCollection('connectorBindings'); + }} + retryDisabled={isRetryDisabled( + state.tabs.connectors.collections.connectorBindings.retryDisabledUntil + )} + loadMoreDisabled={isRetryDisabled( + state.tabs.connectors.collections.connectorBindings.retryDisabledUntil + )} + /> +
+ ) : null} +
+ + {isWideLayout ? ( + + ) : null} +
+ + )} +
+ + {!isWideLayout && detailState.kind !== 'none' ? ( + + + + ) : null} +
+ ); +} diff --git a/app/src/features/coreRegistries/ReadOnlyJson.tsx b/app/src/features/coreRegistries/ReadOnlyJson.tsx new file mode 100644 index 0000000000..703bd36b12 --- /dev/null +++ b/app/src/features/coreRegistries/ReadOnlyJson.tsx @@ -0,0 +1,19 @@ +interface ReadOnlyJsonProps { + value: unknown; +} + +function stringifyJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export default function ReadOnlyJson({ value }: ReadOnlyJsonProps) { + return ( +
+      {stringifyJson(value)}
+    
+ ); +} diff --git a/app/src/features/coreRegistries/RegistryCollectionPane.tsx b/app/src/features/coreRegistries/RegistryCollectionPane.tsx new file mode 100644 index 0000000000..ededa8b5c6 --- /dev/null +++ b/app/src/features/coreRegistries/RegistryCollectionPane.tsx @@ -0,0 +1,251 @@ +import { useT } from '../../lib/i18n/I18nContext'; +import type { RegistryObservationState } from './types'; + +export interface RegistryCollectionPaneItem { + id: string; + title: string; + subtitle: string; + meta?: string[]; + statusLabel?: string; + fingerprintLabel?: string; + onSelect: () => void; +} + +interface RegistryCollectionPaneProps { + title: string; + description: string; + observation: RegistryObservationState; + items: RegistryCollectionPaneItem[]; + loadMoreLabel?: string; + onLoadMore?: () => void; + onRetry?: () => void; + retryDisabled?: boolean; + loadMoreDisabled?: boolean; + hasMore?: boolean; +} + +type TranslateFn = (key: string, fallback?: string) => string; + +function summarizeObservation(t: TranslateFn, observation: RegistryObservationState) { + switch (observation.kind) { + case 'not_loaded': + return { + label: t('registries.collection.state.idle'), + tone: 'stone', + body: t('registries.collection.body.idle'), + }; + case 'loading': + return { + label: t('registries.collection.state.loading'), + tone: 'sky', + body: t('registries.collection.body.loading'), + }; + case 'empty': + return { + label: t('registries.collection.state.observed'), + tone: 'stone', + body: t('registries.collection.body.empty'), + }; + case 'loaded': + return { + label: t('registries.collection.state.observed'), + tone: 'sage', + body: t('registries.collection.body.observedAt').replace( + '{observedAt}', + observation.observedAt + ), + }; + case 'stale': + return { + label: t('registries.collection.state.stale'), + tone: 'amber', + body: t('registries.collection.body.stale').replace('{observedAt}', observation.observedAt), + }; + case 'blocked': + return { + label: t('registries.collection.state.blocked'), + tone: 'coral', + body: t('registries.collection.body.blocked'), + }; + } +} + +type ObservationTone = ReturnType['tone']; + +function badgeClass(tone: ObservationTone) { + switch (tone) { + case 'sage': + return 'border-sage-200 bg-sage-50 text-sage-700'; + case 'amber': + return 'border-amber-200 bg-amber-50 text-amber-700'; + case 'coral': + return 'border-coral-200 bg-coral-50 text-coral-700'; + case 'sky': + return 'border-sky-200 bg-sky-50 text-sky-700'; + default: + return 'border-stone-200 bg-stone-50 text-stone-700 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200'; + } +} + +function focusSiblingRow( + current: HTMLButtonElement, + target: { kind: 'offset'; value: number } | { kind: 'first' } | { kind: 'last' } +) { + const container = current.closest('[data-registry-collection-items]'); + if (!container) { + return; + } + + const rows = Array.from( + container.querySelectorAll('[data-registry-collection-row="true"]') + ); + const currentIndex = rows.indexOf(current); + if (currentIndex === -1 || rows.length === 0) { + return; + } + + if (target.kind === 'first') { + rows[0]?.focus(); + return; + } + + if (target.kind === 'last') { + rows[rows.length - 1]?.focus(); + return; + } + + const nextIndex = (currentIndex + target.value + rows.length) % rows.length; + rows[nextIndex]?.focus(); +} + +export default function RegistryCollectionPane({ + title, + description, + observation, + items, + loadMoreLabel, + onLoadMore, + onRetry, + retryDisabled = false, + loadMoreDisabled = false, + hasMore = false, +}: RegistryCollectionPaneProps) { + const { t } = useT(); + const summary = summarizeObservation(t, observation); + + return ( +
+
+
+

{title}

+

{description}

+
+ + + {summary.label} + +
+ +

{summary.body}

+ +
+ {items.length === 0 ? ( +
+ {t('registries.collection.empty')} +
+ ) : ( + items.map(item => ( + + )) + )} +
+ +
+ {onRetry ? ( + + ) : null} + + {hasMore && onLoadMore ? ( + + ) : null} +
+
+ ); +} diff --git a/app/src/features/coreRegistries/RegistryDetailDrawer.tsx b/app/src/features/coreRegistries/RegistryDetailDrawer.tsx new file mode 100644 index 0000000000..414a75bf31 --- /dev/null +++ b/app/src/features/coreRegistries/RegistryDetailDrawer.tsx @@ -0,0 +1,129 @@ +import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +import { useT } from '../../lib/i18n/I18nContext'; + +interface RegistryDetailDrawerProps { + title: string; + children: React.ReactNode; + onClose: () => void; +} + +const FOCUSABLE_SELECTOR = + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export default function RegistryDetailDrawer({ + title, + children, + onClose, +}: RegistryDetailDrawerProps) { + const { t } = useT(); + const drawerRef = useRef(null); + const closeButtonRef = useRef(null); + const previousFocusRef = useRef(null); + + useLayoutEffect(() => { + previousFocusRef.current = document.activeElement as HTMLElement | null; + closeButtonRef.current?.focus(); + + return () => { + previousFocusRef.current?.focus?.(); + }; + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + onClose(); + return; + } + + if (event.key !== 'Tab' || !drawerRef.current) { + return; + } + + const focusable = Array.from( + drawerRef.current.querySelectorAll(FOCUSABLE_SELECTOR) + ).filter(node => !node.hasAttribute('disabled')); + if (focusable.length === 0) { + event.preventDefault(); + drawerRef.current.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement as HTMLElement | null; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first?.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + const drawerTitleId = useMemo(() => 'registry-detail-drawer-title', []); + + return createPortal( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }}> + , + document.body + ); +} diff --git a/app/src/features/coreRegistries/RegistryDetailPane.tsx b/app/src/features/coreRegistries/RegistryDetailPane.tsx new file mode 100644 index 0000000000..a85d504db5 --- /dev/null +++ b/app/src/features/coreRegistries/RegistryDetailPane.tsx @@ -0,0 +1,742 @@ +import { useId, useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { + AgentRegistryAgent, + ConnectorNormalizationContract, + ConnectorRegistryBinding, + ConnectorRegistryType, + ToolRegistryToolDefinition, + ToolRegistryToolEnablement, +} from '../../services/api/coreRegistriesClient'; +import ReadOnlyJson from './ReadOnlyJson'; +import type { + RegistryDetailRef, + RegistryDetailState, + RegistryInspectionState, + RegistryTab, +} from './types'; + +interface RegistryDetailPaneProps { + activeTab: RegistryTab; + detailState: RegistryDetailState; + state: RegistryInspectionState; + onOpenDetail: (detail: RegistryDetailRef) => void | Promise; +} + +type TranslateFn = (key: string, fallback?: string) => string; + +function formatLiteral(value: string): string { + return value + .split(/[_-]/) + .filter(Boolean) + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function formatDate(t: TranslateFn, value: string | null | undefined): string { + if (!value) { + return t('registries.detail.notAvailable'); + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + +function shortFingerprint(value: string): string { + return value.slice(0, 12); +} + +function canResolveToolDefinition( + state: RegistryInspectionState, + toolKey: string, + version: number +) { + const collection = state.tabs.tools.collections.toolDefinitions; + const match = collection.items.find(item => item.toolKey === toolKey && item.version === version); + return { match, observation: collection.observation.kind }; +} + +function canResolveToolEnablement( + state: RegistryInspectionState, + toolKey: string, + version: number +) { + const collection = state.tabs.tools.collections.toolEnablements; + const match = collection.items.find(item => item.toolKey === toolKey && item.version === version); + return { match, observation: collection.observation.kind }; +} + +function canResolveConnectorType( + state: RegistryInspectionState, + connectorKey: string, + version: number +) { + const collection = state.tabs.connectors.collections.connectorTypes; + const match = collection.items.find( + item => item.connectorKey === connectorKey && item.version === version + ); + return { match, observation: collection.observation.kind }; +} + +async function copyText(value: string) { + if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) { + return; + } + + try { + await navigator.clipboard.writeText(value); + } catch { + // Clipboard can be unavailable inside Tauri webviews; ignore. + } +} + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function FieldList({ entries }: { entries: Array<[label: string, value: React.ReactNode]> }) { + return ( +
+ {entries.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+ ); +} + +function FingerprintRow({ label, value }: { label: string; value: string }) { + const { t } = useT(); + + return ( +
+ + {label} · {shortFingerprint(value)} + + +
+ ); +} + +function summarizeSchema( + t: TranslateFn, + value: Record | null | undefined +): string { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return t('registries.detail.emptySchemaObject'); + } + const schemaType = typeof value.type === 'string' ? value.type : null; + const propertyCount = + value.properties && typeof value.properties === 'object' + ? Object.keys(value.properties as Record).length + : 0; + const requiredCount = Array.isArray(value.required) ? value.required.length : 0; + + if (Object.keys(value).length === 0) { + return t('registries.detail.emptySchemaObject'); + } + + const summary = [ + schemaType ? t('registries.detail.schemaType').replace('{type}', schemaType) : null, + propertyCount > 0 + ? t('registries.detail.schemaProperties').replace('{count}', String(propertyCount)) + : null, + requiredCount > 0 + ? t('registries.detail.schemaRequired').replace('{count}', String(requiredCount)) + : null, + ] + .filter(Boolean) + .join(' · '); + + return summary || summarizeRecordShape(t, value); +} + +function summarizeRecordShape(t: TranslateFn, value: Record): string { + const keys = Object.keys(value); + if (keys.length === 0) { + return t('registries.detail.noFields'); + } + + const sample = keys.slice(0, 3).join(', '); + return keys.length > 3 + ? t('registries.detail.fieldsSampleMore') + .replace('{count}', String(keys.length)) + .replace('{sample}', sample) + : t('registries.detail.fieldsSample') + .replace('{count}', String(keys.length)) + .replace('{sample}', sample); +} + +function summarizeScalarRecord(t: TranslateFn, value: Record): string { + const scalarEntries = Object.entries(value).filter( + (entry): entry is [string, string | number | boolean] => + typeof entry[1] === 'string' || typeof entry[1] === 'number' || typeof entry[1] === 'boolean' + ); + if (scalarEntries.length === 0) { + return summarizeRecordShape(t, value); + } + return scalarEntries + .slice(0, 3) + .map(([key, value]) => `${key}: ${String(value)}`) + .join(' · '); +} + +function summarizeNormalizationContracts( + t: TranslateFn, + contracts: ConnectorNormalizationContract[] +): string { + if (contracts.length === 0) { + return t('registries.detail.noNormalizationContracts'); + } + + return contracts + .map( + contract => + `${contract.evidenceFamily} -> ${contract.kernelEventType}@${contract.kernelEventSchemaVersion}` + ) + .join(', '); +} + +function CollapsibleJson({ label, value }: { label: string; value: unknown }) { + const [expanded, setExpanded] = useState(false); + const panelId = useId(); + + return ( +
+ + {expanded ? ( +
+ +
+ ) : null} +
+ ); +} + +function ReferenceButton({ + label, + detail, + onOpenDetail, +}: { + label: string; + detail: RegistryDetailRef; + onOpenDetail: (detail: RegistryDetailRef) => void | Promise; +}) { + return ( + + ); +} + +function UnresolvedReference({ label }: { label: string }) { + const { t } = useT(); + + return ( + + {t('registries.detail.unresolved').replace('{label}', label)} + + ); +} + +function DeferredReference({ label }: { label: string }) { + const { t } = useT(); + + return ( + + {t('registries.detail.deferred').replace('{label}', label)} + + ); +} + +function AgentDetail({ + record, + state, + onOpenDetail, +}: { + record: AgentRegistryAgent; + state: RegistryInspectionState; + onOpenDetail: (detail: RegistryDetailRef) => void | Promise; +}) { + const { t } = useT(); + + return ( +
+ + +
+ +

+ {t('registries.detail.note.activeRecords')} +

+
+ +
+
+ {record.configuration.allowedToolRefs.length === 0 ? ( +

+ {t('registries.detail.noExactToolRefs')} +

+ ) : ( + record.configuration.allowedToolRefs.map(ref => { + const resolution = canResolveToolDefinition(state, ref.toolKey, ref.version); + const label = `${ref.toolKey} v${ref.version}`; + if ( + resolution.match || + resolution.observation === 'not_loaded' || + resolution.observation === 'loading' + ) { + return ( + + ); + } + if ( + resolution.observation === 'loaded' || + resolution.observation === 'stale' || + resolution.observation === 'empty' || + resolution.observation === 'blocked' + ) { + return ; + } + return ; + }) + )} +
+
+ +
+

+ {t('registries.detail.note.logicalReferenceWarnings')} +

+ `${ref.sourceKey}@${ref.trustVersion} (${ref.accessScope})`) + .join(', '), + ], + [ + t('registries.detail.field.riskPolicy'), + record.configuration.riskPolicyRef + ? `${record.configuration.riskPolicyRef.policyId}@${record.configuration.riskPolicyRef.policyVersion}` + : t('registries.detail.none'), + ], + ]} + /> +
+ +
+ +
+
+ ); +} + +function ToolDefinitionDetail({ + record, + state, + onOpenDetail, +}: { + record: ToolRegistryToolDefinition; + state: RegistryInspectionState; + onOpenDetail: (detail: RegistryDetailRef) => void | Promise; +}) { + const { t } = useT(); + const enablement = canResolveToolEnablement(state, record.toolKey, record.version); + const enablementLabel = enablement.match + ? enablement.match.lifecycleState === 'enabled' + ? t('common.enabled') + : t('common.disabled') + : t('registries.items.status.noTenantEnablement'); + + return ( +
+ + +
+ +

+ {t('registries.detail.note.activeRecords')} +

+ {enablement.match ? ( + + ) : null} +
+ +
+ + +
+
+ ); +} + +function ToolEnablementDetail({ + record, + state, + onOpenDetail, +}: { + record: ToolRegistryToolEnablement; + state: RegistryInspectionState; + onOpenDetail: (detail: RegistryDetailRef) => void | Promise; +}) { + const { t } = useT(); + const resolution = canResolveToolDefinition(state, record.toolKey, record.version); + + return ( +
+
+ +

+ {t('registries.detail.note.enablementRecords')} +

+
+ +
+ {resolution.match ? ( + + ) : resolution.observation === 'loaded' || + resolution.observation === 'stale' || + resolution.observation === 'empty' || + resolution.observation === 'blocked' ? ( + + ) : ( + + )} +
+
+ ); +} + +function ConnectorTypeDetail({ record }: { record: ConnectorRegistryType }) { + const { t } = useT(); + + return ( +
+ + +
+ +
+ +
+ + +
+
+ ); +} + +function ConnectorBindingDetail({ + record, + state, + onOpenDetail, +}: { + record: ConnectorRegistryBinding; + state: RegistryInspectionState; + onOpenDetail: (detail: RegistryDetailRef) => void | Promise; +}) { + const { t } = useT(); + const resolution = canResolveConnectorType( + state, + record.connectorTypeKey, + record.connectorTypeVersion + ); + + return ( +
+ + +
+ +
+ +
+ {resolution.match ? ( + + ) : resolution.observation === 'loaded' || + resolution.observation === 'stale' || + resolution.observation === 'empty' || + resolution.observation === 'blocked' ? ( + + ) : ( + + )} +
+ +
+

+ {t('registries.detail.note.logicalReferencesSecret')} +

+ +
+
+ ); +} + +export default function RegistryDetailPane({ + activeTab: _activeTab, + detailState, + state, + onOpenDetail, +}: RegistryDetailPaneProps) { + const { t } = useT(); + + if (detailState.kind === 'none') { + return ( +
+ {t('registries.detail.empty')} +
+ ); + } + + if (detailState.kind === 'loading') { + return ( +
+ {t('registries.detail.state.loading')} +
+ ); + } + + if (detailState.kind === 'missing') { + return ( +
+ {t('registries.detail.state.missing')} +
+ ); + } + + if (detailState.kind === 'error') { + return ( +
+ {t('registries.detail.state.error')} +
+ ); + } + + const { detail, record } = detailState; + const title = `${detail.key} v${detail.version}`; + + return ( +
+
+

+ {t(`registries.detail.kind.${detail.kind}`)} +

+

{title}

+
+ + {detail.kind === 'agent' ? ( + + ) : null} + {detail.kind === 'tool-definition' ? ( + + ) : null} + {detail.kind === 'tool-enablement' ? ( + + ) : null} + {detail.kind === 'connector-type' ? ( + + ) : null} + {detail.kind === 'connector-binding' ? ( + + ) : null} +
+ ); +} diff --git a/app/src/features/coreRegistries/state.test.ts b/app/src/features/coreRegistries/state.test.ts new file mode 100644 index 0000000000..d64522f930 --- /dev/null +++ b/app/src/features/coreRegistries/state.test.ts @@ -0,0 +1,473 @@ +import { describe, expect, it } from 'vitest'; + +import type { RegistryBridgeErrorMeta } from '../../services/api/coreRegistriesClient'; +import { createRegistryInspectionState, LOAD_MORE_LIMIT, registryInspectionReducer } from './state'; + +const agentSummary = { + id: 'agent-row', + agentKey: 'agent.alpha', + version: 7, + lifecycleState: 'active' as const, + configurationFingerprint: 'a'.repeat(64), + ownerActorType: 'service' as const, + ownerActorId: 'registry-reader', + createdAt: '2026-09-01T12:00:00Z', +}; + +const agentDetail = { + ...agentSummary, + configuration: { + schemaVersion: 1, + domainKey: 'ops', + owner: { actorType: 'service' as const, actorId: 'registry-reader' }, + allowedToolRefs: [{ toolKey: 'tool.alpha', version: 3 }], + knowledgeScopeRefs: [], + riskPolicyRef: null, + }, +}; + +const toolDefinitionSummary = { + toolKey: 'tool.alpha', + version: 3, + lifecycleState: 'active' as const, + definitionFingerprint: 'b'.repeat(64), + schemaVersion: 1, + displayName: 'Tool Alpha', + description: 'Reads data', + toolEffectClass: 'read_only' as const, + abstractAuthScopes: ['scope.read'], + createdAt: '2026-09-01T12:05:00Z', +}; + +const toolEnablement = { + toolKey: 'tool.alpha', + version: 5, + lifecycleState: 'enabled' as const, + generation: 12, + timeoutCapMs: 5000, + approvalRequired: false, + allowTtlSeconds: null, + auditMode: 'metadata_only' as const, + updatedAt: '2026-09-01T12:06:00Z', +}; + +function httpError(httpStatus: number, coreCode: string): RegistryBridgeErrorMeta { + return { kind: 'YouPetCoreHttpError', httpStatus, coreCode }; +} + +describe('registry inspection state', () => { + it('keeps independent collection windows and leaves Tool Enablements unpaged', () => { + let state = createRegistryInspectionState(); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'agents', + generation: 1, + }); + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'agents', + collection: 'agents', + generation: 1, + items: [agentSummary], + nextCursor: 'agent-cursor-1', + append: false, + observedAt: '2026-09-01T12:10:00Z', + }); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 1, + }); + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'tools', + collection: 'toolDefinitions', + generation: 1, + items: [toolDefinitionSummary], + nextCursor: 'tool-definition-cursor-1', + append: false, + observedAt: '2026-09-01T12:11:00Z', + }); + state = registryInspectionReducer(state, { + type: 'unpaged_collection_request_succeeded', + tab: 'tools', + collection: 'toolEnablements', + generation: 1, + items: [toolEnablement], + observedAt: '2026-09-01T12:12:00Z', + }); + + expect(state.tabs.agents.collections.agents.items).toEqual([agentSummary]); + expect(state.tabs.agents.collections.agents.nextCursor).toBe('agent-cursor-1'); + expect(state.tabs.tools.collections.toolDefinitions.items).toEqual([toolDefinitionSummary]); + expect(state.tabs.tools.collections.toolDefinitions.nextCursor).toBe( + 'tool-definition-cursor-1' + ); + expect(state.tabs.tools.collections.toolEnablements.items).toEqual([toolEnablement]); + expect('nextCursor' in state.tabs.tools.collections.toolEnablements).toBe(false); + expect(LOAD_MORE_LIMIT).toBe(50); + }); + + it('clears detail and URL on tab switch while preserving loaded windows', () => { + let state = createRegistryInspectionState({ + tab: 'agents', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + }); + + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'agents', + collection: 'agents', + generation: 0, + items: [agentSummary], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:20:00Z', + }); + state = registryInspectionReducer(state, { + type: 'detail_request_succeeded', + tab: 'agents', + generation: 0, + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: agentDetail, + }); + state = registryInspectionReducer(state, { + type: 'tab_selected', + tab: 'tools', + source: 'user', + }); + + expect(state.urlState).toEqual({ tab: 'tools', detail: null }); + expect(state.tabs.agents.collections.agents.items).toEqual([agentSummary]); + expect(state.tabs.agents.detail).toEqual({ kind: 'none' }); + }); + + it('rejects stale generation updates and keeps generations monotonic', () => { + let state = createRegistryInspectionState(); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'agents', + generation: 1, + }); + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'agents', + generation: 2, + }); + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'agents', + collection: 'agents', + generation: 1, + items: [agentSummary], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:21:00Z', + }); + + expect(state.tabs.agents.generation).toBe(2); + expect(state.tabs.agents.collections.agents.items).toEqual([]); + + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'agents', + collection: 'agents', + generation: 2, + items: [{ ...agentSummary, agentKey: 'agent.beta' }], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:22:00Z', + }); + + expect(state.tabs.agents.collections.agents.items).toEqual([ + { ...agentSummary, agentKey: 'agent.beta' }, + ]); + }); + + it('allows one invalid-cursor restart per generation and blocks on repetition', () => { + let state = createRegistryInspectionState(); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 1, + }); + state = registryInspectionReducer(state, { + type: 'collection_request_failed', + tab: 'tools', + collection: 'toolDefinitions', + generation: 1, + error: httpError(422, 'invalid_cursor'), + restartPlanned: true, + }); + + expect(state.tabs.tools.collections.toolDefinitions.restartGeneration).toBe(1); + expect(state.tabs.tools.collections.toolDefinitions.items).toEqual([]); + expect(state.tabs.tools.collections.toolDefinitions.nextCursor).toBeNull(); + expect(state.tabs.tools.collections.toolDefinitions.observation).toEqual({ + kind: 'loading', + generation: 1, + }); + + state = registryInspectionReducer(state, { + type: 'collection_request_failed', + tab: 'tools', + collection: 'toolDefinitions', + generation: 1, + error: httpError(422, 'invalid_cursor'), + restartPlanned: false, + }); + + expect(state.tabs.tools.collections.toolDefinitions.observation).toEqual({ + kind: 'blocked', + error: httpError(422, 'invalid_cursor'), + }); + expect(state.tabs.tools.summaryState).toBe('blocked'); + }); + + it('advances observedAt only on complete success, preserves stale data on transient failure, and clears the full surface for blockers', () => { + let state = createRegistryInspectionState({ tab: 'tools', detail: null }); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 1, + }); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 1, + }); + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'tools', + collection: 'toolDefinitions', + generation: 1, + items: [toolDefinitionSummary], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:30:00Z', + }); + + expect(state.tabs.tools.observedAt).toBeNull(); + + state = registryInspectionReducer(state, { + type: 'unpaged_collection_request_succeeded', + tab: 'tools', + collection: 'toolEnablements', + generation: 1, + items: [toolEnablement], + observedAt: '2026-09-01T12:31:00Z', + }); + + expect(state.tabs.tools.observedAt).toBe('2026-09-01T12:31:00Z'); + expect(state.tabs.tools.summaryState).toBe('fresh'); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 2, + }); + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'tools', + collection: 'toolDefinitions', + generation: 2, + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:32:00Z', + }); + state = registryInspectionReducer(state, { + type: 'collection_request_failed', + tab: 'tools', + collection: 'toolEnablements', + generation: 2, + error: httpError(429, 'rate_limited'), + restartPlanned: false, + }); + + expect(state.tabs.tools.observedAt).toBe('2026-09-01T12:31:00Z'); + expect(state.tabs.tools.summaryState).toBe('partial'); + expect(state.tabs.tools.collections.toolEnablements.items).toEqual([toolEnablement]); + expect(state.tabs.tools.collections.toolEnablements.observation).toEqual({ + kind: 'stale', + observedAt: '2026-09-01T12:31:00Z', + error: httpError(429, 'rate_limited'), + }); + + state = registryInspectionReducer(state, { + type: 'surface_blocked', + error: httpError(403, 'forbidden_actor'), + }); + + expect(state.urlState).toEqual({ tab: 'tools', detail: null }); + expect(state.surfaceError).toEqual(httpError(403, 'forbidden_actor')); + expect(state.tabs.agents.collections.agents.items).toEqual([]); + expect(state.tabs.tools.collections.toolDefinitions.items).toEqual([]); + expect(state.tabs.tools.collections.toolEnablements.items).toEqual([]); + }); + + it('stores a Retry-After cooldown for 429 collection failures while retaining last-known-good stale data', () => { + let state = createRegistryInspectionState({ tab: 'tools', detail: null }); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 1, + }); + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'tools', + collection: 'toolDefinitions', + generation: 1, + items: [toolDefinitionSummary], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:30:00Z', + }); + state = registryInspectionReducer(state, { + type: 'unpaged_collection_request_succeeded', + tab: 'tools', + collection: 'toolEnablements', + generation: 1, + items: [toolEnablement], + observedAt: '2026-09-01T12:31:00Z', + }); + + state = registryInspectionReducer(state, { + type: 'collection_request_started', + tab: 'tools', + collection: 'toolDefinitions', + generation: 2, + }); + state = registryInspectionReducer(state, { + type: 'collection_request_failed', + tab: 'tools', + collection: 'toolDefinitions', + generation: 2, + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 5, + }, + restartPlanned: false, + failedAtMs: Date.parse('2026-09-01T12:32:00Z'), + }); + + expect(state.tabs.tools.collections.toolDefinitions.items).toEqual([toolDefinitionSummary]); + expect(state.tabs.tools.collections.toolDefinitions.observation).toEqual({ + kind: 'stale', + observedAt: '2026-09-01T12:30:00Z', + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 5, + }, + }); + expect(state.tabs.tools.collections.toolDefinitions.retryDisabledUntil).toBe( + Date.parse('2026-09-01T12:32:05Z') + ); + }); + + it('preserves active Retry-After cooldowns when refresh or retry request-start actions begin', () => { + let state = createRegistryInspectionState({ tab: 'tools', detail: null }); + + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'tools', + collection: 'toolDefinitions', + generation: 1, + items: [toolDefinitionSummary], + nextCursor: 'tool-definition-cursor-1', + append: false, + observedAt: '2026-09-01T12:30:00Z', + }); + state = registryInspectionReducer(state, { + type: 'unpaged_collection_request_succeeded', + tab: 'tools', + collection: 'toolEnablements', + generation: 1, + items: [toolEnablement], + observedAt: '2026-09-01T12:31:00Z', + }); + state.tabs.tools.collections.toolDefinitions.retryDisabledUntil = + Date.parse('2026-09-01T12:32:05Z'); + state.tabs.tools.collections.toolEnablements.retryDisabledUntil = + Date.parse('2026-09-01T12:32:07Z'); + + state = registryInspectionReducer(state, { + type: 'tab_request_started', + tab: 'tools', + generation: 2, + }); + + expect(state.tabs.tools.collections.toolDefinitions.retryDisabledUntil).toBe( + Date.parse('2026-09-01T12:32:05Z') + ); + expect(state.tabs.tools.collections.toolEnablements.retryDisabledUntil).toBe( + Date.parse('2026-09-01T12:32:07Z') + ); + + state = registryInspectionReducer(state, { + type: 'collection_request_started', + tab: 'tools', + collection: 'toolDefinitions', + generation: 3, + }); + + expect(state.tabs.tools.collections.toolDefinitions.retryDisabledUntil).toBe( + Date.parse('2026-09-01T12:32:05Z') + ); + }); + + it('keeps the source collection and only invalidates detail on 404', () => { + let state = createRegistryInspectionState({ + tab: 'agents', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + }); + + state = registryInspectionReducer(state, { + type: 'cursor_collection_request_succeeded', + tab: 'agents', + collection: 'agents', + generation: 0, + items: [agentSummary], + nextCursor: null, + append: false, + observedAt: '2026-09-01T12:40:00Z', + }); + state = registryInspectionReducer(state, { + type: 'detail_request_succeeded', + tab: 'agents', + generation: 0, + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: agentDetail, + }); + + state = registryInspectionReducer(state, { + type: 'detail_request_failed', + tab: 'agents', + generation: 0, + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + error: httpError(404, 'agent_not_found'), + }); + + expect(state.urlState).toEqual({ + tab: 'agents', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + }); + expect(state.tabs.agents.collections.agents.items).toEqual([agentSummary]); + expect(state.tabs.agents.detail).toEqual({ + kind: 'missing', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + error: httpError(404, 'agent_not_found'), + }); + }); +}); diff --git a/app/src/features/coreRegistries/state.ts b/app/src/features/coreRegistries/state.ts new file mode 100644 index 0000000000..52a1bc064a --- /dev/null +++ b/app/src/features/coreRegistries/state.ts @@ -0,0 +1,631 @@ +import type { + AgentRegistryAgent, + AgentRegistryAgentSummary, + ConnectorRegistryBinding, + ConnectorRegistryBindingSummary, + ConnectorRegistryType, + ConnectorRegistryTypeSummary, + RegistryBridgeErrorMeta, + ToolRegistryToolDefinition, + ToolRegistryToolDefinitionSummary, + ToolRegistryToolEnablement, +} from '../../services/api/coreRegistriesClient'; +import { + type CursorRegistryCollectionState, + type RegistryCollectionKey, + type RegistryDetailRef, + type RegistryInspectionState, + type RegistryObservationState, + type RegistrySummaryState, + type RegistryTab, + type RegistryUrlState, + type UnpagedRegistryCollectionState, +} from './types'; + +export const LOAD_MORE_LIMIT = 50; + +type AgentsTabState = RegistryInspectionState['tabs']['agents']; +type ToolsTabState = RegistryInspectionState['tabs']['tools']; +type ConnectorsTabState = RegistryInspectionState['tabs']['connectors']; + +type CursorCollectionSuccessEvent = + | { + type: 'cursor_collection_request_succeeded'; + tab: 'agents'; + collection: 'agents'; + generation: number; + items: AgentRegistryAgentSummary[]; + nextCursor: string | null; + append: boolean; + observedAt: string; + } + | { + type: 'cursor_collection_request_succeeded'; + tab: 'tools'; + collection: 'toolDefinitions'; + generation: number; + items: ToolRegistryToolDefinitionSummary[]; + nextCursor: string | null; + append: boolean; + observedAt: string; + } + | { + type: 'cursor_collection_request_succeeded'; + tab: 'connectors'; + collection: 'connectorTypes' | 'connectorBindings'; + generation: number; + items: ConnectorRegistryTypeSummary[] | ConnectorRegistryBindingSummary[]; + nextCursor: string | null; + append: boolean; + observedAt: string; + }; + +type UnpagedCollectionSuccessEvent = { + type: 'unpaged_collection_request_succeeded'; + tab: 'tools'; + collection: 'toolEnablements'; + generation: number; + items: ToolRegistryToolEnablement[]; + observedAt: string; +}; + +type CollectionFailureEvent = { + type: 'collection_request_failed'; + tab: RegistryTab; + collection: RegistryCollectionKey; + generation: number; + error: RegistryBridgeErrorMeta; + restartPlanned: boolean; + failedAtMs?: number; +}; + +type DetailSuccessEvent = + | { + type: 'detail_request_succeeded'; + tab: 'agents'; + generation: number; + detail: RegistryDetailRef; + record: AgentRegistryAgent; + } + | { + type: 'detail_request_succeeded'; + tab: 'tools'; + generation: number; + detail: RegistryDetailRef; + record: ToolRegistryToolDefinition | ToolRegistryToolEnablement; + } + | { + type: 'detail_request_succeeded'; + tab: 'connectors'; + generation: number; + detail: RegistryDetailRef; + record: ConnectorRegistryType | ConnectorRegistryBinding; + }; + +export type RegistryInspectionAction = + | { type: 'tab_selected'; tab: RegistryTab; source: 'user' | 'history' | 'programmatic' } + | { type: 'tab_request_started'; tab: RegistryTab; generation: number } + | { + type: 'collection_request_started'; + tab: RegistryTab; + collection: RegistryCollectionKey; + generation: number; + } + | CursorCollectionSuccessEvent + | UnpagedCollectionSuccessEvent + | CollectionFailureEvent + | { + type: 'detail_request_started'; + tab: RegistryTab; + generation: number; + detail: RegistryDetailRef; + } + | DetailSuccessEvent + | { + type: 'detail_request_failed'; + tab: RegistryTab; + generation: number; + detail: RegistryDetailRef; + error: RegistryBridgeErrorMeta; + } + | { type: 'surface_blocked'; error: RegistryBridgeErrorMeta }; + +function notLoadedCollection(): CursorRegistryCollectionState { + return { + items: [], + nextCursor: null, + observation: { kind: 'not_loaded' }, + lastObservedAt: null, + successGeneration: null, + restartGeneration: null, + restartAttemptedGeneration: null, + retryDisabledUntil: null, + }; +} + +function notLoadedUnpagedCollection(): UnpagedRegistryCollectionState { + return { + items: [], + observation: { kind: 'not_loaded' }, + lastObservedAt: null, + successGeneration: null, + restartGeneration: null, + restartAttemptedGeneration: null, + retryDisabledUntil: null, + }; +} + +function createAgentsTabState(): AgentsTabState { + return { + generation: 0, + observedAt: null, + summaryState: 'idle', + detail: { kind: 'none' }, + collections: { agents: notLoadedCollection() }, + }; +} + +function createToolsTabState(): ToolsTabState { + return { + generation: 0, + observedAt: null, + summaryState: 'idle', + detail: { kind: 'none' }, + collections: { + toolDefinitions: notLoadedCollection(), + toolEnablements: notLoadedUnpagedCollection(), + }, + }; +} + +function createConnectorsTabState(): ConnectorsTabState { + return { + generation: 0, + observedAt: null, + summaryState: 'idle', + detail: { kind: 'none' }, + collections: { + connectorTypes: notLoadedCollection(), + connectorBindings: notLoadedCollection(), + }, + }; +} + +function relevantCollectionKeys(tab: RegistryTab): RegistryCollectionKey[] { + switch (tab) { + case 'agents': + return ['agents']; + case 'tools': + return ['toolDefinitions', 'toolEnablements']; + case 'connectors': + return ['connectorTypes', 'connectorBindings']; + } +} + +function getCollectionState( + state: RegistryInspectionState, + tab: TTab, + collection: RegistryCollectionKey +) { + if (tab === 'agents' && collection === 'agents') { + return state.tabs.agents.collections.agents; + } + + if (tab === 'tools' && collection === 'toolDefinitions') { + return state.tabs.tools.collections.toolDefinitions; + } + + if (tab === 'tools' && collection === 'toolEnablements') { + return state.tabs.tools.collections.toolEnablements; + } + + if (tab === 'connectors' && collection === 'connectorTypes') { + return state.tabs.connectors.collections.connectorTypes; + } + + if (tab === 'connectors' && collection === 'connectorBindings') { + return state.tabs.connectors.collections.connectorBindings; + } + + return null; +} + +function collectionStateGeneration( + state: RegistryInspectionState, + tab: RegistryTab, + collection: RegistryCollectionKey +): number | null { + return getCollectionState(state, tab, collection)?.restartGeneration ?? state.tabs[tab].generation; +} + +function collectionObservationSuccess( + observedAt: string, + itemCount: number +): RegistryObservationState { + return itemCount === 0 + ? { kind: 'empty', observedAt } + : { kind: 'loaded', observedAt, stale: false }; +} + +function isTransientError(error: RegistryBridgeErrorMeta): boolean { + if (error.kind === 'YouPetCoreTransport') { + return true; + } + + if (error.kind !== 'YouPetCoreHttpError') { + return false; + } + + const status = error.httpStatus; + return status === 429 || status === undefined || status >= 500; +} + +function detailsEqual(left: RegistryDetailRef | null, right: RegistryDetailRef): boolean { + return ( + left !== null && + left.kind === right.kind && + left.key === right.key && + left.version === right.version + ); +} + +function getRetryDisabledUntil(error: RegistryBridgeErrorMeta, failedAtMs?: number): number | null { + if ( + error.kind !== 'YouPetCoreHttpError' || + error.httpStatus !== 429 || + typeof error.retryAfterSeconds !== 'number' || + !Number.isFinite(error.retryAfterSeconds) || + error.retryAfterSeconds <= 0 + ) { + return null; + } + + return (failedAtMs ?? Date.now()) + error.retryAfterSeconds * 1000; +} + +function summarizeTab(state: RegistryInspectionState, tab: RegistryTab): RegistrySummaryState { + const collections = relevantCollectionKeys(tab).map(collection => + getCollectionState(state, tab, collection) + ); + if (collections.some(collection => collection?.observation.kind === 'blocked')) { + return 'blocked'; + } + + const staleCount = collections.filter( + collection => collection?.observation.kind === 'stale' + ).length; + if (staleCount > 0) { + return staleCount === collections.length ? 'stale' : 'partial'; + } + + if (collections.some(collection => collection?.observation.kind === 'loading')) { + return 'loading'; + } + + if ( + collections.every( + collection => + collection?.observation.kind === 'loaded' || collection?.observation.kind === 'empty' + ) + ) { + return 'fresh'; + } + + return 'idle'; +} + +function updateObservedAt( + state: RegistryInspectionState, + tab: RegistryTab, + observedAt: string +): void { + const tabState = state.tabs[tab]; + const complete = relevantCollectionKeys(tab).every(collection => { + const collectionState = getCollectionState(state, tab, collection); + return collectionState?.successGeneration === tabState.generation; + }); + + if (complete) { + tabState.observedAt = observedAt; + } +} + +function cloneState(state: RegistryInspectionState): RegistryInspectionState { + return { + urlState: state.urlState, + surfaceError: state.surfaceError, + tabs: { + agents: { + ...state.tabs.agents, + collections: { + agents: { + ...state.tabs.agents.collections.agents, + items: [...state.tabs.agents.collections.agents.items], + }, + }, + }, + tools: { + ...state.tabs.tools, + collections: { + toolDefinitions: { + ...state.tabs.tools.collections.toolDefinitions, + items: [...state.tabs.tools.collections.toolDefinitions.items], + }, + toolEnablements: { + ...state.tabs.tools.collections.toolEnablements, + items: [...state.tabs.tools.collections.toolEnablements.items], + }, + }, + }, + connectors: { + ...state.tabs.connectors, + collections: { + connectorTypes: { + ...state.tabs.connectors.collections.connectorTypes, + items: [...state.tabs.connectors.collections.connectorTypes.items], + }, + connectorBindings: { + ...state.tabs.connectors.collections.connectorBindings, + items: [...state.tabs.connectors.collections.connectorBindings.items], + }, + }, + }, + }, + }; +} + +function clearAllDetails(state: RegistryInspectionState): void { + state.tabs.agents.detail = { kind: 'none' }; + state.tabs.tools.detail = { kind: 'none' }; + state.tabs.connectors.detail = { kind: 'none' }; +} + +function markTabLoading( + state: RegistryInspectionState, + tab: RegistryTab, + generation: number +): void { + const tabState = state.tabs[tab]; + if (generation < tabState.generation) { + return; + } + + tabState.generation = generation; + tabState.summaryState = 'loading'; + + for (const collection of relevantCollectionKeys(tab)) { + const collectionState = getCollectionState(state, tab, collection); + if (!collectionState) { + continue; + } + collectionState.restartGeneration = generation; + collectionState.observation = { kind: 'loading', generation }; + } + + if (state.urlState.tab === tab && state.urlState.detail) { + tabState.detail = { kind: 'loading', detail: state.urlState.detail, generation }; + } +} + +function markCollectionLoading( + state: RegistryInspectionState, + tab: RegistryTab, + collection: RegistryCollectionKey, + generation: number +): void { + const collectionState = getCollectionState(state, tab, collection); + if (!collectionState) { + return; + } + + collectionState.restartGeneration = generation; + collectionState.observation = { kind: 'loading', generation }; +} + +function resetCollectionForRestart( + collectionState: CursorRegistryCollectionState | UnpagedRegistryCollectionState, + generation: number +): void { + collectionState.items = []; + if ('nextCursor' in collectionState) { + collectionState.nextCursor = null; + } + collectionState.observation = { kind: 'loading', generation }; + collectionState.restartGeneration = generation; + collectionState.restartAttemptedGeneration = generation; + collectionState.successGeneration = null; + collectionState.retryDisabledUntil = null; +} + +function blockCollection( + collectionState: CursorRegistryCollectionState | UnpagedRegistryCollectionState, + error: RegistryBridgeErrorMeta, + retryDisabledUntil: number | null +): void { + collectionState.items = []; + if ('nextCursor' in collectionState) { + collectionState.nextCursor = null; + } + collectionState.observation = { kind: 'blocked', error }; + collectionState.successGeneration = null; + collectionState.retryDisabledUntil = retryDisabledUntil; +} + +export function createRegistryInspectionState( + urlState: RegistryUrlState = { tab: 'agents', detail: null } +): RegistryInspectionState { + return { + urlState, + surfaceError: null, + tabs: { + agents: createAgentsTabState(), + tools: createToolsTabState(), + connectors: createConnectorsTabState(), + }, + }; +} + +export function registryInspectionReducer( + state: RegistryInspectionState, + action: RegistryInspectionAction +): RegistryInspectionState { + const next = cloneState(state); + + switch (action.type) { + case 'tab_selected': { + next.urlState = { tab: action.tab, detail: null }; + clearAllDetails(next); + next.tabs[action.tab].summaryState = summarizeTab(next, action.tab); + return next; + } + + case 'tab_request_started': { + next.surfaceError = null; + markTabLoading(next, action.tab, action.generation); + return next; + } + + case 'collection_request_started': { + next.surfaceError = null; + next.tabs[action.tab].generation = Math.max(next.tabs[action.tab].generation, action.generation); + markCollectionLoading(next, action.tab, action.collection, action.generation); + next.tabs[action.tab].summaryState = summarizeTab(next, action.tab); + return next; + } + + case 'cursor_collection_request_succeeded': { + const tabState = next.tabs[action.tab]; + const collectionState = getCollectionState(next, action.tab, action.collection); + if (!collectionState || !('nextCursor' in collectionState)) { + return state; + } + if (collectionStateGeneration(next, action.tab, action.collection) !== action.generation) { + return state; + } + + const items = action.append + ? [...(collectionState.items as Array<(typeof action.items)[number]>), ...action.items] + : [...action.items]; + collectionState.items = items as typeof collectionState.items; + collectionState.nextCursor = action.nextCursor; + collectionState.lastObservedAt = action.observedAt; + collectionState.successGeneration = action.generation; + collectionState.observation = collectionObservationSuccess(action.observedAt, items.length); + collectionState.retryDisabledUntil = null; + updateObservedAt(next, action.tab, action.observedAt); + tabState.summaryState = summarizeTab(next, action.tab); + return next; + } + + case 'unpaged_collection_request_succeeded': { + const tabState = next.tabs[action.tab]; + const collectionState = getCollectionState(next, action.tab, action.collection); + if (!collectionState || 'nextCursor' in collectionState) { + return state; + } + if (collectionStateGeneration(next, action.tab, action.collection) !== action.generation) { + return state; + } + + collectionState.items = [...action.items]; + collectionState.lastObservedAt = action.observedAt; + collectionState.successGeneration = action.generation; + collectionState.observation = collectionObservationSuccess( + action.observedAt, + action.items.length + ); + collectionState.retryDisabledUntil = null; + updateObservedAt(next, action.tab, action.observedAt); + tabState.summaryState = summarizeTab(next, action.tab); + return next; + } + + case 'collection_request_failed': { + const tabState = next.tabs[action.tab]; + const collectionState = getCollectionState(next, action.tab, action.collection); + if (!collectionState) { + return state; + } + if (collectionStateGeneration(next, action.tab, action.collection) !== action.generation) { + return state; + } + + const retryDisabledUntil = getRetryDisabledUntil(action.error, action.failedAtMs); + + if (action.restartPlanned) { + resetCollectionForRestart(collectionState, action.generation); + } else if ( + collectionState.items.length > 0 && + isTransientError(action.error) && + collectionState.lastObservedAt + ) { + collectionState.observation = { + kind: 'stale', + observedAt: collectionState.lastObservedAt, + error: action.error, + }; + collectionState.retryDisabledUntil = retryDisabledUntil; + } else { + blockCollection(collectionState, action.error, retryDisabledUntil); + } + + tabState.summaryState = summarizeTab(next, action.tab); + return next; + } + + case 'detail_request_started': { + const tabState = next.tabs[action.tab]; + if (action.generation !== tabState.generation) { + return state; + } + + next.urlState = { tab: action.tab, detail: action.detail }; + next.surfaceError = null; + tabState.detail = { kind: 'loading', detail: action.detail, generation: action.generation }; + return next; + } + + case 'detail_request_succeeded': { + const tabState = next.tabs[action.tab]; + if ( + action.generation !== tabState.generation || + next.urlState.tab !== action.tab || + !detailsEqual(next.urlState.detail, action.detail) + ) { + return state; + } + + tabState.detail = { kind: 'loaded', detail: action.detail, record: action.record }; + return next; + } + + case 'detail_request_failed': { + const tabState = next.tabs[action.tab]; + if ( + action.generation !== tabState.generation || + next.urlState.tab !== action.tab || + !detailsEqual(next.urlState.detail, action.detail) + ) { + return state; + } + + tabState.detail = + action.error.kind === 'YouPetCoreHttpError' && action.error.httpStatus === 404 + ? { kind: 'missing', detail: action.detail, error: action.error } + : { kind: 'error', detail: action.detail, error: action.error }; + return next; + } + + case 'surface_blocked': { + next.surfaceError = action.error; + next.urlState = { tab: next.urlState.tab, detail: null }; + next.tabs.agents = createAgentsTabState(); + next.tabs.tools = createToolsTabState(); + next.tabs.connectors = createConnectorsTabState(); + next.tabs.agents.summaryState = 'blocked'; + next.tabs.tools.summaryState = 'blocked'; + next.tabs.connectors.summaryState = 'blocked'; + return next; + } + } +} diff --git a/app/src/features/coreRegistries/types.ts b/app/src/features/coreRegistries/types.ts new file mode 100644 index 0000000000..4c52a38822 --- /dev/null +++ b/app/src/features/coreRegistries/types.ts @@ -0,0 +1,145 @@ +import type { + AgentRegistryAgent, + AgentRegistryAgentSummary, + ConnectorRegistryBinding, + ConnectorRegistryBindingSummary, + ConnectorRegistryType, + ConnectorRegistryTypeSummary, + RegistryBridgeErrorMeta, + ToolRegistryToolDefinition, + ToolRegistryToolDefinitionSummary, + ToolRegistryToolEnablement, +} from '../../services/api/coreRegistriesClient'; + +export const REGISTRY_TABS = ['agents', 'tools', 'connectors'] as const; +export const REGISTRY_DETAIL_KINDS = [ + 'agent', + 'tool-definition', + 'tool-enablement', + 'connector-type', + 'connector-binding', +] as const; +export const REGISTRY_KEY_MAX_LENGTH = 128; + +export type RegistryTab = (typeof REGISTRY_TABS)[number]; +export type RegistryDetailKind = (typeof REGISTRY_DETAIL_KINDS)[number]; +export type RegistrySummaryState = 'idle' | 'loading' | 'fresh' | 'stale' | 'partial' | 'blocked'; + +export interface RegistryDetailRef { + kind: RegistryDetailKind; + key: string; + version: number; +} + +export interface RegistryUrlState { + tab: RegistryTab; + detail: RegistryDetailRef | null; +} + +export type RegistryObservationState = + | { kind: 'not_loaded' } + | { kind: 'loading'; generation: number } + | { kind: 'empty'; observedAt: string } + | { kind: 'loaded'; observedAt: string; stale: false } + | { kind: 'stale'; observedAt: string; error: RegistryBridgeErrorMeta } + | { kind: 'blocked'; error: RegistryBridgeErrorMeta }; + +export type RegistryDetailRecord = + | AgentRegistryAgent + | ToolRegistryToolDefinition + | ToolRegistryToolEnablement + | ConnectorRegistryType + | ConnectorRegistryBinding; + +export type RegistryDetailState = + | { kind: 'none' } + | { kind: 'loading'; detail: RegistryDetailRef; generation: number } + | { kind: 'loaded'; detail: RegistryDetailRef; record: RegistryDetailRecord } + | { kind: 'missing'; detail: RegistryDetailRef; error: RegistryBridgeErrorMeta } + | { kind: 'error'; detail: RegistryDetailRef; error: RegistryBridgeErrorMeta }; + +export interface RegistryCollectionStateBase { + items: TItem[]; + observation: RegistryObservationState; + lastObservedAt: string | null; + successGeneration: number | null; + restartGeneration: number | null; + restartAttemptedGeneration: number | null; + retryDisabledUntil?: number | null; +} + +export interface CursorRegistryCollectionState extends RegistryCollectionStateBase { + nextCursor: string | null; +} + +export interface UnpagedRegistryCollectionState extends RegistryCollectionStateBase {} + +export interface AgentsTabCollections { + agents: CursorRegistryCollectionState; +} + +export interface ToolsTabCollections { + toolDefinitions: CursorRegistryCollectionState; + toolEnablements: UnpagedRegistryCollectionState; +} + +export interface ConnectorsTabCollections { + connectorTypes: CursorRegistryCollectionState; + connectorBindings: CursorRegistryCollectionState; +} + +export interface RegistryTabState { + generation: number; + observedAt: string | null; + summaryState: RegistrySummaryState; + detail: RegistryDetailState; + collections: TCollections; +} + +export interface RegistryInspectionState { + urlState: RegistryUrlState; + surfaceError: RegistryBridgeErrorMeta | null; + tabs: { + agents: RegistryTabState; + tools: RegistryTabState; + connectors: RegistryTabState; + }; +} + +export type CursorCollectionKey = + | keyof AgentsTabCollections + | keyof Pick + | keyof ConnectorsTabCollections; + +export type UnpagedCollectionKey = keyof Pick; +export type RegistryCollectionKey = CursorCollectionKey | UnpagedCollectionKey; + +const ALLOWED_KINDS_BY_TAB: Record = { + agents: ['agent'], + tools: ['tool-definition', 'tool-enablement'], + connectors: ['connector-type', 'connector-binding'], +}; + +const TAB_BY_KIND: Record = { + agent: 'agents', + 'tool-definition': 'tools', + 'tool-enablement': 'tools', + 'connector-type': 'connectors', + 'connector-binding': 'connectors', +}; + +export function isRegistryTab(value: string): value is RegistryTab { + return REGISTRY_TABS.includes(value as RegistryTab); +} + +export function isRegistryDetailKind(value: string): value is RegistryDetailKind { + return REGISTRY_DETAIL_KINDS.includes(value as RegistryDetailKind); +} + +export function getRegistryTabForDetailKind(kind: RegistryDetailKind): RegistryTab { + return TAB_BY_KIND[kind]; +} + +export function isDetailAllowedForTab(tab: RegistryTab, kind: RegistryDetailKind): boolean { + return ALLOWED_KINDS_BY_TAB[tab].includes(kind); +} diff --git a/app/src/features/coreRegistries/urlState.test.ts b/app/src/features/coreRegistries/urlState.test.ts new file mode 100644 index 0000000000..c4a20e2ed6 --- /dev/null +++ b/app/src/features/coreRegistries/urlState.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseRegistryUrlState, + REGISTRY_KEY_MAX_LENGTH, + serializeRegistryUrlState, +} from './urlState'; + +describe('registry URL state', () => { + it('defaults to the Agents tab with no selection', () => { + expect(parseRegistryUrlState('')).toEqual({ tab: 'agents', detail: null }); + expect(parseRegistryUrlState('?ignored=value')).toEqual({ tab: 'agents', detail: null }); + }); + + it('parses and canonicalizes closed tab/kind/key/version identity', () => { + const state = parseRegistryUrlState( + '?tab=tools&kind=tool-definition&key=tool.alpha&version=0007&cursor=secret&observedAt=secret&payload=secret' + ); + + expect(state).toEqual({ + tab: 'tools', + detail: { kind: 'tool-definition', key: 'tool.alpha', version: 7 }, + }); + expect(serializeRegistryUrlState(state)).toBe( + 'tab=tools&kind=tool-definition&key=tool.alpha&version=7' + ); + }); + + it('drops invalid or cross-tab detail selectors as a unit', () => { + expect(parseRegistryUrlState('?tab=connectors&kind=agent&key=agent.alpha&version=7')).toEqual({ + tab: 'connectors', + detail: null, + }); + + expect( + parseRegistryUrlState( + `?tab=agents&kind=agent&key=${'a'.repeat(REGISTRY_KEY_MAX_LENGTH + 1)}&version=7` + ) + ).toEqual({ tab: 'agents', detail: null }); + + expect(parseRegistryUrlState('?tab=agents&kind=agent&key=agent.alpha&version=0')).toEqual({ + tab: 'agents', + detail: null, + }); + + expect(parseRegistryUrlState('?tab=agents&kind=agent&key=agent.alpha')).toEqual({ + tab: 'agents', + detail: null, + }); + }); +}); diff --git a/app/src/features/coreRegistries/urlState.ts b/app/src/features/coreRegistries/urlState.ts new file mode 100644 index 0000000000..0ebd6fb7a7 --- /dev/null +++ b/app/src/features/coreRegistries/urlState.ts @@ -0,0 +1,61 @@ +import { + getRegistryTabForDetailKind, + isDetailAllowedForTab, + isRegistryDetailKind, + isRegistryTab, + REGISTRY_KEY_MAX_LENGTH, + type RegistryDetailRef, + type RegistryUrlState, +} from './types'; + +export { REGISTRY_KEY_MAX_LENGTH } from './types'; + +function parsePositiveBase10Integer(value: string | null): number | null { + if (!value || !/^\d+$/.test(value)) { + return null; + } + + const parsed = Number.parseInt(value, 10); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; +} + +function parseDetail( + params: URLSearchParams, + tab: RegistryUrlState['tab'] +): RegistryDetailRef | null { + const kindValue = params.get('kind'); + const key = params.get('key'); + const version = parsePositiveBase10Integer(params.get('version')); + + if (!kindValue || !isRegistryDetailKind(kindValue) || !isDetailAllowedForTab(tab, kindValue)) { + return null; + } + + if (!key || key.length > REGISTRY_KEY_MAX_LENGTH || version === null) { + return null; + } + + return { kind: kindValue, key, version }; +} + +export function parseRegistryUrlState(search: string): RegistryUrlState { + const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search); + const tab = isRegistryTab(params.get('tab') ?? '') + ? (params.get('tab') as RegistryUrlState['tab']) + : 'agents'; + + return { tab, detail: parseDetail(params, tab) }; +} + +export function serializeRegistryUrlState(state: RegistryUrlState): string { + const params = new URLSearchParams(); + params.set('tab', state.tab); + + if (state.detail && getRegistryTabForDetailKind(state.detail.kind) === state.tab) { + params.set('kind', state.detail.kind); + params.set('key', state.detail.key); + params.set('version', String(state.detail.version)); + } + + return params.toString(); +} diff --git a/app/src/features/coreRegistries/useRegistryInspection.test.ts b/app/src/features/coreRegistries/useRegistryInspection.test.ts new file mode 100644 index 0000000000..f487835c28 --- /dev/null +++ b/app/src/features/coreRegistries/useRegistryInspection.test.ts @@ -0,0 +1,1027 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + AgentRegistryAgent, + AgentRegistryAgentSummary, + ConnectorRegistryBinding, + ConnectorRegistryBindingSummary, + ConnectorRegistryType, + ConnectorRegistryTypeSummary, + CursorRegistryPage, + RegistryCursorListParams, + ToolRegistryToolDefinition, + ToolRegistryToolDefinitionSummary, + ToolRegistryToolEnablement, + UnpagedRegistryCollection, +} from '../../services/api/coreRegistriesClient'; +import { CoreRpcError } from '../../services/coreRpcClient'; +import { LOAD_MORE_LIMIT } from './state'; +import { type RegistryInspectionClient, useRegistryInspection } from './useRegistryInspection'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +const agentSummary: AgentRegistryAgentSummary = { + id: 'agent-row', + agentKey: 'agent.alpha', + version: 7, + lifecycleState: 'active', + configurationFingerprint: 'a'.repeat(64), + ownerActorType: 'service', + ownerActorId: 'registry-reader', + createdAt: '2026-09-01T12:00:00Z', +}; + +const agentDetail: AgentRegistryAgent = { + ...agentSummary, + configuration: { + schemaVersion: 1, + domainKey: 'ops', + owner: { actorType: 'service', actorId: 'registry-reader' }, + allowedToolRefs: [{ toolKey: 'tool.alpha', version: 3 }], + knowledgeScopeRefs: [], + riskPolicyRef: null, + }, +}; + +const toolDefinitionSummary: ToolRegistryToolDefinitionSummary = { + toolKey: 'tool.alpha', + version: 3, + lifecycleState: 'active', + definitionFingerprint: 'b'.repeat(64), + schemaVersion: 1, + displayName: 'Tool Alpha', + description: 'Reads data', + toolEffectClass: 'read_only', + abstractAuthScopes: ['scope.read'], + createdAt: '2026-09-01T12:05:00Z', +}; + +const toolDefinitionDetail: ToolRegistryToolDefinition = { + ...toolDefinitionSummary, + inputSchema: {}, + outputSchema: {}, + timeoutDefaults: {}, + retryContract: {}, + auditContract: {}, +}; + +const toolEnablement: ToolRegistryToolEnablement = { + toolKey: 'tool.alpha', + version: 5, + lifecycleState: 'enabled', + generation: 12, + timeoutCapMs: 5000, + approvalRequired: false, + allowTtlSeconds: null, + auditMode: 'metadata_only', + updatedAt: '2026-09-01T12:06:00Z', +}; + +function makeClient(): RegistryInspectionClient { + return { + listAgents: + vi.fn< + ( + _: RegistryCursorListParams | undefined + ) => Promise> + >(), + getAgentVersion: + vi.fn<(_: { agentKey: string; version: number }) => Promise>(), + listToolDefinitions: + vi.fn< + ( + _: RegistryCursorListParams | undefined + ) => Promise> + >(), + getToolDefinitionVersion: + vi.fn<(_: { toolKey: string; version: number }) => Promise>(), + listToolEnablements: + vi.fn<() => Promise>>(), + getToolEnablementVersion: + vi.fn<(_: { toolKey: string; version: number }) => Promise>(), + listConnectorTypes: + vi.fn< + ( + _: RegistryCursorListParams | undefined + ) => Promise> + >(), + getConnectorTypeVersion: + vi.fn<(_: { connectorKey: string; version: number }) => Promise>(), + listConnectorBindings: + vi.fn< + ( + _: RegistryCursorListParams | undefined + ) => Promise> + >(), + getConnectorBindingVersion: + vi.fn<(_: { bindingKey: string; version: number }) => Promise>(), + }; +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + vi.setSystemTime(new Date('2026-09-01T12:00:00Z')); + window.history.replaceState({}, '', '/registries'); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useRegistryInspection', () => { + it('lazy-loads the default Agents tab without touching other tabs', async () => { + const client = makeClient(); + vi.mocked(client.listAgents).mockResolvedValue({ + items: [agentSummary], + nextCursor: 'agent-cursor-1', + }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + expect(client.listAgents).toHaveBeenCalledWith({ limit: LOAD_MORE_LIMIT }); + expect(client.listToolDefinitions).not.toHaveBeenCalled(); + expect(client.listToolEnablements).not.toHaveBeenCalled(); + expect(client.listConnectorTypes).not.toHaveBeenCalled(); + expect(client.listConnectorBindings).not.toHaveBeenCalled(); + expect(result.current.state.urlState).toEqual({ tab: 'agents', detail: null }); + }); + + it('loads more with limit 50 and restores exact history identity from the page-session detail cache', async () => { + const client = makeClient(); + vi.mocked(client.listAgents) + .mockResolvedValueOnce({ items: [agentSummary], nextCursor: 'agent-cursor-1' }) + .mockResolvedValueOnce({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [] }); + vi.mocked(client.getAgentVersion).mockResolvedValue(agentDetail); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + await act(async () => { + await result.current.loadMoreCollection('agents'); + }); + + expect(client.listAgents).toHaveBeenNthCalledWith(2, { + limit: LOAD_MORE_LIMIT, + cursor: 'agent-cursor-1', + }); + + await act(async () => { + await result.current.openDetail({ kind: 'agent', key: 'agent.alpha', version: 7 }); + }); + + expect(window.location.search).toBe('?tab=agents&kind=agent&key=agent.alpha&version=7'); + expect(client.getAgentVersion).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.setTab('tools'); + }); + + expect(window.location.search).toBe('?tab=tools'); + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]); + expect(result.current.state.tabs.agents.detail).toEqual({ kind: 'none' }); + + await act(async () => { + window.history.back(); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + await waitFor(() => + expect(result.current.state.urlState).toEqual({ + tab: 'agents', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + }) + ); + expect(result.current.state.tabs.agents.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: agentDetail, + }); + expect(client.getAgentVersion).toHaveBeenCalledTimes(1); + }); + + it('rejects late list responses after a newer refresh generation starts', async () => { + const client = makeClient(); + const first = deferred>(); + const second = deferred>(); + vi.mocked(client.listAgents) + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + act(() => { + void result.current.refreshActiveTab(); + }); + + expect(result.current.state.tabs.agents.generation).toBe(2); + + second.resolve({ items: [{ ...agentSummary, agentKey: 'agent.beta' }], nextCursor: null }); + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([ + { ...agentSummary, agentKey: 'agent.beta' }, + ]) + ); + + first.resolve({ items: [agentSummary], nextCursor: null }); + await act(async () => { + await first.promise; + }); + + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([ + { ...agentSummary, agentKey: 'agent.beta' }, + ]); + }); + + it('reopens a same-tab cached detail from history after re-establishing selection state', async () => { + const client = makeClient(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [agentSummary], nextCursor: null }); + vi.mocked(client.getAgentVersion).mockResolvedValue(agentDetail); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + await act(async () => { + await result.current.openDetail({ kind: 'agent', key: 'agent.alpha', version: 7 }); + }); + + expect(client.getAgentVersion).toHaveBeenCalledTimes(1); + expect(result.current.state.tabs.agents.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: agentDetail, + }); + + await act(async () => { + window.history.pushState({}, '', '/registries?tab=agents'); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + await waitFor(() => expect(result.current.state.tabs.agents.detail).toEqual({ kind: 'none' })); + + await act(async () => { + await result.current.openDetail({ kind: 'agent', key: 'agent.alpha', version: 7 }); + }); + + await waitFor(() => + expect(result.current.state.tabs.agents.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'agent', key: 'agent.alpha', version: 7 }, + record: agentDetail, + }) + ); + expect(client.getAgentVersion).toHaveBeenCalledTimes(1); + }); + + it('retries one collection without marking sibling collections loading for the new generation', async () => { + const client = makeClient(); + const retriedDefinitions = deferred>(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }) + .mockReturnValueOnce(retriedDefinitions.promise); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => expect(result.current.state.tabs.tools.summaryState).toBe('fresh')); + const initialEnablementsObservation = + result.current.state.tabs.tools.collections.toolEnablements.observation; + + act(() => { + void result.current.retryCollection('toolDefinitions'); + }); + + expect(result.current.state.tabs.tools.collections.toolDefinitions.observation).toEqual({ + kind: 'loading', + generation: 2, + }); + expect(result.current.state.tabs.tools.collections.toolEnablements.observation).toEqual( + initialEnablementsObservation + ); + + retriedDefinitions.resolve({ + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([ + { ...toolDefinitionSummary, version: 4 }, + ]) + ); + + expect(result.current.state.tabs.tools.collections.toolEnablements.items).toEqual([ + toolEnablement, + ]); + expect(result.current.state.tabs.tools.summaryState).toBe('fresh'); + expect(client.listToolEnablements).toHaveBeenCalledTimes(1); + }); + + it('deep-links a tool enablement detail with exactly one exact-version request on mount', async () => { + const client = makeClient(); + window.history.replaceState( + {}, + '', + '/registries?tab=tools&kind=tool-enablement&key=tool.alpha&version=5' + ); + vi.mocked(client.listToolDefinitions).mockResolvedValue({ + items: [toolDefinitionSummary], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + vi.mocked(client.getToolEnablementVersion).mockResolvedValue(toolEnablement); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.tools.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'tool-enablement', key: 'tool.alpha', version: 5 }, + record: toolEnablement, + }) + ); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(client.getToolEnablementVersion).toHaveBeenCalledTimes(1); + }); + + it('does not retry one tools collection while its sibling collection is still loading', async () => { + const client = makeClient(); + const pendingEnablements = deferred>(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockRejectedValueOnce( + new CoreRpcError('transport down', 'unknown', undefined, { kind: 'YouPetCoreTransport' }) + ) + .mockResolvedValueOnce({ + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements).mockReturnValueOnce(pendingEnablements.promise); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + let toolsLoad!: Promise; + act(() => { + toolsLoad = result.current.setTab('tools'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.observation).toMatchObject( + { kind: 'blocked', error: { kind: 'YouPetCoreTransport' } } + ) + ); + expect(result.current.state.tabs.tools.collections.toolEnablements.observation).toEqual({ + kind: 'loading', + generation: 1, + }); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + + pendingEnablements.resolve({ items: [toolEnablement] }); + await act(async () => { + await toolsLoad; + }); + + expect(result.current.state.tabs.tools.generation).toBe(1); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(1); + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolEnablements.items).toEqual([ + toolEnablement, + ]) + ); + expect(result.current.state.tabs.tools.collections.toolEnablements.observation).toMatchObject({ + kind: 'loaded', + stale: false, + }); + }); + + it('treats response-shape failures as blocked instead of preserving stale collection data', async () => { + const client = makeClient(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }) + .mockRejectedValueOnce( + new CoreRpcError('schema mismatch', 'unknown', undefined, { + kind: 'YouPetCoreResponseShape', + }) + ); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([ + toolDefinitionSummary, + ]) + ); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.observation).toEqual({ + kind: 'blocked', + error: { kind: 'YouPetCoreResponseShape' }, + }) + ); + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([]); + expect(result.current.state.tabs.tools.summaryState).toBe('blocked'); + }); + + it('reobserves a selected Tool Enablement detail on Tools refresh instead of serving stale cached detail', async () => { + const client = makeClient(); + const refreshedEnablement: ToolRegistryToolEnablement = { + ...toolEnablement, + generation: 13, + updatedAt: '2026-09-01T12:08:00Z', + }; + + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }); + vi.mocked(client.listToolEnablements) + .mockResolvedValueOnce({ items: [toolEnablement] }) + .mockResolvedValueOnce({ items: [refreshedEnablement] }); + vi.mocked(client.getToolEnablementVersion) + .mockResolvedValueOnce(toolEnablement) + .mockResolvedValueOnce(refreshedEnablement); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => expect(result.current.state.tabs.tools.summaryState).toBe('fresh')); + + await act(async () => { + await result.current.openDetail({ kind: 'tool-enablement', key: 'tool.alpha', version: 5 }); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'tool-enablement', key: 'tool.alpha', version: 5 }, + record: toolEnablement, + }) + ); + expect(client.getToolEnablementVersion).toHaveBeenCalledTimes(1); + + await act(async () => { + await result.current.refreshActiveTab(); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolEnablements.items).toEqual([ + refreshedEnablement, + ]) + ); + + expect(client.getToolEnablementVersion).toHaveBeenCalledTimes(2); + expect(result.current.state.tabs.tools.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'tool-enablement', key: 'tool.alpha', version: 5 }, + record: refreshedEnablement, + }); + }); + + it('enforces Retry-After before allowing a manual collection retry and does not auto-retry on timer advance', async () => { + const client = makeClient(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }) + .mockRejectedValueOnce( + new CoreRpcError('rate limited', 'unknown', 429, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 429, code: 'rate_limited', retry_after_seconds: 5 }, + }) + ) + .mockResolvedValueOnce({ + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([ + toolDefinitionSummary, + ]) + ); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.observation).toMatchObject( + { + kind: 'stale', + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 5, + }, + } + ) + ); + const retryDisabledUntil = + result.current.state.tabs.tools.collections.toolDefinitions.retryDisabledUntil; + expect(retryDisabledUntil).toBeTypeOf('number'); + expect(retryDisabledUntil).toBeGreaterThanOrEqual(Date.parse('2026-09-01T12:00:05.000Z')); + expect(retryDisabledUntil).toBeLessThanOrEqual(Date.now() + 5_000); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(2); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(2); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([ + { ...toolDefinitionSummary, version: 4 }, + ]) + ); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(3); + }); + + it('blocks refreshActiveTab while the active tab is still under Retry-After cooldown', async () => { + const client = makeClient(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ + items: [toolDefinitionSummary], + nextCursor: 'tool-definition-cursor-1', + }) + .mockRejectedValueOnce( + new CoreRpcError('rate limited', 'unknown', 429, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 429, code: 'rate_limited', retry_after_seconds: 5 }, + }) + ) + .mockResolvedValueOnce({ + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => expect(result.current.state.tabs.tools.summaryState).toBe('fresh')); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.observation).toMatchObject( + { + kind: 'stale', + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 5, + }, + } + ) + ); + + const retryDisabledUntil = + result.current.state.tabs.tools.collections.toolDefinitions.retryDisabledUntil; + + await act(async () => { + await result.current.refreshActiveTab(); + }); + + expect(result.current.state.tabs.tools.generation).toBe(2); + expect(result.current.state.tabs.tools.collections.toolDefinitions.retryDisabledUntil).toBe( + retryDisabledUntil + ); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(2); + expect(client.listToolEnablements).toHaveBeenCalledTimes(1); + }); + + it('blocks loadMoreCollection while the target collection is still under Retry-After cooldown', async () => { + const client = makeClient(); + vi.mocked(client.listAgents).mockResolvedValue({ items: [], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ + items: [toolDefinitionSummary], + nextCursor: 'tool-definition-cursor-1', + }) + .mockRejectedValueOnce( + new CoreRpcError('rate limited', 'unknown', 429, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 429, code: 'rate_limited', retry_after_seconds: 5 }, + }) + ) + .mockResolvedValueOnce({ + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'empty', + observedAt: '2026-09-01T12:00:00.000Z', + }) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.nextCursor).toBe( + 'tool-definition-cursor-1' + ) + ); + + await act(async () => { + await result.current.retryCollection('toolDefinitions'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.observation).toMatchObject( + { + kind: 'stale', + error: { + kind: 'YouPetCoreHttpError', + httpStatus: 429, + coreCode: 'rate_limited', + retryAfterSeconds: 5, + }, + } + ) + ); + + const retryDisabledUntil = + result.current.state.tabs.tools.collections.toolDefinitions.retryDisabledUntil; + + await act(async () => { + await result.current.loadMoreCollection('toolDefinitions'); + }); + + expect(result.current.state.tabs.tools.collections.toolDefinitions.retryDisabledUntil).toBe( + retryDisabledUntil + ); + expect(result.current.state.tabs.tools.collections.toolDefinitions.nextCursor).toBe( + 'tool-definition-cursor-1' + ); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(2); + }); + + it('consumes invalid-cursor restart budget once per collection generation even after a successful restart', async () => { + const client = makeClient(); + vi.mocked(client.listAgents) + .mockResolvedValueOnce({ items: [agentSummary], nextCursor: 'stale-cursor' }) + .mockRejectedValueOnce( + new CoreRpcError('invalid cursor', 'unknown', 422, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 422, code: 'invalid_cursor' }, + }) + ) + .mockResolvedValueOnce({ + items: [{ ...agentSummary, agentKey: 'agent.beta' }], + nextCursor: 'stale-cursor', + }) + .mockRejectedValueOnce( + new CoreRpcError('invalid cursor', 'unknown', 422, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 422, code: 'invalid_cursor' }, + }) + ) + .mockResolvedValueOnce({ + items: [{ ...agentSummary, agentKey: 'agent.gamma' }], + nextCursor: null, + }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + await act(async () => { + await result.current.loadMoreCollection('agents'); + }); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([ + { ...agentSummary, agentKey: 'agent.beta' }, + ]) + ); + + await act(async () => { + await result.current.loadMoreCollection('agents'); + }); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.observation).toEqual({ + kind: 'blocked', + error: { kind: 'YouPetCoreHttpError', httpStatus: 422, coreCode: 'invalid_cursor' }, + }) + ); + + expect(client.listAgents).toHaveBeenCalledTimes(4); + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([]); + }); + + it('replaces the current detail URL with tab-only state when a surface blocker clears the active tab', async () => { + const client = makeClient(); + const blockedError = new CoreRpcError('forbidden actor', 'unknown', 403, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 403, code: 'forbidden_actor' }, + }); + const replaceStateSpy = vi.spyOn(window.history, 'replaceState'); + + vi.mocked(client.listAgents).mockResolvedValue({ items: [agentSummary], nextCursor: null }); + vi.mocked(client.listToolDefinitions).mockResolvedValue({ + items: [toolDefinitionSummary], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements).mockResolvedValue({ items: [toolEnablement] }); + vi.mocked(client.getToolDefinitionVersion).mockRejectedValue(blockedError); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => expect(result.current.state.tabs.tools.summaryState).toBe('fresh')); + + await act(async () => { + await result.current.openDetail({ kind: 'tool-definition', key: 'tool.alpha', version: 3 }); + }); + + await waitFor(() => + expect(result.current.state.surfaceError).toEqual({ + kind: 'YouPetCoreHttpError', + httpStatus: 403, + coreCode: 'forbidden_actor', + }) + ); + + expect(result.current.state.urlState).toEqual({ tab: 'tools', detail: null }); + expect(window.location.search).toBe('?tab=tools'); + expect(replaceStateSpy).toHaveBeenCalledWith({}, '', '/registries?tab=tools'); + }); + + it('keeps cached detail cleared when a blocked history restore follows a later surface blocker', async () => { + const client = makeClient(); + const blockedError = new CoreRpcError('forbidden actor', 'unknown', 403, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 403, code: 'forbidden_actor' }, + }); + + vi.mocked(client.listAgents) + .mockResolvedValueOnce({ items: [agentSummary], nextCursor: null }) + .mockRejectedValueOnce(blockedError); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }) + .mockRejectedValueOnce(blockedError); + vi.mocked(client.listToolEnablements) + .mockResolvedValueOnce({ items: [toolEnablement] }) + .mockResolvedValueOnce({ items: [toolEnablement] }); + vi.mocked(client.getToolDefinitionVersion).mockResolvedValue(toolDefinitionDetail); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => expect(result.current.state.tabs.tools.summaryState).toBe('fresh')); + + await act(async () => { + await result.current.openDetail({ kind: 'tool-definition', key: 'tool.alpha', version: 3 }); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.detail).toEqual({ + kind: 'loaded', + detail: { kind: 'tool-definition', key: 'tool.alpha', version: 3 }, + record: toolDefinitionDetail, + }) + ); + + await act(async () => { + await result.current.setTab('agents'); + }); + + await act(async () => { + await result.current.refreshActiveTab(); + }); + + await waitFor(() => + expect(result.current.state.surfaceError).toEqual({ + kind: 'YouPetCoreHttpError', + httpStatus: 403, + coreCode: 'forbidden_actor', + }) + ); + + await act(async () => { + window.history.pushState( + {}, + '', + '/registries?tab=tools&kind=tool-definition&key=tool.alpha&version=3' + ); + window.dispatchEvent(new PopStateEvent('popstate')); + }); + + await waitFor(() => + expect(result.current.state.surfaceError).toEqual({ + kind: 'YouPetCoreHttpError', + httpStatus: 403, + coreCode: 'forbidden_actor', + }) + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(result.current.state.urlState).toEqual({ tab: 'tools', detail: null }); + expect(result.current.state.tabs.tools.detail).toEqual({ kind: 'none' }); + expect(window.location.search).toBe('?tab=tools'); + expect(client.getToolDefinitionVersion).toHaveBeenCalledTimes(1); + }); + + it('reloads a previously visited tab after a surface blocker resets all tab data', async () => { + const client = makeClient(); + const blockedError = new CoreRpcError('forbidden actor', 'unknown', 403, { + kind: 'YouPetCoreHttpError', + youpet: { http_status: 403, code: 'forbidden_actor' }, + }); + + vi.mocked(client.listAgents).mockResolvedValue({ items: [agentSummary], nextCursor: null }); + vi.mocked(client.listToolDefinitions) + .mockResolvedValueOnce({ items: [toolDefinitionSummary], nextCursor: null }) + .mockRejectedValueOnce(blockedError) + .mockResolvedValueOnce({ + items: [{ ...toolDefinitionSummary, version: 4 }], + nextCursor: null, + }); + vi.mocked(client.listToolEnablements) + .mockResolvedValueOnce({ items: [toolEnablement] }) + .mockResolvedValueOnce({ items: [toolEnablement] }) + .mockResolvedValueOnce({ + items: [ + { ...toolEnablement, version: 6, generation: 13, updatedAt: '2026-09-01T12:07:00Z' }, + ], + }); + + const { result } = renderHook(() => useRegistryInspection({ client })); + + await waitFor(() => + expect(result.current.state.tabs.agents.collections.agents.items).toEqual([agentSummary]) + ); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => expect(result.current.state.tabs.tools.summaryState).toBe('fresh')); + + await act(async () => { + await result.current.refreshActiveTab(); + }); + + await waitFor(() => + expect(result.current.state.surfaceError).toEqual({ + kind: 'YouPetCoreHttpError', + httpStatus: 403, + coreCode: 'forbidden_actor', + }) + ); + + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([]); + expect(result.current.state.tabs.tools.collections.toolEnablements.items).toEqual([]); + + await act(async () => { + await result.current.setTab('tools'); + }); + + await waitFor(() => + expect(result.current.state.tabs.tools.collections.toolDefinitions.items).toEqual([ + { ...toolDefinitionSummary, version: 4 }, + ]) + ); + expect(result.current.state.tabs.tools.collections.toolEnablements.items).toEqual([ + { ...toolEnablement, version: 6, generation: 13, updatedAt: '2026-09-01T12:07:00Z' }, + ]); + expect(client.listToolDefinitions).toHaveBeenCalledTimes(3); + expect(client.listToolEnablements).toHaveBeenCalledTimes(3); + }); +}); diff --git a/app/src/features/coreRegistries/useRegistryInspection.ts b/app/src/features/coreRegistries/useRegistryInspection.ts new file mode 100644 index 0000000000..153607ffa7 --- /dev/null +++ b/app/src/features/coreRegistries/useRegistryInspection.ts @@ -0,0 +1,714 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + type AgentRegistryAgent, + type AgentRegistryAgentSummary, + type ConnectorRegistryBinding, + type ConnectorRegistryBindingSummary, + type ConnectorRegistryType, + type ConnectorRegistryTypeSummary, + coreRegistriesClient, + type CursorRegistryPage, + extractRegistryBridgeErrorMeta, + type RegistryCursorListParams, + type ToolRegistryToolDefinition, + type ToolRegistryToolDefinitionSummary, + type ToolRegistryToolEnablement, + type UnpagedRegistryCollection, +} from '../../services/api/coreRegistriesClient'; +import { createRegistryInspectionState, LOAD_MORE_LIMIT, registryInspectionReducer } from './state'; +import { + getRegistryTabForDetailKind, + type RegistryCollectionKey, + type RegistryDetailRecord, + type RegistryDetailRef, + type RegistryInspectionState, + type RegistryTab, +} from './types'; +import { parseRegistryUrlState, serializeRegistryUrlState } from './urlState'; + +export interface RegistryInspectionClient { + listAgents: ( + params?: RegistryCursorListParams + ) => Promise>; + getAgentVersion: (params: { agentKey: string; version: number }) => Promise; + listToolDefinitions: ( + params?: RegistryCursorListParams + ) => Promise>; + getToolDefinitionVersion: (params: { + toolKey: string; + version: number; + }) => Promise; + listToolEnablements: () => Promise>; + getToolEnablementVersion: (params: { + toolKey: string; + version: number; + }) => Promise; + listConnectorTypes: ( + params?: RegistryCursorListParams + ) => Promise>; + getConnectorTypeVersion: (params: { + connectorKey: string; + version: number; + }) => Promise; + listConnectorBindings: ( + params?: RegistryCursorListParams + ) => Promise>; + getConnectorBindingVersion: (params: { + bindingKey: string; + version: number; + }) => Promise; +} + +interface UseRegistryInspectionOptions { + client?: RegistryInspectionClient; +} + +export interface UseRegistryInspectionResult { + state: RegistryInspectionState; + setTab: (tab: RegistryTab) => Promise; + refreshActiveTab: () => Promise; + loadMoreCollection: (collection: RegistryCollectionKey) => Promise; + openDetail: (detail: RegistryDetailRef) => Promise; + retryCollection: (collection: RegistryCollectionKey) => Promise; +} + +type ExactCache = Map; + +function cacheKey(detail: RegistryDetailRef): string { + return `${detail.kind}:${detail.key}:${detail.version}`; +} + +function isPageSessionDetailCacheable(detail: RegistryDetailRef): boolean { + return detail.kind !== 'tool-enablement'; +} + +function currentObservedAt(): string { + return new Date().toISOString(); +} + +function isSurfaceBlockingError(error: ReturnType) { + if (!error) { + return false; + } + + if (error.kind === 'YouPetConfigMissing' || error.kind === 'YouPetConfigInvalid') { + return true; + } + + if (error.kind !== 'YouPetCoreHttpError') { + return false; + } + + return ( + error.httpStatus === 401 || + error.httpStatus === 403 || + (error.httpStatus === 503 && + (error.coreCode === 'kernel_tenant_unavailable' || + error.coreCode === 'kernel_tenant_invariant_violation')) + ); +} + +function isInvalidCursorError(error: ReturnType) { + return Boolean( + error && + error.kind === 'YouPetCoreHttpError' && + error.httpStatus === 422 && + error.coreCode === 'invalid_cursor' + ); +} + +function pushUrlState( + urlState: RegistryInspectionState['urlState'], + mode: 'push' | 'replace' +): void { + const serialized = serializeRegistryUrlState(urlState); + const search = serialized.length > 0 ? `?${serialized}` : ''; + const hashRoute = window.location.hash ? window.location.hash.split('?')[0] : ''; + const nextUrl = `${window.location.pathname}${hashRoute}${search}`; + + if (mode === 'replace') { + window.history.replaceState({}, '', nextUrl); + return; + } + + window.history.pushState({}, '', nextUrl); +} + +function collectionTab(collection: RegistryCollectionKey): RegistryTab { + switch (collection) { + case 'agents': + return 'agents'; + case 'toolDefinitions': + case 'toolEnablements': + return 'tools'; + case 'connectorTypes': + case 'connectorBindings': + return 'connectors'; + } +} + +function relevantCollectionKeysForTab(tab: RegistryTab): RegistryCollectionKey[] { + switch (tab) { + case 'agents': + return ['agents']; + case 'tools': + return ['toolDefinitions', 'toolEnablements']; + case 'connectors': + return ['connectorTypes', 'connectorBindings']; + } +} + +function browserSelectsDetail(tab: RegistryTab, detail: RegistryDetailRef): boolean { + const current = parseRegistryUrlState( + window.location.hash.includes('?') + ? window.location.hash.slice(window.location.hash.indexOf('?')) + : window.location.search + ); + return ( + current.tab === tab && + current.detail?.kind === detail.kind && + current.detail.key === detail.key && + current.detail.version === detail.version + ); +} + +function getCollectionState( + state: RegistryInspectionState, + tab: RegistryTab, + collection: RegistryCollectionKey +) { + if (tab === 'agents' && collection === 'agents') { + return state.tabs.agents.collections.agents; + } + + if (tab === 'tools' && collection === 'toolDefinitions') { + return state.tabs.tools.collections.toolDefinitions; + } + + if (tab === 'tools' && collection === 'toolEnablements') { + return state.tabs.tools.collections.toolEnablements; + } + + if (tab === 'connectors' && collection === 'connectorTypes') { + return state.tabs.connectors.collections.connectorTypes; + } + + if (tab === 'connectors' && collection === 'connectorBindings') { + return state.tabs.connectors.collections.connectorBindings; + } + + return null; +} + +function isRetryCooldownActive(retryDisabledUntil: number | null | undefined): boolean { + return typeof retryDisabledUntil === 'number' && retryDisabledUntil > Date.now(); +} + +function hasInFlightCollection(state: RegistryInspectionState, tab: RegistryTab): boolean { + return relevantCollectionKeysForTab(tab).some(collection => { + const collectionState = getCollectionState(state, tab, collection); + return collectionState?.observation.kind === 'loading'; + }); +} + +export function useRegistryInspection( + options: UseRegistryInspectionOptions = {} +): UseRegistryInspectionResult { + const client = options.client ?? coreRegistriesClient; + const [state, setState] = useState(() => + createRegistryInspectionState( + parseRegistryUrlState( + window.location.hash.includes('?') + ? window.location.hash.slice(window.location.hash.indexOf('?')) + : window.location.search + ) + ) + ); + const stateRef = useRef(state); + const visitedTabsRef = useRef(new Set()); + const detailCacheRef = useRef(new Map()); + const generationRef = useRef>({ agents: 0, tools: 0, connectors: 0 }); + + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + if (!state.surfaceError) { + return; + } + + visitedTabsRef.current.clear(); + + const serialized = serializeRegistryUrlState(state.urlState); + const nextSearch = serialized.length > 0 ? `?${serialized}` : ''; + const currentSearch = window.location.hash.includes('?') + ? window.location.hash.slice(window.location.hash.indexOf('?')) + : window.location.search; + if (currentSearch !== nextSearch) { + pushUrlState(state.urlState, 'replace'); + } + }, [state.surfaceError, state.urlState]); + + const dispatch = useCallback((action: Parameters[1]) => { + setState(current => { + const next = registryInspectionReducer(current, action); + stateRef.current = next; + return next; + }); + }, []); + + const nextGeneration = useCallback((tab: RegistryTab) => { + generationRef.current[tab] += 1; + return generationRef.current[tab]; + }, []); + + const runDetailRequest = useCallback( + async (tab: RegistryTab, detail: RegistryDetailRef, generation: number) => { + const detailIsCacheable = isPageSessionDetailCacheable(detail); + const cached = detailIsCacheable ? detailCacheRef.current.get(cacheKey(detail)) : undefined; + if (cached) { + dispatch({ type: 'detail_request_started', tab, generation, detail }); + dispatch({ + type: 'detail_request_succeeded', + tab, + generation, + detail, + record: cached as never, + }); + return; + } + + dispatch({ type: 'detail_request_started', tab, generation, detail }); + + try { + let record: RegistryDetailRecord; + switch (detail.kind) { + case 'agent': + record = await client.getAgentVersion({ + agentKey: detail.key, + version: detail.version, + }); + break; + case 'tool-definition': + record = await client.getToolDefinitionVersion({ + toolKey: detail.key, + version: detail.version, + }); + break; + case 'tool-enablement': + record = await client.getToolEnablementVersion({ + toolKey: detail.key, + version: detail.version, + }); + break; + case 'connector-type': + record = await client.getConnectorTypeVersion({ + connectorKey: detail.key, + version: detail.version, + }); + break; + case 'connector-binding': + record = await client.getConnectorBindingVersion({ + bindingKey: detail.key, + version: detail.version, + }); + break; + } + + if (detailIsCacheable) { + detailCacheRef.current.set(cacheKey(detail), record); + } else { + detailCacheRef.current.delete(cacheKey(detail)); + } + dispatch({ + type: 'detail_request_succeeded', + tab, + generation, + detail, + record: record as never, + }); + } catch (error) { + const meta = extractRegistryBridgeErrorMeta(error); + if (isSurfaceBlockingError(meta) && meta) { + dispatch({ type: 'surface_blocked', error: meta }); + return; + } + + dispatch({ + type: 'detail_request_failed', + tab, + generation, + detail, + error: meta ?? { kind: 'YouPetCoreTransport' }, + }); + } + }, + [client, dispatch] + ); + + const runCollectionRequest = useCallback( + async ( + tab: RegistryTab, + collection: RegistryCollectionKey, + generation: number, + options: { append: boolean; cursor?: string; restarted?: boolean } = { append: false } + ) => { + try { + const observedAt = currentObservedAt(); + + switch (collection) { + case 'agents': { + const response = await client.listAgents({ + limit: LOAD_MORE_LIMIT, + ...(options.cursor ? { cursor: options.cursor } : {}), + }); + dispatch({ + type: 'cursor_collection_request_succeeded', + tab: 'agents', + collection: 'agents', + generation, + items: response.items, + nextCursor: response.nextCursor, + append: options.append, + observedAt, + }); + return; + } + + case 'toolDefinitions': { + const response = await client.listToolDefinitions({ + limit: LOAD_MORE_LIMIT, + ...(options.cursor ? { cursor: options.cursor } : {}), + }); + dispatch({ + type: 'cursor_collection_request_succeeded', + tab: 'tools', + collection: 'toolDefinitions', + generation, + items: response.items, + nextCursor: response.nextCursor, + append: options.append, + observedAt, + }); + return; + } + + case 'toolEnablements': { + const response = await client.listToolEnablements(); + dispatch({ + type: 'unpaged_collection_request_succeeded', + tab: 'tools', + collection: 'toolEnablements', + generation, + items: response.items, + observedAt, + }); + return; + } + + case 'connectorTypes': { + const response = await client.listConnectorTypes({ + limit: LOAD_MORE_LIMIT, + ...(options.cursor ? { cursor: options.cursor } : {}), + }); + dispatch({ + type: 'cursor_collection_request_succeeded', + tab: 'connectors', + collection: 'connectorTypes', + generation, + items: response.items, + nextCursor: response.nextCursor, + append: options.append, + observedAt, + }); + return; + } + + case 'connectorBindings': { + const response = await client.listConnectorBindings({ + limit: LOAD_MORE_LIMIT, + ...(options.cursor ? { cursor: options.cursor } : {}), + }); + dispatch({ + type: 'cursor_collection_request_succeeded', + tab: 'connectors', + collection: 'connectorBindings', + generation, + items: response.items, + nextCursor: response.nextCursor, + append: options.append, + observedAt, + }); + return; + } + } + } catch (error) { + const meta = extractRegistryBridgeErrorMeta(error) ?? { + kind: 'YouPetCoreTransport' as const, + }; + if (isSurfaceBlockingError(meta)) { + dispatch({ type: 'surface_blocked', error: meta }); + return; + } + + const shouldRestart = Boolean( + options.cursor && !options.restarted && isInvalidCursorError(meta) + ); + dispatch({ + type: 'collection_request_failed', + tab, + collection, + generation, + error: meta, + restartPlanned: shouldRestart, + failedAtMs: Date.now(), + }); + + if (shouldRestart) { + await runCollectionRequest(tab, collection, generation, { + append: false, + restarted: true, + }); + } + } + }, + [client, dispatch] + ); + + const loadTabGeneration = useCallback( + async (tab: RegistryTab, generation: number) => { + dispatch({ type: 'tab_request_started', tab, generation }); + + if (tab === 'agents') { + await runCollectionRequest('agents', 'agents', generation, { append: false }); + } else if (tab === 'tools') { + await Promise.all([ + runCollectionRequest('tools', 'toolDefinitions', generation, { append: false }), + runCollectionRequest('tools', 'toolEnablements', generation, { append: false }), + ]); + } else { + await Promise.all([ + runCollectionRequest('connectors', 'connectorTypes', generation, { append: false }), + runCollectionRequest('connectors', 'connectorBindings', generation, { append: false }), + ]); + } + }, + [dispatch, runCollectionRequest] + ); + + const ensureTabLoaded = useCallback( + async (tab: RegistryTab) => { + if (stateRef.current.surfaceError) { + visitedTabsRef.current.delete(tab); + } + if (visitedTabsRef.current.has(tab)) { + return; + } + + visitedTabsRef.current.add(tab); + const generation = nextGeneration(tab); + await loadTabGeneration(tab, generation); + }, + [loadTabGeneration, nextGeneration] + ); + + const setTab = useCallback( + async (tab: RegistryTab) => { + dispatch({ type: 'tab_selected', tab, source: 'user' }); + pushUrlState({ tab, detail: null }, 'push'); + await ensureTabLoaded(tab); + }, + [dispatch, ensureTabLoaded] + ); + + const refreshActiveTab = useCallback(async () => { + const tab = stateRef.current.urlState.tab; + for (const collection of relevantCollectionKeysForTab(tab)) { + const collectionState = getCollectionState(stateRef.current, tab, collection); + if (isRetryCooldownActive(collectionState?.retryDisabledUntil)) { + return; + } + } + + visitedTabsRef.current.add(tab); + const generation = nextGeneration(tab); + await loadTabGeneration(tab, generation); + const selected = stateRef.current.urlState; + if (selected.tab === tab && selected.detail && !stateRef.current.surfaceError) { + await runDetailRequest(tab, selected.detail, generation); + } + }, [loadTabGeneration, nextGeneration, runDetailRequest]); + + const loadMoreCollection = useCallback( + async (collection: RegistryCollectionKey) => { + const tab = collectionTab(collection); + const current = stateRef.current; + const collectionState = + tab === 'agents' + ? current.tabs.agents.collections.agents + : tab === 'tools' + ? collection === 'toolDefinitions' + ? current.tabs.tools.collections.toolDefinitions + : current.tabs.tools.collections.toolEnablements + : collection === 'connectorTypes' + ? current.tabs.connectors.collections.connectorTypes + : current.tabs.connectors.collections.connectorBindings; + + if (!('nextCursor' in collectionState) || !collectionState.nextCursor) { + return; + } + + if (isRetryCooldownActive(collectionState.retryDisabledUntil)) { + return; + } + + if (collectionState.observation.kind === 'loading') { + return; + } + + const generation = collectionState.restartGeneration ?? current.tabs[tab].generation; + dispatch({ type: 'collection_request_started', tab, collection, generation }); + await runCollectionRequest(tab, collection, generation, { + append: true, + cursor: collectionState.nextCursor, + restarted: collectionState.restartAttemptedGeneration === generation, + }); + }, + [dispatch, runCollectionRequest] + ); + + const openDetail = useCallback( + async (detail: RegistryDetailRef) => { + const tab = getRegistryTabForDetailKind(detail.kind); + if (stateRef.current.urlState.tab !== tab) { + dispatch({ type: 'tab_selected', tab, source: 'programmatic' }); + pushUrlState({ tab, detail }, 'push'); + await ensureTabLoaded(tab); + } else { + pushUrlState({ tab, detail }, 'push'); + } + + if (stateRef.current.surfaceError || !browserSelectsDetail(tab, detail)) { + return; + } + + const generation = stateRef.current.tabs[tab].generation; + await runDetailRequest(tab, detail, generation); + }, + [dispatch, ensureTabLoaded, runDetailRequest] + ); + + const retryCollection = useCallback( + async (collection: RegistryCollectionKey) => { + const tab = collectionTab(collection); + const collectionState = getCollectionState(stateRef.current, tab, collection); + if (isRetryCooldownActive(collectionState?.retryDisabledUntil)) { + return; + } + if (hasInFlightCollection(stateRef.current, tab)) { + return; + } + + visitedTabsRef.current.add(tab); + const generation = nextGeneration(tab); + dispatch({ type: 'collection_request_started', tab, collection, generation }); + await runCollectionRequest(tab, collection, generation, { append: false }); + }, + [dispatch, nextGeneration, runCollectionRequest] + ); + + useEffect(() => { + const initialSearch = window.location.hash.includes('?') + ? window.location.hash.slice(window.location.hash.indexOf('?')) + : window.location.search; + const initialUrlState = parseRegistryUrlState(initialSearch); + const canonical = serializeRegistryUrlState(initialUrlState); + if (!(window.location.search === '' && canonical === 'tab=agents')) { + const normalizedSearch = canonical ? `?${canonical}` : ''; + if (window.location.search !== normalizedSearch) { + pushUrlState(initialUrlState, 'replace'); + } + } + + void ensureTabLoaded(initialUrlState.tab).then(async () => { + if ( + !initialUrlState.detail || + stateRef.current.surfaceError || + !browserSelectsDetail(initialUrlState.tab, initialUrlState.detail) + ) { + return; + } + + const cached = isPageSessionDetailCacheable(initialUrlState.detail) + ? detailCacheRef.current.get(cacheKey(initialUrlState.detail)) + : undefined; + const generation = stateRef.current.tabs[initialUrlState.tab].generation; + if (cached) { + dispatch({ + type: 'detail_request_started', + tab: initialUrlState.tab, + generation, + detail: initialUrlState.detail, + }); + dispatch({ + type: 'detail_request_succeeded', + tab: initialUrlState.tab, + generation, + detail: initialUrlState.detail, + record: cached as never, + }); + return; + } + + await runDetailRequest(initialUrlState.tab, initialUrlState.detail, generation); + }); + + const onPopState = () => { + const nextSearch = window.location.hash.includes('?') + ? window.location.hash.slice(window.location.hash.indexOf('?')) + : window.location.search; + const nextUrlState = parseRegistryUrlState(nextSearch); + dispatch({ type: 'tab_selected', tab: nextUrlState.tab, source: 'history' }); + + void ensureTabLoaded(nextUrlState.tab).then(async () => { + if ( + !nextUrlState.detail || + stateRef.current.surfaceError || + !browserSelectsDetail(nextUrlState.tab, nextUrlState.detail) + ) { + return; + } + + const cached = isPageSessionDetailCacheable(nextUrlState.detail) + ? detailCacheRef.current.get(cacheKey(nextUrlState.detail)) + : undefined; + const generation = stateRef.current.tabs[nextUrlState.tab].generation; + if (cached) { + dispatch({ + type: 'detail_request_started', + tab: nextUrlState.tab, + generation, + detail: nextUrlState.detail, + }); + dispatch({ + type: 'detail_request_succeeded', + tab: nextUrlState.tab, + generation, + detail: nextUrlState.detail, + record: cached as never, + }); + return; + } + + await runDetailRequest(nextUrlState.tab, nextUrlState.detail, generation); + }); + }; + + window.addEventListener('popstate', onPopState); + return () => window.removeEventListener('popstate', onPopState); + }, [dispatch, ensureTabLoaded, runDetailRequest]); + + return { state, setTab, refreshActiveTab, loadMoreCollection, openDetail, retryCollection }; +} diff --git a/app/src/lib/i18n/__tests__/coverage.test.ts b/app/src/lib/i18n/__tests__/coverage.test.ts index e3b3a4fafa..cf37cf3d46 100644 --- a/app/src/lib/i18n/__tests__/coverage.test.ts +++ b/app/src/lib/i18n/__tests__/coverage.test.ts @@ -36,6 +36,38 @@ function loadLocale(locale: string): Record { } const enFlat = enAggregate as Record; +const REQUIRED_REGISTRY_INSPECTION_KEYS = [ + 'home.coreRegistries', + 'home.coreRegistriesDescription', + 'home.coreRegistriesBlockedTitle', + 'home.coreRegistriesBlockedMissingDescription', + 'home.coreRegistriesBlockedInvalidDescription', + 'home.coreRegistriesBlockedBridgeDescription', + 'home.coreRegistriesBlockedOfflineDescription', + 'registries.page.eyebrow', + 'registries.page.title', + 'registries.page.description', + 'registries.page.retry', + 'registries.detail.empty', + 'registries.detail.copyFingerprint', + 'registries.drawer.title', +] as const; + +function isExplicitTechnicalLiteral(value: string): boolean { + const trimmed = value.trim(); + if (trimmed === '') return true; + if (!/[A-Za-z]{2,}/.test(trimmed)) return true; + if (/^\{[^}]*\}[%s]?$/.test(trimmed)) return true; + if (/^https?:\/\//.test(trimmed)) return true; + if ( + !/\s/.test(trimmed) && + /[0-9._:/@+%{}#-]/.test(trimmed) && + /^[A-Za-z0-9._:/@+%·✓•…#—–{}'-]+$/.test(trimmed) + ) { + return true; + } + return false; +} describe('i18n coverage', () => { it.each(LOCALES)('locale %s has a translation file', locale => { @@ -69,4 +101,31 @@ describe('i18n coverage', () => { const flat = locale === 'en' ? enFlat : loadLocale(locale); expect(flat['settings.search.engineManagedDesc']).toContain('Exa'); }); + + it('registry inspection keys exist in English', () => { + for (const key of REQUIRED_REGISTRY_INSPECTION_KEYS) { + expect(enFlat[key]).toBeDefined(); + } + }); + + it.each(LOCALES)('locale %s includes the registry inspection keys', locale => { + const flat = loadLocale(locale); + for (const key of REQUIRED_REGISTRY_INSPECTION_KEYS) { + expect(flat[key]).toBeDefined(); + } + }); + + it.each(LOCALES)( + 'locale %s translates ordinary registry inspection values instead of copying English', + locale => { + const flat = loadLocale(locale); + const identicalEnglish = Object.keys(enFlat).filter( + key => + (key.startsWith('home.coreRegistries') || key.startsWith('registries.')) && + flat[key] === enFlat[key] && + !isExplicitTechnicalLiteral(flat[key]) + ); + expect(identicalEnglish).toEqual([]); + } + ); }); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 1f01edb3f9..413d5b0bfd 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -654,6 +654,127 @@ const messages: TranslationMap = { 'تعذر التبديل إلى نماذج OpenRouter المجانية. تحقق من تسجيل الدخول إلى OpenRouter وحاول مرة أخرى.', 'home.routinesCard': 'روتيناتك', 'home.routinesActive': '{count} نشطة', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'روتيناتك', 'routines.subtitle': 'الأشياء التي يقوم بها مساعدك تلقائياً', 'routines.loading': 'روتينات تحميل...', @@ -6108,6 +6229,171 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', 'pages.settings.account.security': 'الأمان', 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', + 'home.coreRegistries': 'سجلات Core', + 'home.coreRegistriesDescription': 'افحص سجلات الوكلاء والأدوات والموصلات الدقيقة من Core.', + 'home.coreRegistriesBlockedTitle': 'تكامل Core مطلوب', + 'home.coreRegistriesBlockedMissingDescription': 'أكمل تكامل Core على سطح المكتب قبل فحص السجلات.', + 'home.coreRegistriesBlockedInvalidDescription': 'أصلح تكامل Core على سطح المكتب قبل فحص السجلات.', + 'home.coreRegistriesBlockedBridgeDescription': 'أعد توصيل جسر Core المحلي قبل فحص السجلات.', + 'home.coreRegistriesBlockedOfflineDescription': 'أعد الاتصال بالإنترنت قبل فحص السجلات.', + 'registries.page.eyebrow': 'طرق عرض السجل', + 'registries.page.title': 'سجلات Core', + 'registries.page.description': + 'فحص للقراءة فقط لسجلات الوكلاء والأدوات والموصلات الدقيقة المدعومة من Core.', + 'registries.page.readOnly': 'لا تكتب هذه الشاشة أبداً الإعدادات أو الأسرار أو حالة التشغيل.', + 'registries.page.retry': 'إعادة المحاولة', + 'registries.page.tablistAria': 'سجلات Core', + 'registries.tab.agents': 'الوكلاء', + 'registries.tab.tools': 'الأدوات', + 'registries.tab.connectors': 'الموصلات', + 'registries.summaryState.idle': 'خامل', + 'registries.summaryState.loading': 'جار التحميل', + 'registries.summaryState.fresh': 'حديث', + 'registries.summaryState.stale': 'قديم', + 'registries.summaryState.partial': 'جزئي', + 'registries.summaryState.blocked': 'محظور', + 'registries.summaryState.observed': 'تمت ملاحظته', + 'registries.blocker.configRequiredTitle': 'تكامل Core مطلوب', + 'registries.blocker.configMissingDescription': + 'لم يتم العثور على إعدادات تكامل Core اللازمة لفحص السجلات.', + 'registries.blocker.configInvalidTitle': 'تكامل Core غير صالح', + 'registries.blocker.configInvalidDescription': + 'إعدادات تكامل Core الحالية غير صالحة لفحص السجلات.', + 'registries.blocker.authRequiredTitle': 'مصادقة Core مطلوبة', + 'registries.blocker.authRequiredDescription': + 'تعذر على فحص السجل المصادقة مع Core في هذه الجلسة.', + 'registries.blocker.forbiddenTitle': 'فحص السجل مرفوض', + 'registries.blocker.forbiddenDescription': 'رفض Core هذا الفاعل لفحص سجلات المستأجر.', + 'registries.blocker.tenantUnavailableTitle': 'المستأجر غير متاح', + 'registries.blocker.tenantUnavailableDescription': + 'أبلغ Core أن هذا المستأجر غير متاح مؤقتاً لفحص السجل.', + 'registries.blocker.tenantInvariantTitle': 'انتهاك ثابت المستأجر', + 'registries.blocker.tenantInvariantDescription': + 'أبلغ Core عن انتهاك ثابت المستأجر أثناء فحص السجل.', + 'registries.blocker.genericDescription': 'يعوق تكامل Core حالياً فحص السجل.', + 'registries.blocker.readOnly': 'هذه الشاشة للقراءة فقط ولا يمكنها كتابة الإعدادات نيابةً عنك.', + 'registries.blocker.fixFlow': 'أصلح اتصال Core في مسار التكامل الحالي ثم أعد المحاولة هنا.', + 'registries.collections.agents.title': 'الوكلاء', + 'registries.collections.agents.description': + 'سجلات الوكلاء المنشورة. لا يتم تحديد أي سجل تلقائياً.', + 'registries.collections.agents.loadMore': 'تحميل المزيد من الوكلاء', + 'registries.collections.toolDefinitions.title': 'التعريفات', + 'registries.collections.toolDefinitions.description': + 'عقود الأدوات المنشورة، منفصلة عن سجلات التمكين.', + 'registries.collections.toolDefinitions.loadMore': 'تحميل المزيد من التعريفات', + 'registries.collections.toolEnablements.title': 'التمكين', + 'registries.collections.toolEnablements.description': + 'بوابات الأذونات وحدود الموافقة وقت التشغيل للأدوات.', + 'registries.collections.connectorTypes.title': 'الأنواع', + 'registries.collections.connectorTypes.description': 'أنواع الموصلات المنشورة وعقود التطبيع.', + 'registries.collections.connectorTypes.loadMore': 'تحميل المزيد من الأنواع', + 'registries.collections.connectorBindings.title': 'الارتباطات', + 'registries.collections.connectorBindings.description': + 'حسابات المزوّد المرتبطة واختيارات القدرات.', + 'registries.collections.connectorBindings.loadMore': 'تحميل المزيد من الارتباطات', + 'registries.items.created': 'أُنشئ في {date}', + 'registries.items.fingerprint.config': 'بصمة الإعداد {fingerprint}', + 'registries.items.fingerprint.definition': 'بصمة التعريف {fingerprint}', + 'registries.items.fingerprint.type': 'بصمة النوع {fingerprint}', + 'registries.items.fingerprint.binding': 'بصمة الارتباط {fingerprint}', + 'registries.items.meta.noAuditMode': 'لا يوجد وضع تدقيق', + 'registries.items.meta.approvalRequired': 'الموافقة مطلوبة', + 'registries.items.meta.noApprovalGate': 'لا توجد بوابة موافقة', + 'registries.items.status.noTenantEnablement': 'لم يتم إرجاع تمكين للمستأجر', + 'registries.live.blocked': 'تنبيه: {title}. {description}', + 'registries.live.loaded': 'تبويب {tab} نشط. تم اختيار {key} بالإصدار {version}.', + 'registries.live.loading': 'تبويب {tab} نشط. جار تحميل {key} بالإصدار {version}.', + 'registries.live.idle': 'تبويب {tab} نشط. لا توجد تفاصيل محددة.', + 'registries.drawer.title': 'تفاصيل السجل', + 'registries.drawer.eyebrow': 'تفاصيل السجل', + 'registries.collection.state.idle': 'خامل', + 'registries.collection.state.loading': 'جار التحميل', + 'registries.collection.state.observed': 'تمت ملاحظته', + 'registries.collection.state.stale': 'قديم', + 'registries.collection.state.blocked': 'محظور', + 'registries.collection.body.idle': 'في انتظار الملاحظة الأولى.', + 'registries.collection.body.loading': 'جار تحديث السجلات الدقيقة من Core.', + 'registries.collection.body.empty': 'تمت الملاحظة من دون سجلات مُعادة.', + 'registries.collection.body.observedAt': 'تمت الملاحظة عند {observedAt}.', + 'registries.collection.body.stale': + 'تمت الملاحظة عند {observedAt}؛ واحتفظ آخر تحديث بآخر بيانات سليمة.', + 'registries.collection.body.blocked': 'رفض Core فحص هذه المجموعة.', + 'registries.collection.empty': 'لا توجد سجلات دقيقة متاحة بعد في هذه المجموعة.', + 'registries.detail.notAvailable': 'غير متاح', + 'registries.detail.copyFingerprint': 'نسخ البصمة الكاملة', + 'registries.detail.emptySchemaObject': 'كائن مخطط فارغ', + 'registries.detail.schemaType': 'النوع {type}', + 'registries.detail.schemaProperties': '{count} خصائص', + 'registries.detail.schemaRequired': '{count} مطلوبة', + 'registries.detail.noFields': 'لا توجد حقول', + 'registries.detail.fieldsSampleMore': '{count} حقول · {sample}…', + 'registries.detail.fieldsSample': '{count} حقول · {sample}', + 'registries.detail.noNormalizationContracts': 'لا توجد عقود تطبيع', + 'registries.detail.unresolved': 'غير محلول: {label}', + 'registries.detail.deferred': 'حمّل التبويب الهدف لفحص {label}.', + 'registries.detail.fingerprint.configuration': 'بصمة الإعداد', + 'registries.detail.fingerprint.definition': 'بصمة التعريف', + 'registries.detail.fingerprint.connectorType': 'بصمة نوع الموصل', + 'registries.detail.fingerprint.binding': 'بصمة الارتباط', + 'registries.detail.section.agentLifecycle': 'دورة حياة الوكيل', + 'registries.detail.section.exactToolReferences': 'مراجع الأدوات الدقيقة', + 'registries.detail.section.logicalReferenceWarnings': 'تحذيرات المراجع المنطقية', + 'registries.detail.section.configuration': 'الإعداد', + 'registries.detail.section.definitionLifecycle': 'دورة حياة التعريف', + 'registries.detail.section.schemas': 'المخططات', + 'registries.detail.section.enablementLifecycle': 'دورة حياة التمكين', + 'registries.detail.section.definitionLink': 'رابط التعريف', + 'registries.detail.section.typeLifecycle': 'دورة حياة النوع', + 'registries.detail.section.contracts': 'العقود', + 'registries.detail.section.bindingLifecycle': 'دورة حياة الارتباط', + 'registries.detail.section.exactConnectorType': 'نوع الموصل الدقيق', + 'registries.detail.section.logicalReferences': 'المراجع المنطقية', + 'registries.detail.note.activeRecords': + 'السجلات النشطة تمثل حالات نشر، وليست منح أذونات وقت التشغيل.', + 'registries.detail.noExactToolRefs': 'لم يتم التصريح بأي مراجع أدوات دقيقة.', + 'registries.detail.note.logicalReferenceWarnings': + 'تحتاج المراجع المنطقية إلى متابعة خارج هذا العرض المخصص للقراءة فقط.', + 'registries.detail.field.lifecycle': 'دورة الحياة', + 'registries.detail.field.owner': 'المالك', + 'registries.detail.field.created': 'تاريخ الإنشاء', + 'registries.detail.field.knowledgeScopes': 'نطاقات المعرفة', + 'registries.detail.field.riskPolicy': 'سياسة المخاطر', + 'registries.detail.none': 'لا شيء', + 'registries.detail.field.effectClass': 'فئة التأثير', + 'registries.detail.field.schemaVersion': 'إصدار المخطط', + 'registries.detail.field.enablement': 'التمكين', + 'registries.detail.field.inputSchema': 'مخطط الإدخال', + 'registries.detail.field.outputSchema': 'مخطط الإخراج', + 'registries.detail.field.timeoutDefaults': 'المهل الافتراضية', + 'registries.detail.field.retryContract': 'عقد إعادة المحاولة', + 'registries.detail.field.auditContract': 'عقد التدقيق', + 'registries.detail.viewRawJson': 'عرض JSON الخام', + 'registries.detail.field.generation': 'الجيل', + 'registries.detail.field.approvalRequired': 'الموافقة مطلوبة', + 'registries.detail.field.auditMode': 'وضع التدقيق', + 'registries.detail.notSet': 'غير مضبوط', + 'registries.detail.field.timeoutCap': 'حد المهلة', + 'registries.detail.field.allowTtl': 'TTL المسموح', + 'registries.detail.field.updated': 'آخر تحديث', + 'registries.detail.note.enablementRecords': + 'تعبر سجلات التمكين عن بوابات الأذونات، لا عن حالة نشر التعريف.', + 'registries.detail.field.sourceType': 'نوع المصدر', + 'registries.detail.field.capabilities': 'القدرات', + 'registries.detail.field.normalizationContracts': 'عقود التطبيع', + 'registries.detail.field.deliveryBehavior': 'سلوك التسليم', + 'registries.detail.field.providerAccountReference': 'مرجع حساب المزوّد', + 'registries.detail.note.logicalReferencesSecret': 'يُعرض المرجع المنطقي فقط؛ ولا يُعرض السر.', + 'registries.detail.field.configReference': 'مرجع الإعداد', + 'registries.detail.field.credentialReference': 'مرجع بيانات الاعتماد', + 'registries.detail.empty': 'اختر سجلاً لفحص إصدار السجل الدقيق الخاص به.', + 'registries.detail.state.loading': 'جار تحميل تفاصيل السجل الدقيقة...', + 'registries.detail.state.missing': 'أبلغ Core أن إصدار هذا السجل الدقيق لم يعد موجوداً.', + 'registries.detail.state.error': 'لا يستطيع Core تحميل هذا السجل الدقيق حالياً.', + 'registries.detail.kind.agent': 'وكيل', + 'registries.detail.kind.tool-definition': 'تعريف أداة', + 'registries.detail.kind.tool-enablement': 'تمكين أداة', + 'registries.detail.kind.connector-type': 'نوع موصل', + 'registries.detail.kind.connector-binding': 'ارتباط موصل', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'متدهور', 'memoryTree.status.statusBudgetExhausted': 'متوقف مؤقتًا: انتهت ميزانية التضمينات', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 4e7fdfa4ad..da5712bef8 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -670,6 +670,127 @@ const messages: TranslationMap = { 'OpenRouter-এর বিনামূল্যের মডেলে স্যুইচ করা যায়নি। আপনার OpenRouter সাইন-ইন পরীক্ষা করে আবার চেষ্টা করুন।', 'home.routinesCard': 'আপনার রুটিন', 'home.routinesActive': '{count}টি সক্রিয়', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'তোমার বিদ্বান', 'routines.subtitle': 'আপনার সহকারী স্বয়ংক্রিয়ভাবে যা কিছু করেন', 'routines.loading': 'তালিকা লোড করা হচ্ছে...', @@ -6253,6 +6374,182 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', 'pages.settings.account.security': 'নিরাপত্তা', 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', + 'home.coreRegistries': 'Core রেজিস্ট্রি', + 'home.coreRegistriesDescription': + 'Core থেকে নির্দিষ্ট Agent, Tool এবং Connector রেকর্ড পরিদর্শন করুন।', + 'home.coreRegistriesBlockedTitle': 'Core ইন্টিগ্রেশন প্রয়োজন', + 'home.coreRegistriesBlockedMissingDescription': + 'রেজিস্ট্রি দেখার আগে ডেস্কটপ Core ইন্টিগ্রেশন সম্পূর্ণ করুন।', + 'home.coreRegistriesBlockedInvalidDescription': + 'রেজিস্ট্রি দেখার আগে ডেস্কটপ Core ইন্টিগ্রেশন মেরামত করুন।', + 'home.coreRegistriesBlockedBridgeDescription': + 'রেজিস্ট্রি দেখার আগে স্থানীয় Core ব্রিজ পুনরায় সংযোগ করুন।', + 'home.coreRegistriesBlockedOfflineDescription': + 'রেজিস্ট্রি দেখার আগে আবার ইন্টারনেটে সংযুক্ত হন।', + 'registries.page.eyebrow': 'রেজিস্ট্রি ভিউ', + 'registries.page.title': 'Core রেজিস্ট্রি', + 'registries.page.description': + 'Core-সমর্থিত নির্দিষ্ট Agent, Tool এবং Connector রেকর্ডের শুধু-পাঠযোগ্য পরিদর্শন।', + 'registries.page.readOnly': 'এই স্ক্রিন কখনও কনফিগারেশন, গোপন তথ্য বা রানটাইম অবস্থা লেখে না।', + 'registries.page.retry': 'আবার চেষ্টা করুন', + 'registries.page.tablistAria': 'Core রেজিস্ট্রি', + 'registries.tab.agents': 'এজেন্ট', + 'registries.tab.tools': 'টুল', + 'registries.tab.connectors': 'কনেক্টর', + 'registries.summaryState.idle': 'নিষ্ক্রিয়', + 'registries.summaryState.loading': 'লোড হচ্ছে', + 'registries.summaryState.fresh': 'সাম্প্রতিক', + 'registries.summaryState.stale': 'পুরোনো', + 'registries.summaryState.partial': 'আংশিক', + 'registries.summaryState.blocked': 'অবরুদ্ধ', + 'registries.summaryState.observed': 'পর্যবেক্ষিত', + 'registries.blocker.configRequiredTitle': 'Core ইন্টিগ্রেশন প্রয়োজন', + 'registries.blocker.configMissingDescription': + 'রেজিস্ট্রি পরিদর্শনের জন্য Core ইন্টিগ্রেশন কনফিগারেশন পাওয়া যায়নি।', + 'registries.blocker.configInvalidTitle': 'Core ইন্টিগ্রেশন অবৈধ', + 'registries.blocker.configInvalidDescription': + 'বর্তমান Core ইন্টিগ্রেশন কনফিগারেশন রেজিস্ট্রি পরিদর্শনের জন্য বৈধ নয়।', + 'registries.blocker.authRequiredTitle': 'Core প্রমাণীকরণ প্রয়োজন', + 'registries.blocker.authRequiredDescription': + 'এই সেশনে রেজিস্ট্রি পরিদর্শন Core-এর সাথে প্রমাণীকরণ করতে পারেনি।', + 'registries.blocker.forbiddenTitle': 'রেজিস্ট্রি পরিদর্শন নিষিদ্ধ', + 'registries.blocker.forbiddenDescription': + 'Tenant রেজিস্ট্রি পরিদর্শনের জন্য Core এই actor-কে প্রত্যাখ্যান করেছে।', + 'registries.blocker.tenantUnavailableTitle': 'Tenant অনুপলব্ধ', + 'registries.blocker.tenantUnavailableDescription': + 'Core জানিয়েছে যে এই tenant সাময়িকভাবে রেজিস্ট্রি পরিদর্শনের জন্য উপলব্ধ নয়।', + 'registries.blocker.tenantInvariantTitle': 'Tenant invariant লঙ্ঘন', + 'registries.blocker.tenantInvariantDescription': + 'Core রেজিস্ট্রি পরিদর্শনের জন্য tenant invariant লঙ্ঘনের কথা জানিয়েছে।', + 'registries.blocker.genericDescription': + 'Core ইন্টিগ্রেশন বর্তমানে রেজিস্ট্রি পরিদর্শনকে বাধা দিচ্ছে।', + 'registries.blocker.readOnly': + 'এই স্ক্রিন শুধু-পাঠযোগ্য এবং আপনার জন্য কনফিগারেশন লিখতে পারে না।', + 'registries.blocker.fixFlow': + 'বিদ্যমান ইন্টিগ্রেশন ফ্লোতে Core সংযোগ ঠিক করুন, তারপর এখানে আবার চেষ্টা করুন।', + 'registries.collections.agents.title': 'এজেন্ট', + 'registries.collections.agents.description': + 'প্রকাশিত এজেন্ট রেকর্ড। কোনো রেকর্ড স্বয়ংক্রিয়ভাবে নির্বাচন করা হয় না।', + 'registries.collections.agents.loadMore': 'আরও এজেন্ট লোড করুন', + 'registries.collections.toolDefinitions.title': 'সংজ্ঞা', + 'registries.collections.toolDefinitions.description': + 'প্রকাশিত টুল চুক্তি, অনুমতি সক্রিয়করণ থেকে আলাদা।', + 'registries.collections.toolDefinitions.loadMore': 'আরও সংজ্ঞা লোড করুন', + 'registries.collections.toolEnablements.title': 'সক্রিয়করণ', + 'registries.collections.toolEnablements.description': + 'টুলের জন্য অনুমতি গেট এবং রানটাইম অনুমোদন সীমা।', + 'registries.collections.connectorTypes.title': 'ধরন', + 'registries.collections.connectorTypes.description': + 'প্রকাশিত কনেক্টর ধরন এবং নর্মালাইজেশন চুক্তি।', + 'registries.collections.connectorTypes.loadMore': 'আরও ধরন লোড করুন', + 'registries.collections.connectorBindings.title': 'বাইন্ডিং', + 'registries.collections.connectorBindings.description': + 'বাঁধা provider account এবং capability নির্বাচন।', + 'registries.collections.connectorBindings.loadMore': 'আরও বাইন্ডিং লোড করুন', + 'registries.items.created': 'তৈরি হয়েছে {date}', + 'registries.items.fingerprint.config': 'بিন্যাস ফিঙ্গারপ্রিন্ট {fingerprint}', + 'registries.items.fingerprint.definition': 'সংজ্ঞা fp:{fingerprint}', + 'registries.items.fingerprint.type': 'ধরন fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'বাইন্ডিং fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'কোনো audit mode নেই', + 'registries.items.meta.approvalRequired': 'অনুমোদন প্রয়োজন', + 'registries.items.meta.noApprovalGate': 'কোনো approval gate নেই', + 'registries.items.status.noTenantEnablement': 'কোনো tenant enablement ফেরত আসেনি', + 'registries.live.blocked': 'সতর্কতা: {title}. {description}', + 'registries.live.loaded': '{tab} ট্যাব সক্রিয়। {key} সংস্করণ {version} নির্বাচিত।', + 'registries.live.loading': '{tab} ট্যাব সক্রিয়। {key} সংস্করণ {version} লোড হচ্ছে।', + 'registries.live.idle': '{tab} ট্যাব সক্রিয়। কোনো বিস্তারিত নির্বাচন করা হয়নি।', + 'registries.drawer.title': 'রেজিস্ট্রি বিস্তারিত', + 'registries.drawer.eyebrow': 'রেজিস্ট্রি বিস্তারিত', + 'registries.collection.state.idle': 'নিষ্ক্রিয়', + 'registries.collection.state.loading': 'লোড হচ্ছে', + 'registries.collection.state.observed': 'পর্যবেক্ষিত', + 'registries.collection.state.stale': 'পুরোনো', + 'registries.collection.state.blocked': 'অবরুদ্ধ', + 'registries.collection.body.idle': 'প্রথম পর্যবেক্ষণের অপেক্ষায়।', + 'registries.collection.body.loading': 'Core থেকে নির্দিষ্ট রেকর্ড রিফ্রেশ করা হচ্ছে।', + 'registries.collection.body.empty': 'পর্যবেক্ষণ সম্পন্ন, কিন্তু কোনো রেকর্ড ফেরত আসেনি।', + 'registries.collection.body.observedAt': '{observedAt}-এ পর্যবেক্ষণ করা হয়েছে।', + 'registries.collection.body.stale': + '{observedAt}-এ পর্যবেক্ষিত; সর্বশেষ রিফ্রেশ শেষ ভালো ডেটা ধরে রেখেছে।', + 'registries.collection.body.blocked': 'Core এই collection-কে পরিদর্শনের জন্য প্রত্যাখ্যান করেছে।', + 'registries.collection.empty': 'এই collection-এ এখনও কোনো নির্দিষ্ট রেকর্ড উপলব্ধ নেই।', + 'registries.detail.notAvailable': 'উপলব্ধ নয়', + 'registries.detail.copyFingerprint': 'সম্পূর্ণ fingerprint কপি করুন', + 'registries.detail.emptySchemaObject': 'খালি schema object', + 'registries.detail.schemaType': 'ধরন {type}', + 'registries.detail.schemaProperties': '{count}টি বৈশিষ্ট্য', + 'registries.detail.schemaRequired': '{count}টি প্রয়োজনীয়', + 'registries.detail.noFields': 'কোনো field নেই', + 'registries.detail.fieldsSampleMore': '{count}টি ক্ষেত্র · {sample}…', + 'registries.detail.fieldsSample': '{count}টি ক্ষেত্র · {sample}', + 'registries.detail.noNormalizationContracts': 'কোনো normalization contract নেই', + 'registries.detail.unresolved': 'অমীমাংসিত: {label}', + 'registries.detail.deferred': '{label} দেখতে target tab লোড করুন।', + 'registries.detail.fingerprint.configuration': 'কনফিগারেশন fingerprint', + 'registries.detail.fingerprint.definition': 'সংজ্ঞার fingerprint', + 'registries.detail.fingerprint.connectorType': 'কনেক্টর ধরন fingerprint', + 'registries.detail.fingerprint.binding': 'বাইন্ডিং fingerprint', + 'registries.detail.section.agentLifecycle': 'এজেন্ট lifecycle', + 'registries.detail.section.exactToolReferences': 'নির্দিষ্ট tool reference', + 'registries.detail.section.logicalReferenceWarnings': 'লজিক্যাল reference সতর্কতা', + 'registries.detail.section.configuration': 'কনফিগারেশন', + 'registries.detail.section.definitionLifecycle': 'সংজ্ঞার lifecycle', + 'registries.detail.section.schemas': 'Schema', + 'registries.detail.section.enablementLifecycle': 'সক্রিয়করণের lifecycle', + 'registries.detail.section.definitionLink': 'সংজ্ঞার লিংক', + 'registries.detail.section.typeLifecycle': 'ধরনের lifecycle', + 'registries.detail.section.contracts': 'চুক্তি', + 'registries.detail.section.bindingLifecycle': 'বাইন্ডিং lifecycle', + 'registries.detail.section.exactConnectorType': 'নির্দিষ্ট connector ধরন', + 'registries.detail.section.logicalReferences': 'লজিক্যাল reference', + 'registries.detail.note.activeRecords': + 'সক্রিয় রেকর্ড প্রকাশনার অবস্থা নির্দেশ করে, রানটাইম অনুমতি নয়।', + 'registries.detail.noExactToolRefs': 'কোনো নির্দিষ্ট tool reference ঘোষণা করা হয়নি।', + 'registries.detail.note.logicalReferenceWarnings': + 'এই শুধু-পাঠযোগ্য ভিউয়ের বাইরে লজিক্যাল reference অনুসরণ করতে হবে।', + 'registries.detail.field.lifecycle': 'জীবনচক্র', + 'registries.detail.field.owner': 'মালিক', + 'registries.detail.field.created': 'তৈরি হয়েছে', + 'registries.detail.field.knowledgeScopes': 'জ্ঞান ক্ষেত্র', + 'registries.detail.field.riskPolicy': 'ঝুঁকি নীতি', + 'registries.detail.none': 'কিছুই নয়', + 'registries.detail.field.effectClass': 'প্রভাব শ্রেণি', + 'registries.detail.field.schemaVersion': 'স্কিমা সংস্করণ', + 'registries.detail.field.enablement': 'সক্রিয়করণ', + 'registries.detail.field.inputSchema': 'ইনপুট স্কিমা', + 'registries.detail.field.outputSchema': 'আউটপুট স্কিমা', + 'registries.detail.field.timeoutDefaults': 'ডিফল্ট timeout', + 'registries.detail.field.retryContract': 'পুনরায় প্রচেষ্টা চুক্তি', + 'registries.detail.field.auditContract': 'অডিট চুক্তি', + 'registries.detail.viewRawJson': 'কাঁচা JSON দেখুন', + 'registries.detail.field.generation': 'প্রজন্ম', + 'registries.detail.field.approvalRequired': 'অনুমোদন প্রয়োজন', + 'registries.detail.field.auditMode': 'অডিট মোড', + 'registries.detail.notSet': 'সেট করা নেই', + 'registries.detail.field.timeoutCap': 'টাইমআউট সীমা', + 'registries.detail.field.allowTtl': 'অনুমোদিত TTL', + 'registries.detail.field.updated': 'আপডেট হয়েছে', + 'registries.detail.note.enablementRecords': + 'Enablement record অনুমতি গেট বোঝায়, সংজ্ঞার প্রকাশনা অবস্থা নয়।', + 'registries.detail.field.sourceType': 'উৎসের ধরন', + 'registries.detail.field.capabilities': 'Capability', + 'registries.detail.field.normalizationContracts': 'নর্মালাইজেশন চুক্তি', + 'registries.detail.field.deliveryBehavior': 'ডেলিভারি আচরণ', + 'registries.detail.field.providerAccountReference': 'প্রোভাইডার অ্যাকাউন্ট রেফারেন্স', + 'registries.detail.note.logicalReferencesSecret': + 'শুধু লজিক্যাল reference দেখানো হয়; গোপন তথ্য দেখানো হয় না।', + 'registries.detail.field.configReference': 'কনফিগারেশন রেফারেন্স', + 'registries.detail.field.credentialReference': 'ক্রেডেনশিয়াল রেফারেন্স', + 'registries.detail.empty': 'সঠিক রেজিস্ট্রি সংস্করণ দেখতে একটি রেকর্ড নির্বাচন করুন।', + 'registries.detail.state.loading': 'নির্দিষ্ট রেজিস্ট্রি বিস্তারিত লোড হচ্ছে...', + 'registries.detail.state.missing': 'Core জানিয়েছে যে এই নির্দিষ্ট রেকর্ড সংস্করণটি আর নেই।', + 'registries.detail.state.error': 'Core এখন এই নির্দিষ্ট রেকর্ডটি লোড করতে পারেনি।', + 'registries.detail.kind.agent': 'এজেন্ট', + 'registries.detail.kind.tool-definition': 'টুল সংজ্ঞা', + 'registries.detail.kind.tool-enablement': 'টুল সক্রিয়করণ', + 'registries.detail.kind.connector-type': 'কনেক্টর ধরন', + 'registries.detail.kind.connector-binding': 'কনেক্টর বাইন্ডিং', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'অবনমিত', 'memoryTree.status.statusBudgetExhausted': 'বিরতি: এমবেডিং বাজেট শেষ', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b6a3eafca0..0c3d7f50fb 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -701,6 +701,127 @@ const messages: TranslationMap = { 'Der Wechsel zu kostenlosen OpenRouter-Modellen ist fehlgeschlagen. Prüfe deine OpenRouter-Anmeldung und versuche es erneut.', 'home.routinesCard': 'Deine Routinen', 'home.routinesActive': '{count} aktiv', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Core-Warnungen und Bedieneraktionen prüfen.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Ausstehende Core-Aktionsanfragen als Bediener genehmigen oder ablehnen.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Ihre Routinen', 'routines.subtitle': 'Dinge, die Ihr Assistent automatisch erledigt', 'routines.loading': 'Routinen werden geladen...', @@ -6418,6 +6539,183 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', 'pages.settings.account.security': 'Sicherheit', 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', + 'home.coreRegistries': 'Core-Register', + 'home.coreRegistriesDescription': 'Prüfe exakte Agent-, Tool- und Connector-Einträge aus Core.', + 'home.coreRegistriesBlockedTitle': 'Core-Integration erforderlich', + 'home.coreRegistriesBlockedMissingDescription': + 'Schließe die Desktop-Core-Integration ab, bevor du Register prüfst.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Repariere die Desktop-Core-Integration, bevor du Register prüfst.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Verbinde die lokale Core-Brücke erneut, bevor du Register prüfst.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Stelle die Internetverbindung wieder her, bevor du Register prüfst.', + 'registries.page.eyebrow': 'Registeransichten', + 'registries.page.title': 'Core-Register', + 'registries.page.description': + 'Schreibgeschützte Prüfung exakter Agent-, Tool- und Connector-Einträge aus Core.', + 'registries.page.readOnly': + 'Diese Ansicht schreibt niemals Konfiguration, Geheimnisse oder Laufzeitstatus.', + 'registries.page.retry': 'Erneut versuchen', + 'registries.page.tablistAria': 'Core-Register', + 'registries.tab.agents': 'Agenten', + 'registries.tab.tools': 'Werkzeuge', + 'registries.tab.connectors': 'Konnektoren', + 'registries.summaryState.idle': 'Leerlauf', + 'registries.summaryState.loading': 'Lädt', + 'registries.summaryState.fresh': 'Aktuell', + 'registries.summaryState.stale': 'Veraltet', + 'registries.summaryState.partial': 'Teilweise', + 'registries.summaryState.blocked': 'Blockiert', + 'registries.summaryState.observed': 'Beobachtet', + 'registries.blocker.configRequiredTitle': 'Core-Integration erforderlich', + 'registries.blocker.configMissingDescription': + 'Für die Registerprüfung wurde keine Core-Integrationskonfiguration gefunden.', + 'registries.blocker.configInvalidTitle': 'Core-Integration ungültig', + 'registries.blocker.configInvalidDescription': + 'Die aktuelle Core-Integrationskonfiguration ist für die Registerprüfung ungültig.', + 'registries.blocker.authRequiredTitle': 'Core-Authentifizierung erforderlich', + 'registries.blocker.authRequiredDescription': + 'Die Registerprüfung konnte sich in dieser Sitzung nicht bei Core authentifizieren.', + 'registries.blocker.forbiddenTitle': 'Registerprüfung verboten', + 'registries.blocker.forbiddenDescription': + 'Core hat diesen Akteur für die Tenant-Registerprüfung abgelehnt.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant nicht verfügbar', + 'registries.blocker.tenantUnavailableDescription': + 'Core meldete, dass dieser Tenant für die Registerprüfung vorübergehend nicht verfügbar ist.', + 'registries.blocker.tenantInvariantTitle': 'Tenant-Invariante verletzt', + 'registries.blocker.tenantInvariantDescription': + 'Core meldete eine Verletzung der Tenant-Invariante für die Registerprüfung.', + 'registries.blocker.genericDescription': + 'Die Core-Integration blockiert derzeit die Registerprüfung.', + 'registries.blocker.readOnly': + 'Diese Ansicht ist schreibgeschützt und kann keine Konfiguration für dich schreiben.', + 'registries.blocker.fixFlow': + 'Behebe die Core-Verbindung im vorhandenen Integrationsfluss und versuche es hier erneut.', + 'registries.collections.agents.title': 'Agenten', + 'registries.collections.agents.description': + 'Veröffentlichte Agent-Einträge. Kein Eintrag wird automatisch ausgewählt.', + 'registries.collections.agents.loadMore': 'Mehr Agenten laden', + 'registries.collections.toolDefinitions.title': 'Definitionen', + 'registries.collections.toolDefinitions.description': + 'Veröffentlichte Tool-Verträge, getrennt von Berechtigungsaktivierungen.', + 'registries.collections.toolDefinitions.loadMore': 'Mehr Definitionen laden', + 'registries.collections.toolEnablements.title': 'Aktivierungen', + 'registries.collections.toolEnablements.description': + 'Berechtigungsschranken und Laufzeitfreigaben für Tools.', + 'registries.collections.connectorTypes.title': 'Typen', + 'registries.collections.connectorTypes.description': + 'Veröffentlichte Konnektortypen und Normalisierungsverträge.', + 'registries.collections.connectorTypes.loadMore': 'Mehr Typen laden', + 'registries.collections.connectorBindings.title': 'Bindungen', + 'registries.collections.connectorBindings.description': + 'Gebundene Provider-Konten und Fähigkeitsauswahl.', + 'registries.collections.connectorBindings.loadMore': 'Mehr Bindungen laden', + 'registries.items.created': 'Erstellt {date}', + 'registries.items.fingerprint.config': 'Konfigurations-Fingerprint {fingerprint}', + 'registries.items.fingerprint.definition': 'Definitions-Fingerprint {fingerprint}', + 'registries.items.fingerprint.type': 'Typ fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Bindung fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'Kein Audit-Modus', + 'registries.items.meta.approvalRequired': 'Freigabe erforderlich', + 'registries.items.meta.noApprovalGate': 'Keine Freigabeschranke', + 'registries.items.status.noTenantEnablement': 'Keine Tenant-Aktivierung zurückgegeben', + 'registries.live.blocked': 'Hinweis: {title}. {description}', + 'registries.live.loaded': 'Registerkarte {tab} aktiv. {key} Version {version} ausgewählt.', + 'registries.live.loading': 'Registerkarte {tab} aktiv. {key} Version {version} wird geladen.', + 'registries.live.idle': 'Registerkarte {tab} aktiv. Kein Detail ausgewählt.', + 'registries.drawer.title': 'Registerdetail', + 'registries.drawer.eyebrow': 'Registerdetail', + 'registries.collection.state.idle': 'Leerlauf', + 'registries.collection.state.loading': 'Lädt', + 'registries.collection.state.observed': 'Beobachtet', + 'registries.collection.state.stale': 'Veraltet', + 'registries.collection.state.blocked': 'Blockiert', + 'registries.collection.body.idle': 'Warte auf die erste Beobachtung.', + 'registries.collection.body.loading': 'Exakte Einträge werden aus Core aktualisiert.', + 'registries.collection.body.empty': 'Beobachtet, aber ohne zurückgegebene Einträge.', + 'registries.collection.body.observedAt': 'Beobachtet um {observedAt}.', + 'registries.collection.body.stale': + 'Beobachtet um {observedAt}; die letzte Aktualisierung behielt die letzten gültigen Daten.', + 'registries.collection.body.blocked': 'Core hat diese Sammlung für die Prüfung abgelehnt.', + 'registries.collection.empty': 'In dieser Sammlung sind noch keine exakten Einträge verfügbar.', + 'registries.detail.notAvailable': 'Nicht verfügbar', + 'registries.detail.copyFingerprint': 'Vollständigen Fingerabdruck kopieren', + 'registries.detail.emptySchemaObject': 'Leeres Schemaobjekt', + 'registries.detail.schemaType': 'Typ {type}', + 'registries.detail.schemaProperties': '{count} Eigenschaften', + 'registries.detail.schemaRequired': '{count} erforderlich', + 'registries.detail.noFields': 'Keine Felder', + 'registries.detail.fieldsSampleMore': '{count} Felder · {sample}…', + 'registries.detail.fieldsSample': '{count} Felder · {sample}', + 'registries.detail.noNormalizationContracts': 'Keine Normalisierungsverträge', + 'registries.detail.unresolved': 'Nicht aufgelöst: {label}', + 'registries.detail.deferred': 'Lade die Zielregisterkarte, um {label} zu prüfen.', + 'registries.detail.fingerprint.configuration': 'Konfigurations-Fingerabdruck', + 'registries.detail.fingerprint.definition': 'Definitions-Fingerabdruck', + 'registries.detail.fingerprint.connectorType': 'Konnektortyp-Fingerabdruck', + 'registries.detail.fingerprint.binding': 'Bindungs-Fingerabdruck', + 'registries.detail.section.agentLifecycle': 'Agent-Lebenszyklus', + 'registries.detail.section.exactToolReferences': 'Exakte Tool-Referenzen', + 'registries.detail.section.logicalReferenceWarnings': 'Warnungen zu logischen Referenzen', + 'registries.detail.section.configuration': 'Konfiguration', + 'registries.detail.section.definitionLifecycle': 'Definitions-Lebenszyklus', + 'registries.detail.section.schemas': 'Schemata', + 'registries.detail.section.enablementLifecycle': 'Aktivierungs-Lebenszyklus', + 'registries.detail.section.definitionLink': 'Definitions-Link', + 'registries.detail.section.typeLifecycle': 'Typ-Lebenszyklus', + 'registries.detail.section.contracts': 'Verträge', + 'registries.detail.section.bindingLifecycle': 'Bindungs-Lebenszyklus', + 'registries.detail.section.exactConnectorType': 'Exakter Konnektortyp', + 'registries.detail.section.logicalReferences': 'Logische Referenzen', + 'registries.detail.note.activeRecords': + 'Aktive Einträge sind Veröffentlichungszustände, keine Laufzeitberechtigungen.', + 'registries.detail.noExactToolRefs': 'Es wurden keine exakten Tool-Referenzen angegeben.', + 'registries.detail.note.logicalReferenceWarnings': + 'Logische Referenzen erfordern Nacharbeit außerhalb dieser schreibgeschützten Ansicht.', + 'registries.detail.field.lifecycle': 'Lebenszyklus', + 'registries.detail.field.owner': 'Besitzer', + 'registries.detail.field.created': 'Erstellt', + 'registries.detail.field.knowledgeScopes': 'Wissensbereiche', + 'registries.detail.field.riskPolicy': 'Risikorichtlinie', + 'registries.detail.none': 'Keine', + 'registries.detail.field.effectClass': 'Effektklasse', + 'registries.detail.field.schemaVersion': 'Schema-Version', + 'registries.detail.field.enablement': 'Aktivierung', + 'registries.detail.field.inputSchema': 'Eingabeschema', + 'registries.detail.field.outputSchema': 'Ausgabeschema', + 'registries.detail.field.timeoutDefaults': 'Standard-Timeouts', + 'registries.detail.field.retryContract': 'Retry-Vertrag', + 'registries.detail.field.auditContract': 'Audit-Vertrag', + 'registries.detail.viewRawJson': 'Rohes JSON anzeigen', + 'registries.detail.field.generation': 'Generationsstand', + 'registries.detail.field.approvalRequired': 'Freigabe erforderlich', + 'registries.detail.field.auditMode': 'Audit-Modus', + 'registries.detail.notSet': 'Nicht gesetzt', + 'registries.detail.field.timeoutCap': 'Timeout-Grenze', + 'registries.detail.field.allowTtl': 'Erlaubte TTL', + 'registries.detail.field.updated': 'Aktualisiert', + 'registries.detail.note.enablementRecords': + 'Aktivierungseinträge beschreiben Berechtigungsschranken, nicht den Veröffentlichungszustand der Definition.', + 'registries.detail.field.sourceType': 'Quelltyp', + 'registries.detail.field.capabilities': 'Fähigkeiten', + 'registries.detail.field.normalizationContracts': 'Normalisierungsverträge', + 'registries.detail.field.deliveryBehavior': 'Lieferverhalten', + 'registries.detail.field.providerAccountReference': 'Provider-Konto-Referenz', + 'registries.detail.note.logicalReferencesSecret': + 'Nur logische Referenz; Geheimnis wird nicht angezeigt.', + 'registries.detail.field.configReference': 'Konfigurationsreferenz', + 'registries.detail.field.credentialReference': 'Zugangsdatenreferenz', + 'registries.detail.empty': 'Wähle einen Eintrag aus, um seine exakte Registerversion zu prüfen.', + 'registries.detail.state.loading': 'Exaktes Registerdetail wird geladen...', + 'registries.detail.state.missing': + 'Core meldete, dass diese exakte Eintragsversion nicht mehr existiert.', + 'registries.detail.state.error': 'Core konnte diesen exakten Eintrag derzeit nicht laden.', + 'registries.detail.kind.agent': 'KI-Agent', + 'registries.detail.kind.tool-definition': 'Tool-Definition', + 'registries.detail.kind.tool-enablement': 'Tool-Aktivierung', + 'registries.detail.kind.connector-type': 'Konnektortyp', + 'registries.detail.kind.connector-binding': 'Konnektorbindung', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Eingeschränkt', 'memoryTree.status.statusBudgetExhausted': 'Pausiert: Embedding-Budget erreicht', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 5ae547c5da..c984cadba9 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -494,6 +494,129 @@ const en: TranslationMap = { 'Could not switch to OpenRouter free models. Check your OpenRouter sign-in and try again.', 'home.routinesCard': 'Your Routines', 'home.routinesActive': '{count} active', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', + + // ActionRequest approval inbox (AOS-S1.M1.2.3) + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', // Routines 'routines.title': 'Your Routines', @@ -7013,6 +7136,181 @@ const en: TranslationMap = { 'pages.settings.account.security': 'Security', 'pages.settings.account.securityDesc': 'Secret storage mode and keychain status', + // Core registries + 'home.coreRegistries': 'Core Registries', + 'home.coreRegistriesDescription': 'Inspect exact Agent, Tool, and Connector records from Core.', + 'home.coreRegistriesBlockedTitle': 'Core integration required', + 'home.coreRegistriesBlockedMissingDescription': + 'Complete the desktop Core integration before inspecting registries.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Repair the desktop Core integration before inspecting registries.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Reconnect the local Core bridge before inspecting registries.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Reconnect to the internet before inspecting registries.', + 'registries.page.eyebrow': 'Registry Views', + 'registries.page.title': 'Core Registries', + 'registries.page.description': + 'Read-only inspection for exact agent, tool, and connector records backed by Core.', + 'registries.page.readOnly': 'This screen never writes configuration, secrets, or runtime state.', + 'registries.page.retry': 'Retry', + 'registries.page.tablistAria': 'Core registries', + 'registries.tab.agents': 'Agents', + 'registries.tab.tools': 'Tools', + 'registries.tab.connectors': 'Connectors', + 'registries.summaryState.idle': 'Idle', + 'registries.summaryState.loading': 'Loading', + 'registries.summaryState.fresh': 'Fresh', + 'registries.summaryState.stale': 'Stale', + 'registries.summaryState.partial': 'Partial', + 'registries.summaryState.blocked': 'Blocked', + 'registries.summaryState.observed': 'Observed', + 'registries.blocker.configRequiredTitle': 'Core integration required', + 'registries.blocker.configMissingDescription': + 'No Core integration configuration was found for registry inspection.', + 'registries.blocker.configInvalidTitle': 'Core integration invalid', + 'registries.blocker.configInvalidDescription': + 'The current Core integration configuration is invalid for registry inspection.', + 'registries.blocker.authRequiredTitle': 'Core authentication required', + 'registries.blocker.authRequiredDescription': + 'Registry inspection could not authenticate with Core for this session.', + 'registries.blocker.forbiddenTitle': 'Registry inspection forbidden', + 'registries.blocker.forbiddenDescription': + 'Core rejected this actor for tenant registry inspection.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant unavailable', + 'registries.blocker.tenantUnavailableDescription': + 'Core reported that this tenant is temporarily unavailable for registry inspection.', + 'registries.blocker.tenantInvariantTitle': 'Tenant invariant violation', + 'registries.blocker.tenantInvariantDescription': + 'Core reported a tenant invariant violation for registry inspection.', + 'registries.blocker.genericDescription': + 'Core integration is currently blocking registry inspection.', + 'registries.blocker.readOnly': 'This screen is read-only and cannot write configuration for you.', + 'registries.blocker.fixFlow': + 'Fix the Core connection in the existing integration flow, then retry inspection here.', + 'registries.collections.agents.title': 'Agents', + 'registries.collections.agents.description': + 'Published agent records. No record is auto-selected.', + 'registries.collections.agents.loadMore': 'Load more agents', + 'registries.collections.toolDefinitions.title': 'Definitions', + 'registries.collections.toolDefinitions.description': + 'Published tool contracts, distinct from permission enablements.', + 'registries.collections.toolDefinitions.loadMore': 'Load more definitions', + 'registries.collections.toolEnablements.title': 'Enablements', + 'registries.collections.toolEnablements.description': + 'Permission gates and runtime approval limits for tools.', + 'registries.collections.connectorTypes.title': 'Types', + 'registries.collections.connectorTypes.description': + 'Published connector types and normalization contracts.', + 'registries.collections.connectorTypes.loadMore': 'Load more types', + 'registries.collections.connectorBindings.title': 'Bindings', + 'registries.collections.connectorBindings.description': + 'Bound provider accounts and capability selections.', + 'registries.collections.connectorBindings.loadMore': 'Load more bindings', + 'registries.items.created': 'Created {date}', + 'registries.items.fingerprint.config': 'Config fp:{fingerprint}', + 'registries.items.fingerprint.definition': 'Definition fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Type fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Binding fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'No audit mode', + 'registries.items.meta.approvalRequired': 'Approval required', + 'registries.items.meta.noApprovalGate': 'No approval gate', + 'registries.items.status.noTenantEnablement': 'No tenant enablement returned', + 'registries.live.blocked': '{title}. {description}', + 'registries.live.loaded': '{tab} tab active. Selected {key} version {version}.', + 'registries.live.loading': '{tab} tab active. Loading {key} version {version}.', + 'registries.live.idle': '{tab} tab active. No detail selected.', + 'registries.drawer.title': 'Registry detail', + 'registries.drawer.eyebrow': 'Registry Detail', + 'registries.collection.state.idle': 'Idle', + 'registries.collection.state.loading': 'Loading', + 'registries.collection.state.observed': 'Observed', + 'registries.collection.state.stale': 'Stale', + 'registries.collection.state.blocked': 'Blocked', + 'registries.collection.body.idle': 'Waiting for the first observation.', + 'registries.collection.body.loading': 'Refreshing the exact records from Core.', + 'registries.collection.body.empty': 'Observed with no records returned.', + 'registries.collection.body.observedAt': 'Observed at {observedAt}.', + 'registries.collection.body.stale': + 'Observed at {observedAt}; the latest refresh kept the last good data.', + 'registries.collection.body.blocked': 'Core rejected this collection for inspection.', + 'registries.collection.empty': 'No exact records available in this collection yet.', + 'registries.detail.notAvailable': 'Not available', + 'registries.detail.copyFingerprint': 'Copy full fingerprint', + 'registries.detail.emptySchemaObject': 'Empty schema object', + 'registries.detail.schemaType': 'Type {type}', + 'registries.detail.schemaProperties': '{count} properties', + 'registries.detail.schemaRequired': '{count} required', + 'registries.detail.noFields': 'No fields', + 'registries.detail.fieldsSampleMore': '{count} fields · {sample}…', + 'registries.detail.fieldsSample': '{count} fields · {sample}', + 'registries.detail.noNormalizationContracts': 'No normalization contracts', + 'registries.detail.unresolved': 'Unresolved: {label}', + 'registries.detail.deferred': 'Load the target tab to inspect {label}.', + 'registries.detail.fingerprint.configuration': 'Configuration fingerprint', + 'registries.detail.fingerprint.definition': 'Definition fingerprint', + 'registries.detail.fingerprint.connectorType': 'Connector type fingerprint', + 'registries.detail.fingerprint.binding': 'Binding fingerprint', + 'registries.detail.section.agentLifecycle': 'Agent lifecycle', + 'registries.detail.section.exactToolReferences': 'Exact tool references', + 'registries.detail.section.logicalReferenceWarnings': 'Logical reference warnings', + 'registries.detail.section.configuration': 'Configuration', + 'registries.detail.section.definitionLifecycle': 'Definition lifecycle', + 'registries.detail.section.schemas': 'Schemas', + 'registries.detail.section.enablementLifecycle': 'Enablement lifecycle', + 'registries.detail.section.definitionLink': 'Definition link', + 'registries.detail.section.typeLifecycle': 'Type lifecycle', + 'registries.detail.section.contracts': 'Contracts', + 'registries.detail.section.bindingLifecycle': 'Binding lifecycle', + 'registries.detail.section.exactConnectorType': 'Exact connector type', + 'registries.detail.section.logicalReferences': 'Logical references', + 'registries.detail.note.activeRecords': + 'Active records are publish states, not runtime permission grants.', + 'registries.detail.noExactToolRefs': 'No exact tool references are declared.', + 'registries.detail.note.logicalReferenceWarnings': + 'Logical references need follow-up outside this read-only view.', + 'registries.detail.field.lifecycle': 'Lifecycle', + 'registries.detail.field.owner': 'Owner', + 'registries.detail.field.created': 'Created', + 'registries.detail.field.knowledgeScopes': 'Knowledge scopes', + 'registries.detail.field.riskPolicy': 'Risk policy', + 'registries.detail.none': 'None', + 'registries.detail.field.effectClass': 'Effect class', + 'registries.detail.field.schemaVersion': 'Schema version', + 'registries.detail.field.enablement': 'Enablement', + 'registries.detail.field.inputSchema': 'Input schema', + 'registries.detail.field.outputSchema': 'Output schema', + 'registries.detail.field.timeoutDefaults': 'Timeout defaults', + 'registries.detail.field.retryContract': 'Retry contract', + 'registries.detail.field.auditContract': 'Audit contract', + 'registries.detail.viewRawJson': 'View raw JSON', + 'registries.detail.field.generation': 'Generation', + 'registries.detail.field.approvalRequired': 'Approval required', + 'registries.detail.field.auditMode': 'Audit mode', + 'registries.detail.notSet': 'Not set', + 'registries.detail.field.timeoutCap': 'Timeout cap', + 'registries.detail.field.allowTtl': 'Allow TTL', + 'registries.detail.field.updated': 'Updated', + 'registries.detail.note.enablementRecords': + 'Enablement records express permission gates, not definition publication state.', + 'registries.detail.field.sourceType': 'Source type', + 'registries.detail.field.capabilities': 'Capabilities', + 'registries.detail.field.normalizationContracts': 'Normalization contracts', + 'registries.detail.field.deliveryBehavior': 'Delivery behavior', + 'registries.detail.field.providerAccountReference': 'Provider account reference', + 'registries.detail.note.logicalReferencesSecret': 'Logical reference only; secret not displayed.', + 'registries.detail.field.configReference': 'Config reference', + 'registries.detail.field.credentialReference': 'Credential reference', + 'registries.detail.empty': 'Select a record to inspect its exact registry version.', + 'registries.detail.state.loading': 'Loading exact registry detail...', + 'registries.detail.state.missing': + 'Core reported that this exact record version no longer exists.', + 'registries.detail.state.error': 'Core could not load this exact record right now.', + 'registries.detail.kind.agent': 'Agent', + 'registries.detail.kind.tool-definition': 'Tool Definition', + 'registries.detail.kind.tool-enablement': 'Tool Enablement', + 'registries.detail.kind.connector-type': 'Connector Type', + 'registries.detail.kind.connector-binding': 'Connector Binding', // Chat: agent-generated artifacts (#2779) // Chat composer toolbar 'composer.attachFile': 'Attach file', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 97289ff443..f136ee7f5d 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -690,6 +690,127 @@ const messages: TranslationMap = { 'No se pudo cambiar a los modelos gratis de OpenRouter. Revisa tu inicio de sesión de OpenRouter e inténtalo de nuevo.', 'home.routinesCard': 'Tus rutinas', 'home.routinesActive': '{count} activas', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Tus rutinas', 'routines.subtitle': 'Cosas que tu asistente hace automáticamente', 'routines.loading': 'Cargando rutinas…', @@ -6380,6 +6501,185 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rechazar almacenamiento local', 'pages.settings.account.security': 'Seguridad', 'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero', + 'home.coreRegistries': 'Registros del Core', + 'home.coreRegistriesDescription': + 'Inspecciona registros exactos de agentes, herramientas y conectores desde Core.', + 'home.coreRegistriesBlockedTitle': 'Integración con Core obligatoria', + 'home.coreRegistriesBlockedMissingDescription': + 'Completa la integración de Core en escritorio antes de inspeccionar los registros.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Repara la integración de Core en escritorio antes de inspeccionar los registros.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Vuelve a conectar el puente local con Core antes de inspeccionar los registros.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Vuelve a conectarte a internet antes de inspeccionar los registros.', + 'registries.page.eyebrow': 'Vistas del registro', + 'registries.page.title': 'Registros del Core', + 'registries.page.description': + 'Inspección de solo lectura de registros exactos de agentes, herramientas y conectores respaldados por Core.', + 'registries.page.readOnly': + 'Esta pantalla nunca escribe configuración, secretos ni estado de ejecución.', + 'registries.page.retry': 'Reintentar', + 'registries.page.tablistAria': 'Registros del Core', + 'registries.tab.agents': 'Agentes', + 'registries.tab.tools': 'Herramientas', + 'registries.tab.connectors': 'Conectores', + 'registries.summaryState.idle': 'Inactivo', + 'registries.summaryState.loading': 'Cargando', + 'registries.summaryState.fresh': 'Reciente', + 'registries.summaryState.stale': 'Desactualizado', + 'registries.summaryState.partial': 'Parcial', + 'registries.summaryState.blocked': 'Bloqueado', + 'registries.summaryState.observed': 'Observado', + 'registries.blocker.configRequiredTitle': 'Integración con Core obligatoria', + 'registries.blocker.configMissingDescription': + 'No se encontró configuración de integración con Core para inspeccionar registros.', + 'registries.blocker.configInvalidTitle': 'Integración con Core no válida', + 'registries.blocker.configInvalidDescription': + 'La configuración actual de integración con Core no es válida para inspeccionar registros.', + 'registries.blocker.authRequiredTitle': 'Autenticación con Core obligatoria', + 'registries.blocker.authRequiredDescription': + 'La inspección del registro no pudo autenticarse con Core en esta sesión.', + 'registries.blocker.forbiddenTitle': 'Inspección del registro prohibida', + 'registries.blocker.forbiddenDescription': + 'Core rechazó este actor para la inspección de registros del tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant no disponible', + 'registries.blocker.tenantUnavailableDescription': + 'Core informó que este tenant no está disponible temporalmente para la inspección del registro.', + 'registries.blocker.tenantInvariantTitle': 'Violación de invariante del tenant', + 'registries.blocker.tenantInvariantDescription': + 'Core informó una violación de invariante del tenant para la inspección del registro.', + 'registries.blocker.genericDescription': + 'La integración con Core está bloqueando actualmente la inspección del registro.', + 'registries.blocker.readOnly': + 'Esta pantalla es de solo lectura y no puede escribir configuración por ti.', + 'registries.blocker.fixFlow': + 'Corrige la conexión con Core en el flujo de integración existente y vuelve a intentarlo aquí.', + 'registries.collections.agents.title': 'Agentes', + 'registries.collections.agents.description': + 'Registros publicados de agentes. Ningún registro se selecciona automáticamente.', + 'registries.collections.agents.loadMore': 'Cargar más agentes', + 'registries.collections.toolDefinitions.title': 'Definiciones', + 'registries.collections.toolDefinitions.description': + 'Contratos de herramientas publicados, separados de las habilitaciones de permisos.', + 'registries.collections.toolDefinitions.loadMore': 'Cargar más definiciones', + 'registries.collections.toolEnablements.title': 'Habilitaciones', + 'registries.collections.toolEnablements.description': + 'Puertas de permisos y límites de aprobación en tiempo de ejecución para herramientas.', + 'registries.collections.connectorTypes.title': 'Tipos', + 'registries.collections.connectorTypes.description': + 'Tipos de conectores publicados y contratos de normalización.', + 'registries.collections.connectorTypes.loadMore': 'Cargar más tipos', + 'registries.collections.connectorBindings.title': 'Vinculaciones', + 'registries.collections.connectorBindings.description': + 'Cuentas de proveedor vinculadas y selecciones de capacidades.', + 'registries.collections.connectorBindings.loadMore': 'Cargar más vinculaciones', + 'registries.items.created': 'Creado {date}', + 'registries.items.fingerprint.config': 'Huella de configuración {fingerprint}', + 'registries.items.fingerprint.definition': 'Definición fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Tipo fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Vinculación fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'Sin modo de auditoría', + 'registries.items.meta.approvalRequired': 'Aprobación obligatoria', + 'registries.items.meta.noApprovalGate': 'Sin puerta de aprobación', + 'registries.items.status.noTenantEnablement': 'No se devolvió habilitación del tenant', + 'registries.live.blocked': 'Aviso: {title}. {description}', + 'registries.live.loaded': 'Pestaña {tab} activa. Se seleccionó {key} versión {version}.', + 'registries.live.loading': 'Pestaña {tab} activa. Cargando {key} versión {version}.', + 'registries.live.idle': 'Pestaña {tab} activa. No hay detalle seleccionado.', + 'registries.drawer.title': 'Detalle del registro', + 'registries.drawer.eyebrow': 'Detalle del registro', + 'registries.collection.state.idle': 'Inactivo', + 'registries.collection.state.loading': 'Cargando', + 'registries.collection.state.observed': 'Observado', + 'registries.collection.state.stale': 'Desactualizado', + 'registries.collection.state.blocked': 'Bloqueado', + 'registries.collection.body.idle': 'Esperando la primera observación.', + 'registries.collection.body.loading': 'Actualizando los registros exactos desde Core.', + 'registries.collection.body.empty': 'Se observó la colección sin registros devueltos.', + 'registries.collection.body.observedAt': 'Observado en {observedAt}.', + 'registries.collection.body.stale': + 'Observado en {observedAt}; la última actualización conservó los últimos datos válidos.', + 'registries.collection.body.blocked': 'Core rechazó esta colección para la inspección.', + 'registries.collection.empty': 'Todavía no hay registros exactos disponibles en esta colección.', + 'registries.detail.notAvailable': 'No disponible', + 'registries.detail.copyFingerprint': 'Copiar huella completa', + 'registries.detail.emptySchemaObject': 'Objeto de esquema vacío', + 'registries.detail.schemaType': 'Tipo {type}', + 'registries.detail.schemaProperties': '{count} propiedades', + 'registries.detail.schemaRequired': '{count} obligatorias', + 'registries.detail.noFields': 'Sin campos', + 'registries.detail.fieldsSampleMore': '{count} campos · {sample}…', + 'registries.detail.fieldsSample': '{count} campos · {sample}', + 'registries.detail.noNormalizationContracts': 'Sin contratos de normalización', + 'registries.detail.unresolved': 'Sin resolver: {label}', + 'registries.detail.deferred': 'Carga la pestaña de destino para inspeccionar {label}.', + 'registries.detail.fingerprint.configuration': 'Huella de configuración', + 'registries.detail.fingerprint.definition': 'Huella de definición', + 'registries.detail.fingerprint.connectorType': 'Huella del tipo de conector', + 'registries.detail.fingerprint.binding': 'Huella de vinculación', + 'registries.detail.section.agentLifecycle': 'Ciclo de vida del agente', + 'registries.detail.section.exactToolReferences': 'Referencias exactas de herramientas', + 'registries.detail.section.logicalReferenceWarnings': 'Advertencias de referencias lógicas', + 'registries.detail.section.configuration': 'Configuración', + 'registries.detail.section.definitionLifecycle': 'Ciclo de vida de la definición', + 'registries.detail.section.schemas': 'Esquemas', + 'registries.detail.section.enablementLifecycle': 'Ciclo de vida de la habilitación', + 'registries.detail.section.definitionLink': 'Enlace de la definición', + 'registries.detail.section.typeLifecycle': 'Ciclo de vida del tipo', + 'registries.detail.section.contracts': 'Contratos', + 'registries.detail.section.bindingLifecycle': 'Ciclo de vida de la vinculación', + 'registries.detail.section.exactConnectorType': 'Tipo exacto de conector', + 'registries.detail.section.logicalReferences': 'Referencias lógicas', + 'registries.detail.note.activeRecords': + 'Los registros activos son estados de publicación, no concesiones de permisos en tiempo de ejecución.', + 'registries.detail.noExactToolRefs': 'No se declararon referencias exactas de herramientas.', + 'registries.detail.note.logicalReferenceWarnings': + 'Las referencias lógicas requieren seguimiento fuera de esta vista de solo lectura.', + 'registries.detail.field.lifecycle': 'Ciclo de vida', + 'registries.detail.field.owner': 'Propietario', + 'registries.detail.field.created': 'Creado', + 'registries.detail.field.knowledgeScopes': 'Ámbitos de conocimiento', + 'registries.detail.field.riskPolicy': 'Política de riesgo', + 'registries.detail.none': 'Ninguno', + 'registries.detail.field.effectClass': 'Clase de efecto', + 'registries.detail.field.schemaVersion': 'Versión del esquema', + 'registries.detail.field.enablement': 'Habilitación', + 'registries.detail.field.inputSchema': 'Esquema de entrada', + 'registries.detail.field.outputSchema': 'Esquema de salida', + 'registries.detail.field.timeoutDefaults': 'Tiempos de espera predeterminados', + 'registries.detail.field.retryContract': 'Contrato de reintento', + 'registries.detail.field.auditContract': 'Contrato de auditoría', + 'registries.detail.viewRawJson': 'Ver JSON bruto', + 'registries.detail.field.generation': 'Generación', + 'registries.detail.field.approvalRequired': 'Aprobación obligatoria', + 'registries.detail.field.auditMode': 'Modo de auditoría', + 'registries.detail.notSet': 'Sin establecer', + 'registries.detail.field.timeoutCap': 'Límite de espera', + 'registries.detail.field.allowTtl': 'TTL permitido', + 'registries.detail.field.updated': 'Actualizado', + 'registries.detail.note.enablementRecords': + 'Los registros de habilitación expresan puertas de permisos, no el estado de publicación de la definición.', + 'registries.detail.field.sourceType': 'Tipo de origen', + 'registries.detail.field.capabilities': 'Capacidades', + 'registries.detail.field.normalizationContracts': 'Contratos de normalización', + 'registries.detail.field.deliveryBehavior': 'Comportamiento de entrega', + 'registries.detail.field.providerAccountReference': 'Referencia de cuenta del proveedor', + 'registries.detail.note.logicalReferencesSecret': + 'Solo referencia lógica; el secreto no se muestra.', + 'registries.detail.field.configReference': 'Referencia de configuración', + 'registries.detail.field.credentialReference': 'Referencia de credencial', + 'registries.detail.empty': + 'Selecciona un registro para inspeccionar su versión exacta del registro.', + 'registries.detail.state.loading': 'Cargando detalle exacto del registro...', + 'registries.detail.state.missing': + 'Core informó que esta versión exacta del registro ya no existe.', + 'registries.detail.state.error': 'Core no pudo cargar este registro exacto en este momento.', + 'registries.detail.kind.agent': 'Agente', + 'registries.detail.kind.tool-definition': 'Definición de herramienta', + 'registries.detail.kind.tool-enablement': 'Habilitación de herramienta', + 'registries.detail.kind.connector-type': 'Tipo de conector', + 'registries.detail.kind.connector-binding': 'Vinculación de conector', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Degradado', 'memoryTree.status.statusBudgetExhausted': 'En pausa: se alcanzó el límite de embeddings', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 9de49eda57..540f9dca54 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -701,6 +701,127 @@ const messages: TranslationMap = { 'Impossible de passer aux modèles gratuits OpenRouter. Vérifiez votre connexion OpenRouter et réessayez.', 'home.routinesCard': 'Vos routines', 'home.routinesActive': '{count} actif(s)', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Vos Routines', 'routines.subtitle': 'Choses que votre assistant fait automatiquement', 'routines.loading': 'Chargement des routines…', @@ -6408,6 +6529,186 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Refuser le stockage local', 'pages.settings.account.security': 'Sécurité', 'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau', + 'home.coreRegistries': 'Registres du Core', + 'home.coreRegistriesDescription': + 'Inspectez les enregistrements exacts des agents, outils et connecteurs dans Core.', + 'home.coreRegistriesBlockedTitle': 'Intégration Core requise', + 'home.coreRegistriesBlockedMissingDescription': + 'Terminez intégration Core sur bureau avant inspection des registres.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Réparez intégration Core sur bureau avant inspection des registres.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Reconnectez le pont local du Core avant inspection des registres.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Reconnectez-vous à Internet avant inspection des registres.', + 'registries.page.eyebrow': 'Vues du registre', + 'registries.page.title': 'Registres du Core', + 'registries.page.description': + 'Inspection en lecture seule des enregistrements exacts des agents, outils et connecteurs fournis par Core.', + 'registries.page.readOnly': + 'Cet écran ne modifie jamais la configuration, les secrets ni état exécution.', + 'registries.page.retry': 'Réessayer', + 'registries.page.tablistAria': 'Registres du Core', + 'registries.tab.agents': 'Agents IA', + 'registries.tab.tools': 'Outils', + 'registries.tab.connectors': 'Connecteurs', + 'registries.summaryState.idle': 'Au repos', + 'registries.summaryState.loading': 'Chargement', + 'registries.summaryState.fresh': 'Récent', + 'registries.summaryState.stale': 'Périmé', + 'registries.summaryState.partial': 'Partiel', + 'registries.summaryState.blocked': 'Bloqué', + 'registries.summaryState.observed': 'Observé', + 'registries.blocker.configRequiredTitle': 'Intégration Core requise', + 'registries.blocker.configMissingDescription': + 'Aucune configuration intégration Core trouvée pour inspection des registres.', + 'registries.blocker.configInvalidTitle': 'Intégration Core invalide', + 'registries.blocker.configInvalidDescription': + 'La configuration intégration Core actuelle est invalide pour inspection des registres.', + 'registries.blocker.authRequiredTitle': 'Authentification Core requise', + 'registries.blocker.authRequiredDescription': + 'Inspection du registre non authentifiée auprès du Core pour cette session.', + 'registries.blocker.forbiddenTitle': 'Inspection du registre interdite', + 'registries.blocker.forbiddenDescription': + 'Core a rejeté cet acteur pour inspection des registres du tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant indisponible', + 'registries.blocker.tenantUnavailableDescription': + 'Core a signalé que ce tenant est temporairement indisponible pour inspection du registre.', + 'registries.blocker.tenantInvariantTitle': 'Violation invariant tenant', + 'registries.blocker.tenantInvariantDescription': + 'Core a signalé une violation invariant tenant pour inspection du registre.', + 'registries.blocker.genericDescription': + 'Intégration Core bloque actuellement inspection du registre.', + 'registries.blocker.readOnly': + 'Cet écran est en lecture seule et ne peut pas écrire la configuration pour vous.', + 'registries.blocker.fixFlow': + 'Corrigez connexion Core dans le flux intégration existant puis réessayez ici.', + 'registries.collections.agents.title': 'Agents IA', + 'registries.collections.agents.description': + 'Enregistrements agents publiés. Aucun enregistrement sélectionné automatiquement.', + 'registries.collections.agents.loadMore': 'Charger plus agents', + 'registries.collections.toolDefinitions.title': 'Définitions', + 'registries.collections.toolDefinitions.description': + 'Contrats outils publiés, distincts des activations de permission.', + 'registries.collections.toolDefinitions.loadMore': 'Charger plus définitions', + 'registries.collections.toolEnablements.title': 'Activations', + 'registries.collections.toolEnablements.description': + 'Garde-fous de permission et limites approbation pour outils.', + 'registries.collections.connectorTypes.title': 'Types de connecteur', + 'registries.collections.connectorTypes.description': + 'Types connecteurs publiés et contrats de normalisation.', + 'registries.collections.connectorTypes.loadMore': 'Charger plus types', + 'registries.collections.connectorBindings.title': 'Liaisons', + 'registries.collections.connectorBindings.description': + 'Comptes fournisseur liés et sélections de capacités.', + 'registries.collections.connectorBindings.loadMore': 'Charger plus liaisons', + 'registries.items.created': 'Créé {date}', + 'registries.items.fingerprint.config': 'Empreinte configuration {fingerprint}', + 'registries.items.fingerprint.definition': 'Définition fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Empreinte type {fingerprint}', + 'registries.items.fingerprint.binding': 'Liaison fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'Aucun mode audit', + 'registries.items.meta.approvalRequired': 'Approbation requise', + 'registries.items.meta.noApprovalGate': 'Aucune porte approbation', + 'registries.items.status.noTenantEnablement': 'Aucune activation tenant renvoyée', + 'registries.live.blocked': 'Alerte : {title}. {description}', + 'registries.live.loaded': 'Onglet {tab} actif. {key} version {version} sélectionné.', + 'registries.live.loading': 'Onglet {tab} actif. Chargement {key} version {version}.', + 'registries.live.idle': 'Onglet {tab} actif. Aucun détail sélectionné.', + 'registries.drawer.title': 'Détail du registre', + 'registries.drawer.eyebrow': 'Détail du registre', + 'registries.collection.state.idle': 'Au repos', + 'registries.collection.state.loading': 'Chargement', + 'registries.collection.state.observed': 'Observé', + 'registries.collection.state.stale': 'Périmé', + 'registries.collection.state.blocked': 'Bloqué', + 'registries.collection.body.idle': 'En attente de la première observation.', + 'registries.collection.body.loading': 'Actualisation des enregistrements exacts depuis Core.', + 'registries.collection.body.empty': 'Observation terminée sans enregistrement renvoyé.', + 'registries.collection.body.observedAt': 'Observé à {observedAt}.', + 'registries.collection.body.stale': + 'Observé à {observedAt}; le dernier rafraîchissement a conservé les dernières données valides.', + 'registries.collection.body.blocked': 'Core a rejeté cette collection pour inspection.', + 'registries.collection.empty': + 'Aucun enregistrement exact disponible dans cette collection pour le moment.', + 'registries.detail.notAvailable': 'Non disponible', + 'registries.detail.copyFingerprint': 'Copier empreinte complète', + 'registries.detail.emptySchemaObject': 'Objet schéma vide', + 'registries.detail.schemaType': 'Type : {type}', + 'registries.detail.schemaProperties': '{count} propriétés', + 'registries.detail.schemaRequired': '{count} requises', + 'registries.detail.noFields': 'Aucun champ', + 'registries.detail.fieldsSampleMore': '{count} champs · {sample}…', + 'registries.detail.fieldsSample': '{count} champs · {sample}', + 'registries.detail.noNormalizationContracts': 'Aucun contrat normalisation', + 'registries.detail.unresolved': 'Non résolu: {label}', + 'registries.detail.deferred': 'Chargez onglet cible pour inspecter {label}.', + 'registries.detail.fingerprint.configuration': 'Empreinte configuration', + 'registries.detail.fingerprint.definition': 'Empreinte définition', + 'registries.detail.fingerprint.connectorType': 'Empreinte type connecteur', + 'registries.detail.fingerprint.binding': 'Empreinte liaison', + 'registries.detail.section.agentLifecycle': 'Cycle agent', + 'registries.detail.section.exactToolReferences': 'Références exactes outils', + 'registries.detail.section.logicalReferenceWarnings': 'Avertissements références logiques', + 'registries.detail.section.configuration': 'Paramétrage', + 'registries.detail.section.definitionLifecycle': 'Cycle définition', + 'registries.detail.section.schemas': 'Schémas', + 'registries.detail.section.enablementLifecycle': 'Cycle activation', + 'registries.detail.section.definitionLink': 'Lien définition', + 'registries.detail.section.typeLifecycle': 'Cycle type', + 'registries.detail.section.contracts': 'Contrats', + 'registries.detail.section.bindingLifecycle': 'Cycle liaison', + 'registries.detail.section.exactConnectorType': 'Type connecteur exact', + 'registries.detail.section.logicalReferences': 'Références logiques', + 'registries.detail.note.activeRecords': + 'Les enregistrements actifs sont des états publication, pas des permissions exécution.', + 'registries.detail.noExactToolRefs': 'Aucune référence outil exacte déclarée.', + 'registries.detail.note.logicalReferenceWarnings': + 'Références logiques nécessitent un suivi hors de cette vue lecture seule.', + 'registries.detail.field.lifecycle': 'Cycle', + 'registries.detail.field.owner': 'Propriétaire', + 'registries.detail.field.created': 'Créé', + 'registries.detail.field.knowledgeScopes': 'Portées connaissance', + 'registries.detail.field.riskPolicy': 'Politique risque', + 'registries.detail.none': 'Aucun', + 'registries.detail.field.effectClass': 'Classe effet', + 'registries.detail.field.schemaVersion': 'Version schéma', + 'registries.detail.field.enablement': 'Activation', + 'registries.detail.field.inputSchema': 'Schéma entrée', + 'registries.detail.field.outputSchema': 'Schéma sortie', + 'registries.detail.field.timeoutDefaults': 'Délais par défaut', + 'registries.detail.field.retryContract': 'Contrat retry', + 'registries.detail.field.auditContract': 'Contrat audit', + 'registries.detail.viewRawJson': 'Voir JSON brut', + 'registries.detail.field.generation': 'Génération', + 'registries.detail.field.approvalRequired': 'Approbation requise', + 'registries.detail.field.auditMode': 'Mode audit', + 'registries.detail.notSet': 'Non défini', + 'registries.detail.field.timeoutCap': 'Plafond délai', + 'registries.detail.field.allowTtl': 'TTL autorisé', + 'registries.detail.field.updated': 'Mis à jour', + 'registries.detail.note.enablementRecords': + 'Les activations expriment les garde-fous de permission, pas état publication de la définition.', + 'registries.detail.field.sourceType': 'Type source', + 'registries.detail.field.capabilities': 'Capacités', + 'registries.detail.field.normalizationContracts': 'Contrats normalisation', + 'registries.detail.field.deliveryBehavior': 'Comportement livraison', + 'registries.detail.field.providerAccountReference': 'Référence compte fournisseur', + 'registries.detail.note.logicalReferencesSecret': + 'Référence logique uniquement; secret non affiché.', + 'registries.detail.field.configReference': 'Référence configuration', + 'registries.detail.field.credentialReference': 'Référence identifiant', + 'registries.detail.empty': 'Sélectionnez un enregistrement pour inspecter sa version exacte.', + 'registries.detail.state.loading': 'Chargement du détail exact du registre...', + 'registries.detail.state.missing': + 'Core a signalé que cette version exacte enregistrement n existe plus.', + 'registries.detail.state.error': + 'Core ne peut pas charger cet enregistrement exact pour le moment.', + 'registries.detail.kind.agent': 'Agent IA', + 'registries.detail.kind.tool-definition': 'Définition outil', + 'registries.detail.kind.tool-enablement': 'Activation outil', + 'registries.detail.kind.connector-type': 'Type connecteur', + 'registries.detail.kind.connector-binding': 'Liaison connecteur', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Dégradé', 'memoryTree.status.statusBudgetExhausted': "En pause : budget d'embeddings atteint", diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 4ea1626305..370f80c66c 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -671,6 +671,127 @@ const messages: TranslationMap = { 'OpenRouter के मुफ्त मॉडल पर स्विच नहीं हो सका। अपना OpenRouter साइन-इन जांचें और फिर कोशिश करें।', 'home.routinesCard': 'आपकी रूटीन', 'home.routinesActive': '{count} सक्रिय', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'आपका दिनचर्या', 'routines.subtitle': 'अपने सहायक को स्वचालित रूप से करने के लिए चीजें', 'routines.loading': 'दिनचर्या लोड हो रहा है...', @@ -6253,6 +6374,179 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', 'pages.settings.account.security': 'सुरक्षा', 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', + 'home.coreRegistries': 'Core रजिस्ट्री', + 'home.coreRegistriesDescription': 'Core से सटीक Agent, Tool और Connector रिकॉर्ड देखें।', + 'home.coreRegistriesBlockedTitle': 'Core इंटीग्रेशन आवश्यक है', + 'home.coreRegistriesBlockedMissingDescription': + 'रजिस्ट्री देखने से पहले डेस्कटॉप Core इंटीग्रेशन पूरा करें।', + 'home.coreRegistriesBlockedInvalidDescription': + 'रजिस्ट्री देखने से पहले डेस्कटॉप Core इंटीग्रेशन ठीक करें।', + 'home.coreRegistriesBlockedBridgeDescription': + 'रजिस्ट्री देखने से पहले स्थानीय Core ब्रिज दोबारा जोड़ें।', + 'home.coreRegistriesBlockedOfflineDescription': 'रजिस्ट्री देखने से पहले इंटरनेट से फिर जुड़ें।', + 'registries.page.eyebrow': 'रजिस्ट्री दृश्य', + 'registries.page.title': 'Core रजिस्ट्री', + 'registries.page.description': + 'Core द्वारा समर्थित सटीक Agent, Tool और Connector रिकॉर्ड का केवल-पढ़ने योग्य निरीक्षण।', + 'registries.page.readOnly': 'यह स्क्रीन कभी भी कॉन्फ़िगरेशन, रहस्य या रनटाइम स्थिति नहीं लिखती।', + 'registries.page.retry': 'फिर से प्रयास करें', + 'registries.page.tablistAria': 'Core रजिस्ट्री', + 'registries.tab.agents': 'एजेंट', + 'registries.tab.tools': 'टूल', + 'registries.tab.connectors': 'कनेक्टर', + 'registries.summaryState.idle': 'निष्क्रिय', + 'registries.summaryState.loading': 'लोड हो रहा है', + 'registries.summaryState.fresh': 'हाल का', + 'registries.summaryState.stale': 'पुराना', + 'registries.summaryState.partial': 'आंशिक', + 'registries.summaryState.blocked': 'अवरुद्ध', + 'registries.summaryState.observed': 'देखा गया', + 'registries.blocker.configRequiredTitle': 'Core इंटीग्रेशन आवश्यक है', + 'registries.blocker.configMissingDescription': + 'रजिस्ट्री निरीक्षण के लिए Core इंटीग्रेशन कॉन्फ़िगरेशन नहीं मिला।', + 'registries.blocker.configInvalidTitle': 'Core इंटीग्रेशन अमान्य है', + 'registries.blocker.configInvalidDescription': + 'वर्तमान Core इंटीग्रेशन कॉन्फ़िगरेशन रजिस्ट्री निरीक्षण के लिए मान्य नहीं है।', + 'registries.blocker.authRequiredTitle': 'Core प्रमाणीकरण आवश्यक है', + 'registries.blocker.authRequiredDescription': + 'इस सत्र में रजिस्ट्री निरीक्षण Core के साथ प्रमाणीकरण नहीं कर सका।', + 'registries.blocker.forbiddenTitle': 'रजिस्ट्री निरीक्षण निषिद्ध है', + 'registries.blocker.forbiddenDescription': + 'Tenant रजिस्ट्री निरीक्षण के लिए Core ने इस actor को अस्वीकार कर दिया।', + 'registries.blocker.tenantUnavailableTitle': 'Tenant उपलब्ध नहीं है', + 'registries.blocker.tenantUnavailableDescription': + 'Core ने बताया कि यह tenant अभी रजिस्ट्री निरीक्षण के लिए अस्थायी रूप से उपलब्ध नहीं है।', + 'registries.blocker.tenantInvariantTitle': 'Tenant invariant उल्लंघन', + 'registries.blocker.tenantInvariantDescription': + 'Core ने रजिस्ट्री निरीक्षण के लिए tenant invariant उल्लंघन की सूचना दी।', + 'registries.blocker.genericDescription': 'Core इंटीग्रेशन अभी रजिस्ट्री निरीक्षण को रोक रहा है।', + 'registries.blocker.readOnly': + 'यह स्क्रीन केवल-पढ़ने योग्य है और आपकी ओर से कॉन्फ़िगरेशन नहीं लिख सकती।', + 'registries.blocker.fixFlow': + 'मौजूदा इंटीग्रेशन फ्लो में Core कनेक्शन ठीक करें, फिर यहाँ दोबारा प्रयास करें।', + 'registries.collections.agents.title': 'एजेंट', + 'registries.collections.agents.description': + 'प्रकाशित एजेंट रिकॉर्ड। कोई रिकॉर्ड अपने आप चयनित नहीं होता।', + 'registries.collections.agents.loadMore': 'और एजेंट लोड करें', + 'registries.collections.toolDefinitions.title': 'परिभाषाएँ', + 'registries.collections.toolDefinitions.description': + 'प्रकाशित टूल अनुबंध, अनुमति सक्रियण से अलग।', + 'registries.collections.toolDefinitions.loadMore': 'और परिभाषाएँ लोड करें', + 'registries.collections.toolEnablements.title': 'सक्रियन', + 'registries.collections.toolEnablements.description': + 'टूल के लिए अनुमति गेट और रनटाइम अनुमोदन सीमाएँ।', + 'registries.collections.connectorTypes.title': 'प्रकार', + 'registries.collections.connectorTypes.description': + 'प्रकाशित कनेक्टर प्रकार और normalization अनुबंध।', + 'registries.collections.connectorTypes.loadMore': 'और प्रकार लोड करें', + 'registries.collections.connectorBindings.title': 'बाइंडिंग', + 'registries.collections.connectorBindings.description': + 'बँधे हुए provider account और capability चयन।', + 'registries.collections.connectorBindings.loadMore': 'और बाइंडिंग लोड करें', + 'registries.items.created': '{date} को बनाया गया', + 'registries.items.fingerprint.config': 'कॉन्फ़िगरेशन फिंगरप्रिंट {fingerprint}', + 'registries.items.fingerprint.definition': 'परिभाषा fp:{fingerprint}', + 'registries.items.fingerprint.type': 'प्रकार fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'बाइंडिंग fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'कोई audit mode नहीं', + 'registries.items.meta.approvalRequired': 'अनुमोदन आवश्यक', + 'registries.items.meta.noApprovalGate': 'कोई approval gate नहीं', + 'registries.items.status.noTenantEnablement': 'कोई tenant enablement वापस नहीं आया', + 'registries.live.blocked': 'सूचना: {title}. {description}', + 'registries.live.loaded': '{tab} टैब सक्रिय है। {key} संस्करण {version} चुना गया है।', + 'registries.live.loading': '{tab} टैब सक्रिय है। {key} संस्करण {version} लोड हो रहा है।', + 'registries.live.idle': '{tab} टैब सक्रिय है। कोई विवरण चयनित नहीं है।', + 'registries.drawer.title': 'रजिस्ट्री विवरण', + 'registries.drawer.eyebrow': 'रजिस्ट्री विवरण', + 'registries.collection.state.idle': 'निष्क्रिय', + 'registries.collection.state.loading': 'लोड हो रहा है', + 'registries.collection.state.observed': 'देखा गया', + 'registries.collection.state.stale': 'पुराना', + 'registries.collection.state.blocked': 'अवरुद्ध', + 'registries.collection.body.idle': 'पहले अवलोकन की प्रतीक्षा में।', + 'registries.collection.body.loading': 'Core से सटीक रिकॉर्ड ताज़ा किए जा रहे हैं।', + 'registries.collection.body.empty': 'अवलोकन पूरा हुआ, लेकिन कोई रिकॉर्ड वापस नहीं आया।', + 'registries.collection.body.observedAt': '{observedAt} पर देखा गया।', + 'registries.collection.body.stale': + '{observedAt} पर देखा गया; अंतिम रिफ़्रेश ने पिछला सही डेटा बनाए रखा।', + 'registries.collection.body.blocked': 'Core ने इस collection के निरीक्षण को अस्वीकार कर दिया।', + 'registries.collection.empty': 'इस collection में अभी कोई सटीक रिकॉर्ड उपलब्ध नहीं है।', + 'registries.detail.notAvailable': 'उपलब्ध नहीं', + 'registries.detail.copyFingerprint': 'पूरा fingerprint कॉपी करें', + 'registries.detail.emptySchemaObject': 'खाली schema object', + 'registries.detail.schemaType': 'प्रकार {type}', + 'registries.detail.schemaProperties': '{count} गुण', + 'registries.detail.schemaRequired': '{count} आवश्यक', + 'registries.detail.noFields': 'कोई field नहीं', + 'registries.detail.fieldsSampleMore': '{count} फ़ील्ड · {sample}…', + 'registries.detail.fieldsSample': '{count} फ़ील्ड · {sample}', + 'registries.detail.noNormalizationContracts': 'कोई normalization contract नहीं', + 'registries.detail.unresolved': 'अनसुलझा: {label}', + 'registries.detail.deferred': '{label} देखने के लिए target tab लोड करें।', + 'registries.detail.fingerprint.configuration': 'कॉन्फ़िगरेशन fingerprint', + 'registries.detail.fingerprint.definition': 'परिभाषा fingerprint', + 'registries.detail.fingerprint.connectorType': 'कनेक्टर प्रकार fingerprint', + 'registries.detail.fingerprint.binding': 'बाइंडिंग fingerprint', + 'registries.detail.section.agentLifecycle': 'एजेंट lifecycle', + 'registries.detail.section.exactToolReferences': 'सटीक tool reference', + 'registries.detail.section.logicalReferenceWarnings': 'तार्किक reference चेतावनी', + 'registries.detail.section.configuration': 'कॉन्फ़िगरेशन', + 'registries.detail.section.definitionLifecycle': 'परिभाषा lifecycle', + 'registries.detail.section.schemas': 'Schema', + 'registries.detail.section.enablementLifecycle': 'सक्रियन lifecycle', + 'registries.detail.section.definitionLink': 'परिभाषा लिंक', + 'registries.detail.section.typeLifecycle': 'प्रकार lifecycle', + 'registries.detail.section.contracts': 'अनुबंध', + 'registries.detail.section.bindingLifecycle': 'बाइंडिंग lifecycle', + 'registries.detail.section.exactConnectorType': 'सटीक connector प्रकार', + 'registries.detail.section.logicalReferences': 'तार्किक reference', + 'registries.detail.note.activeRecords': + 'सक्रिय रिकॉर्ड प्रकाशन की स्थिति दिखाते हैं, रनटाइम अनुमति नहीं।', + 'registries.detail.noExactToolRefs': 'कोई सटीक tool reference घोषित नहीं है।', + 'registries.detail.note.logicalReferenceWarnings': + 'इस केवल-पढ़ने योग्य दृश्य के बाहर तार्किक reference पर आगे काम करना होगा।', + 'registries.detail.field.lifecycle': 'जीवनचक्र', + 'registries.detail.field.owner': 'मालिक', + 'registries.detail.field.created': 'बनाया गया', + 'registries.detail.field.knowledgeScopes': 'ज्ञान दायरे', + 'registries.detail.field.riskPolicy': 'जोखिम नीति', + 'registries.detail.none': 'कोई नहीं', + 'registries.detail.field.effectClass': 'प्रभाव श्रेणी', + 'registries.detail.field.schemaVersion': 'स्कीमा संस्करण', + 'registries.detail.field.enablement': 'सक्रियण', + 'registries.detail.field.inputSchema': 'इनपुट स्कीमा', + 'registries.detail.field.outputSchema': 'आउटपुट स्कीमा', + 'registries.detail.field.timeoutDefaults': 'डिफ़ॉल्ट timeout', + 'registries.detail.field.retryContract': 'पुनःप्रयास अनुबंध', + 'registries.detail.field.auditContract': 'ऑडिट अनुबंध', + 'registries.detail.viewRawJson': 'कच्चा JSON देखें', + 'registries.detail.field.generation': 'पीढ़ी', + 'registries.detail.field.approvalRequired': 'अनुमोदन आवश्यक', + 'registries.detail.field.auditMode': 'ऑडिट मोड', + 'registries.detail.notSet': 'सेट नहीं', + 'registries.detail.field.timeoutCap': 'टाइमआउट सीमा', + 'registries.detail.field.allowTtl': 'अनुमत TTL', + 'registries.detail.field.updated': 'अपडेट किया गया', + 'registries.detail.note.enablementRecords': + 'Enablement record अनुमति गेट बताते हैं, परिभाषा की प्रकाशन स्थिति नहीं।', + 'registries.detail.field.sourceType': 'स्रोत प्रकार', + 'registries.detail.field.capabilities': 'Capability', + 'registries.detail.field.normalizationContracts': 'नॉर्मलाइज़ेशन अनुबंध', + 'registries.detail.field.deliveryBehavior': 'डिलीवरी व्यवहार', + 'registries.detail.field.providerAccountReference': 'प्रदाता खाता संदर्भ', + 'registries.detail.note.logicalReferencesSecret': + 'केवल तार्किक reference दिखता है; रहस्य नहीं दिखाया जाता।', + 'registries.detail.field.configReference': 'कॉन्फ़िगरेशन संदर्भ', + 'registries.detail.field.credentialReference': 'क्रेडेंशियल संदर्भ', + 'registries.detail.empty': 'सटीक रजिस्ट्री संस्करण देखने के लिए एक रिकॉर्ड चुनें।', + 'registries.detail.state.loading': 'सटीक रजिस्ट्री विवरण लोड हो रहा है...', + 'registries.detail.state.missing': 'Core ने बताया कि यह सटीक रिकॉर्ड संस्करण अब मौजूद नहीं है।', + 'registries.detail.state.error': 'Core इस समय यह सटीक रिकॉर्ड लोड नहीं कर सका।', + 'registries.detail.kind.agent': 'एजेंट', + 'registries.detail.kind.tool-definition': 'टूल परिभाषा', + 'registries.detail.kind.tool-enablement': 'टूल सक्रियण', + 'registries.detail.kind.connector-type': 'कनेक्टर प्रकार', + 'registries.detail.kind.connector-binding': 'कनेक्टर बाइंडिंग', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'अवक्रमित', 'memoryTree.status.statusBudgetExhausted': 'रुका हुआ: एम्बेडिंग बजट समाप्त', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 35f9ffb8ba..6af003c707 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -680,6 +680,127 @@ const messages: TranslationMap = { 'Tidak dapat beralih ke model gratis OpenRouter. Periksa login OpenRouter Anda dan coba lagi.', 'home.routinesCard': 'Rutinitas Anda', 'home.routinesActive': '{count} aktif', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Rutinitas Anda', 'routines.subtitle': 'Hal yang dilakukan asisten Anda secara otomatis', 'routines.loading': 'Memuat rutinitas…', @@ -6283,6 +6404,183 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', 'pages.settings.account.security': 'Keamanan', 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', + 'home.coreRegistries': 'Registri Core', + 'home.coreRegistriesDescription': + 'Periksa catatan Agent, Tool, dan Connector yang tepat dari Core.', + 'home.coreRegistriesBlockedTitle': 'Integrasi Core diperlukan', + 'home.coreRegistriesBlockedMissingDescription': + 'Selesaikan integrasi Core desktop sebelum memeriksa registri.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Perbaiki integrasi Core desktop sebelum memeriksa registri.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Sambungkan kembali bridge Core lokal sebelum memeriksa registri.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Sambungkan kembali ke internet sebelum memeriksa registri.', + 'registries.page.eyebrow': 'Tampilan registri', + 'registries.page.title': 'Registri Core', + 'registries.page.description': + 'Pemeriksaan hanya-baca untuk catatan Agent, Tool, dan Connector yang tepat dari Core.', + 'registries.page.readOnly': + 'Layar ini tidak pernah menulis konfigurasi, rahasia, atau status runtime.', + 'registries.page.retry': 'Coba lagi', + 'registries.page.tablistAria': 'Registri Core', + 'registries.tab.agents': 'Agent', + 'registries.tab.tools': 'Tool', + 'registries.tab.connectors': 'Connector', + 'registries.summaryState.idle': 'Diam', + 'registries.summaryState.loading': 'Memuat', + 'registries.summaryState.fresh': 'Baru', + 'registries.summaryState.stale': 'Usang', + 'registries.summaryState.partial': 'Parsial', + 'registries.summaryState.blocked': 'Terblokir', + 'registries.summaryState.observed': 'Teramati', + 'registries.blocker.configRequiredTitle': 'Integrasi Core diperlukan', + 'registries.blocker.configMissingDescription': + 'Konfigurasi integrasi Core tidak ditemukan untuk pemeriksaan registri.', + 'registries.blocker.configInvalidTitle': 'Integrasi Core tidak valid', + 'registries.blocker.configInvalidDescription': + 'Konfigurasi integrasi Core saat ini tidak valid untuk pemeriksaan registri.', + 'registries.blocker.authRequiredTitle': 'Autentikasi Core diperlukan', + 'registries.blocker.authRequiredDescription': + 'Pemeriksaan registri tidak dapat melakukan autentikasi ke Core pada sesi ini.', + 'registries.blocker.forbiddenTitle': 'Pemeriksaan registri ditolak', + 'registries.blocker.forbiddenDescription': + 'Core menolak aktor ini untuk pemeriksaan registri tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant tidak tersedia', + 'registries.blocker.tenantUnavailableDescription': + 'Core melaporkan tenant ini sementara tidak tersedia untuk pemeriksaan registri.', + 'registries.blocker.tenantInvariantTitle': 'Pelanggaran invariant tenant', + 'registries.blocker.tenantInvariantDescription': + 'Core melaporkan pelanggaran invariant tenant untuk pemeriksaan registri.', + 'registries.blocker.genericDescription': 'Integrasi Core sedang memblokir pemeriksaan registri.', + 'registries.blocker.readOnly': + 'Layar ini hanya-baca dan tidak dapat menulis konfigurasi untuk Anda.', + 'registries.blocker.fixFlow': + 'Perbaiki koneksi Core di alur integrasi yang ada lalu coba lagi di sini.', + 'registries.collections.agents.title': 'Agent', + 'registries.collections.agents.description': + 'Catatan agent yang dipublikasikan. Tidak ada catatan yang dipilih otomatis.', + 'registries.collections.agents.loadMore': 'Muat lebih banyak agent', + 'registries.collections.toolDefinitions.title': 'Definisi', + 'registries.collections.toolDefinitions.description': + 'Kontrak tool yang dipublikasikan, terpisah dari pengaktifan izin.', + 'registries.collections.toolDefinitions.loadMore': 'Muat lebih banyak definisi', + 'registries.collections.toolEnablements.title': 'Pengaktifan', + 'registries.collections.toolEnablements.description': + 'Gerbang izin dan batas persetujuan runtime untuk tool.', + 'registries.collections.connectorTypes.title': 'Tipe', + 'registries.collections.connectorTypes.description': + 'Tipe connector yang dipublikasikan dan kontrak normalisasi.', + 'registries.collections.connectorTypes.loadMore': 'Muat lebih banyak tipe', + 'registries.collections.connectorBindings.title': 'Binding', + 'registries.collections.connectorBindings.description': + 'Akun penyedia yang terikat dan pilihan kemampuan.', + 'registries.collections.connectorBindings.loadMore': 'Muat lebih banyak binding', + 'registries.items.created': 'Dibuat {date}', + 'registries.items.fingerprint.config': 'Sidik konfigurasi {fingerprint}', + 'registries.items.fingerprint.definition': 'Definisi fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Tipe fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Sidik ikatan {fingerprint}', + 'registries.items.meta.noAuditMode': 'Tanpa mode audit', + 'registries.items.meta.approvalRequired': 'Persetujuan diperlukan', + 'registries.items.meta.noApprovalGate': 'Tanpa gerbang persetujuan', + 'registries.items.status.noTenantEnablement': 'Tidak ada pengaktifan tenant yang dikembalikan', + 'registries.live.blocked': 'Peringatan: {title}. {description}', + 'registries.live.loaded': 'Tab {tab} aktif. {key} versi {version} dipilih.', + 'registries.live.loading': 'Tab {tab} aktif. Memuat {key} versi {version}.', + 'registries.live.idle': 'Tab {tab} aktif. Tidak ada detail yang dipilih.', + 'registries.drawer.title': 'Detail registri', + 'registries.drawer.eyebrow': 'Detail registri', + 'registries.collection.state.idle': 'Diam', + 'registries.collection.state.loading': 'Memuat', + 'registries.collection.state.observed': 'Teramati', + 'registries.collection.state.stale': 'Usang', + 'registries.collection.state.blocked': 'Terblokir', + 'registries.collection.body.idle': 'Menunggu pengamatan pertama.', + 'registries.collection.body.loading': 'Menyegarkan catatan yang tepat dari Core.', + 'registries.collection.body.empty': 'Diamati tanpa catatan yang dikembalikan.', + 'registries.collection.body.observedAt': 'Diamati pada {observedAt}.', + 'registries.collection.body.stale': + 'Diamati pada {observedAt}; penyegaran terakhir mempertahankan data baik terakhir.', + 'registries.collection.body.blocked': 'Core menolak koleksi ini untuk pemeriksaan.', + 'registries.collection.empty': 'Belum ada catatan tepat yang tersedia di koleksi ini.', + 'registries.detail.notAvailable': 'Tidak tersedia', + 'registries.detail.copyFingerprint': 'Salin fingerprint lengkap', + 'registries.detail.emptySchemaObject': 'Objek skema kosong', + 'registries.detail.schemaType': 'Tipe {type}', + 'registries.detail.schemaProperties': '{count} properti', + 'registries.detail.schemaRequired': '{count} wajib', + 'registries.detail.noFields': 'Tidak ada field', + 'registries.detail.fieldsSampleMore': '{count} field · {sample}…', + 'registries.detail.fieldsSample': '{count} field · {sample}', + 'registries.detail.noNormalizationContracts': 'Tidak ada kontrak normalisasi', + 'registries.detail.unresolved': 'Belum terselesaikan: {label}', + 'registries.detail.deferred': 'Muat tab target untuk memeriksa {label}.', + 'registries.detail.fingerprint.configuration': 'Fingerprint konfigurasi', + 'registries.detail.fingerprint.definition': 'Fingerprint definisi', + 'registries.detail.fingerprint.connectorType': 'Fingerprint tipe connector', + 'registries.detail.fingerprint.binding': 'Fingerprint binding', + 'registries.detail.section.agentLifecycle': 'Siklus hidup agent', + 'registries.detail.section.exactToolReferences': 'Referensi tool yang tepat', + 'registries.detail.section.logicalReferenceWarnings': 'Peringatan referensi logis', + 'registries.detail.section.configuration': 'Konfigurasi', + 'registries.detail.section.definitionLifecycle': 'Siklus hidup definisi', + 'registries.detail.section.schemas': 'Skema', + 'registries.detail.section.enablementLifecycle': 'Siklus hidup pengaktifan', + 'registries.detail.section.definitionLink': 'Tautan definisi', + 'registries.detail.section.typeLifecycle': 'Siklus hidup tipe', + 'registries.detail.section.contracts': 'Kontrak', + 'registries.detail.section.bindingLifecycle': 'Siklus hidup binding', + 'registries.detail.section.exactConnectorType': 'Tipe connector yang tepat', + 'registries.detail.section.logicalReferences': 'Referensi logis', + 'registries.detail.note.activeRecords': + 'Catatan aktif adalah status publikasi, bukan izin runtime.', + 'registries.detail.noExactToolRefs': 'Tidak ada referensi tool tepat yang dideklarasikan.', + 'registries.detail.note.logicalReferenceWarnings': + 'Referensi logis memerlukan tindak lanjut di luar tampilan hanya-baca ini.', + 'registries.detail.field.lifecycle': 'Siklus hidup', + 'registries.detail.field.owner': 'Pemilik', + 'registries.detail.field.created': 'Dibuat', + 'registries.detail.field.knowledgeScopes': 'Ruang lingkup pengetahuan', + 'registries.detail.field.riskPolicy': 'Kebijakan risiko', + 'registries.detail.none': 'Tidak ada', + 'registries.detail.field.effectClass': 'Kelas efek', + 'registries.detail.field.schemaVersion': 'Versi skema', + 'registries.detail.field.enablement': 'Pengaktifan', + 'registries.detail.field.inputSchema': 'Skema input', + 'registries.detail.field.outputSchema': 'Skema output', + 'registries.detail.field.timeoutDefaults': 'Batas waktu default', + 'registries.detail.field.retryContract': 'Kontrak retry', + 'registries.detail.field.auditContract': 'Kontrak audit', + 'registries.detail.viewRawJson': 'Lihat JSON mentah', + 'registries.detail.field.generation': 'Generasi', + 'registries.detail.field.approvalRequired': 'Persetujuan diperlukan', + 'registries.detail.field.auditMode': 'Mode audit', + 'registries.detail.notSet': 'Belum disetel', + 'registries.detail.field.timeoutCap': 'Batas timeout', + 'registries.detail.field.allowTtl': 'TTL yang diizinkan', + 'registries.detail.field.updated': 'Diperbarui', + 'registries.detail.note.enablementRecords': + 'Catatan pengaktifan menyatakan gerbang izin, bukan status publikasi definisi.', + 'registries.detail.field.sourceType': 'Tipe sumber', + 'registries.detail.field.capabilities': 'Kemampuan', + 'registries.detail.field.normalizationContracts': 'Kontrak normalisasi', + 'registries.detail.field.deliveryBehavior': 'Perilaku pengiriman', + 'registries.detail.field.providerAccountReference': 'Referensi akun penyedia', + 'registries.detail.note.logicalReferencesSecret': + 'Hanya referensi logis; rahasia tidak ditampilkan.', + 'registries.detail.field.configReference': 'Referensi konfigurasi', + 'registries.detail.field.credentialReference': 'Referensi kredensial', + 'registries.detail.empty': 'Pilih catatan untuk memeriksa versi registri yang tepat.', + 'registries.detail.state.loading': 'Memuat detail registri yang tepat...', + 'registries.detail.state.missing': + 'Core melaporkan bahwa versi catatan tepat ini sudah tidak ada.', + 'registries.detail.state.error': 'Core tidak dapat memuat catatan tepat ini sekarang.', + 'registries.detail.kind.agent': 'Agen', + 'registries.detail.kind.tool-definition': 'Definisi tool', + 'registries.detail.kind.tool-enablement': 'Pengaktifan tool', + 'registries.detail.kind.connector-type': 'Tipe connector', + 'registries.detail.kind.connector-binding': 'Binding connector', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Terdegradasi', 'memoryTree.status.statusBudgetExhausted': 'Dijeda: batas embedding tercapai', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 07133584a6..24608c3019 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -692,6 +692,127 @@ const messages: TranslationMap = { 'Impossibile passare ai modelli gratuiti di OpenRouter. Controlla l’accesso a OpenRouter e riprova.', 'home.routinesCard': 'Le tue routine', 'home.routinesActive': '{count} attive', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Le tue routine', 'routines.subtitle': 'Cose che il tuo assistente fa automaticamente', 'routines.loading': 'Caricamento delle routine…', @@ -6362,6 +6483,186 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale', 'pages.settings.account.security': 'Sicurezza', 'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi', + 'home.coreRegistries': 'Registri del Core', + 'home.coreRegistriesDescription': + 'Ispeziona record esatti di agenti, strumenti e connettori dal Core.', + 'home.coreRegistriesBlockedTitle': 'Integrazione Core richiesta', + 'home.coreRegistriesBlockedMissingDescription': + 'Completa integrazione Core desktop prima di ispezionare i registri.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Ripara integrazione Core desktop prima di ispezionare i registri.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Ricollega il bridge Core locale prima di ispezionare i registri.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Riconnettiti a Internet prima di ispezionare i registri.', + 'registries.page.eyebrow': 'Viste registro', + 'registries.page.title': 'Registri del Core', + 'registries.page.description': + 'Ispezione in sola lettura di record esatti di agenti, strumenti e connettori supportati dal Core.', + 'registries.page.readOnly': + 'Questa schermata non scrive mai configurazione, segreti o stato runtime.', + 'registries.page.retry': 'Riprova', + 'registries.page.tablistAria': 'Registri del Core', + 'registries.tab.agents': 'Agenti', + 'registries.tab.tools': 'Strumenti', + 'registries.tab.connectors': 'Connettori', + 'registries.summaryState.idle': 'Inattivo', + 'registries.summaryState.loading': 'Caricamento', + 'registries.summaryState.fresh': 'Recente', + 'registries.summaryState.stale': 'Obsoleto', + 'registries.summaryState.partial': 'Parziale', + 'registries.summaryState.blocked': 'Bloccato', + 'registries.summaryState.observed': 'Osservato', + 'registries.blocker.configRequiredTitle': 'Integrazione Core richiesta', + 'registries.blocker.configMissingDescription': + 'Nessuna configurazione di integrazione Core trovata per ispezione registri.', + 'registries.blocker.configInvalidTitle': 'Integrazione Core non valida', + 'registries.blocker.configInvalidDescription': + 'Configurazione integrazione Core attuale non valida per ispezione registri.', + 'registries.blocker.authRequiredTitle': 'Autenticazione Core richiesta', + 'registries.blocker.authRequiredDescription': + 'Ispezione registro non autenticata con Core per questa sessione.', + 'registries.blocker.forbiddenTitle': 'Ispezione registro vietata', + 'registries.blocker.forbiddenDescription': + 'Core ha rifiutato questo attore per ispezione registri tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant non disponibile', + 'registries.blocker.tenantUnavailableDescription': + 'Core ha segnalato che questo tenant è temporaneamente indisponibile per ispezione registro.', + 'registries.blocker.tenantInvariantTitle': 'Violazione invariante tenant', + 'registries.blocker.tenantInvariantDescription': + 'Core ha segnalato una violazione invariante tenant per ispezione registro.', + 'registries.blocker.genericDescription': + 'Integrazione Core sta bloccando attualmente ispezione registro.', + 'registries.blocker.readOnly': + 'Questa schermata è di sola lettura e non può scrivere configurazione per te.', + 'registries.blocker.fixFlow': + 'Correggi connessione Core nel flusso integrazione esistente e riprova qui.', + 'registries.collections.agents.title': 'Agenti', + 'registries.collections.agents.description': + 'Record agente pubblicati. Nessun record viene selezionato automaticamente.', + 'registries.collections.agents.loadMore': 'Carica altri agenti', + 'registries.collections.toolDefinitions.title': 'Definizioni', + 'registries.collections.toolDefinitions.description': + 'Contratti strumento pubblicati, separati dalle abilitazioni permesso.', + 'registries.collections.toolDefinitions.loadMore': 'Carica altre definizioni', + 'registries.collections.toolEnablements.title': 'Abilitazioni', + 'registries.collections.toolEnablements.description': + 'Controlli permesso e limiti approvazione runtime per strumenti.', + 'registries.collections.connectorTypes.title': 'Tipi', + 'registries.collections.connectorTypes.description': + 'Tipi connettore pubblicati e contratti di normalizzazione.', + 'registries.collections.connectorTypes.loadMore': 'Carica altri tipi', + 'registries.collections.connectorBindings.title': 'Binding', + 'registries.collections.connectorBindings.description': + 'Account provider collegati e selezioni capacità.', + 'registries.collections.connectorBindings.loadMore': 'Carica altri binding', + 'registries.items.created': 'Creato {date}', + 'registries.items.fingerprint.config': 'Impronta configurazione {fingerprint}', + 'registries.items.fingerprint.definition': 'Definizione fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Tipo fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Impronta collegamento {fingerprint}', + 'registries.items.meta.noAuditMode': 'Nessuna modalità audit', + 'registries.items.meta.approvalRequired': 'Approvazione richiesta', + 'registries.items.meta.noApprovalGate': 'Nessun gate approvazione', + 'registries.items.status.noTenantEnablement': 'Nessuna abilitazione tenant restituita', + 'registries.live.blocked': 'Avviso: {title}. {description}', + 'registries.live.loaded': 'Scheda {tab} attiva. Selezionato {key} versione {version}.', + 'registries.live.loading': 'Scheda {tab} attiva. Caricamento {key} versione {version}.', + 'registries.live.idle': 'Scheda {tab} attiva. Nessun dettaglio selezionato.', + 'registries.drawer.title': 'Dettaglio registro', + 'registries.drawer.eyebrow': 'Dettaglio registro', + 'registries.collection.state.idle': 'Inattivo', + 'registries.collection.state.loading': 'Caricamento', + 'registries.collection.state.observed': 'Osservato', + 'registries.collection.state.stale': 'Obsoleto', + 'registries.collection.state.blocked': 'Bloccato', + 'registries.collection.body.idle': 'In attesa della prima osservazione.', + 'registries.collection.body.loading': 'Aggiornamento dei record esatti dal Core.', + 'registries.collection.body.empty': 'Osservato senza record restituiti.', + 'registries.collection.body.observedAt': 'Osservato alle {observedAt}.', + 'registries.collection.body.stale': + 'Osservato alle {observedAt}; ultimo aggiornamento ha mantenuto ultimi dati validi.', + 'registries.collection.body.blocked': 'Core ha rifiutato questa raccolta per ispezione.', + 'registries.collection.empty': 'Nessun record esatto disponibile ancora in questa raccolta.', + 'registries.detail.notAvailable': 'Non disponibile', + 'registries.detail.copyFingerprint': 'Copia fingerprint completo', + 'registries.detail.emptySchemaObject': 'Oggetto schema vuoto', + 'registries.detail.schemaType': 'Tipo {type}', + 'registries.detail.schemaProperties': '{count} proprietà', + 'registries.detail.schemaRequired': '{count} obbligatorie', + 'registries.detail.noFields': 'Nessun campo', + 'registries.detail.fieldsSampleMore': '{count} campi · {sample}…', + 'registries.detail.fieldsSample': '{count} campi · {sample}', + 'registries.detail.noNormalizationContracts': 'Nessun contratto normalizzazione', + 'registries.detail.unresolved': 'Non risolto: {label}', + 'registries.detail.deferred': 'Carica la scheda di destinazione per ispezionare {label}.', + 'registries.detail.fingerprint.configuration': 'Fingerprint configurazione', + 'registries.detail.fingerprint.definition': 'Fingerprint definizione', + 'registries.detail.fingerprint.connectorType': 'Fingerprint tipo connettore', + 'registries.detail.fingerprint.binding': 'Fingerprint binding', + 'registries.detail.section.agentLifecycle': 'Ciclo di vita agente', + 'registries.detail.section.exactToolReferences': 'Riferimenti strumento esatti', + 'registries.detail.section.logicalReferenceWarnings': 'Avvisi riferimenti logici', + 'registries.detail.section.configuration': 'Configurazione', + 'registries.detail.section.definitionLifecycle': 'Ciclo di vita definizione', + 'registries.detail.section.schemas': 'Schemi', + 'registries.detail.section.enablementLifecycle': 'Ciclo di vita abilitazione', + 'registries.detail.section.definitionLink': 'Collegamento definizione', + 'registries.detail.section.typeLifecycle': 'Ciclo di vita tipo', + 'registries.detail.section.contracts': 'Contratti', + 'registries.detail.section.bindingLifecycle': 'Ciclo di vita binding', + 'registries.detail.section.exactConnectorType': 'Tipo connettore esatto', + 'registries.detail.section.logicalReferences': 'Riferimenti logici', + 'registries.detail.note.activeRecords': + 'I record attivi sono stati di pubblicazione, non permessi runtime.', + 'registries.detail.noExactToolRefs': 'Nessun riferimento strumento esatto dichiarato.', + 'registries.detail.note.logicalReferenceWarnings': + 'I riferimenti logici richiedono follow-up fuori da questa vista di sola lettura.', + 'registries.detail.field.lifecycle': 'Ciclo di vita', + 'registries.detail.field.owner': 'Proprietario', + 'registries.detail.field.created': 'Creato', + 'registries.detail.field.knowledgeScopes': 'Ambiti conoscenza', + 'registries.detail.field.riskPolicy': 'Policy rischio', + 'registries.detail.none': 'Nessuno', + 'registries.detail.field.effectClass': 'Classe effetto', + 'registries.detail.field.schemaVersion': 'Versione schema', + 'registries.detail.field.enablement': 'Abilitazione', + 'registries.detail.field.inputSchema': 'Schema input', + 'registries.detail.field.outputSchema': 'Schema output', + 'registries.detail.field.timeoutDefaults': 'Timeout predefiniti', + 'registries.detail.field.retryContract': 'Contratto retry', + 'registries.detail.field.auditContract': 'Contratto audit', + 'registries.detail.viewRawJson': 'Visualizza JSON grezzo', + 'registries.detail.field.generation': 'Generazione', + 'registries.detail.field.approvalRequired': 'Approvazione richiesta', + 'registries.detail.field.auditMode': 'Modalità audit', + 'registries.detail.notSet': 'Non impostato', + 'registries.detail.field.timeoutCap': 'Limite timeout', + 'registries.detail.field.allowTtl': 'TTL consentito', + 'registries.detail.field.updated': 'Aggiornato', + 'registries.detail.note.enablementRecords': + 'I record di abilitazione esprimono controlli di permesso, non stato pubblicazione definizione.', + 'registries.detail.field.sourceType': 'Tipo sorgente', + 'registries.detail.field.capabilities': 'Capacità', + 'registries.detail.field.normalizationContracts': 'Contratti normalizzazione', + 'registries.detail.field.deliveryBehavior': 'Comportamento consegna', + 'registries.detail.field.providerAccountReference': 'Riferimento account provider', + 'registries.detail.note.logicalReferencesSecret': + 'Solo riferimento logico; segreto non mostrato.', + 'registries.detail.field.configReference': 'Riferimento configurazione', + 'registries.detail.field.credentialReference': 'Riferimento credenziale', + 'registries.detail.empty': + 'Seleziona un record per ispezionare la sua versione esatta del registro.', + 'registries.detail.state.loading': 'Caricamento dettaglio registro esatto...', + 'registries.detail.state.missing': + 'Core ha segnalato che questa versione esatta del record non esiste più.', + 'registries.detail.state.error': + 'Core non ha potuto caricare questo record esatto in questo momento.', + 'registries.detail.kind.agent': 'Agente', + 'registries.detail.kind.tool-definition': 'Definizione strumento', + 'registries.detail.kind.tool-enablement': 'Abilitazione strumento', + 'registries.detail.kind.connector-type': 'Tipo connettore', + 'registries.detail.kind.connector-binding': 'Binding connettore', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Degradato', 'memoryTree.status.statusBudgetExhausted': 'In pausa: budget di embedding raggiunto', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index b5c92c9f05..93f87d2aca 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -664,6 +664,127 @@ const messages: TranslationMap = { 'OpenRouter 무료 모델로 전환하지 못했습니다. OpenRouter 로그인을 확인하고 다시 시도하세요.', 'home.routinesCard': '내 루틴', 'home.routinesActive': '{count}개 활성', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Core 알림과 운영자 작업을 검토합니다.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + '운영자로서 보류 중인 Core 작업 요청을 승인하거나 거부합니다.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': '내 루틴', 'routines.subtitle': '어시스턴트가 자동으로 수행하는 작업', 'routines.loading': '루틴 로드 중…', @@ -6181,6 +6302,175 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '로컬 저장소 거부', 'pages.settings.account.security': '보안', 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', + 'home.coreRegistries': 'Core 레지스트리', + 'home.coreRegistriesDescription': 'Core의 정확한 에이전트, 도구, 커넥터 레코드를 확인합니다.', + 'home.coreRegistriesBlockedTitle': 'Core 통합이 필요합니다', + 'home.coreRegistriesBlockedMissingDescription': + '레지스트리를 확인하기 전에 데스크톱 Core 통합을 완료하세요.', + 'home.coreRegistriesBlockedInvalidDescription': + '레지스트리를 확인하기 전에 데스크톱 Core 통합을 복구하세요.', + 'home.coreRegistriesBlockedBridgeDescription': + '레지스트리를 확인하기 전에 로컬 Core 브리지를 다시 연결하세요.', + 'home.coreRegistriesBlockedOfflineDescription': + '레지스트리를 확인하기 전에 인터넷에 다시 연결하세요.', + 'registries.page.eyebrow': '레지스트리 보기', + 'registries.page.title': 'Core 레지스트리', + 'registries.page.description': + 'Core가 제공하는 정확한 에이전트, 도구, 커넥터 레코드를 읽기 전용으로 확인합니다.', + 'registries.page.readOnly': '이 화면은 구성, 비밀, 런타임 상태를 절대 쓰지 않습니다.', + 'registries.page.retry': '다시 시도', + 'registries.page.tablistAria': 'Core 레지스트리', + 'registries.tab.agents': '에이전트', + 'registries.tab.tools': '도구', + 'registries.tab.connectors': '커넥터', + 'registries.summaryState.idle': '대기', + 'registries.summaryState.loading': '불러오는 중', + 'registries.summaryState.fresh': '최신', + 'registries.summaryState.stale': '오래됨', + 'registries.summaryState.partial': '부분', + 'registries.summaryState.blocked': '차단됨', + 'registries.summaryState.observed': '관측됨', + 'registries.blocker.configRequiredTitle': 'Core 통합이 필요합니다', + 'registries.blocker.configMissingDescription': + '레지스트리 확인에 필요한 Core 통합 구성을 찾을 수 없습니다.', + 'registries.blocker.configInvalidTitle': 'Core 통합이 잘못되었습니다', + 'registries.blocker.configInvalidDescription': + '현재 Core 통합 구성이 레지스트리 확인에 유효하지 않습니다.', + 'registries.blocker.authRequiredTitle': 'Core 인증이 필요합니다', + 'registries.blocker.authRequiredDescription': + '이 세션에서 레지스트리 확인이 Core 인증에 실패했습니다.', + 'registries.blocker.forbiddenTitle': '레지스트리 확인이 거부되었습니다', + 'registries.blocker.forbiddenDescription': + 'Core가 이 행위자의 테넌트 레지스트리 확인을 거부했습니다.', + 'registries.blocker.tenantUnavailableTitle': '테넌트를 사용할 수 없습니다', + 'registries.blocker.tenantUnavailableDescription': + 'Core가 이 테넌트는 현재 레지스트리 확인을 할 수 없다고 보고했습니다.', + 'registries.blocker.tenantInvariantTitle': '테넌트 불변식 위반', + 'registries.blocker.tenantInvariantDescription': + 'Core가 레지스트리 확인 중 테넌트 불변식 위반을 보고했습니다.', + 'registries.blocker.genericDescription': '현재 Core 통합이 레지스트리 확인을 막고 있습니다.', + 'registries.blocker.readOnly': '이 화면은 읽기 전용이므로 대신 구성을 쓸 수 없습니다.', + 'registries.blocker.fixFlow': '기존 통합 흐름에서 Core 연결을 고친 뒤 여기서 다시 시도하세요.', + 'registries.collections.agents.title': '에이전트', + 'registries.collections.agents.description': + '게시된 에이전트 레코드입니다. 어떤 레코드도 자동 선택되지 않습니다.', + 'registries.collections.agents.loadMore': '에이전트 더 불러오기', + 'registries.collections.toolDefinitions.title': '정의', + 'registries.collections.toolDefinitions.description': + '게시된 도구 계약이며 권한 활성화와 분리되어 있습니다.', + 'registries.collections.toolDefinitions.loadMore': '정의 더 불러오기', + 'registries.collections.toolEnablements.title': '활성화', + 'registries.collections.toolEnablements.description': + '도구의 권한 게이트와 런타임 승인 한도입니다.', + 'registries.collections.connectorTypes.title': '유형', + 'registries.collections.connectorTypes.description': '게시된 커넥터 유형과 정규화 계약입니다.', + 'registries.collections.connectorTypes.loadMore': '유형 더 불러오기', + 'registries.collections.connectorBindings.title': '바인딩', + 'registries.collections.connectorBindings.description': '연결된 제공자 계정과 기능 선택입니다.', + 'registries.collections.connectorBindings.loadMore': '바인딩 더 불러오기', + 'registries.items.created': '생성일 {date}', + 'registries.items.fingerprint.config': '구성 지문 {fingerprint}', + 'registries.items.fingerprint.definition': '정의 fp:{fingerprint}', + 'registries.items.fingerprint.type': '유형 fp:{fingerprint}', + 'registries.items.fingerprint.binding': '바인딩 fp:{fingerprint}', + 'registries.items.meta.noAuditMode': '감사 모드 없음', + 'registries.items.meta.approvalRequired': '승인 필요', + 'registries.items.meta.noApprovalGate': '승인 게이트 없음', + 'registries.items.status.noTenantEnablement': '테넌트 활성화 레코드가 없습니다', + 'registries.live.blocked': '알림: {title}. {description}', + 'registries.live.loaded': '{tab} 탭 활성화됨. {key} 버전 {version} 선택됨.', + 'registries.live.loading': '{tab} 탭 활성화됨. {key} 버전 {version} 불러오는 중.', + 'registries.live.idle': '{tab} 탭 활성화됨. 선택된 세부 정보가 없습니다.', + 'registries.drawer.title': '레지스트리 세부 정보', + 'registries.drawer.eyebrow': '레지스트리 세부 정보', + 'registries.collection.state.idle': '대기', + 'registries.collection.state.loading': '불러오는 중', + 'registries.collection.state.observed': '관측됨', + 'registries.collection.state.stale': '오래됨', + 'registries.collection.state.blocked': '차단됨', + 'registries.collection.body.idle': '첫 관측을 기다리는 중입니다.', + 'registries.collection.body.loading': 'Core에서 정확한 레코드를 새로 고치는 중입니다.', + 'registries.collection.body.empty': '관측은 되었지만 반환된 레코드가 없습니다.', + 'registries.collection.body.observedAt': '{observedAt}에 관측되었습니다.', + 'registries.collection.body.stale': + '{observedAt}에 관측됨. 마지막 새로 고침은 마지막 정상 데이터를 유지했습니다.', + 'registries.collection.body.blocked': 'Core가 이 컬렉션의 확인을 거부했습니다.', + 'registries.collection.empty': '이 컬렉션에는 아직 정확한 레코드가 없습니다.', + 'registries.detail.notAvailable': '사용할 수 없음', + 'registries.detail.copyFingerprint': '전체 지문 복사', + 'registries.detail.emptySchemaObject': '빈 스키마 객체', + 'registries.detail.schemaType': '유형 {type}', + 'registries.detail.schemaProperties': '{count}개 속성', + 'registries.detail.schemaRequired': '{count}개 필수', + 'registries.detail.noFields': '필드 없음', + 'registries.detail.fieldsSampleMore': '{count}개 필드 · {sample}…', + 'registries.detail.fieldsSample': '{count}개 필드 · {sample}', + 'registries.detail.noNormalizationContracts': '정규화 계약 없음', + 'registries.detail.unresolved': '해결되지 않음: {label}', + 'registries.detail.deferred': '{label}을 확인하려면 대상 탭을 먼저 불러오세요.', + 'registries.detail.fingerprint.configuration': '구성 지문', + 'registries.detail.fingerprint.definition': '정의 지문', + 'registries.detail.fingerprint.connectorType': '커넥터 유형 지문', + 'registries.detail.fingerprint.binding': '바인딩 지문', + 'registries.detail.section.agentLifecycle': '에이전트 수명 주기', + 'registries.detail.section.exactToolReferences': '정확한 도구 참조', + 'registries.detail.section.logicalReferenceWarnings': '논리 참조 경고', + 'registries.detail.section.configuration': '구성', + 'registries.detail.section.definitionLifecycle': '정의 수명 주기', + 'registries.detail.section.schemas': '스키마', + 'registries.detail.section.enablementLifecycle': '활성화 수명 주기', + 'registries.detail.section.definitionLink': '정의 링크', + 'registries.detail.section.typeLifecycle': '유형 수명 주기', + 'registries.detail.section.contracts': '계약', + 'registries.detail.section.bindingLifecycle': '바인딩 수명 주기', + 'registries.detail.section.exactConnectorType': '정확한 커넥터 유형', + 'registries.detail.section.logicalReferences': '논리 참조', + 'registries.detail.note.activeRecords': + '활성 레코드는 게시 상태이며 런타임 권한 부여가 아닙니다.', + 'registries.detail.noExactToolRefs': '정확한 도구 참조가 선언되지 않았습니다.', + 'registries.detail.note.logicalReferenceWarnings': + '논리 참조는 이 읽기 전용 화면 밖에서 추가 확인이 필요합니다.', + 'registries.detail.field.lifecycle': '수명 주기', + 'registries.detail.field.owner': '소유자', + 'registries.detail.field.created': '생성됨', + 'registries.detail.field.knowledgeScopes': '지식 범위', + 'registries.detail.field.riskPolicy': '위험 정책', + 'registries.detail.none': '없음', + 'registries.detail.field.effectClass': '효과 등급', + 'registries.detail.field.schemaVersion': '스키마 버전', + 'registries.detail.field.enablement': '활성화', + 'registries.detail.field.inputSchema': '입력 스키마', + 'registries.detail.field.outputSchema': '출력 스키마', + 'registries.detail.field.timeoutDefaults': '기본 타임아웃', + 'registries.detail.field.retryContract': '재시도 계약', + 'registries.detail.field.auditContract': '감사 계약', + 'registries.detail.viewRawJson': '원시 JSON 보기', + 'registries.detail.field.generation': '세대', + 'registries.detail.field.approvalRequired': '승인 필요', + 'registries.detail.field.auditMode': '감사 모드', + 'registries.detail.notSet': '설정되지 않음', + 'registries.detail.field.timeoutCap': '타임아웃 상한', + 'registries.detail.field.allowTtl': '허용 TTL', + 'registries.detail.field.updated': '업데이트됨', + 'registries.detail.note.enablementRecords': + '활성화 레코드는 정의의 게시 상태가 아니라 권한 게이트를 나타냅니다.', + 'registries.detail.field.sourceType': '소스 유형', + 'registries.detail.field.capabilities': '기능', + 'registries.detail.field.normalizationContracts': '정규화 계약', + 'registries.detail.field.deliveryBehavior': '전달 동작', + 'registries.detail.field.providerAccountReference': '제공자 계정 참조', + 'registries.detail.note.logicalReferencesSecret': '논리 참조만 표시되며 비밀은 보이지 않습니다.', + 'registries.detail.field.configReference': '구성 참조', + 'registries.detail.field.credentialReference': '자격 증명 참조', + 'registries.detail.empty': '정확한 레지스트리 버전을 확인할 레코드를 선택하세요.', + 'registries.detail.state.loading': '정확한 레지스트리 세부 정보를 불러오는 중...', + 'registries.detail.state.missing': 'Core가 이 정확한 레코드 버전은 더 이상 없다고 보고했습니다.', + 'registries.detail.state.error': 'Core가 지금 이 정확한 레코드를 불러올 수 없습니다.', + 'registries.detail.kind.agent': '에이전트', + 'registries.detail.kind.tool-definition': '도구 정의', + 'registries.detail.kind.tool-enablement': '도구 활성화', + 'registries.detail.kind.connector-type': '커넥터 유형', + 'registries.detail.kind.connector-binding': '커넥터 바인딩', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': '저하됨', 'memoryTree.status.statusBudgetExhausted': '일시 중지됨: 임베딩 예산 소진', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c0f49b6daf..474f703982 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -686,6 +686,127 @@ const messages: TranslationMap = { 'Nie udało się przełączyć na darmowe modele OpenRouter. Sprawdź logowanie do OpenRouter i spróbuj ponownie.', 'home.routinesCard': 'Twoje Rutyny', 'home.routinesActive': '{count} aktywnych', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Twoje rutyny', 'routines.subtitle': 'Rzeczy, które asystent wykonuje automatycznie', 'routines.loading': 'Ładowanie rutyn…', @@ -6349,6 +6470,183 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania', 'pages.settings.account.security': 'Bezpieczeństwo', 'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy', + 'home.coreRegistries': 'Rejestry Core', + 'home.coreRegistriesDescription': + 'Sprawdzaj dokładne rekordy agentów, narzędzi i konektorów z Core.', + 'home.coreRegistriesBlockedTitle': 'Integracja Core wymagana', + 'home.coreRegistriesBlockedMissingDescription': + 'Ukończ integrację Core na desktopie przed sprawdzeniem rejestrów.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Napraw integrację Core na desktopie przed sprawdzeniem rejestrów.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Połącz ponownie lokalny most Core przed sprawdzeniem rejestrów.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Połącz się ponownie z internetem przed sprawdzeniem rejestrów.', + 'registries.page.eyebrow': 'Widoki rejestru', + 'registries.page.title': 'Rejestry Core', + 'registries.page.description': + 'Podgląd tylko do odczytu dokładnych rekordów agentów, narzędzi i konektorów z Core.', + 'registries.page.readOnly': + 'Ten ekran nigdy nie zapisuje konfiguracji, sekretów ani stanu działania.', + 'registries.page.retry': 'Spróbuj ponownie', + 'registries.page.tablistAria': 'Rejestry Core', + 'registries.tab.agents': 'Agenci', + 'registries.tab.tools': 'Narzędzia', + 'registries.tab.connectors': 'Konektory', + 'registries.summaryState.idle': 'Bezczynny', + 'registries.summaryState.loading': 'Ładowanie', + 'registries.summaryState.fresh': 'Świeży', + 'registries.summaryState.stale': 'Nieaktualny', + 'registries.summaryState.partial': 'Częściowy', + 'registries.summaryState.blocked': 'Zablokowany', + 'registries.summaryState.observed': 'Zaobserwowany', + 'registries.blocker.configRequiredTitle': 'Integracja Core wymagana', + 'registries.blocker.configMissingDescription': + 'Nie znaleziono konfiguracji integracji Core do sprawdzania rejestrów.', + 'registries.blocker.configInvalidTitle': 'Integracja Core nieprawidłowa', + 'registries.blocker.configInvalidDescription': + 'Bieżąca konfiguracja integracji Core jest nieprawidłowa do sprawdzania rejestrów.', + 'registries.blocker.authRequiredTitle': 'Uwierzytelnienie Core wymagane', + 'registries.blocker.authRequiredDescription': + 'Sprawdzanie rejestru nie mogło uwierzytelnić się w Core dla tej sesji.', + 'registries.blocker.forbiddenTitle': 'Sprawdzanie rejestru zabronione', + 'registries.blocker.forbiddenDescription': + 'Core odrzucił tego aktora dla sprawdzania rejestrów tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant niedostępny', + 'registries.blocker.tenantUnavailableDescription': + 'Core zgłosił, że tenant jest tymczasowo niedostępny dla sprawdzania rejestru.', + 'registries.blocker.tenantInvariantTitle': 'Naruszenie niezmiennika tenant', + 'registries.blocker.tenantInvariantDescription': + 'Core zgłosił naruszenie niezmiennika tenant dla sprawdzania rejestru.', + 'registries.blocker.genericDescription': 'Integracja Core obecnie blokuje sprawdzanie rejestru.', + 'registries.blocker.readOnly': + 'Ten ekran jest tylko do odczytu i nie może zapisać konfiguracji za Ciebie.', + 'registries.blocker.fixFlow': + 'Napraw połączenie Core w istniejącym przepływie integracji i spróbuj ponownie tutaj.', + 'registries.collections.agents.title': 'Agenci', + 'registries.collections.agents.description': + 'Opublikowane rekordy agentów. Żaden rekord nie jest wybierany automatycznie.', + 'registries.collections.agents.loadMore': 'Załaduj więcej agentów', + 'registries.collections.toolDefinitions.title': 'Definicje', + 'registries.collections.toolDefinitions.description': + 'Opublikowane kontrakty narzędzi, oddzielone od włączeń uprawnień.', + 'registries.collections.toolDefinitions.loadMore': 'Załaduj więcej definicji', + 'registries.collections.toolEnablements.title': 'Włączenia', + 'registries.collections.toolEnablements.description': + 'Bramki uprawnień i limity zgód w czasie działania dla narzędzi.', + 'registries.collections.connectorTypes.title': 'Typy', + 'registries.collections.connectorTypes.description': + 'Opublikowane typy konektorów i kontrakty normalizacji.', + 'registries.collections.connectorTypes.loadMore': 'Załaduj więcej typów', + 'registries.collections.connectorBindings.title': 'Powiązania', + 'registries.collections.connectorBindings.description': + 'Powiązane konta dostawców i wybory możliwości.', + 'registries.collections.connectorBindings.loadMore': 'Załaduj więcej powiązań', + 'registries.items.created': 'Utworzono {date}', + 'registries.items.fingerprint.config': 'Fingerprint konfiguracji {fingerprint}', + 'registries.items.fingerprint.definition': 'Definicja fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Typ fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Powiązanie fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'Brak trybu audytu', + 'registries.items.meta.approvalRequired': 'Wymagana zgoda', + 'registries.items.meta.noApprovalGate': 'Brak bramki zgody', + 'registries.items.status.noTenantEnablement': 'Nie zwrócono włączenia tenant', + 'registries.live.blocked': 'Uwaga: {title}. {description}', + 'registries.live.loaded': 'Karta {tab} aktywna. Wybrano {key} w wersji {version}.', + 'registries.live.loading': 'Karta {tab} aktywna. Ładowanie {key} w wersji {version}.', + 'registries.live.idle': 'Karta {tab} aktywna. Nie wybrano szczegółu.', + 'registries.drawer.title': 'Szczegół rejestru', + 'registries.drawer.eyebrow': 'Szczegół rejestru', + 'registries.collection.state.idle': 'Bezczynny', + 'registries.collection.state.loading': 'Ładowanie', + 'registries.collection.state.observed': 'Zaobserwowany', + 'registries.collection.state.stale': 'Nieaktualny', + 'registries.collection.state.blocked': 'Zablokowany', + 'registries.collection.body.idle': 'Oczekiwanie na pierwszą obserwację.', + 'registries.collection.body.loading': 'Odświeżanie dokładnych rekordów z Core.', + 'registries.collection.body.empty': 'Zaobserwowano bez zwróconych rekordów.', + 'registries.collection.body.observedAt': 'Zaobserwowano o {observedAt}.', + 'registries.collection.body.stale': + 'Zaobserwowano o {observedAt}; ostatnie odświeżenie zachowało ostatnie poprawne dane.', + 'registries.collection.body.blocked': 'Core odrzucił tę kolekcję dla sprawdzania.', + 'registries.collection.empty': 'Brak dokładnych rekordów dostępnych jeszcze w tej kolekcji.', + 'registries.detail.notAvailable': 'Niedostępne', + 'registries.detail.copyFingerprint': 'Kopiuj pełny fingerprint', + 'registries.detail.emptySchemaObject': 'Pusty obiekt schematu', + 'registries.detail.schemaType': 'Typ {type}', + 'registries.detail.schemaProperties': '{count} właściwości', + 'registries.detail.schemaRequired': '{count} wymagane', + 'registries.detail.noFields': 'Brak pól', + 'registries.detail.fieldsSampleMore': '{count} pól · {sample}…', + 'registries.detail.fieldsSample': '{count} pól · {sample}', + 'registries.detail.noNormalizationContracts': 'Brak kontraktów normalizacji', + 'registries.detail.unresolved': 'Nierozwiązane: {label}', + 'registries.detail.deferred': 'Załaduj kartę docelową, aby sprawdzić {label}.', + 'registries.detail.fingerprint.configuration': 'Fingerprint konfiguracji', + 'registries.detail.fingerprint.definition': 'Fingerprint definicji', + 'registries.detail.fingerprint.connectorType': 'Fingerprint typu konektora', + 'registries.detail.fingerprint.binding': 'Fingerprint powiązania', + 'registries.detail.section.agentLifecycle': 'Cykl życia agenta', + 'registries.detail.section.exactToolReferences': 'Dokładne referencje narzędzi', + 'registries.detail.section.logicalReferenceWarnings': 'Ostrzeżenia logicznych referencji', + 'registries.detail.section.configuration': 'Konfiguracja', + 'registries.detail.section.definitionLifecycle': 'Cykl życia definicji', + 'registries.detail.section.schemas': 'Schematy', + 'registries.detail.section.enablementLifecycle': 'Cykl życia włączenia', + 'registries.detail.section.definitionLink': 'Link definicji', + 'registries.detail.section.typeLifecycle': 'Cykl życia typu', + 'registries.detail.section.contracts': 'Kontrakty', + 'registries.detail.section.bindingLifecycle': 'Cykl życia powiązania', + 'registries.detail.section.exactConnectorType': 'Dokładny typ konektora', + 'registries.detail.section.logicalReferences': 'Logiczne referencje', + 'registries.detail.note.activeRecords': + 'Aktywne rekordy są stanami publikacji, a nie uprawnieniami czasu działania.', + 'registries.detail.noExactToolRefs': 'Nie zadeklarowano dokładnych referencji narzędzi.', + 'registries.detail.note.logicalReferenceWarnings': + 'Logiczne referencje wymagają dalszych działań poza tym widokiem tylko do odczytu.', + 'registries.detail.field.lifecycle': 'Cykl życia', + 'registries.detail.field.owner': 'Właściciel', + 'registries.detail.field.created': 'Utworzono', + 'registries.detail.field.knowledgeScopes': 'Zakresy wiedzy', + 'registries.detail.field.riskPolicy': 'Polityka ryzyka', + 'registries.detail.none': 'Brak', + 'registries.detail.field.effectClass': 'Klasa efektu', + 'registries.detail.field.schemaVersion': 'Wersja schematu', + 'registries.detail.field.enablement': 'Włączenie', + 'registries.detail.field.inputSchema': 'Schemat wejścia', + 'registries.detail.field.outputSchema': 'Schemat wyjścia', + 'registries.detail.field.timeoutDefaults': 'Domyślne timeouty', + 'registries.detail.field.retryContract': 'Kontrakt retry', + 'registries.detail.field.auditContract': 'Kontrakt audytu', + 'registries.detail.viewRawJson': 'Pokaż surowy JSON', + 'registries.detail.field.generation': 'Generacja', + 'registries.detail.field.approvalRequired': 'Wymagana zgoda', + 'registries.detail.field.auditMode': 'Tryb audytu', + 'registries.detail.notSet': 'Nie ustawiono', + 'registries.detail.field.timeoutCap': 'Limit timeoutu', + 'registries.detail.field.allowTtl': 'Dozwolony TTL', + 'registries.detail.field.updated': 'Zaktualizowano', + 'registries.detail.note.enablementRecords': + 'Rekordy włączeń opisują bramki uprawnień, a nie stan publikacji definicji.', + 'registries.detail.field.sourceType': 'Typ źródła', + 'registries.detail.field.capabilities': 'Możliwości', + 'registries.detail.field.normalizationContracts': 'Kontrakty normalizacji', + 'registries.detail.field.deliveryBehavior': 'Zachowanie dostawy', + 'registries.detail.field.providerAccountReference': 'Referencja konta dostawcy', + 'registries.detail.note.logicalReferencesSecret': + 'Tylko logiczna referencja; sekret nie jest wyświetlany.', + 'registries.detail.field.configReference': 'Referencja konfiguracji', + 'registries.detail.field.credentialReference': 'Referencja poświadczenia', + 'registries.detail.empty': 'Wybierz rekord, aby sprawdzić jego dokładną wersję rejestru.', + 'registries.detail.state.loading': 'Ładowanie dokładnego szczegółu rejestru...', + 'registries.detail.state.missing': + 'Core zgłosił, że ta dokładna wersja rekordu już nie istnieje.', + 'registries.detail.state.error': 'Core nie mógł teraz załadować tego dokładnego rekordu.', + 'registries.detail.kind.agent': 'Agent AI', + 'registries.detail.kind.tool-definition': 'Definicja narzędzia', + 'registries.detail.kind.tool-enablement': 'Włączenie narzędzia', + 'registries.detail.kind.connector-type': 'Typ konektora', + 'registries.detail.kind.connector-binding': 'Powiązanie konektora', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Ograniczony', 'memoryTree.status.statusBudgetExhausted': 'Wstrzymano: wyczerpano budżet osadzeń', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 486d0a2c42..cde230526a 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -688,6 +688,127 @@ const messages: TranslationMap = { 'Não foi possível mudar para os modelos grátis da OpenRouter. Verifique seu login da OpenRouter e tente novamente.', 'home.routinesCard': 'Suas Rotinas', 'home.routinesActive': '{count} ativas', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Seus Hábitos', 'routines.subtitle': 'Coisas que seu assistente faz automaticamente', 'routines.loading': 'Carregando rotinas…', @@ -6351,6 +6472,184 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Recusar armazenamento local', 'pages.settings.account.security': 'Segurança', 'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro', + 'home.coreRegistries': 'Registros do Core', + 'home.coreRegistriesDescription': + 'Inspecione registros exatos de agentes, ferramentas e conectores do Core.', + 'home.coreRegistriesBlockedTitle': 'Integração com Core obrigatória', + 'home.coreRegistriesBlockedMissingDescription': + 'Conclua a integração do Core no desktop antes de inspecionar os registros.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Repare a integração do Core no desktop antes de inspecionar os registros.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Reconecte a ponte local do Core antes de inspecionar os registros.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Reconecte-se à internet antes de inspecionar os registros.', + 'registries.page.eyebrow': 'Visões do registro', + 'registries.page.title': 'Registros do Core', + 'registries.page.description': + 'Inspeção somente leitura de registros exatos de agentes, ferramentas e conectores apoiados pelo Core.', + 'registries.page.readOnly': + 'Esta tela nunca grava configuração, segredos nem estado de execução.', + 'registries.page.retry': 'Tentar novamente', + 'registries.page.tablistAria': 'Registros do Core', + 'registries.tab.agents': 'Agentes', + 'registries.tab.tools': 'Ferramentas', + 'registries.tab.connectors': 'Conectores', + 'registries.summaryState.idle': 'Inativo', + 'registries.summaryState.loading': 'Carregando', + 'registries.summaryState.fresh': 'Recente', + 'registries.summaryState.stale': 'Desatualizado', + 'registries.summaryState.partial': 'Parcial', + 'registries.summaryState.blocked': 'Bloqueado', + 'registries.summaryState.observed': 'Observado', + 'registries.blocker.configRequiredTitle': 'Integração com Core obrigatória', + 'registries.blocker.configMissingDescription': + 'Nenhuma configuração de integração com Core foi encontrada para inspeção dos registros.', + 'registries.blocker.configInvalidTitle': 'Integração com Core inválida', + 'registries.blocker.configInvalidDescription': + 'A configuração atual de integração com Core é inválida para inspeção dos registros.', + 'registries.blocker.authRequiredTitle': 'Autenticação com Core obrigatória', + 'registries.blocker.authRequiredDescription': + 'A inspeção do registro não conseguiu autenticar com o Core nesta sessão.', + 'registries.blocker.forbiddenTitle': 'Inspeção do registro proibida', + 'registries.blocker.forbiddenDescription': + 'O Core rejeitou este ator para inspeção de registros do tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant indisponível', + 'registries.blocker.tenantUnavailableDescription': + 'O Core informou que este tenant está temporariamente indisponível para inspeção do registro.', + 'registries.blocker.tenantInvariantTitle': 'Violação de invariante do tenant', + 'registries.blocker.tenantInvariantDescription': + 'O Core informou uma violação de invariante do tenant para inspeção do registro.', + 'registries.blocker.genericDescription': + 'A integração com Core está bloqueando a inspeção do registro neste momento.', + 'registries.blocker.readOnly': + 'Esta tela é somente leitura e não pode gravar configuração por você.', + 'registries.blocker.fixFlow': + 'Corrija a conexão com o Core no fluxo de integração existente e tente novamente aqui.', + 'registries.collections.agents.title': 'Agentes', + 'registries.collections.agents.description': + 'Registros publicados de agentes. Nenhum registro é selecionado automaticamente.', + 'registries.collections.agents.loadMore': 'Carregar mais agentes', + 'registries.collections.toolDefinitions.title': 'Definições', + 'registries.collections.toolDefinitions.description': + 'Contratos de ferramentas publicados, separados das habilitações de permissão.', + 'registries.collections.toolDefinitions.loadMore': 'Carregar mais definições', + 'registries.collections.toolEnablements.title': 'Habilitações', + 'registries.collections.toolEnablements.description': + 'Portões de permissão e limites de aprovação em tempo de execução para ferramentas.', + 'registries.collections.connectorTypes.title': 'Tipos', + 'registries.collections.connectorTypes.description': + 'Tipos de conectores publicados e contratos de normalização.', + 'registries.collections.connectorTypes.loadMore': 'Carregar mais tipos', + 'registries.collections.connectorBindings.title': 'Vinculações', + 'registries.collections.connectorBindings.description': + 'Contas de provedor vinculadas e seleções de capacidades.', + 'registries.collections.connectorBindings.loadMore': 'Carregar mais vinculações', + 'registries.items.created': 'Criado {date}', + 'registries.items.fingerprint.config': 'Fingerprint de configuração {fingerprint}', + 'registries.items.fingerprint.definition': 'Definição fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Tipo fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Vinculação fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'Sem modo de auditoria', + 'registries.items.meta.approvalRequired': 'Aprovação obrigatória', + 'registries.items.meta.noApprovalGate': 'Sem portão de aprovação', + 'registries.items.status.noTenantEnablement': 'Nenhuma habilitação do tenant retornada', + 'registries.live.blocked': 'Aviso: {title}. {description}', + 'registries.live.loaded': 'Aba {tab} ativa. {key} versão {version} selecionado.', + 'registries.live.loading': 'Aba {tab} ativa. Carregando {key} versão {version}.', + 'registries.live.idle': 'Aba {tab} ativa. Nenhum detalhe selecionado.', + 'registries.drawer.title': 'Detalhe do registro', + 'registries.drawer.eyebrow': 'Detalhe do registro', + 'registries.collection.state.idle': 'Inativo', + 'registries.collection.state.loading': 'Carregando', + 'registries.collection.state.observed': 'Observado', + 'registries.collection.state.stale': 'Desatualizado', + 'registries.collection.state.blocked': 'Bloqueado', + 'registries.collection.body.idle': 'Aguardando a primeira observação.', + 'registries.collection.body.loading': 'Atualizando os registros exatos a partir do Core.', + 'registries.collection.body.empty': 'Observado sem registros retornados.', + 'registries.collection.body.observedAt': 'Observado em {observedAt}.', + 'registries.collection.body.stale': + 'Observado em {observedAt}; a última atualização manteve os últimos dados válidos.', + 'registries.collection.body.blocked': 'O Core rejeitou esta coleção para inspeção.', + 'registries.collection.empty': 'Ainda não há registros exatos disponíveis nesta coleção.', + 'registries.detail.notAvailable': 'Não disponível', + 'registries.detail.copyFingerprint': 'Copiar fingerprint completo', + 'registries.detail.emptySchemaObject': 'Objeto de esquema vazio', + 'registries.detail.schemaType': 'Tipo {type}', + 'registries.detail.schemaProperties': '{count} propriedades', + 'registries.detail.schemaRequired': '{count} obrigatórias', + 'registries.detail.noFields': 'Sem campos', + 'registries.detail.fieldsSampleMore': '{count} campos · {sample}…', + 'registries.detail.fieldsSample': '{count} campos · {sample}', + 'registries.detail.noNormalizationContracts': 'Sem contratos de normalização', + 'registries.detail.unresolved': 'Não resolvido: {label}', + 'registries.detail.deferred': 'Carregue a aba de destino para inspecionar {label}.', + 'registries.detail.fingerprint.configuration': 'Fingerprint de configuração', + 'registries.detail.fingerprint.definition': 'Fingerprint de definição', + 'registries.detail.fingerprint.connectorType': 'Fingerprint de tipo de conector', + 'registries.detail.fingerprint.binding': 'Fingerprint de vinculação', + 'registries.detail.section.agentLifecycle': 'Ciclo de vida do agente', + 'registries.detail.section.exactToolReferences': 'Referências exatas de ferramentas', + 'registries.detail.section.logicalReferenceWarnings': 'Avisos de referências lógicas', + 'registries.detail.section.configuration': 'Configuração', + 'registries.detail.section.definitionLifecycle': 'Ciclo de vida da definição', + 'registries.detail.section.schemas': 'Esquemas', + 'registries.detail.section.enablementLifecycle': 'Ciclo de vida da habilitação', + 'registries.detail.section.definitionLink': 'Link da definição', + 'registries.detail.section.typeLifecycle': 'Ciclo de vida do tipo', + 'registries.detail.section.contracts': 'Contratos', + 'registries.detail.section.bindingLifecycle': 'Ciclo de vida da vinculação', + 'registries.detail.section.exactConnectorType': 'Tipo exato de conector', + 'registries.detail.section.logicalReferences': 'Referências lógicas', + 'registries.detail.note.activeRecords': + 'Registros ativos são estados de publicação, não concessões de permissão em tempo de execução.', + 'registries.detail.noExactToolRefs': 'Nenhuma referência exata de ferramenta foi declarada.', + 'registries.detail.note.logicalReferenceWarnings': + 'Referências lógicas exigem acompanhamento fora desta visão somente leitura.', + 'registries.detail.field.lifecycle': 'Ciclo de vida', + 'registries.detail.field.owner': 'Proprietário', + 'registries.detail.field.created': 'Criado', + 'registries.detail.field.knowledgeScopes': 'Escopos de conhecimento', + 'registries.detail.field.riskPolicy': 'Política de risco', + 'registries.detail.none': 'Nenhum', + 'registries.detail.field.effectClass': 'Classe de efeito', + 'registries.detail.field.schemaVersion': 'Versão do esquema', + 'registries.detail.field.enablement': 'Habilitação', + 'registries.detail.field.inputSchema': 'Esquema de entrada', + 'registries.detail.field.outputSchema': 'Esquema de saída', + 'registries.detail.field.timeoutDefaults': 'Timeouts padrão', + 'registries.detail.field.retryContract': 'Contrato de retry', + 'registries.detail.field.auditContract': 'Contrato de auditoria', + 'registries.detail.viewRawJson': 'Ver JSON bruto', + 'registries.detail.field.generation': 'Geração', + 'registries.detail.field.approvalRequired': 'Aprovação obrigatória', + 'registries.detail.field.auditMode': 'Modo de auditoria', + 'registries.detail.notSet': 'Não definido', + 'registries.detail.field.timeoutCap': 'Limite de timeout', + 'registries.detail.field.allowTtl': 'TTL permitido', + 'registries.detail.field.updated': 'Atualizado', + 'registries.detail.note.enablementRecords': + 'Registros de habilitação expressam controles de permissão, não estado de publicação da definição.', + 'registries.detail.field.sourceType': 'Tipo de origem', + 'registries.detail.field.capabilities': 'Capacidades', + 'registries.detail.field.normalizationContracts': 'Contratos de normalização', + 'registries.detail.field.deliveryBehavior': 'Comportamento de entrega', + 'registries.detail.field.providerAccountReference': 'Referência de conta do provedor', + 'registries.detail.note.logicalReferencesSecret': + 'Apenas referência lógica; segredo não exibido.', + 'registries.detail.field.configReference': 'Referência de configuração', + 'registries.detail.field.credentialReference': 'Referência de credencial', + 'registries.detail.empty': 'Selecione um registro para inspecionar sua versão exata.', + 'registries.detail.state.loading': 'Carregando detalhe exato do registro...', + 'registries.detail.state.missing': + 'O Core informou que esta versão exata do registro não existe mais.', + 'registries.detail.state.error': 'O Core não conseguiu carregar este registro exato agora.', + 'registries.detail.kind.agent': 'Agente', + 'registries.detail.kind.tool-definition': 'Definição de ferramenta', + 'registries.detail.kind.tool-enablement': 'Habilitação de ferramenta', + 'registries.detail.kind.connector-type': 'Tipo de conector', + 'registries.detail.kind.connector-binding': 'Vinculação de conector', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Degradado', 'memoryTree.status.statusBudgetExhausted': 'Em pausa: limite de embeddings atingido', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f831832197..7cbf1058b4 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -680,6 +680,127 @@ const messages: TranslationMap = { 'Не удалось переключиться на бесплатные модели OpenRouter. Проверьте вход в OpenRouter и повторите попытку.', 'home.routinesCard': 'Ваши подпрограммы', 'home.routinesActive': '{count} активных', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': 'Ваши рутины', 'routines.subtitle': 'Что ваш помощник делает автоматически', 'routines.loading': 'Загрузка процедур…', @@ -6319,6 +6440,182 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', 'pages.settings.account.security': 'Безопасность', 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', + 'home.coreRegistries': 'Реестры Core', + 'home.coreRegistriesDescription': 'Проверяйте точные записи Agent, Tool и Connector из Core.', + 'home.coreRegistriesBlockedTitle': 'Требуется интеграция Core', + 'home.coreRegistriesBlockedMissingDescription': + 'Завершите интеграцию Core на компьютере перед проверкой реестров.', + 'home.coreRegistriesBlockedInvalidDescription': + 'Исправьте интеграцию Core на компьютере перед проверкой реестров.', + 'home.coreRegistriesBlockedBridgeDescription': + 'Повторно подключите локальный мост Core перед проверкой реестров.', + 'home.coreRegistriesBlockedOfflineDescription': + 'Снова подключитесь к интернету перед проверкой реестров.', + 'registries.page.eyebrow': 'Просмотр реестра', + 'registries.page.title': 'Реестры Core', + 'registries.page.description': + 'Режим только для чтения для точных записей Agent, Tool и Connector из Core.', + 'registries.page.readOnly': + 'Этот экран никогда не записывает конфигурацию, секреты или состояние выполнения.', + 'registries.page.retry': 'Повторить', + 'registries.page.tablistAria': 'Реестры Core', + 'registries.tab.agents': 'Агенты', + 'registries.tab.tools': 'Инструменты', + 'registries.tab.connectors': 'Коннекторы', + 'registries.summaryState.idle': 'Ожидание', + 'registries.summaryState.loading': 'Загрузка', + 'registries.summaryState.fresh': 'Свежие', + 'registries.summaryState.stale': 'Устаревшие', + 'registries.summaryState.partial': 'Частично', + 'registries.summaryState.blocked': 'Заблокировано', + 'registries.summaryState.observed': 'Получено', + 'registries.blocker.configRequiredTitle': 'Требуется интеграция Core', + 'registries.blocker.configMissingDescription': + 'Не найдена конфигурация интеграции Core для проверки реестров.', + 'registries.blocker.configInvalidTitle': 'Интеграция Core недействительна', + 'registries.blocker.configInvalidDescription': + 'Текущая конфигурация интеграции Core недействительна для проверки реестров.', + 'registries.blocker.authRequiredTitle': 'Требуется аутентификация Core', + 'registries.blocker.authRequiredDescription': + 'Проверка реестра не смогла пройти аутентификацию в Core в этой сессии.', + 'registries.blocker.forbiddenTitle': 'Проверка реестра запрещена', + 'registries.blocker.forbiddenDescription': + 'Core отклонил этого участника для проверки реестров tenant.', + 'registries.blocker.tenantUnavailableTitle': 'Tenant недоступен', + 'registries.blocker.tenantUnavailableDescription': + 'Core сообщил, что этот tenant временно недоступен для проверки реестра.', + 'registries.blocker.tenantInvariantTitle': 'Нарушение инварианта tenant', + 'registries.blocker.tenantInvariantDescription': + 'Core сообщил о нарушении инварианта tenant при проверке реестра.', + 'registries.blocker.genericDescription': 'Интеграция Core сейчас блокирует проверку реестра.', + 'registries.blocker.readOnly': + 'Этот экран доступен только для чтения и не может записать конфигурацию за вас.', + 'registries.blocker.fixFlow': + 'Исправьте подключение Core в существующем потоке интеграции и попробуйте снова здесь.', + 'registries.collections.agents.title': 'Агенты', + 'registries.collections.agents.description': + 'Опубликованные записи агентов. Ни одна запись не выбирается автоматически.', + 'registries.collections.agents.loadMore': 'Загрузить больше агентов', + 'registries.collections.toolDefinitions.title': 'Определения', + 'registries.collections.toolDefinitions.description': + 'Опубликованные контракты инструментов, отдельно от записей включения.', + 'registries.collections.toolDefinitions.loadMore': 'Загрузить больше определений', + 'registries.collections.toolEnablements.title': 'Включения', + 'registries.collections.toolEnablements.description': + 'Шлюзы разрешений и лимиты согласования во время выполнения для инструментов.', + 'registries.collections.connectorTypes.title': 'Типы', + 'registries.collections.connectorTypes.description': + 'Опубликованные типы коннекторов и контракты нормализации.', + 'registries.collections.connectorTypes.loadMore': 'Загрузить больше типов', + 'registries.collections.connectorBindings.title': 'Привязки', + 'registries.collections.connectorBindings.description': + 'Привязанные аккаунты провайдера и выбранные возможности.', + 'registries.collections.connectorBindings.loadMore': 'Загрузить больше привязок', + 'registries.items.created': 'Создано {date}', + 'registries.items.fingerprint.config': 'Отпечаток конфигурации {fingerprint}', + 'registries.items.fingerprint.definition': 'Определение fp:{fingerprint}', + 'registries.items.fingerprint.type': 'Тип fp:{fingerprint}', + 'registries.items.fingerprint.binding': 'Привязка fp:{fingerprint}', + 'registries.items.meta.noAuditMode': 'Режим аудита не задан', + 'registries.items.meta.approvalRequired': 'Требуется согласование', + 'registries.items.meta.noApprovalGate': 'Нет шлюза согласования', + 'registries.items.status.noTenantEnablement': 'Запись включения tenant не возвращена', + 'registries.live.blocked': 'Внимание: {title}. {description}', + 'registries.live.loaded': 'Вкладка {tab} активна. Выбрано {key} версии {version}.', + 'registries.live.loading': 'Вкладка {tab} активна. Загружается {key} версии {version}.', + 'registries.live.idle': 'Вкладка {tab} активна. Детали не выбраны.', + 'registries.drawer.title': 'Детали реестра', + 'registries.drawer.eyebrow': 'Детали реестра', + 'registries.collection.state.idle': 'Ожидание', + 'registries.collection.state.loading': 'Загрузка', + 'registries.collection.state.observed': 'Получено', + 'registries.collection.state.stale': 'Устаревшие', + 'registries.collection.state.blocked': 'Заблокировано', + 'registries.collection.body.idle': 'Ожидание первого наблюдения.', + 'registries.collection.body.loading': 'Обновляются точные записи из Core.', + 'registries.collection.body.empty': 'Наблюдение завершено, но записи не возвращены.', + 'registries.collection.body.observedAt': 'Получено в {observedAt}.', + 'registries.collection.body.stale': + 'Получено в {observedAt}; последнее обновление сохранило последние корректные данные.', + 'registries.collection.body.blocked': 'Core отклонил проверку этой коллекции.', + 'registries.collection.empty': 'В этой коллекции пока нет доступных точных записей.', + 'registries.detail.notAvailable': 'Недоступно', + 'registries.detail.copyFingerprint': 'Скопировать полный fingerprint', + 'registries.detail.emptySchemaObject': 'Пустой объект схемы', + 'registries.detail.schemaType': 'Тип {type}', + 'registries.detail.schemaProperties': '{count} свойств', + 'registries.detail.schemaRequired': '{count} обязательных', + 'registries.detail.noFields': 'Нет полей', + 'registries.detail.fieldsSampleMore': '{count} полей · {sample}…', + 'registries.detail.fieldsSample': '{count} полей · {sample}', + 'registries.detail.noNormalizationContracts': 'Нет контрактов нормализации', + 'registries.detail.unresolved': 'Не разрешено: {label}', + 'registries.detail.deferred': 'Загрузите целевую вкладку, чтобы проверить {label}.', + 'registries.detail.fingerprint.configuration': 'Fingerprint конфигурации', + 'registries.detail.fingerprint.definition': 'Fingerprint определения', + 'registries.detail.fingerprint.connectorType': 'Fingerprint типа коннектора', + 'registries.detail.fingerprint.binding': 'Fingerprint привязки', + 'registries.detail.section.agentLifecycle': 'Жизненный цикл агента', + 'registries.detail.section.exactToolReferences': 'Точные ссылки на инструменты', + 'registries.detail.section.logicalReferenceWarnings': 'Предупреждения о логических ссылках', + 'registries.detail.section.configuration': 'Конфигурация', + 'registries.detail.section.definitionLifecycle': 'Жизненный цикл определения', + 'registries.detail.section.schemas': 'Схемы', + 'registries.detail.section.enablementLifecycle': 'Жизненный цикл включения', + 'registries.detail.section.definitionLink': 'Ссылка на определение', + 'registries.detail.section.typeLifecycle': 'Жизненный цикл типа', + 'registries.detail.section.contracts': 'Контракты', + 'registries.detail.section.bindingLifecycle': 'Жизненный цикл привязки', + 'registries.detail.section.exactConnectorType': 'Точный тип коннектора', + 'registries.detail.section.logicalReferences': 'Логические ссылки', + 'registries.detail.note.activeRecords': + 'Активные записи показывают состояние публикации, а не разрешения времени выполнения.', + 'registries.detail.noExactToolRefs': 'Точные ссылки на инструменты не объявлены.', + 'registries.detail.note.logicalReferenceWarnings': + 'Логические ссылки требуют дальнейшей проверки вне этого режима только для чтения.', + 'registries.detail.field.lifecycle': 'Жизненный цикл', + 'registries.detail.field.owner': 'Владелец', + 'registries.detail.field.created': 'Создано', + 'registries.detail.field.knowledgeScopes': 'Области знаний', + 'registries.detail.field.riskPolicy': 'Политика риска', + 'registries.detail.none': 'Нет', + 'registries.detail.field.effectClass': 'Класс эффекта', + 'registries.detail.field.schemaVersion': 'Версия схемы', + 'registries.detail.field.enablement': 'Включение', + 'registries.detail.field.inputSchema': 'Схема входа', + 'registries.detail.field.outputSchema': 'Схема выхода', + 'registries.detail.field.timeoutDefaults': 'Стандартные тайм-ауты', + 'registries.detail.field.retryContract': 'Контракт повтора', + 'registries.detail.field.auditContract': 'Контракт аудита', + 'registries.detail.viewRawJson': 'Показать сырой JSON', + 'registries.detail.field.generation': 'Поколение', + 'registries.detail.field.approvalRequired': 'Требуется согласование', + 'registries.detail.field.auditMode': 'Режим аудита', + 'registries.detail.notSet': 'Не задано', + 'registries.detail.field.timeoutCap': 'Лимит тайм-аута', + 'registries.detail.field.allowTtl': 'Допустимый TTL', + 'registries.detail.field.updated': 'Обновлено', + 'registries.detail.note.enablementRecords': + 'Записи включения описывают шлюзы разрешений, а не состояние публикации определения.', + 'registries.detail.field.sourceType': 'Тип источника', + 'registries.detail.field.capabilities': 'Возможности', + 'registries.detail.field.normalizationContracts': 'Контракты нормализации', + 'registries.detail.field.deliveryBehavior': 'Поведение доставки', + 'registries.detail.field.providerAccountReference': 'Ссылка на аккаунт провайдера', + 'registries.detail.note.logicalReferencesSecret': + 'Показывается только логическая ссылка; секрет не отображается.', + 'registries.detail.field.configReference': 'Ссылка на конфигурацию', + 'registries.detail.field.credentialReference': 'Ссылка на учётные данные', + 'registries.detail.empty': 'Выберите запись, чтобы проверить её точную версию реестра.', + 'registries.detail.state.loading': 'Загружаются точные детали реестра...', + 'registries.detail.state.missing': + 'Core сообщил, что этой точной версии записи больше не существует.', + 'registries.detail.state.error': 'Core сейчас не смог загрузить эту точную запись.', + 'registries.detail.kind.agent': 'Агент', + 'registries.detail.kind.tool-definition': 'Определение инструмента', + 'registries.detail.kind.tool-enablement': 'Включение инструмента', + 'registries.detail.kind.connector-type': 'Тип коннектора', + 'registries.detail.kind.connector-binding': 'Привязка коннектора', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': 'Ухудшено', 'memoryTree.status.statusBudgetExhausted': 'Приостановлено: бюджет эмбеддингов исчерпан', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 543ec3de78..25a4470d50 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -624,6 +624,127 @@ const messages: TranslationMap = { 'openrouterFree.error': '无法切换到 OpenRouter 免费模型。请检查 OpenRouter 登录后重试。', 'home.routinesCard': '我的例程', 'home.routinesActive': '{count} 个运行中', + 'home.youpetWorkbench': 'YouPet Workbench', + 'home.youpetWorkbenchDescription': 'Review Core alerts and operator actions.', + 'actionRequest.eyebrow': 'YouPet Core', + 'actionRequest.title': 'Action Request Inbox', + 'actionRequest.subtitle': + 'Review pending Core ActionRequests and approve or reject with an operator reason.', + 'actionRequest.filterLabel': 'Approval filter', + 'actionRequest.filter.pending': 'Pending', + 'actionRequest.filter.all': 'All', + 'actionRequest.refresh': 'Refresh', + 'actionRequest.refreshing': 'Refreshing', + 'actionRequest.loading': 'Loading action requests…', + 'actionRequest.empty': 'No action requests match the current filter.', + 'actionRequest.selectPrompt': 'Select an action request to inspect.', + 'actionRequest.rowVersion': 'Row version', + 'actionRequest.approval': 'Approval', + 'actionRequest.execution': 'Execution', + 'actionRequest.actionType': 'Action type', + 'actionRequest.risk': 'Risk', + 'actionRequest.proposer': 'Proposer', + 'actionRequest.target': 'Target', + 'actionRequest.policyOutcome': 'Policy outcome', + 'actionRequest.correlation': 'Correlation', + 'actionRequest.updated': 'Updated', + 'actionRequest.reasons': 'Policy reasons', + 'actionRequest.obligations': 'Obligations', + 'actionRequest.payload': 'Payload', + 'actionRequest.none': 'None', + 'actionRequest.reasonLabel': 'Operator reason', + 'actionRequest.reasonRequired': 'A non-empty operator reason is required.', + 'actionRequest.approve': 'Approve', + 'actionRequest.approving': 'Approving…', + 'actionRequest.reject': 'Reject', + 'actionRequest.rejecting': 'Rejecting…', + 'actionRequest.terminalReadOnly': 'This request is no longer pending and is read-only.', + 'actionRequest.requestFailed': 'Action request failed. Check Core configuration and try again.', + 'actionRequest.errorWithCode': 'Action request failed ({code}).', + 'actionRequest.conflictRefresh': + 'State changed ({code}). Reloaded from Core: {state} v{version}.', + 'actionRequest.conflictRefreshFailed': 'State conflict ({code}), and refresh from Core failed.', + 'actionRequest.links': 'Links', + 'actionRequest.links.workflowId': 'Workflow ID', + 'actionRequest.links.workflowTraceId': 'Workflow trace ID', + 'actionRequest.links.agentRunId': 'Agent run ID', + 'actionRequest.links.proposalEventId': 'Proposal event ID', + 'actionRequest.links.idempotencyKey': 'Idempotency key', + 'actionRequest.links.auditLogIds': 'Audit log IDs', + 'actionRequest.links.domainEventIds': 'Domain event IDs', + 'actionRequest.links.outboxDeliveryIds': 'Outbox delivery IDs', + 'actionRequest.linksEmpty': 'No correlation links recorded on this request.', + 'actionRequest.storageWarning': + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.', + 'actionRequest.storageUnavailable': + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.', + 'actionRequest.refreshAfterMutationFailed': + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.', + 'actionRequest.tenantConfigMissing': + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.', + 'home.youpetActionRequests': 'Action Request Inbox', + 'home.youpetActionRequestsDescription': + 'Approve or reject pending Core ActionRequests as the operator.', + 'workbench.eyebrow': 'YouPet Core', + 'workbench.title': 'Workbench', + 'workbench.refresh': 'Refresh', + 'workbench.refreshing': 'Refreshing', + 'workbench.status': 'Status', + 'workbench.statusFilterLabel': 'Alert status filter', + 'workbench.status.all': 'All statuses', + 'workbench.status.open': 'Open', + 'workbench.status.acknowledged': 'Acknowledged', + 'workbench.status.resolved': 'Resolved', + 'workbench.status.dismissed': 'Dismissed', + 'workbench.severity': 'Severity', + 'workbench.severityFilterLabel': 'Alert severity filter', + 'workbench.severity.all': 'All severities', + 'workbench.severity.low': 'Low', + 'workbench.severity.medium': 'Medium', + 'workbench.severity.high': 'High', + 'workbench.severity.critical': 'Critical', + 'workbench.contextUnavailable': 'Operational context unavailable for this alert.', + 'workbench.contextFor': 'Operational context for {alertId}', + 'workbench.context.pet': 'Pet', + 'workbench.context.owner': 'Owner', + 'workbench.context.plan': 'Health plan', + 'workbench.context.task': 'Task', + 'workbench.context.flowId': 'Flow ID', + 'workbench.context.missed': 'Missed', + 'workbench.context.due': 'Due', + 'workbench.context.latestCheckin': 'Latest check-in', + 'workbench.requestFailed': 'Workbench request failed. Check Core configuration and try again.', + 'workbench.loading': 'Loading alerts', + 'workbench.empty': 'No alerts match the current filters.', + 'workbench.noSummary': 'No alert summary', + 'workbench.none': 'None', + 'workbench.related': 'Related', + 'workbench.created': 'Created', + 'workbench.acknowledged': 'Acknowledged', + 'workbench.resolved': 'Resolved', + 'workbench.ackNote': 'Ack note', + 'workbench.ackNoteFor': 'Ack note for {alertId}', + 'workbench.acknowledge': 'Acknowledge', + 'workbench.acknowledging': 'Acknowledging', + 'workbench.resolution': 'Resolution', + 'workbench.resolutionFor': 'Resolution for {alertId}', + 'workbench.resolve': 'Resolve', + 'workbench.resolving': 'Resolving', + 'workbench.trace.lane.action': 'Action', + 'workbench.trace.actionRequestId': 'Action request', + 'workbench.trace.actionType': 'Action type', + 'workbench.trace.target': 'Target', + 'workbench.trace.risk': 'Risk', + 'workbench.trace.policyOutcome': 'Policy', + 'workbench.trace.requiredApproverClass': 'Required approver', + 'workbench.trace.approvalState': 'Approval', + 'workbench.trace.approverClass': 'Approver class', + 'workbench.trace.executionState': 'Execution', + 'workbench.trace.executionResult': 'Result', + 'workbench.trace.executionError': 'Error', + 'workbench.trace.warning.missingRelatedActionRequest': 'Missing related ActionRequest', + 'workbench.trace.warning.actionRequestLinksTruncated': 'ActionRequest links truncated', + 'workbench.trace.warning.traceReservedBudgetExceeded': 'Trace reserved budget exceeded', 'routines.title': '你的例程', 'routines.subtitle': '助手自动执行的事项', 'routines.loading': '正在加载例程…', @@ -5907,6 +6028,159 @@ const messages: TranslationMap = { 'keyring.settings.revokeConsent': '拒绝本地存储', 'pages.settings.account.security': '安全', 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', + 'home.coreRegistries': 'Core 注册表', + 'home.coreRegistriesDescription': '检查来自 Core 的精确代理、工具和连接器记录。', + 'home.coreRegistriesBlockedTitle': '需要 Core 集成', + 'home.coreRegistriesBlockedMissingDescription': '请先完成桌面端 Core 集成,再检查注册表。', + 'home.coreRegistriesBlockedInvalidDescription': '请先修复桌面端 Core 集成,再检查注册表。', + 'home.coreRegistriesBlockedBridgeDescription': '请先重新连接本地 Core 桥接,再检查注册表。', + 'home.coreRegistriesBlockedOfflineDescription': '请先重新连接互联网,再检查注册表。', + 'registries.page.eyebrow': '注册表视图', + 'registries.page.title': 'Core 注册表', + 'registries.page.description': '对来自 Core 的精确代理、工具和连接器记录进行只读检查。', + 'registries.page.readOnly': '此页面绝不会写入配置、密钥或运行时状态。', + 'registries.page.retry': '重试', + 'registries.page.tablistAria': 'Core 注册表', + 'registries.tab.agents': '代理', + 'registries.tab.tools': '工具', + 'registries.tab.connectors': '连接器', + 'registries.summaryState.idle': '空闲', + 'registries.summaryState.loading': '加载中', + 'registries.summaryState.fresh': '最新', + 'registries.summaryState.stale': '陈旧', + 'registries.summaryState.partial': '部分', + 'registries.summaryState.blocked': '已阻止', + 'registries.summaryState.observed': '已观测', + 'registries.blocker.configRequiredTitle': '需要 Core 集成', + 'registries.blocker.configMissingDescription': '未找到用于检查注册表的 Core 集成配置。', + 'registries.blocker.configInvalidTitle': 'Core 集成无效', + 'registries.blocker.configInvalidDescription': '当前 Core 集成配置无法用于检查注册表。', + 'registries.blocker.authRequiredTitle': '需要 Core 身份验证', + 'registries.blocker.authRequiredDescription': '本次会话中的注册表检查无法通过 Core 身份验证。', + 'registries.blocker.forbiddenTitle': '注册表检查被拒绝', + 'registries.blocker.forbiddenDescription': 'Core 拒绝该参与者执行租户注册表检查。', + 'registries.blocker.tenantUnavailableTitle': '租户不可用', + 'registries.blocker.tenantUnavailableDescription': 'Core 报告此租户暂时无法执行注册表检查。', + 'registries.blocker.tenantInvariantTitle': '租户不变量冲突', + 'registries.blocker.tenantInvariantDescription': 'Core 报告注册表检查存在租户不变量冲突。', + 'registries.blocker.genericDescription': '当前是 Core 集成阻止了注册表检查。', + 'registries.blocker.readOnly': '此页面为只读页面,无法替你写入配置。', + 'registries.blocker.fixFlow': '请先在现有集成流程中修复 Core 连接,然后再回到这里重试。', + 'registries.collections.agents.title': '代理', + 'registries.collections.agents.description': '已发布的代理记录。不会自动选中任何记录。', + 'registries.collections.agents.loadMore': '加载更多代理', + 'registries.collections.toolDefinitions.title': '定义', + 'registries.collections.toolDefinitions.description': + '已发布的工具契约,与权限启用记录分开显示。', + 'registries.collections.toolDefinitions.loadMore': '加载更多定义', + 'registries.collections.toolEnablements.title': '启用', + 'registries.collections.toolEnablements.description': '工具的权限闸门和运行时审批限制。', + 'registries.collections.connectorTypes.title': '类型', + 'registries.collections.connectorTypes.description': '已发布的连接器类型和规范化契约。', + 'registries.collections.connectorTypes.loadMore': '加载更多类型', + 'registries.collections.connectorBindings.title': '绑定', + 'registries.collections.connectorBindings.description': '已绑定的提供方账户和能力选择。', + 'registries.collections.connectorBindings.loadMore': '加载更多绑定', + 'registries.items.created': '创建于 {date}', + 'registries.items.fingerprint.config': '配置指纹 {fingerprint}', + 'registries.items.fingerprint.definition': '定义 fp:{fingerprint}', + 'registries.items.fingerprint.type': '类型 fp:{fingerprint}', + 'registries.items.fingerprint.binding': '绑定 fp:{fingerprint}', + 'registries.items.meta.noAuditMode': '无审计模式', + 'registries.items.meta.approvalRequired': '需要审批', + 'registries.items.meta.noApprovalGate': '无审批闸门', + 'registries.items.status.noTenantEnablement': '未返回租户启用记录', + 'registries.live.blocked': '提示:{title}。{description}', + 'registries.live.loaded': '{tab} 标签已激活。已选中 {key} 版本 {version}。', + 'registries.live.loading': '{tab} 标签已激活。正在加载 {key} 版本 {version}。', + 'registries.live.idle': '{tab} 标签已激活。尚未选择详情。', + 'registries.drawer.title': '注册表详情', + 'registries.drawer.eyebrow': '注册表详情', + 'registries.collection.state.idle': '空闲', + 'registries.collection.state.loading': '加载中', + 'registries.collection.state.observed': '已观测', + 'registries.collection.state.stale': '陈旧', + 'registries.collection.state.blocked': '已阻止', + 'registries.collection.body.idle': '正在等待第一次观测。', + 'registries.collection.body.loading': '正在从 Core 刷新精确记录。', + 'registries.collection.body.empty': '已完成观测,但没有返回任何记录。', + 'registries.collection.body.observedAt': '观测时间:{observedAt}。', + 'registries.collection.body.stale': + '观测时间:{observedAt};最近一次刷新保留了最后一份有效数据。', + 'registries.collection.body.blocked': 'Core 拒绝了对此集合的检查。', + 'registries.collection.empty': '此集合中暂时还没有可用的精确记录。', + 'registries.detail.notAvailable': '不可用', + 'registries.detail.copyFingerprint': '复制完整指纹', + 'registries.detail.emptySchemaObject': '空模式对象', + 'registries.detail.schemaType': '类型 {type}', + 'registries.detail.schemaProperties': '{count} 个属性', + 'registries.detail.schemaRequired': '{count} 个必填项', + 'registries.detail.noFields': '无字段', + 'registries.detail.fieldsSampleMore': '{count} 个字段 · {sample}…', + 'registries.detail.fieldsSample': '{count} 个字段 · {sample}', + 'registries.detail.noNormalizationContracts': '无规范化契约', + 'registries.detail.unresolved': '未解析:{label}', + 'registries.detail.deferred': '请先加载目标标签页,再检查 {label}。', + 'registries.detail.fingerprint.configuration': '配置指纹', + 'registries.detail.fingerprint.definition': '定义指纹', + 'registries.detail.fingerprint.connectorType': '连接器类型指纹', + 'registries.detail.fingerprint.binding': '绑定指纹', + 'registries.detail.section.agentLifecycle': '代理生命周期', + 'registries.detail.section.exactToolReferences': '精确工具引用', + 'registries.detail.section.logicalReferenceWarnings': '逻辑引用警告', + 'registries.detail.section.configuration': '配置', + 'registries.detail.section.definitionLifecycle': '定义生命周期', + 'registries.detail.section.schemas': '模式', + 'registries.detail.section.enablementLifecycle': '启用生命周期', + 'registries.detail.section.definitionLink': '定义链接', + 'registries.detail.section.typeLifecycle': '类型生命周期', + 'registries.detail.section.contracts': '契约', + 'registries.detail.section.bindingLifecycle': '绑定生命周期', + 'registries.detail.section.exactConnectorType': '精确连接器类型', + 'registries.detail.section.logicalReferences': '逻辑引用', + 'registries.detail.note.activeRecords': '活动记录表示发布状态,而不是运行时权限。', + 'registries.detail.noExactToolRefs': '未声明任何精确工具引用。', + 'registries.detail.note.logicalReferenceWarnings': '逻辑引用需要在此只读视图之外继续跟进。', + 'registries.detail.field.lifecycle': '生命周期', + 'registries.detail.field.owner': '所有者', + 'registries.detail.field.created': '创建时间', + 'registries.detail.field.knowledgeScopes': '知识范围', + 'registries.detail.field.riskPolicy': '风险策略', + 'registries.detail.none': '无', + 'registries.detail.field.effectClass': '效果类别', + 'registries.detail.field.schemaVersion': '模式版本', + 'registries.detail.field.enablement': '启用', + 'registries.detail.field.inputSchema': '输入模式', + 'registries.detail.field.outputSchema': '输出模式', + 'registries.detail.field.timeoutDefaults': '默认超时', + 'registries.detail.field.retryContract': '重试契约', + 'registries.detail.field.auditContract': '审计契约', + 'registries.detail.viewRawJson': '查看原始 JSON', + 'registries.detail.field.generation': '代次', + 'registries.detail.field.approvalRequired': '需要审批', + 'registries.detail.field.auditMode': '审计模式', + 'registries.detail.notSet': '未设置', + 'registries.detail.field.timeoutCap': '超时上限', + 'registries.detail.field.allowTtl': '允许 TTL', + 'registries.detail.field.updated': '更新时间', + 'registries.detail.note.enablementRecords': '启用记录表达的是权限闸门,而不是定义的发布状态。', + 'registries.detail.field.sourceType': '来源类型', + 'registries.detail.field.capabilities': '能力', + 'registries.detail.field.normalizationContracts': '规范化契约', + 'registries.detail.field.deliveryBehavior': '交付行为', + 'registries.detail.field.providerAccountReference': '提供方账户引用', + 'registries.detail.note.logicalReferencesSecret': '这里只显示逻辑引用,不显示密钥。', + 'registries.detail.field.configReference': '配置引用', + 'registries.detail.field.credentialReference': '凭证引用', + 'registries.detail.empty': '选择一条记录以检查其精确注册表版本。', + 'registries.detail.state.loading': '正在加载精确注册表详情...', + 'registries.detail.state.missing': 'Core 报告此精确记录版本已不存在。', + 'registries.detail.state.error': 'Core 当前无法加载此精确记录。', + 'registries.detail.kind.agent': '代理', + 'registries.detail.kind.tool-definition': '工具定义', + 'registries.detail.kind.tool-enablement': '工具启用', + 'registries.detail.kind.connector-type': '连接器类型', + 'registries.detail.kind.connector-binding': '连接器绑定', // #002 memory-pipeline-hardening: degraded badges + typed remediation. 'memoryTree.status.statusDegraded': '已降级', 'memoryTree.status.statusBudgetExhausted': '已暂停:嵌入额度已用尽', diff --git a/app/src/pages/ActionRequestInbox.bridge.test.tsx b/app/src/pages/ActionRequestInbox.bridge.test.tsx new file mode 100644 index 0000000000..b844d0b337 --- /dev/null +++ b/app/src/pages/ActionRequestInbox.bridge.test.tsx @@ -0,0 +1,155 @@ +/** + * Bounded UI integration scenario for M1.2.3 ActionRequest inbox. + * + * Covers the user-visible path in one journey: approve + reject, storage + * fail-closed, authoritative Core get, and pending-filter removal. + * + * This is the real page against a mocked `createCoreActionRequestClient` + * interface — not a bridge/client contract proof (the Core RPC client is + * fully replaced in this suite). + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setActiveUserId } from '../store/userScopedStorage'; +import ActionRequestInbox, { setActionRequestIntentStorageAdapter } from './ActionRequestInbox'; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(), + get: vi.fn(), + approve: vi.fn(), + reject: vi.fn(), +})); + +vi.mock('../services/api/coreActionRequestClient', async () => { + const actual = await vi.importActual( + '../services/api/coreActionRequestClient' + ); + return { ...actual, createCoreActionRequestClient: () => mockClient }; +}); + +const PENDING_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const PENDING_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const TENANT = '20000000-0000-0000-0000-000000000001'; +const USER = 'bridge-operator'; + +function envelope(id: string, approval: string, rowVersion: number): Record { + return { + id, + tenant_id: TENANT, + row_version: rowVersion, + approval_state: approval, + execution_state: 'not_started', + policy_outcome: 'require_approval', + correlation_id: `corr-${id.slice(0, 8)}`, + created_at: '2026-08-08T12:00:00Z', + updated_at: '2026-08-08T12:00:00Z', + action_request: { + id, + action_type: 'task.escalate', + risk: 'high', + proposer: { type: 'agent', id: 'openclaw-main' }, + target: { type: 'task', id: `task-${id.slice(0, 4)}` }, + policy: { reasons: ['needs review'], obligations: [] }, + payload: { summary: `payload-${id.slice(0, 4)}` }, + links: { + audit_log_ids: [], + domain_event_ids: [], + outbox_delivery_ids: [], + workflow_id: null, + workflow_trace_id: null, + agent_run_id: null, + proposal_event_id: null, + idempotency_key: null, + }, + }, + }; +} + +describe('ActionRequestInbox bridge journey', () => { + beforeEach(() => { + mockClient.list.mockReset(); + mockClient.get.mockReset(); + mockClient.approve.mockReset(); + mockClient.reject.mockReset(); + window.localStorage.clear(); + setActionRequestIntentStorageAdapter(null); + setActiveUserId(USER); + }); + + afterEach(() => { + setActionRequestIntentStorageAdapter(null); + setActiveUserId(null); + vi.restoreAllMocks(); + }); + + it('covers approve, reject, storage fail-closed, Core refresh, and pending removal', async () => { + const pendingA = envelope(PENDING_A, 'pending', 1); + const pendingB = envelope(PENDING_B, 'pending', 2); + const approvedA = envelope(PENDING_A, 'approved', 2); + const rejectedB = envelope(PENDING_B, 'rejected', 3); + + mockClient.list.mockResolvedValue([pendingA, pendingB]); + mockClient.approve.mockResolvedValueOnce(approvedA); + mockClient.get.mockResolvedValueOnce(approvedA); + mockClient.reject.mockResolvedValueOnce(rejectedB); + mockClient.get.mockResolvedValueOnce(rejectedB); + + const user = userEvent.setup(); + render(); + + // --- Approve path: authoritative get + pending-filter removal --- + expect(await screen.findByTestId(`action-request-row-${PENDING_A}`)).toBeInTheDocument(); + expect(screen.getByTestId(`action-request-row-${PENDING_B}`)).toBeInTheDocument(); + + await user.click(screen.getByTestId(`action-request-row-${PENDING_A}`)); + await user.type(screen.getByTestId('action-request-reason'), 'bridge approve ok'); + await user.click(screen.getByTestId('action-request-approve')); + + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_A)); + await waitFor(() => + expect(screen.queryByTestId(`action-request-row-${PENDING_A}`)).not.toBeInTheDocument() + ); + // B remains pending under the default pending filter. + expect(screen.getByTestId(`action-request-row-${PENDING_B}`)).toBeInTheDocument(); + + // --- Persistence failure blocks Core mutation --- + setActionRequestIntentStorageAdapter({ + getItem: () => null, + setItem: () => { + throw new Error('quota exceeded'); + }, + removeItem: () => undefined, + }); + + await user.click(screen.getByTestId(`action-request-row-${PENDING_B}`)); + await user.clear(screen.getByTestId('action-request-reason')); + await user.type(screen.getByTestId('action-request-reason'), 'blocked by storage'); + await user.click(screen.getByTestId('action-request-reject')); + + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'retry-key storage' + ); + expect(mockClient.reject).not.toHaveBeenCalled(); + + // Restore durable storage and complete reject + Core refresh + removal. + setActionRequestIntentStorageAdapter(null); + await user.clear(screen.getByTestId('action-request-reason')); + await user.type(screen.getByTestId('action-request-reason'), 'bridge reject ok'); + await user.click(screen.getByTestId('action-request-reject')); + + await waitFor(() => expect(mockClient.reject).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_B)); + await waitFor(() => + expect(screen.queryByTestId(`action-request-row-${PENDING_B}`)).not.toBeInTheDocument() + ); + expect(await screen.findByTestId('action-request-empty')).toBeInTheDocument(); + + // Bridge contract: every successful mutation performed authoritative get. + expect(mockClient.get).toHaveBeenCalledTimes(2); + expect(mockClient.approve).toHaveBeenCalledTimes(1); + expect(mockClient.reject).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/pages/ActionRequestInbox.test.tsx b/app/src/pages/ActionRequestInbox.test.tsx new file mode 100644 index 0000000000..6d95496a5f --- /dev/null +++ b/app/src/pages/ActionRequestInbox.test.tsx @@ -0,0 +1,804 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { setActiveUserId } from '../store/userScopedStorage'; +import ActionRequestInbox, { + actionRequestIdempotencyStorageKey, + clearAllDecisionIdempotencyKeys, + clearIdempotencyKey, + fingerprintReason, + getOrCreateIdempotencyKey, + type IntentScope, + type IntentStorageAdapter, + resolveActiveUserScope, + setActionRequestIntentStorageAdapter, +} from './ActionRequestInbox'; + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(), + get: vi.fn(), + approve: vi.fn(), + reject: vi.fn(), +})); + +vi.mock('../services/api/coreActionRequestClient', async () => { + const actual = await vi.importActual( + '../services/api/coreActionRequestClient' + ); + return { ...actual, createCoreActionRequestClient: () => mockClient }; +}); + +const PENDING_ID = '33333333-3333-4333-8333-333333333333'; +const TENANT_ID = '20000000-0000-0000-0000-000000000001'; +const ACTIVE_USER = 'test-active-user'; + +const pendingItem = { + action_request: { + id: PENDING_ID, + action_type: 'task.escalate', + risk: 'high', + proposer: { type: 'agent', id: 'openclaw-main' }, + target: { type: 'task', id: 'task-1' }, + policy: { + reasons: ['high risk requires human approval'], + obligations: ['notify owner after decision'], + }, + payload: { summary: 'Escalate missed check-in' }, + links: { + workflow_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + workflow_trace_id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + agent_run_id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + proposal_event_id: 'proposal-evt-1', + idempotency_key: 'create-key-1', + audit_log_ids: ['dddddddd-dddd-4ddd-8ddd-dddddddddddd'], + domain_event_ids: ['domain-evt-1'], + outbox_delivery_ids: ['eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'], + }, + }, + row_version: 2, + id: PENDING_ID, + tenant_id: TENANT_ID, + approval_state: 'pending', + execution_state: 'not_started', + policy_outcome: 'require_approval', + correlation_id: 'corr_1', + created_at: '2026-08-08T12:00:00Z', + updated_at: '2026-08-08T12:00:00Z', +} as const; + +function defaultScope(): IntentScope { + return { tenantId: TENANT_ID, activeUserId: ACTIVE_USER }; +} + +/** Physical key under default user-scoped verified adapter. */ +function physicalStorageKey(tenantId = TENANT_ID, userId = ACTIVE_USER) { + return `${userId}:${actionRequestIdempotencyStorageKey(tenantId)}`; +} + +function storedIdempotencyRecords() { + const raw = window.localStorage.getItem(physicalStorageKey()); + return raw + ? (JSON.parse(raw) as Record< + string, + { key: string; reasonFingerprint?: string; reason?: string } + >) + : {}; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + +function structuredError(code: string, field?: string) { + return { data: { kind: 'YouPetCoreHttpError', youpet: field ? { code, field } : { code } } }; +} + +function memoryStorageAdapter(initial: Record = {}): IntentStorageAdapter { + const map = new Map(Object.entries(initial)); + return { + getItem(key) { + return map.has(key) ? (map.get(key) as string) : null; + }, + setItem(key, value) { + map.set(key, value); + }, + removeItem(key) { + map.delete(key); + }, + }; +} + +describe('ActionRequestInbox', () => { + beforeEach(() => { + mockClient.list.mockReset(); + mockClient.get.mockReset(); + mockClient.approve.mockReset(); + mockClient.reject.mockReset(); + window.localStorage.clear(); + setActionRequestIntentStorageAdapter(null); + setActiveUserId(ACTIVE_USER); + }); + + afterEach(() => { + setActionRequestIntentStorageAdapter(null); + setActiveUserId(null); + vi.restoreAllMocks(); + }); + + it('renders pending request context including links for operator review', async () => { + mockClient.list.mockResolvedValueOnce([pendingItem]); + + render(); + + expect(await screen.findByTestId(`action-request-row-${PENDING_ID}`)).toBeInTheDocument(); + expect(screen.getByTestId('action-request-detail-id')).toHaveTextContent(PENDING_ID); + expect(screen.getByTestId('action-request-detail')).toHaveTextContent('task.escalate'); + expect(screen.getByText(/agent · openclaw-main/)).toBeInTheDocument(); + expect(screen.getByText(/task · task-1/)).toBeInTheDocument(); + expect(screen.getByText('high risk requires human approval')).toBeInTheDocument(); + expect(screen.getByTestId('action-request-link-workflow')).toHaveTextContent( + 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + ); + expect(screen.getByTestId('action-request-link-proposal')).toHaveTextContent('proposal-evt-1'); + expect(screen.getByTestId('action-request-link-audit')).toHaveTextContent( + 'dddddddd-dddd-4ddd-8ddd-dddddddddddd' + ); + expect(screen.getByTestId('action-request-approve')).toBeInTheDocument(); + expect(screen.getByTestId('action-request-reject')).toBeInTheDocument(); + }); + + it('shows empty, loading, and error states including tenant config', async () => { + mockClient.list.mockResolvedValueOnce([]); + const first = render(); + expect(await screen.findByTestId('action-request-empty')).toBeInTheDocument(); + first.unmount(); + + const pendingList = deferred<(typeof pendingItem)[]>(); + mockClient.list.mockReturnValueOnce(pendingList.promise); + const loadingRender = render(); + expect(await screen.findByTestId('action-request-loading')).toBeInTheDocument(); + pendingList.resolve([]); + await waitFor(() => + expect(screen.queryByTestId('action-request-loading')).not.toBeInTheDocument() + ); + loadingRender.unmount(); + + mockClient.list.mockRejectedValueOnce({ + data: { kind: 'YouPetConfigMissing', youpet: { field: 'tenant_id' } }, + }); + render(); + expect(await screen.findByTestId('action-request-error')).toHaveTextContent('YOUPET_TENANT_ID'); + }); + + it('renders empty links when Core omits correlation links', async () => { + mockClient.list.mockResolvedValueOnce([ + { + ...pendingItem, + action_request: { + ...pendingItem.action_request, + links: { + audit_log_ids: [], + domain_event_ids: [], + outbox_delivery_ids: [], + workflow_id: null, + workflow_trace_id: null, + agent_run_id: null, + proposal_event_id: null, + idempotency_key: null, + }, + }, + }, + ]); + render(); + expect(await screen.findByTestId('action-request-links-empty')).toBeInTheDocument(); + }); + + it('approves with reason, row version, stable key, Core refresh, and pending-filter removal', async () => { + const approved = { ...pendingItem, approval_state: 'approved', row_version: 3 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockResolvedValueOnce(approved); + mockClient.get.mockResolvedValueOnce(approved); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'safe to proceed'); + await user.click(screen.getByTestId('action-request-approve')); + + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + expect(mockClient.approve).toHaveBeenCalledWith(PENDING_ID, { + reason: 'safe to proceed', + expectedRowVersion: 2, + idempotencyKey: expect.stringContaining(`youpet-action-request:approve:${PENDING_ID}:`), + }); + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]).toBeUndefined(); + expect(storedIdempotencyRecords()[`reject:${PENDING_ID}`]).toBeUndefined(); + expect(await screen.findByTestId('action-request-empty')).toBeInTheDocument(); + expect(screen.queryByTestId(`action-request-row-${PENDING_ID}`)).not.toBeInTheDocument(); + }); + + it('rejects with reason, expected row version, and Core refresh', async () => { + const rejected = { ...pendingItem, approval_state: 'rejected', row_version: 3 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.reject.mockResolvedValueOnce(rejected); + mockClient.get.mockResolvedValueOnce(rejected); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'too risky'); + await user.click(screen.getByTestId('action-request-reject')); + + await waitFor(() => expect(mockClient.reject).toHaveBeenCalledTimes(1)); + expect(mockClient.reject).toHaveBeenCalledWith(PENDING_ID, { + reason: 'too risky', + expectedRowVersion: 2, + idempotencyKey: expect.stringContaining(`youpet-action-request:reject:${PENDING_ID}:`), + }); + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + expect(storedIdempotencyRecords()[`reject:${PENDING_ID}`]).toBeUndefined(); + }); + + it('reuses the same approve idempotency key across failed retry and remount', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValue(new Error('temporary failure')); + const user = userEvent.setup(); + + const firstRender = render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'retry me'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + const firstKey = mockClient.approve.mock.calls[0]?.[1]?.idempotencyKey as string; + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]?.key).toBe(firstKey); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]?.reasonFingerprint).toBe( + fingerprintReason('retry me') + ); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]?.reason).toBeUndefined(); + + firstRender.unmount(); + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'retry me'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(2)); + expect(mockClient.approve.mock.calls[1]?.[1]?.idempotencyKey).toBe(firstKey); + }); + + it('reuses the same reject idempotency key across failed retry and remount', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.reject.mockRejectedValue(new Error('temporary failure')); + const user = userEvent.setup(); + + const firstRender = render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'reject retry'); + await user.click(screen.getByTestId('action-request-reject')); + await waitFor(() => expect(mockClient.reject).toHaveBeenCalledTimes(1)); + const firstKey = mockClient.reject.mock.calls[0]?.[1]?.idempotencyKey as string; + + firstRender.unmount(); + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'reject retry'); + await user.click(screen.getByTestId('action-request-reject')); + await waitFor(() => expect(mockClient.reject).toHaveBeenCalledTimes(2)); + expect(mockClient.reject.mock.calls[1]?.[1]?.idempotencyKey).toBe(firstKey); + }); + + it('rotates the key when the operator reason changes after failure', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValue(new Error('temporary failure')); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'first reason'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + const firstKey = mockClient.approve.mock.calls[0]?.[1]?.idempotencyKey as string; + + await user.clear(screen.getByTestId('action-request-reason')); + await user.type(screen.getByTestId('action-request-reason'), 'changed reason'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(2)); + const secondKey = mockClient.approve.mock.calls[1]?.[1]?.idempotencyKey as string; + expect(secondKey).not.toBe(firstKey); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]?.reasonFingerprint).toBe( + fingerprintReason('changed reason') + ); + }); + + it('does not reuse an approve key for reject', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValueOnce(new Error('temporary failure')); + mockClient.reject.mockRejectedValueOnce(new Error('temporary failure')); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'decision reason'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + const approveKey = mockClient.approve.mock.calls[0]?.[1]?.idempotencyKey as string; + + await user.click(screen.getByTestId('action-request-reject')); + await waitFor(() => expect(mockClient.reject).toHaveBeenCalledTimes(1)); + const rejectKey = mockClient.reject.mock.calls[0]?.[1]?.idempotencyKey as string; + expect(rejectKey).not.toBe(approveKey); + }); + + it('suppresses duplicate in-flight approve and reject clicks', async () => { + const approved = { ...pendingItem, approval_state: 'approved', row_version: 3 }; + const pendingApprove = deferred(); + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockReturnValueOnce(pendingApprove.promise); + mockClient.get.mockResolvedValue(approved); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'one click only'); + const approveButton = screen.getByTestId('action-request-approve'); + const rejectButton = screen.getByTestId('action-request-reject'); + + await user.click(approveButton); + await waitFor(() => expect(approveButton).toBeDisabled()); + expect(rejectButton).toBeDisabled(); + await user.click(approveButton); + await user.click(rejectButton); + expect(mockClient.approve).toHaveBeenCalledTimes(1); + expect(mockClient.reject).not.toHaveBeenCalled(); + + pendingApprove.resolve(approved); + await waitFor(() => + expect(screen.queryByTestId(`action-request-row-${PENDING_ID}`)).not.toBeInTheDocument() + ); + }); + + it('reloads Core state on concurrency conflict and explains the conflict', async () => { + const refreshed = { ...pendingItem, approval_state: 'approved', row_version: 4 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValueOnce(structuredError('concurrency_conflict')); + mockClient.get.mockResolvedValueOnce(refreshed); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'race'); + await user.click(screen.getByTestId('action-request-approve')); + + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'concurrency_conflict' + ); + expect(screen.getByTestId('action-request-error')).toHaveTextContent('approved'); + expect(screen.getByTestId('action-request-error')).toHaveTextContent('v4'); + await waitFor(() => + expect(screen.queryByTestId(`action-request-row-${PENDING_ID}`)).not.toBeInTheDocument() + ); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]).toBeUndefined(); + }); + + it('handles idempotency_conflict with authoritative refresh', async () => { + const refreshed = { ...pendingItem, approval_state: 'rejected', row_version: 5 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.reject.mockRejectedValueOnce(structuredError('idempotency_conflict')); + mockClient.get.mockResolvedValueOnce(refreshed); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'conflict path'); + await user.click(screen.getByTestId('action-request-reject')); + + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'idempotency_conflict' + ); + }); + + it('keeps the same-intent key when conflict leaves the row pending', async () => { + const stillPending = { ...pendingItem, row_version: 3 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValueOnce(structuredError('concurrency_conflict')); + mockClient.get.mockResolvedValueOnce(stillPending); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'retry later'); + await user.click(screen.getByTestId('action-request-approve')); + + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + const approved = { ...stillPending, approval_state: 'approved', row_version: 4 }; + mockClient.approve.mockResolvedValueOnce(approved); + mockClient.get.mockResolvedValueOnce(approved); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(2)); + expect(mockClient.approve.mock.calls[1]?.[1]?.expectedRowVersion).toBe(3); + }); + + it('hides decision controls for terminal approvals including expired', async () => { + mockClient.list.mockResolvedValueOnce([ + { ...pendingItem, approval_state: 'expired', row_version: 5 }, + ]); + + render(); + expect(await screen.findByTestId('action-request-terminal')).toBeInTheDocument(); + expect(screen.queryByTestId('action-request-approve')).not.toBeInTheDocument(); + }); + + it('requires a non-empty reason before submitting', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.click(screen.getByTestId('action-request-approve')); + + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'non-empty operator reason' + ); + expect(mockClient.approve).not.toHaveBeenCalled(); + }); + + it('maps not-found and validation-style Core codes into stable errors', async () => { + mockClient.list.mockRejectedValueOnce(structuredError('not_found')); + const first = render(); + expect(await screen.findByTestId('action-request-error')).toHaveTextContent('not_found'); + first.unmount(); + + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValueOnce(structuredError('invalid_request')); + const user = userEvent.setup(); + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'validate'); + await user.click(screen.getByTestId('action-request-approve')); + expect(await screen.findByTestId('action-request-error')).toHaveTextContent('invalid_request'); + }); + + it('maps forbidden_consumer_operation Core responses into a stable error', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValueOnce(structuredError('forbidden_consumer_operation')); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'not allowed'); + await user.click(screen.getByTestId('action-request-approve')); + + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'forbidden_consumer_operation' + ); + expect(mockClient.get).not.toHaveBeenCalled(); + }); + + it('fails closed when the initial idempotency key cannot be persisted', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + const user = userEvent.setup(); + + setActionRequestIntentStorageAdapter({ + getItem: () => null, + setItem: () => { + throw new Error('quota exceeded'); + }, + removeItem: () => undefined, + }); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'blocked write'); + await user.click(screen.getByTestId('action-request-approve')); + + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'retry-key storage' + ); + expect(mockClient.approve).not.toHaveBeenCalled(); + expect(mockClient.reject).not.toHaveBeenCalled(); + }); + + it('fails closed on retry when storage read throws without rotating the prior key', async () => { + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockRejectedValue(new Error('ambiguous network')); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'keep key'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + const firstKey = mockClient.approve.mock.calls[0]?.[1]?.idempotencyKey as string; + const before = window.localStorage.getItem(physicalStorageKey()); + expect(before).toContain(firstKey); + + let writeCount = 0; + setActionRequestIntentStorageAdapter({ + getItem() { + throw new Error('transient storage read failure'); + }, + setItem() { + writeCount += 1; + }, + removeItem() { + // no-op + }, + }); + + await user.click(screen.getByTestId('action-request-approve')); + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'retry-key storage' + ); + expect(mockClient.approve).toHaveBeenCalledTimes(1); + expect(writeCount).toBe(0); + + setActionRequestIntentStorageAdapter(null); + expect(window.localStorage.getItem(physicalStorageKey())).toBe(before); + }); + + it('fails closed when no authenticated active user is in scope', async () => { + setActiveUserId(null); + mockClient.list.mockResolvedValue([pendingItem]); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'no user'); + await user.click(screen.getByTestId('action-request-approve')); + + expect(await screen.findByTestId('action-request-error')).toHaveTextContent( + 'retry-key storage' + ); + expect(mockClient.approve).not.toHaveBeenCalled(); + }); + + it('shows a non-blocking warning when cleanup after success cannot rewrite storage', async () => { + const approved = { ...pendingItem, approval_state: 'approved', row_version: 3 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockResolvedValueOnce(approved); + mockClient.get.mockResolvedValueOnce(approved); + const user = userEvent.setup(); + + const backing = new Map(); + let writeCount = 0; + setActionRequestIntentStorageAdapter({ + getItem(key) { + return backing.has(key) ? (backing.get(key) as string) : null; + }, + setItem(key, value) { + writeCount += 1; + if (writeCount >= 2) { + throw new Error('quota exceeded'); + } + backing.set(key, value); + }, + removeItem(key) { + backing.delete(key); + }, + }); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.type(screen.getByTestId('action-request-reason'), 'persist warn'); + await user.click(screen.getByTestId('action-request-approve')); + + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + expect(await screen.findByTestId('action-request-warning')).toHaveTextContent( + 'retry-key storage' + ); + }); + + it('invalidates a stale in-flight list after mutation without leaving refresh stuck', async () => { + const approved = { ...pendingItem, approval_state: 'approved', row_version: 3 }; + const slowList = deferred<(typeof pendingItem)[]>(); + mockClient.list + .mockResolvedValueOnce([pendingItem]) // initial + .mockReturnValueOnce(slowList.promise); // refresh started before approve settles + mockClient.approve.mockResolvedValueOnce(approved); + mockClient.get.mockResolvedValueOnce(approved); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + + const refreshButton = screen.getByTestId('action-request-refresh'); + await user.click(refreshButton); + await waitFor(() => expect(mockClient.list).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(refreshButton).toBeDisabled()); + + await user.type(screen.getByTestId('action-request-reason'), 'stale race'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.approve).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + await waitFor(() => + expect(screen.queryByTestId(`action-request-row-${PENDING_ID}`)).not.toBeInTheDocument() + ); + + // Stale list must not reintroduce the pending row after mutation invalidation. + slowList.resolve([pendingItem]); + await waitFor(() => expect(refreshButton).not.toBeDisabled()); + expect(screen.queryByTestId(`action-request-row-${PENDING_ID}`)).not.toBeInTheDocument(); + expect(refreshButton).toHaveTextContent(/Refresh/i); + }); + + it('keeps terminal rows when filter is all after approve', async () => { + const approved = { ...pendingItem, approval_state: 'approved', row_version: 3 }; + mockClient.list.mockResolvedValue([pendingItem]); + mockClient.approve.mockResolvedValueOnce(approved); + mockClient.get.mockResolvedValueOnce(approved); + const user = userEvent.setup(); + + render(); + await screen.findByTestId(`action-request-row-${PENDING_ID}`); + await user.selectOptions(screen.getByTestId('action-request-filter'), 'all'); + await waitFor(() => expect(mockClient.list).toHaveBeenCalledTimes(2)); + + await user.type(screen.getByTestId('action-request-reason'), 'keep visible'); + await user.click(screen.getByTestId('action-request-approve')); + await waitFor(() => expect(mockClient.get).toHaveBeenCalledWith(PENDING_ID)); + expect(await screen.findByTestId('action-request-terminal')).toBeInTheDocument(); + expect(screen.getByTestId(`action-request-row-${PENDING_ID}`)).toBeInTheDocument(); + }); +}); + +describe('action request idempotency helpers', () => { + beforeEach(() => { + window.localStorage.clear(); + setActionRequestIntentStorageAdapter(null); + setActiveUserId(ACTIVE_USER); + }); + + afterEach(() => { + setActionRequestIntentStorageAdapter(null); + setActiveUserId(null); + }); + + it('binds the stored key to the complete command fingerprint without raw reason', () => { + const scope = defaultScope(); + const first = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'same', 2, scope); + expect(first.persisted).toBe(true); + const second = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'same', 2, scope); + expect(second.key).toBe(first.key); + const rotated = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'different', 2, scope); + expect(rotated.key).not.toBe(first.key); + const raw = window.localStorage.getItem(physicalStorageKey()); + expect(raw).toBeTruthy(); + expect(raw).not.toContain('different'); + expect(raw).not.toContain('same'); + clearIdempotencyKey(PENDING_ID, 'approve', scope); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]).toBeUndefined(); + }); + + it('separates equal-length FNV-colliding reasons with SHA-256 fingerprints', () => { + // Known 32-bit FNV-1a collision pair (len 8) reported by ac-codex review. + const a = 'tgvipcjq'; + const b = 'tonydgba'; + expect(fingerprintReason(a)).not.toBe(fingerprintReason(b)); + expect(fingerprintReason(a)).toMatch(/^sha256:[0-9a-f]{64}$/); + + const scope = defaultScope(); + const first = getOrCreateIdempotencyKey(PENDING_ID, 'approve', a, 1, scope); + const second = getOrCreateIdempotencyKey(PENDING_ID, 'approve', b, 1, scope); + expect(first.persisted).toBe(true); + expect(second.persisted).toBe(true); + expect(second.key).not.toBe(first.key); + }); + + it('scopes intent stores by tenant under the active user namespace', () => { + const a = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'reason', 1, { + tenantId: 'tenant-a', + activeUserId: ACTIVE_USER, + }); + const b = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'reason', 1, { + tenantId: 'tenant-b', + activeUserId: ACTIVE_USER, + }); + expect(a.key).not.toBe(b.key); + expect(window.localStorage.getItem(physicalStorageKey('tenant-a'))).toContain(a.key); + expect(window.localStorage.getItem(physicalStorageKey('tenant-b'))).toContain(b.key); + }); + + it('does not share intent storage across active users', () => { + const first = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'reason', 1, defaultScope()); + expect(first.persisted).toBe(true); + + setActiveUserId('other-user'); + const second = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'reason', 1, { + tenantId: TENANT_ID, + activeUserId: 'other-user', + }); + expect(second.persisted).toBe(true); + expect(second.key).not.toBe(first.key); + expect(window.localStorage.getItem(physicalStorageKey(TENANT_ID, ACTIVE_USER))).toContain( + first.key + ); + expect(window.localStorage.getItem(physicalStorageKey(TENANT_ID, 'other-user'))).toContain( + second.key + ); + }); + + it('returns persisted=false when the initial write fails', () => { + setActionRequestIntentStorageAdapter({ + getItem: () => null, + setItem: () => { + throw new Error('boom'); + }, + removeItem: () => undefined, + }); + const result = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'x', 1, defaultScope()); + expect(result.persisted).toBe(false); + }); + + it('does not rotate an existing retry key when storage read throws', () => { + const scope = defaultScope(); + const first = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'same intent', 2, scope); + expect(first.persisted).toBe(true); + const before = window.localStorage.getItem(physicalStorageKey()); + expect(before).toContain(first.key); + + let writeCount = 0; + setActionRequestIntentStorageAdapter({ + getItem() { + throw new Error('transient storage read failure'); + }, + setItem() { + writeCount += 1; + }, + removeItem() { + // no-op + }, + }); + + const retry = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'same intent', 2, scope); + expect(retry.persisted).toBe(false); + expect(retry.key).toBe(''); + expect(writeCount).toBe(0); + + // Prior durable intent must remain untouched under the real user-scoped path. + setActionRequestIntentStorageAdapter(null); + expect(window.localStorage.getItem(physicalStorageKey())).toBe(before); + const recovered = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'same intent', 2, scope); + expect(recovered.persisted).toBe(true); + expect(recovered.key).toBe(first.key); + }); + + it('returns persisted=false without an active user scope', () => { + setActiveUserId(null); + expect(resolveActiveUserScope()).toBeNull(); + const result = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'x', 1, { + tenantId: TENANT_ID, + activeUserId: '', + }); + expect(result.persisted).toBe(false); + }); + + it('clears both approve and reject keys on terminal cleanup', () => { + const scope = defaultScope(); + getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'a', 1, scope); + getOrCreateIdempotencyKey(PENDING_ID, 'reject', 'b', 1, scope); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]).toBeDefined(); + expect(storedIdempotencyRecords()[`reject:${PENDING_ID}`]).toBeDefined(); + expect(clearAllDecisionIdempotencyKeys(PENDING_ID, scope)).toBe(true); + expect(storedIdempotencyRecords()[`approve:${PENDING_ID}`]).toBeUndefined(); + expect(storedIdempotencyRecords()[`reject:${PENDING_ID}`]).toBeUndefined(); + }); + + it('accepts an injected adapter for deterministic storage tests', () => { + const adapter = memoryStorageAdapter(); + setActionRequestIntentStorageAdapter(adapter); + const scope = { tenantId: 't', activeUserId: ACTIVE_USER }; + const first = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'memo', 9, scope); + expect(first.persisted).toBe(true); + const second = getOrCreateIdempotencyKey(PENDING_ID, 'approve', 'memo', 9, scope); + expect(second.key).toBe(first.key); + }); +}); diff --git a/app/src/pages/ActionRequestInbox.tsx b/app/src/pages/ActionRequestInbox.tsx new file mode 100644 index 0000000000..a36ceac158 --- /dev/null +++ b/app/src/pages/ActionRequestInbox.tsx @@ -0,0 +1,937 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useT } from '../lib/i18n/I18nContext'; +import { + type CoreActionRequestLifecycleEnvelope, + createCoreActionRequestClient, + extractYoupetErrorCode, + extractYoupetErrorField, +} from '../services/api/coreActionRequestClient'; +import { + createVerifiedUserScopedStorage, + getActiveUserId, + type VerifiedUserScopedStorage, +} from '../store/userScopedStorage'; + +type DecisionAction = 'approve' | 'reject'; +type PendingDecisions = Record; + +/** Logical key (physical path is user-scoped via verified storage). */ +const IDEMPOTENCY_STORAGE_PREFIX = 'openhuman.youpet.action_request.idempotency.v4'; +const DEFAULT_FILTER = 'pending'; + +export interface IntentStorageAdapter { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +export interface IntentScope { + tenantId: string; + /** OpenHuman active user id — never Core operator_user_id. */ + activeUserId: string; +} + +const defaultVerifiedStorage: VerifiedUserScopedStorage = createVerifiedUserScopedStorage(); + +/** + * Default adapter: repository user-scoping + verified durable writes. + * Fail closed when no authenticated active user is set. + */ +const defaultUserScopedAdapter: IntentStorageAdapter = { + getItem(key) { + return defaultVerifiedStorage.getItem(key); + }, + setItem(key, value) { + defaultVerifiedStorage.setItem(key, value); + }, + removeItem(key) { + defaultVerifiedStorage.removeItem(key); + }, +}; + +/** Overridable for deterministic fault-injection in tests. */ +let storageAdapter: IntentStorageAdapter = defaultUserScopedAdapter; + +export function setActionRequestIntentStorageAdapter(adapter: IntentStorageAdapter | null): void { + storageAdapter = adapter ?? defaultUserScopedAdapter; +} + +/** Logical storage key partitioned by tenant (user partition is physical). */ +export function actionRequestIdempotencyStorageKey(tenantId: string): string { + return `${IDEMPOTENCY_STORAGE_PREFIX}:${tenantId}`; +} + +/** + * Active OpenHuman user used for durable intent scoping. + * Returns null when unauthenticated — callers must fail closed (no shared fallback). + */ +export function resolveActiveUserScope(): string | null { + const id = getActiveUserId(); + if (!id || !id.trim()) return null; + return id; +} + +interface StoredIntent { + key: string; + action: DecisionAction; + actionRequestId: string; + /** SHA-256 fingerprint of the operator reason — never the raw reason text. */ + reasonFingerprint: string; + expectedRowVersion: number; +} + +type IntentStore = Record; + +function isStoredIntent(value: unknown): value is StoredIntent { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + typeof record.key === 'string' && + record.key.trim().length > 0 && + (record.action === 'approve' || record.action === 'reject') && + typeof record.actionRequestId === 'string' && + typeof record.reasonFingerprint === 'string' && + record.reasonFingerprint.trim().length > 0 && + typeof record.expectedRowVersion === 'number' && + Number.isFinite(record.expectedRowVersion) + ); +} + +function makeIdempotencyStorageId(actionRequestId: string, action: DecisionAction) { + return `${action}:${actionRequestId}`; +} + +function normalizeReason(reason: string) { + return reason.trim(); +} + +/** + * Collision-resistant fingerprint so raw operator intent is not persisted, + * while distinct reasons never share an idempotency identity. + */ +export function fingerprintReason(reason: string): string { + const normalized = normalizeReason(reason); + const digest = sha256(new TextEncoder().encode(normalized)); + return `sha256:${bytesToHex(digest)}`; +} + +function generateIdempotencyKey(actionRequestId: string, action: DecisionAction) { + const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2); + return `youpet-action-request:${action}:${actionRequestId}:${random}`; +} + +function readIdempotencyStore(tenantId: string): IntentStore { + const storageKey = actionRequestIdempotencyStorageKey(tenantId); + try { + const raw = storageAdapter.getItem(storageKey); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const next: IntentStore = {}; + for (const [id, value] of Object.entries(parsed)) { + if (typeof id === 'string' && isStoredIntent(value)) { + next[id] = value; + } + } + return next; + } catch { + throw new Error('idempotency_storage_read_failed'); + } +} + +/** Persist intent store; returns false when the adapter cannot durable-write. */ +function writeIdempotencyStore(tenantId: string, store: IntentStore): boolean { + const storageKey = actionRequestIdempotencyStorageKey(tenantId); + const serialized = JSON.stringify(store); + try { + storageAdapter.setItem(storageKey, serialized); + // Verify round-trip so silent no-op adapters still fail closed. + const roundTrip = storageAdapter.getItem(storageKey); + if (roundTrip !== serialized) { + return false; + } + return true; + } catch { + return false; + } +} + +export interface IdempotencyKeyResult { + key: string; + /** True only when the key is durable in scoped storage. */ + persisted: boolean; +} + +function requireScope(scope?: IntentScope): IntentScope | null { + if (scope?.tenantId && scope.activeUserId) return scope; + const activeUserId = resolveActiveUserScope(); + if (!activeUserId) return null; + if (scope?.tenantId) { + return { tenantId: scope.tenantId, activeUserId }; + } + return null; +} + +/** + * Return a stable idempotency key for the same complete operator intent. + * Rotates when reason fingerprint or expected row version changes. + * Callers must fail closed (do not call Core) when `persisted` is false. + * + * Requires an authenticated active-user scope; no shared `local-operator` fallback. + */ +export function getOrCreateIdempotencyKey( + actionRequestId: string, + action: DecisionAction, + reason: string, + expectedRowVersion: number, + scope: IntentScope +): IdempotencyKeyResult { + const resolved = requireScope(scope); + if (!resolved) { + return { key: '', persisted: false }; + } + // Enforce that helper calls cannot write under a mismatched user identity + // when using the default user-scoped adapter (active user drives the namespace). + const liveUser = resolveActiveUserScope(); + if ( + liveUser && + liveUser !== resolved.activeUserId && + storageAdapter === defaultUserScopedAdapter + ) { + return { key: '', persisted: false }; + } + + let store: IntentStore; + try { + store = readIdempotencyStore(resolved.tenantId); + } catch { + return { key: '', persisted: false }; + } + const id = makeIdempotencyStorageId(actionRequestId, action); + const reasonFingerprint = fingerprintReason(reason); + const existing = store[id]; + if ( + existing && + existing.reasonFingerprint === reasonFingerprint && + existing.expectedRowVersion === expectedRowVersion && + existing.action === action && + existing.actionRequestId === actionRequestId + ) { + return { key: existing.key, persisted: true }; + } + const key = generateIdempotencyKey(actionRequestId, action); + const nextStore: IntentStore = { + ...store, + [id]: { key, action, actionRequestId, reasonFingerprint, expectedRowVersion }, + }; + const persisted = writeIdempotencyStore(resolved.tenantId, nextStore); + return { key, persisted }; +} + +export function clearIdempotencyKey( + actionRequestId: string, + action: DecisionAction, + scope: IntentScope +): boolean { + const resolved = requireScope(scope); + if (!resolved) return false; + let store: IntentStore; + try { + store = readIdempotencyStore(resolved.tenantId); + } catch { + return false; + } + const next = { ...store }; + delete next[makeIdempotencyStorageId(actionRequestId, action)]; + return writeIdempotencyStore(resolved.tenantId, next); +} + +/** Clear both approve and reject intent keys for a request (terminal cleanup). */ +export function clearAllDecisionIdempotencyKeys( + actionRequestId: string, + scope: IntentScope +): boolean { + const resolved = requireScope(scope); + if (!resolved) return false; + let store: IntentStore; + try { + store = readIdempotencyStore(resolved.tenantId); + } catch { + return false; + } + const next = { ...store }; + delete next[makeIdempotencyStorageId(actionRequestId, 'approve')]; + delete next[makeIdempotencyStorageId(actionRequestId, 'reject')]; + return writeIdempotencyStore(resolved.tenantId, next); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null; +} + +function readIdList(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map(item => { + if (typeof item === 'string' && item.trim()) return item; + if (item && typeof item === 'object' && 'toString' in item) { + const text = String(item); + return text.trim() ? text : null; + } + return null; + }) + .filter((item): item is string => Boolean(item)); +} + +function formatDate(value: string | null | undefined, noneLabel: string) { + if (!value) return noneLabel; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); +} + +function isPending(item: CoreActionRequestLifecycleEnvelope) { + return item.approval_state === 'pending'; +} + +function isTerminalApproval(item: CoreActionRequestLifecycleEnvelope) { + return ( + item.approval_state === 'approved' || + item.approval_state === 'rejected' || + item.approval_state === 'expired' || + item.approval_state === 'not_required' + ); +} + +function matchesFilter( + item: CoreActionRequestLifecycleEnvelope, + filter: 'pending' | 'all' +): boolean { + if (filter === 'all') return true; + return isPending(item); +} + +export default function ActionRequestInbox() { + const { t } = useT(); + const client = useMemo(() => createCoreActionRequestClient(), []); + const [items, setItems] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [warning, setWarning] = useState(null); + const [reasonById, setReasonById] = useState>({}); + const [pending, setPending] = useState({}); + const [filter, setFilter] = useState<'pending' | 'all'>(DEFAULT_FILTER); + const inFlightRef = useRef>(new Set()); + /** + * Data epoch: discard stale list *results* when a newer load or mutation + * supersedes them. Intentionally separate from loading/refresh flag ownership + * so invalidation never leaves the UI stuck refreshing. + */ + const dataEpochRef = useRef(0); + /** Owner token for the initial-load spinner. */ + const loadingOwnerRef = useRef(0); + /** Owner token for the refresh button busy state. */ + const refreshingOwnerRef = useRef(0); + const filterRef = useRef(filter); + filterRef.current = filter; + + const selected = useMemo( + () => items.find(item => item.id === selectedId) ?? null, + [items, selectedId] + ); + + const mapError = useCallback( + (err: unknown): string => { + const code = extractYoupetErrorCode(err); + const field = extractYoupetErrorField(err); + if (field === 'tenant_id' || code === 'tenant_config_missing') { + return t( + 'actionRequest.tenantConfigMissing', + 'YouPet tenant is not configured. Set YOUPET_TENANT_ID or youpet.tenant_id before listing ActionRequests.' + ); + } + if (code) { + return t('actionRequest.errorWithCode', 'Action request failed ({code}).').replace( + '{code}', + code + ); + } + return t( + 'actionRequest.requestFailed', + 'Action request failed. Check Core configuration and try again.' + ); + }, + [t] + ); + + const applyAuthoritativeItem = useCallback( + (item: CoreActionRequestLifecycleEnvelope, activeFilter: 'pending' | 'all') => { + if (!matchesFilter(item, activeFilter)) { + setItems(current => current.filter(row => row.id !== item.id)); + setSelectedId(current => (current === item.id ? null : current)); + return; + } + setItems(current => { + const exists = current.some(row => row.id === item.id); + if (!exists) return [item, ...current]; + return current.map(row => (row.id === item.id ? item : row)); + }); + }, + [] + ); + + const load = useCallback( + async (mode: 'initial' | 'refresh' = 'initial') => { + const dataEpoch = ++dataEpochRef.current; + const loadOwner = mode === 'initial' ? ++loadingOwnerRef.current : loadingOwnerRef.current; + const refreshOwner = + mode === 'refresh' ? ++refreshingOwnerRef.current : refreshingOwnerRef.current; + if (mode === 'initial') setLoading(true); + else setRefreshing(true); + setError(null); + try { + const listed = await client.list({ + approvalState: filter === 'pending' ? 'pending' : undefined, + limit: 50, + }); + // Stale data only — cleanup still runs in finally via owner tokens. + if (dataEpoch !== dataEpochRef.current) return; + setItems(listed); + setSelectedId(current => { + if (current && listed.some(item => item.id === current)) return current; + return listed[0]?.id ?? null; + }); + } catch (err) { + if (dataEpoch !== dataEpochRef.current) return; + setError(mapError(err)); + } finally { + // Generation counters discard stale data, not stale cleanup. + if (mode === 'initial' && loadOwner === loadingOwnerRef.current) { + setLoading(false); + } + if (mode === 'refresh' && refreshOwner === refreshingOwnerRef.current) { + setRefreshing(false); + } + } + }, + [client, filter, mapError] + ); + + useEffect(() => { + void load('initial'); + }, [load]); + + const refreshOne = useCallback( + async (actionRequestId: string) => { + const fresh = await client.get(actionRequestId); + applyAuthoritativeItem(fresh, filterRef.current); + return fresh; + }, + [applyAuthoritativeItem, client] + ); + + const submitDecision = useCallback( + async (item: CoreActionRequestLifecycleEnvelope, action: DecisionAction) => { + const flightKey = `${action}:${item.id}`; + if (inFlightRef.current.has(flightKey) || pending[item.id]) return; + if (!isPending(item)) { + setError( + t('actionRequest.terminalReadOnly', 'This request is no longer pending and is read-only.') + ); + return; + } + const reason = normalizeReason(reasonById[item.id] ?? ''); + if (!reason) { + setError(t('actionRequest.reasonRequired', 'A non-empty operator reason is required.')); + return; + } + + const activeUserId = resolveActiveUserScope(); + if (!activeUserId) { + setError( + t( + 'actionRequest.storageUnavailable', + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.' + ) + ); + return; + } + const scope: IntentScope = { tenantId: item.tenant_id, activeUserId }; + + // Fail closed: only call Core when the intent key is durably persisted. + let idempotencyKey: string; + let persisted: boolean; + try { + ({ key: idempotencyKey, persisted } = getOrCreateIdempotencyKey( + item.id, + action, + reason, + item.row_version, + scope + )); + } catch { + setError( + t( + 'actionRequest.storageUnavailable', + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.' + ) + ); + return; + } + if (!persisted || !idempotencyKey) { + setError( + t( + 'actionRequest.storageUnavailable', + 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.' + ) + ); + return; + } + + inFlightRef.current.add(flightKey); + setPending(current => ({ ...current, [item.id]: action })); + setError(null); + setWarning(null); + + // Invalidate outstanding list *data* only. Do not steal loading/refresh + // owner tokens — those finally blocks still clear their own busy flags. + dataEpochRef.current += 1; + + try { + const params = { reason, expectedRowVersion: item.row_version, idempotencyKey }; + const updated = + action === 'approve' + ? await client.approve(item.id, params) + : await client.reject(item.id, params); + + // Immediate local apply for responsiveness, then authoritative Core get. + applyAuthoritativeItem(updated, filterRef.current); + setReasonById(current => { + const next = { ...current }; + delete next[item.id]; + return next; + }); + + let authoritative = updated; + try { + authoritative = await client.get(item.id); + applyAuthoritativeItem(authoritative, filterRef.current); + } catch { + // Mutation already succeeded; keep the response snapshot. + setWarning( + t( + 'actionRequest.refreshAfterMutationFailed', + 'Decision applied, but an authoritative Core refresh failed. Use Refresh to reload.' + ) + ); + } + + if (isTerminalApproval(authoritative)) { + const cleared = clearAllDecisionIdempotencyKeys(item.id, scope); + if (!cleared) { + setWarning( + t( + 'actionRequest.storageWarning', + 'Local retry-key storage is unavailable; retry safety may be limited for this browser session.' + ) + ); + } + } + } catch (err) { + const code = extractYoupetErrorCode(err); + if (code === 'concurrency_conflict' || code === 'idempotency_conflict') { + try { + const fresh = await refreshOne(item.id); + setError( + t( + 'actionRequest.conflictRefresh', + 'State changed ({code}). Reloaded from Core: {state} v{version}.' + ) + .replace('{code}', code) + .replace('{state}', fresh.approval_state) + .replace('{version}', String(fresh.row_version)) + ); + if (isTerminalApproval(fresh)) { + clearAllDecisionIdempotencyKeys(item.id, scope); + } + } catch { + setError( + t( + 'actionRequest.conflictRefreshFailed', + 'State conflict ({code}), and refresh from Core failed.' + ).replace('{code}', code ?? 'conflict') + ); + } + } else { + setError(mapError(err)); + } + } finally { + inFlightRef.current.delete(flightKey); + setPending(current => { + const next = { ...current }; + delete next[item.id]; + return next; + }); + } + }, + [applyAuthoritativeItem, client, mapError, pending, reasonById, refreshOne, t] + ); + + // Clear both action keys when loading a terminal request (stale intent hygiene). + useEffect(() => { + if (!selected || !isTerminalApproval(selected)) return; + const activeUserId = resolveActiveUserScope(); + if (!activeUserId) return; + clearAllDecisionIdempotencyKeys(selected.id, { tenantId: selected.tenant_id, activeUserId }); + }, [selected]); + + const doc = asRecord(selected?.action_request); + const proposer = asRecord(doc?.proposer); + const target = asRecord(doc?.target); + const policy = asRecord(doc?.policy); + const payload = asRecord(doc?.payload); + const links = asRecord(doc?.links); + const reasons = Array.isArray(policy?.reasons) + ? policy.reasons.filter((r): r is string => typeof r === 'string') + : []; + const obligations = Array.isArray(policy?.obligations) + ? policy.obligations.filter((r): r is string => typeof r === 'string') + : []; + const auditLogIds = readIdList(links?.audit_log_ids); + const domainEventIds = readIdList(links?.domain_event_ids); + const outboxDeliveryIds = readIdList(links?.outbox_delivery_ids); + const workflowId = readString(links?.workflow_id); + const workflowTraceId = readString(links?.workflow_trace_id); + const agentRunId = readString(links?.agent_run_id); + const proposalEventId = readString(links?.proposal_event_id); + const linksIdempotencyKey = readString(links?.idempotency_key); + const hasLinks = + Boolean(links) && + (auditLogIds.length > 0 || + domainEventIds.length > 0 || + outboxDeliveryIds.length > 0 || + Boolean(workflowId) || + Boolean(workflowTraceId) || + Boolean(agentRunId) || + Boolean(proposalEventId) || + Boolean(linksIdempotencyKey)); + + return ( +
+
+
+

+ {t('actionRequest.eyebrow')} +

+

{t('actionRequest.title')}

+

{t('actionRequest.subtitle')}

+
+
+ + + +
+
+ + {error ? ( +
+ {error} +
+ ) : null} + + {warning ? ( +
+ {warning} +
+ ) : null} + + {loading ? ( +

+ {t('actionRequest.loading')} +

+ ) : null} + + {!loading && items.length === 0 ? ( +

+ {t('actionRequest.empty')} +

+ ) : null} + +
+
    + {items.map(item => { + const active = item.id === selectedId; + return ( +
  • + +
  • + ); + })} +
+ +
+ {!selected ? ( +

{t('actionRequest.selectPrompt')}

+ ) : ( +
+
+ {selected.id} + + {t('actionRequest.rowVersion')}: {selected.row_version} + + + {t('actionRequest.approval')}: {selected.approval_state} + + + {t('actionRequest.execution')}: {selected.execution_state} + +
+ +
+
{t('actionRequest.actionType')}
+
{readString(doc?.action_type) ?? t('actionRequest.none')}
+
{t('actionRequest.risk')}
+
{readString(doc?.risk) ?? t('actionRequest.none')}
+
{t('actionRequest.proposer')}
+
+ {readString(proposer?.type) ?? t('actionRequest.none')} + {readString(proposer?.id) ? ` · ${readString(proposer?.id)}` : ''} +
+
{t('actionRequest.target')}
+
+ {readString(target?.type) ?? t('actionRequest.none')} + {readString(target?.id) ? ` · ${readString(target?.id)}` : ''} +
+
{t('actionRequest.policyOutcome')}
+
{selected.policy_outcome}
+
{t('actionRequest.correlation')}
+
{selected.correlation_id}
+
{t('actionRequest.updated')}
+
{formatDate(selected.updated_at, t('actionRequest.none'))}
+
+ + {reasons.length > 0 ? ( +
+

+ {t('actionRequest.reasons')} +

+
    + {reasons.map(reason => ( +
  • {reason}
  • + ))} +
+
+ ) : null} + + {obligations.length > 0 ? ( +
+

+ {t('actionRequest.obligations')} +

+
    + {obligations.map(item => ( +
  • {item}
  • + ))} +
+
+ ) : null} + +
+

+ {t('actionRequest.links', 'Links')} +

+ {hasLinks ? ( +
+ {workflowId ? ( + <> +
+ {t('actionRequest.links.workflowId', 'Workflow ID')} +
+
{workflowId}
+ + ) : null} + {workflowTraceId ? ( + <> +
+ {t('actionRequest.links.workflowTraceId', 'Workflow trace ID')} +
+
{workflowTraceId}
+ + ) : null} + {agentRunId ? ( + <> +
+ {t('actionRequest.links.agentRunId', 'Agent run ID')} +
+
{agentRunId}
+ + ) : null} + {proposalEventId ? ( + <> +
+ {t('actionRequest.links.proposalEventId', 'Proposal event ID')} +
+
{proposalEventId}
+ + ) : null} + {linksIdempotencyKey ? ( + <> +
+ {t('actionRequest.links.idempotencyKey', 'Idempotency key')} +
+
{linksIdempotencyKey}
+ + ) : null} + {auditLogIds.length > 0 ? ( + <> +
+ {t('actionRequest.links.auditLogIds', 'Audit log IDs')} +
+
{auditLogIds.join(', ')}
+ + ) : null} + {domainEventIds.length > 0 ? ( + <> +
+ {t('actionRequest.links.domainEventIds', 'Domain event IDs')} +
+
+ {domainEventIds.join(', ')} +
+ + ) : null} + {outboxDeliveryIds.length > 0 ? ( + <> +
+ {t('actionRequest.links.outboxDeliveryIds', 'Outbox delivery IDs')} +
+
+ {outboxDeliveryIds.join(', ')} +
+ + ) : null} +
+ ) : ( +

+ {t( + 'actionRequest.linksEmpty', + 'No correlation links recorded on this request.' + )} +

+ )} +
+ + {payload ? ( +
+

+ {t('actionRequest.payload')} +

+
+                    {JSON.stringify(payload, null, 2)}
+                  
+
+ ) : null} + + {isPending(selected) ? ( +
+ +