feat(governance): add agent market automation surfaces
Some checks failed
Ansible Lint / lint (push) Successful in 35s
CD Pipeline / tests (push) Failing after 13s
CD Pipeline / build-and-deploy (push) Has been skipped
CD Pipeline / post-deploy-checks (push) Has been skipped
Code Review / ai-code-review (push) Failing after 11s

This commit is contained in:
Your Name
2026-06-04 21:40:12 +08:00
parent b9bd5e3ba8
commit cfb866d055
334 changed files with 62000 additions and 82 deletions

View File

@@ -22,6 +22,8 @@ import { GlassCard } from '@/components/ui/glass-card'
import { SloTab } from './tabs/slo-tab'
import { EventsTab } from './tabs/events-tab'
import { QueueTab } from './tabs/queue-tab'
import { AgentMarketTab } from './tabs/agent-market-tab'
import { AutomationInventoryTab } from './tabs/automation-inventory-tab'
export default function GovernancePage({ params }: { params: { locale: string } }) {
const t = useTranslations('governance')
@@ -30,6 +32,8 @@ export default function GovernancePage({ params }: { params: { locale: string }
{ id: 'slo', label: t('tabs.slo'), content: <SloTab /> },
{ id: 'events', label: t('tabs.events'), content: <EventsTab /> },
{ id: 'queue', label: t('tabs.queue'), content: <QueueTab /> },
{ id: 'agent-market', label: t('tabs.agentMarket'), content: <AgentMarketTab /> },
{ id: 'automation-inventory', label: t('tabs.automationInventory'), content: <AutomationInventoryTab /> },
]
return (

View File

@@ -0,0 +1,705 @@
'use client'
/**
* AgentMarketTab — AI Agent 市場治理 Tab
* =====================================
* 消費GET /api/v1/agents/market-governance-snapshot
*
* 只讀最新 committed governance snapshot不提供任何批准或執行操作。
*/
import { useEffect, useState } from 'react'
import { AlertTriangle, Ban, CalendarClock, CheckCircle2, ListChecks, Lock, RefreshCw, ShieldCheck } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
import { StatusOrb } from '@/components/ui/status-orb'
import { apiClient, type AgentMarketGovernanceSnapshot } from '@/lib/api-client'
// =============================================================================
// Helpers
// =============================================================================
function formatDateTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--'
return date.toLocaleString('zh-TW', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
// =============================================================================
// Small UI
// =============================================================================
function MetricCard({ label, value, tone = 'neutral' }: { label: string; value: number | string; tone?: 'neutral' | 'ok' | 'warn' }) {
const color = tone === 'ok' ? '#22C55E' : tone === 'warn' ? '#F59E0B' : '#141413'
return (
<GlassCard variant="subtle" padding="md" className="min-w-0">
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
{label}
</span>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 26,
fontWeight: 700,
color,
lineHeight: 1,
}}>
{value}
</span>
</div>
</GlassCard>
)
}
function CandidatePill({ value, muted = false }: { value: string; muted?: boolean }) {
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
minHeight: 22,
padding: '3px 7px',
borderRadius: 5,
border: `0.5px solid ${muted ? '#e0ddd4' : '#d9775740'}`,
background: muted ? '#faf9f3' : 'rgba(217,119,87,0.06)',
color: muted ? '#87867f' : '#141413',
fontFamily: "'DM Mono', monospace",
fontSize: 10,
lineHeight: 1.3,
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
overflowWrap: 'normal',
whiteSpace: 'nowrap',
}}>
{value}
</span>
)
}
function CandidateGroup({ title, items, muted = false }: { title: string; items: string[]; muted?: boolean }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
<div style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
textTransform: 'uppercase',
letterSpacing: '0.7px',
}}>
{title}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{items.length > 0 ? items.map(item => (
<CandidatePill key={item} value={item} muted={muted} />
)) : (
<CandidatePill value="--" muted />
)}
</div>
</div>
)
}
function PolicyGate({ label, approved }: { label: string; approved: number }) {
const isApproved = approved > 0
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 10,
padding: '9px 11px',
border: '0.5px solid #e0ddd4',
borderRadius: 7,
background: '#fff',
minWidth: 0,
}}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
lineHeight: 1.4,
minWidth: 0,
}}>
{label}
</span>
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 5,
color: isApproved ? '#F59E0B' : '#22C55E',
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 700,
whiteSpace: 'nowrap',
}}>
{isApproved ? <AlertTriangle size={12} /> : <Lock size={12} />}
{approved}
</span>
</div>
)
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
{label}
</span>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#141413',
minWidth: 0,
}}>
{children}
</div>
</div>
)
}
// =============================================================================
// Component
// =============================================================================
export function AgentMarketTab() {
const t = useTranslations('governance.agentMarket')
const [snapshot, setSnapshot] = useState<AgentMarketGovernanceSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const fetchSnapshot = () => {
setLoading(true)
apiClient.getAgentMarketGovernanceSnapshot()
.then((data: AgentMarketGovernanceSnapshot) => {
setSnapshot(data)
setError(false)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchSnapshot()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (loading) {
return (
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="agent-market-kpi-grid">
{[0, 1, 2, 3].map(i => (
<GlassCard key={i} variant="subtle" padding="md">
<div style={{ width: 84, height: 10, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite', marginBottom: 10, animationDelay: `${i * 0.08}s` }} />
<div style={{ width: 52, height: 26, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
</GlassCard>
))}
</div>
)
}
if (error || !snapshot) {
return (
<div style={{ padding: 20 }}>
<GlassCard variant="subtle" padding="lg">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '24px 0' }}>
<AlertTriangle size={24} style={{ color: '#F59E0B' }} />
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>
{t('error')}
</span>
<button
onClick={fetchSnapshot}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
border: '0.5px solid #d97757',
borderRadius: 6,
background: 'transparent',
color: '#d97757',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
}}
>
<RefreshCw size={12} />
{t('retry')}
</button>
</div>
</GlassCard>
</div>
)
}
const summary = snapshot.summary
const allApprovals =
summary.priority_upgrades_approved +
summary.market_scorecard_updates_approved +
summary.replay_candidates_approved +
summary.sdk_installations_approved +
summary.paid_api_calls_approved +
summary.production_changes_approved +
summary.shadow_or_canary_approved +
summary.replacement_decisions_approved
const watchHealth = snapshot.market_watch_health
const watchHealthHealthy = watchHealth.status === 'healthy'
return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div style={{
width: 34,
height: 34,
borderRadius: 8,
border: '0.5px solid #22C55E40',
background: 'rgba(34,197,94,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<ShieldCheck size={17} style={{ color: '#22C55E' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
<StatusOrb status={allApprovals === 0 ? 'healthy' : 'warning'} size="sm" glow />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 15, fontWeight: 700, color: '#141413' }}>
{t('title')}
</span>
</div>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#87867f',
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
overflowWrap: 'normal',
whiteSpace: 'nowrap',
}}>
{snapshot.current_decision}
</div>
</div>
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
{t('generatedAt')} {formatDateTime(snapshot.generated_at)}
</div>
</div>
</GlassCard>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 12,
}} className="agent-market-kpi-grid">
<MetricCard label={t('metrics.candidates')} value={summary.candidate_count} />
<MetricCard label={t('metrics.sources')} value={summary.source_count} />
<MetricCard label={t('metrics.blocked')} value={summary.blocked_from_integration} tone="warn" />
<MetricCard label={t('metrics.prescreenReady')} value={summary.eligible_for_market_scorecard_prescreen} tone="ok" />
</div>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
{watchHealthHealthy ? (
<ShieldCheck size={14} style={{ color: '#22C55E' }} />
) : (
<AlertTriangle size={14} style={{ color: '#F59E0B' }} />
)}
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('health.title')}
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 12,
}} className="agent-market-health-grid">
<DetailRow label={t('health.status')}>
<span style={{ color: watchHealthHealthy ? '#22C55E' : '#F59E0B', fontWeight: 700 }}>
{t(`health.statuses.${watchHealth.status}`)}
</span>
</DetailRow>
<DetailRow label={t('health.freshnessSla')}>
{t('health.slaValue', {
slaHours: watchHealth.freshness_sla_hours,
graceHours: watchHealth.stale_grace_hours,
})}
</DetailRow>
<DetailRow label={t('health.staleAfter')}>
{formatDateTime(watchHealth.stale_after)}
</DetailRow>
<DetailRow label={t('health.priorityGate')}>
{watchHealth.source_failures_block_priority_upgrade ? t('health.blocked') : t('health.clear')}
</DetailRow>
<DetailRow label={t('health.blockedIntegrations')}>
{watchHealth.blocked_from_integration}
</DetailRow>
<DetailRow label={t('health.blockers')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{watchHealth.operator_blockers.length > 0 ? (
watchHealth.operator_blockers.map(blocker => (
<CandidatePill key={blocker} value={blocker} muted />
))
) : (
<CandidatePill value={t('health.noBlockers')} />
)}
</div>
</DetailRow>
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<CalendarClock size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('cadence.title')}
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, minmax(0, 1fr))',
gap: 12,
}} className="agent-market-cadence-grid">
<DetailRow label={t('cadence.workflow')}>
<CandidatePill value={snapshot.evaluation_cadence.workflow} />
</DetailRow>
<DetailRow label={t('cadence.schedule')}>
<CandidatePill value={snapshot.evaluation_cadence.schedule} />
</DetailRow>
<DetailRow label={t('cadence.nextRun')}>
{formatDateTime(snapshot.evaluation_cadence.next_scheduled_run_at)}
</DetailRow>
<DetailRow label={t('cadence.sourcePolicy')}>
<CandidatePill value={snapshot.evaluation_cadence.primary_source_policy} />
</DetailRow>
<DetailRow label={t('cadence.reviewGate')}>
<CandidatePill value={snapshot.evaluation_cadence.operator_review_gate} muted />
</DetailRow>
<DetailRow label={t('cadence.triggerModes')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{snapshot.evaluation_cadence.trigger_modes.map(mode => (
<CandidatePill key={mode} value={mode} />
))}
</div>
</DetailRow>
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 18,
}} className="agent-market-groups-grid">
<CandidateGroup title={t('groups.baseline')} items={snapshot.candidate_groups.production_baseline} />
<CandidateGroup title={t('groups.blocked')} items={snapshot.candidate_groups.replay_or_integration_blocked} muted />
<CandidateGroup title={t('groups.watchOnly')} items={snapshot.candidate_groups.watch_only_candidates} />
<CandidateGroup title={t('groups.prescreenReady')} items={snapshot.candidate_groups.watch_only_scorecard_prescreen_ready} />
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<ListChecks size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('decisionQueue.title')}
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 10,
}} className="agent-market-decision-grid">
{snapshot.operator_decision_queue.map(item => {
const activeBoundaries = Object.entries(item.approval_boundary)
.filter(([, required]) => required)
.map(([key]) => key)
return (
<div
key={item.candidate_id}
style={{
minWidth: 0,
padding: 12,
border: '0.5px solid #e0ddd4',
borderRadius: 7,
background: '#fff',
display: 'flex',
flexDirection: 'column',
gap: 9,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 13,
fontWeight: 700,
color: '#141413',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{item.display_name}
</span>
<CandidatePill value={item.candidate_id} muted />
</div>
<span style={{
color: item.queue_status === 'baseline_protected' ? '#22C55E' : '#F59E0B',
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 700,
whiteSpace: 'nowrap',
}}>
{t('decisionQueue.priority')} {item.priority}
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 8,
}} className="agent-market-status-detail-grid">
<DetailRow label={t('decisionQueue.status')}>
<CandidatePill value={t(`decisionQueue.statuses.${item.queue_status}`)} />
</DetailRow>
<DetailRow label={t('decisionQueue.nextAction')}>
<CandidatePill value={item.recommended_action} muted />
</DetailRow>
</div>
<DetailRow label={t('decisionQueue.approvalBoundary')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{activeBoundaries.length > 0 ? (
activeBoundaries.map(key => (
<CandidatePill key={key} value={t(`decisionQueue.boundaries.${key}`)} muted />
))
) : (
<CandidatePill value={t('decisionQueue.none')} muted />
)}
</div>
</DetailRow>
<DetailRow label={t('decisionQueue.riskNotes')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{item.risk_notes.length > 0 ? (
item.risk_notes.map(note => <CandidatePill key={note} value={note} muted />)
) : (
<CandidatePill value={t('decisionQueue.none')} muted />
)}
</div>
</DetailRow>
<DetailRow label={t('decisionQueue.evidence')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{item.evidence_refs.length > 0 ? (
item.evidence_refs.map(ref => <CandidatePill key={ref} value={ref} />)
) : (
<CandidatePill value={t('decisionQueue.none')} muted />
)}
</div>
</DetailRow>
</div>
)
})}
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<ShieldCheck size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('matrix.title')}
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 10,
}} className="agent-market-status-grid">
{snapshot.candidate_statuses.map(candidate => {
const evidence = [
candidate.evidence.latest_smoke_model,
candidate.evidence.latest_replay_summary,
candidate.evidence.latest_smoke_gate,
].filter((item): item is string => Boolean(item))
return (
<div
key={candidate.candidate_id}
style={{
minWidth: 0,
padding: 12,
border: '0.5px solid #e0ddd4',
borderRadius: 7,
background: '#fff',
display: 'flex',
flexDirection: 'column',
gap: 9,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 13,
fontWeight: 700,
color: '#141413',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{candidate.display_name}
</span>
<CandidatePill value={candidate.candidate_id} muted />
</div>
<span style={{
color: candidate.gate_status === 'production_baseline' ? '#22C55E' : '#F59E0B',
fontFamily: "'DM Mono', monospace",
fontSize: 10,
fontWeight: 700,
whiteSpace: 'nowrap',
}}>
{t(`matrix.gateStatuses.${candidate.gate_status}`)}
</span>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 8,
}} className="agent-market-status-detail-grid">
<DetailRow label={t('matrix.role')}>
<CandidatePill value={candidate.role || t('matrix.none')} />
</DetailRow>
<DetailRow label={t('matrix.score')}>
{candidate.score === null ? t('matrix.noScore') : candidate.score.toFixed(4)}
</DetailRow>
<DetailRow label={t('matrix.currentGate')}>
<CandidatePill value={candidate.current_gate || t('matrix.none')} />
</DetailRow>
<DetailRow label={t('matrix.nextGate')}>
<CandidatePill value={candidate.required_next_gate || t('matrix.none')} muted />
</DetailRow>
<DetailRow label={t('matrix.runtimeApprovals')}>
{t('matrix.noRuntimeApprovals')}
</DetailRow>
<DetailRow label={t('matrix.blockers')}>
{candidate.operator_blockers.length}
</DetailRow>
</div>
<DetailRow label={t('matrix.evidence')}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{evidence.length > 0 ? (
evidence.map(item => <CandidatePill key={item} value={item} />)
) : (
<CandidatePill value={t('matrix.noEvidence')} muted />
)}
</div>
</DetailRow>
</div>
)
})}
</div>
</div>
</GlassCard>
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
gap: 12,
}} className="agent-market-policy-grid">
<GlassCard variant="subtle" padding="md" className="min-w-0">
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<Ban size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('policy.title')}
</span>
</div>
<div style={{ display: 'grid', gap: 7 }}>
<PolicyGate label={t('policy.replacement')} approved={summary.replacement_decisions_approved} />
<PolicyGate label={t('policy.replay')} approved={summary.replay_candidates_approved} />
<PolicyGate label={t('policy.sdk')} approved={summary.sdk_installations_approved} />
<PolicyGate label={t('policy.paidApi')} approved={summary.paid_api_calls_approved} />
<PolicyGate label={t('policy.production')} approved={summary.production_changes_approved} />
<PolicyGate label={t('policy.shadowCanary')} approved={summary.shadow_or_canary_approved} />
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md" className="min-w-0">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 8 }}>
<CheckCircle2 size={14} style={{ color: '#22C55E' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('allowed.title')}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{snapshot.next_allowed_actions.map(action => (
<CandidatePill key={action} value={action} />
))}
</div>
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 8 }}>
<Lock size={14} style={{ color: '#F59E0B' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('forbidden.title')}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{snapshot.forbidden_actions_without_new_approval.map(action => (
<CandidatePill key={action} value={action} muted />
))}
</div>
</div>
</div>
</GlassCard>
</div>
<style>{`
@media (max-width: 900px) {
.agent-market-kpi-grid,
.agent-market-health-grid,
.agent-market-cadence-grid,
.agent-market-decision-grid,
.agent-market-status-grid,
.agent-market-status-detail-grid,
.agent-market-policy-grid,
.agent-market-groups-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,522 @@
'use client'
/**
* AutomationInventoryTab — AI Agent 自動化盤點 Tab
* =================================================
* 消費GET /api/v1/agents/automation-inventory-snapshot
*
* 只讀最新 committed snapshot不提供批准、執行、回滾或 provider 切換操作。
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import { AlertTriangle, Boxes, Database, Lock, PackageCheck, RefreshCw, Server, ShieldCheck } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { GlassCard } from '@/components/ui/glass-card'
import { StatusOrb } from '@/components/ui/status-orb'
import {
apiClient,
type AiAgentAutomationBacklogSnapshot,
type AiAgentAutomationInventorySnapshot,
} from '@/lib/api-client'
function formatDateTime(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '--'
return date.toLocaleString('zh-TW', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function toneColor(tone: 'ok' | 'warn' | 'danger' | 'neutral') {
if (tone === 'ok') return '#22C55E'
if (tone === 'warn') return '#F59E0B'
if (tone === 'danger') return '#EF4444'
return '#141413'
}
function SmallLabel({ children }: { children: ReactNode }) {
return (
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
{children}
</span>
)
}
function Chip({ value, muted = false }: { value: string; muted?: boolean }) {
return (
<span style={{
display: 'inline-flex',
alignItems: 'center',
minHeight: 22,
padding: '3px 7px',
borderRadius: 5,
border: `0.5px solid ${muted ? '#e0ddd4' : '#d9775740'}`,
background: muted ? '#faf9f3' : 'rgba(217,119,87,0.06)',
color: muted ? '#87867f' : '#141413',
fontFamily: "'DM Mono', monospace",
fontSize: 10,
lineHeight: 1.3,
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
whiteSpace: 'nowrap',
}}>
{value}
</span>
)
}
function MetricCard({
label,
value,
tone = 'neutral',
icon,
}: {
label: string
value: number | string
tone?: 'ok' | 'warn' | 'danger' | 'neutral'
icon: ReactNode
}) {
const color = toneColor(tone)
return (
<GlassCard variant="subtle" padding="md" className="min-w-0">
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div style={{
width: 34,
height: 34,
borderRadius: 8,
border: `0.5px solid ${color}40`,
background: `${color}12`,
color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
{icon}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5, minWidth: 0 }}>
<SmallLabel>{label}</SmallLabel>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 25,
fontWeight: 700,
color,
lineHeight: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}>
{value}
</span>
</div>
</div>
</GlassCard>
)
}
function ProgressRow({ label, percent, nextTask }: { label: string; percent: number; nextTask: string }) {
const color = percent >= 70 ? '#22C55E' : percent >= 35 ? '#F59E0B' : '#d97757'
return (
<div style={{
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr) 48px',
gap: 10,
alignItems: 'center',
minWidth: 0,
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, minWidth: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{label}
</span>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
whiteSpace: 'nowrap',
}}>
{nextTask}
</span>
</div>
<div style={{ height: 6, borderRadius: 999, background: '#e0ddd4', overflow: 'hidden' }}>
<div style={{ width: `${percent}%`, height: '100%', borderRadius: 999, background: color }} />
</div>
</div>
<span style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
fontWeight: 700,
color,
textAlign: 'right',
}}>
{percent}%
</span>
</div>
)
}
export function AutomationInventoryTab() {
const t = useTranslations('governance.automationInventory')
const [snapshot, setSnapshot] = useState<AiAgentAutomationInventorySnapshot | null>(null)
const [backlog, setBacklog] = useState<AiAgentAutomationBacklogSnapshot | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const fetchSnapshot = () => {
setLoading(true)
Promise.all([
apiClient.getAiAgentAutomationInventorySnapshot(),
apiClient.getAiAgentAutomationBacklogSnapshot(),
])
.then(([inventoryData, backlogData]) => {
setSnapshot(inventoryData)
setBacklog(backlogData)
setError(false)
})
.catch(() => setError(true))
.finally(() => setLoading(false))
}
useEffect(() => {
fetchSnapshot()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const groupedAssets = useMemo(() => {
const groups = new Map<string, AiAgentAutomationInventorySnapshot['assets']>()
if (!snapshot) return []
for (const asset of snapshot.assets) {
const current = groups.get(asset.domain_id) ?? []
current.push(asset)
groups.set(asset.domain_id, current)
}
return snapshot.asset_domains.map(domain => ({
...domain,
assets: groups.get(domain.domain_id) ?? [],
})).filter(group => group.assets.length > 0)
}, [snapshot])
const groupedBacklog = useMemo(() => {
if (!backlog) return []
return (['P1', 'P2', 'P3', 'P0'] as const)
.map(priority => ({
priority,
items: backlog.backlog_items.filter(item => item.priority === priority),
}))
.filter(group => group.items.length > 0)
}, [backlog])
if (loading) {
return (
<div style={{ padding: 20, display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-kpi-grid">
{[0, 1, 2, 3].map(i => (
<GlassCard key={i} variant="subtle" padding="md">
<div style={{ width: 90, height: 10, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite', marginBottom: 10, animationDelay: `${i * 0.08}s` }} />
<div style={{ width: 54, height: 26, borderRadius: 4, background: '#e0ddd4', animation: 'pulse 1.5s infinite' }} />
</GlassCard>
))}
</div>
)
}
if (error || !snapshot || !backlog) {
return (
<div style={{ padding: 20 }}>
<GlassCard variant="subtle" padding="lg">
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, padding: '24px 0' }}>
<AlertTriangle size={24} style={{ color: '#F59E0B' }} />
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 12, color: '#87867f' }}>
{t('error')}
</span>
<button
onClick={fetchSnapshot}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '6px 14px',
border: '0.5px solid #d97757',
borderRadius: 6,
background: 'transparent',
color: '#d97757',
cursor: 'pointer',
fontFamily: "'DM Mono', monospace",
fontSize: 11,
}}
>
<RefreshCw size={12} />
{t('retry')}
</button>
</div>
</GlassCard>
</div>
)
}
const blockedAssets = snapshot.assets.filter(asset => asset.status === 'blocked').length
const criticalAssets = snapshot.assets.filter(asset => asset.risk_level === 'critical').length
const completedTasks = snapshot.tasks.filter(task => task.status === 'done').length
const p1BacklogCount = backlog.rollups.by_priority.P1 ?? 0
const blockedApprovals = Object.entries(snapshot.approval_boundaries)
.filter(([, allowed]) => allowed === false)
.map(([key]) => key)
return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 16 }}>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
<div style={{
width: 34,
height: 34,
borderRadius: 8,
border: '0.5px solid #22C55E40',
background: 'rgba(34,197,94,0.08)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<ShieldCheck size={17} style={{ color: '#22C55E' }} />
</div>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 3 }}>
<StatusOrb status="healthy" size="sm" glow />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 15, fontWeight: 700, color: '#141413' }}>
{t('title')}
</span>
</div>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 11,
color: '#87867f',
maxWidth: '100%',
overflowX: 'auto',
overflowY: 'hidden',
whiteSpace: 'nowrap',
}}>
{t('readOnly')} · {snapshot.program_status.current_task_id} {snapshot.program_status.next_task_id}
</div>
</div>
</div>
<div style={{ fontFamily: "'DM Mono', monospace", fontSize: 10, color: '#87867f' }}>
{t('generatedAt')} {formatDateTime(snapshot.generated_at)}
</div>
</div>
</GlassCard>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 12,
}} className="automation-inventory-kpi-grid">
<MetricCard label={t('metrics.progress')} value={`${snapshot.program_status.overall_completion_percent}%`} tone="ok" icon={<ShieldCheck size={16} />} />
<MetricCard label={t('metrics.assets')} value={snapshot.assets.length} icon={<Boxes size={16} />} />
<MetricCard label={t('metrics.backlog')} value={backlog.rollups.total_items} tone="warn" icon={<PackageCheck size={16} />} />
<MetricCard label={t('metrics.p1Backlog')} value={p1BacklogCount} icon={<Boxes size={16} />} />
<MetricCard label={t('metrics.blocked')} value={blockedAssets} tone={blockedAssets > 0 ? 'warn' : 'ok'} icon={<AlertTriangle size={16} />} />
<MetricCard label={t('metrics.critical')} value={criticalAssets} tone="danger" icon={<Lock size={16} />} />
</div>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<Server size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('workstreams.title')}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-workstream-grid">
{snapshot.workstreams.map(workstream => (
<div key={workstream.workstream_id} style={{ padding: 11, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', minWidth: 0 }}>
<ProgressRow
label={workstream.display_name}
percent={workstream.completion_percent}
nextTask={workstream.next_task_id}
/>
</div>
))}
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<PackageCheck size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('backlog.title', { total: backlog.rollups.total_items })}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-backlog-grid">
{groupedBacklog.map(group => (
<div key={group.priority} style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{group.priority}
</span>
<Chip value={String(group.items.length)} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 9, minWidth: 0 }}>
{group.items.slice(0, 5).map(item => (
<div key={item.item_id} style={{ display: 'flex', flexDirection: 'column', gap: 7, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, minWidth: 0 }}>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{item.title}
</span>
<Chip value={item.item_id} muted />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
<Chip value={item.owner_agent} />
<Chip value={item.gate_status} muted />
<Chip value={item.next_review} muted />
</div>
<div style={{
fontFamily: "'DM Mono', monospace",
fontSize: 10,
color: '#87867f',
lineHeight: 1.45,
overflowWrap: 'anywhere',
}}>
{item.acceptance_criteria[0]}
</div>
</div>
))}
{group.items.length > 5 ? (
<Chip value={t('backlog.more', { count: group.items.length - 5 })} muted />
) : null}
</div>
</div>
))}
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<Database size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('assets.title')}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 12 }} className="automation-inventory-domain-grid">
{groupedAssets.map(group => (
<div key={group.domain_id} style={{ padding: 12, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', display: 'flex', flexDirection: 'column', gap: 10, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{group.display_name}
</span>
<Chip value={String(group.assets.length)} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{group.assets.map(asset => (
<Chip
key={asset.asset_id}
value={`${asset.display_name} · ${asset.gate_status}`}
muted={asset.status !== 'blocked'}
/>
))}
</div>
</div>
))}
</div>
</div>
</GlassCard>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.2fr) minmax(0, 0.8fr)', gap: 12 }} className="automation-inventory-bottom-grid">
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<PackageCheck size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('tasks.title', { done: completedTasks, total: snapshot.tasks.length })}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 10 }} className="automation-inventory-task-grid">
{snapshot.tasks.map(task => (
<div key={task.task_id} style={{ padding: 11, border: '0.5px solid #e0ddd4', borderRadius: 7, background: '#fff', minWidth: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontFamily: "'DM Mono', monospace", fontSize: 11, fontWeight: 700, color: '#141413' }}>
{task.task_id}
</span>
<Chip value={t(`tasks.statuses.${task.status}`)} muted={task.status !== 'done'} />
</div>
<span style={{
fontFamily: 'Syne, sans-serif',
fontSize: 12,
fontWeight: 700,
color: '#141413',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{task.title}
</span>
<ProgressRow label={task.owner_agent} percent={task.completion_percent} nextTask={task.gate_status} />
</div>
</div>
))}
</div>
</div>
</GlassCard>
<GlassCard variant="subtle" padding="md">
<div style={{ display: 'flex', flexDirection: 'column', gap: 13, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
<Lock size={14} style={{ color: '#d97757' }} />
<span style={{ fontFamily: 'Syne, sans-serif', fontSize: 13, fontWeight: 700, color: '#141413' }}>
{t('boundaries.title')}
</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, minWidth: 0 }}>
{blockedApprovals.map(key => (
<Chip key={key} value={t(`boundaries.items.${key}`)} />
))}
</div>
</div>
</GlassCard>
</div>
<style jsx global>{`
@media (max-width: 900px) {
.automation-inventory-kpi-grid,
.automation-inventory-workstream-grid,
.automation-inventory-domain-grid,
.automation-inventory-backlog-grid,
.automation-inventory-bottom-grid,
.automation-inventory-task-grid {
grid-template-columns: 1fr !important;
}
}
`}</style>
</div>
)
}

View File

@@ -112,7 +112,7 @@ export function FlywheelKPICard() {
}
}, [])
const fmt = (n: number | undefined, digits = 0) =>
const fmt = (n: number | null | undefined, digits = 0) =>
n == null ? '--' : n.toLocaleString(undefined, { maximumFractionDigits: digits })
const pct = (n: number | null | undefined) =>

View File

@@ -81,7 +81,7 @@ function HostCard({ host }: { host: HostInfo }) {
<div style={{ width: 6, height: 6, borderRadius: '50%', background: accentColor, flexShrink: 0 }} />
<span style={{ fontSize: 12, fontWeight: 700, color: '#141413', fontFamily: 'var(--font-body), monospace' }}>
{/* 顯示末段 IP 作為簡短標識,完整名稱放 IP 欄位 */}
{host.ip.split('.').pop() ?? host.hostname}
{host.ip.includes('.') ? host.ip.split('.').pop() ?? host.ip : host.hostname}
</span>
{isK3s && (
<span style={{
@@ -92,8 +92,8 @@ function HostCard({ host }: { host: HostInfo }) {
</span>
)}
</div>
<span style={{ fontSize: 10, color: '#b0ad9f', fontFamily: 'monospace' }}>{host.ip}</span>
</div>
<span style={{ fontSize: 10, color: '#b0ad9f', fontFamily: 'monospace' }}>{host.ip}</span>
</div>
<div style={{ display: 'flex', gap: 10, marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#b0ad9f' }}>CPU <MetricBar value={host.cpuPct} /></span>
<span style={{ fontSize: 10, color: '#b0ad9f' }}>RAM <MetricBar value={host.ramPct} /></span>
@@ -117,6 +117,9 @@ function HostCard({ host }: { host: HostInfo }) {
)
}
const K8S_VIP_INFO_FALLBACK =
'K8S VIP topology (ops-only) · kubectl:6443 · web:32335 · api:32334'
export function HostGrid({ hosts }: HostGridProps) {
if (hosts.length === 0) {
return <div style={{ padding: 16, textAlign: 'center', color: '#b0ad9f', fontSize: 12 }}>--</div>
@@ -150,7 +153,7 @@ export function HostGrid({ hosts }: HostGridProps) {
K3S CLUSTER (HA)
</span>
<span style={{ fontSize: 10, color: '#3b7de8' }}>
{process.env.NEXT_PUBLIC_K8S_VIP_INFO ?? 'VIP 192.168.0.125 · kubectl :6443 · Web :32335 · API :32334'}
{(process.env.NEXT_PUBLIC_K8S_VIP_INFO ?? '').trim() || K8S_VIP_INFO_FALLBACK}
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, padding: 6 }}>

View File

@@ -246,6 +246,21 @@ export const apiClient = {
const res = await fetch(`${API_BASE_URL}/errors/ux-audit`)
return handleResponse<UXAuditResponse>(res)
},
async getAgentMarketGovernanceSnapshot() {
const res = await fetch(`${API_BASE_URL}/agents/market-governance-snapshot`)
return handleResponse<AgentMarketGovernanceSnapshot>(res)
},
async getAiAgentAutomationInventorySnapshot() {
const res = await fetch(`${API_BASE_URL}/agents/automation-inventory-snapshot`)
return handleResponse<AiAgentAutomationInventorySnapshot>(res)
},
async getAiAgentAutomationBacklogSnapshot() {
const res = await fetch(`${API_BASE_URL}/agents/automation-backlog-snapshot`)
return handleResponse<AiAgentAutomationBacklogSnapshot>(res)
},
}
// =========================================================================
@@ -470,3 +485,239 @@ export interface UXAuditResponse {
details: UXAuditDetail[]
replay_dashboard_url: string
}
// =========================================================================
// Agent Market Governance Snapshot
// =========================================================================
export interface AgentMarketGovernanceSnapshot {
schema_version: 'agent_market_governance_snapshot_v1'
generated_at: string
current_decision: string
policy: Record<string, boolean>
evaluation_cadence: {
workflow: string
schedule: string
timezone: 'Asia/Taipei'
next_scheduled_run_at: string
trigger_modes: string[]
primary_source_policy: string
operator_review_gate: string
}
market_watch_health: {
status: 'healthy' | 'blocked'
freshness_sla_hours: 168
stale_grace_hours: 6
stale_after: string
source_failures_block_priority_upgrade: boolean
blocked_from_integration: number
operator_blockers: string[]
}
summary: {
candidate_count: number
source_count: number
source_failures: number
changed_candidates: number
integration_queue_count: number
blocked_from_integration: number
watch_only_candidates_reviewed: number
eligible_for_market_scorecard_prescreen: number
recommended_watch_additions_remaining: number
priority_upgrades_approved: number
market_scorecard_updates_approved: number
replay_candidates_approved: number
sdk_installations_approved: number
paid_api_calls_approved: number
production_changes_approved: number
shadow_or_canary_approved: number
replacement_decisions_approved: number
}
candidate_groups: {
production_baseline: string[]
replay_or_integration_blocked: string[]
watch_only_candidates: string[]
watch_only_scorecard_prescreen_ready: string[]
}
candidate_statuses: Array<{
candidate_id: string
display_name: string
role: string
evaluation_priority: string
gate_status:
| 'production_baseline'
| 'integration_blocked'
| 'integration_reviewed'
| 'watch_only_prescreen_ready'
| 'watch_only_blocked'
| 'watch_only_monitoring'
| 'registered_no_review'
current_gate: string
required_next_gate: string
integration_decision: string
score: number | null
evidence: {
latest_replay_summary: string | null
latest_smoke_gate: string | null
latest_smoke_matrix: string | null
latest_smoke_model: string | null
}
approvals: {
replay: false
sdk_install: false
paid_api: false
shadow_or_canary: false
production_routing: false
}
operator_blockers: string[]
}>
operator_decision_queue: Array<{
candidate_id: string
display_name: string
priority: number
queue_status:
| 'baseline_protected'
| 'blocked_needs_evidence'
| 'operator_review_required'
| 'operator_priority_review'
| 'watch_only_blocked'
| 'watch_only_monitoring'
| 'registered_no_review'
recommended_action: string
approval_boundary: {
replacement_adr_required: boolean
priority_upgrade_required: boolean
market_scorecard_update_required: boolean
replay_approval_required: boolean
sdk_install_approval_required: boolean
paid_api_approval_required: boolean
shadow_or_canary_approval_required: boolean
production_routing_approval_required: boolean
}
risk_notes: string[]
evidence_refs: string[]
}>
next_allowed_actions: string[]
forbidden_actions_without_new_approval: string[]
}
// =========================================================================
// AI Agent Automation Inventory Snapshot
// =========================================================================
export interface AiAgentAutomationInventorySnapshot {
schema_version: 'ai_agent_automation_inventory_snapshot_v1'
generated_at: string
program_status: {
overall_completion_percent: number
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
current_task_id: string
next_task_id: string
read_only_mode: true
}
status_taxonomy: {
task_statuses: string[]
gate_statuses: string[]
priorities: Array<'P0' | 'P1' | 'P2' | 'P3'>
}
agent_roles: Array<{
agent_id: string
display_name: string
primary_role: string
allowed_actions: string[]
blocked_actions: string[]
}>
asset_domains: Array<{
domain_id: string
display_name: string
description: string
}>
assets: Array<{
asset_id: string
domain_id: string
display_name: string
asset_type: string
status: string
gate_status: string
owner_agent: string
risk_level: 'low' | 'medium' | 'high' | 'critical'
evidence_refs: string[]
next_action: string
}>
workstreams: Array<{
workstream_id: string
display_name: string
completion_percent: number
status: string
next_task_id: string
}>
tasks: Array<{
task_id: string
priority: 'P0' | 'P1' | 'P2' | 'P3'
status: string
completion_percent: number
owner_agent: string
title: string
output: string
gate_status: string
next_action: string
}>
evidence: Array<{
evidence_id: string
kind: 'schema' | 'test' | 'browser' | 'api' | 'build' | 'doc' | 'runtime'
ref: string
result: string
}>
approval_boundaries: Record<
| 'sdk_installation_allowed'
| 'paid_api_call_allowed'
| 'shadow_or_canary_allowed'
| 'production_routing_allowed'
| 'destructive_operation_allowed',
false
>
}
export interface AiAgentAutomationBacklogSnapshot {
schema_version: 'ai_agent_automation_backlog_v1'
generated_at: string
source_inventory_snapshot_ref: string
program_status: {
overall_completion_percent: number
current_priority: 'P0' | 'P1' | 'P2' | 'P3'
current_task_id: string
next_task_id: string
read_only_mode: true
}
rollups: {
total_items: number
by_priority: Record<string, number>
by_status: Record<string, number>
by_gate_status: Record<string, number>
by_owner_agent: Record<string, number>
}
backlog_items: Array<{
item_id: string
priority: 'P0' | 'P1' | 'P2' | 'P3'
status: string
workstream_id: string
source_asset_id: string
source_signal_kind: string
title: string
owner_agent: string
recommended_action: string
action_class: string
gate_status: string
risk_level: 'low' | 'medium' | 'high' | 'critical'
evidence_refs: string[]
acceptance_criteria: string[]
next_review: string
}>
approval_boundaries: Record<
| 'sdk_installation_allowed'
| 'paid_api_call_allowed'
| 'shadow_or_canary_allowed'
| 'production_routing_allowed'
| 'destructive_operation_allowed',
false
>
}