4ff31557a8
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
127 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|