Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
//! 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.
|
||||
Reference in New Issue
Block a user