doatlas-2 / artifacts /api-server /src /routes /evolutionFlywheel.ts
Iostream-Li's picture
Add files using upload-large-folder tool
6d1fe92 verified
/**
* /api/admin/evolution — Wave B online-evolution control plane.
*
* Endpoints (all admin-gated):
* GET /admin/evolution/networks
* GET /admin/evolution/networks/:networkId/fitness
* GET /admin/evolution/networks/:networkId/events
* GET /admin/evolution/networks/:networkId/shadow
* GET /admin/evolution/networks/:networkId/promotions
* GET /admin/evolution/networks/:networkId/regression
* POST /admin/evolution/networks/:networkId/regression
* POST /admin/evolution/networks/:networkId/triggers/evaluate
* POST /admin/evolution/networks/:networkId/promote
* POST /admin/evolution/networks/:networkId/rollback-tick
*
* The control plane is read-mostly; the three POST endpoints exist so
* an operator can manually drive the loop while the cron worker is
* being wired (the cron worker itself is intentionally out of scope —
* it just calls the same functions on a schedule).
*/
import { Router, type IRouter, type RequestHandler } from "express";
import { and, asc, desc, eq, gte, inArray } from "drizzle-orm";
import {
db,
networkEvolutionEvents,
networkPromotions,
networkVersions,
toolNetworks,
} from "@workspace/db";
import { requireAuth, requireAdmin } from "../middlewares/auth";
import { logger } from "../lib/logger";
import { rollingFitness } from "../lib/evolution/fitness";
import { evaluateTriggers } from "../lib/evolution/triggers";
import {
computeShadowBudget,
getMostRecentShadowVariantId,
summariseShadow,
type ShadowSummary,
} from "../lib/evolution/shadow";
import type { ShadowActivePairing } from "../lib/evolution/shadow-core";
import { listEvents, recordEvent } from "../lib/evolution/events";
import {
DEFAULT_EVOLUTION_STRATEGY,
getEvolutionStrategy,
listEvolutionStrategyNames,
} from "../lib/evolution/strategies";
import { attemptAutoPromote } from "../lib/evolution/promote";
import { tickRollbackWatch } from "../lib/evolution/rollback";
import { runEvolutionTick } from "../lib/evolution/scheduler";
import {
archiveSample,
listSamples,
runSuiteAgainstVariant,
} from "../lib/evolution/regression-suite";
const router: IRouter = Router();
const adminGuard = [requireAuth, requireAdmin] as const;
const clampInt = (raw: unknown, def: number, min: number, max: number): number => {
const n = Number(raw);
if (!Number.isFinite(n)) return def;
const i = Math.floor(n);
if (i < min) return min;
if (i > max) return max;
return i;
};
class HttpError extends Error {
status: number;
code: string;
constructor(status: number, code: string, msg?: string) {
super(msg ?? code);
this.status = status;
this.code = code;
}
}
const sendError = (
res: Parameters<RequestHandler>[1],
err: unknown,
fallback: string,
): void => {
if (err instanceof HttpError) {
res.status(err.status).json({ error: err.code });
return;
}
// Map common domain errors thrown as plain Error("...not_found"/"invalid_state...")
// by the evolution lib into 4xx responses instead of generic 500s.
const msg = err instanceof Error ? err.message : "";
if (/not[_\s-]?found/i.test(msg)) {
res.status(404).json({ error: msg.replace(/\s+/g, "_") });
return;
}
if (/invalid|missing|required|already|conflict/i.test(msg)) {
res.status(409).json({ error: msg.replace(/\s+/g, "_") });
return;
}
res.status(500).json({ error: fallback });
};
const listNetworks: RequestHandler = async (_req, res) => {
try {
const nets = await db
.select()
.from(toolNetworks)
.orderBy(desc(toolNetworks.updatedAt));
const out = await Promise.all(
nets.map(async (n) => {
let fitness = null;
if (n.activeVariantId) {
fitness = await rollingFitness(
n.id,
n.activeVariantId,
n.problemClassPath,
);
}
const shadowBudget = await computeShadowBudget(n.id);
// Task #228 — surface 当前 evolutionStrategy, 让 Admin UI
// 可以渲染 "切换策略" 按钮的当前状态。缺省 (jsonb 缺字段) 时
// 显式回退到 DEFAULT_EVOLUTION_STRATEGY, 与 builder.ts 选择
// 策略时的回退一致。
const cfg = (n.config ?? {}) as Record<string, unknown>;
const evolutionStrategy =
typeof cfg["evolutionStrategy"] === "string"
? (cfg["evolutionStrategy"] as string)
: DEFAULT_EVOLUTION_STRATEGY;
return {
id: n.id,
name: n.name,
problemClassPath: n.problemClassPath,
status: n.status,
activeVariantId: n.activeVariantId,
releaseTierFloor: n.releaseTierFloor,
builderModelTier: n.builderModelTier,
updatedAt: n.updatedAt,
fitness,
shadowBudget,
evolutionStrategy,
};
}),
);
res.json({ networks: out });
} catch (err) {
logger.error({ err }, "evolution: listNetworks failed");
sendError(res, err, "failed_to_list_networks");
}
};
const requireNetwork = async (networkId: string) => {
const row = (
await db
.select()
.from(toolNetworks)
.where(eq(toolNetworks.id, networkId))
.limit(1)
)[0];
if (!row) throw new HttpError(404, "network_not_found");
return row;
};
const getFitness: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const versionIdParam = (req.query.versionId as string | undefined) ?? null;
const days = clampInt(req.query.days, 7, 1, 90);
try {
const network = await requireNetwork(networkId);
const versions = await db
.select()
.from(networkVersions)
.where(eq(networkVersions.networkId, networkId))
.orderBy(desc(networkVersions.createdAt));
const targetVersionIds = versionIdParam
? [versionIdParam]
: versions.map((v) => v.id).slice(0, 5);
const out = await Promise.all(
targetVersionIds.map(async (vid) => ({
versionId: vid,
fitness: await rollingFitness(networkId, vid, network.problemClassPath, {
windowDays: days,
}),
})),
);
res.json({ networkId, versions: out });
} catch (err) {
logger.error({ err, networkId }, "evolution: fitness failed");
sendError(res, err, "failed_to_compute_fitness");
}
};
const getEvents: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const limit = clampInt(req.query.limit, 200, 1, 500);
try {
await requireNetwork(networkId);
const events = await listEvents({ networkId, limit });
res.json({ events });
} catch (err) {
logger.error({ err, networkId }, "evolution: events failed");
sendError(res, err, "failed_to_list_events");
}
};
// Pure mapping from raw threshold events to chartable trend points.
// Exported for unit testing — the route handler below adds DB lookup and
// auth concerns on top of this. listEvents returns newest-first, but the
// chart wants oldest-first so we reverse here.
export interface BudgetTrendPoint {
eventId: string;
at: Date;
thresholdMs: number;
previousThresholdMs: number | null;
mode: string;
p75Ms: number | null;
safetyFactor: number | null;
sampleCount: number | null;
recentSkipRatio: number;
recentSampleCount: number;
}
export function eventsToBudgetTrendPoints(
events: Array<{
id: string;
createdAt: Date;
payload: unknown;
}>,
): BudgetTrendPoint[] {
const num = (v: unknown): number | null =>
typeof v === "number" && Number.isFinite(v) ? v : null;
return events
.map((e) => {
const p = (e.payload ?? {}) as Record<string, unknown>;
const thresholdMs = num(p.thresholdMs);
if (thresholdMs === null) return null;
const mode = typeof p.mode === "string" ? p.mode : "unknown";
return {
eventId: e.id,
at: e.createdAt,
thresholdMs,
previousThresholdMs: num(p.previousThresholdMs),
mode,
p75Ms: num(p.p75Ms),
safetyFactor: num(p.safetyFactor),
sampleCount: num(p.sampleCount),
recentSkipRatio: num(p.recentSkipRatio) ?? 0,
recentSampleCount: num(p.recentSampleCount) ?? 0,
};
})
.filter((x): x is BudgetTrendPoint => x !== null)
.reverse();
}
// Pull the recent shadow_budget_threshold events for a network and flatten
// them into a chartable point series. Each event already carries the
// threshold ms, the dynamic-mode metadata, and the recent budget-skip
// ratio observed at that moment, so the admin UI can plot drift without
// having to bucket raw shadow samples itself.
const getBudgetTrend: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const limit = clampInt(req.query.limit, 50, 1, 200);
try {
await requireNetwork(networkId);
const events = await listEvents({
networkId,
kind: "shadow_budget_threshold",
limit,
});
const points = eventsToBudgetTrendPoints(events);
res.json({ networkId, points });
} catch (err) {
logger.error({ err, networkId }, "evolution: budget trend failed");
sendError(res, err, "failed_to_load_budget_trend");
}
};
const getShadow: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const candidateId = req.query.candidateId as string | undefined;
if (!candidateId) {
return res.status(400).json({ error: "candidateId_required" });
}
try {
const summary = await summariseShadow(networkId, candidateId);
res.json({ summary });
} catch (err) {
logger.error({ err, networkId, candidateId }, "evolution: shadow failed");
sendError(res, err, "failed_to_summarise_shadow");
}
};
/**
* Wall-clock window the overview computes its summary against. Kept
* in sync with the default in {@link summariseShadow} so the promotion
* lookup uses exactly the same horizon as the win-delta math.
*/
const SHADOW_OVERVIEW_WINDOW_DAYS = 7;
/**
* Promotion that ended a particular pairing in the shadow window. The
* UI uses `eventId` to scroll/highlight the matching row in the events
* stream and `reason` to colour the chip differently for "switched-
* because-promoted" (`auto_promote` / `manual` / `seed`) vs
* "switched-because-rolled-back" (`auto_rollback`).
*/
export interface PairingPromotionLink {
promotionId: string;
/** "manual" | "auto_promote" | "auto_rollback" | "seed" */
reason: string;
/** The variant that took over after this pairing's active was demoted. */
toVariantId: string;
createdAt: Date;
/**
* `network_evolution_events.id` for the matching `promote` /
* `rollback` event — null if no event was recorded (legacy data /
* direct DB insert). The UI links the chip to this event id.
*/
eventId: string | null;
}
export type EnrichedShadowActivePairing = ShadowActivePairing & {
endedByPromotion: PairingPromotionLink | null;
};
/**
* Pure mapping: given the pairings the shadow window observed and the
* promotions already filtered to the same window, attach the earliest
* promotion that demoted the pairing's active variant after its last
* sample. Exported so the route's enrichment logic is unit-testable
* without standing up the DB / Express / events writer.
*
* Matching rule (see task #199): a pairing P with `lastSampleAt = T_last`
* is considered ended by the *earliest* promotion `pr` where
* pr.fromVariantId === P.activeVariantId AND
* pr.createdAt >= T_last
* The `>= T_last` clamp is what protects against a network that was
* promoted out of and then back into the same active variant — only
* the promotion that fires *after* the pairing's last sample matters.
* When the pairing has no `lastSampleAt` (defensive: legacy data with
* null timestamps), we fall back to "earliest matching promotion in
* the window" so the link still shows up rather than disappearing.
*/
export function enrichPairingsWithPromotions(
pairings: ReadonlyArray<ShadowActivePairing>,
promotions: ReadonlyArray<{
id: string;
fromVariantId: string | null;
toVariantId: string;
reason: string;
createdAt: Date;
}>,
eventByPromotionId: ReadonlyMap<string, string>,
): EnrichedShadowActivePairing[] {
// Promotions arrive in any order; sort once ascending so the
// `find(...)` below picks the earliest match.
const byTimeAsc = promotions
.slice()
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
return pairings.map((p) => {
const lastTs = p.lastSampleAt ? p.lastSampleAt.getTime() : null;
const pr = byTimeAsc.find(
(x) =>
x.fromVariantId === p.activeVariantId &&
(lastTs === null || x.createdAt.getTime() >= lastTs),
);
if (!pr) return { ...p, endedByPromotion: null };
return {
...p,
endedByPromotion: {
promotionId: pr.id,
reason: pr.reason,
toVariantId: pr.toVariantId,
createdAt: pr.createdAt,
eventId: eventByPromotionId.get(pr.id) ?? null,
},
};
});
}
/**
* Per-network roll-up of "what's the candidate currently being shadow-
* tested doing?" — sample N, mean active vs shadow score, win delta,
* critical-signal count, budget-skipped %, plus links to the most
* recent shadow_started / shadow_runner_error / shadow_budget_skipped
* events for triage. Used by the Evolution Live admin page so an
* operator can see at a glance whether `attemptAutoPromote` is likely
* to fire (or whether the candidate is silently regressing).
*
* Returns one row per network with at least one shadow sample. Networks
* with no shadow samples yet are still included with a null `summary`
* so the operator can confirm a fresh deploy is wired up but quiet.
*
* Each pairing in `summary.activePairings` is enriched with the
* promotion event (if any) that ended it, so the UI can render the
* promotion timestamp inline with the pairing chip and visually
* separate auto-promote boundaries from rollback boundaries.
*/
const getShadowOverview: RequestHandler = async (_req, res) => {
try {
const nets = await db
.select()
.from(toolNetworks)
.orderBy(desc(toolNetworks.updatedAt));
const since = new Date(
Date.now() - SHADOW_OVERVIEW_WINDOW_DAYS * 24 * 60 * 60 * 1000,
);
const items = await Promise.all(
nets.map(async (n) => {
const shadowVariantId = await getMostRecentShadowVariantId(n.id);
let summary: (Omit<ShadowSummary, "activePairings"> & {
activePairings: EnrichedShadowActivePairing[];
}) | null = null;
let budgetSkippedPct: number | null = null;
if (shadowVariantId) {
const raw = await summariseShadow(n.id, shadowVariantId);
// Promotions and their matching events for the same window
// we summarised. Both reads run in parallel because they hit
// independent indexes.
const [promos, promoEvents] = await Promise.all([
db
.select({
id: networkPromotions.id,
fromVariantId: networkPromotions.fromVariantId,
toVariantId: networkPromotions.toVariantId,
reason: networkPromotions.reason,
createdAt: networkPromotions.createdAt,
})
.from(networkPromotions)
.where(
and(
eq(networkPromotions.networkId, n.id),
gte(networkPromotions.createdAt, since),
),
)
.orderBy(asc(networkPromotions.createdAt)),
db
.select({
id: networkEvolutionEvents.id,
promotionId: networkEvolutionEvents.promotionId,
})
.from(networkEvolutionEvents)
.where(
and(
eq(networkEvolutionEvents.networkId, n.id),
inArray(networkEvolutionEvents.kind, ["promote", "rollback"]),
gte(networkEvolutionEvents.createdAt, since),
),
),
]);
const eventByPromotionId = new Map<string, string>();
for (const e of promoEvents) {
if (e.promotionId) eventByPromotionId.set(e.promotionId, e.id);
}
const enrichedPairings = enrichPairingsWithPromotions(
raw.activePairings,
promos,
eventByPromotionId,
);
summary = { ...raw, activePairings: enrichedPairings };
const total = summary.sampleCount + summary.budgetSkippedCount;
budgetSkippedPct = total > 0 ? summary.budgetSkippedCount / total : 0;
}
// Pull the most recent triage events independently so a network
// that has never had a runner error still gets an empty list
// for that kind, rather than a silent "no events" merger.
const [shadowStarted, runnerError, budgetSkipped] = await Promise.all([
listEvents({ networkId: n.id, kind: "shadow_started", limit: 5 }),
listEvents({
networkId: n.id,
kind: "shadow_runner_error",
limit: 5,
}),
listEvents({
networkId: n.id,
kind: "shadow_budget_skipped",
limit: 5,
}),
]);
return {
networkId: n.id,
networkName: n.name,
problemClassPath: n.problemClassPath,
activeVariantId: n.activeVariantId,
shadowVariantId,
summary,
winDelta: summary ? summary.deltaMean : null,
budgetSkippedPct,
events: {
shadow_started: shadowStarted,
shadow_runner_error: runnerError,
shadow_budget_skipped: budgetSkipped,
},
};
}),
);
res.json({ items });
} catch (err) {
logger.error({ err }, "evolution: shadow overview failed");
sendError(res, err, "failed_to_load_shadow_overview");
}
};
const getPromotions: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
try {
await requireNetwork(networkId);
const rows = await db
.select()
.from(networkPromotions)
.where(eq(networkPromotions.networkId, networkId))
.orderBy(desc(networkPromotions.createdAt))
.limit(50);
res.json({ promotions: rows });
} catch (err) {
logger.error({ err, networkId }, "evolution: promotions failed");
sendError(res, err, "failed_to_list_promotions");
}
};
const getRegression: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
try {
await requireNetwork(networkId);
const samples = await listSamples(networkId, "active");
res.json({ samples });
} catch (err) {
logger.error({ err, networkId }, "evolution: regression list failed");
sendError(res, err, "failed_to_list_regression");
}
};
const postRegression: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const body = (req.body ?? {}) as {
problemClassPath?: unknown;
label?: unknown;
inputPayload?: unknown;
expectedFloor?: unknown;
expectedShape?: unknown;
};
if (typeof body.problemClassPath !== "string" || !body.problemClassPath) {
return res.status(400).json({ error: "problemClassPath_required" });
}
if (!body.inputPayload || typeof body.inputPayload !== "object") {
return res.status(400).json({ error: "inputPayload_required" });
}
try {
const row = await archiveSample({
networkId,
problemClassPath: body.problemClassPath,
label: typeof body.label === "string" ? body.label : "",
inputPayload: body.inputPayload as Record<string, unknown>,
expectedFloor:
typeof body.expectedFloor === "number" ? body.expectedFloor : undefined,
expectedShape:
body.expectedShape && typeof body.expectedShape === "object"
? (body.expectedShape as Record<string, unknown>)
: undefined,
createdBy: req.user?.id ?? "admin",
});
res.status(201).json({ sample: row });
} catch (err) {
logger.error({ err, networkId }, "evolution: regression archive failed");
res.status(500).json({ error: "failed_to_archive_sample" });
}
};
const postEvaluate: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
try {
const evalResult = await evaluateTriggers(networkId);
res.json({ evaluation: evalResult });
} catch (err) {
logger.error({ err, networkId }, "evolution: evaluate failed");
res.status(500).json({ error: "failed_to_evaluate" });
}
};
const postPromote: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const body = (req.body ?? {}) as {
candidateVariantId?: unknown;
minSamplesOverride?: unknown;
};
if (typeof body.candidateVariantId !== "string" || !body.candidateVariantId) {
return res.status(400).json({ error: "candidateVariantId_required" });
}
try {
const decision = await attemptAutoPromote({
networkId,
candidateVariantId: body.candidateVariantId,
actor: req.user?.id ? `admin:${req.user.id}` : "admin_manual",
minSamplesOverride:
typeof body.minSamplesOverride === "number"
? body.minSamplesOverride
: undefined,
});
res.json({ decision });
} catch (err) {
logger.error({ err, networkId }, "evolution: promote failed");
res.status(500).json({ error: "failed_to_promote" });
}
};
const postRollbackTick: RequestHandler = async (_req, res) => {
try {
const results = await tickRollbackWatch();
res.json({ results });
} catch (err) {
logger.error({ err }, "evolution: rollback tick failed");
res.status(500).json({ error: "failed_to_tick_rollback" });
}
};
/**
* Task #228 — admin 切换 evolutionStrategy 端点。
*
* PATCH /admin/evolution/networks/:networkId/strategy { strategy: string }
*
* 行为:
* 1. 校验 strategy 是已注册策略名;不在注册表 → 400 unknown_strategy
* (列出可选名给前端做下拉提示)。
* 2. 把 tool_networks.config.evolutionStrategy 写成新值
* (merge, 不动其他 config 字段)。
* 3. 写一条 strategy_changed 事件, payload = {from, to, actorId},
* Builder Proposals 卡片 / 审计回放可读到。
* 4. 切换不影响已存在 candidate; 下一次 scheduler-tick 才会用新策略。
*/
/**
* Task #228 — 提出 PATCH /strategy 入参校验为可单测的纯函数。
*
* 把"body 形状 + 策略名注册表查"两个可纯函数化的检查从 handler 抽出来,
* 便于在 routes/__tests__ 里不起 DB 也能回归 (a) 缺 strategy 字段,
* (b) strategy 字段非字符串, (c) 未注册策略名 → 列出可选名 这三条契约。
*
* Handler 仍要做 requireNetwork → db.update → recordEvent 这些有副作用
* 的步骤, 但那些走另外的 e2e 测试覆盖。
*/
export interface PatchStrategyValidation {
ok: boolean;
status: number;
body: { error?: string; available?: string[]; strategy?: string };
}
export function validatePatchStrategyBody(
raw: unknown,
): PatchStrategyValidation {
const body = (raw ?? {}) as { strategy?: unknown };
if (typeof body.strategy !== "string" || body.strategy.length === 0) {
return { ok: false, status: 400, body: { error: "strategy_required" } };
}
const target = body.strategy;
if (!getEvolutionStrategy(target)) {
return {
ok: false,
status: 400,
body: {
error: "unknown_strategy",
available: listEvolutionStrategyNames(),
},
};
}
return { ok: true, status: 200, body: { strategy: target } };
}
const patchStrategy: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const validation = validatePatchStrategyBody(req.body);
if (!validation.ok) {
return res.status(validation.status).json(validation.body);
}
const target = validation.body.strategy as string;
try {
const network = await requireNetwork(networkId);
const priorCfg = (network.config ?? {}) as Record<string, unknown>;
const from =
typeof priorCfg["evolutionStrategy"] === "string"
? (priorCfg["evolutionStrategy"] as string)
: DEFAULT_EVOLUTION_STRATEGY;
if (from === target) {
return res.json({
ok: true,
strategy: target,
unchanged: true,
available: listEvolutionStrategyNames(),
});
}
const merged = { ...priorCfg, evolutionStrategy: target };
await db
.update(toolNetworks)
.set({ config: merged, updatedAt: new Date() })
.where(eq(toolNetworks.id, networkId));
try {
await recordEvent({
networkId,
kind: "strategy_changed",
payload: {
from,
to: target,
actorId: req.user?.id ?? "admin_manual",
},
});
} catch (eventErr) {
// 写事件失败不应回滚 config 更新 — 事件流是观测层, 不是事实层。
logger.warn(
{ err: eventErr, networkId, from, to: target },
"evolution: strategy_changed event write failed (suppressed)",
);
}
res.json({
ok: true,
strategy: target,
from,
available: listEvolutionStrategyNames(),
});
} catch (err) {
logger.error({ err, networkId, target }, "evolution: patch strategy failed");
sendError(res, err, "failed_to_patch_strategy");
}
};
const postSchedulerTick: RequestHandler = async (_req, res) => {
try {
const summary = await runEvolutionTick();
res.json({ summary });
} catch (err) {
logger.error({ err }, "evolution: scheduler tick failed");
res.status(500).json({ error: "failed_to_run_scheduler_tick" });
}
};
const postRunSuite: RequestHandler = async (req, res) => {
const { networkId } = req.params as { networkId: string };
const body = (req.body ?? {}) as { variantId?: unknown };
if (typeof body.variantId !== "string" || !body.variantId) {
return res.status(400).json({ error: "variantId_required" });
}
try {
const result = await runSuiteAgainstVariant(networkId, body.variantId);
res.json({ result });
} catch (err) {
logger.error({ err, networkId }, "evolution: regression run failed");
res.status(500).json({ error: "failed_to_run_suite" });
}
};
/**
* Wave B step 2 — recent builder activity timeline.
*
* Returns the last `limit` `builder_proposed` / `builder_no_candidate` /
* `builder_error` events emitted by `runEvolutionBuilder`. Optionally
* filters by network. The admin Evolution Live page renders the
* stream as a small "Builder Proposals" card so operators can see
* which strategies are firing, what configs they emit, and which
* networks have stalled (`no_candidate`) or are throwing
* (`builder_error`).
*
* Read-only and cheap: pulls each kind in parallel and merges. The
* three independent `listEvents` calls each cap at 200 server-side,
* so the worst case is 600 rows merged + sliced — well below any
* sensible page payload.
*/
const getBuilderEvents: RequestHandler = async (req, res) => {
const networkId =
typeof req.query.networkId === "string" ? req.query.networkId : undefined;
const limit = clampInt(req.query.limit, 50, 1, 200);
// Time window: default to last 24h so the admin card never balloons
// across a long-running deploy. Operators can widen via ?hours=72
// (capped at 168 = one week) or pin a custom point with
// ?since=<ISO timestamp>. `since` wins if both are supplied.
const hours = clampInt(req.query.hours, 24, 1, 168);
let cutoffMs = Date.now() - hours * 60 * 60 * 1000;
if (typeof req.query.since === "string" && req.query.since.length > 0) {
const parsed = Date.parse(req.query.since);
if (Number.isFinite(parsed)) {
cutoffMs = parsed;
}
}
try {
if (networkId) {
await requireNetwork(networkId);
}
const [proposed, none, errored] = await Promise.all([
listEvents({ networkId, kind: "builder_proposed", limit }),
listEvents({ networkId, kind: "builder_no_candidate", limit }),
listEvents({ networkId, kind: "builder_error", limit }),
]);
const events = [...proposed, ...none, ...errored]
.filter((e) => e.createdAt.getTime() >= cutoffMs)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
.slice(0, limit);
res.json({ events, windowHours: hours, since: new Date(cutoffMs).toISOString() });
} catch (err) {
logger.error({ err, networkId }, "evolution: builder events failed");
sendError(res, err, "failed_to_list_builder_events");
}
};
router.get("/admin/evolution/networks", ...adminGuard, listNetworks);
router.get(
"/admin/evolution/networks/:networkId/fitness",
...adminGuard,
getFitness,
);
router.get(
"/admin/evolution/networks/:networkId/events",
...adminGuard,
getEvents,
);
router.get(
"/admin/evolution/networks/:networkId/budget-trend",
...adminGuard,
getBudgetTrend,
);
router.get(
"/admin/evolution/networks/:networkId/shadow",
...adminGuard,
getShadow,
);
router.get(
"/admin/evolution/shadow-overview",
...adminGuard,
getShadowOverview,
);
router.get(
"/admin/evolution/networks/:networkId/promotions",
...adminGuard,
getPromotions,
);
router.get(
"/admin/evolution/networks/:networkId/regression",
...adminGuard,
getRegression,
);
router.post(
"/admin/evolution/networks/:networkId/regression",
...adminGuard,
postRegression,
);
router.post(
"/admin/evolution/networks/:networkId/regression/run",
...adminGuard,
postRunSuite,
);
router.post(
"/admin/evolution/networks/:networkId/triggers/evaluate",
...adminGuard,
postEvaluate,
);
router.post(
"/admin/evolution/networks/:networkId/promote",
...adminGuard,
postPromote,
);
router.patch(
"/admin/evolution/networks/:networkId/strategy",
...adminGuard,
patchStrategy,
);
router.post(
"/admin/evolution/rollback-tick",
...adminGuard,
postRollbackTick,
);
router.post(
"/admin/evolution/scheduler-tick",
...adminGuard,
postSchedulerTick,
);
router.get(
"/admin/evolution/builder-events",
...adminGuard,
getBuilderEvents,
);
export default router;
// Test-only handler exports — surfaced so route unit tests can call
// each one without spinning up the auth middleware chain.
export const _handlers = {
listNetworks,
getFitness,
getEvents,
getBudgetTrend,
getShadow,
getShadowOverview,
getPromotions,
getRegression,
postRegression,
postEvaluate,
postPromote,
postRollbackTick,
postSchedulerTick,
postRunSuite,
getBuilderEvents,
patchStrategy,
};