Adds a full evals dashboard view (FN-3394) with a client hook, server-side read routes, and navigation integration across the Header and mobile nav, backed by comprehensive tests. Wires Hermes chat message tools with mailbox behavior documentation (FN-3620), and extends the agent heartbeat and tools Fusion-Task-Id: FN-3394
128 lines
4.9 KiB
TypeScript
128 lines
4.9 KiB
TypeScript
import { Router } from "express";
|
|
import type { NextFunction, Request, Response } from "express";
|
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import type { EvalRun, EvalTaskResult, TaskStore } from "@fusion/core";
|
|
import { ApiError, badRequest, notFound } from "./api-error.js";
|
|
|
|
function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never {
|
|
if (error instanceof ApiError) throw error;
|
|
if (error instanceof Error) throw new ApiError(500, error.message);
|
|
throw new ApiError(500, fallbackMessage);
|
|
}
|
|
|
|
function getProjectId(req: Request): string | undefined {
|
|
if (typeof req.query.projectId === "string" && req.query.projectId.trim()) return req.query.projectId;
|
|
return undefined;
|
|
}
|
|
|
|
function parseOptionalInt(value: unknown, key: string): number | undefined {
|
|
if (value === undefined) return undefined;
|
|
const parsed = Number(value);
|
|
if (!Number.isInteger(parsed)) {
|
|
throw badRequest(`Invalid ${key}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeEvalText(result: EvalTaskResult): string {
|
|
return [
|
|
result.id,
|
|
result.taskId,
|
|
result.runId,
|
|
result.taskSnapshot.title,
|
|
result.taskSnapshot.summary,
|
|
result.summary,
|
|
result.rationale,
|
|
].filter((value): value is string => typeof value === "string" && value.trim().length > 0).join(" ").toLowerCase();
|
|
}
|
|
|
|
export function createEvalsRouter(store: TaskStore): Router {
|
|
const router = Router();
|
|
const requestContext = new AsyncLocalStorage<TaskStore>();
|
|
|
|
router.use((req: Request, _res: Response, next: NextFunction) => {
|
|
const projectId = getProjectId(req);
|
|
if (projectId) {
|
|
import("./project-store-resolver.js").then(({ getOrCreateProjectStore }) => {
|
|
getOrCreateProjectStore(projectId).then((scopedStore) => {
|
|
requestContext.run(scopedStore, () => next());
|
|
}).catch((err) => rethrowAsApiError(err, "Failed to get project store"));
|
|
});
|
|
return;
|
|
}
|
|
requestContext.run(store, () => next());
|
|
});
|
|
|
|
function getEvalStore() {
|
|
const scopedStore = requestContext.getStore();
|
|
if (!scopedStore) throw new ApiError(500, "Store context not available");
|
|
return scopedStore.getEvalStore();
|
|
}
|
|
|
|
router.get("/runs", (req: Request, res: Response) => {
|
|
try {
|
|
const evalStore = getEvalStore();
|
|
const runs = evalStore.listRuns().map((run: EvalRun) => ({
|
|
id: run.id,
|
|
createdAt: run.createdAt,
|
|
completedAt: run.completedAt,
|
|
status: run.status,
|
|
evaluatedTaskCount: run.counts.scoredTasks,
|
|
counts: run.counts,
|
|
trigger: run.trigger,
|
|
}));
|
|
res.json({ runs });
|
|
} catch (error) {
|
|
rethrowAsApiError(error, "Failed to list eval runs");
|
|
}
|
|
});
|
|
|
|
router.get("/:id", (req: Request, res: Response) => {
|
|
try {
|
|
const evalStore = getEvalStore();
|
|
const evalId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const result = evalStore.getTaskResult(evalId);
|
|
if (!result) throw notFound(`Eval result not found: ${evalId}`);
|
|
res.json({ result });
|
|
} catch (error) {
|
|
rethrowAsApiError(error, "Failed to get eval result");
|
|
}
|
|
});
|
|
|
|
router.get("/", (req: Request, res: Response) => {
|
|
try {
|
|
const evalStore = getEvalStore();
|
|
const q = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : "";
|
|
const runId = typeof req.query.runId === "string" && req.query.runId.trim() ? req.query.runId : undefined;
|
|
const scoreMin = parseOptionalInt(req.query.scoreMin, "scoreMin");
|
|
const scoreMax = parseOptionalInt(req.query.scoreMax, "scoreMax");
|
|
const limit = parseOptionalInt(req.query.limit, "limit") ?? 100;
|
|
const offset = parseOptionalInt(req.query.offset, "offset") ?? 0;
|
|
|
|
if (scoreMin !== undefined && (scoreMin < 0 || scoreMin > 100)) throw badRequest("scoreMin must be between 0 and 100");
|
|
if (scoreMax !== undefined && (scoreMax < 0 || scoreMax > 100)) throw badRequest("scoreMax must be between 0 and 100");
|
|
if (scoreMin !== undefined && scoreMax !== undefined && scoreMin > scoreMax) throw badRequest("scoreMin cannot exceed scoreMax");
|
|
if (limit < 1) throw badRequest("Invalid limit");
|
|
if (offset < 0) throw badRequest("Invalid offset");
|
|
|
|
let results = evalStore.listTaskResults({ runId });
|
|
results = results.filter((result) => {
|
|
if (scoreMin !== undefined && (result.overallScore ?? -1) < scoreMin) return false;
|
|
if (scoreMax !== undefined && (result.overallScore ?? 101) > scoreMax) return false;
|
|
if (q && !normalizeEvalText(result).includes(q)) return false;
|
|
return true;
|
|
});
|
|
|
|
results.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
const count = results.length;
|
|
results = results.slice(offset, offset + limit);
|
|
|
|
res.json({ results, count });
|
|
} catch (error) {
|
|
rethrowAsApiError(error, "Failed to list eval results");
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}
|