Add finished-roast .alog uploads with Pi agent deep review and /roasts history
Test and deploy / test-and-deploy (push) Successful in 1m39s

- POST /api/roasts stores the original .alog verbatim (multiple per plan),
  parses it server-side, and kicks off an async zero-tool Pi agent review
  (grade, highlights, concerns, next-batch suggestions, plan-vs-actual)
- /roasts page: table of historical actual roasts with review summaries;
  row detail renders the BT curve as SVG with the plan curve overlaid,
  shows the full review, and offers original-.alog backup download,
  re-evaluate, and delete
- Planner's Reference curve drawer gains a multi-file finished-roast
  uploader that attaches to the open (synced) plan
- Failed reviews record the error on the row and are retryable via
  POST /api/roasts/:id/evaluate (e.g. once a model is configured)

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-08 20:18:22 -04:00
co-authored by Claude Fable 5
parent 84ce75dc14
commit eb82263ead
15 changed files with 1425 additions and 8 deletions
+229 -3
View File
@@ -6,6 +6,7 @@ import { fetchPageText } from "./fetch-page.js";
import { runPrefill } from "./prefill.js";
import { parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { evaluateRoast as defaultEvaluateRoast } from "./evaluate-roast.js";
import { sendMail } from "./mailer.js";
import { coerceSession, computeTotalScore, blankSession } from "../shared/cupping.js";
import { computeMachineProfile } from "../shared/learn.js";
@@ -38,10 +39,17 @@ const PUBLIC_SHELL_FILES = new Set([
"/setup.html",
"/inventory.html",
"/cupping.html",
"/roasts.html",
]);
/** Creates the HTTP app separately from listening, so tests can use an isolated database. */
export function createApp({ db, root, env = process.env } = {}) {
export function createApp({
db,
root,
env = process.env,
// Injectable so tests can stub the Pi-agent call; production always uses the real one.
evaluateRoast = defaultEvaluateRoast,
} = {}) {
const app = express();
const production = env.NODE_ENV === "production";
const cookieSecure = env.COOKIE_SECURE
@@ -119,7 +127,8 @@ export function createApp({ db, root, env = process.env } = {}) {
req.path === "/admin" ||
req.path === "/account" ||
req.path === "/inventory" ||
req.path === "/cupping"
req.path === "/cupping" ||
req.path === "/roasts"
)
res.set("Cache-Control", "no-store, private");
res.set({
@@ -133,7 +142,18 @@ export function createApp({ db, root, env = process.env } = {}) {
});
next();
});
app.use(express.json({ limit: "1mb" }));
// Finished-roast uploads carry a whole Artisan .alog (full telemetry arrays) inside a JSON
// string — those legitimately run to a few MB, so that one route gets a larger body cap
// without loosening the 1mb limit everything else keeps.
const jsonBody = express.json({ limit: "1mb" });
const jsonBodyLarge = express.json({ limit: "8mb" });
app.use((req, res, next) =>
(req.method === "POST" && req.path === "/api/roasts" ? jsonBodyLarge : jsonBody)(
req,
res,
next,
),
);
// express.json() leaves req.body undefined when the request has no body or a non-JSON
// content-type (Express 5 no longer defaults it to {}), so every route below that reads
// req.body.<field> would 500 instead of validating and returning 400.
@@ -351,6 +371,15 @@ export function createApp({ db, root, env = process.env } = {}) {
next(error);
}
});
app.get("/roasts", async (req, res, next) => {
try {
const user = await session(req);
if (!user) return res.redirect("/login");
res.sendFile(path.join(root, "public", "roasts.html"));
} catch (error) {
next(error);
}
});
app.get("/admin", async (req, res, next) => {
try {
const user = await session(req);
@@ -1090,6 +1119,203 @@ export function createApp({ db, root, env = process.env } = {}) {
},
);
// ─── Actual roasts (finished .alog uploads) ────────────────────────────
const toRoastRow = (row, { full = false } = {}) => {
const parsed = row.parsed || {};
const evaluation = row.evaluation || null;
const base = {
id: row.id,
roastPlanId: row.roast_plan_id,
planTitle: row.plan_title ?? null,
filename: row.filename,
roast: parsed.roast ?? null,
derived: parsed.derived ?? null,
evaluationStatus: row.evaluation_status,
evaluationError: row.evaluation_error,
evaluationGrade: evaluation?.grade ?? null,
evaluationSummary: evaluation?.summary ?? null,
createdAt: row.created_at,
};
return full ? { ...base, parsed, evaluation, plan: row.plan ?? null } : base;
};
// Fire-and-forget: the upload response never waits on the model (a deep review takes tens of
// seconds, and uploads arrive in batches); the row starts 'pending' and the client polls.
// Failure is recorded on the row rather than lost — 'failed' + evaluation_error, and the
// re-evaluate endpoint below is the retry path (e.g. once a model is configured).
function startEvaluation(roastId, userId) {
const run = (async () => {
const row = (
await db.query(
`SELECT a.parsed, p.plan FROM actual_roasts a
LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[roastId, userId],
)
).rows[0];
if (!row) return;
try {
const evaluation = await evaluateRoast(row.parsed, row.plan ?? null);
await db.query(
"UPDATE actual_roasts SET evaluation=$1, evaluation_status='done', evaluation_error=NULL, updated_at=now() WHERE id=$2",
[evaluation, roastId],
);
} catch (e) {
await db.query(
"UPDATE actual_roasts SET evaluation_status='failed', evaluation_error=$1, updated_at=now() WHERE id=$2",
[`${e.code ?? "error"}: ${e.message}`.slice(0, 500), roastId],
);
}
})().catch((e) => console.error("roast_evaluation_failed", roastId, e));
return run;
}
app.post("/api/roasts", requireAuth, csrf, async (req, res, next) => {
try {
const content = req.body.content;
if (typeof content !== "string" || !content.trim())
return res.status(400).json({ ok: false, code: "bad_request" });
const filename = String(req.body.filename || "upload.alog").slice(0, 200);
let roastPlanId = null;
if (req.body.roastPlanId) {
if (!UUID_RE.test(req.body.roastPlanId))
return res.status(404).json({ ok: false, code: "not_found" });
const ownsPlan = (
await db.query(
"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
[req.body.roastPlanId, req.user.id],
)
).rowCount;
if (!ownsPlan)
return res.status(404).json({ ok: false, code: "not_found" });
roastPlanId = req.body.roastPlanId;
}
let parsed;
try {
parsed = parseAlog(content, filename);
} catch (err) {
return res
.status(422)
.json({ ok: false, code: "unparseable_alog", error: err.message });
}
const row = (
await db.query(
`INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed)
VALUES($1,$2,$3,$4,$5) RETURNING *`,
[req.user.id, roastPlanId, filename, content, parsed],
)
).rows[0];
startEvaluation(row.id, req.user.id);
res.status(201).json({ ok: true, roast: toRoastRow(row) });
} catch (e) {
next(e);
}
});
app.get("/api/roasts", requireAuth, async (req, res, next) => {
try {
const planFilter =
typeof req.query.plan === "string" && UUID_RE.test(req.query.plan)
? req.query.plan
: null;
const rows = (
await db.query(
`SELECT a.id,a.roast_plan_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
p.plan->'fields'->>'0.1' AS plan_title
FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.user_id=$1 AND ($2::uuid IS NULL OR a.roast_plan_id=$2)
ORDER BY a.created_at DESC`,
[req.user.id, planFilter],
)
).rows;
res.json({ ok: true, roasts: rows.map((r) => toRoastRow(r)) });
} catch (e) {
next(e);
}
});
app.get(
"/api/roasts/:id",
requireAuth,
requireUuidParam("id"),
async (req, res, next) => {
try {
const row = (
await db.query(
`SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, p.plan
FROM actual_roasts a LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
WHERE a.id=$1 AND a.user_id=$2`,
[req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true, roast: toRoastRow(row, { full: true }) });
} catch (e) {
next(e);
}
},
);
app.get(
"/api/roasts/:id/download",
requireAuth,
requireUuidParam("id"),
async (req, res, next) => {
try {
const row = (
await db.query(
"SELECT filename,original_content FROM actual_roasts WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
)
).rows[0];
if (!row) return res.status(404).json({ ok: false, code: "not_found" });
let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
if (!/\.alog$/i.test(name)) name += ".alog";
res.set({
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${name}"`,
});
res.send(row.original_content);
} catch (e) {
next(e);
}
},
);
app.post(
"/api/roasts/:id/evaluate",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"UPDATE actual_roasts SET evaluation_status='pending', evaluation_error=NULL, updated_at=now() WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
startEvaluation(req.params.id, req.user.id);
res.json({ ok: true, evaluationStatus: "pending" });
} catch (e) {
next(e);
}
},
);
app.delete(
"/api/roasts/:id",
requireAuth,
csrf,
requireUuidParam("id"),
async (req, res, next) => {
try {
const r = await db.query(
"DELETE FROM actual_roasts WHERE id=$1 AND user_id=$2",
[req.params.id, req.user.id],
);
if (!r.rowCount)
return res.status(404).json({ ok: false, code: "not_found" });
res.json({ ok: true });
} catch (e) {
next(e);
}
},
);
// ─── Cupping ───────────────────────────────────────────────────────────
const toSessionRow = (row, { full = false } = {}) => {
const data = row.data || {};