import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; import { buildHonoApp } from './hono'; import { waitForDatabase } from './db'; import { startQueue } from './generations/queue'; // The officer-atelier sidecar. Owns the whole image-generation contract: providers, the generation // queue, and the `atelier` Postgres database (kept separate from officer_db — see ../PLUGIN.md). // // The platform API (../api/router.ts) is a thin auth-gated forwarder holding no provider credentials // of its own. This process binds a loopback port and reports it over the registration socket, exactly // like offscale's sidecar; `createSidecarProxy` on the platform side learns the port from the // `atelier:server` event and forwards `/api/atelier/*` here with the prefix stripped. /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probe.port; probe.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); const app = buildHonoApp(); const server = Bun.serve({ port, hostname: '127.0.0.1', // Generation runs 20-60s and a caller may long-poll for up to MAX_WAIT_SECONDS (sidecar/api/images.ts) // — the ceiling Bun allows, one below the ceiling rejected outright. idleTimeout: 255, // OpenAI's edits endpoint takes up to 16 images at 50MB each. maxRequestBodySize: 1024 * 1024 * 900, fetch(req, srv) { const url = new URL(req.url); // Liveness only — matches the shape every other HTTP sidecar in this codebase uses. if (url.pathname === '/_health') return Response.json({ ok: true }); return app.fetch(req, srv); }, }); console.log(`[atelier] listening on 127.0.0.1:${port}`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'atelier', handles: ['atelier'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { // Tell the API where we're listening, so it can forward /api/atelier/* here. connection.send({ type: 'atelier:server', port }); console.log(`[atelier] reported port ${port} to API`); }, }); // Awaited, not fired and forgotten. postgres-js is lazy, so on a cold boot the first query loses a // race with Postgres and the restart sweep (`failInterrupted`) — the only thing that corrects rows // left `running` by the last process — would fail once and be swallowed silently. void (async () => { if (await waitForDatabase()) await startQueue(); else console.error('[atelier:queue] not started: no database'); })(); function shutdown(signal: string) { console.log(`[atelier] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));