diff --git a/package.json b/package.json index 35b8d59..62b34e4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-eval", - "version": "0.34.1", + "version": "0.35.0", "description": "Substrate for self-improving agents: traces, verifiable rewards, preferences, GEPA / reflective mutation, auto-research, replay, sequential anytime-valid stats, and release gates.", "homepage": "https://github.com/tangle-network/agent-eval#readme", "repository": { diff --git a/src/wire/index.ts b/src/wire/index.ts index 1f4c054..3fe96ae 100644 --- a/src/wire/index.ts +++ b/src/wire/index.ts @@ -13,4 +13,4 @@ export { buildOpenApi } from './openapi' export { dispatchRpc, runRpcBatch, runRpcOnce } from './rpc' export { BUILTIN_RUBRICS, getBuiltinRubric, listBuiltinRubrics } from './rubrics' export * from './schemas' -export { createApp, type ServeOptions, startServer } from './server' +export { createApp, type ServeOptions, type StartedServer, startServer, startServerAsync } from './server' diff --git a/src/wire/server.ts b/src/wire/server.ts index 7efdcc5..d2a5e8d 100644 --- a/src/wire/server.ts +++ b/src/wire/server.ts @@ -201,3 +201,50 @@ export function startServer(opts: ServeOptions = {}): ServerType { console.log(`[agent-eval] serving on http://${address}:${actualPort}`) }) } + +export interface StartedServer { + server: ServerType + /** The OS-assigned port. When opts.port was 0, this is the actual port the + * kernel bound — callers that need to dial back (smoke tests, sidecars + * registering with a parent) read this rather than guessing a free port. */ + port: number + /** Resolved host the server bound to (defaults to 127.0.0.1). */ + host: string + /** Close the server. Resolves once active connections have drained. */ + close(): Promise +} + +/** + * Promise-returning variant of `startServer` that resolves once the server is + * listening and surfaces the resolved bound port. Use this from smoke tests + * (`startServerAsync({ port: 0 })`) and any caller that needs to dial back. + */ +export function startServerAsync(opts: ServeOptions = {}): Promise { + const app = createApp(opts) + const port = opts.port ?? 5005 + const host = opts.host ?? '127.0.0.1' + return new Promise((resolve, reject) => { + let settled = false + let server: ServerType | undefined + server = serve({ fetch: app.fetch, port, hostname: host }, ({ address, port: actualPort }) => { + if (settled) return + settled = true + // eslint-disable-next-line no-console + console.log(`[agent-eval] serving on http://${address}:${actualPort}`) + resolve({ + server: server!, + port: actualPort, + host: address, + close: () => + new Promise((res, rej) => { + server!.close((err) => (err ? rej(err) : res())) + }), + }) + }) + server.on('error', (err) => { + if (settled) return + settled = true + reject(err) + }) + }) +} diff --git a/tests/wire/server.test.ts b/tests/wire/server.test.ts index 3050fed..1dd60bf 100644 --- a/tests/wire/server.test.ts +++ b/tests/wire/server.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { createApp } from '../../src/wire/server' +import { createApp, startServerAsync } from '../../src/wire/server' const app = createApp() @@ -97,3 +97,31 @@ describe('POST /v1/judge', () => { expect(['rubric_not_found', 'validation_error']).toContain(err.error.code) }) }) + +describe('startServerAsync', () => { + it('resolves with the actual bound port when opts.port=0', async () => { + const started = await startServerAsync({ port: 0 }) + try { + expect(started.port).toBeGreaterThan(0) + expect(started.port).toBeLessThan(65536) + expect(started.host).toBe('127.0.0.1') + + const res = await fetch(`http://127.0.0.1:${started.port}/healthz`) + expect(res.status).toBe(200) + const body = (await res.json()) as { status: string } + expect(body.status).toBe('ok') + } finally { + await started.close() + } + }) + + it('two concurrent servers on port 0 receive distinct ports', async () => { + const [a, b] = await Promise.all([startServerAsync({ port: 0 }), startServerAsync({ port: 0 })]) + try { + expect(a.port).not.toBe(b.port) + } finally { + await a.close() + await b.close() + } + }) +})