diff --git a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts index 8ef8767681..994d39dffc 100644 --- a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts +++ b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts @@ -701,11 +701,10 @@ describe('agentRoutes', () => { const res = await request(app).put('/agents/mybot/publish'); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.published).toBe(true); + expect(res.body.lifecycleBypassed).toBe(true); }); - it('publishes agent that is already in staging', async () => { + it('does not report bypass when publishing from staging', async () => { const { app } = setup({ isAdmin: true, initialConfig: { @@ -725,8 +724,7 @@ describe('agentRoutes', () => { const res = await request(app).put('/agents/mybot/publish'); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.published).toBe(true); + expect(res.body.lifecycleBypassed).toBe(false); }); it('rejects non-admin access', async () => { @@ -874,8 +872,7 @@ describe('agentRoutes', () => { .send({ agentIds: ['mybot'], published: true }); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); - expect(res.body.count).toBe(1); + expect(res.body.lifecycleBypassed).toContain('mybot'); }); it('rejects invalid payload', async () => { @@ -923,11 +920,42 @@ describe('agentRoutes', () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); + expect(res.body.cleanupResults.chatAgents).toBe('success'); const configs = store.chatAgents as Array<{ agentId: string }>; expect(configs.find(c => c.agentId === 'mybot')).toBeUndefined(); }); + it('cascading delete cleans up orchestration config for orchestration agents', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + agents: { + orchbot: { name: 'Orch Bot', instructions: 'Route queries' }, + otherbot: { name: 'Other', instructions: 'Keep me' }, + }, + chatAgents: [ + { + agentId: 'orchbot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app).delete('/agents/orchbot'); + + expect(res.status).toBe(200); + expect(res.body.cleanupResults.orchestration).toBe('success'); + + const agentMap = store.agents as Record; + expect(agentMap.orchbot).toBeUndefined(); + expect(agentMap.otherbot).toBeDefined(); + }); + it('non-admin can delete their own draft agent', async () => { const { app } = setup({ isAdmin: false, @@ -1041,7 +1069,9 @@ describe('agentRoutes', () => { ); expect(res.status).toBe(200); - expect(res.body.success).toBe(true); + expect(res.body.cleanupResults.kagenti).toContain( + 'requires DELETE /kagenti/agents', + ); }); }); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts index 868a60b118..4fd5f2a376 100644 --- a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts +++ b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts @@ -490,6 +490,7 @@ export function registerAgentRoutes( // --------------------------------------------------------------------------- // PUT /agents/:agentId/publish -- shortcut: promote to production + // Logs audit warning when bypassing lifecycle stages. // --------------------------------------------------------------------------- router.put( '/agents/:agentId/publish', @@ -503,6 +504,9 @@ export function registerAgentRoutes( const configs = await loadChatAgentConfigs(); const existing = configs.find(c => c.agentId === agentId); const now = new Date().toISOString(); + const currentStage = normalizeLifecycleStage(existing?.lifecycleStage); + + const bypassed = !isValidTransition(currentStage, 'production'); if (existing) { existing.lifecycleStage = 'production'; @@ -531,10 +535,26 @@ export function registerAgentRoutes( target: agentId, outcome: 'success', sourceIp: AuditLogger.extractIp(req), - meta: { to: 'production', direction: 'publish' }, + meta: { + from: currentStage, + to: 'production', + direction: 'publish', + lifecycleBypassed: bypassed, + }, + }); + if (bypassed) { + logger.warn( + `Admin bypass: "${agentId}" published from "${currentStage}" (skipped lifecycle) by ${userRef}`, + ); + } else { + logger.info(`Agent "${agentId}" published by ${userRef}`); + } + res.json({ + success: true, + agentId, + published: true, + lifecycleBypassed: bypassed, }); - logger.info(`Agent "${agentId}" published by ${userRef}`); - res.json({ success: true, agentId, published: true }); }, ), ); @@ -590,6 +610,7 @@ export function registerAgentRoutes( // --------------------------------------------------------------------------- // PUT /agents/bulk-publish -- bulk publish/unpublish + // Logs audit warning per agent when lifecycle is bypassed. // --------------------------------------------------------------------------- router.put( '/agents/bulk-publish', @@ -617,8 +638,18 @@ export function registerAgentRoutes( ? 'production' : 'staging'; + const bypassed: string[] = []; + for (const agentId of agentIds) { const existing = configMap.get(agentId); + const currentStage = normalizeLifecycleStage( + existing?.lifecycleStage, + ); + + if (!isValidTransition(currentStage, targetStage)) { + bypassed.push(agentId); + } + if (existing) { existing.lifecycleStage = targetStage; existing.published = published; @@ -648,10 +679,34 @@ export function registerAgentRoutes( } await saveChatAgentConfigs(configs, userRef); + + if (bypassed.length > 0) { + audit.log({ + action: 'agent.lifecycle', + actor: userRef, + target: bypassed.join(', '), + outcome: 'success', + sourceIp: AuditLogger.extractIp(req), + meta: { + direction: 'bulk-publish', + lifecycleBypassed: true, + bypassedCount: bypassed.length, + }, + }); + logger.warn( + `Admin bypass: bulk-publish of ${bypassed.length}/${agentIds.length} agents skipped lifecycle by ${userRef}`, + ); + } + logger.info( `Bulk ${published ? 'publish' : 'unpublish'} of ${agentIds.length} agents by ${userRef}`, ); - res.json({ success: true, count: agentIds.length, published }); + res.json({ + success: true, + count: agentIds.length, + published, + lifecycleBypassed: bypassed.length > 0 ? bypassed : undefined, + }); }, ), ); @@ -691,7 +746,11 @@ export function registerAgentRoutes( ); // --------------------------------------------------------------------------- - // DELETE /agents/:agentId -- remove agent lifecycle config entry + // DELETE /agents/:agentId -- enterprise-grade cascading delete + // Detects the agent's source and cleans up all stores: + // - chatAgents lifecycle config entry + // - orchestration admin config ('agents' key) for responses-api agents + // Kagenti K8s resource cleanup requires the dedicated admin endpoint. // Non-admins can only delete their own draft agents. // Admins can delete any agent's config. // --------------------------------------------------------------------------- @@ -703,26 +762,31 @@ export function registerAgentRoutes( async (req, res) => { const agentId = decodeURIComponent(req.params.agentId); const userRef = await ctx.getUserRef(req); - const isAdmin = await ctx.checkIsAdmin(req); + const admin = await ctx.checkIsAdmin(req); + + const { agents: allAgents } = await buildUnifiedAgentList(); + const agentRecord = allAgents.find(a => a.id === agentId); + const source = agentRecord?.source ?? 'unknown'; + const configs = await loadChatAgentConfigs(); const idx = configs.findIndex(c => c.agentId === agentId); - if (idx === -1) { - res.status(404).json({ error: 'Agent config not found' }); + if (idx === -1 && !agentRecord) { + res.status(404).json({ error: 'Agent not found' }); return; } - const existing = configs[idx]; - const stage = normalizeLifecycleStage(existing.lifecycleStage); + const existing = idx !== -1 ? configs[idx] : undefined; + const stage = normalizeLifecycleStage(existing?.lifecycleStage); - if (!isAdmin) { + if (!admin) { if (stage !== 'draft') { res.status(403).json({ error: 'Non-admin users can only delete agents in draft stage.', }); return; } - if (existing.createdBy && existing.createdBy !== userRef) { + if (existing?.createdBy && existing.createdBy !== userRef) { res.status(403).json({ error: 'You can only delete agents you created.', }); @@ -730,8 +794,47 @@ export function registerAgentRoutes( } } - configs.splice(idx, 1); - await saveChatAgentConfigs(configs, userRef); + const cleanupResults: Record = {}; + + if (idx !== -1) { + configs.splice(idx, 1); + await saveChatAgentConfigs(configs, userRef); + cleanupResults.chatAgents = 'success'; + } else { + cleanupResults.chatAgents = 'skipped'; + } + + if (source === 'orchestration') { + try { + const raw = await adminConfig.get('agents'); + if (raw && typeof raw === 'object') { + const agentMap = { ...(raw as Record) }; + if (Object.hasOwn(agentMap, agentId)) { + delete agentMap[agentId]; + await adminConfig.set('agents', agentMap, userRef); + cleanupResults.orchestration = 'success'; + } else { + cleanupResults.orchestration = 'skipped'; + } + } else { + cleanupResults.orchestration = 'skipped'; + } + } catch (err) { + cleanupResults.orchestration = `failed: ${err instanceof Error ? err.message : 'unknown'}`; + logger.warn( + `Orchestration cleanup failed for "${agentId}": ${cleanupResults.orchestration}`, + ); + } + } else { + cleanupResults.orchestration = 'skipped'; + } + + if (source === 'kagenti') { + cleanupResults.kagenti = + 'requires DELETE /kagenti/agents/:ns/:name (admin endpoint)'; + } else { + cleanupResults.kagenti = 'skipped'; + } audit.log({ action: 'agent.delete', @@ -739,12 +842,17 @@ export function registerAgentRoutes( target: agentId, outcome: 'success', sourceIp: AuditLogger.extractIp(req), - meta: { lifecycleStage: stage, isAdmin }, + meta: { + lifecycleStage: stage, + isAdmin: admin, + source, + cleanupResults, + }, }); logger.info( - `Agent config "${agentId}" deleted (stage: ${stage}) by ${userRef}`, + `Agent "${agentId}" deleted (source: ${source}, stage: ${stage}) by ${userRef}`, ); - res.json({ success: true, agentId }); + res.json({ success: true, agentId, source, cleanupResults }); }, ), ); diff --git a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx index 88cf4f4d70..5a052eb38c 100644 --- a/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx +++ b/workspaces/augment/plugins/augment/src/components/AdminPanels/KagentiPanels/OrchAgentDetailView.tsx @@ -35,7 +35,6 @@ import { useApi } from '@backstage/core-plugin-api'; import type { ChatAgent } from '@red-hat-developer-hub/backstage-plugin-augment-common'; import { normalizeLifecycleStage } from '@red-hat-developer-hub/backstage-plugin-augment-common'; import { augmentApiRef } from '../../../api'; -import { useAdminConfig } from '../../../hooks'; import { useEffectiveConfig } from '../../../hooks/useEffectiveConfig'; import { agentFromConfig } from '../AgentsPanel/agentValidation'; import { ConfirmDialog } from '../shared/ConfirmDialog'; @@ -88,7 +87,6 @@ export function OrchAgentDetailView({ const theme = useTheme(); const api = useApi(augmentApiRef); - const { entry: agentsEntry, save: saveAgents } = useAdminConfig('agents'); const { config: effectiveConfig } = useEffectiveConfig(); const [lifecycleStage, setLifecycleStage] = useState( @@ -180,17 +178,7 @@ export function OrchAgentDetailView({ const handleDelete = useCallback(async () => { setError(null); try { - if ( - !agentsEntry?.configValue || - typeof agentsEntry.configValue !== 'object' - ) { - throw new Error('Unable to load current agent configuration'); - } - const existing = { - ...(agentsEntry.configValue as Record), - }; - delete existing[agent.id]; - await saveAgents(existing); + await api.deleteAgentConfig(agent.id); setToast({ message: 'Agent deleted', severity: 'success' }); onBack(); } catch (err) { @@ -198,7 +186,7 @@ export function OrchAgentDetailView({ } finally { setDeleteOpen(false); } - }, [agentsEntry, agent.id, saveAgents, onBack]); + }, [api, agent.id, onBack]); const roleLabel = agent.agentRole ? agent.agentRole.charAt(0).toUpperCase() + agent.agentRole.slice(1) diff --git a/workspaces/augment/plugins/augment/src/components/CommandCenter/OpsOverview.tsx b/workspaces/augment/plugins/augment/src/components/CommandCenter/OpsOverview.tsx index e0c7faf565..246f89f44d 100644 --- a/workspaces/augment/plugins/augment/src/components/CommandCenter/OpsOverview.tsx +++ b/workspaces/augment/plugins/augment/src/components/CommandCenter/OpsOverview.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import { useTheme, alpha } from '@mui/material/styles'; @@ -53,31 +53,35 @@ export function OpsOverview({ namespace, onNavigate }: OpsOverviewProps) { const [agents, setAgents] = useState([]); const [tools, setTools] = useState([]); const [loading, setLoading] = useState(true); + const initialLoadDone = useRef(false); - useEffect(() => { - let cancelled = false; - setLoading(true); + const loadData = useCallback(() => { + if (!initialLoadDone.current) { + setLoading(true); + } Promise.all([ - api.listAgents().catch(() => []), + api.listAgents().catch(() => [] as ChatAgent[]), api .listKagentiTools(namespace) .then(r => r.tools ?? []) - .catch(() => []), + .catch(() => [] as KagentiToolSummary[]), ]) .then(([a, t]) => { - if (!cancelled) { - setAgents(a as ChatAgent[]); - setTools(t); - } + setAgents(a); + setTools(t); }) .finally(() => { - if (!cancelled) setLoading(false); + initialLoadDone.current = true; + setLoading(false); }); - return () => { - cancelled = true; - }; }, [api, namespace]); + useEffect(() => { + loadData(); + const interval = setInterval(loadData, 30_000); + return () => clearInterval(interval); + }, [loadData]); + const stats = useMemo(() => { const total = agents.length; const ready = agents.filter( diff --git a/workspaces/augment/plugins/augment/src/components/CommandCenter/ReviewQueue.tsx b/workspaces/augment/plugins/augment/src/components/CommandCenter/ReviewQueue.tsx index 8116b99e91..01b775068e 100644 --- a/workspaces/augment/plugins/augment/src/components/CommandCenter/ReviewQueue.tsx +++ b/workspaces/augment/plugins/augment/src/components/CommandCenter/ReviewQueue.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useRef } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; @@ -54,19 +54,28 @@ export function ReviewQueue() { const [rejectAgentId, setRejectAgentId] = useState(null); const [rejectReason, setRejectReason] = useState(''); + const initialLoadDone = useRef(false); + const loadAgents = useCallback(() => { - setLoading(true); + if (!initialLoadDone.current) { + setLoading(true); + } api .listAgents() .then(result => setAgents(result.filter(a => a.lifecycleStage === 'review')), ) .catch(() => {}) - .finally(() => setLoading(false)); + .finally(() => { + initialLoadDone.current = true; + setLoading(false); + }); }, [api]); useEffect(() => { loadAgents(); + const interval = setInterval(loadAgents, 30_000); + return () => clearInterval(interval); }, [loadAgents]); const handleApprove = useCallback(