f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
357 lines
15 KiB
Rust
357 lines
15 KiB
Rust
//! IDMM business logic: config persistence (conversation `extra.idmm` /
|
|
//! `terminal_sessions.idmm`), state assembly, global settings, and the
|
|
//! `ConfigReader` + `ProbeFactory` impls that let `IdmmManager` (re)build probes
|
|
//! and read config lazily. No axum here.
|
|
//!
|
|
//! Construction is layered to avoid a cycle: `ProbeDeps` (probe build + config
|
|
//! read) needs no manager → it backs the factory/config-reader → those back the
|
|
//! `IdmmManager` → the `IdmmService` composes `ProbeDeps` + sidecar + manager.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use nomifun_ai_agent::task_manager::IWorkerTaskManager;
|
|
use nomifun_api_types::{IdmmConfig, IdmmSettings, IdmmState, IdmmTargetKind, InterventionRecord};
|
|
use nomifun_common::AppError;
|
|
use nomifun_conversation::ConversationService;
|
|
use nomifun_db::models::IdmmInterventionRow;
|
|
use nomifun_db::{IClientPreferenceRepository, IConversationRepository, IIdmmInterventionRepository};
|
|
use nomifun_terminal::TerminalDriver;
|
|
|
|
use crate::probe::{ConversationProbe, SessionProbe, TerminalProbe};
|
|
use crate::sidecar::{PREF_BACKUP_MODEL, PREF_BACKUP_PROVIDER, PREF_DEFAULT_STEERING, SidecarClient};
|
|
use crate::supervisor::{ConfigReader, IdmmManager, ProbeFactory, build_state};
|
|
|
|
const SYSTEM_DEFAULT_USER_ID: &str = "system_default_user";
|
|
|
|
/// Parse an IDMM string `target_id` (the kind-agnostic target handle on the
|
|
/// IDMM DTO) into the integer key the conversation repo / terminal driver now
|
|
/// use. A non-numeric id yields an explicit NotFound (spec §2.5/§7.4).
|
|
fn parse_target_id(target_id: &str) -> Result<i64, AppError> {
|
|
target_id
|
|
.parse::<i64>()
|
|
.map_err(|_| AppError::NotFound(format!("session {target_id}")))
|
|
}
|
|
|
|
/// Map a persisted row to the API/WS `InterventionRecord` DTO.
|
|
fn row_to_record(row: IdmmInterventionRow) -> InterventionRecord {
|
|
InterventionRecord {
|
|
id: row.id,
|
|
target_kind: row.target_kind,
|
|
target_id: row.target_id,
|
|
watch: row.watch,
|
|
at: row.at,
|
|
stall_class: row.signal,
|
|
tier_used: row.tier_used,
|
|
category: row.category,
|
|
action: row.action,
|
|
detail: row.detail,
|
|
outcome: row.outcome,
|
|
reason: row.reason,
|
|
confidence: row.confidence.map(|c| c as f32),
|
|
bypass_model: row.bypass_model,
|
|
}
|
|
}
|
|
|
|
/// Collaborators needed to build probes + read config (NO manager → breaks the
|
|
/// construction cycle). Shared by the factory, config-reader, and service.
|
|
pub struct ProbeDeps {
|
|
pub conversation_service: ConversationService,
|
|
pub conversation_repo: Arc<dyn IConversationRepository>,
|
|
pub terminal_driver: Arc<dyn TerminalDriver>,
|
|
pub task_manager: Arc<dyn IWorkerTaskManager>,
|
|
}
|
|
|
|
impl ProbeDeps {
|
|
/// Read the persisted per-session config (default when none / store absent).
|
|
pub async fn read_config(&self, kind: IdmmTargetKind, target_id: &str) -> Result<IdmmConfig, AppError> {
|
|
let raw: Option<serde_json::Value> = match kind {
|
|
IdmmTargetKind::Conversation => {
|
|
let Some(row) = self.conversation_repo.get(parse_target_id(target_id)?).await? else {
|
|
return Ok(IdmmConfig::default());
|
|
};
|
|
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or_default();
|
|
extra.get("idmm").cloned()
|
|
}
|
|
IdmmTargetKind::Terminal => match self
|
|
.terminal_driver
|
|
.read_idmm(parse_target_id(target_id)?)
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("read_idmm failed: {e}")))?
|
|
{
|
|
Some(s) => serde_json::from_str(&s).ok(),
|
|
None => None,
|
|
},
|
|
};
|
|
Ok(raw.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default())
|
|
}
|
|
|
|
fn build_probe(&self, kind: IdmmTargetKind, target_id: &str) -> Option<Arc<dyn SessionProbe>> {
|
|
match kind {
|
|
IdmmTargetKind::Conversation => Some(Arc::new(ConversationProbe {
|
|
task_manager: self.task_manager.clone(),
|
|
conversation_service: self.conversation_service.clone(),
|
|
conversation_repo: self.conversation_repo.clone(),
|
|
conversation_id: target_id.to_string(),
|
|
user_id: SYSTEM_DEFAULT_USER_ID.to_string(),
|
|
})),
|
|
IdmmTargetKind::Terminal => {
|
|
// A non-numeric terminal target cannot map to a PTY → no probe.
|
|
let id = target_id.parse::<i64>().ok()?;
|
|
Some(Arc::new(TerminalProbe::new(self.terminal_driver.clone(), id)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ProbeFactory for ProbeDeps {
|
|
fn build(&self, kind: IdmmTargetKind, target_id: &str) -> Option<Arc<dyn SessionProbe>> {
|
|
self.build_probe(kind, target_id)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ConfigReader for ProbeDeps {
|
|
async fn read(&self, kind: IdmmTargetKind, target_id: &str) -> IdmmConfig {
|
|
self.read_config(kind, target_id).await.unwrap_or_default()
|
|
}
|
|
}
|
|
|
|
/// IDMM's API-facing service (config persistence, state, settings, log).
|
|
#[derive(Clone)]
|
|
pub struct IdmmService {
|
|
probe_deps: Arc<ProbeDeps>,
|
|
client_prefs: Arc<dyn IClientPreferenceRepository>,
|
|
sidecar: Arc<SidecarClient>,
|
|
manager: IdmmManager,
|
|
records: Arc<dyn IIdmmInterventionRepository>,
|
|
}
|
|
|
|
impl IdmmService {
|
|
pub fn new(
|
|
probe_deps: Arc<ProbeDeps>,
|
|
client_prefs: Arc<dyn IClientPreferenceRepository>,
|
|
sidecar: Arc<SidecarClient>,
|
|
manager: IdmmManager,
|
|
records: Arc<dyn IIdmmInterventionRepository>,
|
|
) -> Self {
|
|
Self {
|
|
probe_deps,
|
|
client_prefs,
|
|
sidecar,
|
|
manager,
|
|
records,
|
|
}
|
|
}
|
|
|
|
pub fn manager(&self) -> &IdmmManager {
|
|
&self.manager
|
|
}
|
|
|
|
/// Whether the RulePlusModel tier has a resolvable bypass model: a per-watch
|
|
/// override / global default, OR — for a conversation target — the
|
|
/// conversation's own selected model (which becomes the bypass model, so the
|
|
/// model tier works with zero extra config on a plain chat). Terminals have
|
|
/// no own callable model (their agent CLI owns the model), so they still need
|
|
/// an explicit backup. Feeds both `validate` and the
|
|
/// `sidecar_provider_resolved` state flag the frontend gates its toggle on.
|
|
///
|
|
/// Checks both watches' bypass models (either resolving satisfies the
|
|
/// requirement — `validate` only demands a backup when an enabled watch is on
|
|
/// the model tier, and both watches resolve through the same global default).
|
|
async fn sidecar_backup_resolvable(&self, kind: IdmmTargetKind, target_id: &str, cfg: &IdmmConfig) -> bool {
|
|
if self.sidecar.backup_resolvable(&cfg.decision_watch.base.bypass_model).await
|
|
|| self.sidecar.backup_resolvable(&cfg.fault_watch.base.bypass_model).await
|
|
{
|
|
return true;
|
|
}
|
|
if kind == IdmmTargetKind::Conversation
|
|
&& let Ok(id) = parse_target_id(target_id)
|
|
&& let Ok(Some(row)) = self.probe_deps.conversation_repo.get(id).await
|
|
{
|
|
let pm = nomifun_conversation::task_options::provider_model_from_conversation_row(&row);
|
|
return !pm.provider_id.trim().is_empty();
|
|
}
|
|
false
|
|
}
|
|
|
|
// ── Config persistence ──
|
|
|
|
/// Validate + persist a per-session config, then arm/stop supervision.
|
|
pub async fn save_config(&self, kind: IdmmTargetKind, target_id: &str, cfg: &IdmmConfig) -> Result<(), AppError> {
|
|
let backup_resolvable = self.sidecar_backup_resolvable(kind, target_id, cfg).await;
|
|
crate::config::validate(cfg, backup_resolvable).map_err(AppError::BadRequest)?;
|
|
|
|
match kind {
|
|
IdmmTargetKind::Conversation => {
|
|
let blob = serde_json::to_value(cfg).map_err(|e| AppError::Internal(e.to_string()))?;
|
|
self.probe_deps
|
|
.conversation_service
|
|
.update_extra(target_id, serde_json::json!({ "idmm": blob }))
|
|
.await?;
|
|
}
|
|
IdmmTargetKind::Terminal => {
|
|
let s = serde_json::to_string(cfg).map_err(|e| AppError::Internal(e.to_string()))?;
|
|
self.probe_deps
|
|
.terminal_driver
|
|
.write_idmm(parse_target_id(target_id)?, Some(&s))
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("write_idmm failed: {e}")))?;
|
|
}
|
|
}
|
|
|
|
if cfg.any_enabled() {
|
|
self.manager.ensure(kind, target_id).await;
|
|
} else {
|
|
self.manager.stop(kind, target_id);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Read the persisted per-session config. Returns `Ok(None)` when no
|
|
/// config has been saved for this target (the frontend should then seed
|
|
/// the form from `IdmmSettings.default_steering_prompt` instead of from a
|
|
/// blank `IdmmConfig::default()`).
|
|
pub async fn read_config_persisted(
|
|
&self,
|
|
kind: IdmmTargetKind,
|
|
target_id: &str,
|
|
) -> Result<Option<IdmmConfig>, AppError> {
|
|
let raw: Option<serde_json::Value> = match kind {
|
|
IdmmTargetKind::Conversation => {
|
|
let Some(row) = self.probe_deps.conversation_repo.get(parse_target_id(target_id)?).await? else {
|
|
return Ok(None);
|
|
};
|
|
let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or_default();
|
|
extra.get("idmm").cloned()
|
|
}
|
|
IdmmTargetKind::Terminal => match self
|
|
.probe_deps
|
|
.terminal_driver
|
|
.read_idmm(parse_target_id(target_id)?)
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("read_idmm failed: {e}")))?
|
|
{
|
|
Some(s) => serde_json::from_str(&s).ok(),
|
|
None => None,
|
|
},
|
|
};
|
|
Ok(raw.and_then(|v| serde_json::from_value(v).ok()))
|
|
}
|
|
|
|
pub async fn read_config(&self, kind: IdmmTargetKind, target_id: &str) -> Result<IdmmConfig, AppError> {
|
|
self.probe_deps.read_config(kind, target_id).await
|
|
}
|
|
|
|
/// Assemble the live state (config + manager runtime + backup resolvability).
|
|
/// Includes the persisted config (when one exists) so the frontend can
|
|
/// rehydrate its form without losing user input on remount (Req4).
|
|
pub async fn build_state(&self, kind: IdmmTargetKind, target_id: &str) -> Result<IdmmState, AppError> {
|
|
let persisted = self.read_config_persisted(kind, target_id).await?;
|
|
let cfg = persisted.clone().unwrap_or_default();
|
|
let shared = self.manager.shared_for(kind, target_id);
|
|
let resolved = self.sidecar_backup_resolvable(kind, target_id, &cfg).await;
|
|
Ok(build_state(
|
|
&shared,
|
|
kind,
|
|
target_id,
|
|
&cfg,
|
|
resolved,
|
|
persisted.as_ref(),
|
|
))
|
|
}
|
|
|
|
/// Recent intervention log for a target (most-recent-first), read from the
|
|
/// persisted audit table — the DB is the sole source of truth (the supervisor
|
|
/// itself keeps only live counters, not a record ring). `limit` caps the rows.
|
|
pub async fn log(&self, kind: IdmmTargetKind, target_id: &str, limit: i64) -> Result<Vec<InterventionRecord>, AppError> {
|
|
let rows = self
|
|
.records
|
|
.list_for_target(kind.as_str(), target_id, limit)
|
|
.await?;
|
|
Ok(rows.into_iter().map(row_to_record).collect())
|
|
}
|
|
|
|
/// Clear all persisted intervention records for a target. Returns the count
|
|
/// removed. (Manual "清空记录" + the session-delete cascade both route here.)
|
|
pub async fn clear_log(&self, kind: IdmmTargetKind, target_id: &str) -> Result<u64, AppError> {
|
|
Ok(self
|
|
.records
|
|
.delete_for_target(kind.as_str(), target_id)
|
|
.await?)
|
|
}
|
|
|
|
/// Cross-session recent intervention feed (most-recent-first across ALL
|
|
/// targets), read from the persisted audit table. `limit` caps the rows.
|
|
pub async fn recent_activity(&self, limit: i64) -> Result<Vec<InterventionRecord>, AppError> {
|
|
let rows = self.records.list_recent(limit).await?;
|
|
Ok(rows.into_iter().map(row_to_record).collect())
|
|
}
|
|
|
|
/// Clear EVERY persisted intervention record across all targets. Returns the
|
|
/// count removed (manual "清空全部记录").
|
|
pub async fn clear_all_activity(&self) -> Result<u64, AppError> {
|
|
Ok(self.records.clear_all().await?)
|
|
}
|
|
|
|
/// Force one ladder pass now (manual "act now"): ensures supervision is
|
|
/// running; the actual pass happens on the next observed signal.
|
|
pub async fn intervene_now(&self, kind: IdmmTargetKind, target_id: &str) -> Result<(), AppError> {
|
|
self.manager.ensure(kind, target_id).await;
|
|
Ok(())
|
|
}
|
|
|
|
// ── Global settings (client_preferences) ──
|
|
|
|
pub async fn get_settings(&self) -> Result<IdmmSettings, AppError> {
|
|
let rows = self
|
|
.client_prefs
|
|
.get_by_keys(&[PREF_BACKUP_PROVIDER, PREF_BACKUP_MODEL, PREF_DEFAULT_STEERING])
|
|
.await?;
|
|
let mut s = IdmmSettings::default();
|
|
for r in rows {
|
|
match r.key.as_str() {
|
|
PREF_BACKUP_PROVIDER => s.backup_provider_id = Some(r.value),
|
|
PREF_BACKUP_MODEL => s.backup_model = Some(r.value),
|
|
PREF_DEFAULT_STEERING => s.default_steering_prompt = r.value,
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(s)
|
|
}
|
|
|
|
pub async fn set_settings(&self, settings: &IdmmSettings) -> Result<(), AppError> {
|
|
let mut entries: Vec<(&str, &str)> = Vec::new();
|
|
if let Some(p) = &settings.backup_provider_id {
|
|
entries.push((PREF_BACKUP_PROVIDER, p.as_str()));
|
|
}
|
|
if let Some(m) = &settings.backup_model {
|
|
entries.push((PREF_BACKUP_MODEL, m.as_str()));
|
|
}
|
|
entries.push((PREF_DEFAULT_STEERING, settings.default_steering_prompt.as_str()));
|
|
self.client_prefs.upsert_batch(&entries).await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Verify a terminal target belongs to `user_id` (data isolation).
|
|
pub async fn verify_terminal_owner(&self, terminal_id: &str, user_id: &str) -> Result<(), AppError> {
|
|
let desc = self
|
|
.probe_deps
|
|
.terminal_driver
|
|
.describe(parse_target_id(terminal_id)?)
|
|
.await
|
|
.map_err(|e| AppError::Internal(format!("describe failed: {e}")))?
|
|
.ok_or_else(|| AppError::NotFound(format!("terminal {terminal_id} not found")))?;
|
|
if desc.user_id != user_id {
|
|
return Err(AppError::Forbidden("not your terminal".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Service-level persistence + validation + settings are covered end-to-end by
|
|
// `nomifun-app/tests/idmm_e2e.rs` against a real in-memory database (per
|
|
// AGENTS.md: prefer a real DB over brittle stubs of the agent/conversation
|
|
// stack). The pure pieces (config validation, policy, detector, sidecar) are
|
|
// unit-tested in their own modules.
|