| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import { and, eq, isNull } from "drizzle-orm"; |
| import { db, networkVersions, toolNetworks } from "@workspace/db"; |
| import { logger } from "../logger"; |
| import { evaluateTriggers, type TriggerEvaluation } from "./triggers"; |
| import { |
| attemptAutoPromote, |
| type AutoPromoteDecision, |
| } from "./promote"; |
| import { tickRollbackWatch, type MonitorResult } from "./rollback"; |
| import { runEvolutionBuilder, type BuilderResult } from "./builder"; |
|
|
| export const DEFAULT_EVOLUTION_TICK_MS = 5 * 60 * 1000; |
|
|
| export interface EvolutionTickOptions { |
| |
| |
| |
| |
| |
| |
| maxCandidatesPerNetwork?: number; |
| } |
|
|
| export interface EvolutionTickNetworkSummary { |
| networkId: string; |
| networkName: string; |
| evaluation?: Pick<TriggerEvaluation, "anyFired" | "signals">; |
| |
| |
| |
| |
| |
| |
| builder?: BuilderResult; |
| promoteDecisions: AutoPromoteDecision[]; |
| errors: string[]; |
| } |
|
|
| export interface EvolutionTickSummary { |
| startedAt: string; |
| finishedAt: string; |
| durationMs: number; |
| networksScanned: number; |
| triggersFired: number; |
| |
| |
| |
| |
| |
| |
| |
| candidatesProposed: number; |
| promotionsAttempted: number; |
| promoted: number; |
| rolledBack: number; |
| perNetwork: EvolutionTickNetworkSummary[]; |
| rollback: MonitorResult[]; |
| errors: string[]; |
| } |
|
|
| |
| |
| |
| |
| |
| export async function runEvolutionTick( |
| opts: EvolutionTickOptions = {}, |
| ): Promise<EvolutionTickSummary> { |
| const startedAtMs = Date.now(); |
| const startedAt = new Date(startedAtMs).toISOString(); |
| const maxCandidates = opts.maxCandidatesPerNetwork ?? 5; |
|
|
| const summary: EvolutionTickSummary = { |
| startedAt, |
| finishedAt: startedAt, |
| durationMs: 0, |
| networksScanned: 0, |
| triggersFired: 0, |
| candidatesProposed: 0, |
| promotionsAttempted: 0, |
| promoted: 0, |
| rolledBack: 0, |
| perNetwork: [], |
| rollback: [], |
| errors: [], |
| }; |
|
|
| let networks: Array<{ id: string; name: string }> = []; |
| try { |
| networks = await db |
| .select({ id: toolNetworks.id, name: toolNetworks.name }) |
| .from(toolNetworks) |
| .where(eq(toolNetworks.status, "active")); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| summary.errors.push(`list_networks_failed: ${msg}`); |
| logger.error({ err }, "evolution scheduler: failed to list networks"); |
| } |
|
|
| for (const net of networks) { |
| summary.networksScanned += 1; |
| const perNet: EvolutionTickNetworkSummary = { |
| networkId: net.id, |
| networkName: net.name, |
| promoteDecisions: [], |
| errors: [], |
| }; |
|
|
| let evaluation: TriggerEvaluation | null = null; |
| try { |
| evaluation = await evaluateTriggers(net.id); |
| perNet.evaluation = { |
| anyFired: evaluation.anyFired, |
| signals: evaluation.signals, |
| }; |
| if (evaluation.anyFired) { |
| summary.triggersFired += 1; |
| } |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| perNet.errors.push(`evaluate_failed: ${msg}`); |
| logger.error( |
| { err, networkId: net.id }, |
| "evolution scheduler: evaluateTriggers failed", |
| ); |
| } |
|
|
| if (evaluation?.anyFired) { |
| |
| |
| |
| |
| |
| |
| try { |
| const builderResult = await runEvolutionBuilder({ |
| networkId: net.id, |
| reason: "scheduler_tick", |
| }); |
| perNet.builder = builderResult; |
| if (builderResult.status === "proposed") { |
| summary.candidatesProposed += 1; |
| } |
| } catch (err) { |
| |
| |
| |
| const msg = err instanceof Error ? err.message : String(err); |
| perNet.errors.push(`builder_threw: ${msg}`); |
| logger.error( |
| { err, networkId: net.id }, |
| "evolution scheduler: runEvolutionBuilder threw (contract violation)", |
| ); |
| } |
|
|
| |
| |
| |
| let candidates: Array<{ id: string }> = []; |
| try { |
| candidates = await db |
| .select({ id: networkVersions.id }) |
| .from(networkVersions) |
| .where( |
| and( |
| eq(networkVersions.networkId, net.id), |
| eq(networkVersions.status, "shadow"), |
| isNull(networkVersions.privateNamespace), |
| ), |
| ); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| perNet.errors.push(`list_candidates_failed: ${msg}`); |
| logger.error( |
| { err, networkId: net.id }, |
| "evolution scheduler: failed to list shadow candidates", |
| ); |
| } |
|
|
| const limited = candidates.slice(0, maxCandidates); |
| for (const cand of limited) { |
| summary.promotionsAttempted += 1; |
| try { |
| const decision = await attemptAutoPromote({ |
| networkId: net.id, |
| candidateVariantId: cand.id, |
| actor: "scheduler", |
| }); |
| perNet.promoteDecisions.push(decision); |
| if ( |
| decision.outcome === "promoted" || |
| decision.outcome === "promoted_private" |
| ) { |
| summary.promoted += 1; |
| } |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| perNet.errors.push(`promote_failed:${cand.id}:${msg}`); |
| logger.warn( |
| { err, networkId: net.id, candidateVariantId: cand.id }, |
| "evolution scheduler: attemptAutoPromote threw", |
| ); |
| } |
| } |
| } |
|
|
| summary.perNetwork.push(perNet); |
| } |
|
|
| |
| |
| try { |
| const rollback = await tickRollbackWatch(); |
| summary.rollback = rollback; |
| summary.rolledBack = rollback.filter( |
| (r) => r.outcome === "rolled_back", |
| ).length; |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| summary.errors.push(`rollback_tick_failed: ${msg}`); |
| logger.error( |
| { err }, |
| "evolution scheduler: tickRollbackWatch failed", |
| ); |
| } |
|
|
| const finishedAtMs = Date.now(); |
| summary.finishedAt = new Date(finishedAtMs).toISOString(); |
| summary.durationMs = finishedAtMs - startedAtMs; |
| return summary; |
| } |
|
|
| export interface StartEvolutionSchedulerOptions { |
| |
| intervalMs?: number; |
| |
| runOnStart?: boolean; |
| |
| tick?: EvolutionTickOptions; |
| } |
|
|
| export interface EvolutionSchedulerHandle { |
| stop: () => void; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function startEvolutionScheduler( |
| opts: StartEvolutionSchedulerOptions = {}, |
| ): EvolutionSchedulerHandle { |
| if (process.env["NODE_ENV"] === "test") { |
| return { stop: () => {} }; |
| } |
| if (process.env["EVOLUTION_SCHEDULER_DISABLED"] === "1") { |
| logger.info( |
| "evolution scheduler: disabled via EVOLUTION_SCHEDULER_DISABLED=1", |
| ); |
| return { stop: () => {} }; |
| } |
|
|
| const envInterval = Number(process.env["EVOLUTION_SCHEDULER_INTERVAL_MS"]); |
| const intervalMs = |
| opts.intervalMs ?? |
| (Number.isFinite(envInterval) && envInterval >= 1000 |
| ? envInterval |
| : DEFAULT_EVOLUTION_TICK_MS); |
|
|
| let stopped = false; |
| let inFlight = false; |
|
|
| const fire = async () => { |
| if (stopped) return; |
| if (inFlight) { |
| logger.warn( |
| { kind: "evolution_scheduler_tick" }, |
| "evolution scheduler: previous tick still in flight, skipping", |
| ); |
| return; |
| } |
| inFlight = true; |
| try { |
| const summary = await runEvolutionTick(opts.tick ?? {}); |
| logger.info( |
| { |
| kind: "evolution_scheduler_tick", |
| networksScanned: summary.networksScanned, |
| triggersFired: summary.triggersFired, |
| candidatesProposed: summary.candidatesProposed, |
| promotionsAttempted: summary.promotionsAttempted, |
| promoted: summary.promoted, |
| rolledBack: summary.rolledBack, |
| durationMs: summary.durationMs, |
| errors: summary.errors, |
| }, |
| "evolution scheduler: tick complete", |
| ); |
| } catch (err) { |
| logger.error( |
| { err, kind: "evolution_scheduler_tick" }, |
| "evolution scheduler: tick threw", |
| ); |
| } finally { |
| inFlight = false; |
| } |
| }; |
|
|
| const timer = setInterval(() => { |
| void fire(); |
| }, intervalMs); |
| if (typeof timer.unref === "function") timer.unref(); |
|
|
| if (opts.runOnStart !== false) { |
| |
| |
| setTimeout(() => { |
| void fire(); |
| }, 0).unref?.(); |
| } |
|
|
| logger.info( |
| { intervalMs, kind: "evolution_scheduler_start" }, |
| "evolution scheduler: started", |
| ); |
|
|
| return { |
| stop: () => { |
| stopped = true; |
| clearInterval(timer); |
| }, |
| }; |
| } |
|
|