Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <AgentEvent JSON>` 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. |
Expand Down
3 changes: 2 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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: <AgentEvent JSON>` 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. |
Expand Down
14 changes: 12 additions & 2 deletions src/deploy/nodeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> => {
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));
Expand All @@ -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<void> => {
draining = true;
if (server.listening) server.close();
server.closeIdleConnections();
let timer: ReturnType<typeof setTimeout> | undefined;
Expand Down
68 changes: 68 additions & 0 deletions src/deploy/nodeServerReady.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>)[] = [];
afterEach(async () => {
for (const close of closers.splice(0).reverse()) await close();
});

async function start(options: DeployedServerOptions = { env: {} }, tools: ReturnType<typeof defineTool>[] = []) {
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<void>((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<void>((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<void>((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;
});
});
Loading