Files
atelier/sidecar/index.ts
T
pastilhas 0de38f0b13 feat(catalogue): a curated file of providers and models, refreshed daily
The five connections in the database were never configured by anyone — a
migration minted one per model from the adapter's own list, so the table held
five decisions nobody made. What that list actually is, is a CATALOGUE: what
you can choose from, which is not the same kind of thing as what you chose.

So it is a file. `sidecar/catalogue/shipped.ts` is the curated baseline, in
git, reviewed; `$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh
writes, and it wins where it has something to say. Data dir, not the plugin
dir — a plugin directory is a clone an update replaces, which is the rule
env.ts already states for images.

Refresh runs in the sidecar, daily, and only on boot when the file is actually
stale — this process has restarted twenty times in an afternoon and should not
hit OpenAI twenty times for it.

Two sources, unequal, and the code says so. `GET /v1/models` is authoritative
and thin: ids and nothing else, so a model matching /^(gpt-image|dall-e)/
appears the day it ships without a release. Prices come off an HTML page
because there is no pricing endpoint for any plan, and that parser is written
to give up rather than guess — a guess gets written to disk and then believed.
When it gives up it says so in the log; the durable replacement is handing the
page to a model for structured output, which Officer can already do locally.

Nothing a refresh cannot establish overwrites a curated value: fields merge
one by one, not objects wholesale, so an id-only listing cannot wipe the sizes
and prices that were written by hand. Retiring is decided by the merge, which
can tell a complete listing from a request that failed halfway — and it marks
`deprecated` rather than deleting, because the gallery labels 88 historical
rows from this list.

Per-model sizes, qualities and transparency now travel with each entry, which
is what will let the form stop offering dall-e-2 a transparent background it
cannot do. The catalogue is advisory throughout: validate.ts still refuses to
enforce per-model rules, exactly as before.
2026-08-20 22:15:35 +01:00

102 lines
3.7 KiB
TypeScript

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';
import { startCatalogueRefresh } from './catalogue/refresh';
// 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');
// After the database, because a refresh reads any connection's key before falling back to the
// environment — and before it, there is nothing to read.
await startCatalogueRefresh();
})();
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'));