manifest, thin proxy router, and the sidecar backend
ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
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<string, unknown>).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'));
|
||||
Reference in New Issue
Block a user