Skip to main content

fastskill_core/core/
service.rs

1//! Main FastSkill service implementation
2
3use crate::core::blob_storage::BlobStorageConfig;
4use crate::events::EventBus;
5use crate::execution::{ExecutionConfig, ExecutionSandbox};
6use crate::storage::ZipHandler;
7use crate::validation::{SkillValidator, ZipValidator};
8use std::path::PathBuf;
9use std::sync::Arc;
10use tracing::info;
11
12/// HTTP server CORS configuration
13#[derive(Debug, Clone, Default)]
14pub struct HttpServerConfig {
15    /// List of origins allowed for CORS (required when server is used)
16    pub allowed_origins: Vec<String>,
17
18    /// Optional: allow list of request headers
19    /// Default: ["Content-Type", "Authorization"] if unset
20    pub allowed_headers: Vec<String>,
21}
22
23/// Main service configuration
24#[derive(Debug, Clone)]
25pub struct ServiceConfig {
26    /// Base directory for skill storage
27    pub skill_storage_path: PathBuf,
28
29    /// Execution configuration
30    pub execution: ExecutionConfig,
31
32    /// Hot reloading configuration
33    pub hot_reload: HotReloadConfig,
34
35    /// Cache configuration
36    pub cache: CacheConfig,
37
38    /// Embedding configuration
39    pub embedding: Option<EmbeddingConfig>,
40
41    /// Security configuration
42    pub security: SecurityConfig,
43
44    /// Staging directory for registry publishing
45    pub staging_dir: Option<PathBuf>,
46
47    /// Registry blob storage configuration
48    pub registry_blob_storage: Option<BlobStorageConfig>,
49
50    /// Registry index path
51    pub registry_index_path: Option<PathBuf>,
52
53    /// Registry blob base URL
54    pub registry_blob_base_url: Option<String>,
55
56    /// HTTP server configuration
57    pub http_server: Option<HttpServerConfig>,
58}
59
60impl Default for ServiceConfig {
61    fn default() -> Self {
62        Self {
63            skill_storage_path: PathBuf::from("./skills"),
64            execution: ExecutionConfig::default(),
65            hot_reload: HotReloadConfig::default(),
66            cache: CacheConfig::default(),
67            embedding: None,
68            security: SecurityConfig::default(),
69            staging_dir: None,
70            registry_blob_storage: None,
71            registry_index_path: None,
72            registry_blob_base_url: None,
73            http_server: None,
74        }
75    }
76}
77
78/// Hot reloading configuration
79#[derive(Debug, Clone)]
80pub struct HotReloadConfig {
81    /// Enable hot reloading
82    pub enabled: bool,
83
84    /// Directories to watch for changes
85    pub watch_paths: Vec<PathBuf>,
86
87    /// Debounce duration for file changes (ms)
88    pub debounce_ms: u64,
89
90    /// Automatically reload on file changes
91    pub auto_reload: bool,
92}
93
94impl Default for HotReloadConfig {
95    fn default() -> Self {
96        Self {
97            enabled: true,
98            watch_paths: vec![PathBuf::from("./skills")],
99            debounce_ms: 1000,
100            auto_reload: true,
101        }
102    }
103}
104
105/// Cache configuration
106#[derive(Debug, Clone)]
107pub struct CacheConfig {
108    /// Maximum cache size (number of skills)
109    pub max_size: usize,
110
111    /// Cache TTL for metadata (seconds)
112    pub metadata_ttl: u64,
113
114    /// Cache TTL for content (seconds)
115    pub content_ttl: u64,
116}
117
118impl Default for CacheConfig {
119    fn default() -> Self {
120        Self {
121            max_size: 1000,
122            metadata_ttl: 300, // 5 minutes
123            content_ttl: 60,   // 1 minute
124        }
125    }
126}
127
128/// Embedding configuration
129#[derive(Debug, Clone)]
130pub struct EmbeddingConfig {
131    /// OpenAI API base URL
132    pub openai_base_url: String,
133
134    /// Embedding model name
135    pub embedding_model: String,
136
137    /// Custom path for vector index database
138    pub index_path: Option<PathBuf>,
139}
140
141/// Security configuration
142#[derive(Debug, Clone)]
143pub struct SecurityConfig {
144    /// Enable security sandboxing
145    pub enable_sandbox: bool,
146
147    /// Allowed file system paths for scripts
148    pub allowed_paths: Vec<PathBuf>,
149
150    /// Audit logging configuration
151    pub audit_logging: bool,
152
153    /// Maximum script execution time
154    pub max_execution_time: std::time::Duration,
155}
156
157impl Default for SecurityConfig {
158    fn default() -> Self {
159        Self {
160            enable_sandbox: true,
161            allowed_paths: vec![PathBuf::from("/tmp"), PathBuf::from("./temp")],
162            audit_logging: true,
163            max_execution_time: std::time::Duration::from_secs(60),
164        }
165    }
166}
167
168/// Unique identifier for a skill
169#[derive(Debug, Clone, PartialEq, Eq, Hash)]
170pub struct SkillId(String);
171
172impl SkillId {
173    /// Create a new SkillId with validation
174    pub fn new(id: String) -> Result<Self, ServiceError> {
175        if id.trim().is_empty() {
176            return Err(ServiceError::Validation(
177                "Skill ID cannot be empty".to_string(),
178            ));
179        }
180        if id.len() > 255 {
181            return Err(ServiceError::Validation(
182                "Skill ID too long (max 255 characters)".to_string(),
183            ));
184        }
185        // Reject forward slashes (scope should be handled separately)
186        if id.contains('/') {
187            return Err(ServiceError::Validation(
188                "Skill ID cannot contain forward slashes. Scope should be handled separately during publishing.".to_string(),
189            ));
190        }
191        // Basic validation for allowed characters (alphanumeric, dash, underscore)
192        if !id
193            .chars()
194            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
195        {
196            return Err(ServiceError::Validation("Skill ID contains invalid characters (only alphanumeric, dash, underscore allowed)".to_string()));
197        }
198        Ok(Self(id))
199    }
200
201    /// Get the string value
202    pub fn as_str(&self) -> &str {
203        &self.0
204    }
205
206    /// Convert to owned string
207    pub fn into_string(self) -> String {
208        self.0
209    }
210}
211
212impl std::fmt::Display for SkillId {
213    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214        write!(f, "{}", self.0)
215    }
216}
217
218impl From<SkillId> for String {
219    fn from(id: SkillId) -> String {
220        id.0
221    }
222}
223
224impl TryFrom<String> for SkillId {
225    type Error = ServiceError;
226
227    fn try_from(s: String) -> Result<Self, Self::Error> {
228        SkillId::new(s)
229    }
230}
231
232impl AsRef<str> for SkillId {
233    fn as_ref(&self) -> &str {
234        &self.0
235    }
236}
237
238impl serde::Serialize for SkillId {
239    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
240    where
241        S: serde::Serializer,
242    {
243        serializer.serialize_str(&self.0)
244    }
245}
246
247impl<'de> serde::Deserialize<'de> for SkillId {
248    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
249    where
250        D: serde::Deserializer<'de>,
251    {
252        let s = String::deserialize(deserializer)?;
253        SkillId::new(s).map_err(serde::de::Error::custom)
254    }
255}
256
257/// Main service error type
258#[derive(Debug, thiserror::Error)]
259pub enum ServiceError {
260    #[error("Storage error: {0}")]
261    Storage(String),
262
263    #[error("Execution error: {0}")]
264    Execution(#[from] crate::execution::ExecutionError),
265
266    #[error("Validation error: {0}")]
267    Validation(String),
268
269    #[error("Event error: {0}")]
270    Event(String),
271
272    #[error("IO error: {0}")]
273    Io(#[from] std::io::Error),
274
275    #[error("Configuration error: {0}")]
276    Config(String),
277
278    #[error("Skill not found: {0}")]
279    SkillNotFound(String),
280
281    #[error("Invalid operation: {0}")]
282    InvalidOperation(String),
283
284    #[error("Custom error: {0}")]
285    Custom(String),
286}
287
288/// Main FastSkill service
289///
290/// Note: This struct does not derive Debug because it contains Arc<dyn Trait> fields
291/// which cannot implement Debug. This is acceptable for enterprise software.
292pub struct FastSkillService {
293    /// Service configuration
294    config: ServiceConfig,
295
296    /// Skill manager (shared instance)
297    skill_manager: Arc<dyn crate::core::skill_manager::SkillManagementService>,
298
299    /// Metadata service (depends on skill manager)
300    metadata_service: Arc<dyn crate::core::metadata::MetadataService>,
301
302    /// Vector index service (optional, for embedding search)
303    vector_index_service: Option<Arc<dyn crate::core::vector_index::VectorIndexService>>,
304
305    /// Skill storage backend
306    storage: Arc<dyn crate::storage::StorageBackend>,
307
308    /// ZIP package handler
309    #[allow(dead_code)]
310    zip_handler: Arc<ZipHandler>,
311
312    /// Execution sandbox
313    #[allow(dead_code)]
314    sandbox: Arc<ExecutionSandbox>,
315
316    /// Skill validator
317    #[allow(dead_code)]
318    skill_validator: Arc<SkillValidator>,
319
320    /// ZIP validator
321    #[allow(dead_code)]
322    zip_validator: Arc<ZipValidator>,
323
324    /// Event bus for notifications
325    #[allow(dead_code)]
326    event_bus: Arc<EventBus>,
327
328    /// Hot reload manager
329    hot_reload_manager: Option<Arc<crate::storage::hot_reload::HotReloadManager>>,
330
331    /// Service state
332    initialized: bool,
333}
334
335impl FastSkillService {
336    /// Create a new service instance
337    pub async fn new(config: ServiceConfig) -> Result<Self, ServiceError> {
338        // Initialize logging only if not already initialized
339        crate::init_logging();
340
341        info!("Initializing FastSkill service v{}", crate::VERSION);
342
343        // Create storage backend
344        let storage = Arc::new(
345            crate::storage::FilesystemStorage::new(config.skill_storage_path.clone()).await?,
346        );
347
348        // Create ZIP handler
349        let zip_handler = Arc::new(crate::storage::ZipHandler::new()?);
350
351        // Create execution sandbox
352        let sandbox = Arc::new(crate::execution::ExecutionSandbox::new(
353            config.execution.clone(),
354        )?);
355
356        // Create validators
357        let skill_validator = Arc::new(crate::validation::SkillValidator::new());
358        let zip_validator = Arc::new(crate::validation::ZipValidator::new());
359
360        // Create event bus
361        let event_bus = Arc::new(crate::events::EventBus::new());
362
363        // Create skill manager
364        let skill_manager = Arc::new(crate::core::skill_manager::SkillManager::new());
365
366        // Create metadata service (depends on skill manager)
367        let metadata_service = Arc::new(crate::core::metadata::MetadataServiceImpl::new(
368            skill_manager.clone(),
369        ));
370
371        // Create vector index service if embedding is configured
372        let vector_index_service = if let Some(embedding_config) = &config.embedding {
373            Some(Arc::new(
374                crate::core::vector_index::VectorIndexServiceImpl::with_config(
375                    embedding_config,
376                    &config.skill_storage_path,
377                ),
378            )
379                as Arc<dyn crate::core::vector_index::VectorIndexService>)
380        } else {
381            None
382        };
383
384        // Create hot reload manager if enabled
385        let hot_reload_manager = if config.hot_reload.enabled {
386            Some(Arc::new(crate::storage::hot_reload::HotReloadManager::new(
387                storage.clone(),
388                event_bus.clone(),
389            )?))
390        } else {
391            None
392        };
393
394        Ok(Self {
395            config,
396            skill_manager,
397            metadata_service,
398            vector_index_service,
399            storage,
400            zip_handler,
401            sandbox,
402            skill_validator,
403            zip_validator,
404            event_bus,
405            hot_reload_manager,
406            initialized: false,
407        })
408    }
409
410    /// Initialize the service
411    pub async fn initialize(&mut self) -> Result<(), ServiceError> {
412        if self.initialized {
413            return Ok(());
414        }
415
416        info!("Initializing service components...");
417
418        // Initialize storage
419        self.storage.initialize().await?;
420
421        // Initialize hot reload if enabled
422        if let Some(hot_reload) = &self.hot_reload_manager {
423            hot_reload
424                .enable_hot_reloading(self.config.hot_reload.watch_paths.clone())
425                .await?;
426        }
427
428        // Auto-index skills from filesystem
429        self.auto_index_skills_from_filesystem().await?;
430
431        self.initialized = true;
432        info!("Service initialization complete");
433
434        Ok(())
435    }
436
437    /// Shutdown the service
438    pub async fn shutdown(&mut self) -> Result<(), ServiceError> {
439        info!("Shutting down service...");
440
441        // Disable hot reloading
442        if let Some(hot_reload) = &self.hot_reload_manager {
443            hot_reload.disable_hot_reloading().await?;
444        }
445
446        // Clear any caches
447        self.storage.clear_cache().await?;
448
449        self.initialized = false;
450        info!("Service shutdown complete");
451
452        Ok(())
453    }
454
455    /// Get skill manager service
456    pub fn skill_manager(&self) -> Arc<dyn crate::core::skill_manager::SkillManagementService> {
457        self.skill_manager.clone()
458    }
459
460    /// Get metadata service
461    pub fn metadata_service(&self) -> Arc<dyn crate::core::metadata::MetadataService> {
462        self.metadata_service.clone()
463    }
464
465    /// Get vector index service (if available)
466    pub fn vector_index_service(
467        &self,
468    ) -> Option<Arc<dyn crate::core::vector_index::VectorIndexService>> {
469        self.vector_index_service.clone()
470    }
471
472    /// Get loading service
473    pub fn loading_service(&self) -> Arc<dyn crate::core::loading::ProgressiveLoadingService> {
474        Arc::new(crate::core::loading::LoadingService::new())
475    }
476
477    /// Get tool calling service
478    pub fn tool_service(&self) -> Arc<dyn crate::core::tool_calling::ToolCallingService> {
479        Arc::new(crate::core::tool_calling::ToolCallingServiceImpl::new())
480    }
481
482    /// Get routing service
483    pub fn routing_service(&self) -> Arc<dyn crate::core::routing::RoutingService> {
484        Arc::new(crate::core::routing::RoutingServiceImpl::new(
485            self.metadata_service.clone(),
486        ))
487    }
488
489    /// Get service configuration
490    pub fn config(&self) -> &ServiceConfig {
491        &self.config
492    }
493
494    /// Get context resolver for machine-first skill resolution
495    pub fn context_resolver(&self) -> crate::core::context_resolver::ContextResolver {
496        crate::core::context_resolver::ContextResolver::new(
497            self.skill_manager.clone(),
498            self.metadata_service.clone(),
499            self.vector_index_service.clone(),
500            self.config.embedding.clone(),
501            self.config.skill_storage_path.clone(),
502        )
503    }
504
505    /// Check if service is initialized
506    pub fn is_initialized(&self) -> bool {
507        self.initialized
508    }
509
510    /// Auto-index skills from the filesystem by scanning for SKILL.md files
511    async fn auto_index_skills_from_filesystem(&self) -> Result<(), ServiceError> {
512        use walkdir::WalkDir;
513
514        let mut indexed_count = 0;
515
516        // Walk the skills directory recursively
517        for entry in WalkDir::new(&self.config.skill_storage_path)
518            .into_iter()
519            .filter_entry(|e| {
520                // Skip hidden directories and common system directories
521                !e.file_name()
522                    .to_str()
523                    .map(|s| {
524                        s.starts_with('.')
525                            || s == "node_modules"
526                            || s == "target"
527                            || s == "__pycache__"
528                    })
529                    .unwrap_or(false)
530            })
531        {
532            let entry = entry.map_err(|e| {
533                ServiceError::Custom(format!("Failed to read directory entry: {}", e))
534            })?;
535
536            // Look for SKILL.md or skill.md files
537            if entry.file_type().is_file() {
538                let fname = entry.file_name();
539                if fname == "SKILL.md" || fname == "skill.md" {
540                    let skill_file = entry.path();
541
542                    match self.try_index_skill_from_file(skill_file).await {
543                        Ok(_) => {
544                            indexed_count += 1;
545                        }
546                        Err(e) => {
547                            tracing::warn!(
548                                "Failed to index skill at {}: {}",
549                                skill_file.display(),
550                                e
551                            );
552                        }
553                    }
554                }
555            }
556        }
557
558        if indexed_count > 0 {
559            info!("Auto-indexed {} skills from filesystem", indexed_count);
560        }
561
562        Ok(())
563    }
564
565    /// Try to index a single skill from its SKILL.md file
566    async fn try_index_skill_from_file(
567        &self,
568        skill_file: &std::path::Path,
569    ) -> Result<(), ServiceError> {
570        // Read the SKILL.md file
571        let content = tokio::fs::read_to_string(skill_file)
572            .await
573            .map_err(|e| ServiceError::Custom(format!("Failed to read SKILL.md: {}", e)))?;
574
575        // Parse the frontmatter
576        let frontmatter = crate::core::metadata::parse_yaml_frontmatter(&content)?;
577
578        // Get the skill directory (parent of SKILL.md)
579        let skill_dir = skill_file
580            .parent()
581            .ok_or_else(|| ServiceError::Custom("SKILL.md has no parent directory".to_string()))?;
582
583        // Use directory name as skill ID
584        let skill_id_str = skill_dir
585            .file_name()
586            .and_then(|n| n.to_str())
587            .ok_or_else(|| ServiceError::Custom("Invalid skill directory name".to_string()))?
588            .to_string();
589        let skill_id = SkillId::new(skill_id_str)?;
590
591        // Create skill definition from frontmatter
592        let mut skill = crate::core::skill_manager::SkillDefinition::new(
593            skill_id.clone(),
594            frontmatter.name,
595            frontmatter.description,
596            frontmatter.version.unwrap_or_else(|| "1.0.0".to_string()),
597        );
598
599        // Set additional fields
600        skill.author = frontmatter.author;
601        skill.skill_file = skill_file.to_path_buf();
602
603        // Set timestamps
604        skill.created_at = chrono::Utc::now();
605        skill.updated_at = chrono::Utc::now();
606
607        // Try to register the skill (ignore if it already exists)
608        match self.skill_manager.register_skill(skill).await {
609            Ok(_) => Ok(()),
610            Err(crate::core::service::ServiceError::Custom(msg))
611                if msg.contains("already exists") =>
612            {
613                // Skill already registered, that's fine
614                Ok(())
615            }
616            Err(e) => Err(e),
617        }
618    }
619}
620
621#[cfg(test)]
622#[allow(clippy::unwrap_used)]
623mod tests {
624    use super::*;
625    use tempfile::TempDir;
626
627    #[tokio::test]
628    async fn test_service_creation() {
629        let temp_dir = TempDir::new().unwrap();
630        let config = ServiceConfig {
631            skill_storage_path: temp_dir.path().to_path_buf(),
632            ..Default::default()
633        };
634
635        let mut service = FastSkillService::new(config).await.unwrap();
636        assert!(!service.is_initialized());
637
638        service.initialize().await.unwrap();
639        assert!(service.is_initialized());
640    }
641
642    #[tokio::test]
643    async fn test_service_shutdown() {
644        let temp_dir = TempDir::new().unwrap();
645        let config = ServiceConfig {
646            skill_storage_path: temp_dir.path().to_path_buf(),
647            ..Default::default()
648        };
649
650        let mut service = FastSkillService::new(config).await.unwrap();
651        service.initialize().await.unwrap();
652
653        service.shutdown().await.unwrap();
654        assert!(!service.is_initialized());
655    }
656
657    #[test]
658    fn test_skill_id_new_validates_input() {
659        assert!(SkillId::new("valid-id".to_string()).is_ok());
660        assert!(SkillId::new("valid_id_123".to_string()).is_ok());
661        assert!(SkillId::new("".to_string()).is_err());
662        assert!(SkillId::new("bad/id".to_string()).is_err());
663        assert!(SkillId::new("id with spaces".to_string()).is_err());
664    }
665
666    #[test]
667    fn test_skill_id_try_from_validates_input() {
668        // TryFrom should validate input
669        assert!(SkillId::try_from("valid-id".to_string()).is_ok());
670        assert!(SkillId::try_from("".to_string()).is_err());
671        assert!(SkillId::try_from("bad/id".to_string()).is_err());
672    }
673}