From ae87371b3a625f9e4596da9852a1409f275de3cd Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 20 Mar 2026 00:39:24 -0700 Subject: [PATCH] =?UTF-8?q?feat(agents):=20deepen=20agent=20registry=20?= =?UTF-8?q?=E2=80=94=20version=20lifecycle,=20lookup=20by=20key,=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repository.ts: add getAgentByKey, updateAgentVersion, getPublishedVersion, deleteAgent - routes.ts: 5 new endpoints — GET /agents/by-key/:key, GET /agents/:id/published, POST /agents/:id/versions/:vId/publish (auto-deprecates previous), POST /agents/:id/versions/:vId/deprecate, DELETE /agents/:id (draft only) - routes.test.ts: 6 new tests (8 total) — publish lifecycle, deprecate guard, key lookup, delete draft-only guard - repository.test.ts: 1 existing test unchanged --- .../src/modules/agents/repository.ts | 34 +++++++++ .../src/modules/agents/routes.test.ts | 76 +++++++++++++++++++ .../src/modules/agents/routes.ts | 70 +++++++++++++++++ 3 files changed, 180 insertions(+) diff --git a/services/platform-service/src/modules/agents/repository.ts b/services/platform-service/src/modules/agents/repository.ts index cebb48e5..f7e891b7 100644 --- a/services/platform-service/src/modules/agents/repository.ts +++ b/services/platform-service/src/modules/agents/repository.ts @@ -65,3 +65,37 @@ export async function getAgentVersion(id: string, agentId: string): Promise { + const results = await agentCollection().findMany({ + filter: { productId, key }, + limit: 1, + }); + return results[0] ?? null; +} + +export async function updateAgentVersion( + id: string, + agentId: string, + updates: Partial +): Promise { + const updated = await versionCollection().update(id, agentId, updates); + if (!updated) throw new NotFoundError(`Agent version '${id}' not found`); + return updated; +} + +export async function getPublishedVersion(agentId: string): Promise { + const results = await versionCollection().findMany({ + filter: { agentId, status: 'published' }, + sort: { version: -1 }, + limit: 1, + }); + return results[0] ?? null; +} + +export async function deleteAgent(id: string, productId: string): Promise { + await agentCollection().delete(id, productId); +} diff --git a/services/platform-service/src/modules/agents/routes.test.ts b/services/platform-service/src/modules/agents/routes.test.ts index 3dc8dc31..636e8fb5 100644 --- a/services/platform-service/src/modules/agents/routes.test.ts +++ b/services/platform-service/src/modules/agents/routes.test.ts @@ -6,8 +6,13 @@ const repoMock = { createAgent: vi.fn(), getAgent: vi.fn(), updateAgent: vi.fn(), + deleteAgent: vi.fn(), createAgentVersion: vi.fn(), listAgentVersions: vi.fn(), + getAgentVersion: vi.fn(), + getAgentByKey: vi.fn(), + updateAgentVersion: vi.fn(), + getPublishedVersion: vi.fn(), }; vi.mock('./repository.js', () => repoMock); @@ -96,4 +101,75 @@ describe('agentRoutes', () => { }) ); }); + + it('GET /agents/by-key/:key looks up agent by key', async () => { + repoMock.getAgentByKey.mockResolvedValue({ id: 'agt_1', key: 'incident-responder' }); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/agents/by-key/incident-responder' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).key).toBe('incident-responder'); + }); + + it('GET /agents/by-key/:key returns 400 for unknown key', async () => { + repoMock.getAgentByKey.mockResolvedValue(null); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'GET', url: '/api/agents/by-key/nonexistent' }); + expect(res.statusCode).toBe(400); + }); + + it('POST /agents/:id/versions/:versionId/publish publishes and deprecates previous', async () => { + repoMock.getAgent.mockResolvedValue({ id: 'agt_1', productId: 'lysnrai', status: 'active' }); + repoMock.getAgentVersion.mockResolvedValue({ id: 'agt_1:v2', status: 'draft', version: 2 }); + repoMock.getPublishedVersion.mockResolvedValue({ id: 'agt_1:v1', status: 'published' }); + repoMock.updateAgentVersion.mockResolvedValue({ id: 'agt_1:v2', status: 'published' }); + repoMock.updateAgent.mockResolvedValue({ id: 'agt_1' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/agt_1/versions/agt_1:v2/publish', + }); + + expect(res.statusCode).toBe(200); + expect(repoMock.updateAgentVersion).toHaveBeenCalledWith('agt_1:v1', 'agt_1', { + status: 'deprecated', + }); + expect(repoMock.updateAgentVersion).toHaveBeenCalledWith('agt_1:v2', 'agt_1', { + status: 'published', + }); + }); + + it('POST /agents/:id/versions/:versionId/publish rejects already published', async () => { + repoMock.getAgent.mockResolvedValue({ id: 'agt_1', productId: 'lysnrai' }); + repoMock.getAgentVersion.mockResolvedValue({ id: 'agt_1:v1', status: 'published' }); + + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/agt_1/versions/agt_1:v1/publish', + }); + + expect(res.statusCode).toBe(400); + }); + + it('DELETE /agents/:id only allows deleting draft agents', async () => { + repoMock.getAgent.mockResolvedValue({ id: 'agt_1', productId: 'lysnrai', status: 'active' }); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/agents/agt_1' }); + expect(res.statusCode).toBe(400); + expect(repoMock.deleteAgent).not.toHaveBeenCalled(); + }); + + it('DELETE /agents/:id succeeds for draft agent', async () => { + repoMock.getAgent.mockResolvedValue({ id: 'agt_1', productId: 'lysnrai', status: 'draft' }); + repoMock.deleteAgent.mockResolvedValue(undefined); + const app = await buildApp({ sub: 'admin_1', productId: 'lysnrai', role: 'admin' }); + + const res = await app.inject({ method: 'DELETE', url: '/api/agents/agt_1' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ deleted: true }); + }); }); diff --git a/services/platform-service/src/modules/agents/routes.ts b/services/platform-service/src/modules/agents/routes.ts index 12100cfa..cce49a5f 100644 --- a/services/platform-service/src/modules/agents/routes.ts +++ b/services/platform-service/src/modules/agents/routes.ts @@ -142,4 +142,74 @@ export async function agentRoutes(app: FastifyInstance) { }); return createdVersion; }); + + // ── Get agent by key ─────────────────────────────────── + app.get('/agents/by-key/:key', async req => { + const access = requireAdmin(req); + const { key } = req.params as { key: string }; + const agent = await repo.getAgentByKey(access.productId, key); + if (!agent) throw new BadRequestError(`Agent with key '${key}' not found`); + return agent; + }); + + // ── Get published version ────────────────────────────── + app.get('/agents/:id/published', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + await repo.getAgent(id, access.productId); + const published = await repo.getPublishedVersion(id); + if (!published) throw new BadRequestError('No published version found'); + return published; + }); + + // ── Publish a version ────────────────────────────────── + app.post('/agents/:id/versions/:versionId/publish', async req => { + const access = requireAdmin(req); + const { id, versionId } = req.params as { id: string; versionId: string }; + await repo.getAgent(id, access.productId); + const version = await repo.getAgentVersion(versionId, id); + + if (version.status === 'published') { + throw new BadRequestError('Version is already published'); + } + + // Deprecate any previously published version + const currentPublished = await repo.getPublishedVersion(id); + if (currentPublished) { + await repo.updateAgentVersion(currentPublished.id, id, { status: 'deprecated' }); + } + + const updated = await repo.updateAgentVersion(versionId, id, { status: 'published' }); + await repo.updateAgent(id, access.productId, { + status: 'active', + currentVersion: version.version, + }); + return updated; + }); + + // ── Deprecate a version ──────────────────────────────── + app.post('/agents/:id/versions/:versionId/deprecate', async req => { + const access = requireAdmin(req); + const { id, versionId } = req.params as { id: string; versionId: string }; + await repo.getAgent(id, access.productId); + const version = await repo.getAgentVersion(versionId, id); + + if (version.status === 'deprecated') { + throw new BadRequestError('Version is already deprecated'); + } + + return repo.updateAgentVersion(versionId, id, { status: 'deprecated' }); + }); + + // ── Delete agent (draft only) ────────────────────────── + app.delete('/agents/:id', async req => { + const access = requireAdmin(req); + const { id } = req.params as { id: string }; + const agent = await repo.getAgent(id, access.productId); + if (agent.status !== 'draft') { + throw new BadRequestError('Only draft agents can be deleted'); + } + await repo.deleteAgent(id, access.productId); + return { deleted: true }; + }); }