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;
}