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>
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|