audit-verify, autopromote, histogramas y hot-reload caps
- audit::verify_chain_from_cas() recorre prev_sha desde head hasta genesis, valida sha256(blob)==sha del CAS para detectar tampering. Endpoint VerifyAudit + brainctl audit-verify. - autopromote loop background: cada N segundos detecta cristales sobre threshold y los promueve sin operador. Anti-doble vía HashSet de pares (ant, con). Flag --autopromote-secs. - GapHistogram con buckets exponenciales (1ms..1000s) capturado en observer.record(). top_gap_pairs(K) limita cardinalidad. Expuesto en Prometheus como ente_brain_pair_gap_seconds histogram per pair. - BusRequest::UpdateCapabilities con auth obligatorio (sólo el dueño puede modificar sus caps). Incarnated.dynamic_provides separa runtime de la Card immutable. Graph mediator actualiza providers index + revoca grants. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
IntrospectRequest::ListAudit { limit }
|
||||
}
|
||||
"flush-audit" => IntrospectRequest::FlushAudit,
|
||||
"audit-verify" | "verify" => IntrospectRequest::VerifyAudit,
|
||||
"reload" => {
|
||||
let path = args.get(2).cloned();
|
||||
IntrospectRequest::ReloadRules { path }
|
||||
@@ -130,6 +131,16 @@ fn print_response(r: &IntrospectResponse) {
|
||||
IntrospectResponse::Reloaded { count } => {
|
||||
println!("reload OK: {count} reglas activas tras reload");
|
||||
}
|
||||
IntrospectResponse::AuditVerified(rep) => {
|
||||
if let Some(seq) = rep.broken_at_seq {
|
||||
println!("✗ verificación FALLÓ tras seq={seq}");
|
||||
if let Some(e) = &rep.error { println!(" motivo: {e}"); }
|
||||
println!(" entries verificadas: {}", rep.verified);
|
||||
} else {
|
||||
println!("✓ chain verificada — {} entries íntegras", rep.verified);
|
||||
if let Some(g) = rep.genesis_sha { println!(" genesis: {}", hex_long(g)); }
|
||||
}
|
||||
}
|
||||
IntrospectResponse::Error(e) => eprintln!("error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +164,83 @@ pub struct AuditHeadPointer {
|
||||
pub timestamp_ms: u64,
|
||||
}
|
||||
|
||||
/// Reporte de verificación de la cadena audit.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerificationReport {
|
||||
/// Cuántas entries se recorrieron y verificaron exitosamente.
|
||||
pub verified: u64,
|
||||
/// Si hubo error, el seq donde se detectó.
|
||||
pub broken_at_seq: Option<u64>,
|
||||
/// Detalles del error si hubo.
|
||||
pub error: Option<String>,
|
||||
/// SHA del genesis (primer entry; prev_sha = None).
|
||||
pub genesis_sha: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// Recorre la cadena del audit log desde `start_sha` hacia atrás vía `prev_sha`
|
||||
/// hasta el genesis. Para cada entry valida:
|
||||
/// 1. CAS contiene un blob bajo ese SHA
|
||||
/// 2. sha256(blob) == SHA esperado (defensa contra tampering del CAS)
|
||||
/// 3. El blob deserializa a AuditEntry con sha=[0;32] (forma canónica)
|
||||
///
|
||||
/// Devuelve un VerificationReport con el conteo, posibles errores y
|
||||
/// el SHA del genesis (útil para clientes que quieren cachearlo).
|
||||
pub fn verify_chain_from_cas(start_sha: [u8; 32]) -> VerificationReport {
|
||||
let mut current = Some(start_sha);
|
||||
let mut verified = 0u64;
|
||||
let mut last_seen: Option<AuditEntry> = None;
|
||||
|
||||
while let Some(sha) = current {
|
||||
let path = ente_cas::cas_root().join(ente_cas::hex(&sha));
|
||||
let bytes = match std::fs::read(&path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return VerificationReport {
|
||||
verified,
|
||||
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
|
||||
error: Some(format!("CAS read {}: {e}", path.display())),
|
||||
genesis_sha: None,
|
||||
},
|
||||
};
|
||||
// Verificación 1: el blob hashea a la SHA esperada (CAS contract).
|
||||
let actual = ente_cas::sha256_of(&bytes);
|
||||
if actual != sha {
|
||||
return VerificationReport {
|
||||
verified,
|
||||
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
|
||||
error: Some(format!(
|
||||
"CAS tamper en {}: expected {} got {}",
|
||||
path.display(), ente_cas::hex(&sha), ente_cas::hex(&actual)
|
||||
)),
|
||||
genesis_sha: None,
|
||||
};
|
||||
}
|
||||
// Verificación 2: deserialize. El blob canónico tiene sha=[0;32].
|
||||
let mut entry: AuditEntry = match serde_json::from_slice(&bytes) {
|
||||
Ok(e) => e,
|
||||
Err(e) => return VerificationReport {
|
||||
verified,
|
||||
broken_at_seq: last_seen.as_ref().map(|e| e.seq),
|
||||
error: Some(format!("deserialize: {e}")),
|
||||
genesis_sha: None,
|
||||
},
|
||||
};
|
||||
// Re-poblar el sha en el entry para reportar coherentemente.
|
||||
entry.sha = sha;
|
||||
verified += 1;
|
||||
|
||||
let prev = entry.prev_sha;
|
||||
last_seen = Some(entry);
|
||||
current = prev;
|
||||
}
|
||||
|
||||
VerificationReport {
|
||||
verified,
|
||||
broken_at_seq: None,
|
||||
error: None,
|
||||
genesis_sha: last_seen.as_ref().map(|e| e.sha),
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuditLog {
|
||||
fn default() -> Self { Self::new() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Autopromote loop. Background task que cada N segundos detecta cristales
|
||||
//! con thresholds altos y los promueve sin intervención humana.
|
||||
//!
|
||||
//! Anti-doble-promote: tras promover, registramos en un set la pareja
|
||||
//! (antecedent_kind, consequent_kind). Antes de promover, verificamos que
|
||||
//! no exista ya una regla con el mismo trigger_kind (heurística simple —
|
||||
//! evita ráfagas de duplicados de la misma estadística).
|
||||
|
||||
use crate::audit::AuditAction;
|
||||
use crate::crystallize::{crystal_to_kcl, crystal_to_rule, detect_crystals, Crystal, CrystallizationParams};
|
||||
use crate::introspect::{append_kcl_snippet, BrainState};
|
||||
use crate::rules::EventKind;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AutopromoteParams {
|
||||
pub interval_secs: u64,
|
||||
pub threshold: CrystallizationParams,
|
||||
}
|
||||
|
||||
impl Default for AutopromoteParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
interval_secs: 60,
|
||||
// Más estrictos que el threshold default — evitar ruido.
|
||||
threshold: CrystallizationParams {
|
||||
min_support: 10,
|
||||
min_conditional_prob: 0.85,
|
||||
min_pmi: 2.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn del bucle. El handle Mutex evita que dos pasadas concurrentes
|
||||
/// promuevan el mismo cristal (el lock garantiza serialización por brain).
|
||||
pub fn spawn_autopromote_loop(state: BrainState, params: AutopromoteParams) {
|
||||
let promoted_keys: Arc<Mutex<HashSet<(EventKind, EventKind)>>> =
|
||||
Arc::new(Mutex::new(HashSet::new()));
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(params.interval_secs));
|
||||
tick.tick().await; // descartar primer tick inmediato
|
||||
info!(?params, "autopromote loop activo");
|
||||
loop {
|
||||
tick.tick().await;
|
||||
run_one_pass(&state, ¶ms, &promoted_keys).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn run_one_pass(
|
||||
state: &BrainState,
|
||||
params: &AutopromoteParams,
|
||||
promoted_keys: &Arc<Mutex<HashSet<(EventKind, EventKind)>>>,
|
||||
) {
|
||||
let crystals: Vec<Crystal> = {
|
||||
let obs = state.observer.read().await;
|
||||
detect_crystals(&obs, ¶ms.threshold)
|
||||
};
|
||||
if crystals.is_empty() { return; }
|
||||
|
||||
let mut pk = promoted_keys.lock().await;
|
||||
for c in crystals {
|
||||
let key = (c.antecedent.clone(), c.consequent.clone());
|
||||
if pk.contains(&key) {
|
||||
// Ya promovido — el observer puede seguir reportando este
|
||||
// cristal pero no necesitamos otra regla.
|
||||
continue;
|
||||
}
|
||||
promote_one(state, &c).await;
|
||||
pk.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
async fn promote_one(state: &BrainState, c: &Crystal) {
|
||||
let rule = crystal_to_rule(c);
|
||||
let snippet = crystal_to_kcl(c);
|
||||
let rule_id = rule.id;
|
||||
state.engine.write().await.insert(rule);
|
||||
if let Some(path) = state.rules_out.as_ref() {
|
||||
if let Err(e) = append_kcl_snippet(path, &snippet) {
|
||||
warn!(?e, "autopromote: rules_out append falló");
|
||||
}
|
||||
}
|
||||
state.audit.write().await.append(AuditAction::PromoteCrystal {
|
||||
rule_id,
|
||||
crystal: c.clone(),
|
||||
});
|
||||
info!(
|
||||
%rule_id,
|
||||
antecedent = ?c.antecedent,
|
||||
consequent = ?c.consequent,
|
||||
cp = c.conditional_prob,
|
||||
pmi = c.pmi,
|
||||
"autopromote: cristal → regla"
|
||||
);
|
||||
}
|
||||
@@ -103,6 +103,9 @@ pub enum IntrospectRequest {
|
||||
/// Recarga reglas desde el archivo configurado por --rules-out (o el
|
||||
/// path provisto). Vacía el engine antes de cargar.
|
||||
ReloadRules { path: Option<String> },
|
||||
/// Verifica la cadena audit recorriendo prev_sha hasta el genesis,
|
||||
/// validando integridad de cada entry contra el CAS.
|
||||
VerifyAudit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -124,6 +127,8 @@ pub enum IntrospectResponse {
|
||||
Flushed { written: usize, head_sha: Option<[u8; 32]>, total_flushed: u64 },
|
||||
/// Resultado de ReloadRules: número total de reglas tras el reload.
|
||||
Reloaded { count: usize },
|
||||
/// Resultado de VerifyAudit.
|
||||
AuditVerified(crate::audit::VerificationReport),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -317,6 +322,17 @@ impl IntrospectServer {
|
||||
Err(e) => IntrospectResponse::Error(format!("flush_to_cas: {e}")),
|
||||
}
|
||||
}
|
||||
IntrospectRequest::VerifyAudit => {
|
||||
let head = self.state.audit.read().await.last_flushed_sha();
|
||||
let head = match head {
|
||||
Some(h) => h,
|
||||
None => return IntrospectResponse::Error(
|
||||
"audit log sin entries flushadas — nada que verificar".into()
|
||||
),
|
||||
};
|
||||
let report = crate::audit::verify_chain_from_cas(head);
|
||||
IntrospectResponse::AuditVerified(report)
|
||||
}
|
||||
IntrospectRequest::ReloadRules { path } => {
|
||||
// Path explícito gana sobre el rules_out configurado.
|
||||
let resolved = path.map(std::path::PathBuf::from)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! - Observer mantiene contadores incrementales — sin recomputación.
|
||||
|
||||
pub mod audit;
|
||||
pub mod autopromote;
|
||||
pub mod crystallize;
|
||||
pub mod dispatch;
|
||||
pub mod engine;
|
||||
@@ -26,6 +27,7 @@ pub mod metrics;
|
||||
pub mod observer;
|
||||
pub mod rules;
|
||||
|
||||
pub use autopromote::{spawn_autopromote_loop, AutopromoteParams};
|
||||
pub use crystallize::{detect_crystals, Crystal, CrystallizationParams};
|
||||
pub use dispatch::{dispatch_actions, ActionSink, NullSink};
|
||||
pub use engine::{EventKindDiscriminant, RuleEngine, SubjectInfo};
|
||||
|
||||
@@ -102,6 +102,32 @@ async fn format_metrics(state: &BrainState) -> String {
|
||||
out.push_str("# TYPE ente_brain_crystals_total gauge\n");
|
||||
out.push_str(&format!("ente_brain_crystals_total {}\n", crystals.len()));
|
||||
|
||||
// ---- Histogramas de gaps temporales (top-32 pares más frecuentes) ----
|
||||
out.push_str("# HELP ente_brain_pair_gap_seconds Time gap between correlated events.\n");
|
||||
out.push_str("# TYPE ente_brain_pair_gap_seconds histogram\n");
|
||||
let limits = crate::observer::GapHistogram::bucket_limits();
|
||||
for ((a, b), hist) in obs.top_gap_pairs(32) {
|
||||
let labels = format!(r#"a="{}",b="{}""#, kind_label(a), kind_label(b));
|
||||
for (i, &limit) in limits.iter().enumerate() {
|
||||
out.push_str(&format!(
|
||||
"ente_brain_pair_gap_seconds_bucket{{{},le=\"{}\"}} {}\n",
|
||||
labels, limit, hist.buckets[i]
|
||||
));
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"ente_brain_pair_gap_seconds_bucket{{{},le=\"+Inf\"}} {}\n",
|
||||
labels, hist.count
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"ente_brain_pair_gap_seconds_sum{{{}}} {:.6}\n",
|
||||
labels, hist.sum_secs
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"ente_brain_pair_gap_seconds_count{{{}}} {}\n",
|
||||
labels, hist.count
|
||||
));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,41 @@ pub struct TimedEvent {
|
||||
pub at: Instant,
|
||||
}
|
||||
|
||||
/// Histograma de gaps temporales con buckets exponenciales en segundos.
|
||||
/// Cubre 10 órdenes de magnitud: 1ms hasta 1000s.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GapHistogram {
|
||||
/// Buckets cumulativos (Prometheus-style): cada índice cuenta eventos
|
||||
/// con gap ≤ ese límite. Limites: 1ms, 10ms, 100ms, 1s, 10s, 100s, 1000s.
|
||||
pub buckets: [u64; 7],
|
||||
pub count: u64,
|
||||
pub sum_secs: f64,
|
||||
pub max_secs: f64,
|
||||
}
|
||||
|
||||
const GAP_BUCKET_LIMITS_SECS: [f64; 7] = [
|
||||
0.001, 0.01, 0.1, 1.0, 10.0, 100.0, 1000.0,
|
||||
];
|
||||
|
||||
impl GapHistogram {
|
||||
pub fn observe(&mut self, gap_secs: f64) {
|
||||
for (i, &limit) in GAP_BUCKET_LIMITS_SECS.iter().enumerate() {
|
||||
if gap_secs <= limit {
|
||||
self.buckets[i] += 1;
|
||||
}
|
||||
}
|
||||
self.count += 1;
|
||||
self.sum_secs += gap_secs;
|
||||
if gap_secs > self.max_secs { self.max_secs = gap_secs; }
|
||||
}
|
||||
|
||||
pub fn mean_secs(&self) -> f64 {
|
||||
if self.count == 0 { 0.0 } else { self.sum_secs / self.count as f64 }
|
||||
}
|
||||
|
||||
pub fn bucket_limits() -> &'static [f64; 7] { &GAP_BUCKET_LIMITS_SECS }
|
||||
}
|
||||
|
||||
pub struct Observer {
|
||||
window: VecDeque<TimedEvent>,
|
||||
window_size: usize,
|
||||
@@ -33,6 +68,8 @@ pub struct Observer {
|
||||
/// Half-life del decay exponencial en segundos. None = sin decay
|
||||
/// (las consultas devuelven los counts crudos).
|
||||
half_life_secs: Option<f64>,
|
||||
/// Histograma de gaps temporales por par (a, b). Capturado al `record()`.
|
||||
gap_histograms: HashMap<(EventKind, EventKind), GapHistogram>,
|
||||
}
|
||||
|
||||
impl Observer {
|
||||
@@ -46,6 +83,7 @@ impl Observer {
|
||||
last_seen_marginal: HashMap::new(),
|
||||
last_seen_cooccur: HashMap::new(),
|
||||
half_life_secs: None,
|
||||
gap_histograms: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,10 +105,13 @@ impl Observer {
|
||||
let timed = TimedEvent { kind: kind.clone(), at: now };
|
||||
|
||||
// Co-ocurrencias: este evento con cada uno previo en ventana.
|
||||
// Capturamos también el gap temporal (now - w.at) para histograma.
|
||||
for w in &self.window {
|
||||
let key = (w.kind.clone(), kind.clone());
|
||||
*self.cooccur.entry(key.clone()).or_insert(0) += 1;
|
||||
self.last_seen_cooccur.insert(key, now);
|
||||
self.last_seen_cooccur.insert(key.clone(), now);
|
||||
let gap_secs = now.duration_since(w.at).as_secs_f64();
|
||||
self.gap_histograms.entry(key).or_default().observe(gap_secs);
|
||||
}
|
||||
|
||||
self.window.push_back(timed);
|
||||
@@ -176,6 +217,19 @@ impl Observer {
|
||||
let start = self.window.len().saturating_sub(n);
|
||||
self.window.range(start..)
|
||||
}
|
||||
|
||||
pub fn gap_histograms(&self) -> &HashMap<(EventKind, EventKind), GapHistogram> {
|
||||
&self.gap_histograms
|
||||
}
|
||||
|
||||
/// Top-K pares por count del histograma (más frecuentes primero).
|
||||
/// Útil para limitar cardinalidad de métricas exportadas.
|
||||
pub fn top_gap_pairs(&self, k: usize) -> Vec<(&(EventKind, EventKind), &GapHistogram)> {
|
||||
let mut pairs: Vec<_> = self.gap_histograms.iter().collect();
|
||||
pairs.sort_by(|a, b| b.1.count.cmp(&a.1.count));
|
||||
pairs.truncate(k);
|
||||
pairs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -61,6 +61,15 @@ pub enum BusRequest {
|
||||
/// Invocación genérica de capacidad. `cap` debe estar provista por algún
|
||||
/// Ente del grafo; el blob es el argumento opaco que el proveedor parsea.
|
||||
Invoke { cap: Capability, blob: Vec<u8> },
|
||||
|
||||
/// Actualización dinámica del set de capacidades del Ente que llama.
|
||||
/// Sólo aplicable al `from_authenticated` — un Ente sólo puede modificar
|
||||
/// sus propias caps. La Card original (immutable) no se toca; la mutación
|
||||
/// va al `dynamic_provides` del Incarnated.
|
||||
UpdateCapabilities {
|
||||
adds: Vec<Capability>,
|
||||
removes: Vec<Capability>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -16,11 +16,16 @@ use ulid::Ulid;
|
||||
|
||||
/// Operaciones que requieren identidad verificada en el bus.
|
||||
///
|
||||
/// Sólo `Announce`: establece la entrada en `bus_connections` para forwarding
|
||||
/// y debe ser no-falsificable. Invoke, ListEntes y power-mgmt se aceptan
|
||||
/// anonymous — políticas por capacidad se aplican aguas abajo, no aquí.
|
||||
/// - `Announce`: establece bus_connections para forwarding.
|
||||
/// - `UpdateCapabilities`: muta dynamic_provides del Ente — sólo el dueño.
|
||||
///
|
||||
/// Invoke, ListEntes y power-mgmt se aceptan anonymous — políticas por
|
||||
/// capacidad se aplican aguas abajo, no aquí.
|
||||
fn requires_auth(req: &BusRequest) -> bool {
|
||||
matches!(req, BusRequest::Announce { .. })
|
||||
matches!(
|
||||
req,
|
||||
BusRequest::Announce { .. } | BusRequest::UpdateCapabilities { .. }
|
||||
)
|
||||
}
|
||||
|
||||
impl EnteGraph {
|
||||
@@ -101,9 +106,65 @@ impl EnteGraph {
|
||||
BusRequest::Invoke { cap, blob } => {
|
||||
self.forward_invoke(from_authenticated, cap, blob, reply).await;
|
||||
}
|
||||
BusRequest::UpdateCapabilities { adds, removes } => {
|
||||
let id = from_authenticated.expect("auth-required guarantees Some");
|
||||
self.apply_capability_update(id, adds, removes);
|
||||
let _ = reply.send(BusResponse::Ok);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Muta `dynamic_provides` del Ente y actualiza el índice global de
|
||||
/// providers. La Card original (immutable) no se toca.
|
||||
fn apply_capability_update(
|
||||
&mut self,
|
||||
ente_id: Ulid,
|
||||
adds: Vec<Capability>,
|
||||
removes: Vec<Capability>,
|
||||
) {
|
||||
// Adiciones: dedupe contra Card.provides + dynamic_provides existentes.
|
||||
let mut added = Vec::new();
|
||||
let mut removed = Vec::new();
|
||||
if let Some(inc) = self.incarnated.get_mut(&ente_id) {
|
||||
for cap in adds {
|
||||
if inc.card.provides.contains(&cap) || inc.dynamic_provides.contains(&cap) {
|
||||
continue; // ya provista, no-op
|
||||
}
|
||||
inc.dynamic_provides.insert(cap.clone());
|
||||
added.push(cap);
|
||||
}
|
||||
for cap in removes {
|
||||
if inc.dynamic_provides.remove(&cap) {
|
||||
removed.push(cap);
|
||||
}
|
||||
// Caps de la Card original no se pueden quitar — silenciosamente
|
||||
// ignoradas. Una Card es contrato; sólo el dynamic es mutable.
|
||||
}
|
||||
}
|
||||
// Actualizar índice global. Hacemos esto fuera del scope `inc` para
|
||||
// evitar el doble-borrow de self.
|
||||
for cap in &added {
|
||||
self.register_dynamic_cap(ente_id, cap.clone());
|
||||
}
|
||||
for cap in &removed {
|
||||
self.unregister_dynamic_cap(ente_id, cap);
|
||||
// Revocar grants emitidos contra esta cap por este Ente.
|
||||
let revoked: Vec<u64> = self.grants.iter()
|
||||
.filter(|(_, g)| g.provider == ente_id && &g.cap == cap)
|
||||
.map(|(t, _)| *t)
|
||||
.collect();
|
||||
for t in revoked {
|
||||
self.grants.remove(&t);
|
||||
}
|
||||
}
|
||||
info!(
|
||||
%ente_id,
|
||||
added_count = added.len(),
|
||||
removed_count = removed.len(),
|
||||
"capabilities actualizadas en runtime"
|
||||
);
|
||||
}
|
||||
|
||||
/// Enruta un Invoke al proveedor real de la capacidad. Aloca un seq
|
||||
/// server-side, registra el reply oneshot en `pending_invokes`, y empuja
|
||||
/// el request por la conexión del proveedor.
|
||||
|
||||
@@ -84,7 +84,10 @@ impl EnteGraph {
|
||||
self.children.entry(parent).or_default().push(card.id);
|
||||
}
|
||||
info!(label = %card.label, ?pid, lineage = ?card.lineage, "Ente encarnado");
|
||||
self.incarnated.insert(card.id, Incarnated { card, pid });
|
||||
self.incarnated.insert(card.id, Incarnated {
|
||||
card, pid,
|
||||
dynamic_provides: std::collections::BTreeSet::new(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ pub struct EnteGraph {
|
||||
pub(in crate::graph) struct Incarnated {
|
||||
pub card: EntityCard,
|
||||
pub pid: Option<Pid>,
|
||||
/// Capacidades añadidas en runtime vía BusRequest::UpdateCapabilities.
|
||||
/// La Card original es immutable; la "vista efectiva" del Ente es
|
||||
/// `card.provides ∪ dynamic_provides`.
|
||||
pub dynamic_provides: BTreeSet<Capability>,
|
||||
}
|
||||
|
||||
pub(in crate::graph) struct GrantedCapability {
|
||||
@@ -86,7 +90,10 @@ impl EnteGraph {
|
||||
// El Ente #0 se inscribe a sí mismo como proveedor de las capacidades
|
||||
// que su Card declara — sólo así los hijos pueden requerirlas.
|
||||
g.register_provider(&seed);
|
||||
g.incarnated.insert(seed.id, Incarnated { card: seed, pid: None });
|
||||
g.incarnated.insert(seed.id, Incarnated {
|
||||
card: seed, pid: None,
|
||||
dynamic_provides: BTreeSet::new(),
|
||||
});
|
||||
g
|
||||
}
|
||||
|
||||
@@ -144,6 +151,19 @@ impl EnteGraph {
|
||||
}
|
||||
}
|
||||
|
||||
/// Quita una capacidad dinámica del índice de providers para un Ente
|
||||
/// específico. Usado al recibir UpdateCapabilities con `removes`.
|
||||
pub(in crate::graph) fn unregister_dynamic_cap(&mut self, ente_id: Ulid, cap: &Capability) {
|
||||
if let Some(set) = self.providers.get_mut(cap) {
|
||||
set.remove(&ente_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserta una capacidad dinámica al índice de providers para un Ente.
|
||||
pub(in crate::graph) fn register_dynamic_cap(&mut self, ente_id: Ulid, cap: Capability) {
|
||||
self.providers.entry(cap).or_default().insert(ente_id);
|
||||
}
|
||||
|
||||
/// El Ente #0 (semilla) tiene todas sus capacidades declaradas. Otros
|
||||
/// las tienen si su Card las declara o si poseen un grant vivo.
|
||||
pub(in crate::graph) fn holder_has(&self, holder: Ulid, cap: &Capability) -> bool {
|
||||
|
||||
@@ -42,6 +42,7 @@ struct CliArgs {
|
||||
audit_head: Option<PathBuf>,
|
||||
metrics_addr: Option<String>,
|
||||
brain_half_life: Option<f64>,
|
||||
autopromote_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn parse_args() -> CliArgs {
|
||||
@@ -53,6 +54,7 @@ fn parse_args() -> CliArgs {
|
||||
let mut audit_head = None;
|
||||
let mut metrics_addr = None;
|
||||
let mut brain_half_life = None;
|
||||
let mut autopromote_secs = None;
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--checkpoint" => checkpoint = args.next().map(PathBuf::from),
|
||||
@@ -62,10 +64,14 @@ fn parse_args() -> CliArgs {
|
||||
"--audit-head" => audit_head = args.next().map(PathBuf::from),
|
||||
"--metrics-addr" => metrics_addr = args.next(),
|
||||
"--brain-half-life" => brain_half_life = args.next().and_then(|s| s.parse().ok()),
|
||||
"--autopromote-secs" => autopromote_secs = args.next().and_then(|s| s.parse().ok()),
|
||||
other => warn!(arg = %other, "argumento desconocido, ignorado"),
|
||||
}
|
||||
}
|
||||
CliArgs { checkpoint, restore, rules, rules_out, audit_head, metrics_addr, brain_half_life }
|
||||
CliArgs {
|
||||
checkpoint, restore, rules, rules_out, audit_head,
|
||||
metrics_addr, brain_half_life, autopromote_secs,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
@@ -94,6 +100,7 @@ fn main() -> anyhow::Result<()> {
|
||||
card, dev_mode,
|
||||
cli.checkpoint, cli.rules, cli.rules_out,
|
||||
cli.audit_head, cli.metrics_addr, cli.brain_half_life,
|
||||
cli.autopromote_secs,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -106,6 +113,7 @@ async fn primordial_loop(
|
||||
audit_head: Option<PathBuf>,
|
||||
metrics_addr: Option<String>,
|
||||
brain_half_life: Option<f64>,
|
||||
autopromote_secs: Option<u64>,
|
||||
) -> anyhow::Result<()> {
|
||||
info!(seed_id = %seed_card.id, label = %seed_card.label, "Ente #0 entra al bucle primordial");
|
||||
|
||||
@@ -158,6 +166,15 @@ async fn primordial_loop(
|
||||
*obs = ente_brain::Observer::new(1024).with_half_life(hl);
|
||||
info!(hl_secs = hl, "observer con time-decay activo");
|
||||
}
|
||||
if let Some(secs) = autopromote_secs {
|
||||
ente_brain::spawn_autopromote_loop(
|
||||
brain.clone(),
|
||||
ente_brain::AutopromoteParams {
|
||||
interval_secs: secs,
|
||||
threshold: brain.params, // mismo threshold que crystals manuales
|
||||
},
|
||||
);
|
||||
}
|
||||
// Si --audit-head, configuramos el head pointer y arrancamos auto-flush.
|
||||
if let Some(head_path) = audit_head {
|
||||
// Re-creamos el AuditLog con head pointer.
|
||||
|
||||
Reference in New Issue
Block a user