feat(agents): deepen agent registry — version lifecycle, lookup by key, delete
- 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
This commit is contained in:
parent
0195cde1c0
commit
ae87371b3a
@ -65,3 +65,37 @@ export async function getAgentVersion(id: string, agentId: string): Promise<Agen
|
|||||||
if (!version) throw new NotFoundError(`Agent version '${id}' not found`);
|
if (!version) throw new NotFoundError(`Agent version '${id}' not found`);
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAgentByKey(
|
||||||
|
productId: string,
|
||||||
|
key: string
|
||||||
|
): Promise<AgentDefinitionDoc | null> {
|
||||||
|
const results = await agentCollection().findMany({
|
||||||
|
filter: { productId, key },
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return results[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAgentVersion(
|
||||||
|
id: string,
|
||||||
|
agentId: string,
|
||||||
|
updates: Partial<AgentVersionDoc>
|
||||||
|
): Promise<AgentVersionDoc> {
|
||||||
|
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<AgentVersionDoc | null> {
|
||||||
|
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<void> {
|
||||||
|
await agentCollection().delete(id, productId);
|
||||||
|
}
|
||||||
|
|||||||
@ -6,8 +6,13 @@ const repoMock = {
|
|||||||
createAgent: vi.fn(),
|
createAgent: vi.fn(),
|
||||||
getAgent: vi.fn(),
|
getAgent: vi.fn(),
|
||||||
updateAgent: vi.fn(),
|
updateAgent: vi.fn(),
|
||||||
|
deleteAgent: vi.fn(),
|
||||||
createAgentVersion: vi.fn(),
|
createAgentVersion: vi.fn(),
|
||||||
listAgentVersions: vi.fn(),
|
listAgentVersions: vi.fn(),
|
||||||
|
getAgentVersion: vi.fn(),
|
||||||
|
getAgentByKey: vi.fn(),
|
||||||
|
updateAgentVersion: vi.fn(),
|
||||||
|
getPublishedVersion: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('./repository.js', () => repoMock);
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -142,4 +142,74 @@ export async function agentRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
return createdVersion;
|
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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user