File size: 3,821 Bytes
ff78003 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | /**
* Wave B — append-only evolution event log helper.
*
* Every Wave B mechanism (triggers, shadow A/B, auto-promote, rollback)
* writes one or more rows here. The "Evolution Live" admin page reads
* the same stream and renders it as a per-network timeline; the
* watchdog and CLI tools also pivot off `kind` for cheap filtering.
*
* The helper deliberately exposes only `recordEvent` and `listEvents`
* — direct table access from feature code is discouraged so the event
* vocabulary stays centralised.
*/
import { and, desc, eq } from "drizzle-orm";
import {
db,
networkEvolutionEvents,
type NetworkEvolutionEventRow,
} from "@workspace/db";
import { newId } from "../ids";
export type EvolutionEventKind =
| "cadence_trigger"
| "regression_trigger"
| "coverage_trigger"
| "shadow_started"
| "shadow_budget_skipped"
| "shadow_budget_threshold"
| "shadow_runner_error"
| "shadow_reviewer_fallback"
| "shadow_no_active_cost"
| "auto_promote_skipped"
| "promote"
| "rollback"
| "regression_suite_failed"
// Wave B step 2 — generic builder produced (or declined to produce)
// a new shadow candidate. The scheduler tick writes one of these
// every time a trigger fires; admin "Builder Proposals" panel reads
// them straight off the events stream. Vocabulary stays here so the
// central event type guards the whole flywheel.
| "builder_proposed"
| "builder_no_candidate"
| "builder_error"
// Task #228 — admin 切换 tool_networks.config.evolutionStrategy 时记录的
// 单条审计事件。payload 至少包含 {from, to, actorId}, 让 Builder Proposals
// 卡片 / 审计回放可以复盘"是谁、在什么时间把这个网络切到了 fitness_guided"。
| "strategy_changed"
// Task #227 — competition feedback flywheel
// external_truth_backfilled: emitted by /admin/competition/feedback/import
// when the operator confirms a write (one row per affected network).
// external_truth_trigger: emitted by evaluateTriggers as the consumption
// marker — when a backfill event exists newer than the last consumption,
// the trigger fires so scheduler treats it like a cadence/regression
// wakeup and schedules a fresh fitness re-eval / candidate proposal.
| "external_truth_backfilled"
| "external_truth_trigger";
export interface RecordEventInput {
networkId: string;
kind: EvolutionEventKind;
variantId?: string | null;
payload?: Record<string, unknown>;
relatedEventId?: string | null;
promotionId?: string | null;
}
export async function recordEvent(
input: RecordEventInput,
): Promise<NetworkEvolutionEventRow> {
const id = newId("nevt");
await db.insert(networkEvolutionEvents).values({
id,
networkId: input.networkId,
kind: input.kind,
variantId: input.variantId ?? null,
payload: (input.payload ?? {}) as Record<string, unknown>,
relatedEventId: input.relatedEventId ?? null,
promotionId: input.promotionId ?? null,
});
return (
await db
.select()
.from(networkEvolutionEvents)
.where(eq(networkEvolutionEvents.id, id))
.limit(1)
)[0]!;
}
export interface ListEventsOptions {
networkId?: string;
kind?: EvolutionEventKind;
limit?: number;
}
export async function listEvents(
opts: ListEventsOptions = {},
): Promise<NetworkEvolutionEventRow[]> {
const conds = [];
if (opts.networkId) {
conds.push(eq(networkEvolutionEvents.networkId, opts.networkId));
}
if (opts.kind) {
conds.push(eq(networkEvolutionEvents.kind, opts.kind));
}
const where = conds.length ? and(...conds) : undefined;
const q = db
.select()
.from(networkEvolutionEvents)
.orderBy(desc(networkEvolutionEvents.createdAt))
.limit(Math.min(opts.limit ?? 200, 1000));
return where ? q.where(where) : q;
}
|