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();
+ }
+ }}>
+
+
+
+
+
+
+ {t('registries.drawer.eyebrow')}
+
+
+ {title}
+
+
+
+
+
+
+
{children}
+
+
,
+ 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 (
+
+ );
+}
+
+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 (
+
+
+
+ {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) ? (
+
+
+
+ ) : (
+
+ {t('actionRequest.terminalReadOnly')}
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/pages/Home.tsx b/app/src/pages/Home.tsx
index f4d170a404..411c80bc3c 100644
--- a/app/src/pages/Home.tsx
+++ b/app/src/pages/Home.tsx
@@ -16,6 +16,43 @@ import { resolveUserName } from '../utils/userName';
/** @deprecated Use `resolveUserName` from `utils/userName`. Kept for back-compat. */
export const resolveHomeUserName = resolveUserName;
+type TranslateFn = (key: string, fallback?: string) => string;
+
+function registryCardCopy(
+ t: TranslateFn,
+ blocking: 'internet-offline' | 'core-unreachable' | 'backend-only' | 'ok',
+ coreError: string | undefined
+) {
+ if (blocking === 'core-unreachable') {
+ const normalized = coreError?.toLowerCase() ?? '';
+ if (normalized.includes('config missing')) {
+ return {
+ title: t('home.coreRegistriesBlockedTitle'),
+ description: t('home.coreRegistriesBlockedMissingDescription'),
+ };
+ }
+ if (normalized.includes('config invalid')) {
+ return {
+ title: t('home.coreRegistriesBlockedTitle'),
+ description: t('home.coreRegistriesBlockedInvalidDescription'),
+ };
+ }
+ return {
+ title: t('home.coreRegistriesBlockedTitle'),
+ description: t('home.coreRegistriesBlockedBridgeDescription'),
+ };
+ }
+
+ if (blocking === 'internet-offline') {
+ return {
+ title: t('home.coreRegistriesBlockedTitle'),
+ description: t('home.coreRegistriesBlockedOfflineDescription'),
+ };
+ }
+
+ return { title: t('home.coreRegistries'), description: t('home.coreRegistriesDescription') };
+}
+
const Home = () => {
const { t } = useT();
const { user } = useUser();
@@ -38,6 +75,7 @@ const Home = () => {
// failure mode now has its own copy so the user knows *which* link is
// broken instead of seeing a single conflated "device offline" line.
const blocking = useAppSelector(selectBlockingState);
+ const connectivityErrors = useAppSelector(state => state.connectivity.lastError);
const [isRestartingCore, setIsRestartingCore] = useState(false);
const [restartError, setRestartError] = useState(null);
@@ -67,6 +105,7 @@ const Home = () => {
'core-unreachable': t('home.statusCoreUnreachable'),
'internet-offline': t('home.statusInternetOffline'),
}[blocking];
+ const registriesCard = registryCardCopy(t, blocking, connectivityErrors.core);
// Open in-app chat.
const handleStartCooking = async () => {
@@ -212,6 +251,92 @@ const Home = () => {
+
+
+
+
+
+
{/* Next steps — compact directory of where to go next */}
diff --git a/app/src/pages/Workbench.test.tsx b/app/src/pages/Workbench.test.tsx
new file mode 100644
index 0000000000..b92775f381
--- /dev/null
+++ b/app/src/pages/Workbench.test.tsx
@@ -0,0 +1,1053 @@
+import { fireEvent, render, screen, waitFor, within } 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 Workbench, {
+ setWorkbenchIntentStorageAdapter,
+ workbenchIdempotencyStorageKey,
+} from './Workbench';
+
+const mockClient = vi.hoisted(() => ({
+ listAlerts: vi.fn(),
+ ackAlert: vi.fn(),
+ resolveAlert: vi.fn(),
+ getAlertTrace: vi.fn(),
+}));
+
+const ACTIVE_USER = 'workbench-user-a';
+
+vi.mock('../services/api/coreWorkbenchClient', () => ({
+ createCoreWorkbenchClient: () => mockClient,
+}));
+
+const baseAlert = {
+ id: 'alert-1',
+ alert_type: 'missed_checkin',
+ severity: 'critical',
+ related_type: 'task_instance',
+ related_id: 'task-1',
+ status: 'open',
+ summary: 'Buddy missed a check-in.',
+ created_at: '2026-06-01T00:00:00Z',
+ acknowledged_at: null,
+ resolved_at: null,
+ context: {
+ pet: {
+ id: 'pet-1',
+ name: 'Mochi',
+ species: 'cat',
+ breed: 'Exotic Shorthair',
+ status: 'active',
+ },
+ owner: { id: 'owner-1', name: 'Owner A', phone: '18800000001', status: 'active' },
+ health_plan: {
+ id: 'plan-1',
+ title: 'Daily check-in',
+ plan_type: 'checkin',
+ status: 'active',
+ openclaw_flow_id: 'flow-plan-1',
+ },
+ task: {
+ id: 'task-1',
+ status: 'missed',
+ due_at: '2026-06-01T10:01:00Z',
+ missed_count: 2,
+ openclaw_flow_id: 'flow-task-1',
+ },
+ latest_checkin: {
+ id: 'checkin-1',
+ submitted_at: '2026-06-01T10:10:00Z',
+ submitted_by: 'owner-1',
+ text: 'Looks normal.',
+ status_tags: ['normal'],
+ },
+ },
+} as const;
+
+const baseTrace = {
+ alert_id: 'alert-1',
+ workflow: {
+ type: 'health_plan',
+ id: 'plan-1',
+ task_id: 'task-1',
+ openclaw_flow_id: 'flow-plan-1',
+ },
+ partial: true,
+ warnings: [
+ { code: 'trace_truncated', message: 'Trace limited to 50 entries', source: 'event_outbox' },
+ ],
+ entries: [
+ {
+ id: 'health-plan:plan-1:state',
+ occurred_at: '2026-05-31T23:59:00Z',
+ kind: 'health_plan_state',
+ source: 'health_plans',
+ title: 'Health plan active',
+ detail: 'Daily check-in',
+ actor: null,
+ related_type: 'health_plan',
+ related_id: 'plan-1',
+ severity: null,
+ metadata: { status: 'active' },
+ },
+ {
+ id: 'alert:alert-1',
+ occurred_at: '2026-06-01T00:00:00Z',
+ kind: 'alert_created',
+ source: 'alerts',
+ title: 'Alert created',
+ detail: 'Critical missed check-in alert.',
+ actor: { type: 'system', id: 'core' },
+ related_type: 'task_instance',
+ related_id: 'task-1',
+ severity: 'critical',
+ metadata: { alert_type: 'missed_checkin', tags: ['late', 'critical'] },
+ },
+ {
+ id: 'audit:nack-1',
+ occurred_at: '2026-06-01T00:01:00Z',
+ kind: 'delivery_failed',
+ source: 'audit_logs',
+ title: 'Delivery failed; retry scheduled',
+ detail: 'provider timeout',
+ actor: { type: 'agent', id: 'openclaw-youpet-consumer' },
+ related_type: 'event_outbox',
+ related_id: 'event-1',
+ severity: null,
+ metadata: { consumer: 'openclaw', attempts: 1 },
+ },
+ {
+ id: 'audit:ack-1',
+ occurred_at: '2026-06-01T00:02:00Z',
+ kind: 'delivery_succeeded',
+ source: 'audit_logs',
+ title: 'Delivery succeeded',
+ detail: null,
+ actor: { type: 'agent', id: 'openclaw-youpet-consumer' },
+ related_type: 'event_outbox',
+ related_id: 'event-2',
+ severity: null,
+ metadata: { consumer: 'openclaw', attempts: 0, recovered: false },
+ },
+ {
+ id: 'audit:ack-2',
+ occurred_at: '2026-06-01T00:03:00Z',
+ kind: 'delivery_recovered',
+ source: 'audit_logs',
+ title: 'Delivery recovered',
+ detail: null,
+ actor: { type: 'agent', id: 'openclaw-youpet-consumer' },
+ related_type: 'event_outbox',
+ related_id: 'event-1',
+ severity: null,
+ metadata: { consumer: 'openclaw', attempts: 1, recovered: true },
+ },
+ ],
+};
+
+function storedIdempotencyKeys() {
+ const raw = window.localStorage.getItem(physicalStorageKey());
+ return raw ? (JSON.parse(raw) as Record) : {};
+}
+
+function physicalStorageKey(userId = ACTIVE_USER) {
+ return `${userId}:${workbenchIdempotencyStorageKey}`;
+}
+
+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 };
+}
+
+type AlertList = Array & { id: string; summary: string }>;
+
+describe('Workbench', () => {
+ beforeEach(() => {
+ mockClient.listAlerts.mockReset();
+ mockClient.ackAlert.mockReset();
+ mockClient.resolveAlert.mockReset();
+ mockClient.getAlertTrace.mockReset();
+ setWorkbenchIntentStorageAdapter(null);
+ setActiveUserId(ACTIVE_USER);
+ window.localStorage.clear();
+ });
+
+ afterEach(() => {
+ setWorkbenchIntentStorageAdapter(null);
+ setActiveUserId(null);
+ });
+
+ it('renders alerts with operational context', async () => {
+ mockClient.listAlerts.mockResolvedValueOnce([baseAlert]);
+
+ render();
+
+ expect(await screen.findByText('Buddy missed a check-in.')).toBeInTheDocument();
+ expect(screen.getByText('critical')).toBeInTheDocument();
+ expect(screen.getByText('open')).toBeInTheDocument();
+ expect(screen.getByText('missed_checkin')).toBeInTheDocument();
+ expect(screen.getByText('Mochi')).toBeInTheDocument();
+ expect(screen.getByText('Owner A')).toBeInTheDocument();
+ expect(screen.getByText('Daily check-in')).toBeInTheDocument();
+ expect(screen.getAllByText(/flow-plan-1/i)).toHaveLength(1);
+ expect(screen.getAllByText(/flow-task-1/i)).toHaveLength(1);
+ expect(screen.getByText('Looks normal.')).toBeInTheDocument();
+ expect(screen.getByText(/· normal/)).toBeInTheDocument();
+ });
+
+ it('renders unavailable context per row when Core returns null context', async () => {
+ mockClient.listAlerts.mockResolvedValueOnce([{ ...baseAlert, context: null }]);
+
+ render();
+
+ expect(await screen.findByText('Buddy missed a check-in.')).toBeInTheDocument();
+ expect(screen.getByText('Operational context unavailable for this alert.')).toBeInTheDocument();
+ });
+
+ it('maps all filters to Core sentinel params instead of status=all', async () => {
+ mockClient.listAlerts.mockResolvedValue([]);
+ const user = userEvent.setup();
+
+ render();
+ await waitFor(() =>
+ expect(mockClient.listAlerts).toHaveBeenCalledWith({ status: 'open', severity: undefined })
+ );
+
+ await user.selectOptions(screen.getByLabelText('Alert status filter'), 'all');
+ await waitFor(() =>
+ expect(mockClient.listAlerts).toHaveBeenLastCalledWith({ status: null, severity: undefined })
+ );
+ expect(mockClient.listAlerts).not.toHaveBeenCalledWith(
+ expect.objectContaining({ status: 'all' })
+ );
+
+ await user.selectOptions(screen.getByLabelText('Alert severity filter'), 'high');
+ await waitFor(() =>
+ expect(mockClient.listAlerts).toHaveBeenLastCalledWith({ status: null, severity: 'high' })
+ );
+ });
+
+ it('keeps newer alert-list results when an older filter request resolves late', async () => {
+ const first = deferred();
+ const second = deferred();
+ mockClient.listAlerts.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
+ const user = userEvent.setup();
+
+ render();
+
+ await user.selectOptions(screen.getByLabelText('Alert severity filter'), 'high');
+
+ await waitFor(() =>
+ expect(mockClient.listAlerts).toHaveBeenNthCalledWith(2, { status: 'open', severity: 'high' })
+ );
+
+ second.resolve([{ ...baseAlert, id: 'alert-2', summary: 'Milo missed a check-in.' }]);
+ expect(await screen.findByText('Milo missed a check-in.')).toBeInTheDocument();
+
+ first.resolve([baseAlert]);
+ await waitFor(() => expect(screen.getByText('Milo missed a check-in.')).toBeInTheDocument());
+ expect(screen.queryByText('Buddy missed a check-in.')).not.toBeInTheDocument();
+ });
+
+ it('persists ack idempotency keys across retry and remount', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.ackAlert.mockRejectedValue(new Error('temporary failure'));
+ const user = userEvent.setup();
+
+ const firstRender = render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(1));
+ const firstKey = mockClient.ackAlert.mock.calls[0]?.[1]?.idempotencyKey;
+ expect(firstKey).toEqual(expect.stringContaining('youpet-workbench:ack:alert-1:'));
+ expect(storedIdempotencyKeys()['ack:alert-1:']).toBe(firstKey);
+
+ firstRender.unmount();
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(2));
+
+ expect(mockClient.ackAlert.mock.calls[1]?.[1]?.idempotencyKey).toBe(firstKey);
+ expect(storedIdempotencyKeys()['ack:alert-1:']).toBe(firstKey);
+ });
+
+ it('fails closed when there is no active user for scoped retry-key storage', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ setActiveUserId(null);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+
+ expect(mockClient.ackAlert).not.toHaveBeenCalled();
+ expect(
+ screen.getByText(
+ 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('fails closed when retry-key persistence cannot verify durable storage', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ setWorkbenchIntentStorageAdapter({
+ getItem: () => null,
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ });
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+
+ expect(mockClient.ackAlert).not.toHaveBeenCalled();
+ expect(
+ screen.getByText(
+ 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('does not share retry keys across active users', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.ackAlert.mockRejectedValue(new Error('temporary failure'));
+ const user = userEvent.setup();
+
+ const firstRender = render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(1));
+ const firstKey = mockClient.ackAlert.mock.calls[0]?.[1]?.idempotencyKey as string;
+ expect(window.localStorage.getItem(physicalStorageKey(ACTIVE_USER))).toContain(firstKey);
+
+ firstRender.unmount();
+ setActiveUserId('workbench-user-b');
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(2));
+
+ const secondKey = mockClient.ackAlert.mock.calls[1]?.[1]?.idempotencyKey as string;
+ expect(secondKey).not.toBe(firstKey);
+ expect(window.localStorage.getItem(physicalStorageKey('workbench-user-b'))).toContain(
+ secondKey
+ );
+ expect(window.localStorage.getItem(physicalStorageKey(ACTIVE_USER))).toContain(firstKey);
+ });
+
+ it('clears action keys only after success and refreshes visible alert state', async () => {
+ const acknowledged = {
+ ...baseAlert,
+ status: 'acknowledged',
+ acknowledged_at: '2026-06-01T01:00:00Z',
+ } as const;
+ mockClient.listAlerts.mockResolvedValueOnce([baseAlert]).mockResolvedValueOnce([acknowledged]);
+ mockClient.ackAlert.mockResolvedValueOnce(acknowledged);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.type(screen.getByLabelText('Ack note for alert-1'), 'Calling owner');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(1));
+ expect(mockClient.ackAlert).toHaveBeenCalledWith('alert-1', {
+ note: 'Calling owner',
+ idempotencyKey: expect.stringContaining('youpet-workbench:ack:alert-1:'),
+ });
+ await waitFor(() => expect(mockClient.listAlerts).toHaveBeenCalledTimes(2));
+ expect(storedIdempotencyKeys()['ack:alert-1:']).toBeUndefined();
+ expect(await screen.findByText('acknowledged')).toBeInTheDocument();
+ });
+
+ it('preserves existing context when an action response omits context and refresh fails', async () => {
+ const plainAcknowledged = {
+ id: baseAlert.id,
+ alert_type: baseAlert.alert_type,
+ severity: baseAlert.severity,
+ related_type: baseAlert.related_type,
+ related_id: baseAlert.related_id,
+ status: 'acknowledged',
+ summary: baseAlert.summary,
+ created_at: baseAlert.created_at,
+ acknowledged_at: '2026-06-01T01:00:00Z',
+ resolved_at: null,
+ } as const;
+ mockClient.listAlerts
+ .mockResolvedValueOnce([baseAlert])
+ .mockRejectedValueOnce(new Error('refresh failed'));
+ mockClient.ackAlert.mockResolvedValueOnce(plainAcknowledged);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(mockClient.listAlerts).toHaveBeenCalledTimes(2));
+ expect(await screen.findByText('acknowledged')).toBeInTheDocument();
+ expect(screen.getByText('Mochi')).toBeInTheDocument();
+ expect(screen.getByText('Daily check-in')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Operational context unavailable for this alert.')
+ ).not.toBeInTheDocument();
+ });
+
+ it('preserves existing context when an action response returns context null and refresh fails', async () => {
+ const nullContextAcknowledged = {
+ id: baseAlert.id,
+ alert_type: baseAlert.alert_type,
+ severity: baseAlert.severity,
+ related_type: baseAlert.related_type,
+ related_id: baseAlert.related_id,
+ status: 'acknowledged',
+ summary: baseAlert.summary,
+ created_at: baseAlert.created_at,
+ acknowledged_at: '2026-06-01T01:00:00Z',
+ resolved_at: null,
+ context: null,
+ } as const;
+ mockClient.listAlerts
+ .mockResolvedValueOnce([baseAlert])
+ .mockRejectedValueOnce(new Error('refresh failed'));
+ mockClient.ackAlert.mockResolvedValueOnce(nullContextAcknowledged);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(mockClient.listAlerts).toHaveBeenCalledTimes(2));
+ expect(await screen.findByText('acknowledged')).toBeInTheDocument();
+ expect(screen.getByText('Mochi')).toBeInTheDocument();
+ expect(screen.getByText('Daily check-in')).toBeInTheDocument();
+ expect(
+ screen.queryByText('Operational context unavailable for this alert.')
+ ).not.toBeInTheDocument();
+ });
+
+ it('disables both row actions while an alert action is pending', async () => {
+ const acknowledged = {
+ ...baseAlert,
+ status: 'acknowledged',
+ acknowledged_at: '2026-06-01T01:00:00Z',
+ } as const;
+ const pendingAck = deferred();
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.ackAlert.mockReturnValueOnce(pendingAck.promise);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ const ackButton = screen.getByRole('button', { name: 'Acknowledge' });
+ const resolveButton = screen.getByRole('button', { name: 'Resolve' });
+
+ await user.click(ackButton);
+ await waitFor(() => expect(ackButton).toBeDisabled());
+ expect(resolveButton).toBeDisabled();
+
+ await user.click(resolveButton);
+ expect(mockClient.resolveAlert).not.toHaveBeenCalled();
+
+ pendingAck.resolve(acknowledged);
+ await waitFor(() => expect(mockClient.ackAlert).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(ackButton).not.toBeDisabled());
+ });
+
+ it('keeps the original row locked when another alert starts an action', async () => {
+ const secondAlert = {
+ ...baseAlert,
+ id: 'alert-2',
+ related_id: 'task-2',
+ severity: 'high',
+ summary: 'Milo missed a check-in.',
+ } as const;
+ const acknowledged = {
+ ...baseAlert,
+ status: 'acknowledged',
+ acknowledged_at: '2026-06-01T01:00:00Z',
+ } as const;
+ const resolvedSecond = {
+ ...secondAlert,
+ status: 'resolved',
+ resolved_at: '2026-06-01T02:00:00Z',
+ } as const;
+ const pendingAck = deferred();
+ const pendingResolve = deferred();
+ mockClient.listAlerts.mockResolvedValue([baseAlert, secondAlert]);
+ mockClient.ackAlert.mockReturnValueOnce(pendingAck.promise);
+ mockClient.resolveAlert.mockReturnValueOnce(pendingResolve.promise);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await screen.findByText('Milo missed a check-in.');
+
+ const firstRow = screen.getByText('Buddy missed a check-in.').closest('article');
+ const secondRow = screen.getByText('Milo missed a check-in.').closest('article');
+ expect(firstRow).not.toBeNull();
+ expect(secondRow).not.toBeNull();
+
+ const firstAckButton = within(firstRow as HTMLElement).getByRole('button', {
+ name: 'Acknowledge',
+ });
+ const firstResolveButton = within(firstRow as HTMLElement).getByRole('button', {
+ name: 'Resolve',
+ });
+ const secondResolveButton = within(secondRow as HTMLElement).getByRole('button', {
+ name: 'Resolve',
+ });
+
+ await user.click(firstAckButton);
+ await waitFor(() => expect(firstResolveButton).toBeDisabled());
+
+ await user.click(secondResolveButton);
+ await waitFor(() => expect(secondResolveButton).toBeDisabled());
+ expect(firstAckButton).toBeDisabled();
+ expect(firstResolveButton).toBeDisabled();
+
+ await user.click(firstResolveButton);
+ expect(mockClient.resolveAlert).toHaveBeenCalledTimes(1);
+ expect(mockClient.resolveAlert).toHaveBeenCalledWith(
+ 'alert-2',
+ expect.objectContaining({
+ idempotencyKey: expect.stringContaining('youpet-workbench:resolve:alert-2:'),
+ })
+ );
+
+ pendingAck.resolve(acknowledged);
+ pendingResolve.resolve(resolvedSecond);
+ await waitFor(() => expect(firstAckButton).not.toBeDisabled());
+ await waitFor(() => expect(secondResolveButton).not.toBeDisabled());
+ });
+
+ it('opens trace drawer and renders timeline entries with warnings', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce(baseTrace);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ expect(mockClient.getAlertTrace).toHaveBeenCalledWith('alert-1');
+ const drawer = await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ const workflowSummary = within(drawer).getByRole('region', { name: 'Workflow summary' });
+ expect(workflowSummary).toBeInTheDocument();
+ expect(within(drawer).getAllByText('Daily check-in').length).toBeGreaterThan(0);
+ expect(workflowSummary).toHaveTextContent('flow-plan-1');
+ expect(within(drawer).getByText('Alert created')).toBeInTheDocument();
+ expect(within(drawer).getByText('Critical missed check-in alert.')).toBeInTheDocument();
+ expect(within(drawer).getByText('Alert Created')).toBeInTheDocument();
+ expect(within(drawer).getByText('Alerts')).toBeInTheDocument();
+ expect(within(drawer).getByText('System · core')).toBeInTheDocument();
+ expect(within(drawer).getAllByText('task_instance / task-1')).toHaveLength(2);
+ expect(within(drawer).getByText('Trace Truncated')).toBeInTheDocument();
+ expect(within(drawer).getByText('Trace limited to 50 entries')).toBeInTheDocument();
+ expect(within(drawer).getAllByText('Step').length).toBeGreaterThan(0);
+ expect(within(drawer).getAllByText('Event').length).toBeGreaterThan(0);
+ expect(within(drawer).getAllByText('Delivery').length).toBeGreaterThan(0);
+ expect(within(drawer).getByText('Failed · Retry scheduled')).toBeInTheDocument();
+ const succeededEntry = within(drawer).getByText('Delivery succeeded').closest('li');
+ expect(succeededEntry).not.toBeNull();
+ expect(within(succeededEntry as HTMLElement).getByText('Delivery')).toBeInTheDocument();
+ expect(within(succeededEntry as HTMLElement).getByText('Succeeded')).toBeInTheDocument();
+ expect(within(drawer).getByText('Recovered')).toBeInTheDocument();
+ expect(drawer).toHaveTextContent('alert_type: missed_checkin');
+ });
+
+ it('renders translated titles for missing ActionRequest and truncated link warnings', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce({
+ ...baseTrace,
+ warnings: [
+ {
+ code: 'missing_related_action_request',
+ message: 'alert related action_request was not found',
+ source: 'action_requests',
+ },
+ {
+ code: 'action_request_links_truncated',
+ message: 'ActionRequest link identifiers limited to 3 values',
+ source: 'action_requests',
+ },
+ {
+ code: 'trace_reserved_budget_exceeded',
+ message: 'Trace reserved bundle limited to 7 entries',
+ source: null,
+ },
+ ],
+ entries: baseTrace.entries,
+ });
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ const drawer = await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ expect(within(drawer).getByText('Missing related ActionRequest')).toBeInTheDocument();
+ expect(
+ within(drawer).getByText('alert related action_request was not found')
+ ).toBeInTheDocument();
+ expect(within(drawer).getByText('ActionRequest links truncated')).toBeInTheDocument();
+ expect(
+ within(drawer).getByText('ActionRequest link identifiers limited to 3 values')
+ ).toBeInTheDocument();
+ expect(within(drawer).getByText('Trace reserved budget exceeded')).toBeInTheDocument();
+ expect(
+ within(drawer).getByText('Trace reserved bundle limited to 7 entries')
+ ).toBeInTheDocument();
+ expect(within(drawer).getAllByText('Action Requests').length).toBeGreaterThan(0);
+ });
+
+ it('renders ActionRequest lifecycle entries in a fifth Action lane while keeping Event and Audit lanes mixed in order', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce({
+ ...baseTrace,
+ warnings: [
+ {
+ code: 'action_request_projection_truncated',
+ message: 'ActionRequest projection limited to the latest request',
+ source: 'action_requests',
+ },
+ ],
+ entries: [
+ {
+ id: 'action-request:req-1:proposal',
+ occurred_at: '2026-06-01T00:01:00Z',
+ kind: 'action_request_proposed',
+ source: 'action_requests',
+ title: 'ActionRequest proposed',
+ detail: null,
+ actor: { type: 'agent', id: 'openclaw-youpet-consumer' },
+ related_type: 'action_request',
+ related_id: 'req-1',
+ severity: null,
+ metadata: {
+ action_request_id: 'req-1',
+ action_type: 'task.escalate',
+ target_type: 'task_instance',
+ target_id: 'task-1',
+ risk: 'high',
+ policy_outcome: 'require_approval',
+ required_approver_class: 'operator',
+ domain_event_ids: ['evt-task-missed-1'],
+ proposal_event_id: 'evt-task-missed-1',
+ },
+ },
+ {
+ id: 'event:event-req-1',
+ occurred_at: '2026-06-01T00:01:30Z',
+ kind: 'outbox_event',
+ source: 'event_outbox',
+ title: 'Event action_request.created',
+ detail: null,
+ actor: null,
+ related_type: 'action_request',
+ related_id: 'req-1',
+ severity: null,
+ metadata: { event_type: 'action_request.created' },
+ },
+ {
+ id: 'audit:req-1:version-2',
+ occurred_at: '2026-06-01T00:01:45Z',
+ kind: 'audit_action',
+ source: 'audit_logs',
+ title: 'ActionRequest lifecycle audit row written',
+ detail: null,
+ actor: { type: 'system', id: 'core' },
+ related_type: 'action_request',
+ related_id: 'req-1',
+ severity: null,
+ metadata: { action: 'action_request.created' },
+ },
+ {
+ id: 'action-request:req-1:approved',
+ occurred_at: '2026-06-01T00:02:00Z',
+ kind: 'action_request_approved',
+ source: 'action_requests',
+ title: 'ActionRequest approved',
+ detail: null,
+ actor: { type: 'operator', id: 'user-1' },
+ related_type: 'action_request',
+ related_id: 'req-1',
+ severity: null,
+ metadata: { approval_state: 'approved' },
+ },
+ {
+ id: 'action-request:req-2:rejected',
+ occurred_at: '2026-06-01T00:03:00Z',
+ kind: 'action_request_rejected',
+ source: 'action_requests',
+ title: 'ActionRequest rejected',
+ detail: null,
+ actor: { type: 'operator', id: 'user-2' },
+ related_type: 'action_request',
+ related_id: 'req-2',
+ severity: null,
+ metadata: { approval_state: 'rejected' },
+ },
+ {
+ id: 'action-request:req-1:execution',
+ occurred_at: '2026-06-01T00:04:00Z',
+ kind: 'action_request_execution',
+ source: 'action_requests',
+ title: 'ActionRequest execution succeeded',
+ detail: null,
+ actor: null,
+ related_type: 'action_request',
+ related_id: 'req-1',
+ severity: null,
+ metadata: { execution_state: 'succeeded', result_outcome_code: 'sent_to_operator' },
+ },
+ ],
+ });
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ const drawer = await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ expect(within(drawer).getByText('Action Request Projection Truncated')).toBeInTheDocument();
+ expect(within(drawer).getAllByText('Action Requests').length).toBeGreaterThan(0);
+
+ const items = within(drawer).getAllByRole('listitem');
+ const findItem = (title: string) =>
+ items.find(item => within(item).queryByText(title)) as HTMLElement | undefined;
+
+ const proposedItem = findItem('ActionRequest proposed');
+ const eventItem = findItem('Event action_request.created');
+ const auditItem = findItem('ActionRequest lifecycle audit row written');
+ const approvedItem = findItem('ActionRequest approved');
+ const rejectedItem = findItem('ActionRequest rejected');
+ const executionItem = findItem('ActionRequest execution succeeded');
+
+ expect(proposedItem).toBeDefined();
+ expect(eventItem).toBeDefined();
+ expect(auditItem).toBeDefined();
+ expect(approvedItem).toBeDefined();
+ expect(rejectedItem).toBeDefined();
+ expect(executionItem).toBeDefined();
+
+ expect(within(proposedItem as HTMLElement).getByText('Action')).toBeInTheDocument();
+ expect(
+ within(proposedItem as HTMLElement).getByText('task_instance / task-1')
+ ).toBeInTheDocument();
+ expect(within(proposedItem as HTMLElement).getByText('task.escalate')).toBeInTheDocument();
+ expect(within(approvedItem as HTMLElement).getByText('Action')).toBeInTheDocument();
+ expect(within(rejectedItem as HTMLElement).getByText('Action')).toBeInTheDocument();
+ expect(within(executionItem as HTMLElement).getByText('Action')).toBeInTheDocument();
+ expect(within(executionItem as HTMLElement).getByText('sent_to_operator')).toBeInTheDocument();
+ expect(within(eventItem as HTMLElement).getByText('Event')).toBeInTheDocument();
+ expect(within(auditItem as HTMLElement).getByText('Audit')).toBeInTheDocument();
+
+ const itemTexts = items.map(item => item.textContent ?? '');
+ const proposedIndex = itemTexts.findIndex(text => text.includes('ActionRequest proposed'));
+ const eventIndex = itemTexts.findIndex(text => text.includes('Event action_request.created'));
+ const auditIndex = itemTexts.findIndex(text =>
+ text.includes('ActionRequest lifecycle audit row written')
+ );
+ const approvedIndex = itemTexts.findIndex(text => text.includes('ActionRequest approved'));
+ const rejectedIndex = itemTexts.findIndex(text => text.includes('ActionRequest rejected'));
+ const executionIndex = itemTexts.findIndex(text =>
+ text.includes('ActionRequest execution succeeded')
+ );
+
+ expect(proposedIndex).toBeGreaterThanOrEqual(0);
+ expect(eventIndex).toBeGreaterThan(proposedIndex);
+ expect(auditIndex).toBeGreaterThan(eventIndex);
+ expect(approvedIndex).toBeGreaterThan(auditIndex);
+ expect(rejectedIndex).toBeGreaterThan(approvedIndex);
+ expect(executionIndex).toBeGreaterThan(rejectedIndex);
+ });
+
+ it('renders an unavailable workflow identity when Core returns workflow null', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce({ ...baseTrace, workflow: null });
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ const drawer = await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ expect(
+ within(drawer).getByText('Workflow identity is unavailable for this alert.')
+ ).toBeInTheDocument();
+ });
+
+ it('renders empty trace as an empty state', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce({
+ alert_id: 'alert-1',
+ partial: false,
+ warnings: [],
+ entries: [],
+ });
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ expect(
+ await screen.findByText('No trace entries available for this alert.')
+ ).toBeInTheDocument();
+ });
+
+ it('renders generic trace errors without leaking backend text', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockRejectedValueOnce(new Error('Bearer svc-token leaked'));
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ expect(await screen.findByText('Trace request failed. Try again.')).toBeInTheDocument();
+ expect(screen.queryByText(/svc-token/)).not.toBeInTheDocument();
+ });
+
+ it('refreshes trace for the same alert only when requested', async () => {
+ const refreshedTrace = {
+ ...baseTrace,
+ entries: [{ ...baseTrace.entries[0], id: 'audit:audit-1', title: 'Operator acknowledged' }],
+ };
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce(baseTrace).mockResolvedValueOnce(refreshedTrace);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+ await screen.findByText('Alert created');
+
+ await user.click(screen.getByRole('button', { name: 'Refresh trace' }));
+
+ await screen.findByText('Operator acknowledged');
+ expect(mockClient.getAlertTrace).toHaveBeenCalledTimes(2);
+ expect(mockClient.getAlertTrace).toHaveBeenNthCalledWith(2, 'alert-1');
+ });
+
+ it('keeps the last loaded trace visible when refresh fails', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace
+ .mockResolvedValueOnce(baseTrace)
+ .mockRejectedValueOnce(new Error('backend leaked svc-token'));
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+ await screen.findByText('Alert created');
+
+ await user.click(screen.getByRole('button', { name: 'Refresh trace' }));
+
+ expect(await screen.findByText('Trace request failed. Try again.')).toBeInTheDocument();
+ expect(screen.getByText('Alert created')).toBeInTheDocument();
+ expect(screen.queryByText(/svc-token/)).not.toBeInTheDocument();
+ expect(mockClient.getAlertTrace).toHaveBeenCalledTimes(2);
+ });
+
+ it('traps trace drawer focus, restores focus, and closes on Escape or backdrop', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValue(baseTrace);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ const traceButton = screen.getByRole('button', { name: 'Trace' });
+ await user.click(traceButton);
+
+ const drawer = await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ expect(drawer).toHaveFocus();
+
+ await user.tab({ shift: true });
+ expect(within(drawer).getByRole('button', { name: 'Refresh trace' })).toHaveFocus();
+
+ await user.tab();
+ expect(within(drawer).getByRole('button', { name: 'Close' })).toHaveFocus();
+
+ await user.keyboard('{Escape}');
+
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('dialog', { name: 'Workflow trace for alert-1' })
+ ).not.toBeInTheDocument()
+ );
+ expect(traceButton).toHaveFocus();
+
+ await user.click(traceButton);
+ const reopenedDrawer = await screen.findByRole('dialog', {
+ name: 'Workflow trace for alert-1',
+ });
+ fireEvent.mouseDown(reopenedDrawer.parentElement as HTMLElement);
+
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('dialog', { name: 'Workflow trace for alert-1' })
+ ).not.toBeInTheDocument()
+ );
+ });
+
+ it('keeps an open trace drawer through alert action refreshes without refetching trace', async () => {
+ const acknowledged = {
+ ...baseAlert,
+ status: 'acknowledged',
+ acknowledged_at: '2026-06-01T01:00:00Z',
+ } as const;
+ mockClient.listAlerts.mockResolvedValueOnce([baseAlert]).mockResolvedValueOnce([]);
+ mockClient.getAlertTrace.mockResolvedValueOnce(baseTrace);
+ mockClient.ackAlert.mockResolvedValueOnce(acknowledged);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+ await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ await screen.findByText('Alert created');
+
+ await user.click(screen.getByRole('button', { name: 'Acknowledge' }));
+
+ await waitFor(() => expect(mockClient.listAlerts).toHaveBeenCalledTimes(2));
+ expect(screen.getByRole('dialog', { name: 'Workflow trace for alert-1' })).toBeInTheDocument();
+ expect(screen.getByText('Alert created')).toBeInTheDocument();
+ expect(mockClient.getAlertTrace).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens another row without rendering stale trace data from the first row', async () => {
+ const secondAlert = {
+ ...baseAlert,
+ id: 'alert-2',
+ related_id: 'task-2',
+ summary: 'Milo missed a check-in.',
+ } as const;
+ const firstTrace = deferred();
+ const secondTrace = {
+ ...baseTrace,
+ alert_id: 'alert-2',
+ entries: [{ ...baseTrace.entries[0], id: 'alert:alert-2', title: 'Second alert trace' }],
+ };
+ mockClient.listAlerts.mockResolvedValue([baseAlert, secondAlert]);
+ mockClient.getAlertTrace
+ .mockReturnValueOnce(firstTrace.promise)
+ .mockResolvedValueOnce(secondTrace);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await screen.findByText('Milo missed a check-in.');
+
+ await user.click(
+ within(
+ screen.getByText('Buddy missed a check-in.').closest('article') as HTMLElement
+ ).getByRole('button', { name: 'Trace' })
+ );
+ await user.click(
+ within(
+ screen.getByText('Milo missed a check-in.').closest('article') as HTMLElement
+ ).getByRole('button', { name: 'Trace' })
+ );
+ firstTrace.resolve({
+ ...baseTrace,
+ entries: [{ ...baseTrace.entries[0], id: 'alert:alert-1', title: 'First stale trace' }],
+ });
+
+ const drawer = await screen.findByRole('dialog', { name: 'Workflow trace for alert-2' });
+ expect(await within(drawer).findByText('Second alert trace')).toBeInTheDocument();
+ expect(within(drawer).queryByText('First stale trace')).not.toBeInTheDocument();
+ });
+
+ it('ignores trace responses that resolve after the drawer closes', async () => {
+ const pendingTrace = deferred();
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockReturnValueOnce(pendingTrace.promise);
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+ await screen.findByRole('dialog', { name: 'Workflow trace for alert-1' });
+ await user.click(screen.getByRole('button', { name: 'Close' }));
+
+ pendingTrace.resolve(baseTrace);
+
+ await waitFor(() =>
+ expect(
+ screen.queryByRole('dialog', { name: 'Workflow trace for alert-1' })
+ ).not.toBeInTheDocument()
+ );
+ expect(screen.queryByText('Alert created')).not.toBeInTheDocument();
+ });
+
+ it('renders bounded deterministic metadata chips for nested values', async () => {
+ const longKey = `epsilon_${'very_long_metadata_key_'.repeat(4)}`;
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.getAlertTrace.mockResolvedValueOnce({
+ ...baseTrace,
+ partial: false,
+ warnings: [],
+ entries: [
+ {
+ ...baseTrace.entries[0],
+ metadata: {
+ zeta: { b: 2, a: 1 },
+ alpha: ['one', 'two'],
+ beta: 'x'.repeat(120),
+ [longKey]: 'visible',
+ theta: null,
+ yotta: Object.fromEntries(
+ Array.from({ length: 40 }, (_, index) => [`key_${index}`, 'value'.repeat(40)])
+ ),
+ zz_overflow: 'hidden',
+ },
+ },
+ ],
+ });
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Trace' }));
+
+ const metadata = await screen.findByLabelText('Metadata');
+ expect(metadata).toHaveTextContent('alpha: ["one","two"]');
+ expect(metadata).toHaveTextContent('zeta: {"a":1,"b":2}');
+ expect(metadata).not.toHaveTextContent(longKey);
+ expect(metadata).toHaveTextContent(`${longKey.slice(0, 47)}…`);
+ expect(metadata.textContent).not.toContain('x'.repeat(100));
+ expect(metadata.textContent).not.toContain('value'.repeat(20));
+ expect(metadata).not.toHaveTextContent('zz_overflow');
+ });
+
+ it('keeps resolve key on failure and does not leak error details', async () => {
+ mockClient.listAlerts.mockResolvedValue([baseAlert]);
+ mockClient.resolveAlert.mockRejectedValue(new Error('svc-token leaked'));
+ const user = userEvent.setup();
+
+ render();
+ await screen.findByText('Buddy missed a check-in.');
+ await user.click(screen.getByRole('button', { name: 'Resolve' }));
+
+ await waitFor(() => expect(mockClient.resolveAlert).toHaveBeenCalledTimes(1));
+ expect(storedIdempotencyKeys()['resolve:alert-1:']).toEqual(
+ mockClient.resolveAlert.mock.calls[0]?.[1]?.idempotencyKey
+ );
+ expect(
+ screen.getByText('Workbench request failed. Check Core configuration and try again.')
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/svc-token/)).not.toBeInTheDocument();
+ });
+});
diff --git a/app/src/pages/Workbench.tsx b/app/src/pages/Workbench.tsx
new file mode 100644
index 0000000000..337d239ebf
--- /dev/null
+++ b/app/src/pages/Workbench.tsx
@@ -0,0 +1,1163 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+
+import { useT } from '../lib/i18n/I18nContext';
+import {
+ type CoreAlertSeverity,
+ type CoreAlertStatus,
+ type CoreWorkbenchAlert,
+ type CoreWorkbenchAlertTrace,
+ type CoreWorkbenchTraceActor,
+ type CoreWorkbenchTraceEntry,
+ type CoreWorkbenchTraceWarning,
+ createCoreWorkbenchClient,
+} from '../services/api/coreWorkbenchClient';
+import {
+ createVerifiedUserScopedStorage,
+ getActiveUserId,
+ type VerifiedUserScopedStorage,
+} from '../store/userScopedStorage';
+
+type StatusFilter = CoreAlertStatus | 'all';
+type SeverityFilter = CoreAlertSeverity | 'all';
+type AlertAction = 'ack' | 'resolve';
+type PendingActions = Record;
+
+const STATUS_FILTERS: StatusFilter[] = ['open', 'acknowledged', 'resolved', 'dismissed', 'all'];
+const SEVERITY_FILTERS: SeverityFilter[] = ['all', 'low', 'medium', 'high', 'critical'];
+const IDEMPOTENCY_STORAGE_KEY = 'openhuman.youpet.workbench.idempotency.v1';
+const STATUS_LABEL_KEYS: Record = {
+ open: 'workbench.status.open',
+ acknowledged: 'workbench.status.acknowledged',
+ resolved: 'workbench.status.resolved',
+ dismissed: 'workbench.status.dismissed',
+ all: 'workbench.status.all',
+};
+const SEVERITY_LABEL_KEYS: Record = {
+ all: 'workbench.severity.all',
+ low: 'workbench.severity.low',
+ medium: 'workbench.severity.medium',
+ high: 'workbench.severity.high',
+ critical: 'workbench.severity.critical',
+};
+const MAX_METADATA_CHIPS = 6;
+const MAX_METADATA_KEY_LENGTH = 48;
+const MAX_METADATA_VALUE_LENGTH = 80;
+const MAX_METADATA_COLLECTION_ITEMS = 8;
+const MAX_METADATA_DEPTH = 2;
+const MAX_METADATA_SERIALIZED_LENGTH = 512;
+
+export const workbenchIdempotencyStorageKey = IDEMPOTENCY_STORAGE_KEY;
+
+export interface WorkbenchIntentStorageAdapter {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+const defaultVerifiedStorage: VerifiedUserScopedStorage = createVerifiedUserScopedStorage();
+const defaultUserScopedAdapter: WorkbenchIntentStorageAdapter = {
+ getItem(key) {
+ return defaultVerifiedStorage.getItem(key);
+ },
+ setItem(key, value) {
+ defaultVerifiedStorage.setItem(key, value);
+ },
+ removeItem(key) {
+ defaultVerifiedStorage.removeItem(key);
+ },
+};
+
+let storageAdapter: WorkbenchIntentStorageAdapter = defaultUserScopedAdapter;
+
+export function setWorkbenchIntentStorageAdapter(adapter: WorkbenchIntentStorageAdapter | null) {
+ storageAdapter = adapter ?? defaultUserScopedAdapter;
+}
+
+export function resolveWorkbenchActiveUserScope(): string | null {
+ const id = getActiveUserId();
+ if (!id || !id.trim()) return null;
+ return id;
+}
+
+function readIdempotencyStore(): Record {
+ try {
+ const raw = storageAdapter.getItem(IDEMPOTENCY_STORAGE_KEY);
+ if (!raw) return {};
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
+ return Object.fromEntries(
+ Object.entries(parsed).filter((entry): entry is [string, string] => {
+ const [key, value] = entry;
+ return typeof key === 'string' && typeof value === 'string' && value.trim().length > 0;
+ })
+ );
+ } catch {
+ throw new Error('idempotency_storage_read_failed');
+ }
+}
+
+function writeIdempotencyStore(store: Record) {
+ const serialized = JSON.stringify(store);
+ try {
+ storageAdapter.setItem(IDEMPOTENCY_STORAGE_KEY, serialized);
+ return storageAdapter.getItem(IDEMPOTENCY_STORAGE_KEY) === serialized;
+ } catch {
+ return false;
+ }
+}
+
+function makeIdempotencyStorageId(alertId: string, action: AlertAction, intent: string) {
+ return `${action}:${alertId}:${intent}`;
+}
+
+function generateIdempotencyKey(alertId: string, action: AlertAction) {
+ const random = globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2);
+ return `youpet-workbench:${action}:${alertId}:${random}`;
+}
+
+function getOrCreateIdempotencyKey(alertId: string, action: AlertAction, intent: string) {
+ if (!resolveWorkbenchActiveUserScope()) {
+ return { key: '', persisted: false };
+ }
+ let store: Record;
+ try {
+ store = readIdempotencyStore();
+ } catch {
+ return { key: '', persisted: false };
+ }
+ const id = makeIdempotencyStorageId(alertId, action, intent);
+ if (store[id]) return { key: store[id], persisted: true };
+ const key = generateIdempotencyKey(alertId, action);
+ return { key, persisted: writeIdempotencyStore({ ...store, [id]: key }) };
+}
+
+function clearIdempotencyKey(alertId: string, action: AlertAction, intent: string) {
+ let store: Record;
+ try {
+ store = readIdempotencyStore();
+ } catch {
+ return false;
+ }
+ delete store[makeIdempotencyStorageId(alertId, action, intent)];
+ return writeIdempotencyStore(store);
+}
+
+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 formatOptional(value: string | null | undefined, noneLabel: string) {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : noneLabel;
+}
+
+function normalizeMetadataValue(value: unknown, depth = 0): unknown {
+ if (Array.isArray(value)) {
+ if (depth >= MAX_METADATA_DEPTH) return `[${value.length} items]`;
+ const normalized = value
+ .slice(0, MAX_METADATA_COLLECTION_ITEMS)
+ .map(nested => normalizeMetadataValue(nested, depth + 1));
+ return value.length > MAX_METADATA_COLLECTION_ITEMS
+ ? [...normalized, `… ${value.length - MAX_METADATA_COLLECTION_ITEMS} more`]
+ : normalized;
+ }
+ if (value && typeof value === 'object') {
+ if (depth >= MAX_METADATA_DEPTH) return '[object]';
+ const entries = Object.entries(value as Record).sort(([left], [right]) =>
+ left.localeCompare(right)
+ );
+ const normalized = entries
+ .slice(0, MAX_METADATA_COLLECTION_ITEMS)
+ .map(([key, nested]) => [key, normalizeMetadataValue(nested, depth + 1)]);
+ if (entries.length > MAX_METADATA_COLLECTION_ITEMS) {
+ normalized.push(['…', `${entries.length - MAX_METADATA_COLLECTION_ITEMS} more keys`]);
+ }
+ return Object.fromEntries(normalized);
+ }
+ return value;
+}
+
+function stringifyMetadataValue(value: unknown): string {
+ if (value === null) return 'null';
+ if (value === undefined) return '';
+ if (typeof value === 'string') return truncateText(value, MAX_METADATA_SERIALIZED_LENGTH);
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
+ try {
+ return truncateText(
+ JSON.stringify(normalizeMetadataValue(value)),
+ MAX_METADATA_SERIALIZED_LENGTH
+ );
+ } catch {
+ return truncateText(String(value), MAX_METADATA_SERIALIZED_LENGTH);
+ }
+}
+
+function truncateText(value: string, limit = MAX_METADATA_VALUE_LENGTH): string {
+ return value.length > limit ? `${value.slice(0, limit - 1)}…` : value;
+}
+
+function metadataChips(
+ metadata: Record | undefined,
+ omitKeys: ReadonlySet = new Set()
+) {
+ if (!metadata || typeof metadata !== 'object') return [];
+ return Object.keys(metadata)
+ .filter(key => !omitKeys.has(key))
+ .sort((left, right) => left.localeCompare(right))
+ .slice(0, MAX_METADATA_CHIPS)
+ .map(
+ key =>
+ [
+ key,
+ truncateText(key, MAX_METADATA_KEY_LENGTH),
+ truncateText(stringifyMetadataValue(metadata[key])),
+ ] as const
+ )
+ .filter(([, , value]) => value.length > 0);
+}
+
+const ACTION_NAMED_METADATA_KEYS = new Set([
+ 'action_request_id',
+ 'action_type',
+ 'target_type',
+ 'target_id',
+ 'risk',
+ 'policy_outcome',
+ 'required_approver_class',
+ 'approval_state',
+ 'approver_class',
+ 'execution_state',
+ 'result_outcome_code',
+ 'error_code',
+ 'error_message',
+]);
+
+function metadataString(metadata: Record, key: string): string | null {
+ const value = metadata[key];
+ if (typeof value === 'string' && value.trim()) return value;
+ return null;
+}
+
+function labelFromLiteral(value: string): string {
+ return value
+ .split('_')
+ .filter(Boolean)
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
+
+function formatTraceActor(actor: CoreWorkbenchTraceActor | null | undefined, noneLabel: string) {
+ if (!actor) return noneLabel;
+ return actor.id ? `${labelFromLiteral(actor.type)} · ${actor.id}` : labelFromLiteral(actor.type);
+}
+
+function isActionRequestLifecycleKind(entry: CoreWorkbenchTraceEntry) {
+ return (
+ entry.kind === 'action_request_proposed' ||
+ entry.kind === 'action_request_approved' ||
+ entry.kind === 'action_request_rejected' ||
+ entry.kind === 'action_request_execution'
+ );
+}
+
+function traceLane(
+ entry: CoreWorkbenchTraceEntry
+): 'Step' | 'Action' | 'Event' | 'Delivery' | 'Audit' {
+ if (isActionRequestLifecycleKind(entry)) {
+ return 'Action';
+ }
+ if (
+ entry.kind === 'health_plan_state' ||
+ entry.kind === 'task_state' ||
+ entry.kind === 'checkin_received'
+ ) {
+ return 'Step';
+ }
+ if (
+ entry.kind === 'outbox_delivery' ||
+ entry.kind === 'delivery_failed' ||
+ entry.kind === 'delivery_succeeded' ||
+ entry.kind === 'delivery_recovered' ||
+ entry.kind === 'delivery_dead_lettered'
+ ) {
+ return 'Delivery';
+ }
+ if (entry.kind === 'audit_action' || entry.source === 'audit_logs') return 'Audit';
+ if (entry.source === 'health_plans' || entry.source === 'task_instances') return 'Step';
+ if (entry.source === 'outbox_deliveries') return 'Delivery';
+ return 'Event';
+}
+
+function traceDeliveryStatus(entry: CoreWorkbenchTraceEntry): string | null {
+ if (entry.kind === 'delivery_failed') return 'Failed · Retry scheduled';
+ if (entry.kind === 'delivery_recovered') return 'Recovered';
+ if (entry.kind === 'delivery_succeeded') return 'Succeeded';
+ if (entry.kind === 'delivery_dead_lettered') return 'Dead lettered';
+ if (entry.kind !== 'outbox_delivery') return null;
+ const state = entry.metadata?.state;
+ return typeof state === 'string' ? labelFromLiteral(state) : null;
+}
+
+function WorkbenchAlertContextPanel({
+ alert,
+ t,
+}: {
+ alert: CoreWorkbenchAlert;
+ t: (key: string, fallback?: string) => string;
+}) {
+ const none = t('workbench.none');
+ const context = alert.context;
+ if (!context) {
+ return (
+
+ {t('workbench.contextUnavailable')}
+
+ );
+ }
+
+ const latestCheckin = context.latest_checkin;
+
+ return (
+
+
+
+ {t('workbench.context.pet', 'Pet')}
+
+
{context.pet.name}
+
+ {context.pet.species} · {context.pet.status}
+ {context.pet.breed ? ` · ${context.pet.breed}` : ''}
+
+
+
+
+ {t('workbench.context.owner', 'Owner')}
+
+
{context.owner.name}
+
+ {formatOptional(context.owner.phone, none)} · {context.owner.status}
+
+
+
+
+ {t('workbench.context.plan', 'Health plan')}
+
+
{context.health_plan.title}
+
+ {context.health_plan.plan_type} · {context.health_plan.status}
+
+
+ {t('workbench.context.flowId', 'Flow ID')}:{' '}
+ {formatOptional(context.health_plan.openclaw_flow_id, none)}
+
+
+
+
+ {t('workbench.context.task', 'Task')}
+
+
+ {context.task.status} · {t('workbench.context.missed', 'Missed')}:{' '}
+ {context.task.missed_count}
+
+
+ {t('workbench.context.due', 'Due')}: {formatDate(context.task.due_at, none)}
+
+
+ {t('workbench.context.flowId', 'Flow ID')}:{' '}
+ {formatOptional(context.task.openclaw_flow_id, none)}
+
+
+
+
+ {t('workbench.context.latestCheckin', 'Latest check-in')}
+
+ {latestCheckin ? (
+ <>
+
{formatOptional(latestCheckin.text, none)}
+
+ {formatDate(latestCheckin.submitted_at, none)}
+ {latestCheckin.status_tags.length > 0
+ ? ` · ${latestCheckin.status_tags.join(', ')}`
+ : ''}
+
+ >
+ ) : (
+
{none}
+ )}
+
+
+ );
+}
+
+function WorkbenchTraceDrawer({
+ alert,
+ trace,
+ loading,
+ refreshing,
+ error,
+ onClose,
+ onRefresh,
+ t,
+}: {
+ alert: CoreWorkbenchAlert;
+ trace: CoreWorkbenchAlertTrace | null;
+ loading: boolean;
+ refreshing: boolean;
+ error: string | null;
+ onClose: () => void;
+ onRefresh: () => void;
+ t: (key: string, fallback?: string) => string;
+}) {
+ const none = t('workbench.none');
+ const drawerRef = useRef(null);
+
+ useEffect(() => {
+ const previousFocus =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ drawerRef.current?.focus();
+
+ const focusableSelector = [
+ 'button:not([disabled])',
+ '[href]',
+ 'input:not([disabled])',
+ 'select:not([disabled])',
+ 'textarea:not([disabled])',
+ '[tabindex]:not([tabindex="-1"])',
+ ].join(',');
+
+ 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(focusableSelector)
+ ).filter(
+ element =>
+ !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true'
+ );
+ if (focusable.length === 0) {
+ event.preventDefault();
+ drawerRef.current.focus();
+ return;
+ }
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ const activeElement =
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
+ if (activeElement === drawerRef.current || !drawerRef.current.contains(activeElement)) {
+ event.preventDefault();
+ (event.shiftKey ? last : first).focus();
+ } else if (event.shiftKey && activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ previousFocus?.focus();
+ };
+ }, [onClose]);
+
+ return (
+ {
+ if (event.target === event.currentTarget) onClose();
+ }}>
+
+
+ );
+}
+
+const TRACE_WARNING_TITLE_KEYS: Record = {
+ missing_related_action_request: 'workbench.trace.warning.missingRelatedActionRequest',
+ action_request_links_truncated: 'workbench.trace.warning.actionRequestLinksTruncated',
+ trace_reserved_budget_exceeded: 'workbench.trace.warning.traceReservedBudgetExceeded',
+};
+
+function TraceWarningNotice({
+ warning,
+ t,
+}: {
+ warning: CoreWorkbenchTraceWarning;
+ t: (key: string, fallback?: string) => string;
+}) {
+ const titleKey = TRACE_WARNING_TITLE_KEYS[warning.code];
+ const title = titleKey
+ ? t(titleKey, labelFromLiteral(warning.code))
+ : labelFromLiteral(warning.code);
+ return (
+
+
{title}
+
{warning.message}
+ {warning.source ? (
+
{labelFromLiteral(warning.source)}
+ ) : null}
+
+ );
+}
+
+function formatActionTarget(metadata: Record, none: string): string {
+ const targetType = metadataString(metadata, 'target_type');
+ const targetId = metadataString(metadata, 'target_id');
+ if (targetType && targetId) return `${targetType} / ${targetId}`;
+ return targetId ?? targetType ?? none;
+}
+
+function ActionRequestFields({
+ entry,
+ none,
+ t,
+}: {
+ entry: CoreWorkbenchTraceEntry;
+ none: string;
+ t: (key: string, fallback?: string) => string;
+}) {
+ const metadata = entry.metadata ?? {};
+ const fields: Array<[string, string]> = [];
+ const actionRequestId = metadataString(metadata, 'action_request_id');
+ const actionType = metadataString(metadata, 'action_type');
+ const risk = metadataString(metadata, 'risk');
+ const policyOutcome = metadataString(metadata, 'policy_outcome');
+ const requiredApproverClass = metadataString(metadata, 'required_approver_class');
+ const approvalState = metadataString(metadata, 'approval_state');
+ const approverClass = metadataString(metadata, 'approver_class');
+ const executionState = metadataString(metadata, 'execution_state');
+ const resultOutcome = metadataString(metadata, 'result_outcome_code');
+ const errorCode = metadataString(metadata, 'error_code');
+ const errorMessage = metadataString(metadata, 'error_message');
+ if (actionRequestId) {
+ fields.push([t('workbench.trace.actionRequestId', 'Action request'), actionRequestId]);
+ }
+ if (actionType) {
+ fields.push([t('workbench.trace.actionType', 'Action type'), actionType]);
+ }
+ if (metadataString(metadata, 'target_type') || metadataString(metadata, 'target_id')) {
+ fields.push([t('workbench.trace.target', 'Target'), formatActionTarget(metadata, none)]);
+ }
+ if (risk) {
+ fields.push([t('workbench.trace.risk', 'Risk'), risk]);
+ }
+ if (policyOutcome) {
+ fields.push([t('workbench.trace.policyOutcome', 'Policy'), policyOutcome]);
+ }
+ if (requiredApproverClass) {
+ fields.push([
+ t('workbench.trace.requiredApproverClass', 'Required approver'),
+ requiredApproverClass,
+ ]);
+ }
+ if (approvalState) {
+ fields.push([t('workbench.trace.approvalState', 'Approval'), approvalState]);
+ }
+ if (approverClass) {
+ fields.push([t('workbench.trace.approverClass', 'Approver class'), approverClass]);
+ }
+ if (executionState) {
+ fields.push([t('workbench.trace.executionState', 'Execution'), executionState]);
+ }
+ if (resultOutcome) {
+ fields.push([t('workbench.trace.executionResult', 'Result'), resultOutcome]);
+ }
+ if (errorCode || errorMessage) {
+ fields.push([
+ t('workbench.trace.executionError', 'Error'),
+ [errorCode, errorMessage].filter(Boolean).join(' · '),
+ ]);
+ }
+ if (fields.length === 0) return null;
+ return (
+
+ {fields.map(([label, value]) => (
+
+
- {label}
+ - {value}
+
+ ))}
+
+ );
+}
+
+function TraceEntryItem({
+ entry,
+ none,
+ t,
+}: {
+ entry: CoreWorkbenchTraceEntry;
+ none: string;
+ t: (key: string, fallback?: string) => string;
+}) {
+ const isAction = isActionRequestLifecycleKind(entry);
+ const chips = metadataChips(entry.metadata, isAction ? ACTION_NAMED_METADATA_KEYS : undefined);
+ const lane = traceLane(entry);
+ const laneLabel = isAction ? t('workbench.trace.lane.action', 'Action') : lane;
+ const deliveryStatus = traceDeliveryStatus(entry);
+
+ return (
+
+
+
+ {laneLabel}
+
+
+ {labelFromLiteral(entry.kind)}
+
+
+ {labelFromLiteral(entry.source)}
+
+ {entry.severity ? (
+
+ {labelFromLiteral(entry.severity)}
+
+ ) : null}
+ {deliveryStatus ? (
+
+ {deliveryStatus}
+
+ ) : null}
+
+ {entry.title}
+ {formatDate(entry.occurred_at, none)}
+ {entry.detail ? {entry.detail}
: null}
+
+
+
- {t('workbench.trace.actor', 'Actor')}
+ - {formatTraceActor(entry.actor, none)}
+
+
+
-
+ {t('workbench.trace.related', 'Related')}
+
+ -
+ {entry.related_type && entry.related_id
+ ? `${entry.related_type} / ${entry.related_id}`
+ : none}
+
+
+
+ {isAction ? : null}
+ {chips.length > 0 ? (
+
+ {chips.map(([key, displayKey, value]) => (
+
+ {displayKey}: {value}
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+const Workbench = () => {
+ const { t } = useT();
+ const client = useMemo(() => createCoreWorkbenchClient({ timeoutMs: 15_000 }), []);
+ const [status, setStatus] = useState('open');
+ const [severity, setSeverity] = useState('all');
+ const [alerts, setAlerts] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+ const [error, setError] = useState(null);
+ const [actionError, setActionError] = useState(null);
+ const [pendingActions, setPendingActions] = useState({});
+ const [notes, setNotes] = useState>({});
+ const [resolutions, setResolutions] = useState>({});
+ const [traceAlertId, setTraceAlertId] = useState(null);
+ const [traceAlert, setTraceAlert] = useState(null);
+ const [trace, setTrace] = useState(null);
+ const [traceLoading, setTraceLoading] = useState(false);
+ const [traceRefreshing, setTraceRefreshing] = useState(false);
+ const [traceError, setTraceError] = useState(null);
+ const alertsRequestSeq = useRef(0);
+ const traceRequestSeq = useRef(0);
+ const activeTraceAlertRef = useRef(null);
+
+ const loadAlerts = useCallback(
+ async (mode: 'initial' | 'refresh' = 'refresh') => {
+ const requestId = alertsRequestSeq.current + 1;
+ alertsRequestSeq.current = requestId;
+ if (mode === 'initial') {
+ setLoading(true);
+ } else {
+ setRefreshing(true);
+ }
+ setError(null);
+ try {
+ const next = await client.listAlerts({
+ status: status === 'all' ? null : status,
+ severity: severity === 'all' ? undefined : severity,
+ });
+ if (alertsRequestSeq.current !== requestId) {
+ return;
+ }
+ setAlerts(next);
+ } catch {
+ if (alertsRequestSeq.current !== requestId) {
+ return;
+ }
+ setError(t('workbench.requestFailed'));
+ } finally {
+ if (alertsRequestSeq.current === requestId) {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }
+ },
+ [client, severity, status, t]
+ );
+
+ useEffect(() => {
+ void loadAlerts('initial');
+ }, [loadAlerts]);
+
+ const runAction = async (alert: CoreWorkbenchAlert, action: AlertAction) => {
+ if (!resolveWorkbenchActiveUserScope()) {
+ setActionError(
+ t(
+ 'actionRequest.storageUnavailable',
+ 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.'
+ )
+ );
+ return;
+ }
+ const intent =
+ action === 'ack' ? notes[alert.id]?.trim() || '' : resolutions[alert.id]?.trim() || '';
+ const { key: idempotencyKey, persisted } = getOrCreateIdempotencyKey(alert.id, action, intent);
+ if (!persisted || !idempotencyKey) {
+ setActionError(
+ t(
+ 'actionRequest.storageUnavailable',
+ 'Local retry-key storage is unavailable. Decision blocked until storage works so retries stay idempotent.'
+ )
+ );
+ return;
+ }
+ setPendingActions(current => ({ ...current, [alert.id]: action }));
+ setActionError(null);
+ try {
+ const updated =
+ action === 'ack'
+ ? await client.ackAlert(alert.id, {
+ note: notes[alert.id]?.trim() || undefined,
+ idempotencyKey,
+ })
+ : await client.resolveAlert(alert.id, {
+ resolution: resolutions[alert.id]?.trim() || undefined,
+ idempotencyKey,
+ });
+ clearIdempotencyKey(alert.id, action, intent);
+ setAlerts(current =>
+ current.map(item =>
+ item.id === updated.id ? { ...updated, context: updated.context ?? item.context } : item
+ )
+ );
+ await loadAlerts('refresh');
+ } catch {
+ setActionError(t('workbench.requestFailed'));
+ } finally {
+ setPendingActions(current => {
+ const { [alert.id]: _finished, ...rest } = current;
+ return rest;
+ });
+ }
+ };
+
+ const loadTrace = useCallback(
+ async (alertId: string, mode: 'initial' | 'refresh' = 'initial') => {
+ const requestId = traceRequestSeq.current + 1;
+ traceRequestSeq.current = requestId;
+ activeTraceAlertRef.current = alertId;
+ if (mode === 'initial') {
+ setTraceLoading(true);
+ setTrace(null);
+ } else {
+ setTraceRefreshing(true);
+ }
+ setTraceError(null);
+ try {
+ const nextTrace = await client.getAlertTrace(alertId);
+ if (traceRequestSeq.current !== requestId || activeTraceAlertRef.current !== alertId) {
+ return;
+ }
+ setTrace(nextTrace);
+ } catch {
+ if (traceRequestSeq.current !== requestId || activeTraceAlertRef.current !== alertId) {
+ return;
+ }
+ setTraceError(t('workbench.trace.requestFailed', 'Trace request failed. Try again.'));
+ } finally {
+ if (traceRequestSeq.current === requestId && activeTraceAlertRef.current === alertId) {
+ setTraceLoading(false);
+ setTraceRefreshing(false);
+ }
+ }
+ },
+ [client, t]
+ );
+
+ const openTrace = (alert: CoreWorkbenchAlert) => {
+ setTraceAlertId(alert.id);
+ setTraceAlert(alert);
+ setTrace(null);
+ setTraceError(null);
+ void loadTrace(alert.id, 'initial');
+ };
+
+ const closeTrace = useCallback(() => {
+ traceRequestSeq.current += 1;
+ activeTraceAlertRef.current = null;
+ setTraceAlertId(null);
+ setTraceAlert(null);
+ setTrace(null);
+ setTraceError(null);
+ setTraceLoading(false);
+ setTraceRefreshing(false);
+ }, []);
+
+ const refreshTrace = useCallback(() => {
+ if (!traceAlertId) return;
+ void loadTrace(traceAlertId, 'refresh');
+ }, [loadTrace, traceAlertId]);
+
+ const isAlertPending = (alertId: string) => Boolean(pendingActions[alertId]);
+ const isActionPending = (alertId: string, action: AlertAction) =>
+ pendingActions[alertId] === action;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+ {actionError && (
+
+ {actionError}
+
+ )}
+
+
+ {loading ? (
+ {t('workbench.loading')}
+ ) : alerts.length === 0 ? (
+ {t('workbench.empty')}
+ ) : (
+
+ {alerts.map(alert => (
+
+
+
+
+ {alert.severity}
+
+
+ {alert.status}
+
+ {alert.alert_type}
+
+
+ {alert.summary || t('workbench.noSummary')}
+
+
+
+
-
+ {t('workbench.related')}
+
+ -
+ {alert.related_type} / {alert.related_id}
+
+
+
+
-
+ {t('workbench.created')}
+
+ - {formatDate(alert.created_at, t('workbench.none'))}
+
+
+
-
+ {t('workbench.acknowledged')}
+
+ - {formatDate(alert.acknowledged_at, t('workbench.none'))}
+
+
+
-
+ {t('workbench.resolved')}
+
+ - {formatDate(alert.resolved_at, t('workbench.none'))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {traceAlert ? (
+
+ ) : null}
+
+
+ );
+};
+
+export default Workbench;
diff --git a/app/src/pages/__tests__/Home.test.tsx b/app/src/pages/__tests__/Home.test.tsx
index ac2b245050..310749537a 100644
--- a/app/src/pages/__tests__/Home.test.tsx
+++ b/app/src/pages/__tests__/Home.test.tsx
@@ -14,7 +14,8 @@ vi.mock('../../utils/config', async importOriginal => {
return { ...actual, APP_VERSION: '0.0.0-test' };
});
-vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
+const navigateMock = vi.hoisted(() => vi.fn());
+vi.mock('react-router-dom', () => ({ useNavigate: () => navigateMock }));
const mockUseUsageState = vi.hoisted(() =>
vi.fn(() => ({ shouldShowBudgetCompletedMessage: false }))
@@ -34,6 +35,7 @@ vi.mock('../../services/api/openrouterFreeModels', () => ({
const useAppSelectorMock = vi.fn(() => 'ok' as string);
const useAppDispatchMock = vi.fn(() => vi.fn());
const themeModeProbe = { current: 'system' as 'system' | 'light' | 'dark' };
+const connectivityErrorsProbe = { current: {} as { core?: string } };
/* eslint-disable react-hooks/rules-of-hooks -- mock factories, not real hooks */
vi.mock('../../store/hooks', () => ({
useAppSelector: (selector: unknown) => {
@@ -41,10 +43,27 @@ vi.mock('../../store/hooks', () => ({
try {
const probed = (selector as (s: unknown) => unknown)({
theme: { mode: themeModeProbe.current },
+ connectivity: {
+ internet: 'online',
+ core: 'reachable',
+ backend: 'connected',
+ lastError: connectivityErrorsProbe.current,
+ },
});
if (probed === 'system' || probed === 'light' || probed === 'dark') {
return probed;
}
+ if (
+ probed === 'ok' ||
+ probed === 'backend-only' ||
+ probed === 'core-unreachable' ||
+ probed === 'internet-offline'
+ ) {
+ return useAppSelectorMock();
+ }
+ if (probed && typeof probed === 'object') {
+ return connectivityErrorsProbe.current;
+ }
} catch {
// Selector didn't tolerate the probe — fall through to default.
}
@@ -56,7 +75,11 @@ vi.mock('../../store/hooks', () => ({
/* eslint-enable react-hooks/rules-of-hooks */
vi.mock('../../store/socketSelectors', () => ({ selectSocketStatus: vi.fn() }));
-vi.mock('../../store/connectivitySelectors', () => ({ selectBlockingState: vi.fn() }));
+vi.mock('../../store/connectivitySelectors', () => ({
+ selectBlockingState: () => 'ok',
+ selectConnectivityErrors: (state: { connectivity: { lastError: { core?: string } } }) =>
+ state.connectivity.lastError,
+}));
vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() }));
@@ -105,6 +128,40 @@ describe('resolveHomeUserName', () => {
});
describe('Home page — handleRestartCore and blocking state rendering', () => {
+ it('links to Core Registries from Home', async () => {
+ useAppSelectorMock.mockReturnValue('ok');
+ const { default: Home } = await import('../Home');
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: /Core Registries/i }));
+ expect(navigateMock).toHaveBeenCalledWith('/registries');
+ });
+
+ it('shows missing-config copy on the Core Registries card', async () => {
+ useAppSelectorMock.mockReturnValue('core-unreachable');
+ connectivityErrorsProbe.current = { core: 'config missing' };
+
+ const { default: Home } = await import('../Home');
+ render();
+
+ expect(screen.getByRole('button', { name: /Core integration required/i })).toBeInTheDocument();
+ expect(
+ screen.getByText('Complete the desktop Core integration before inspecting registries.')
+ ).toBeInTheDocument();
+ });
+
+ it('shows invalid-config copy on the Core Registries card', async () => {
+ useAppSelectorMock.mockReturnValue('core-unreachable');
+ connectivityErrorsProbe.current = { core: 'config invalid' };
+
+ const { default: Home } = await import('../Home');
+ render();
+
+ expect(
+ screen.getByText('Repair the desktop Core integration before inspecting registries.')
+ ).toBeInTheDocument();
+ });
+
it('shows "Restart Core" button when blocking=core-unreachable (lines 194, 200)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
@@ -116,6 +173,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('does NOT show "Restart Core" button when blocking=ok (line 194)', async () => {
useAppSelectorMock.mockReturnValue('ok');
+ connectivityErrorsProbe.current = {};
const { default: Home } = await import('../Home');
render();
@@ -125,6 +183,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('handleRestartCore calls restartCoreProcess and resets state on success (lines 78-81, 85)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
+ connectivityErrorsProbe.current = {};
restartCoreProcessMock.mockResolvedValueOnce(undefined);
@@ -146,6 +205,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('handleRestartCore shows error message when restartCoreProcess throws (lines 78-83, 202)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
+ connectivityErrorsProbe.current = {};
restartCoreProcessMock.mockRejectedValueOnce(new Error('sidecar not found'));
@@ -160,6 +220,7 @@ describe('Home page — handleRestartCore and blocking state rendering', () => {
it('handleRestartCore shows string error when restartCoreProcess throws a non-Error (lines 83)', async () => {
useAppSelectorMock.mockReturnValue('core-unreachable');
+ connectivityErrorsProbe.current = {};
restartCoreProcessMock.mockRejectedValueOnce('raw string error');
diff --git a/app/src/services/__tests__/rpcMethods.test.ts b/app/src/services/__tests__/rpcMethods.test.ts
index 7e501257c4..7067d88f76 100644
--- a/app/src/services/__tests__/rpcMethods.test.ts
+++ b/app/src/services/__tests__/rpcMethods.test.ts
@@ -4,6 +4,46 @@ import { describe, expect, test } from 'vitest';
import { CORE_RPC_METHODS, LEGACY_METHOD_ALIASES, normalizeRpcMethod } from '../rpcMethods';
+const EXPECTED_REGISTRY_METHODS = {
+ youpetRegistryListAgents: 'openhuman.youpet_registry_list_agents',
+ youpetRegistryGetAgentVersion: 'openhuman.youpet_registry_get_agent_version',
+ youpetRegistryListToolDefinitions: 'openhuman.youpet_registry_list_tool_definitions',
+ youpetRegistryGetToolDefinitionVersion: 'openhuman.youpet_registry_get_tool_definition_version',
+ youpetRegistryListToolEnablements: 'openhuman.youpet_registry_list_tool_enablements',
+ youpetRegistryGetToolEnablementVersion: 'openhuman.youpet_registry_get_tool_enablement_version',
+ youpetRegistryListConnectorTypes: 'openhuman.youpet_registry_list_connector_types',
+ youpetRegistryGetConnectorTypeVersion: 'openhuman.youpet_registry_get_connector_type_version',
+ youpetRegistryListConnectorBindings: 'openhuman.youpet_registry_list_connector_bindings',
+ youpetRegistryGetConnectorBindingVersion:
+ 'openhuman.youpet_registry_get_connector_binding_version',
+} as const;
+
+function parseCoreRpcMethodsFromSource(): Record {
+ const source = fs.readFileSync(path.resolve(__dirname, '../rpcMethods.ts'), 'utf8');
+ const start = source.indexOf('export const CORE_RPC_METHODS = {');
+ const end = source.indexOf('} as const;', start);
+
+ if (start === -1 || end === -1) {
+ throw new Error('CORE_RPC_METHODS source block not found');
+ }
+
+ const body = source
+ .slice(start + 'export const CORE_RPC_METHODS = {'.length, end)
+ .split('\n')
+ .map(line => line.trim())
+ .filter(line => line.length > 0 && !line.startsWith('//'));
+
+ return Object.fromEntries(
+ body.map(line => {
+ const entry = line.match(/^([A-Za-z0-9]+):\s*(['"])(.+)\2,$/);
+ if (!entry) {
+ throw new Error(`CORE_RPC_METHODS entry is not a single-line quoted value: ${line}`);
+ }
+ return [entry[1], entry[3]];
+ })
+ );
+}
+
describe('rpcMethods catalog', () => {
describe('normalizeRpcMethod', () => {
test('resolves all legacy aliases to their canonical core method', () => {
@@ -44,6 +84,21 @@ describe('rpcMethods catalog', () => {
);
});
+ test('registers all ten Core Registries RPC methods with the Rust spellings', () => {
+ expect(
+ Object.fromEntries(
+ Object.keys(EXPECTED_REGISTRY_METHODS).map(key => [
+ key,
+ CORE_RPC_METHODS[key as keyof typeof EXPECTED_REGISTRY_METHODS],
+ ])
+ )
+ ).toEqual(EXPECTED_REGISTRY_METHODS);
+ });
+
+ test('keeps every CORE_RPC_METHODS entry parseable as a single-line quoted source value', () => {
+ expect(parseCoreRpcMethodsFromSource()).toEqual(CORE_RPC_METHODS);
+ });
+
describe('MCP client legacy alias resolution (Sentry CORE-RUST-DW/DV/DT/DS/DR)', () => {
test('mcp_clients.list resolves to mcp_clients_installed_list', () => {
expect(normalizeRpcMethod('mcp_clients.list')).toBe(CORE_RPC_METHODS.mcpClientsInstalledList);
@@ -144,33 +199,47 @@ describe('rpcMethods catalog', () => {
return [fs.readFileSync(abs, 'utf8'), ...parts].join('\n');
};
+ const configSchemaSource = readWithParts(
+ '../../../../src/openhuman/config/schemas/schema_defs.rs'
+ );
+ const inferenceProviderSchemaSource = readWithParts(
+ '../../../../src/openhuman/inference/provider/schemas.rs'
+ );
+ const inferenceSchemaSource = readWithParts('../../../../src/openhuman/inference/schemas.rs');
+ const inferenceLocalSchemaSource = readWithParts(
+ '../../../../src/openhuman/inference/local/schemas.rs'
+ );
+ const embeddingsSchemaSource = readWithParts(
+ '../../../../src/openhuman/inference/embeddings/schemas.rs'
+ );
+ const mcpRegistrySchemaSource = readWithParts(
+ '../../../../src/openhuman/mcp/registry/schemas.rs'
+ );
+ const toolRegistrySchemaSource = readWithParts(
+ '../../../../src/openhuman/tools/registry/schemas.rs'
+ );
+ const healthSchemaSource = readWithParts(
+ '../../../../src/openhuman/platform/health/schemas.rs'
+ );
+ const youpetSchemaSource = readWithParts('../../../../src/openhuman/youpet/schemas.rs');
+ const youpetRegistrySchemaSource = readWithParts(
+ '../../../../src/openhuman/youpet/registry/schemas.rs'
+ );
+ const channelsSchemaSource = readWithParts(
+ '../../../../src/openhuman/channels/controllers/schemas.rs'
+ );
const schemaSources = [
- readWithParts('../../../../src/openhuman/config/schemas/schema_defs.rs'),
- readWithParts('../../../../src/openhuman/inference/provider/schemas.rs'),
- readWithParts('../../../../src/openhuman/inference/schemas.rs'),
- readWithParts('../../../../src/openhuman/inference/local/schemas.rs'),
- readWithParts('../../../../src/openhuman/inference/embeddings/schemas.rs'),
- readWithParts('../../../../src/openhuman/mcp/registry/schemas.rs'),
- readWithParts('../../../../src/openhuman/tools/registry/schemas.rs'),
- readWithParts('../../../../src/openhuman/platform/health/schemas.rs'),
- readWithParts('../../../../src/openhuman/channels/controllers/schemas.rs'),
- // The channels_* namespace/function literals now live in the vendored
- // tinychannels workspace (`ChannelControllerSchema`), not in the thin
- // `src/openhuman/channels/controllers/schemas.rs` adapter above, which
- // only converts from it (#4557 "Use tinychannels provider
- // implementations") — read both so this drift guard still sees them.
- //
- // Controller metadata is contract, so it lives in the `tinychannels-bus`
- // crate rather than the implementation crate. `readFileSync` throws on a
- // missing path, which is what we want: if this file moves again the guard
- // fails loudly instead of silently checking a shorter corpus and passing.
- fs.readFileSync(
- path.resolve(
- __dirname,
- '../../../../vendor/tinychannels/crates/tinychannels-bus/src/controllers/schemas.rs'
- ),
- 'utf8'
- ),
+ configSchemaSource,
+ inferenceProviderSchemaSource,
+ inferenceSchemaSource,
+ inferenceLocalSchemaSource,
+ embeddingsSchemaSource,
+ mcpRegistrySchemaSource,
+ toolRegistrySchemaSource,
+ healthSchemaSource,
+ youpetSchemaSource,
+ youpetRegistrySchemaSource,
+ channelsSchemaSource,
].join('\n');
for (const method of Object.values(CORE_RPC_METHODS)) {
@@ -189,12 +258,20 @@ describe('rpcMethods catalog', () => {
? 'health'
: methodRoot.startsWith('channels_')
? 'channels'
- : methodRoot.startsWith('tool_registry_')
- ? 'tool_registry'
- : 'config';
+ : methodRoot.startsWith('youpet_registry_')
+ ? 'youpet'
+ : methodRoot.startsWith('youpet_')
+ ? 'youpet'
+ : methodRoot.startsWith('tool_registry_')
+ ? 'tool_registry'
+ : 'config';
const fnName = methodRoot.slice(`${namespace}_`.length);
- expect(schemaSources).toContain(`namespace: "${namespace}"`);
- expect(schemaSources).toContain(`function: "${fnName}"`);
+ if (namespace === 'channels') {
+ expect(channelsSchemaSource).toContain(`schemas("${fnName}")`);
+ } else {
+ expect(schemaSources).toContain(`namespace: "${namespace}"`);
+ expect(schemaSources).toContain(`function: "${fnName}"`);
+ }
}
});
});
diff --git a/app/src/services/api/coreActionRequestClient.test.ts b/app/src/services/api/coreActionRequestClient.test.ts
new file mode 100644
index 0000000000..817e612639
--- /dev/null
+++ b/app/src/services/api/coreActionRequestClient.test.ts
@@ -0,0 +1,137 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { CORE_RPC_METHODS } from '../rpcMethods';
+import {
+ createCoreActionRequestClient,
+ extractYoupetErrorCode,
+ extractYoupetErrorField,
+} from './coreActionRequestClient';
+
+const callCoreRpc = vi.hoisted(() => vi.fn());
+
+vi.mock('../coreRpcClient', () => ({ callCoreRpc }));
+
+const SAMPLE = {
+ action_request: {
+ id: '33333333-3333-4333-8333-333333333333',
+ action_type: 'task.escalate',
+ risk: 'high',
+ links: {
+ workflow_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ audit_log_ids: [],
+ domain_event_ids: [],
+ outbox_delivery_ids: [],
+ },
+ },
+ row_version: 2,
+ id: '33333333-3333-4333-8333-333333333333',
+ tenant_id: '20000000-0000-0000-0000-000000000001',
+ 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',
+};
+
+describe('CoreActionRequestClient', () => {
+ beforeEach(() => {
+ callCoreRpc.mockReset();
+ });
+
+ it('lists action requests with filter params', async () => {
+ callCoreRpc.mockResolvedValueOnce([SAMPLE]);
+ const client = createCoreActionRequestClient();
+ const items = await client.list({
+ tenantId: '20000000-0000-0000-0000-000000000001',
+ approvalState: 'pending',
+ executionState: 'not_started',
+ limit: 25,
+ });
+ expect(items).toEqual([SAMPLE]);
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: CORE_RPC_METHODS.youpetListActionRequests,
+ params: {
+ tenantId: '20000000-0000-0000-0000-000000000001',
+ approvalState: 'pending',
+ executionState: 'not_started',
+ limit: 25,
+ },
+ timeoutMs: undefined,
+ });
+ });
+
+ it('gets one action request by id', async () => {
+ callCoreRpc.mockResolvedValueOnce({ result: SAMPLE });
+ const client = createCoreActionRequestClient({ timeoutMs: 5_000 });
+ const item = await client.get(SAMPLE.id);
+ expect(item).toEqual(SAMPLE);
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: CORE_RPC_METHODS.youpetGetActionRequest,
+ params: { actionRequestId: SAMPLE.id },
+ timeoutMs: 5_000,
+ });
+ });
+
+ it('approves with required reason, row version, and idempotency key', async () => {
+ callCoreRpc.mockResolvedValueOnce({ ...SAMPLE, approval_state: 'approved', row_version: 3 });
+ const client = createCoreActionRequestClient();
+ await client.approve(SAMPLE.id, {
+ reason: 'safe to proceed',
+ expectedRowVersion: 2,
+ idempotencyKey: 'ar-approve-1',
+ });
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: CORE_RPC_METHODS.youpetApproveActionRequest,
+ params: {
+ actionRequestId: SAMPLE.id,
+ reason: 'safe to proceed',
+ expectedRowVersion: 2,
+ idempotencyKey: 'ar-approve-1',
+ },
+ timeoutMs: undefined,
+ });
+ });
+
+ it('rejects with required reason and expected row version', async () => {
+ callCoreRpc.mockResolvedValueOnce({ ...SAMPLE, approval_state: 'rejected', row_version: 3 });
+ const client = createCoreActionRequestClient();
+ await client.reject(SAMPLE.id, {
+ reason: 'too risky',
+ expectedRowVersion: 2,
+ idempotencyKey: 'ar-reject-1',
+ });
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: CORE_RPC_METHODS.youpetRejectActionRequest,
+ params: {
+ actionRequestId: SAMPLE.id,
+ reason: 'too risky',
+ expectedRowVersion: 2,
+ idempotencyKey: 'ar-reject-1',
+ },
+ timeoutMs: undefined,
+ });
+ });
+
+ it('rejects blank idempotency keys before RPC dispatch', async () => {
+ const client = createCoreActionRequestClient();
+ await expect(
+ client.approve(SAMPLE.id, { reason: 'x', expectedRowVersion: 1, idempotencyKey: ' ' })
+ ).rejects.toThrow(/idempotencyKey is required/);
+ expect(callCoreRpc).not.toHaveBeenCalled();
+ });
+
+ it('extracts youpet error codes and fields from structured RPC failures', () => {
+ expect(
+ extractYoupetErrorCode({
+ data: { kind: 'YouPetCoreHttpError', youpet: { code: 'concurrency_conflict' } },
+ })
+ ).toBe('concurrency_conflict');
+ expect(
+ extractYoupetErrorField({
+ data: { kind: 'YouPetConfigMissing', youpet: { field: 'tenant_id' } },
+ })
+ ).toBe('tenant_id');
+ expect(extractYoupetErrorCode(new Error('nope'))).toBeNull();
+ });
+});
diff --git a/app/src/services/api/coreActionRequestClient.ts b/app/src/services/api/coreActionRequestClient.ts
new file mode 100644
index 0000000000..b7707d156f
--- /dev/null
+++ b/app/src/services/api/coreActionRequestClient.ts
@@ -0,0 +1,167 @@
+import { callCoreRpc } from '../coreRpcClient';
+import { CORE_RPC_METHODS } from '../rpcMethods';
+
+export type CoreApprovalState =
+ | 'not_required'
+ | 'pending'
+ | 'approved'
+ | 'rejected'
+ | 'expired'
+ | (string & {});
+
+export type CoreExecutionState =
+ | 'not_started'
+ | 'queued'
+ | 'running'
+ | 'succeeded'
+ | 'failed'
+ | 'cancelled'
+ | (string & {});
+
+export interface CoreActionRequestLifecycleEnvelope {
+ action_request: Record;
+ row_version: number;
+ id: string;
+ tenant_id: string;
+ approval_state: CoreApprovalState;
+ execution_state: CoreExecutionState;
+ policy_outcome: string;
+ correlation_id: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ListCoreActionRequestsParams {
+ tenantId?: string;
+ approvalState?: CoreApprovalState;
+ executionState?: CoreExecutionState;
+ limit?: number;
+}
+
+export interface CoreActionRequestDecisionParams {
+ reason: string;
+ expectedRowVersion: number;
+ /** Required stable per-intent key so retries are replay-safe. */
+ idempotencyKey: string;
+}
+
+export interface CoreActionRequestClientOptions {
+ timeoutMs?: number;
+}
+
+type CoreResult = T | { result: T; logs?: string[] };
+
+export class CoreActionRequestClient {
+ private readonly timeoutMs?: number;
+
+ constructor(options: CoreActionRequestClientOptions = {}) {
+ this.timeoutMs = options.timeoutMs;
+ }
+
+ async list(
+ params: ListCoreActionRequestsParams = {}
+ ): Promise {
+ const raw = await callCoreRpc>({
+ method: CORE_RPC_METHODS.youpetListActionRequests,
+ params,
+ timeoutMs: this.timeoutMs,
+ });
+ return unwrapCoreResult(raw);
+ }
+
+ async get(actionRequestId: string): Promise {
+ const raw = await callCoreRpc>({
+ method: CORE_RPC_METHODS.youpetGetActionRequest,
+ params: { actionRequestId },
+ timeoutMs: this.timeoutMs,
+ });
+ return unwrapCoreResult(raw);
+ }
+
+ async approve(
+ actionRequestId: string,
+ params: CoreActionRequestDecisionParams
+ ): Promise {
+ assertDecisionParams(params);
+ const raw = await callCoreRpc>({
+ method: CORE_RPC_METHODS.youpetApproveActionRequest,
+ params: {
+ actionRequestId,
+ reason: params.reason,
+ expectedRowVersion: params.expectedRowVersion,
+ idempotencyKey: params.idempotencyKey,
+ },
+ timeoutMs: this.timeoutMs,
+ });
+ return unwrapCoreResult(raw);
+ }
+
+ async reject(
+ actionRequestId: string,
+ params: CoreActionRequestDecisionParams
+ ): Promise {
+ assertDecisionParams(params);
+ const raw = await callCoreRpc>({
+ method: CORE_RPC_METHODS.youpetRejectActionRequest,
+ params: {
+ actionRequestId,
+ reason: params.reason,
+ expectedRowVersion: params.expectedRowVersion,
+ idempotencyKey: params.idempotencyKey,
+ },
+ timeoutMs: this.timeoutMs,
+ });
+ return unwrapCoreResult(raw);
+ }
+}
+
+function assertDecisionParams(params: CoreActionRequestDecisionParams) {
+ if (!params.reason?.trim()) {
+ throw new Error('reason is required for ActionRequest decisions');
+ }
+ if (!Number.isFinite(params.expectedRowVersion) || params.expectedRowVersion < 1) {
+ throw new Error('expectedRowVersion must be >= 1');
+ }
+ if (!params.idempotencyKey?.trim()) {
+ throw new Error('idempotencyKey is required for ActionRequest decisions');
+ }
+}
+
+export const createCoreActionRequestClient = (
+ options: CoreActionRequestClientOptions = {}
+): CoreActionRequestClient => new CoreActionRequestClient(options);
+
+function unwrapCoreResult(value: CoreResult): T {
+ if (isRecord(value) && 'result' in value) {
+ return value.result as T;
+ }
+ return value as T;
+}
+
+function isRecord(value: unknown): value is Record {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
+
+function youpetPayload(error: unknown): Record | null {
+ if (!error || typeof error !== 'object') return null;
+ const data = (error as { data?: unknown }).data;
+ if (!isRecord(data)) return null;
+ const youpet = data.youpet;
+ return isRecord(youpet) ? youpet : null;
+}
+
+/** Extract a stable Core lifecycle error code from structured RPC failures when present. */
+export function extractYoupetErrorCode(error: unknown): string | null {
+ const youpet = youpetPayload(error);
+ if (!youpet) return null;
+ const code = youpet.code;
+ return typeof code === 'string' && code.trim() ? code : null;
+}
+
+/** Extract a structured config/request field marker (e.g. tenant_id) when present. */
+export function extractYoupetErrorField(error: unknown): string | null {
+ const youpet = youpetPayload(error);
+ if (!youpet) return null;
+ const field = youpet.field;
+ return typeof field === 'string' && field.trim() ? field : null;
+}
diff --git a/app/src/services/api/coreRegistriesClient.test.ts b/app/src/services/api/coreRegistriesClient.test.ts
new file mode 100644
index 0000000000..8bbf814a09
--- /dev/null
+++ b/app/src/services/api/coreRegistriesClient.test.ts
@@ -0,0 +1,561 @@
+import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
+
+import { CoreRpcError } from '../coreRpcClient';
+import {
+ type AgentRegistryAgent,
+ type AgentRegistryAgentSummary,
+ type ConnectorRegistryBinding,
+ type ConnectorRegistryBindingSummary,
+ type ConnectorRegistryType,
+ type ConnectorRegistryTypeSummary,
+ coreRegistriesClient,
+ type CursorRegistryPage,
+ extractRegistryBridgeErrorMeta,
+ type RegistryBridgeErrorKind,
+ type RegistryCursorListParams,
+ type ToolRegistryToolDefinition,
+ type ToolRegistryToolDefinitionSummary,
+ type ToolRegistryToolEnablement,
+ type UnpagedRegistryCollection,
+} from './coreRegistriesClient';
+
+vi.mock('../coreRpcClient', async () => {
+ const actual = await vi.importActual('../coreRpcClient');
+ return { ...actual, callCoreRpc: vi.fn() };
+});
+
+describe('coreRegistriesClient contracts', () => {
+ beforeEach(async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc).mockReset();
+ });
+
+ it('freezes list/exact method param surfaces', () => {
+ expectTypeOf().toMatchTypeOf<{ limit?: number; cursor?: string }>();
+ expectTypeOf>().toEqualTypeOf();
+ expectTypeOf>().toEqualTypeOf();
+ expectTypeOf>().toEqualTypeOf();
+ expectTypeOf>().toEqualTypeOf();
+ expectTypeOf>().toEqualTypeOf();
+ expectTypeOf>().toEqualTypeOf();
+
+ expectTypeOf().parameters.toEqualTypeOf<
+ [params?: RegistryCursorListParams]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [params?: RegistryCursorListParams]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [params?: RegistryCursorListParams]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [params?: RegistryCursorListParams]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<[]>();
+
+ expectTypeOf().parameters.toEqualTypeOf<
+ [{ agentKey: string; version: number }]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [{ toolKey: string; version: number }]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [{ toolKey: string; version: number }]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [{ connectorKey: string; version: number }]
+ >();
+ expectTypeOf().parameters.toEqualTypeOf<
+ [{ bindingKey: string; version: number }]
+ >();
+ });
+
+ it('uses callCoreRpc for the four cursor-backed lists', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc)
+ .mockResolvedValueOnce({ items: [], next_cursor: null })
+ .mockResolvedValueOnce({ items: [], next_cursor: 'tool-cursor' })
+ .mockResolvedValueOnce({ items: [], next_cursor: 'connector-type-cursor' })
+ .mockResolvedValueOnce({ items: [], next_cursor: 'binding-cursor' });
+
+ await expect(
+ coreRegistriesClient.listAgents({ limit: 25, cursor: 'agent-cursor' })
+ ).resolves.toEqual({
+ items: [],
+ nextCursor: null,
+ } satisfies CursorRegistryPage);
+ await expect(coreRegistriesClient.listToolDefinitions()).resolves.toEqual({
+ items: [],
+ nextCursor: 'tool-cursor',
+ } satisfies CursorRegistryPage);
+ await expect(
+ coreRegistriesClient.listConnectorTypes({ cursor: 'connector-type-cursor' })
+ ).resolves.toEqual({
+ items: [],
+ nextCursor: 'connector-type-cursor',
+ } satisfies CursorRegistryPage);
+ await expect(coreRegistriesClient.listConnectorBindings({ limit: 10 })).resolves.toEqual({
+ items: [],
+ nextCursor: 'binding-cursor',
+ } satisfies CursorRegistryPage);
+
+ expect(callCoreRpc).toHaveBeenNthCalledWith(1, {
+ method: 'openhuman.youpet_registry_list_agents',
+ params: { limit: 25, cursor: 'agent-cursor' },
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(2, {
+ method: 'openhuman.youpet_registry_list_tool_definitions',
+ params: {},
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(3, {
+ method: 'openhuman.youpet_registry_list_connector_types',
+ params: { cursor: 'connector-type-cursor' },
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(4, {
+ method: 'openhuman.youpet_registry_list_connector_bindings',
+ params: { limit: 10 },
+ });
+ });
+
+ it('unwraps the logged Rust RpcOutcome envelope before registry decoding', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({
+ result: { items: [], next_cursor: null },
+ logs: ['[youpet] listed Core registry agents'],
+ });
+
+ await expect(coreRegistriesClient.listAgents()).resolves.toEqual({
+ items: [],
+ nextCursor: null,
+ });
+ });
+
+ it('uses items-only decoding for the unpaged tool enablement list', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({
+ items: [
+ {
+ tool_key: 'tool.alpha',
+ version: 3,
+ lifecycle_state: 'enabled',
+ generation: 11,
+ timeout_cap_ms: 5000,
+ approval_required: true,
+ allow_ttl_seconds: 120,
+ audit_mode: 'metadata_only',
+ updated_at: '2026-09-01T12:00:00Z',
+ },
+ ],
+ next_cursor: 'must-be-ignored',
+ });
+
+ await expect(coreRegistriesClient.listToolEnablements()).resolves.toEqual({
+ items: [
+ {
+ toolKey: 'tool.alpha',
+ version: 3,
+ lifecycleState: 'enabled',
+ generation: 11,
+ timeoutCapMs: 5000,
+ approvalRequired: true,
+ allowTtlSeconds: 120,
+ auditMode: 'metadata_only',
+ updatedAt: '2026-09-01T12:00:00Z',
+ },
+ ],
+ } satisfies UnpagedRegistryCollection);
+
+ expect(callCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.youpet_registry_list_tool_enablements',
+ params: {},
+ });
+ });
+
+ it('rejects cursor-backed responses that omit required nullable next_cursor', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({ items: [] });
+ const operation = coreRegistriesClient.listAgents();
+
+ await expect(operation).rejects.toMatchObject({
+ name: 'CoreRpcError',
+ data: { kind: 'YouPetCoreResponseShape' },
+ });
+ await expect(operation.catch(extractRegistryBridgeErrorMeta)).resolves.toEqual({
+ kind: 'YouPetCoreResponseShape',
+ });
+ });
+
+ it('rejects non-active rows from active-only agent and tool definition lists', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc)
+ .mockResolvedValueOnce({
+ items: [
+ {
+ id: 'agent-record',
+ agent_key: 'agent.alpha',
+ version: 7,
+ lifecycle_state: 'draft',
+ configuration_fingerprint: 'a'.repeat(64),
+ owner_actor_type: 'service',
+ owner_actor_id: 'registry-reader',
+ created_at: '2026-09-01T12:00:00Z',
+ },
+ ],
+ next_cursor: null,
+ })
+ .mockResolvedValueOnce({
+ items: [
+ {
+ tool_key: 'tool.alpha',
+ version: 3,
+ lifecycle_state: 'retired',
+ definition_fingerprint: 'b'.repeat(64),
+ schema_version: 1,
+ display_name: 'Tool Alpha',
+ description: 'Reads data',
+ tool_effect_class: 'read_only',
+ abstract_auth_scopes: ['scope.read'],
+ created_at: '2026-09-01T12:05:00Z',
+ },
+ ],
+ next_cursor: null,
+ });
+
+ await expect(coreRegistriesClient.listAgents()).rejects.toMatchObject({
+ name: 'CoreRpcError',
+ data: { kind: 'YouPetCoreResponseShape' },
+ });
+ await expect(coreRegistriesClient.listToolDefinitions()).rejects.toMatchObject({
+ name: 'CoreRpcError',
+ data: { kind: 'YouPetCoreResponseShape' },
+ });
+ });
+
+ it('classifies malformed exact-record shapes as YouPetCoreResponseShape', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc).mockResolvedValueOnce({
+ toolDefinition: {
+ tool_key: 'tool.alpha',
+ version: 3,
+ lifecycle_state: 'active',
+ definition_fingerprint: 'b'.repeat(64),
+ schema_version: 1,
+ display_name: 'Tool Alpha',
+ description: 'Reads data',
+ tool_effect_class: 'read_only',
+ abstract_auth_scopes: ['scope.read'],
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ timeout_defaults: { ms: 5000 },
+ retry_contract: { attempts: 2 },
+ audit_contract: { mode: 'metadata_only' },
+ },
+ });
+
+ const operation = coreRegistriesClient.getToolDefinitionVersion({
+ toolKey: 'tool.alpha',
+ version: 3,
+ });
+
+ await expect(operation).rejects.toMatchObject({
+ name: 'CoreRpcError',
+ data: { kind: 'YouPetCoreResponseShape' },
+ });
+ await expect(operation.catch(extractRegistryBridgeErrorMeta)).resolves.toEqual({
+ kind: 'YouPetCoreResponseShape',
+ });
+ });
+
+ it('loads exact records through domain-specific key/version methods', async () => {
+ const { callCoreRpc } = await import('../coreRpcClient');
+ vi.mocked(callCoreRpc)
+ .mockResolvedValueOnce({
+ agent: {
+ id: 'agent-record',
+ agent_key: 'agent.alpha',
+ version: 7,
+ lifecycle_state: 'active',
+ configuration: {
+ schema_version: 1,
+ domain_key: 'ops',
+ owner: { actor_type: 'service', actor_id: 'registry-reader' },
+ allowed_tool_refs: [{ tool_key: 'tool.alpha', version: 3 }],
+ knowledge_scope_refs: [
+ { source_key: 'docs', trust_version: 'v1', access_scope: 'read' },
+ ],
+ risk_policy_ref: { policy_id: 'risk.default', policy_version: 'v2' },
+ },
+ configuration_fingerprint: 'a'.repeat(64),
+ owner_actor_type: 'service',
+ owner_actor_id: 'registry-reader',
+ created_at: '2026-09-01T12:00:00Z',
+ },
+ })
+ .mockResolvedValueOnce({
+ toolDefinition: {
+ tool_key: 'tool.alpha',
+ version: 3,
+ lifecycle_state: 'active',
+ definition_fingerprint: 'b'.repeat(64),
+ schema_version: 1,
+ display_name: 'Tool Alpha',
+ description: 'Reads data',
+ tool_effect_class: 'read_only',
+ abstract_auth_scopes: ['scope.read'],
+ input_schema: { type: 'object' },
+ output_schema: { type: 'object' },
+ timeout_defaults: { ms: 5000 },
+ retry_contract: { attempts: 2 },
+ audit_contract: { mode: 'metadata_only' },
+ created_at: '2026-09-01T12:05:00Z',
+ },
+ })
+ .mockResolvedValueOnce({
+ toolEnablement: {
+ tool_key: 'tool.alpha',
+ version: 5,
+ lifecycle_state: 'disabled',
+ generation: 12,
+ timeout_cap_ms: null,
+ approval_required: false,
+ allow_ttl_seconds: null,
+ audit_mode: null,
+ updated_at: '2026-09-01T12:10:00Z',
+ },
+ })
+ .mockResolvedValueOnce({
+ connectorType: {
+ connector_key: 'wecom',
+ version: 2,
+ lifecycle_state: 'active',
+ source_type: 'wecom',
+ connector_type_fingerprint: 'c'.repeat(64),
+ capabilities: ['messages.read'],
+ normalization_contracts: [
+ {
+ evidence_family: 'messages',
+ kernel_event_type: 'message.created',
+ kernel_event_schema_version: 1,
+ },
+ ],
+ delivery_behavior: { mode: 'push' },
+ created_at: '2026-09-01T12:15:00Z',
+ },
+ })
+ .mockResolvedValueOnce({
+ connectorBinding: {
+ binding_key: 'wecom-primary',
+ version: 11,
+ lifecycle_state: 'retired',
+ connector_type_key: 'wecom',
+ connector_type_version: 2,
+ connector_type_fingerprint: 'd'.repeat(64),
+ provider_account: { namespace: 'wechat', external_account_ref: 'acct-1' },
+ config_ref: 'config://wecom/primary',
+ credential_ref: 'credential://wecom/primary',
+ enabled_capabilities: ['messages.read'],
+ binding_fingerprint: 'e'.repeat(64),
+ created_at: '2026-09-01T12:20:00Z',
+ },
+ });
+
+ await expect(
+ coreRegistriesClient.getAgentVersion({ agentKey: 'agent.alpha', version: 7 })
+ ).resolves.toEqual({
+ id: 'agent-record',
+ agentKey: 'agent.alpha',
+ version: 7,
+ lifecycleState: 'active',
+ configuration: {
+ schemaVersion: 1,
+ domainKey: 'ops',
+ owner: { actorType: 'service', actorId: 'registry-reader' },
+ allowedToolRefs: [{ toolKey: 'tool.alpha', version: 3 }],
+ knowledgeScopeRefs: [{ sourceKey: 'docs', trustVersion: 'v1', accessScope: 'read' }],
+ riskPolicyRef: { policyId: 'risk.default', policyVersion: 'v2' },
+ },
+ configurationFingerprint: 'a'.repeat(64),
+ ownerActorType: 'service',
+ ownerActorId: 'registry-reader',
+ createdAt: '2026-09-01T12:00:00Z',
+ } satisfies AgentRegistryAgent);
+ await expect(
+ coreRegistriesClient.getToolDefinitionVersion({ toolKey: 'tool.alpha', version: 3 })
+ ).resolves.toEqual({
+ 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'],
+ inputSchema: { type: 'object' },
+ outputSchema: { type: 'object' },
+ timeoutDefaults: { ms: 5000 },
+ retryContract: { attempts: 2 },
+ auditContract: { mode: 'metadata_only' },
+ createdAt: '2026-09-01T12:05:00Z',
+ } satisfies ToolRegistryToolDefinition);
+ await expect(
+ coreRegistriesClient.getToolEnablementVersion({ toolKey: 'tool.alpha', version: 5 })
+ ).resolves.toEqual({
+ toolKey: 'tool.alpha',
+ version: 5,
+ lifecycleState: 'disabled',
+ generation: 12,
+ timeoutCapMs: null,
+ approvalRequired: false,
+ allowTtlSeconds: null,
+ auditMode: null,
+ updatedAt: '2026-09-01T12:10:00Z',
+ } satisfies ToolRegistryToolEnablement);
+ await expect(
+ coreRegistriesClient.getConnectorTypeVersion({ connectorKey: 'wecom', version: 2 })
+ ).resolves.toEqual({
+ connectorKey: 'wecom',
+ version: 2,
+ lifecycleState: 'active',
+ sourceType: 'wecom',
+ connectorTypeFingerprint: 'c'.repeat(64),
+ capabilities: ['messages.read'],
+ normalizationContracts: [
+ {
+ evidenceFamily: 'messages',
+ kernelEventType: 'message.created',
+ kernelEventSchemaVersion: 1,
+ },
+ ],
+ deliveryBehavior: { mode: 'push' },
+ createdAt: '2026-09-01T12:15:00Z',
+ } satisfies ConnectorRegistryType);
+ await expect(
+ coreRegistriesClient.getConnectorBindingVersion({ bindingKey: 'wecom-primary', version: 11 })
+ ).resolves.toEqual({
+ bindingKey: 'wecom-primary',
+ version: 11,
+ lifecycleState: 'retired',
+ connectorTypeKey: 'wecom',
+ connectorTypeVersion: 2,
+ connectorTypeFingerprint: 'd'.repeat(64),
+ providerAccount: { namespace: 'wechat', externalAccountRef: 'acct-1' },
+ configRef: 'config://wecom/primary',
+ credentialRef: 'credential://wecom/primary',
+ enabledCapabilities: ['messages.read'],
+ bindingFingerprint: 'e'.repeat(64),
+ createdAt: '2026-09-01T12:20:00Z',
+ } satisfies ConnectorRegistryBinding);
+
+ expect(callCoreRpc).toHaveBeenNthCalledWith(1, {
+ method: 'openhuman.youpet_registry_get_agent_version',
+ params: { agentKey: 'agent.alpha', version: 7 },
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(2, {
+ method: 'openhuman.youpet_registry_get_tool_definition_version',
+ params: { toolKey: 'tool.alpha', version: 3 },
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(3, {
+ method: 'openhuman.youpet_registry_get_tool_enablement_version',
+ params: { toolKey: 'tool.alpha', version: 5 },
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(4, {
+ method: 'openhuman.youpet_registry_get_connector_type_version',
+ params: { connectorKey: 'wecom', version: 2 },
+ });
+ expect(callCoreRpc).toHaveBeenNthCalledWith(5, {
+ method: 'openhuman.youpet_registry_get_connector_binding_version',
+ params: { bindingKey: 'wecom-primary', version: 11 },
+ });
+ });
+
+ it('extracts only safe registry bridge error metadata for Q23-Q27 classes', () => {
+ const errorCases: Array<{
+ error: CoreRpcError;
+ expected: {
+ kind: RegistryBridgeErrorKind;
+ httpStatus?: number;
+ coreCode?: string;
+ retryAfterSeconds?: number;
+ };
+ }> = [
+ {
+ error: new CoreRpcError('config missing', 'unknown', undefined, {
+ kind: 'YouPetConfigMissing',
+ youpet: { field: 'service_token', secret: 'must-not-leak' },
+ }),
+ expected: { kind: 'YouPetConfigMissing' },
+ },
+ {
+ error: new CoreRpcError('forbidden', 'unknown', undefined, {
+ kind: 'YouPetCoreHttpError',
+ youpet: {
+ http_status: 403,
+ code: 'forbidden_actor',
+ raw_body: { detail: 'must-not-leak' },
+ },
+ }),
+ expected: { kind: 'YouPetCoreHttpError', httpStatus: 403, coreCode: 'forbidden_actor' },
+ },
+ {
+ error: new CoreRpcError('tenant unavailable', 'unknown', undefined, {
+ kind: 'YouPetCoreHttpError',
+ youpet: { http_status: 503, code: 'kernel_tenant_unavailable' },
+ }),
+ expected: {
+ kind: 'YouPetCoreHttpError',
+ httpStatus: 503,
+ coreCode: 'kernel_tenant_unavailable',
+ },
+ },
+ {
+ error: new CoreRpcError('invalid cursor', 'unknown', undefined, {
+ kind: 'YouPetCoreHttpError',
+ youpet: { http_status: 422, code: 'invalid_cursor', cursor: 'secret-cursor' },
+ }),
+ expected: { kind: 'YouPetCoreHttpError', httpStatus: 422, coreCode: 'invalid_cursor' },
+ },
+ {
+ error: new CoreRpcError('rate limited', 'unknown', undefined, {
+ kind: 'YouPetCoreHttpError',
+ youpet: {
+ http_status: 429,
+ code: 'rate_limited',
+ retry_after_seconds: 2,
+ response_body: 'must-not-leak',
+ },
+ }),
+ expected: {
+ kind: 'YouPetCoreHttpError',
+ httpStatus: 429,
+ coreCode: 'rate_limited',
+ retryAfterSeconds: 2,
+ },
+ },
+ {
+ error: new CoreRpcError('detail missing', 'unknown', undefined, {
+ kind: 'YouPetCoreHttpError',
+ youpet: { http_status: 404, code: 'tool_definition_not_found' },
+ }),
+ expected: {
+ kind: 'YouPetCoreHttpError',
+ httpStatus: 404,
+ coreCode: 'tool_definition_not_found',
+ },
+ },
+ {
+ error: new CoreRpcError('schema mismatch', 'unknown', undefined, {
+ kind: 'YouPetCoreResponseShape',
+ youpet: { raw_body: { secret: 'must-not-leak' } },
+ }),
+ expected: { kind: 'YouPetCoreResponseShape' },
+ },
+ ];
+
+ for (const { error, expected } of errorCases) {
+ expect(extractRegistryBridgeErrorMeta(error)).toEqual(expected);
+ }
+
+ expect(extractRegistryBridgeErrorMeta(new Error('plain error'))).toBeNull();
+ });
+});
diff --git a/app/src/services/api/coreRegistriesClient.ts b/app/src/services/api/coreRegistriesClient.ts
new file mode 100644
index 0000000000..c53522c8ff
--- /dev/null
+++ b/app/src/services/api/coreRegistriesClient.ts
@@ -0,0 +1,897 @@
+import { z } from 'zod';
+
+import { callCoreRpc, CoreRpcError } from '../coreRpcClient';
+import { CORE_RPC_METHODS } from '../rpcMethods';
+
+const MAX_REGISTRY_KEY_LENGTH = 128;
+
+const registryOwnerActorTypeSchema = z.enum(['service', 'user']);
+const agentLifecycleStateSchema = z.enum(['draft', 'active', 'retired']);
+const toolDefinitionLifecycleStateSchema = z.enum(['draft', 'active', 'retired']);
+const toolEffectClassSchema = z.enum(['read_only', 'effectful', 'destructive']);
+const toolEnablementLifecycleStateSchema = z.enum(['enabled', 'disabled']);
+const toolEnablementAuditModeSchema = z.enum(['metadata_only', 'redacted_io']);
+const connectorTypeLifecycleStateSchema = z.enum(['draft', 'active', 'retired']);
+const connectorBindingLifecycleStateSchema = z.enum(['draft', 'active', 'retired']);
+const jsonObjectSchema = z.object({}).catchall(z.unknown());
+
+export type RegistryOwnerActorType = z.infer;
+export type AgentRegistryLifecycleState = z.infer;
+export type ToolDefinitionLifecycleState = z.infer;
+export type ToolEffectClass = z.infer;
+export type ToolEnablementLifecycleState = z.infer;
+export type ToolEnablementAuditMode = z.infer;
+export type ConnectorTypeLifecycleState = z.infer;
+export type ConnectorBindingLifecycleState = z.infer;
+
+export interface CursorRegistryPage {
+ items: T[];
+ nextCursor: string | null;
+}
+
+export interface UnpagedRegistryCollection {
+ items: T[];
+}
+
+export interface RegistryCursorListParams {
+ limit?: number;
+ cursor?: string;
+}
+
+export interface AgentOwnerRef {
+ actorType: RegistryOwnerActorType;
+ actorId: string;
+}
+
+export interface ToolRefV1 {
+ toolKey: string;
+ version: number;
+}
+
+export interface KnowledgeScopeRefV1 {
+ sourceKey: string;
+ trustVersion: string;
+ accessScope: string;
+}
+
+export interface PolicyRefV1 {
+ policyId: string;
+ policyVersion: string;
+}
+
+export interface AgentConfigurationV1 {
+ schemaVersion: number;
+ domainKey: string;
+ owner: AgentOwnerRef;
+ allowedToolRefs: ToolRefV1[];
+ knowledgeScopeRefs: KnowledgeScopeRefV1[];
+ riskPolicyRef: PolicyRefV1 | null;
+}
+
+export interface AgentRegistryAgentSummary {
+ id: string;
+ agentKey: string;
+ version: number;
+ lifecycleState: 'active';
+ configurationFingerprint: string;
+ ownerActorType: RegistryOwnerActorType;
+ ownerActorId: string;
+ createdAt: string;
+}
+
+export interface AgentRegistryAgent extends Omit {
+ lifecycleState: AgentRegistryLifecycleState;
+ configuration: AgentConfigurationV1;
+}
+
+export interface ToolRegistryToolDefinitionSummary {
+ toolKey: string;
+ version: number;
+ lifecycleState: 'active';
+ definitionFingerprint: string;
+ schemaVersion: number;
+ displayName: string;
+ description: string;
+ toolEffectClass: ToolEffectClass;
+ abstractAuthScopes: string[];
+ createdAt: string;
+}
+
+export interface ToolRegistryToolDefinition extends Omit<
+ ToolRegistryToolDefinitionSummary,
+ 'lifecycleState'
+> {
+ lifecycleState: ToolDefinitionLifecycleState;
+ inputSchema: Record;
+ outputSchema: Record;
+ timeoutDefaults: Record;
+ retryContract: Record;
+ auditContract: Record;
+}
+
+export interface ToolRegistryToolEnablement {
+ toolKey: string;
+ version: number;
+ lifecycleState: ToolEnablementLifecycleState;
+ generation: number;
+ timeoutCapMs: number | null;
+ approvalRequired: boolean;
+ allowTtlSeconds: number | null;
+ auditMode: ToolEnablementAuditMode | null;
+ updatedAt: string;
+}
+
+export interface ConnectorNormalizationContract {
+ evidenceFamily: string;
+ kernelEventType: string;
+ kernelEventSchemaVersion: number;
+}
+
+export interface ConnectorRegistryTypeSummary {
+ connectorKey: string;
+ version: number;
+ lifecycleState: ConnectorTypeLifecycleState;
+ sourceType: string;
+ connectorTypeFingerprint: string;
+ capabilities: string[];
+ createdAt: string;
+}
+
+export interface ConnectorRegistryType extends ConnectorRegistryTypeSummary {
+ normalizationContracts: ConnectorNormalizationContract[];
+ deliveryBehavior: Record;
+}
+
+export interface ConnectorRegistryProviderAccount {
+ namespace: string;
+ externalAccountRef: string;
+}
+
+export interface ConnectorRegistryBindingSummary {
+ bindingKey: string;
+ version: number;
+ lifecycleState: ConnectorBindingLifecycleState;
+ connectorTypeKey: string;
+ connectorTypeVersion: number;
+ connectorTypeFingerprint: string;
+ enabledCapabilities: string[];
+ bindingFingerprint: string;
+ createdAt: string;
+}
+
+export interface ConnectorRegistryBinding extends ConnectorRegistryBindingSummary {
+ providerAccount: ConnectorRegistryProviderAccount;
+ configRef: string;
+ credentialRef: string;
+}
+
+export const REGISTRY_BRIDGE_ERROR_KINDS = [
+ 'YouPetConfigMissing',
+ 'YouPetConfigInvalid',
+ 'YouPetRequestInvalid',
+ 'YouPetCoreTransport',
+ 'YouPetCoreInvalidJson',
+ 'YouPetCoreResponseShape',
+ 'YouPetCoreHttpError',
+] as const;
+
+export type RegistryBridgeErrorKind = (typeof REGISTRY_BRIDGE_ERROR_KINDS)[number];
+
+export interface RegistryBridgeErrorMeta {
+ kind: RegistryBridgeErrorKind;
+ httpStatus?: number;
+ coreCode?: string;
+ retryAfterSeconds?: number;
+}
+
+const agentOwnerRefSchema = z
+ .object({ actor_type: registryOwnerActorTypeSchema, actor_id: z.string() })
+ .transform(
+ ({ actor_type, actor_id }): AgentOwnerRef => ({ actorType: actor_type, actorId: actor_id })
+ );
+
+const toolRefSchema = z
+ .object({ tool_key: z.string(), version: z.number().int() })
+ .transform(({ tool_key, version }): ToolRefV1 => ({ toolKey: tool_key, version }));
+
+const knowledgeScopeRefSchema = z
+ .object({ source_key: z.string(), trust_version: z.string(), access_scope: z.string() })
+ .transform(
+ ({ source_key, trust_version, access_scope }): KnowledgeScopeRefV1 => ({
+ sourceKey: source_key,
+ trustVersion: trust_version,
+ accessScope: access_scope,
+ })
+ );
+
+const policyRefSchema = z
+ .object({ policy_id: z.string(), policy_version: z.string() })
+ .transform(
+ ({ policy_id, policy_version }): PolicyRefV1 => ({
+ policyId: policy_id,
+ policyVersion: policy_version,
+ })
+ );
+
+const agentConfigurationSchema = z
+ .object({
+ schema_version: z.number().int(),
+ domain_key: z.string(),
+ owner: agentOwnerRefSchema,
+ allowed_tool_refs: z.array(toolRefSchema),
+ knowledge_scope_refs: z.array(knowledgeScopeRefSchema),
+ risk_policy_ref: policyRefSchema.nullable(),
+ })
+ .transform(
+ ({
+ schema_version,
+ domain_key,
+ owner,
+ allowed_tool_refs,
+ knowledge_scope_refs,
+ risk_policy_ref,
+ }): AgentConfigurationV1 => ({
+ schemaVersion: schema_version,
+ domainKey: domain_key,
+ owner,
+ allowedToolRefs: allowed_tool_refs,
+ knowledgeScopeRefs: knowledge_scope_refs,
+ riskPolicyRef: risk_policy_ref,
+ })
+ );
+
+const agentSummarySchema = z
+ .object({
+ id: z.string(),
+ agent_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: z.literal('active'),
+ configuration_fingerprint: z.string(),
+ owner_actor_type: registryOwnerActorTypeSchema,
+ owner_actor_id: z.string(),
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ id,
+ agent_key,
+ version,
+ lifecycle_state,
+ configuration_fingerprint,
+ owner_actor_type,
+ owner_actor_id,
+ created_at,
+ }): AgentRegistryAgentSummary => ({
+ id,
+ agentKey: agent_key,
+ version,
+ lifecycleState: lifecycle_state,
+ configurationFingerprint: configuration_fingerprint,
+ ownerActorType: owner_actor_type,
+ ownerActorId: owner_actor_id,
+ createdAt: created_at,
+ })
+ );
+
+const agentSchema = z
+ .object({
+ id: z.string(),
+ agent_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: agentLifecycleStateSchema,
+ configuration: agentConfigurationSchema,
+ configuration_fingerprint: z.string(),
+ owner_actor_type: registryOwnerActorTypeSchema,
+ owner_actor_id: z.string(),
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ id,
+ agent_key,
+ version,
+ lifecycle_state,
+ configuration,
+ configuration_fingerprint,
+ owner_actor_type,
+ owner_actor_id,
+ created_at,
+ }): AgentRegistryAgent => ({
+ id,
+ agentKey: agent_key,
+ version,
+ lifecycleState: lifecycle_state,
+ configuration,
+ configurationFingerprint: configuration_fingerprint,
+ ownerActorType: owner_actor_type,
+ ownerActorId: owner_actor_id,
+ createdAt: created_at,
+ })
+ );
+
+const toolDefinitionSummarySchema = z
+ .object({
+ tool_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: z.literal('active'),
+ definition_fingerprint: z.string(),
+ schema_version: z.number().int(),
+ display_name: z.string(),
+ description: z.string(),
+ tool_effect_class: toolEffectClassSchema,
+ abstract_auth_scopes: z.array(z.string()),
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ tool_key,
+ version,
+ lifecycle_state,
+ definition_fingerprint,
+ schema_version,
+ display_name,
+ description,
+ tool_effect_class,
+ abstract_auth_scopes,
+ created_at,
+ }): ToolRegistryToolDefinitionSummary => ({
+ toolKey: tool_key,
+ version,
+ lifecycleState: lifecycle_state,
+ definitionFingerprint: definition_fingerprint,
+ schemaVersion: schema_version,
+ displayName: display_name,
+ description,
+ toolEffectClass: tool_effect_class,
+ abstractAuthScopes: abstract_auth_scopes,
+ createdAt: created_at,
+ })
+ );
+
+const toolDefinitionSchema = z
+ .object({
+ tool_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: toolDefinitionLifecycleStateSchema,
+ definition_fingerprint: z.string(),
+ schema_version: z.number().int(),
+ display_name: z.string(),
+ description: z.string(),
+ tool_effect_class: toolEffectClassSchema,
+ abstract_auth_scopes: z.array(z.string()),
+ input_schema: jsonObjectSchema,
+ output_schema: jsonObjectSchema,
+ timeout_defaults: jsonObjectSchema,
+ retry_contract: jsonObjectSchema,
+ audit_contract: jsonObjectSchema,
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ tool_key,
+ version,
+ lifecycle_state,
+ definition_fingerprint,
+ schema_version,
+ display_name,
+ description,
+ tool_effect_class,
+ abstract_auth_scopes,
+ input_schema,
+ output_schema,
+ timeout_defaults,
+ retry_contract,
+ audit_contract,
+ created_at,
+ }): ToolRegistryToolDefinition => ({
+ toolKey: tool_key,
+ version,
+ lifecycleState: lifecycle_state,
+ definitionFingerprint: definition_fingerprint,
+ schemaVersion: schema_version,
+ displayName: display_name,
+ description,
+ toolEffectClass: tool_effect_class,
+ abstractAuthScopes: abstract_auth_scopes,
+ inputSchema: input_schema,
+ outputSchema: output_schema,
+ timeoutDefaults: timeout_defaults,
+ retryContract: retry_contract,
+ auditContract: audit_contract,
+ createdAt: created_at,
+ })
+ );
+
+const toolEnablementSchema = z
+ .object({
+ tool_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: toolEnablementLifecycleStateSchema,
+ generation: z.number().int(),
+ timeout_cap_ms: z.number().int().nullable(),
+ approval_required: z.boolean(),
+ allow_ttl_seconds: z.number().int().nullable(),
+ audit_mode: toolEnablementAuditModeSchema.nullable(),
+ updated_at: z.string(),
+ })
+ .transform(
+ ({
+ tool_key,
+ version,
+ lifecycle_state,
+ generation,
+ timeout_cap_ms,
+ approval_required,
+ allow_ttl_seconds,
+ audit_mode,
+ updated_at,
+ }): ToolRegistryToolEnablement => ({
+ toolKey: tool_key,
+ version,
+ lifecycleState: lifecycle_state,
+ generation,
+ timeoutCapMs: timeout_cap_ms,
+ approvalRequired: approval_required,
+ allowTtlSeconds: allow_ttl_seconds,
+ auditMode: audit_mode,
+ updatedAt: updated_at,
+ })
+ );
+
+const connectorNormalizationContractSchema = z
+ .object({
+ evidence_family: z.string(),
+ kernel_event_type: z.string(),
+ kernel_event_schema_version: z.number().int(),
+ })
+ .transform(
+ ({
+ evidence_family,
+ kernel_event_type,
+ kernel_event_schema_version,
+ }): ConnectorNormalizationContract => ({
+ evidenceFamily: evidence_family,
+ kernelEventType: kernel_event_type,
+ kernelEventSchemaVersion: kernel_event_schema_version,
+ })
+ );
+
+const connectorTypeSummarySchema = z
+ .object({
+ connector_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: connectorTypeLifecycleStateSchema,
+ source_type: z.string(),
+ connector_type_fingerprint: z.string(),
+ capabilities: z.array(z.string()),
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ connector_key,
+ version,
+ lifecycle_state,
+ source_type,
+ connector_type_fingerprint,
+ capabilities,
+ created_at,
+ }): ConnectorRegistryTypeSummary => ({
+ connectorKey: connector_key,
+ version,
+ lifecycleState: lifecycle_state,
+ sourceType: source_type,
+ connectorTypeFingerprint: connector_type_fingerprint,
+ capabilities,
+ createdAt: created_at,
+ })
+ );
+
+const connectorTypeSchema = z
+ .object({
+ connector_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: connectorTypeLifecycleStateSchema,
+ source_type: z.string(),
+ connector_type_fingerprint: z.string(),
+ capabilities: z.array(z.string()),
+ normalization_contracts: z.array(connectorNormalizationContractSchema),
+ delivery_behavior: jsonObjectSchema,
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ connector_key,
+ version,
+ lifecycle_state,
+ source_type,
+ connector_type_fingerprint,
+ capabilities,
+ normalization_contracts,
+ delivery_behavior,
+ created_at,
+ }): ConnectorRegistryType => ({
+ connectorKey: connector_key,
+ version,
+ lifecycleState: lifecycle_state,
+ sourceType: source_type,
+ connectorTypeFingerprint: connector_type_fingerprint,
+ capabilities,
+ normalizationContracts: normalization_contracts,
+ deliveryBehavior: delivery_behavior,
+ createdAt: created_at,
+ })
+ );
+
+const connectorRegistryProviderAccountSchema = z
+ .object({ namespace: z.string(), external_account_ref: z.string() })
+ .transform(
+ ({ namespace, external_account_ref }): ConnectorRegistryProviderAccount => ({
+ namespace,
+ externalAccountRef: external_account_ref,
+ })
+ );
+
+const connectorBindingSummarySchema = z
+ .object({
+ binding_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: connectorBindingLifecycleStateSchema,
+ connector_type_key: z.string(),
+ connector_type_version: z.number().int(),
+ connector_type_fingerprint: z.string(),
+ enabled_capabilities: z.array(z.string()),
+ binding_fingerprint: z.string(),
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ binding_key,
+ version,
+ lifecycle_state,
+ connector_type_key,
+ connector_type_version,
+ connector_type_fingerprint,
+ enabled_capabilities,
+ binding_fingerprint,
+ created_at,
+ }): ConnectorRegistryBindingSummary => ({
+ bindingKey: binding_key,
+ version,
+ lifecycleState: lifecycle_state,
+ connectorTypeKey: connector_type_key,
+ connectorTypeVersion: connector_type_version,
+ connectorTypeFingerprint: connector_type_fingerprint,
+ enabledCapabilities: enabled_capabilities,
+ bindingFingerprint: binding_fingerprint,
+ createdAt: created_at,
+ })
+ );
+
+const connectorBindingSchema = z
+ .object({
+ binding_key: z.string(),
+ version: z.number().int(),
+ lifecycle_state: connectorBindingLifecycleStateSchema,
+ connector_type_key: z.string(),
+ connector_type_version: z.number().int(),
+ connector_type_fingerprint: z.string(),
+ provider_account: connectorRegistryProviderAccountSchema,
+ config_ref: z.string(),
+ credential_ref: z.string(),
+ enabled_capabilities: z.array(z.string()),
+ binding_fingerprint: z.string(),
+ created_at: z.string(),
+ })
+ .transform(
+ ({
+ binding_key,
+ version,
+ lifecycle_state,
+ connector_type_key,
+ connector_type_version,
+ connector_type_fingerprint,
+ provider_account,
+ config_ref,
+ credential_ref,
+ enabled_capabilities,
+ binding_fingerprint,
+ created_at,
+ }): ConnectorRegistryBinding => ({
+ bindingKey: binding_key,
+ version,
+ lifecycleState: lifecycle_state,
+ connectorTypeKey: connector_type_key,
+ connectorTypeVersion: connector_type_version,
+ connectorTypeFingerprint: connector_type_fingerprint,
+ providerAccount: provider_account,
+ configRef: config_ref,
+ credentialRef: credential_ref,
+ enabledCapabilities: enabled_capabilities,
+ bindingFingerprint: binding_fingerprint,
+ createdAt: created_at,
+ })
+ );
+
+function parseCursorRegistryPage(
+ response: unknown,
+ itemSchema: z.ZodType
+): CursorRegistryPage {
+ return parseWithSchema(
+ unwrapLoggedCoreResult(response),
+ z
+ .object({ items: z.array(itemSchema), next_cursor: z.string().nullable() })
+ .transform(
+ ({ items, next_cursor }): CursorRegistryPage => ({ items, nextCursor: next_cursor })
+ )
+ );
+}
+
+function parseUnpagedRegistryCollection(
+ response: unknown,
+ itemSchema: z.ZodType
+): UnpagedRegistryCollection {
+ return parseWithSchema(
+ unwrapLoggedCoreResult(response),
+ z
+ .object({ items: z.array(itemSchema) })
+ .transform(({ items }): UnpagedRegistryCollection => ({ items }))
+ );
+}
+
+function unwrapLoggedCoreResult(response: unknown): unknown {
+ if (
+ response &&
+ typeof response === 'object' &&
+ !Array.isArray(response) &&
+ 'result' in response &&
+ 'logs' in response &&
+ Array.isArray((response as { logs?: unknown }).logs)
+ ) {
+ return (response as { result: unknown }).result;
+ }
+ return response;
+}
+
+function normalizeRegistrySchemaError(error: z.ZodError): never {
+ throw new CoreRpcError(
+ `Registry bridge response shape mismatch: ${error.issues[0]?.message ?? 'invalid payload'}`,
+ 'unknown',
+ undefined,
+ { kind: 'YouPetCoreResponseShape' }
+ );
+}
+
+function parseWithSchema(response: unknown, schema: z.ZodType): T {
+ try {
+ return schema.parse(response);
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ normalizeRegistrySchemaError(error);
+ }
+ throw error;
+ }
+}
+
+function unwrapExactRecord(response: unknown, recordKey: string): unknown {
+ response = unwrapLoggedCoreResult(response);
+ if (response && typeof response === 'object' && recordKey in response) {
+ return (response as Record)[recordKey];
+ }
+ return response;
+}
+
+function pruneParams(params: Record): Record {
+ const next: Record = {};
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined) {
+ next[key] = value;
+ }
+ }
+ return next;
+}
+
+function assertValidRegistryKey(field: string, key: string): string {
+ const trimmed = key.trim();
+ if (!trimmed) {
+ throw new Error(`${field} must be non-empty`);
+ }
+ if (trimmed.length > MAX_REGISTRY_KEY_LENGTH) {
+ throw new Error(`${field} must be at most ${MAX_REGISTRY_KEY_LENGTH} characters`);
+ }
+ return trimmed;
+}
+
+function assertPositiveVersion(version: number): number {
+ if (!Number.isInteger(version) || version < 1) {
+ throw new Error('version must be a positive integer');
+ }
+ return version;
+}
+
+function buildCursorParams(params?: RegistryCursorListParams): Record {
+ if (!params) {
+ return {};
+ }
+
+ const next = pruneParams({ limit: params.limit, cursor: params.cursor?.trim() || undefined });
+
+ if ('limit' in next) {
+ const limit = next.limit;
+ if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 200) {
+ throw new Error('limit must be an integer between 1 and 200');
+ }
+ }
+
+ return next;
+}
+
+function buildExactParams(
+ field: 'agentKey' | 'toolKey' | 'connectorKey' | 'bindingKey',
+ key: string,
+ version: number
+): { version: number } & Record {
+ return {
+ [field]: assertValidRegistryKey(field, key),
+ version: assertPositiveVersion(version),
+ } as { version: number } & Record;
+}
+
+function parseRegistryBridgeErrorKind(kind: unknown): RegistryBridgeErrorKind | null {
+ return REGISTRY_BRIDGE_ERROR_KINDS.includes(kind as RegistryBridgeErrorKind)
+ ? (kind as RegistryBridgeErrorKind)
+ : null;
+}
+
+export function extractRegistryBridgeErrorMeta(error: unknown): RegistryBridgeErrorMeta | null {
+ if (!(error instanceof CoreRpcError)) {
+ return null;
+ }
+
+ const data = error.data;
+ if (!data || typeof data !== 'object') {
+ return null;
+ }
+
+ const kind = parseRegistryBridgeErrorKind((data as { kind?: unknown }).kind);
+ if (!kind) {
+ return null;
+ }
+
+ const meta: RegistryBridgeErrorMeta = { kind };
+ const youpet = (data as { youpet?: unknown }).youpet;
+ if (youpet && typeof youpet === 'object') {
+ const record = youpet as {
+ http_status?: unknown;
+ code?: unknown;
+ retry_after_seconds?: unknown;
+ };
+ if (typeof record.http_status === 'number') {
+ meta.httpStatus = record.http_status;
+ }
+ if (typeof record.code === 'string') {
+ meta.coreCode = record.code;
+ }
+ if (typeof record.retry_after_seconds === 'number') {
+ meta.retryAfterSeconds = record.retry_after_seconds;
+ }
+ }
+
+ return meta;
+}
+
+async function getExactRecord(
+ method: (typeof CORE_RPC_METHODS)[keyof typeof CORE_RPC_METHODS],
+ params: Record,
+ recordKey: string,
+ recordSchema: z.ZodType
+): Promise {
+ const response = await callCoreRpc({ method, params });
+ return parseWithSchema(unwrapExactRecord(response, recordKey), recordSchema);
+}
+
+export const coreRegistriesClient = {
+ listAgents: async (
+ params?: RegistryCursorListParams
+ ): Promise> =>
+ parseCursorRegistryPage(
+ await callCoreRpc({
+ method: CORE_RPC_METHODS.youpetRegistryListAgents,
+ params: buildCursorParams(params),
+ }),
+ agentSummarySchema
+ ),
+
+ getAgentVersion: (params: { agentKey: string; version: number }): Promise =>
+ getExactRecord(
+ CORE_RPC_METHODS.youpetRegistryGetAgentVersion,
+ buildExactParams('agentKey', params.agentKey, params.version),
+ 'agent',
+ agentSchema
+ ),
+
+ listToolDefinitions: async (
+ params?: RegistryCursorListParams
+ ): Promise> =>
+ parseCursorRegistryPage(
+ await callCoreRpc({
+ method: CORE_RPC_METHODS.youpetRegistryListToolDefinitions,
+ params: buildCursorParams(params),
+ }),
+ toolDefinitionSummarySchema
+ ),
+
+ getToolDefinitionVersion: (params: {
+ toolKey: string;
+ version: number;
+ }): Promise =>
+ getExactRecord(
+ CORE_RPC_METHODS.youpetRegistryGetToolDefinitionVersion,
+ buildExactParams('toolKey', params.toolKey, params.version),
+ 'toolDefinition',
+ toolDefinitionSchema
+ ),
+
+ listToolEnablements: async (): Promise