feat(agents): manifest plumbing + GET /agents/registry (ADR-0014 step 3)
Each agent now exports a module-level MANIFEST declaring id, version, pref_schema, required_consents, ttl_sec, and silenced_in_contexts. The registry surfaces both the agent and its manifest, and rejects on mismatch so the two cannot drift. ml/serving exposes GET /agents/registry; services/api proxies it as GET /api/agents/registry with a 60s in-process cache so admin pageviews don't hammer upstream. Failures aren't cached. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
108
services/api/src/routes/__tests__/agent-registry.test.ts
Normal file
108
services/api/src/routes/__tests__/agent-registry.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* GET /api/agents/registry — proxies ml/serving's manifest list with a short
|
||||
* in-process cache. Tests stub global fetch and verify caching + 502 fallback.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterEach, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import * as http from 'http';
|
||||
|
||||
vi.mock('../../middleware/session.js', () => ({
|
||||
sessionMiddleware: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
|
||||
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
|
||||
(req as any).userId = 'user-1';
|
||||
next();
|
||||
},
|
||||
}));
|
||||
|
||||
const REGISTRY_PAYLOAD = {
|
||||
agents: [
|
||||
{ id: 'overdue-task', version: '1.0.0', pref_schema: { type: 'object' } },
|
||||
{ id: 'momentum', version: '1.0.0', pref_schema: { type: 'object' } },
|
||||
],
|
||||
};
|
||||
|
||||
function get(url: string): Promise<{ status: number; body: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
http.get({ hostname: u.hostname, port: Number(u.port), path: u.pathname }, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (c) => { data += c; });
|
||||
res.on('end', () => {
|
||||
try { resolve({ status: res.statusCode ?? 0, body: data ? JSON.parse(data) : null }); }
|
||||
catch { resolve({ status: res.statusCode ?? 0, body: data }); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
describe('GET /api/agents/registry', () => {
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
let savedFetch: typeof globalThis.fetch;
|
||||
let resetCache: () => void;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../agent-registry.js');
|
||||
const router = mod.default;
|
||||
resetCache = mod._resetRegistryCache;
|
||||
const app = express();
|
||||
app.use('/api/agents', router);
|
||||
server = await new Promise<http.Server>((resolve) => {
|
||||
const s = app.listen(0, () => resolve(s));
|
||||
});
|
||||
const addr = server.address() as { port: number };
|
||||
baseUrl = `http://localhost:${addr.port}`;
|
||||
savedFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetCache();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = savedFetch;
|
||||
});
|
||||
|
||||
it('proxies ml/serving manifests', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const r = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.body).toEqual(REGISTRY_PAYLOAD);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('caches across calls within the TTL', async () => {
|
||||
const fetchMock = vi.fn(async () =>
|
||||
new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }),
|
||||
);
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
await get(`${baseUrl}/api/agents/registry`);
|
||||
await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 502 when ml/serving fails', async () => {
|
||||
globalThis.fetch = vi.fn(async () => new Response('boom', { status: 500 })) as unknown as typeof fetch;
|
||||
const r = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(r.status).toBe(502);
|
||||
expect(r.body.error).toBe('ml/serving unavailable');
|
||||
});
|
||||
|
||||
it('does not cache failures', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response('boom', { status: 500 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify(REGISTRY_PAYLOAD), { status: 200 }));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
const first = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(first.status).toBe(502);
|
||||
const second = await get(`${baseUrl}/api/agents/registry`);
|
||||
expect(second.status).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user