UNPKG

autosnippet

Version:

Extract code patterns into a knowledge base for AI coding assistants

398 lines (397 loc) 16.4 kB
/** * RecipeLifecycleSupervisor — 统一状态转移管理层 * * 核心职责: * 1. Guard 前置检查 — 验证转移是否合法(VALID_TRANSITIONS + 扩展条件) * 2. Entry/Exit Actions — 进入/离开状态的固定副作用 * 3. Event 记录 — 每次转移记录为不可变 TransitionEvent * 4. 超时监控 — 中间态超时自动处理 * 5. 健康摘要 — 全局状态分布与卡死检测 * * 所有状态变更建议通过 Supervisor,目前作为可选增强层存在。 * 不改变现有 ProposalExecutor/StagingManager 的内部逻辑, * 而是在它们之上提供审计、超时检查和健康监控。 * * 触发时机:UiStartupTasks Stage 6(新增) * * @module service/evolution/RecipeLifecycleSupervisor */ import { randomUUID } from 'node:crypto'; import { isValidTransition } from '../../domain/knowledge/Lifecycle.js'; import Logger from '../../infrastructure/logging/Logger.js'; /* ────────────────────── Types ────────────────────── */ /* ────────────────────── Constants ────────────────────── */ /** 中间态超时配置(毫秒) */ const TIMEOUT_MS = { evolving: 7 * 24 * 60 * 60 * 1000, // 7 天 decaying: 30 * 24 * 60 * 60 * 1000, // 30 天 staging: 7 * 24 * 60 * 60 * 1000, // 7 天(安全兜底,正常由 StagingManager 处理) pending: 30 * 24 * 60 * 60 * 1000, // 30 天 }; /** 超时后的目标状态 */ const TIMEOUT_TARGET = { evolving: 'active', // 回退到 active(内容不变) decaying: 'deprecated', // 长期衰退 → 废弃 pending: 'deprecated', // 30 天未审核 → 废弃 }; /** 卡死告警阈值(毫秒) */ const STUCK_THRESHOLD_MS = { evolving: 3 * 24 * 60 * 60 * 1000, // > 3天 decaying: 15 * 24 * 60 * 60 * 1000, // > 15天 staging: 3 * 24 * 60 * 60 * 1000, // > 3天 pending: 7 * 24 * 60 * 60 * 1000, // > 7天 }; /* ────────────────────── Entry/Exit Action Types ────────────────────── */ /** 进入状态时写入 stats 的元数据键 */ const ENTRY_META_KEYS = { staging: 'stagingEnteredAt', evolving: 'evolvingStartedAt', decaying: 'decayStartedAt', active: 'activeSince', }; /* ────────────────────── Class ────────────────────── */ export class RecipeLifecycleSupervisor { #knowledgeRepo; #proposalRepo; #lifecycleEventRepo; #signalBus; #logger = Logger.getInstance(); constructor(knowledgeRepo, options = {}) { this.#knowledgeRepo = knowledgeRepo; this.#proposalRepo = options.proposalRepo ?? null; this.#lifecycleEventRepo = options.lifecycleEventRepo ?? null; this.#signalBus = options.signalBus ?? null; } /* ═══════════════════ Core Transition ═══════════════════ */ /** * 执行状态转移 — 统一入口 * * 1. 获取当前状态 * 2. Guard 检查(合法转移 + 扩展条件) * 3. Exit Action(离开旧状态) * 4. 更新 lifecycle * 5. Entry Action(进入新状态) * 6. 记录 TransitionEvent * 7. 发射信号 */ async transition(request) { const { recipeId, targetState, trigger, evidence, proposalId, operatorId } = request; const opId = operatorId ?? 'system'; // 1. 获取当前状态 const current = await this.#getRecipeState(recipeId); if (!current) { return { success: false, fromState: 'unknown', toState: targetState, error: 'Recipe not found', }; } const fromState = current.lifecycle; // 2. Guard 检查 if (!isValidTransition(fromState, targetState)) { this.#logger.warn(`[Supervisor] Invalid transition: ${recipeId} ${fromState} → ${targetState} (trigger: ${trigger})`); return { success: false, fromState, toState: targetState, error: `Invalid transition: ${fromState} → ${targetState}`, }; } // 3. Exit Action await this.#executeExitAction(recipeId, fromState); // 4. 更新 lifecycle const now = Date.now(); await this.#knowledgeRepo.updateLifecycle(recipeId, targetState); // 5. Entry Action await this.#executeEntryAction(recipeId, targetState, now, proposalId); // 6. 记录 TransitionEvent const event = this.#recordEvent({ recipeId, fromState, toState: targetState, trigger, evidence: evidence ?? null, proposalId: proposalId ?? null, operatorId: opId, createdAt: now, }); // 7. 发射信号 this.#emitSignal(recipeId, fromState, targetState, trigger); this.#logger.info(`[Supervisor] ${recipeId}: ${fromState} → ${targetState} (trigger: ${trigger})`); return { success: true, fromState, toState: targetState, event }; } /* ═══════════════════ Timeout Check ═══════════════════ */ /** * 检查中间态超时 + 自动处理 * * 处理范围: * - evolving > 7d → active(回退) * - decaying > 30d → deprecated */ async checkTimeouts() { const result = { timedOut: [], checked: 0 }; const now = Date.now(); for (const [state, timeoutMs] of Object.entries(TIMEOUT_MS)) { if (!(state in TIMEOUT_TARGET)) { continue; } const targetState = TIMEOUT_TARGET[state]; const entries = await this.#knowledgeRepo.findAllByLifecycles([state]); result.checked += entries.length; for (const entry of entries) { const stats = (entry.stats ?? {}); const entryKey = ENTRY_META_KEYS[state]; const enteredAt = (entryKey ? stats[entryKey] : null); const stateAge = enteredAt ? now - enteredAt : await this.#getRecipeAge(entry.id, now); if (stateAge > timeoutMs) { const transitionResult = await this.transition({ recipeId: entry.id, targetState, trigger: 'timeout-recovery', evidence: { reason: `${state} timeout after ${Math.round(stateAge / (24 * 60 * 60 * 1000))}d`, }, }); if (transitionResult.success) { result.timedOut.push({ recipeId: entry.id, fromState: state, toState: targetState, age: stateAge, }); } } } } if (result.timedOut.length > 0) { this.#logger.info(`[Supervisor] Timeout check: ${result.timedOut.length} recipes timed out (checked: ${result.checked})`); } return result; } /* ═══════════════════ Query ═══════════════════ */ /** * 查询 Recipe 的转移历史 */ getTransitionHistory(recipeId, limit = 50) { try { if (!this.#lifecycleEventRepo) { return []; } return this.#lifecycleEventRepo.getHistory(recipeId, limit); } catch { // 表可能不存在(migration 未运行) return []; } } /** * 获取全局状态健康摘要 */ async getHealthSummary() { const now = Date.now(); const stateDistribution = await this.#getStateDistribution(); const intermediateStates = { stuckEvolving: await this.#getStuckInfo('evolving', STUCK_THRESHOLD_MS.evolving, now), stuckDecaying: await this.#getStuckInfo('decaying', STUCK_THRESHOLD_MS.decaying, now), stuckStaging: await this.#getStuckInfo('staging', STUCK_THRESHOLD_MS.staging, now), stuckPending: await this.#getStuckInfo('pending', STUCK_THRESHOLD_MS.pending, now), }; // 最近转移统计 const recentTransitions = this.#getRecentTransitionStats(now); // Proposal 指标 const proposalMetrics = this.#getProposalMetrics(); return { stateDistribution, intermediateStates, recentTransitions, proposalMetrics }; } /* ═══════════════════ Entry/Exit Actions ═══════════════════ */ async #executeEntryAction(recipeId, state, now, proposalId) { const metaKey = ENTRY_META_KEYS[state]; if (!metaKey) { return; } const entry = await this.#knowledgeRepo.findById(recipeId); const stats = (entry?.stats ?? {}); stats[metaKey] = now; if (state === 'evolving' && proposalId) { stats.evolvingProposalId = proposalId; } if (state === 'active') { delete stats.evolvingStartedAt; delete stats.evolvingProposalId; delete stats.decayStartedAt; } if (state === 'deprecated') { stats.deprecatedAt = now; } await this.#knowledgeRepo.update(recipeId, { stats }); } async #executeExitAction(recipeId, state) { if (state === 'active') { const entry = await this.#knowledgeRepo.findById(recipeId); const stats = (entry?.stats ?? {}); stats.lastActiveAt = Date.now(); await this.#knowledgeRepo.update(recipeId, { stats }); } } /* ═══════════════════ Event Recording ═══════════════════ */ #recordEvent(params) { const id = randomUUID(); const event = { id, recipeId: params.recipeId, fromState: params.fromState, toState: params.toState, trigger: params.trigger, evidence: params.evidence, proposalId: params.proposalId, operatorId: params.operatorId, createdAt: params.createdAt, }; try { if (!this.#lifecycleEventRepo) { this.#logger.warn(`[Supervisor] No lifecycleEventRepo available, cannot record transition event`); return event; } this.#lifecycleEventRepo.record({ id, recipeId: params.recipeId, fromState: params.fromState, toState: params.toState, trigger: params.trigger, operatorId: params.operatorId, evidence: params.evidence, proposalId: params.proposalId, createdAt: params.createdAt, }); } catch { // lifecycle_transition_events 表可能不存在(降级容忍) this.#logger.warn(`[Supervisor] Failed to record transition event (table may not exist)`); } return event; } /* ═══════════════════ Health Queries ═══════════════════ */ async #getStateDistribution() { const dist = { pending: 0, staging: 0, active: 0, evolving: 0, decaying: 0, deprecated: 0, }; try { const grouped = await this.#knowledgeRepo.countGroupByLifecycle(); for (const [lifecycle, cnt] of Object.entries(grouped)) { dist[lifecycle] = cnt; } } catch { // fallback } return dist; } async #getStuckInfo(state, thresholdMs, now) { try { const entries = await this.#knowledgeRepo.findAllByLifecycles([state]); let count = 0; let oldestAge = 0; for (const entry of entries) { const stats = (entry.stats ?? {}); const metaKey = ENTRY_META_KEYS[state]; const enteredAt = (metaKey ? stats[metaKey] : null); const age = enteredAt ? now - enteredAt : now - (entry.updatedAt || now); if (age > thresholdMs) { count++; if (age > oldestAge) { oldestAge = age; } } } return { count, oldestAge }; } catch { return { count: 0, oldestAge: 0 }; } } #getRecentTransitionStats(now) { try { if (!this.#lifecycleEventRepo) { return { last24h: 0, last7d: 0, topTriggers: [] }; } const last24hCount = this.#lifecycleEventRepo.countSince(now - 24 * 60 * 60 * 1000); const last7dCount = this.#lifecycleEventRepo.countSince(now - 7 * 24 * 60 * 60 * 1000); const topTriggers = this.#lifecycleEventRepo.topTriggersSince(now - 7 * 24 * 60 * 60 * 1000, 5); return { last24h: last24hCount, last7d: last7dCount, topTriggers }; } catch { return { last24h: 0, last7d: 0, topTriggers: [] }; } } #getProposalMetrics() { try { const statusMap = this.#proposalRepo ? this.#proposalRepo.stats() : {}; const pending = statusMap.pending ?? 0; const observing = statusMap.observing ?? 0; const executed = statusMap.executed ?? 0; const rejected = statusMap.rejected ?? 0; const expired = statusMap.expired ?? 0; const total = executed + rejected + expired; let contentPatchRate = 0; try { if (this.#lifecycleEventRepo) { const patchCount = this.#lifecycleEventRepo.countByTrigger('content-patch-complete'); const execCount = this.#lifecycleEventRepo.countByTriggers([ 'proposal-execution', 'proposal-attach', ]); contentPatchRate = execCount > 0 ? patchCount / execCount : 0; } } catch { // table may not exist yet } return { pendingCount: pending, observingCount: observing, executionRate: total > 0 ? executed / total : 0, avgObservationDays: 0, contentPatchRate, }; } catch { return { pendingCount: 0, observingCount: 0, executionRate: 0, avgObservationDays: 0, contentPatchRate: 0, }; } } /* ═══════════════════ DB Helpers ═══════════════════ */ async #getRecipeState(recipeId) { const entry = await this.#knowledgeRepo.findById(recipeId); return entry ? { lifecycle: entry.lifecycle } : null; } async #getRecipeAge(recipeId, now) { const entry = await this.#knowledgeRepo.findById(recipeId); return entry ? now - (entry.updatedAt || now) : 0; } /* ═══════════════════ Signal ═══════════════════ */ #emitSignal(recipeId, fromState, toState, trigger) { if (!this.#signalBus) { return; } this.#signalBus.send('lifecycle', 'RecipeLifecycleSupervisor', 0.5, { target: recipeId, metadata: { fromState, toState, trigger, }, }); } }