From f5d6d834e45474d6686c2de2770273e32d27ec41 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Thu, 21 May 2026 12:29:04 -0400 Subject: [PATCH 1/3] feat(augment): add polling to admin dashboards without loading flash Phase 3 of lifecycle hardening: adds 30-second polling to ReviewQueue and OpsOverview for real-time admin visibility. Key fix: only sets loading=true on the initial load, not on subsequent polls. This eliminates the skeleton flash that occurred every 30 seconds in the previous implementation (PR G). ReviewQueue: - 30s polling interval for review queue agents - initialLoadDone ref prevents loading flash on polls OpsOverview: - 30s polling interval for agent and tool data - Same loading flash fix via initialLoadDone ref --- .../components/CommandCenter/OpsOverview.tsx | 32 +++++++++++-------- .../components/CommandCenter/ReviewQueue.tsx | 15 +++++++-- 2 files changed, 30 insertions(+), 17 deletions(-) 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( From 7469ed05a1c08de7102999da976429621aad6a53 Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Thu, 21 May 2026 12:32:52 -0400 Subject: [PATCH 2/3] feat(augment): enterprise-grade cascading delete and lifecycle enforcement Phase 4 of lifecycle hardening: source-aware cascading delete and lifecycle graph enforcement for publish routes. Cascading DELETE /agents/:agentId: - Detects agent source via unified agent list (kagenti/orchestration/workflow) - For orchestration agents: removes from 'agents' admin config key AND chatAgents lifecycle entry in a single operation - For Kagenti agents: removes chatAgents entry, notes that K8s cleanup requires the dedicated admin endpoint - Returns detailed cleanupResults per store for transparency - Ownership enforcement: non-admins restricted to own draft agents Lifecycle enforcement on publish routes: - PUT /agents/:agentId/publish: detects when admin bypasses lifecycle stages (e.g. draft -> production), logs audit warning with lifecycleBypassed flag, still allows the operation - PUT /agents/bulk-publish: same bypass detection per agent, logs warning with count of bypassed agents OrchAgentDetailView: - Updated handleDelete to use the cascading DELETE /agents/:agentId endpoint instead of directly mutating admin config - Removed unused useAdminConfig('agents') hook --- .../src/routes/agentRoutes.test.ts | 46 +++++- .../augment-backend/src/routes/agentRoutes.ts | 142 +++++++++++++++--- .../KagentiPanels/OrchAgentDetailView.tsx | 16 +- 3 files changed, 165 insertions(+), 39 deletions(-) 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) From 8365e4c7ce634b36dcf740ee739e935107099efe Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Thu, 21 May 2026 12:35:11 -0400 Subject: [PATCH 3/3] feat(augment): add SonataFlow agent-approval workflow Phase 5 of lifecycle hardening: SonataFlow workflow for automated agent lifecycle approval, based on the RHDH orchestrator escalation pattern. Workflow (agent-approval.sw.yaml): - Triggered when agent is submitted for review (draft -> review) - Sends Backstage notification to admins on submission - Suspends in callback state awaiting admin decision CloudEvent - Approval: promotes agent to staging, notifies creator - Rejection: demotes to draft with reason, notifies creator - 72-hour timeout: sends escalation notification, re-enters wait state - Uses kogitoprocrefid CloudEvent extension for instance correlation Infrastructure: - OpenAPI spec for augment promote/demote endpoints - JSON Schema for workflow input validation - Application properties for SonataFlow Operator deployment - Knative Eventing configuration for CloudEvent routing CloudEvent type: io.rhdhorchestrator.agent.approval.decision Correlation: kogitoprocrefid (SonataFlow process instance ID) Prerequisites: SonataFlow Operator, Knative Eventing, Backstage Notifications plugin, OIDC service account. --- .../workflows/agent-approval/README.md | 77 ++++++++ .../agent-approval/agent-approval.sw.yaml | 171 ++++++++++++++++++ .../agent-approval/application.properties | 22 +++ .../schemas/agent-approval-input.json | 35 ++++ .../specs/augment-agent-lifecycle.yaml | 88 +++++++++ 5 files changed, 393 insertions(+) create mode 100644 workspaces/augment/workflows/agent-approval/README.md create mode 100644 workspaces/augment/workflows/agent-approval/agent-approval.sw.yaml create mode 100644 workspaces/augment/workflows/agent-approval/application.properties create mode 100644 workspaces/augment/workflows/agent-approval/schemas/agent-approval-input.json create mode 100644 workspaces/augment/workflows/agent-approval/specs/augment-agent-lifecycle.yaml diff --git a/workspaces/augment/workflows/agent-approval/README.md b/workspaces/augment/workflows/agent-approval/README.md new file mode 100644 index 0000000000..9e1ffb8fad --- /dev/null +++ b/workspaces/augment/workflows/agent-approval/README.md @@ -0,0 +1,77 @@ +# Agent Lifecycle Approval Workflow + +SonataFlow workflow that automates the agent lifecycle approval process in RHDH. + +## Overview + +When an agent is submitted for review (`draft -> review`), this workflow: + +1. **Notifies admins** via Backstage Notifications that a new agent is pending review +2. **Suspends** in a `callback` state, waiting for an admin decision CloudEvent +3. On **approval**: promotes the agent to `staging` and notifies the creator +4. On **rejection**: demotes the agent to `draft` with a reason and notifies the creator +5. On **timeout** (72 hours): sends an escalation notification and re-enters the wait state + +## Architecture + +``` +Agent Creator SonataFlow Admin + | | | + |-- submit for review -------->| | + | |-- notify admins ----------->| + | | | + | | (callback state) | + | | waits for CloudEvent | + | | | + | |<-- approval decision -------| + | | (CloudEvent with | + | | kogitoprocrefid) | + | | | + |<-- approval notification ----| | + | |-- promote to staging ------>| +``` + +## CloudEvent Format + +The admin decision CloudEvent must include: + +```json +{ + "specversion": "1.0", + "type": "io.rhdhorchestrator.agent.approval.decision", + "source": "augment.admin", + "id": "", + "kogitoprocrefid": "", + "datacontenttype": "application/json", + "data": { + "approved": true, + "decidedBy": "user:default/admin-name", + "reason": "Optional reason (required for rejections)" + } +} +``` + +## Prerequisites + +- SonataFlow Operator installed on OpenShift +- Knative Eventing with a Broker configured +- Backstage Notifications plugin enabled +- OIDC client configured for service-to-service auth + +## Deployment + +1. Create a `SonataFlow` CR referencing this workflow +2. Configure environment variables in `application.properties` +3. Set up a Knative Trigger to route `io.rhdhorchestrator.agent.approval.decision` events to the workflow +4. Configure the Augment backend to emit CloudEvents on admin approve/reject actions + +## Configuration + +| Variable | Description | Default | +| ---------------------- | ------------------------------- | ----------------------------------------------------------------------------- | +| `NOTIFICATIONS_URL` | Backstage Notifications API URL | `http://backstage-backend.backstage.svc.cluster.local:7007/api/notifications` | +| `AUGMENT_BACKEND_URL` | Augment plugin backend URL | `http://backstage-backend.backstage.svc.cluster.local:7007/api/augment` | +| `K_SINK` | Knative Eventing sink URL | `http://broker-ingress.knative-eventing.svc.cluster.local/default/default` | +| `OIDC_CLIENT_ID` | OIDC client ID for service auth | `sonataflow-agent-approval` | +| `OIDC_CLIENT_SECRET` | OIDC client secret | (required) | +| `OIDC_AUTH_SERVER_URL` | Keycloak/OIDC server URL | (required) | diff --git a/workspaces/augment/workflows/agent-approval/agent-approval.sw.yaml b/workspaces/augment/workflows/agent-approval/agent-approval.sw.yaml new file mode 100644 index 0000000000..ba233f6ef9 --- /dev/null +++ b/workspaces/augment/workflows/agent-approval/agent-approval.sw.yaml @@ -0,0 +1,171 @@ +specVersion: '0.8' +id: agentApproval +name: Agent Lifecycle Approval +annotations: + - 'workflow-type/infrastructure' +version: 0.1.0 +description: > + SonataFlow workflow for agent lifecycle approval. When an agent is submitted + for review (draft -> review), this workflow suspends in a callback state + awaiting an admin decision CloudEvent. If no decision is received within + the configured timeout, an escalation notification is sent. +timeouts: + workflowExecTimeout: + duration: P7D +start: NotifyReviewSubmitted +extensions: + - extensionid: workflow-uri-definitions + definitions: + notifications: 'https://raw.githubusercontent.com/rhdhorchestrator/serverless-workflows/main/workflows/shared/specs/notifications-openapi.yaml' + augment: 'specs/augment-agent-lifecycle.yaml' +dataInputSchema: + failOnValidationErrors: true + schema: schemas/agent-approval-input.json +errors: + - name: approvalTimeout + code: TimedOut + - name: notAvailable + code: '404' +functions: + - name: createNotification + operation: notifications#createNotification + - name: promoteAgent + operation: augment#promoteAgent + - name: demoteAgent + operation: augment#demoteAgent + - name: logInfo + type: custom + operation: 'sysout:INFO' +events: + - name: approvalDecisionEvent + source: augment.admin + type: io.rhdhorchestrator.agent.approval.decision + correlation: + - contextAttributeName: kogitoprocrefid +states: + - name: NotifyReviewSubmitted + type: operation + actions: + - name: logSubmission + functionRef: + refName: logInfo + arguments: + message: '"Agent submitted for review: " + .agentId + " by " + .submittedBy' + - name: notifyAdmins + functionRef: + refName: createNotification + arguments: + recipients: + type: 'broadcast' + payload: + title: '"Agent Review: " + .agentName + " (" + .agentId + ")"' + description: '"Agent submitted for review by " + .submittedBy + ". Please approve or reject."' + topic: 'Agent Lifecycle' + link: .reviewUrl + severity: 'normal' + onErrors: + - errorRef: notAvailable + transition: WaitForDecision + transition: WaitForDecision + + - name: WaitForDecision + type: callback + action: + functionRef: + refName: logInfo + arguments: + message: '"Waiting for admin decision on agent: " + .agentId' + eventRef: approvalDecisionEvent + eventDataFilter: + data: '.decision' + toStateData: '.adminDecision' + timeouts: + eventTimeout: PT72H + onErrors: + - errorRef: approvalTimeout + transition: EscalateTimeout + transition: ProcessDecision + + - name: EscalateTimeout + type: operation + actions: + - name: logEscalation + functionRef: + refName: logInfo + arguments: + message: '"Escalation: No decision on agent " + .agentId + " within 72h"' + - name: escalationNotification + functionRef: + refName: createNotification + arguments: + recipients: + type: 'broadcast' + payload: + title: '"ESCALATION: Agent " + .agentName + " awaiting review for 72+ hours"' + description: '"Agent " + .agentId + " submitted by " + .submittedBy + " has been pending review for over 72 hours. Please take action."' + topic: 'Agent Lifecycle' + link: .reviewUrl + severity: 'high' + onErrors: + - errorRef: notAvailable + transition: WaitForDecision + transition: WaitForDecision + + - name: ProcessDecision + type: switch + dataConditions: + - condition: (.adminDecision.approved == true) + transition: ApproveAgent + - condition: (.adminDecision.approved == false) + transition: RejectAgent + defaultCondition: + transition: WaitForDecision + + - name: ApproveAgent + type: operation + actions: + - name: promoteToStaging + functionRef: + refName: promoteAgent + arguments: + agentId: .agentId + targetStage: 'staging' + - name: notifyApproval + functionRef: + refName: createNotification + arguments: + recipients: + entityRef: .submittedBy + payload: + title: '"Agent Approved: " + .agentName' + description: '"Your agent " + .agentId + " has been approved and moved to staging by " + .adminDecision.decidedBy' + topic: 'Agent Lifecycle' + severity: 'normal' + stateDataFilter: + output: '{agentId: .agentId, outcome: "approved", decidedBy: .adminDecision.decidedBy}' + end: true + + - name: RejectAgent + type: operation + actions: + - name: demoteToDraft + functionRef: + refName: demoteAgent + arguments: + agentId: .agentId + targetStage: 'draft' + reason: .adminDecision.reason + - name: notifyRejection + functionRef: + refName: createNotification + arguments: + recipients: + entityRef: .submittedBy + payload: + title: '"Agent Rejected: " + .agentName' + description: '"Your agent " + .agentId + " was rejected by " + .adminDecision.decidedBy + ". Reason: " + (.adminDecision.reason // "No reason provided")' + topic: 'Agent Lifecycle' + severity: 'normal' + stateDataFilter: + output: '{agentId: .agentId, outcome: "rejected", decidedBy: .adminDecision.decidedBy, reason: .adminDecision.reason}' + end: true diff --git a/workspaces/augment/workflows/agent-approval/application.properties b/workspaces/augment/workflows/agent-approval/application.properties new file mode 100644 index 0000000000..e7ee060563 --- /dev/null +++ b/workspaces/augment/workflows/agent-approval/application.properties @@ -0,0 +1,22 @@ +# Agent Approval Workflow - SonataFlow Configuration +# Deploy on OpenShift with the SonataFlow Operator + +# Workflow ID +quarkus.rest-client.notifications.url=${NOTIFICATIONS_URL:http://backstage-backend.backstage.svc.cluster.local:7007/api/notifications} +quarkus.rest-client.augment.url=${AUGMENT_BACKEND_URL:http://backstage-backend.backstage.svc.cluster.local:7007/api/augment} + +# Knative Eventing - CloudEvent sink for emitting events +mp.messaging.outgoing.kogito_outgoing_stream.url=${K_SINK:http://broker-ingress.knative-eventing.svc.cluster.local/default/default} + +# OIDC / Service Account authentication for backend calls +quarkus.oidc-client.client-id=${OIDC_CLIENT_ID:sonataflow-agent-approval} +quarkus.oidc-client.client-secret=${OIDC_CLIENT_SECRET:} +quarkus.oidc-client.token-path=${OIDC_TOKEN_PATH:/realms/backstage/protocol/openid-connect/token} +quarkus.oidc-client.auth-server-url=${OIDC_AUTH_SERVER_URL:} + +# Correlation: CloudEvents must include kogitoprocrefid for instance matching +kogito.addon.events.process.kogitoprocrefid-enabled=true + +# Approval timeout (overridable via secret) +# Default: 72 hours before escalation +agent.approval.timeout=PT72H diff --git a/workspaces/augment/workflows/agent-approval/schemas/agent-approval-input.json b/workspaces/augment/workflows/agent-approval/schemas/agent-approval-input.json new file mode 100644 index 0000000000..e286985215 --- /dev/null +++ b/workspaces/augment/workflows/agent-approval/schemas/agent-approval-input.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Agent Approval Workflow Input", + "description": "Input data for the agent lifecycle approval workflow", + "type": "object", + "required": ["agentId", "agentName", "submittedBy"], + "properties": { + "agentId": { + "type": "string", + "description": "Unique agent identifier (e.g. 'namespace/name' or agent key)" + }, + "agentName": { + "type": "string", + "description": "Human-readable agent display name" + }, + "submittedBy": { + "type": "string", + "description": "Backstage user entity ref of who submitted the agent for review" + }, + "reviewUrl": { + "type": "string", + "description": "URL to the review queue in RHDH for deep-linking in notifications" + }, + "agentSource": { + "type": "string", + "enum": ["kagenti", "orchestration", "workflow-builder"], + "description": "Origin of the agent" + }, + "agentFramework": { + "type": "string", + "description": "Agent framework (e.g. 'a2a', 'responses-api', 'workflow-builder')" + } + }, + "additionalProperties": false +} diff --git a/workspaces/augment/workflows/agent-approval/specs/augment-agent-lifecycle.yaml b/workspaces/augment/workflows/agent-approval/specs/augment-agent-lifecycle.yaml new file mode 100644 index 0000000000..0ffbecb4d1 --- /dev/null +++ b/workspaces/augment/workflows/agent-approval/specs/augment-agent-lifecycle.yaml @@ -0,0 +1,88 @@ +openapi: 3.0.3 +info: + title: Augment Agent Lifecycle API + description: > + Subset of the RHDH Augment plugin backend API used by the SonataFlow + agent-approval workflow. Only the promote and demote endpoints are + exposed here for workflow automation. + version: 1.0.0 +servers: + - url: '{augmentBackendUrl}' + description: RHDH Augment backend (injected at deploy time) + variables: + augmentBackendUrl: + default: http://backstage-backend.backstage.svc.cluster.local:7007/api/augment +paths: + /agents/{agentId}/promote: + put: + operationId: promoteAgent + summary: Promote an agent to the next lifecycle stage + parameters: + - name: agentId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetStage: + type: string + enum: [draft, review, staging, production, retired] + responses: + '200': + description: Agent promoted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + agentId: + type: string + lifecycleStage: + type: string + version: + type: integer + /agents/{agentId}/demote: + put: + operationId: demoteAgent + summary: Demote an agent to a previous lifecycle stage + parameters: + - name: agentId + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + targetStage: + type: string + enum: [draft, review, staging, production, retired] + reason: + type: string + description: Rejection reason (for review -> draft transitions) + responses: + '200': + description: Agent demoted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + agentId: + type: string + lifecycleStage: + type: string