use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use thiserror::Error; use zeroclaw::skills::{load_skills_from_directory, Skill}; use crate::scene_contract::manifest::{ SceneManifest, SCENE_MANIFEST_FILE_NAME, SUPPORTED_SCHEMA_VERSION_V1, SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1, SUPPORTED_SCHEDULED_MONITORING_KIND_V1, }; #[derive(Debug, Clone)] pub struct ScheduledMonitoringRegistryEntry { pub manifest: SceneManifest, pub skill_root: PathBuf, pub workflow_id: String, } #[derive(Debug, Error)] pub enum ScheduledMonitoringRegistryError { #[error("failed to read skills directory {path}: {source}")] ReadSkillsDir { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to read scene manifest {path}: {source}")] ReadManifest { path: PathBuf, #[source] source: std::io::Error, }, #[error("failed to parse scene manifest {path}: {source}")] ParseManifest { path: PathBuf, #[source] source: toml::de::Error, }, #[error("scheduled scene manifest {path} declares unsupported schema_version {version}; only {supported} is supported in v1")] UnsupportedSchemaVersion { path: PathBuf, version: String, supported: &'static str, }, #[error("scheduled scene manifest {path} declares unsupported kind {kind}; only {supported} is supported")] UnsupportedSceneKind { path: PathBuf, kind: String, supported: &'static str, }, #[error("scheduled scene manifest {path} declares unsupported category {category}; only {supported} is supported")] UnsupportedSceneCategory { path: PathBuf, category: String, supported: &'static str, }, #[error("scheduled scene manifest {path} points to missing skill package {skill}")] MissingSkill { path: PathBuf, skill: String }, #[error("scheduled scene manifest {path} declares skill {manifest_skill}, but containing skill package is {package_skill}")] SkillPackageMismatch { path: PathBuf, manifest_skill: String, package_skill: String, }, #[error("scheduled scene manifest {path} is missing trigger section")] MissingTriggerSection { path: PathBuf }, #[error("scheduled scene manifest {path} is missing modes section")] MissingModesSection { path: PathBuf }, #[error("scheduled scene manifest {path} is missing runtime_context section")] MissingRuntimeContextSection { path: PathBuf }, #[error("scheduled scene manifest {path} is missing safety section")] MissingSafetySection { path: PathBuf }, #[error("scheduled scene manifest {path} is missing tools section")] MissingToolsSection { path: PathBuf }, #[error("scheduled scene manifest {path} is missing references section")] MissingReferencesSection { path: PathBuf }, #[error("scheduled scene manifest {path} is missing workflow_id")] MissingWorkflowId { path: PathBuf }, #[error("scheduled scene manifest {path} has unsafe active or queue_process enablement")] UnsafeModesEnabled { path: PathBuf }, #[error("scheduled scene manifest {path} incorrectly exposes natural-language primary trigger")] NaturalLanguagePrimaryEnabled { path: PathBuf }, #[error("scheduled workflow id {workflow_id} is declared twice: {first_path} and {second_path}")] DuplicateWorkflowId { workflow_id: String, first_path: PathBuf, second_path: PathBuf, }, } pub fn load_scheduled_monitoring_registry( skills_dir: &Path, ) -> Result, ScheduledMonitoringRegistryError> { if !skills_dir.exists() { return Ok(Vec::new()); } let mut skill_roots = Vec::new(); for entry in fs::read_dir(skills_dir).map_err(|source| { ScheduledMonitoringRegistryError::ReadSkillsDir { path: skills_dir.to_path_buf(), source, } })? { let entry = entry.map_err(|source| ScheduledMonitoringRegistryError::ReadSkillsDir { path: skills_dir.to_path_buf(), source, })?; let path = entry.path(); if path.is_dir() { skill_roots.push(path); } } skill_roots.sort(); let skills_by_root = index_skills_by_root(skills_dir); let mut workflow_ids = HashMap::new(); let mut registry = Vec::new(); for skill_root in skill_roots { let manifest_path = skill_root.join(SCENE_MANIFEST_FILE_NAME); if !manifest_path.exists() { continue; } let manifest = load_manifest(&manifest_path)?; if manifest.scene.kind != SUPPORTED_SCHEDULED_MONITORING_KIND_V1 { continue; } let workflow_id = validate_manifest(&manifest, &manifest_path, &skill_root, &skills_by_root)?; if let Some(first_path) = workflow_ids.insert(workflow_id.clone(), manifest_path.clone()) { return Err(ScheduledMonitoringRegistryError::DuplicateWorkflowId { workflow_id, first_path, second_path: manifest_path, }); } registry.push(ScheduledMonitoringRegistryEntry { manifest, skill_root, workflow_id, }); } Ok(registry) } fn index_skills_by_root(skills_dir: &Path) -> HashMap { load_skills_from_directory(skills_dir, true) .into_iter() .filter_map(|skill| { let skill_root = skill .location .as_deref() .and_then(Path::parent) .map(Path::to_path_buf)?; Some((skill_root, skill)) }) .collect() } fn load_manifest(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|source| ScheduledMonitoringRegistryError::ReadManifest { path: path.to_path_buf(), source, })?; toml::from_str(&content).map_err(|source| ScheduledMonitoringRegistryError::ParseManifest { path: path.to_path_buf(), source, }) } fn validate_manifest( manifest: &SceneManifest, manifest_path: &Path, skill_root: &Path, skills_by_root: &HashMap, ) -> Result { if manifest.manifest.schema_version != SUPPORTED_SCHEMA_VERSION_V1 { return Err(ScheduledMonitoringRegistryError::UnsupportedSchemaVersion { path: manifest_path.to_path_buf(), version: manifest.manifest.schema_version.clone(), supported: SUPPORTED_SCHEMA_VERSION_V1, }); } if manifest.scene.kind != SUPPORTED_SCHEDULED_MONITORING_KIND_V1 { return Err(ScheduledMonitoringRegistryError::UnsupportedSceneKind { path: manifest_path.to_path_buf(), kind: manifest.scene.kind.clone(), supported: SUPPORTED_SCHEDULED_MONITORING_KIND_V1, }); } if manifest.scene.category != SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1 { return Err(ScheduledMonitoringRegistryError::UnsupportedSceneCategory { path: manifest_path.to_path_buf(), category: manifest.scene.category.clone(), supported: SUPPORTED_SCHEDULED_MONITORING_CATEGORY_V1, }); } let trigger = manifest .trigger .as_ref() .ok_or_else(|| ScheduledMonitoringRegistryError::MissingTriggerSection { path: manifest_path.to_path_buf(), })?; let modes = manifest .modes .as_ref() .ok_or_else(|| ScheduledMonitoringRegistryError::MissingModesSection { path: manifest_path.to_path_buf(), })?; let _runtime_context = manifest.runtime_context.as_ref().ok_or_else(|| { ScheduledMonitoringRegistryError::MissingRuntimeContextSection { path: manifest_path.to_path_buf(), } })?; let safety = manifest .safety .as_ref() .ok_or_else(|| ScheduledMonitoringRegistryError::MissingSafetySection { path: manifest_path.to_path_buf(), })?; let tools = manifest .tools .as_ref() .ok_or_else(|| ScheduledMonitoringRegistryError::MissingToolsSection { path: manifest_path.to_path_buf(), })?; let _references = manifest.references.as_ref().ok_or_else(|| { ScheduledMonitoringRegistryError::MissingReferencesSection { path: manifest_path.to_path_buf(), } })?; let workflow_id = manifest .scene .workflow_id .clone() .filter(|value| !value.trim().is_empty()) .ok_or_else(|| ScheduledMonitoringRegistryError::MissingWorkflowId { path: manifest_path.to_path_buf(), })?; if safety.active_enabled || safety.queue_process_enabled { return Err(ScheduledMonitoringRegistryError::UnsafeModesEnabled { path: manifest_path.to_path_buf(), }); } if trigger.natural_language_primary { return Err(ScheduledMonitoringRegistryError::NaturalLanguagePrimaryEnabled { path: manifest_path.to_path_buf(), }); } if !modes.enabled.iter().any(|mode| mode == "dry_run" || mode == "monitor_only") { return Err(ScheduledMonitoringRegistryError::UnsafeModesEnabled { path: manifest_path.to_path_buf(), }); } let Some(skill) = skills_by_root.get(skill_root) else { return Err(ScheduledMonitoringRegistryError::MissingSkill { path: manifest_path.to_path_buf(), skill: manifest.scene.skill.clone(), }); }; if skill.name != manifest.scene.skill { return Err(ScheduledMonitoringRegistryError::SkillPackageMismatch { path: manifest_path.to_path_buf(), manifest_skill: manifest.scene.skill.clone(), package_skill: skill.name.clone(), }); } for expected_tool in [&tools.detect, &tools.decide, &tools.action_plan] { if expected_tool.trim().is_empty() { return Err(ScheduledMonitoringRegistryError::MissingToolsSection { path: manifest_path.to_path_buf(), }); } } Ok(workflow_id) }