Files
parking_solution/apps/collector/src/training.test.ts
T
julian 4ff31557a8 feat(trainer): training from the collector UI — the trainer becomes a job service, the review page gains a Training section
Trainer: `parking-trainer serve` — a stdlib HTTP job API on the compose network (never
published): /health, /readiness, /versions, /versions/<v>/report, /jobs. One job at a
time; each job runs the CLI as a subprocess with its output captured, state + log
persisted under /out/jobs/ so a restart keeps history. `publish` takes its URL from
TRAINER_PUBLISH_URL. Dockerfile: CMD serve, EXPOSE 8091, healthcheck.

Collector: COLLECTOR_TRAINER_URL + /api/training/{status,jobs,jobs/:id,versions/:v/report}
— a reviewer-gated proxy that forwards a fixed set of paths and whitelisted knobs and
passes the trainer's status codes through (409 while a job runs; 503 unconfigured, 502
unreachable). /review gains the Training section: labels per class vs the minimum with
Train disabled until two classes clear it, mode / backbone / floor, the running job's
live log, the versions with Report / Evaluate / Publish (publish confirms), and the
reminder that pinning stays a git commit. Fixed on the way: an apostrophe in the page's
inline script broke the whole page — a test now parses the script.

Compose: `trainer` is a service (restart: unless-stopped, read-only data volume, its own
trainer-out volume), the `train` profile and TRAINER_OUT are gone; the Docker-socket
route was rejected (root on the host for a service booths upload to). Verified with both
images running together: a Train started through the proxy finished, version and report
came back, the page rendered.

Wiki: bodytype-classifier-training (loop, running it, operating notes superseded),
vision-review-outbox, fleet-deployment-komodo, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 14:34:13 +02:00

127 lines
6.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { buildCollector, type CollectorApp } from "./app.js";
// The Training section's proxy: reviewer-gated, forwards a fixed set of paths to the
// trainer's job API, passes its status codes through, and degrades cleanly when the trainer
// is not configured or not reachable. The trainer is faked with a bare node http server.
const REVIEWER = { user: "julian", pass: "review-pass-123" };
const basic = "Basic " + Buffer.from(`${REVIEWER.user}:${REVIEWER.pass}`).toString("base64");
let dir: string;
let fake: Server;
let fakeUrl: string;
let seen: { method: string; url: string; body: string }[];
let app: CollectorApp;
async function start(trainerUrl: string | null): Promise<void> {
app = await buildCollector({ host: "127.0.0.1", port: 0, dataDir: dir, boothTokens: new Map(), reviewer: REVIEWER, trainerUrl }, { dbFile: ":memory:" });
await app.ready();
}
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "collector-"));
seen = [];
fake = createServer((req: IncomingMessage, res: ServerResponse) => {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
seen.push({ method: req.method ?? "", url: req.url ?? "", body });
const json = (code: number, obj: unknown) => {
res.writeHead(code, { "content-type": "application/json" });
res.end(JSON.stringify(obj));
};
if (req.url === "/health") return json(200, { ok: true, busy: false });
if (req.url === "/readiness") return json(200, { ready: false, labelled: { total: 3 } });
if (req.url === "/versions") return json(200, { versions: [{ version: "v1", written: true }] });
if (req.url === "/jobs" && req.method === "GET") return json(200, { jobs: [{ id: "j1" }], current: null });
if (req.url === "/jobs" && req.method === "POST") return body.includes('"busy"') ? json(409, { error: "a job is already running" }) : json(202, { id: "j2", status: "running" });
if (req.url === "/jobs/j1") return json(200, { id: "j1", status: "done", log: "ok" });
if (req.url === "/versions/v1/report") {
res.writeHead(200, { "content-type": "text/markdown; charset=utf-8" });
return res.end("# Body-type classifier v1\n");
}
return json(404, { error: "not found" });
});
});
await new Promise<void>((r) => fake.listen(0, "127.0.0.1", r));
const a = fake.address() as { port: number };
fakeUrl = `http://127.0.0.1:${a.port}`;
});
afterEach(async () => {
await app?.close();
await new Promise<void>((r) => fake.close(() => r()));
await rm(dir, { recursive: true, force: true });
});
describe("review page script", () => {
it("parses as JavaScript (an apostrophe in a template literal once broke the whole page)", async () => {
const { reviewPage } = await import("./review-page.js");
const html = reviewPage();
const script = html.slice(html.indexOf("<script>") + 8, html.lastIndexOf("</script>"));
expect(() => new Function(script)).not.toThrow();
});
});
describe("training proxy", () => {
it("is hidden when no trainer is configured", async () => {
await start(null);
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.json()).toEqual({ configured: false });
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
expect(j.statusCode).toBe(503);
});
it("aggregates status and forwards jobs and reports behind the reviewer login", async () => {
await start(fakeUrl);
expect((await app.inject({ method: "GET", url: "/api/training/status" })).statusCode).toBe(401);
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.statusCode).toBe(200);
const body = s.json();
expect(body.configured).toBe(true);
expect(body.reachable).toBe(true);
expect(body.readiness.labelled.total).toBe(3);
expect(body.versions[0].version).toBe("v1");
expect(body.jobs[0].id).toBe("j1");
const j = await app.inject({
method: "POST",
url: "/api/training/jobs",
headers: { authorization: basic },
payload: { kind: "train", mode: "features", minAccuracy: 0.9, secret: "nope", version: "v2" },
});
expect(j.statusCode).toBe(202);
expect(j.json().id).toBe("j2");
const posted = seen.find((r) => r.method === "POST")!;
expect(JSON.parse(posted.body)).toEqual({ kind: "train", mode: "features", minAccuracy: 0.9, version: "v2" }); // unknown keys dropped
const busy = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "evaluate", version: "busy" } });
expect(busy.statusCode).toBe(409); // the trainer's answer passes through
const bad = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "rm-rf" } });
expect(bad.statusCode).toBe(400);
const one = await app.inject({ method: "GET", url: "/api/training/jobs/j1", headers: { authorization: basic } });
expect(one.json().status).toBe("done");
expect((await app.inject({ method: "GET", url: "/api/training/jobs/..%2Fx", headers: { authorization: basic } })).statusCode).toBe(400);
const rep = await app.inject({ method: "GET", url: "/api/training/versions/v1/report", headers: { authorization: basic } });
expect(rep.statusCode).toBe(200);
expect(rep.headers["content-type"]).toContain("text/markdown");
expect(rep.body).toContain("# Body-type classifier v1");
});
it("reports an unreachable trainer without failing the page", async () => {
await start("http://127.0.0.1:9"); // nothing listens on the discard port
const s = await app.inject({ method: "GET", url: "/api/training/status", headers: { authorization: basic } });
expect(s.statusCode).toBe(200);
expect(s.json().reachable).toBe(false);
const j = await app.inject({ method: "POST", url: "/api/training/jobs", headers: { authorization: basic }, payload: { kind: "train" } });
expect(j.statusCode).toBe(502);
});
});