feat(collector): review collector skeleton — apps/collector, its own Komodo stack on the reviewer's host
The far end of the Car Wash review outbox (wiki/concepts/vision-review-outbox.md): a small Fastify + SQLite service in the monorepo (shares the payload contract and the class vocabulary via @parking/shared), delivered to art-docker-station by its own stack so nothing booth-side lands there and nothing of it on a booth. - POST /ingest: bearer token per booth (constant-time), X-Booth-Id must match, multipart meta + JPEG (magic checked, 2 MB cap), meta validated against the contract, idempotent on the item id; crop stored at crops/<booth>/<item>.jpg on the volume + one items row. - /review + /api/*: the reviewer's screen served by the process (Basic auth, one login): one pending crop at a time, operator's pick and camera's pick beside it, one button/key per vocabulary class + unusable + skip; stats per booth and per hashed operator (agree / disagree / unusable — disagree = the reviewer's class is outside the operator's category). - GET /export/labels.csv: reviewed usable rows for training; formula-leading cells are neutralised (booth-supplied names). Crops stay on the volume for the trainer on the host. - Booth payload now carries operatorCategory.classes so the comparison needs no site setup. - Delivery: apps/collector/Dockerfile (monorepo context), docker-compose.collector.yml (bind to the overlay IP; commented `trainer` profile seam for the GPU), a third build step in build-images.yml, a `wash-collector` stack in komodo/resources.toml with one secret per booth referenced from both the collector's token list and the booth's own stack (park-2 lines templated, commented, DNS name for the URL). - Tests: app.test.ts (ingest ok/dup/refusals, review + stats + export, config). Image built and smoke-tested locally (health, ingest, duplicate, auth, verdict, export). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
export interface CollectorConfig {
|
||||
readonly host: string;
|
||||
readonly port: number;
|
||||
readonly dataDir: string;
|
||||
/** boothId → bearer token. */
|
||||
readonly boothTokens: ReadonlyMap<string, string>;
|
||||
/** The single reviewer login; null = review screen and export refuse (503). */
|
||||
readonly reviewer: { readonly user: string; readonly pass: string } | null;
|
||||
}
|
||||
|
||||
/** "booth-7:abc,booth-9:def" (commas, whitespace or newlines between pairs). */
|
||||
export function parseBoothTokens(raw: string): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const pair of raw.split(/[,\s]+/)) {
|
||||
if (!pair) continue;
|
||||
const i = pair.indexOf(":");
|
||||
if (i <= 0) throw new Error(`COLLECTOR_BOOTH_TOKENS: bad pair "${pair}" (want boothId:token)`);
|
||||
const booth = pair.slice(0, i).trim();
|
||||
const token = pair.slice(i + 1).trim();
|
||||
if (!booth || token.length < 16) throw new Error(`COLLECTOR_BOOTH_TOKENS: token for "${booth}" too short (>=16 chars)`);
|
||||
out.set(booth, token);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function configFromEnv(env: NodeJS.ProcessEnv = process.env): CollectorConfig {
|
||||
const user = (env.COLLECTOR_REVIEWER_USER ?? "").trim();
|
||||
const pass = env.COLLECTOR_REVIEWER_PASS ?? "";
|
||||
return {
|
||||
host: env.COLLECTOR_HOST ?? "0.0.0.0",
|
||||
port: Number(env.COLLECTOR_PORT ?? 8090),
|
||||
dataDir: env.COLLECTOR_DATA_DIR ?? "/data",
|
||||
boothTokens: parseBoothTokens(env.COLLECTOR_BOOTH_TOKENS ?? ""),
|
||||
reviewer: user && pass.length >= 8 ? { user, pass } : null,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user