From 7cd15f38b084363c4f679ed71745d801b2a746b0 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Tue, 20 Oct 2026 05:48:46 +0300 Subject: [PATCH] V19: Add a /ready route to the built server Co-Authored-By: Claude Opus 5.5 --- docs/deployment.md | 3 +- llms-full.txt | 3 +- src/deploy/nodeServer.ts | 14 +++++- src/deploy/nodeServerReady.test.ts | 68 ++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/deploy/nodeServerReady.test.ts diff --git a/docs/deployment.md b/docs/deployment.md index 716b883e3..a2fde4c78 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -96,7 +96,8 @@ a page or script written against the dev server works against the deployed one. | Endpoint | What it does | | -------- | ------------ | -| `GET /health` | `200 ok`. Never needs auth: point load balancers and container health checks here. | +| `GET /health` | `200 ok`. Liveness: the process is up. Never needs auth: point load balancers and container health checks here. | +| `GET /ready` | Readiness (`node-server` and `docker` only): `200 {"status":"ready"}` while the server takes traffic, `503 {"status":"draining"}` once a graceful shutdown (SIGTERM) has begun. Never needs auth, and the body carries no detail. Point a Kubernetes `readinessProbe` or a load balancer here, and keep `livenessProbe` and the Docker `HEALTHCHECK` on `/health`. It does not probe the session store (the store interface has no cheap check). | | `POST /chat` `{ "sessionId", "input" }` | Runs a turn of session `sessionId` (1-128 characters of `A-Za-z0-9_-`; a new id starts a conversation, a known one continues it) and streams it as SSE: one `data: ` per event ([Streaming](./streaming.md)), then `event: done`. `input` is a string or an array of content parts. | | `GET /chat/:sessionId` | The session's transcript: `{ sessionId, messages, pending }`. A turn's messages are saved when it finishes, so `messages` does not hold a turn that is paused on an approval; `pending` says it is. | | `POST /chat/:sessionId/approvals/:id` `{ "approved", "note"? }` or `{ "answer" }` | Decides a pending tool approval, or answers an `ask_question`, and streams the continued turn live as SSE, in the same framing and event types as `POST /chat` (the decided call's `tool.resume` / `tool.done` or `tool.error`, text deltas, `approval.requested` if it pauses again, `run.done`), from `agent.approvals.streamResolve()` / `streamAnswer()`. `404` when `id` is not pending; `409` (`LOUSHO_APPROVAL_CONFLICT`) while another request's decision of it is still streaming; `400` (`LOUSHO_APPROVAL_DECISION_INVALID`) for a body with neither field. When the turn's step paused on several calls, each id is decided on its own; a decision that leaves others pending streams `run.done` with `finishReason: 'awaiting-approval'`, and the last one runs the step. | diff --git a/llms-full.txt b/llms-full.txt index 75ad64194..295785008 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -7154,7 +7154,8 @@ a page or script written against the dev server works against the deployed one. | Endpoint | What it does | | -------- | ------------ | -| `GET /health` | `200 ok`. Never needs auth: point load balancers and container health checks here. | +| `GET /health` | `200 ok`. Liveness: the process is up. Never needs auth: point load balancers and container health checks here. | +| `GET /ready` | Readiness (`node-server` and `docker` only): `200 {"status":"ready"}` while the server takes traffic, `503 {"status":"draining"}` once a graceful shutdown (SIGTERM) has begun. Never needs auth, and the body carries no detail. Point a Kubernetes `readinessProbe` or a load balancer here, and keep `livenessProbe` and the Docker `HEALTHCHECK` on `/health`. It does not probe the session store (the store interface has no cheap check). | | `POST /chat` `{ "sessionId", "input" }` | Runs a turn of session `sessionId` (1-128 characters of `A-Za-z0-9_-`; a new id starts a conversation, a known one continues it) and streams it as SSE: one `data: ` per event ([Streaming](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/streaming.md)), then `event: done`. `input` is a string or an array of content parts. | | `GET /chat/:sessionId` | The session's transcript: `{ sessionId, messages, pending }`. A turn's messages are saved when it finishes, so `messages` does not hold a turn that is paused on an approval; `pending` says it is. | | `POST /chat/:sessionId/approvals/:id` `{ "approved", "note"? }` or `{ "answer" }` | Decides a pending tool approval, or answers an `ask_question`, and streams the continued turn live as SSE, in the same framing and event types as `POST /chat` (the decided call's `tool.resume` / `tool.done` or `tool.error`, text deltas, `approval.requested` if it pauses again, `run.done`), from `agent.approvals.streamResolve()` / `streamAnswer()`. `404` when `id` is not pending; `409` (`LOUSHO_APPROVAL_CONFLICT`) while another request's decision of it is still streaming; `400` (`LOUSHO_APPROVAL_DECISION_INVALID`) for a body with neither field. When the turn's step paused on several calls, each id is decided on its own; a decision that leaves others pending streams `run.done` with `finishReason: 'awaiting-approval'`, and the last one runs the step. | diff --git a/src/deploy/nodeServer.ts b/src/deploy/nodeServer.ts index c7951cc40..eb4009404 100644 --- a/src/deploy/nodeServer.ts +++ b/src/deploy/nodeServer.ts @@ -4,9 +4,11 @@ * Serves the `/chat` API shared with `lousho dev` (src/server/chatRoutes.ts) * over a `node:http` server (the routes are Fetch-native, src/server/fetchRoutes.ts, * and shared with the Worker target): `GET /health`, sessions, SSE streaming, - * approvals and the legacy `POST /chat { message }`. When auth is configured (a bearer + * approvals and the legacy `POST /chat { message }`, plus `GET /ready` + * (readiness: 200 while serving, 503 once `shutdown()` has begun; `/health` stays + * the liveness probe). When auth is configured (a bearer * token, or an auth list from `@lousho/build-ai-agent/auth`), every route except - * `/health` requires it, including a channel route, unless that channel + * `/health` and `/ready` requires it, including a channel route, unless that channel * authenticates its own requests (it has a `verify`: a signed platform channel, * a webhook with a secret, an `httpChannel({ verify })`). * Once it listens it finishes the runs a crash left interrupted @@ -250,7 +252,14 @@ export function createDeployedServer(agent: SimpleAgent, options: DeployedServer waitUntil?.(promise); }, }; + // Readiness: flips once shutdown() begins, so a keep-alive probe sees it before the sockets close. + let draining = false; const handle = async (req: http.IncomingMessage, res: http.ServerResponse): Promise => { + if (req.method === 'GET' && new URL(req.url ?? '/', 'http://localhost').pathname === '/ready') { + res.writeHead(draining ? 503 : 200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); + res.end(JSON.stringify({ status: draining ? 'draining' : 'ready' })); + return; + } if (auth !== undefined && guardedChannelRoute(req, guarded) && !(await channelRequestAllowed(req, res, auth))) return; if (await channels?.(req, res)) return; await relayFetch(req, res, (request) => serveFetch(request, chat, auth)); @@ -276,6 +285,7 @@ export function createDeployedServer(agent: SimpleAgent, options: DeployedServer server.on('close', () => void stopRecovery()); } const shutdown = async ({ timeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS }: { timeoutMs?: number } = {}): Promise => { + draining = true; if (server.listening) server.close(); server.closeIdleConnections(); let timer: ReturnType | undefined; diff --git a/src/deploy/nodeServerReady.test.ts b/src/deploy/nodeServerReady.test.ts new file mode 100644 index 000000000..6005f7390 --- /dev/null +++ b/src/deploy/nodeServerReady.test.ts @@ -0,0 +1,68 @@ +/** `GET /ready` of the node server: readiness, separate from the `/health` liveness probe. */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as net from 'node:net'; +import type { AddressInfo } from 'node:net'; +import { z } from 'zod'; +import { createAgent } from '../createAgent'; +import { defineTool } from '../tools/defineTool'; +import { mockModel } from '../testing'; +import { createDeployedServer, type DeployedServerOptions } from './nodeServer'; + +const CRLF = '\r\n'; +const closers: (() => Promise)[] = []; +afterEach(async () => { + for (const close of closers.splice(0).reverse()) await close(); +}); + +async function start(options: DeployedServerOptions = { env: {} }, tools: ReturnType[] = []) { + const agent = createAgent({ provider: mockModel([{ toolCalls: [{ name: 'slow', args: {} }] }, 'done']), instructions: 'x', tools }); + closers.push(() => agent.close()); + const deployed = createDeployedServer(agent, options); + closers.push(() => deployed.shutdown({ timeoutMs: 50 })); + await new Promise((done) => deployed.server.listen(0, '127.0.0.1', done)); + return { ...deployed, url: `http://127.0.0.1:${(deployed.server.address() as AddressInfo).port}` }; +} + +describe('GET /ready', () => { + it('answers 200 ready while serving, and leaves /health as ok', async () => { + const { url } = await start(); + const ready = await fetch(`${url}/ready`); + expect(ready.status).toBe(200); + expect(await ready.json()).toEqual({ status: 'ready' }); + const health = await fetch(`${url}/health`); + expect(health.status).toBe(200); + expect(await health.text()).toBe('ok'); + }); + + it('needs no auth even with LOUSHO_API_TOKEN set', async () => { + const { url } = await start({ env: { LOUSHO_API_TOKEN: 'secret' } }); + expect((await fetch(`${url}/ready`)).status).toBe(200); + expect((await fetch(`${url}/chat/abc`)).status).toBe(401); + }); + + it('answers 503 draining once shutdown has begun', async () => { + let release!: () => void; + const gate = new Promise((done) => (release = done)); + const started = vi.fn(); + const slow = defineTool({ name: 'slow', description: 'Takes a while', input: z.object({}), execute: async () => (started(), await gate, 'finished') }); + const { url, shutdown } = await start({ env: {} }, [slow]); + // A probe whose request is half sent when shutdown starts is not an idle connection, so shutdown leaves it open. + const socket = net.createConnection({ host: '127.0.0.1', port: Number(new URL(url).port) }); + closers.push(async () => socket.destroy()); + await new Promise((done) => socket.once('connect', done)); + socket.write(['GET /ready HTTP/1.1', 'Host: localhost', 'Connection: close', ''].join(CRLF)); + await new Promise((done) => setTimeout(done, 20)); + + const turn = fetch(`${url}/chat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ message: 'go' }) }); + await vi.waitFor(() => expect(started).toHaveBeenCalled()); + const stopping = shutdown({ timeoutMs: 5_000 }); + let raw = ''; + socket.on('data', (chunk) => (raw += chunk)); + socket.write(CRLF); + await vi.waitFor(() => expect(raw).toContain('{"status":"draining"}')); + expect(raw.split(CRLF)[0]).toBe('HTTP/1.1 503 Service Unavailable'); + release(); + await turn; + await stopping; + }); +});