/** * /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[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; 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; 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, promotions: ReadonlyArray<{ id: string; fromVariantId: string | null; toVariantId: string; reason: string; createdAt: Date; }>, eventByPromotionId: ReadonlyMap, ): 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 & { 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(); 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, expectedFloor: typeof body.expectedFloor === "number" ? body.expectedFloor : undefined, expectedShape: body.expectedShape && typeof body.expectedShape === "object" ? (body.expectedShape as Record) : 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; 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=. `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, };