"use client" /** * The tenant's standing decision about unattended action. * * `resolveTenantAutoApprovalPolicy` has always read this record, and until now * nothing wrote it: every tenant ran the conservative default (`enabled`, * ceiling `medium`) with no way to see that, let alone change it. An operator * whose high-risk proposal was held had a queue that had simply stopped. * * The screen deliberately shows the OTHER gates too. Auto-approval is a policy, * not a threshold — a proposal also needs its guardrails passed, a readable * trace and a risk within the ceiling — and an admin who reads this page as * "confidence is the rule" is the person who later files the bug that it did not * fire. * * optimistic-lock-exempt — the target is a single ModuleConfigService value, not * a versioned entity: there is no `updatedAt` to send, and the surface is a * single-admin settings screen rather than a collaborative-edit record. */ import * as React from 'react' import { Button } from '@open-mercato/ui/primitives/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@open-mercato/ui/primitives/card' import { Label } from '@open-mercato/ui/primitives/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@open-mercato/ui/primitives/select' import { Spinner } from '@open-mercato/ui/primitives/spinner' import { StatusBadge } from '@open-mercato/ui/primitives/status-badge' import { Switch } from '@open-mercato/ui/primitives/switch' import { Page, PageBody, PageHeader } from '@open-mercato/ui/backend/Page' import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider' import { hasFeature } from '@open-mercato/shared/security/features' import { ErrorMessage } from '@open-mercato/ui/backend/detail' import { apiCall } from '@open-mercato/ui/backend/utils/apiCall' import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation' import { flash } from '@open-mercato/ui/backend/FlashMessages' import { useT } from '@open-mercato/shared/lib/i18n/context' const SETTINGS_URL = '/api/agent_orchestrator/auto-approval/settings' type ActionRisk = 'low' | 'medium' | 'high' type Policy = { enabled: boolean maxAutoApproveRisk: ActionRisk } type PolicyResponse = { policy: Policy source: 'tenant' | 'default' } const RISK_TIERS: readonly ActionRisk[] = ['low', 'medium', 'high'] function isPolicyResponse(value: unknown): value is PolicyResponse { if (typeof value !== 'object' || value === null || !('policy' in value)) return false const policy = (value as { policy: unknown }).policy return typeof policy === 'object' && policy !== null && 'enabled' in policy } function samePolicy(left: Policy, right: Policy): boolean { return left.enabled === right.enabled && left.maxAutoApproveRisk === right.maxAutoApproveRisk } /** * The gates a proposal clears BEFORE its confidence is ever compared, in the * order `evaluateAutoApproval` answers them. * * Rendered as plain copy rather than controls: none of them is tunable here, and * the reason an admin lands on this page is usually a proposal that was held by * one of them. */ function GateList() { const t = useT() const gates = [ t( 'agent_orchestrator.settings.autoApproval.gate.node', 'The workflow step does not say "always ask".', ), t( 'agent_orchestrator.settings.autoApproval.gate.guardrail', 'Every guardrail check on the run passed.', ), t( 'agent_orchestrator.settings.autoApproval.gate.trace', 'The run left a trace an auditor can read.', ), t( 'agent_orchestrator.settings.autoApproval.gate.risk', 'The proposed action is within the risk ceiling set above.', ), t( 'agent_orchestrator.settings.autoApproval.gate.confidence', 'Only then: the confidence clears the step’s threshold and its margin over the runner-up.', ), ] return ( ) } export default function AutoApprovalSettingsPage() { const t = useT() const [policy, setPolicy] = React.useState(null) const [baseline, setBaseline] = React.useState(null) const [source, setSource] = React.useState<'tenant' | 'default'>('default') const [isLoading, setIsLoading] = React.useState(true) const [isSaving, setIsSaving] = React.useState(false) const [loadError, setLoadError] = React.useState(null) const { runMutation } = useGuardedMutation({ contextId: 'agent_orchestrator.auto_approval.settings' }) const { payload: chromePayload } = useBackendChrome() const canManage = hasFeature(chromePayload?.grantedFeatures, 'agent_orchestrator.agents.manage') const load = React.useCallback(async () => { setIsLoading(true) try { const call = await apiCall(SETTINGS_URL) if (!call.ok || !isPolicyResponse(call.result)) { setLoadError( t('agent_orchestrator.settings.autoApproval.loadError', 'Could not load the auto-approval policy.'), ) return } setPolicy(call.result.policy) setBaseline(call.result.policy) setSource(call.result.source) setLoadError(null) } catch { setLoadError( t('agent_orchestrator.settings.autoApproval.loadError', 'Could not load the auto-approval policy.'), ) } finally { setIsLoading(false) } }, [t]) React.useEffect(() => { void load() }, [load]) const save = React.useCallback(async () => { if (!policy) return setIsSaving(true) await runMutation({ context: { contextId: 'agent_orchestrator.auto_approval.settings' }, operation: async () => { const call = await apiCall(SETTINGS_URL, { method: 'PUT', body: JSON.stringify(policy) }) if (!call.ok) { flash( t('agent_orchestrator.settings.autoApproval.saveError', 'Could not save the auto-approval policy.'), 'error', ) return } setBaseline(policy) setSource('tenant') flash(t('agent_orchestrator.settings.autoApproval.saved', 'Saved.'), 'success') }, }) setIsSaving(false) }, [policy, runMutation, t]) if (isLoading) { return (
) } if (loadError) { return ( ) } if (!policy || !baseline) return null const isDirty = !samePolicy(policy, baseline) const riskLabel = (tier: ActionRisk): string => t(`agent_orchestrator.settings.autoApproval.risk.${tier}`, tier) return ( {t('agent_orchestrator.settings.autoApproval.unsaved', 'Unsaved changes')} ) : source === 'tenant' ? ( {t('agent_orchestrator.settings.autoApproval.sourceTenant', 'Saved for this tenant')} ) : ( {t('agent_orchestrator.settings.autoApproval.sourceDefault', 'Inherited default')} ) } /> {t('agent_orchestrator.settings.autoApproval.policy', 'Policy')} {t( 'agent_orchestrator.settings.autoApproval.policyHint', 'Applies to every workflow step that invokes an agent. A step can still be stricter by asking for a human every time.', )}
setPolicy({ ...policy, enabled: next })} />

{t( 'agent_orchestrator.settings.autoApproval.enabledHint', 'Off sends every proposal to the caseload, however confident the agent is.', )}

{t( 'agent_orchestrator.settings.autoApproval.maxRiskHint', 'An action whose risk is not declared counts as medium. Anything above this goes to a person.', )}

{canManage ? (
) : (

{t( 'agent_orchestrator.settings.autoApproval.readOnly', 'You can see this policy but not change it.', )}

)}
{t('agent_orchestrator.settings.autoApproval.gates', 'What else a proposal must clear')} {t( 'agent_orchestrator.settings.autoApproval.gatesHint', 'Confidence is evidence, not authorization. A proposal is approved unattended only when all of this holds.', )}
) }