From 2593801f925ac922ea91ddba4dd5fdb33affa94b Mon Sep 17 00:00:00 2001 From: Raghuram Banda Date: Thu, 21 May 2026 13:24:41 -0400 Subject: [PATCH] feat(augment): consolidate agent lifecycle backend types and plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of lifecycle hardening: single PR consolidating all backend type changes and plumbing that were previously split across PRs A/B/E. Backend types (shared.ts): - Add createdBy, createdAt to ChatAgentConfig and ChatAgent - Add rejectionReason, rejectedBy, rejectedAt for rejection tracking Agent routes (agentRoutes.ts): - overlayConfig surfaces all new lifecycle fields - GET /agents scoped for non-admins: published + own + legacy unowned - Promote: ownership checks, phantom draft blocking for non-admins, createdBy set on new config entries, rejection fields cleared on re-promote - Demote: accepts optional reason param, stores rejection fields on review → draft transitions - DELETE /agents/:agentId: ownership-gated draft deletion for non-admins, full deletion for admins, with audit logging Frontend API (AugmentApi.ts): - Add deleteAgentConfig(agentId) method - Add reason param to demoteAgent signature Audit logger: - Add 'agent.delete' audit action API reports updated for both augment-common and augment plugins. --- .../augment/.changeset/lifecycle-hardening.md | 7 + .../src/routes/agentRoutes.test.ts | 1231 +++++++++++++++++ .../augment-backend/src/routes/agentRoutes.ts | 191 ++- .../src/services/AuditLogger.ts | 1 + .../plugins/augment-common/report.api.md | 7 + .../augment-common/src/types/shared.ts | 22 +- .../augment/plugins/augment/report.api.md | 1 + .../plugins/augment/src/api/AugmentApi.ts | 9 +- 8 files changed, 1398 insertions(+), 71 deletions(-) create mode 100644 workspaces/augment/.changeset/lifecycle-hardening.md create mode 100644 workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts diff --git a/workspaces/augment/.changeset/lifecycle-hardening.md b/workspaces/augment/.changeset/lifecycle-hardening.md new file mode 100644 index 0000000000..8aa8cc6e64 --- /dev/null +++ b/workspaces/augment/.changeset/lifecycle-hardening.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-augment': minor +'@red-hat-developer-hub/backstage-plugin-augment-backend': minor +'@red-hat-developer-hub/backstage-plugin-augment-common': minor +--- + +Agent lifecycle hardening: add 5-stage promotion pipeline (draft/review/staging/production/retired), ownership-gated operations, rejection tracking, role-aware UI, admin polling dashboards, cascading delete, and SonataFlow agent-approval workflow. diff --git a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts new file mode 100644 index 0000000000..8ef8767681 --- /dev/null +++ b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.test.ts @@ -0,0 +1,1231 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import request from 'supertest'; +import { registerAgentRoutes } from './agentRoutes'; +import { createMockLogger } from '../test-utils/mocks'; +import type { AdminConfigService } from '../services/AdminConfigService'; +import type { AdminConfigKey } from '@red-hat-developer-hub/backstage-plugin-augment-common'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +interface SetupOptions { + isAdmin?: boolean; + userRef?: string; + /** Seed the in-memory config store before each test. */ + initialConfig?: Record; + /** Provider agents returned by provider.listAgents */ + providerAgents?: Array>; +} + +function setup(opts: SetupOptions = {}) { + const { isAdmin = true, userRef = 'user:default/admin' } = opts; + + const store: Record = { ...(opts.initialConfig ?? {}) }; + + const adminConfig: jest.Mocked< + Pick + > = { + get: jest.fn(async (key: AdminConfigKey) => store[key] ?? null), + set: jest.fn( + async (key: AdminConfigKey, value: unknown, _updatedBy: string) => { + store[key] = value; + }, + ), + delete: jest.fn(async (key: AdminConfigKey) => { + delete store[key]; + return true; + }), + initialize: jest.fn(), + }; + + const router = express.Router(); + const logger = createMockLogger(); + + const providerAgents = opts.providerAgents ?? []; + + const ctx = { + router, + logger, + config: {} as never, + provider: { + id: 'llamastack', + displayName: 'Llama Stack', + listAgents: jest.fn().mockResolvedValue(providerAgents), + } as never, + orchestrationProvider: undefined, + sessions: undefined, + toErrorMessage: (e: unknown) => + e instanceof Error ? e.message : String(e), + sendRouteError: jest.fn( + (res: express.Response, err: unknown, _label: string, msg: string) => { + const status = + err instanceof Error && err.name === 'InputError' ? 400 : 500; + res.status(status).json({ error: msg }); + }, + ), + missingSessions: jest.fn().mockReturnValue(false), + missingConversations: jest.fn().mockReturnValue(false), + getUserRef: jest.fn().mockResolvedValue(userRef), + checkIsAdmin: jest.fn().mockResolvedValue(isAdmin), + requireAdminAccess: (( + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (!isAdmin) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + next(); + }) as express.RequestHandler, + parseChatRequest: jest.fn(), + parseApprovalRequest: jest.fn(), + }; + + registerAgentRoutes(ctx, adminConfig as unknown as AdminConfigService); + + const app = express(); + app.use(express.json()); + app.use(router); + + return { app, adminConfig, store, logger }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('agentRoutes', () => { + // ========================================================================= + // GET /agents -- unified listing & scoping + // ========================================================================= + + describe('GET /agents', () => { + it('returns an empty list when no agents exist', async () => { + const { app } = setup(); + const res = await request(app).get('/agents'); + expect(res.status).toBe(200); + expect(res.body.agents).toEqual([]); + expect(res.body.sources).toBeDefined(); + }); + + it('returns orchestration agents from the agents config key', async () => { + const { app } = setup({ + initialConfig: { + agents: { + triage: { name: 'Triage', instructions: 'Route user queries' }, + }, + }, + }); + const res = await request(app).get('/agents'); + expect(res.status).toBe(200); + expect(res.body.agents).toHaveLength(1); + expect(res.body.agents[0].id).toBe('triage'); + expect(res.body.agents[0].source).toBe('orchestration'); + }); + + it('non-admin sees only published, own, or unowned agents', async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { + public: { name: 'Public Bot', instructions: 'Help' }, + draft: { name: 'Draft Bot', instructions: 'In progress' }, + other: { name: 'Other Bot', instructions: 'Owned by bob' }, + }, + chatAgents: [ + { + agentId: 'public', + lifecycleStage: 'production', + published: true, + visible: true, + featured: false, + }, + { + agentId: 'draft', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + }, + { + agentId: 'other', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/bob', + }, + ], + }, + }); + + const res = await request(app).get('/agents'); + expect(res.status).toBe(200); + const ids = res.body.agents.map((a: { id: string }) => a.id); + expect(ids).toContain('public'); + expect(ids).toContain('draft'); + expect(ids).not.toContain('other'); + }); + + it('admin sees all agents regardless of ownership', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { + public: { name: 'Public', instructions: 'x' }, + draft: { name: 'Draft', instructions: 'y' }, + }, + chatAgents: [ + { + agentId: 'public', + lifecycleStage: 'production', + published: true, + visible: true, + featured: false, + }, + { + agentId: 'draft', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/bob', + }, + ], + }, + }); + + const res = await request(app).get('/agents'); + expect(res.status).toBe(200); + expect(res.body.agents).toHaveLength(2); + }); + + it('filters to only published when ?published=true', async () => { + const { app } = setup({ + initialConfig: { + agents: { + prod: { name: 'Prod', instructions: 'x' }, + draft: { name: 'Draft', instructions: 'y' }, + }, + chatAgents: [ + { + agentId: 'prod', + lifecycleStage: 'production', + published: true, + visible: true, + featured: false, + }, + { + agentId: 'draft', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app).get('/agents?published=true'); + expect(res.status).toBe(200); + expect(res.body.agents).toHaveLength(1); + expect(res.body.agents[0].id).toBe('prod'); + }); + }); + + // ========================================================================= + // PUT /agents/:agentId/promote + // ========================================================================= + + describe('PUT /agents/:agentId/promote', () => { + it('promotes draft to review for non-admin owner', async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/promote') + .send({ targetStage: 'review' }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.lifecycleStage).toBe('review'); + }); + + it('rejects non-admin attempting review → staging', async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'review', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/promote') + .send({ targetStage: 'staging' }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('Only admins'); + }); + + it('blocks phantom draft promotion for non-admin', async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { ghost: { name: 'Ghost', instructions: 'Boo' } }, + }, + }); + + const res = await request(app) + .put('/agents/ghost/promote') + .send({ targetStage: 'review' }); + + expect(res.status).toBe(404); + expect(res.body.error).toContain('not found in lifecycle config'); + }); + + it("blocks non-admin from promoting another user's agent", async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { bobbot: { name: 'Bob Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'bobbot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/bob', + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/bobbot/promote') + .send({ targetStage: 'review' }); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('only promote agents you created'); + }); + + it('admin promotes review → staging', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'review', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/promote') + .send({ targetStage: 'staging' }); + + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('staging'); + }); + + it('admin promotes staging → production', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'staging', + published: false, + visible: false, + featured: false, + version: 2, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/promote') + .send({ targetStage: 'production' }); + + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('production'); + }); + + it('rejects invalid transition draft → production', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/promote') + .send({ targetStage: 'production' }); + + expect(res.status).toBe(400); + }); + + it('rejects invalid targetStage string', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/promote') + .send({ targetStage: 'invalid' }); + + expect(res.status).toBe(400); + }); + + it('creates new config entry when agent has no existing config', async () => { + const { app, store } = setup({ + isAdmin: true, + userRef: 'user:default/admin', + initialConfig: { + agents: { newbot: { name: 'New Bot', instructions: 'Hello' } }, + }, + }); + + const res = await request(app) + .put('/agents/newbot/promote') + .send({ targetStage: 'review' }); + + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('review'); + + const configs = store.chatAgents as Array<{ + agentId: string; + createdBy?: string; + createdAt?: string; + }>; + const entry = configs.find(c => c.agentId === 'newbot'); + expect(entry).toBeDefined(); + expect(entry?.createdBy).toBe('user:default/admin'); + expect(entry?.createdAt).toBeDefined(); + }); + + it('clears rejection fields when re-promoting from draft', async () => { + const { app, store } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { + rejected: { name: 'Rejected Bot', instructions: 'Retry' }, + }, + chatAgents: [ + { + agentId: 'rejected', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + rejectionReason: 'Needs more work', + rejectedBy: 'user:default/admin', + rejectedAt: '2025-01-01T00:00:00.000Z', + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/rejected/promote') + .send({ targetStage: 'review' }); + + expect(res.status).toBe(200); + const configs = store.chatAgents as Array<{ + agentId: string; + rejectionReason?: string; + rejectedBy?: string; + rejectedAt?: string; + }>; + const entry = configs.find(c => c.agentId === 'rejected'); + expect(entry?.rejectionReason).toBeUndefined(); + expect(entry?.rejectedBy).toBeUndefined(); + expect(entry?.rejectedAt).toBeUndefined(); + }); + }); + + // ========================================================================= + // PUT /agents/:agentId/demote + // ========================================================================= + + describe('PUT /agents/:agentId/demote', () => { + it('admin demotes review → draft', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'review', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/demote') + .send({ targetStage: 'draft' }); + + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('draft'); + }); + + it('stores rejection reason when demoting review → draft', async () => { + const { app, store } = setup({ + isAdmin: true, + userRef: 'user:default/admin', + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'review', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/demote') + .send({ targetStage: 'draft', reason: 'Needs better prompt' }); + + expect(res.status).toBe(200); + const configs = store.chatAgents as Array<{ + agentId: string; + rejectionReason?: string; + rejectedBy?: string; + rejectedAt?: string; + }>; + const entry = configs.find(c => c.agentId === 'mybot'); + expect(entry?.rejectionReason).toBe('Needs better prompt'); + expect(entry?.rejectedBy).toBe('user:default/admin'); + expect(entry?.rejectedAt).toBeDefined(); + }); + + it('rejects non-admin access to demote', async () => { + const { app } = setup({ + isAdmin: false, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'review', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/demote') + .send({ targetStage: 'draft' }); + + expect(res.status).toBe(403); + }); + + it('rejects invalid transition draft → draft', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/demote') + .send({ targetStage: 'draft' }); + + expect(res.status).toBe(400); + }); + }); + + // ========================================================================= + // PUT /agents/:agentId/publish -- admin shortcut + // ========================================================================= + + describe('PUT /agents/:agentId/publish', () => { + it('publishes an agent to production', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'staging', + published: false, + visible: false, + featured: false, + version: 2, + }, + ], + }, + }); + + 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); + + const configs = store.chatAgents as Array<{ + agentId: string; + lifecycleStage: string; + published: boolean; + version: number; + }>; + const entry = configs.find(c => c.agentId === 'mybot'); + expect(entry?.lifecycleStage).toBe('production'); + expect(entry?.published).toBe(true); + expect(entry?.version).toBe(3); + }); + + it('reports lifecycle bypass when publishing from non-staging stage', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + 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); + }); + + it('publishes agent that is already in staging', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'staging', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + 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); + }); + + it('rejects non-admin access', async () => { + const { app } = setup({ isAdmin: false }); + const res = await request(app).put('/agents/mybot/publish'); + expect(res.status).toBe(403); + }); + + it('creates new config entry for unknown agent', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + agents: { newbot: { name: 'New Bot', instructions: 'Hello' } }, + }, + }); + + const res = await request(app).put('/agents/newbot/publish'); + + expect(res.status).toBe(200); + const configs = store.chatAgents as Array<{ + agentId: string; + lifecycleStage: string; + version: number; + }>; + const entry = configs.find(c => c.agentId === 'newbot'); + expect(entry?.lifecycleStage).toBe('production'); + expect(entry?.version).toBe(1); + }); + }); + + // ========================================================================= + // PUT /agents/:agentId/unpublish + // ========================================================================= + + describe('PUT /agents/:agentId/unpublish', () => { + it('moves agent from production to staging', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'production', + published: true, + visible: true, + featured: false, + }, + ], + }, + }); + + const res = await request(app).put('/agents/mybot/unpublish'); + + expect(res.status).toBe(200); + expect(res.body.published).toBe(false); + + const configs = store.chatAgents as Array<{ + agentId: string; + lifecycleStage: string; + published: boolean; + visible: boolean; + }>; + const entry = configs.find(c => c.agentId === 'mybot'); + expect(entry?.lifecycleStage).toBe('staging'); + expect(entry?.published).toBe(false); + expect(entry?.visible).toBe(false); + }); + + it('rejects non-admin access', async () => { + const { app } = setup({ isAdmin: false }); + const res = await request(app).put('/agents/mybot/unpublish'); + expect(res.status).toBe(403); + }); + }); + + // ========================================================================= + // PUT /agents/bulk-publish + // ========================================================================= + + describe('PUT /agents/bulk-publish', () => { + it('publishes multiple agents at once', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + agents: { + bot1: { name: 'Bot 1', instructions: 'x' }, + bot2: { name: 'Bot 2', instructions: 'y' }, + }, + chatAgents: [ + { + agentId: 'bot1', + lifecycleStage: 'staging', + published: false, + visible: false, + featured: false, + }, + { + agentId: 'bot2', + lifecycleStage: 'staging', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/bulk-publish') + .send({ agentIds: ['bot1', 'bot2'], published: true }); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(2); + expect(res.body.published).toBe(true); + + const configs = store.chatAgents as Array<{ + agentId: string; + published: boolean; + }>; + for (const c of configs) { + expect(c.published).toBe(true); + } + }); + + it('reports bypassed agents when lifecycle is skipped', async () => { + const { app } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/bulk-publish') + .send({ agentIds: ['mybot'], published: true }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.count).toBe(1); + }); + + it('rejects invalid payload', async () => { + const { app } = setup({ isAdmin: true }); + + const res = await request(app) + .put('/agents/bulk-publish') + .send({ agentIds: 'bad', published: true }); + + expect(res.status).toBe(400); + }); + + it('rejects non-admin access', async () => { + const { app } = setup({ isAdmin: false }); + const res = await request(app) + .put('/agents/bulk-publish') + .send({ agentIds: ['bot1'], published: true }); + expect(res.status).toBe(403); + }); + }); + + // ========================================================================= + // DELETE /agents/:agentId -- cascading delete + // ========================================================================= + + describe('DELETE /agents/:agentId', () => { + it('admin deletes an agent and removes chatAgents config', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app).delete('/agents/mybot'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const configs = store.chatAgents as Array<{ agentId: string }>; + expect(configs.find(c => c.agentId === 'mybot')).toBeUndefined(); + }); + + it('non-admin can delete their own draft agent', async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + }, + ], + }, + }); + + const res = await request(app).delete('/agents/mybot'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("non-admin cannot delete another user's draft agent", async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { bobbot: { name: 'Bob Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'bobbot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/bob', + }, + ], + }, + }); + + const res = await request(app).delete('/agents/bobbot'); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('only delete agents you created'); + }); + + it('non-admin cannot delete non-draft agents', async () => { + const { app } = setup({ + isAdmin: false, + userRef: 'user:default/alice', + initialConfig: { + agents: { mybot: { name: 'My Bot', instructions: 'Help' } }, + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'review', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/alice', + }, + ], + }, + }); + + const res = await request(app).delete('/agents/mybot'); + + expect(res.status).toBe(403); + expect(res.body.error).toContain('only delete agents in draft stage'); + }); + + it('returns 404 for non-existent agent', async () => { + const { app } = setup({ isAdmin: true }); + + const res = await request(app).delete('/agents/nonexistent'); + + expect(res.status).toBe(404); + expect(res.body.error).toContain('not found'); + }); + + it('notes kagenti cleanup requirement for kagenti-sourced agents', async () => { + const { app } = setup({ + isAdmin: true, + providerAgents: [ + { + id: 'team1/k8sbot', + name: 'K8s Bot', + status: 'Running', + source: 'kagenti', + }, + ], + initialConfig: { + chatAgents: [ + { + agentId: 'team1/k8sbot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app).delete( + `/agents/${encodeURIComponent('team1/k8sbot')}`, + ); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + }); + + // ========================================================================= + // PUT /agents/:agentId/config -- display config updates + // ========================================================================= + + describe('PUT /agents/:agentId/config', () => { + it('updates existing agent config', async () => { + const { app, store } = setup({ + isAdmin: true, + initialConfig: { + chatAgents: [ + { + agentId: 'mybot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + }, + ], + }, + }); + + const res = await request(app) + .put('/agents/mybot/config') + .send({ featured: true, visible: true }); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + + const configs = store.chatAgents as Array<{ + agentId: string; + featured: boolean; + visible: boolean; + }>; + const entry = configs.find(c => c.agentId === 'mybot'); + expect(entry?.featured).toBe(true); + expect(entry?.visible).toBe(true); + }); + + it('creates new config entry for unknown agent', async () => { + const { app, store } = setup({ isAdmin: true }); + + const res = await request(app) + .put('/agents/newbot/config') + .send({ featured: true }); + + expect(res.status).toBe(200); + const configs = store.chatAgents as Array<{ + agentId: string; + featured: boolean; + }>; + expect(configs.find(c => c.agentId === 'newbot')).toBeDefined(); + }); + + it('rejects non-admin access', async () => { + const { app } = setup({ isAdmin: false }); + const res = await request(app) + .put('/agents/mybot/config') + .send({ featured: true }); + expect(res.status).toBe(403); + }); + }); + + // ========================================================================= + // End-to-end lifecycle round-trip + // ========================================================================= + + describe('end-to-end lifecycle', () => { + it('draft → review → staging → production → staging (unpublish)', async () => { + const { app, store } = setup({ + isAdmin: true, + userRef: 'user:default/admin', + initialConfig: { + agents: { e2ebot: { name: 'E2E Bot', instructions: 'Test' } }, + chatAgents: [ + { + agentId: 'e2ebot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/admin', + }, + ], + }, + }); + + // draft → review + let res = await request(app) + .put('/agents/e2ebot/promote') + .send({ targetStage: 'review' }); + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('review'); + + // review → staging + res = await request(app) + .put('/agents/e2ebot/promote') + .send({ targetStage: 'staging' }); + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('staging'); + + // staging → production + res = await request(app) + .put('/agents/e2ebot/promote') + .send({ targetStage: 'production' }); + expect(res.status).toBe(200); + expect(res.body.lifecycleStage).toBe('production'); + + // verify it's published in listing + res = await request(app).get('/agents?published=true'); + expect(res.body.agents.map((a: { id: string }) => a.id)).toContain( + 'e2ebot', + ); + + // unpublish → staging + res = await request(app).put('/agents/e2ebot/unpublish'); + expect(res.status).toBe(200); + expect(res.body.published).toBe(false); + + const configs = store.chatAgents as Array<{ + agentId: string; + lifecycleStage: string; + }>; + const entry = configs.find(c => c.agentId === 'e2ebot'); + expect(entry?.lifecycleStage).toBe('staging'); + }); + + it('reject round-trip: draft → review → reject (with reason) → re-submit', async () => { + const { app, store } = setup({ + isAdmin: true, + userRef: 'user:default/admin', + initialConfig: { + agents: { + rejectbot: { name: 'Reject Bot', instructions: 'Test' }, + }, + chatAgents: [ + { + agentId: 'rejectbot', + lifecycleStage: 'draft', + published: false, + visible: false, + featured: false, + createdBy: 'user:default/admin', + }, + ], + }, + }); + + // draft → review + let res = await request(app) + .put('/agents/rejectbot/promote') + .send({ targetStage: 'review' }); + expect(res.status).toBe(200); + + // review → draft (rejection) + res = await request(app) + .put('/agents/rejectbot/demote') + .send({ targetStage: 'draft', reason: 'Insufficient instructions' }); + expect(res.status).toBe(200); + + let configs = store.chatAgents as Array<{ + agentId: string; + rejectionReason?: string; + rejectedBy?: string; + }>; + let entry = configs.find(c => c.agentId === 'rejectbot'); + expect(entry?.rejectionReason).toBe('Insufficient instructions'); + + // re-promote draft → review (should clear rejection fields) + res = await request(app) + .put('/agents/rejectbot/promote') + .send({ targetStage: 'review' }); + expect(res.status).toBe(200); + + configs = store.chatAgents as Array<{ + agentId: string; + rejectionReason?: string; + rejectedBy?: string; + }>; + entry = configs.find(c => c.agentId === 'rejectbot'); + expect(entry?.rejectionReason).toBeUndefined(); + expect(entry?.rejectedBy).toBeUndefined(); + }); + }); +}); diff --git a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts index 0470b1956f..868a60b118 100644 --- a/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts +++ b/workspaces/augment/plugins/augment-backend/src/routes/agentRoutes.ts @@ -178,6 +178,9 @@ export function registerAgentRoutes( promotedAt: cfg?.promotedAt, promotedBy: cfg?.promotedBy, createdBy: cfg?.createdBy, + rejectionReason: cfg?.rejectionReason, + rejectedBy: cfg?.rejectedBy, + rejectedAt: cfg?.rejectedAt, }; } @@ -230,23 +233,24 @@ export function registerAgentRoutes( // --------------------------------------------------------------------------- // GET /agents -- unified agent listing + // Non-admins see: published agents + their own agents + legacy unowned agents. + // Admins see everything. // --------------------------------------------------------------------------- router.get( '/agents', withRoute('GET /agents', 'Failed to list agents', async (req, res) => { const { agents, sources } = await buildUnifiedAgentList(); const publishedFilter = req.query.published; + const isAdmin = await ctx.checkIsAdmin(req); - let filtered = - publishedFilter === 'true' - ? agents.filter(a => a.published === true) - : agents; + let filtered = agents; - const isAdmin = await ctx.checkIsAdmin(req); - if (!isAdmin) { + if (publishedFilter === 'true') { + filtered = filtered.filter(a => a.published === true); + } else if (!isAdmin) { const userRef = await ctx.getUserRef(req); filtered = filtered.filter( - a => a.published === true || a.createdBy === userRef, + a => a.published === true || a.createdBy === userRef || !a.createdBy, ); } @@ -259,6 +263,8 @@ export function registerAgentRoutes( // Enforces valid transitions defined in LIFECYCLE_TRANSITIONS. // draft → review is open to any authenticated user (submit for approval). // All other transitions require admin access. + // Non-admins cannot promote phantom drafts (agents with no config entry). + // Non-admins can only promote agents they own (createdBy === userRef). // --------------------------------------------------------------------------- router.put( '/agents/:agentId/promote', @@ -277,6 +283,7 @@ export function registerAgentRoutes( ); } const userRef = await ctx.getUserRef(req); + const isAdmin = await ctx.checkIsAdmin(req); const configs = await loadChatAgentConfigs(); const existing = configs.find(c => c.agentId === agentId); @@ -298,16 +305,29 @@ export function registerAgentRoutes( const isSubmitForReview = currentStage === 'draft' && nextStage === 'review'; - if (!isSubmitForReview) { - const isAdmin = await ctx.checkIsAdmin(req); - if (!isAdmin) { - res.status(403).json({ - error: - 'Only admins can perform this lifecycle transition. ' + - 'Non-admin users may only submit draft agents for review.', - }); - return; - } + if (!isSubmitForReview && !isAdmin) { + res.status(403).json({ + error: + 'Only admins can perform this lifecycle transition. ' + + 'Non-admin users may only submit draft agents for review.', + }); + return; + } + + if (!isAdmin && !existing) { + res.status(404).json({ + error: + 'Agent not found in lifecycle config. ' + + 'Only agents you have created can be submitted for review.', + }); + return; + } + + if (!isAdmin && existing?.createdBy && existing.createdBy !== userRef) { + res.status(403).json({ + error: 'You can only promote agents you created.', + }); + return; } const now = new Date().toISOString(); @@ -320,6 +340,11 @@ export function registerAgentRoutes( existing.version = (existing.version ?? 0) + 1; existing.promotedAt = now; existing.promotedBy = userRef; + if (existing.rejectionReason) { + existing.rejectionReason = undefined; + existing.rejectedBy = undefined; + existing.rejectedAt = undefined; + } } else { configs.push({ agentId, @@ -330,6 +355,8 @@ export function registerAgentRoutes( version: 1, promotedAt: now, promotedBy: userRef, + createdBy: userRef, + createdAt: now, }); } @@ -363,6 +390,7 @@ export function registerAgentRoutes( // --------------------------------------------------------------------------- // PUT /agents/:agentId/demote -- transition agent backward // Enforces valid transitions defined in LIFECYCLE_TRANSITIONS. + // Accepts optional `reason` for review → draft (rejection). // --------------------------------------------------------------------------- router.put( '/agents/:agentId/demote', @@ -372,7 +400,10 @@ export function registerAgentRoutes( 'Failed to demote agent', async (req, res) => { const agentId = decodeURIComponent(req.params.agentId); - const { targetStage } = req.body as { targetStage?: string }; + const { targetStage, reason } = req.body as { + targetStage?: string; + reason?: string; + }; const resolved = targetStage ? normalizeLifecycleStage(targetStage) : undefined; @@ -402,6 +433,7 @@ export function registerAgentRoutes( const now = new Date().toISOString(); const isProd = isProductionStage(nextStage); + const isRejection = currentStage === 'review' && nextStage === 'draft'; if (existing) { existing.lifecycleStage = nextStage; @@ -412,6 +444,11 @@ export function registerAgentRoutes( } existing.promotedAt = now; existing.promotedBy = userRef; + if (isRejection) { + existing.rejectionReason = reason || undefined; + existing.rejectedBy = userRef; + existing.rejectedAt = now; + } } else { configs.push({ agentId, @@ -421,6 +458,13 @@ export function registerAgentRoutes( featured: false, promotedAt: now, promotedBy: userRef, + ...(isRejection + ? { + rejectionReason: reason || undefined, + rejectedBy: userRef, + rejectedAt: now, + } + : {}), }); } @@ -431,7 +475,12 @@ export function registerAgentRoutes( target: agentId, outcome: 'success', sourceIp: AuditLogger.extractIp(req), - meta: { from: currentStage, to: nextStage, direction: 'demote' }, + meta: { + from: currentStage, + to: nextStage, + direction: 'demote', + ...(isRejection && reason ? { rejectionReason: reason } : {}), + }, }); logger.info(`Agent "${agentId}" demoted to ${nextStage} by ${userRef}`); res.json({ success: true, agentId, lifecycleStage: nextStage }); @@ -607,51 +656,6 @@ export function registerAgentRoutes( ), ); - // --------------------------------------------------------------------------- - // DELETE /agents/:agentId -- remove agent from chatAgents config - // Only draft agents can be deleted; other stages must be retired first. - // --------------------------------------------------------------------------- - router.delete( - '/agents/:agentId', - withRoute( - 'DELETE /agents/:agentId', - 'Failed to delete agent', - async (req, res) => { - const agentId = decodeURIComponent(req.params.agentId); - const userRef = await ctx.getUserRef(req); - const configs = await loadChatAgentConfigs(); - const existing = configs.find(c => c.agentId === agentId); - - const stage = normalizeLifecycleStage(existing?.lifecycleStage); - if (stage !== 'draft') { - const isAdmin = await ctx.checkIsAdmin(req); - if (!isAdmin) { - res.status(403).json({ - error: - 'Only admins can delete non-draft agents. ' + - 'Non-admin users may only delete agents in "draft" stage.', - }); - return; - } - } - - const updated = configs.filter(c => c.agentId !== agentId); - await saveChatAgentConfigs(updated, userRef); - - audit.log({ - action: 'agent.lifecycle', - actor: userRef, - target: agentId, - outcome: 'success', - sourceIp: AuditLogger.extractIp(req), - meta: { from: stage, direction: 'delete' }, - }); - logger.info(`Agent "${agentId}" config deleted by ${userRef}`); - res.json({ success: true, agentId, deleted: true }); - }, - ), - ); - // --------------------------------------------------------------------------- // PUT /agents/:agentId/config -- update agent display config // --------------------------------------------------------------------------- @@ -685,4 +689,63 @@ export function registerAgentRoutes( }, ), ); + + // --------------------------------------------------------------------------- + // DELETE /agents/:agentId -- remove agent lifecycle config entry + // Non-admins can only delete their own draft agents. + // Admins can delete any agent's config. + // --------------------------------------------------------------------------- + router.delete( + '/agents/:agentId', + withRoute( + 'DELETE /agents/:agentId', + 'Failed to delete agent config', + async (req, res) => { + const agentId = decodeURIComponent(req.params.agentId); + const userRef = await ctx.getUserRef(req); + const isAdmin = await ctx.checkIsAdmin(req); + const configs = await loadChatAgentConfigs(); + const idx = configs.findIndex(c => c.agentId === agentId); + + if (idx === -1) { + res.status(404).json({ error: 'Agent config not found' }); + return; + } + + const existing = configs[idx]; + const stage = normalizeLifecycleStage(existing.lifecycleStage); + + if (!isAdmin) { + 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) { + res.status(403).json({ + error: 'You can only delete agents you created.', + }); + return; + } + } + + configs.splice(idx, 1); + await saveChatAgentConfigs(configs, userRef); + + audit.log({ + action: 'agent.delete', + actor: userRef, + target: agentId, + outcome: 'success', + sourceIp: AuditLogger.extractIp(req), + meta: { lifecycleStage: stage, isAdmin }, + }); + logger.info( + `Agent config "${agentId}" deleted (stage: ${stage}) by ${userRef}`, + ); + res.json({ success: true, agentId }); + }, + ), + ); } diff --git a/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts b/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts index 84789f9753..9fa8612967 100644 --- a/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts +++ b/workspaces/augment/plugins/augment-backend/src/services/AuditLogger.ts @@ -25,6 +25,7 @@ export type AuditAction = | 'tool.approval' | 'tool.lifecycle' | 'agent.lifecycle' + | 'agent.delete' | 'document.upload' | 'document.delete' | 'admin.login'; diff --git a/workspaces/augment/plugins/augment-common/report.api.md b/workspaces/augment/plugins/augment-common/report.api.md index 7e4417c8c5..42a080c8eb 100644 --- a/workspaces/augment/plugins/augment-common/report.api.md +++ b/workspaces/augment/plugins/augment-common/report.api.md @@ -270,6 +270,9 @@ export interface ChatAgent { protocols?: string[]; providerType: string; published?: boolean; + rejectedAt?: string; + rejectedBy?: string; + rejectionReason?: string; source?: string; starters?: string[]; status: string; @@ -282,6 +285,7 @@ export interface ChatAgentConfig { agentId: string; avatarUrl?: string; conversationStarters?: string[]; + createdAt?: string; createdBy?: string; description?: string; displayName?: string; @@ -292,6 +296,9 @@ export interface ChatAgentConfig { promotedAt?: string; promotedBy?: string; published: boolean; + rejectedAt?: string; + rejectedBy?: string; + rejectionReason?: string; version?: number; visible: boolean; } diff --git a/workspaces/augment/plugins/augment-common/src/types/shared.ts b/workspaces/augment/plugins/augment-common/src/types/shared.ts index 2d841dd2f4..8ac6ed1eae 100644 --- a/workspaces/augment/plugins/augment-common/src/types/shared.ts +++ b/workspaces/augment/plugins/augment-common/src/types/shared.ts @@ -390,6 +390,16 @@ export interface ChatAgentConfig { promotedAt?: string; /** User ref of who last promoted this agent */ promotedBy?: string; + /** User ref of who originally created/registered this agent in the lifecycle */ + createdBy?: string; + /** ISO timestamp of when the agent was first registered in the lifecycle */ + createdAt?: string; + /** Reason the agent was rejected (set on review → draft demotion, cleared on re-promote) */ + rejectionReason?: string; + /** User ref of who rejected the agent */ + rejectedBy?: string; + /** ISO timestamp of when the agent was rejected */ + rejectedAt?: string; /** Display order (lower first) */ order?: number; /** Override display name */ @@ -404,8 +414,6 @@ export interface ChatAgentConfig { greeting?: string; /** Suggested prompts shown on the agent card and below the input */ conversationStarters?: string[]; - /** User ref of who originally created this agent config entry */ - createdBy?: string; } /** @@ -614,10 +622,16 @@ export interface ChatAgent { promotedAt?: string; /** Who promoted this agent */ promotedBy?: string; + /** User ref of who originally created/registered this agent in the lifecycle */ + createdBy?: string; + /** Reason the agent was rejected (set on review → draft demotion, cleared on re-promote) */ + rejectionReason?: string; + /** User ref of who rejected the agent */ + rejectedBy?: string; + /** ISO timestamp of when the agent was rejected */ + rejectedAt?: string; /** Role of this agent in the orchestration topology */ agentRole?: AgentRole; - /** User ref of who originally created this agent */ - createdBy?: string; } /** diff --git a/workspaces/augment/plugins/augment/report.api.md b/workspaces/augment/plugins/augment/report.api.md index e719803981..486fdf6865 100644 --- a/workspaces/augment/plugins/augment/report.api.md +++ b/workspaces/augment/plugins/augment/report.api.md @@ -182,6 +182,7 @@ export interface AugmentApi { demoteAgent( agentId: string, targetStage?: AgentLifecycleStage, + reason?: string, ): Promise<{ lifecycleStage: string; }>; diff --git a/workspaces/augment/plugins/augment/src/api/AugmentApi.ts b/workspaces/augment/plugins/augment/src/api/AugmentApi.ts index 282db95471..d477954e77 100644 --- a/workspaces/augment/plugins/augment/src/api/AugmentApi.ts +++ b/workspaces/augment/plugins/augment/src/api/AugmentApi.ts @@ -93,15 +93,17 @@ export interface AugmentApi { /** * Demote an agent to a previous lifecycle stage (deployed → registered → draft). + * @param reason - Optional rejection reason (used for review → draft transitions) */ demoteAgent( agentId: string, targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, + reason?: string, ): Promise<{ lifecycleStage: string }>; /** - * Delete an agent's lifecycle config entry. For Kagenti agents, also call - * deleteKagentiAgent to remove the K8s deployment. + * Delete an agent's lifecycle config entry. + * Non-admins can only delete their own draft agents. */ deleteAgentConfig(agentId: string): Promise; @@ -787,11 +789,12 @@ export class AugmentApiClient implements AugmentApi { async demoteAgent( agentId: string, targetStage?: import('@red-hat-developer-hub/backstage-plugin-augment-common').AgentLifecycleStage, + reason?: string, ): Promise<{ lifecycleStage: string }> { return this.fetchJson(`/agents/${encodeURIComponent(agentId)}/demote`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ targetStage }), + body: JSON.stringify({ targetStage, reason }), }); }