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:
2026-05-05 10:55:54 +00:00
parent 5d43339616
commit 305eeae38b
13 changed files with 511 additions and 33 deletions

View 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);
});
});