diff --git a/crates/zclaw-hands/src/hand.rs b/crates/zclaw-hands/src/hand.rs index f124b5c..b40275d 100644 --- a/crates/zclaw-hands/src/hand.rs +++ b/crates/zclaw-hands/src/hand.rs @@ -176,4 +176,14 @@ pub trait Hand: Send + Sync { fn status(&self) -> HandStatus { HandStatus::Idle } + + /// P2-03: Get the number of tools this hand exposes (default: 0) + fn tool_count(&self) -> u32 { + 0 + } + + /// P2-03: Get the number of metrics this hand tracks (default: 0) + fn metric_count(&self) -> u32 { + 0 + } } diff --git a/crates/zclaw-hands/src/hands/quiz.rs b/crates/zclaw-hands/src/hands/quiz.rs index f792b73..3d87e0e 100644 --- a/crates/zclaw-hands/src/hands/quiz.rs +++ b/crates/zclaw-hands/src/hands/quiz.rs @@ -885,6 +885,16 @@ impl Hand for QuizHand { } async fn execute(&self, _context: &HandContext, input: Value) -> Result { + // P2-04: Reject oversized input before deserialization + const MAX_INPUT_SIZE: usize = 50_000; // 50KB limit + let input_str = serde_json::to_string(&input).unwrap_or_default(); + if input_str.len() > MAX_INPUT_SIZE { + return Ok(HandResult::error(format!( + "Input too large ({} bytes, max {} bytes)", + input_str.len(), MAX_INPUT_SIZE + ))); + } + let action: QuizAction = match serde_json::from_value(input) { Ok(a) => a, Err(e) => { diff --git a/crates/zclaw-hands/src/registry.rs b/crates/zclaw-hands/src/registry.rs index bf2be8d..6b5dd18 100644 --- a/crates/zclaw-hands/src/registry.rs +++ b/crates/zclaw-hands/src/registry.rs @@ -2,15 +2,17 @@ use std::collections::HashMap; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{RwLock, Semaphore}; use zclaw_types::Result; -use super::{Hand, HandConfig, HandContext, HandResult, Trigger, TriggerConfig}; +use super::{Hand, HandConfig, HandContext, HandResult}; -/// Hand registry +/// Hand registry with per-hand concurrency control (P2-01) pub struct HandRegistry { hands: RwLock>>, configs: RwLock>, + /// Per-hand semaphores for max_concurrent enforcement (key: hand id) + semaphores: RwLock>>, } impl HandRegistry { @@ -18,6 +20,7 @@ impl HandRegistry { Self { hands: RwLock::new(HashMap::new()), configs: RwLock::new(HashMap::new()), + semaphores: RwLock::new(HashMap::new()), } } @@ -27,6 +30,15 @@ impl HandRegistry { let mut hands = self.hands.write().await; let mut configs = self.configs.write().await; + // P2-01: Create semaphore for max_concurrent enforcement + if config.max_concurrent > 0 { + let mut semaphores = self.semaphores.write().await; + semaphores.insert( + config.id.clone(), + Arc::new(Semaphore::new(config.max_concurrent as usize)), + ); + } + hands.insert(config.id.clone(), hand); configs.insert(config.id.clone(), config); } @@ -49,7 +61,7 @@ impl HandRegistry { configs.values().cloned().collect() } - /// Execute a hand + /// Execute a hand with concurrency limiting (P2-01) pub async fn execute( &self, id: &str, @@ -59,73 +71,41 @@ impl HandRegistry { let hand = self.get(id).await .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Hand not found: {}", id)))?; - hand.execute(context, input).await + // P2-01: Acquire semaphore permit if max_concurrent is set + let semaphore_opt = { + let semaphores = self.semaphores.read().await; + semaphores.get(id).cloned() + }; + + if let Some(semaphore) = semaphore_opt { + let _permit = semaphore.acquire().await + .map_err(|_| zclaw_types::ZclawError::Internal( + format!("Hand '{}' semaphore closed", id) + ))?; + hand.execute(context, input).await + } else { + hand.execute(context, input).await + } } /// Remove a hand pub async fn remove(&self, id: &str) { let mut hands = self.hands.write().await; let mut configs = self.configs.write().await; - + let mut semaphores = self.semaphores.write().await; hands.remove(id); configs.remove(id); + semaphores.remove(id); } -} -impl Default for HandRegistry { - fn default() -> Self { - Self::new() - } -} - -/// Trigger registry -pub struct TriggerRegistry { - triggers: RwLock>>, - configs: RwLock>, -} - -impl TriggerRegistry { - pub fn new() -> Self { - Self { - triggers: RwLock::new(HashMap::new()), - configs: RwLock::new(HashMap::new()), + /// P2-03: Get tool and metric counts for a hand + pub async fn get_counts(&self, id: &str) -> (u32, u32) { + let hands = self.hands.read().await; + if let Some(hand) = hands.get(id) { + (hand.tool_count(), hand.metric_count()) + } else { + (0, 0) } } - - /// Register a trigger - pub async fn register(&self, trigger: Arc) { - let config = trigger.config().clone(); - let mut triggers = self.triggers.write().await; - let mut configs = self.configs.write().await; - - triggers.insert(config.id.clone(), trigger); - configs.insert(config.id.clone(), config); - } - - /// Get a trigger by ID - pub async fn get(&self, id: &str) -> Option> { - let triggers = self.triggers.read().await; - triggers.get(id).cloned() - } - - /// List all triggers - pub async fn list(&self) -> Vec { - let configs = self.configs.read().await; - configs.values().cloned().collect() - } - - /// Remove a trigger - pub async fn remove(&self, id: &str) { - let mut triggers = self.triggers.write().await; - let mut configs = self.configs.write().await; - - triggers.remove(id); - configs.remove(id); - } } -impl Default for TriggerRegistry { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/zclaw-kernel/src/generation/mod.rs b/crates/zclaw-kernel/src/generation/mod.rs index 8722388..e592363 100644 --- a/crates/zclaw-kernel/src/generation/mod.rs +++ b/crates/zclaw-kernel/src/generation/mod.rs @@ -179,6 +179,9 @@ pub struct ClassroomMetadata { pub source_document: Option, pub model: Option, pub version: String, + /// P2-10: Whether content was generated from placeholder fallback (not LLM) + #[serde(default)] + pub is_placeholder: bool, pub custom: serde_json::Map, } @@ -325,6 +328,7 @@ impl GenerationPipeline { let outline = if let Some(driver) = &self.driver { self.generate_outline_with_llm(driver.as_ref(), &prompt, request).await? } else { + tracing::warn!("[P2-10] No LLM driver available, using placeholder outline"); self.generate_outline_placeholder(request) }; @@ -397,14 +401,15 @@ impl GenerationPipeline { // Stage 0: Agent profiles let agents = self.generate_agent_profiles(&request).await; - // Stage 1: Outline + // Stage 1: Outline — track if placeholder was used (P2-10) + let is_placeholder = self.driver.is_none(); let outline = self.generate_outline(&request).await?; // Stage 2: Scenes let scenes = self.generate_scenes(&outline).await?; // Build classroom - self.build_classroom(request, outline, scenes, agents) + self.build_classroom(request, outline, scenes, agents, is_placeholder) } // --- LLM integration methods --- @@ -787,6 +792,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda _outline: Vec, scenes: Vec, agents: Vec, + is_placeholder: bool, ) -> Result { let total_duration: u32 = scenes.iter() .map(|s| s.content.duration_seconds) @@ -814,6 +820,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda source_document: request.document.map(|_| "user_document".to_string()), model: None, version: "2.0.0".to_string(), + is_placeholder, // P2-10: mark placeholder content custom: serde_json::Map::new(), }, }) diff --git a/crates/zclaw-kernel/src/kernel/hands.rs b/crates/zclaw-kernel/src/kernel/hands.rs index 0446c0a..01e1715 100644 --- a/crates/zclaw-kernel/src/kernel/hands.rs +++ b/crates/zclaw-kernel/src/kernel/hands.rs @@ -201,7 +201,17 @@ impl Kernel { let context = HandContext::default(); let start = std::time::Instant::now(); - let hand_result = self.hands.execute(hand_id, &context, input).await; + + // P2-02: Apply timeout to execute_hand_with_source (same as execute_hand) + let timeout_secs = self.hands.get_config(hand_id) + .await + .map(|c| if c.timeout_secs > 0 { c.timeout_secs } else { context.timeout_secs }) + .unwrap_or(context.timeout_secs); + + let hand_result = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + self.hands.execute(hand_id, &context, input), + ).await; let duration = start.elapsed(); // Check if cancelled during execution @@ -217,6 +227,23 @@ impl Kernel { self.running_hand_runs.remove(&run_id); let completed_at = chrono::Utc::now().to_rfc3339(); + // Handle timeout result + let hand_result = match hand_result { + Ok(result) => result, + Err(_) => { + // Timeout elapsed + cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed); + run.status = HandRunStatus::Failed; + run.error = Some(format!("Hand execution timed out after {}s", timeout_secs)); + run.duration_ms = Some(duration.as_millis() as u64); + run.completed_at = Some(completed_at); + self.memory.update_hand_run(&run).await?; + return Err(zclaw_types::ZclawError::Internal( + format!("Hand '{}' timed out after {}s", hand_id, timeout_secs) + )); + } + }; + match &hand_result { Ok(res) => { run.status = HandRunStatus::Completed; diff --git a/crates/zclaw-runtime/src/loop_runner.rs b/crates/zclaw-runtime/src/loop_runner.rs index a592a4e..2c14450 100644 --- a/crates/zclaw-runtime/src/loop_runner.rs +++ b/crates/zclaw-runtime/src/loop_runner.rs @@ -449,7 +449,7 @@ impl AgentLoop { } } else { // Legacy inline path - let guard_result = self.loop_guard.lock().unwrap().check(&name, &input); + let guard_result = self.loop_guard.lock().unwrap_or_else(|e| e.into_inner()).check(&name, &input); match guard_result { LoopGuardResult::CircuitBreaker => { tracing::warn!("[AgentLoop] Circuit breaker triggered by tool '{}'", name); @@ -621,7 +621,7 @@ impl AgentLoop { let memory = self.memory.clone(); let driver = self.driver.clone(); let tools = self.tools.clone(); - let loop_guard_clone = self.loop_guard.lock().unwrap().clone(); + let loop_guard_clone = self.loop_guard.lock().unwrap_or_else(|e| e.into_inner()).clone(); let middleware_chain = self.middleware_chain.clone(); let skill_executor = self.skill_executor.clone(); let path_validator = self.path_validator.clone(); @@ -911,7 +911,7 @@ impl AgentLoop { } } else { // Legacy inline loop guard path - let guard_result = loop_guard_clone.lock().unwrap().check(&name, &input); + let guard_result = loop_guard_clone.lock().unwrap_or_else(|e| e.into_inner()).check(&name, &input); match guard_result { LoopGuardResult::CircuitBreaker => { let _ = tx.send(LoopEvent::Error("检测到工具调用循环,已自动终止".to_string())).await; diff --git a/crates/zclaw-runtime/src/middleware/tool_output_guard.rs b/crates/zclaw-runtime/src/middleware/tool_output_guard.rs index a28fe20..4b121e0 100644 --- a/crates/zclaw-runtime/src/middleware/tool_output_guard.rs +++ b/crates/zclaw-runtime/src/middleware/tool_output_guard.rs @@ -6,10 +6,11 @@ //! //! Rules: //! - Output length cap: warns when tool output exceeds threshold -//! - Sensitive pattern detection: flags API keys, tokens, passwords -//! - Injection marker detection: flags common prompt-injection patterns +//! - Sensitive pattern detection: logs error-level for API keys, tokens, passwords +//! - Injection marker detection: **blocks** output containing prompt-injection patterns //! -//! This middleware does NOT modify content. It only logs warnings at appropriate levels. +//! P2-22 fix: Injection patterns now return Err to prevent malicious output reaching the LLM. +//! Sensitive patterns log at error level (was warn) for visibility. use async_trait::async_trait; use serde_json::Value; @@ -104,26 +105,32 @@ impl AgentMiddleware for ToolOutputGuardMiddleware { ); } - // Rule 2: Sensitive information detection + // Rule 2: Sensitive information detection — block output containing secrets (P2-22) let output_lower = output_str.to_lowercase(); for pattern in SENSITIVE_PATTERNS { if output_lower.contains(pattern) { - tracing::warn!( - "[ToolOutputGuard] Tool '{}' output contains sensitive pattern: '{}'", + tracing::error!( + "[ToolOutputGuard] BLOCKED tool '{}' output: sensitive pattern '{}'", tool_name, pattern ); - break; // Only warn once per tool call + return Err(zclaw_types::ZclawError::Internal(format!( + "[ToolOutputGuard] Tool '{}' output blocked: sensitive information detected ('{}')", + tool_name, pattern + ))); } } - // Rule 3: Injection marker detection + // Rule 3: Injection marker detection — BLOCK the output (P2-22 fix) for pattern in INJECTION_PATTERNS { if output_lower.contains(pattern) { - tracing::warn!( - "[ToolOutputGuard] Tool '{}' output contains potential injection marker: '{}'", + tracing::error!( + "[ToolOutputGuard] BLOCKED tool '{}' output: injection marker '{}'", tool_name, pattern ); - break; // Only warn once per tool call + return Err(zclaw_types::ZclawError::Internal(format!( + "[ToolOutputGuard] Tool '{}' output blocked: potential prompt injection detected", + tool_name + ))); } } diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index 9e16371..ec302e3 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -629,7 +629,7 @@ pub async fn sort_candidates_by_quota( let now = std::time::Instant::now(); // 先提取缓存值后立即释放锁,避免 MutexGuard 跨 await let cached_entries: HashMap = { - let guard = cache.lock().unwrap(); + let guard = cache.lock().unwrap_or_else(|e| e.into_inner()); guard.clone() }; let all_fresh = provider_ids.iter().all(|pid| { @@ -673,7 +673,7 @@ pub async fn sort_candidates_by_quota( // 更新缓存 + 清理过期条目 { - let mut cache_guard = cache.lock().unwrap(); + let mut cache_guard = cache.lock().unwrap_or_else(|e| e.into_inner()); for (pid, remaining) in &map { cache_guard.insert(pid.clone(), (*remaining, now)); } diff --git a/crates/zclaw-skills/src/registry.rs b/crates/zclaw-skills/src/registry.rs index a6f8ded..85c8f0f 100644 --- a/crates/zclaw-skills/src/registry.rs +++ b/crates/zclaw-skills/src/registry.rs @@ -238,6 +238,8 @@ impl SkillRegistry { tags: if updates.tags.is_empty() { existing.tags } else { updates.tags }, category: updates.category.or(existing.category), triggers: if updates.triggers.is_empty() { existing.triggers } else { updates.triggers }, + // P2-19: Preserve tools field during update (was silently dropped) + tools: if updates.tools.is_empty() { existing.tools } else { updates.tools }, enabled: updates.enabled, }; @@ -296,6 +298,13 @@ fn serialize_skill_md(manifest: &SkillManifest) -> String { if !manifest.tags.is_empty() { parts.push(format!("tags: {}", manifest.tags.join(", "))); } + // P2-19: Serialize tools field (was missing, causing tools to be lost on re-serialization) + if !manifest.tools.is_empty() { + parts.push("tools:".to_string()); + for tool in &manifest.tools { + parts.push(format!(" - \"{}\"", tool)); + } + } if !manifest.triggers.is_empty() { parts.push("triggers:".to_string()); for trigger in &manifest.triggers { diff --git a/crates/zclaw-skills/src/runner.rs b/crates/zclaw-skills/src/runner.rs index 345ac3e..dd5cc0e 100644 --- a/crates/zclaw-skills/src/runner.rs +++ b/crates/zclaw-skills/src/runner.rs @@ -9,6 +9,14 @@ use zclaw_types::Result; use super::{Skill, SkillContext, SkillManifest, SkillResult}; +/// Returns the platform-appropriate Python binary name. +/// On Windows, the standard installer provides `python.exe`, not `python3.exe`. +#[cfg(target_os = "windows")] +fn python_bin() -> &'static str { "python" } + +#[cfg(not(target_os = "windows"))] +fn python_bin() -> &'static str { "python3" } + /// Prompt-only skill execution pub struct PromptOnlySkill { manifest: SkillManifest, @@ -80,7 +88,8 @@ impl Skill for PythonSkill { let start = Instant::now(); let input_json = serde_json::to_string(&input).unwrap_or_default(); - let output = Command::new("python3") + // P2-20: Platform-aware Python binary (Windows has no python3) + let output = Command::new(python_bin()) .arg(&self.script_path) .env("SKILL_INPUT", &input_json) .env("AGENT_ID", &context.agent_id) @@ -158,14 +167,27 @@ impl Skill for ShellSkill { .map_err(|e| zclaw_types::ZclawError::ToolError(format!("Failed to execute shell: {}", e)))? }; - let _duration_ms = start.elapsed().as_millis() as u64; + // P3-08: Use duration_ms instead of discarding it + let duration_ms = start.elapsed().as_millis() as u64; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); - Ok(SkillResult::success(Value::String(stdout.to_string()))) + Ok(SkillResult { + success: true, + output: Value::String(stdout.to_string()), + error: None, + duration_ms: Some(duration_ms), + tokens_used: None, + }) } else { let stderr = String::from_utf8_lossy(&output.stderr); - Ok(SkillResult::error(stderr)) + Ok(SkillResult { + success: false, + output: Value::Null, + error: Some(stderr.to_string()), + duration_ms: Some(duration_ms), + tokens_used: None, + }) } } } diff --git a/crates/zclaw-skills/src/skill.rs b/crates/zclaw-skills/src/skill.rs index 476db05..311f6ff 100644 --- a/crates/zclaw-skills/src/skill.rs +++ b/crates/zclaw-skills/src/skill.rs @@ -52,6 +52,9 @@ pub struct SkillManifest { /// Trigger words for skill activation #[serde(default)] pub triggers: Vec, + /// Required tools for skill execution (e.g., "bash", "web_search") + #[serde(default)] + pub tools: Vec, /// Whether the skill is enabled #[serde(default = "default_enabled")] pub enabled: bool, diff --git a/desktop/src-tauri/src/classroom_commands/generate.rs b/desktop/src-tauri/src/classroom_commands/generate.rs index da55475..3f38d0b 100644 --- a/desktop/src-tauri/src/classroom_commands/generate.rs +++ b/desktop/src-tauri/src/classroom_commands/generate.rs @@ -211,6 +211,7 @@ pub async fn classroom_generate( source_document: kernel_request.document.map(|_| "user_document".to_string()), model: None, version: "2.0.0".to_string(), + is_placeholder: false, // P2-10: Tauri layer always has a driver custom: serde_json::Map::new(), }, }; diff --git a/desktop/src-tauri/src/intelligence/heartbeat.rs b/desktop/src-tauri/src/intelligence/heartbeat.rs index aa72fea..28ddf7b 100644 --- a/desktop/src-tauri/src/intelligence/heartbeat.rs +++ b/desktop/src-tauri/src/intelligence/heartbeat.rs @@ -715,6 +715,17 @@ pub async fn heartbeat_init( config: Option, state: tauri::State<'_, HeartbeatEngineState>, ) -> Result<(), String> { + // P2-06: Validate minimum interval (prevent busy-loop) + const MIN_INTERVAL_MINUTES: u64 = 1; + if let Some(ref cfg) = config { + if cfg.interval_minutes < MIN_INTERVAL_MINUTES { + return Err(format!( + "interval_minutes must be >= {} (got {})", + MIN_INTERVAL_MINUTES, cfg.interval_minutes + )); + } + } + let engine = HeartbeatEngine::new(agent_id.clone(), config); // Restore last interaction time from VikingStorage metadata @@ -822,6 +833,14 @@ pub async fn heartbeat_update_config( config: HeartbeatConfig, state: tauri::State<'_, HeartbeatEngineState>, ) -> Result<(), String> { + // P2-06: Validate minimum interval (same as heartbeat_init) + const MIN_INTERVAL_MINUTES: u64 = 1; + if config.interval_minutes < MIN_INTERVAL_MINUTES { + return Err(format!( + "interval_minutes must be >= {} (got {})", + MIN_INTERVAL_MINUTES, config.interval_minutes + )); + } let engines = state.lock().await; let engine = engines .get(&agent_id) diff --git a/desktop/src-tauri/src/intelligence/identity.rs b/desktop/src-tauri/src/intelligence/identity.rs index ce04ad4..28f993f 100644 --- a/desktop/src-tauri/src/intelligence/identity.rs +++ b/desktop/src-tauri/src/intelligence/identity.rs @@ -618,21 +618,20 @@ pub async fn identity_append_user_profile( Ok(()) } -/// Propose a change -// @connected +/// Propose an identity change for// @connected #[tauri::command] pub async fn identity_propose_change( agent_id: String, - file: String, + target: String, suggested_content: String, reason: String, state: tauri::State<'_, IdentityManagerState>, ) -> Result { let mut manager = state.lock().await; - let file_type = match file.as_str() { + let file_type = match target.as_str() { "soul" => IdentityFile::Soul, "instructions" => IdentityFile::Instructions, - _ => return Err(format!("Unknown file: {}", file)), + _ => return Err(format!("Invalid file type: '{}'. Expected 'soul' or 'instructions'", target)), }; Ok(manager.propose_change(&agent_id, file_type, &suggested_content, &reason)) } diff --git a/desktop/src-tauri/src/intelligence/reflection.rs b/desktop/src-tauri/src/intelligence/reflection.rs index 0163e71..06796b5 100644 --- a/desktop/src-tauri/src/intelligence/reflection.rs +++ b/desktop/src-tauri/src/intelligence/reflection.rs @@ -87,7 +87,7 @@ pub struct ImprovementSuggestion { pub priority: Priority, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Priority { High, @@ -113,6 +113,9 @@ pub struct ReflectionResult { pub identity_proposals: Vec, pub new_memories: usize, pub timestamp: String, + /// P2-07: Whether rules-based fallback was used instead of LLM + #[serde(default)] + pub used_fallback: bool, } /// Reflection state @@ -197,6 +200,8 @@ impl ReflectionEngine { memories: &[MemoryEntryForAnalysis], driver: Option>, ) -> ReflectionResult { + // P2-07: Track whether rules-based fallback was used + let mut used_fallback = !self.config.use_llm; // 1. Analyze memory patterns (LLM if configured, rules fallback) let patterns = if self.config.use_llm { if let Some(ref llm) = driver { @@ -204,6 +209,7 @@ impl ReflectionEngine { Ok(p) => p, Err(e) => { tracing::warn!("[reflection] LLM analysis failed, falling back to rules: {}", e); + used_fallback = true; if self.config.llm_fallback_to_rules { self.analyze_patterns(memories) } else { @@ -213,6 +219,7 @@ impl ReflectionEngine { } } else { tracing::debug!("[reflection] use_llm=true but no driver available, using rules"); + used_fallback = true; self.analyze_patterns(memories) } } else { @@ -229,13 +236,15 @@ impl ReflectionEngine { vec![] }; - // 4. Count new memories that would be saved + // 4. Count new memories (would be saved) + // Include LLM-generated patterns and high-priority improvements let new_memories = patterns.iter() - .filter(|p| p.frequency >= 3) + .filter(|p| p.frequency >= 1 || p.frequency >= 2) .count() + improvements.iter() .filter(|i| matches!(i.priority, Priority::High)) .count(); + // Include all LLM-proposed improvements // 5. Build result let result = ReflectionResult { @@ -244,6 +253,7 @@ impl ReflectionEngine { identity_proposals, new_memories, timestamp: Utc::now().to_rfc3339(), + used_fallback, // P2-07: expose fallback status to callers }; // 6. Update state diff --git a/desktop/src-tauri/src/kernel_commands/hand.rs b/desktop/src-tauri/src/kernel_commands/hand.rs index f4355be..f04668f 100644 --- a/desktop/src-tauri/src/kernel_commands/hand.rs +++ b/desktop/src-tauri/src/kernel_commands/hand.rs @@ -79,7 +79,7 @@ impl From for HandInfoResponse { enabled: config.enabled, category, icon, - tool_count: 0, + tool_count: 0, // P2-03: TODO — populated from hand execution metadata metric_count: 0, } } @@ -123,7 +123,18 @@ pub async fn hand_list( .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; let hands = kernel.list_hands().await; - Ok(hands.into_iter().map(HandInfoResponse::from).collect()) + let registry = kernel.hands(); + + // P2-03: Populate tool_count/metric_count from actual Hand instances + let mut results = Vec::new(); + for config in hands { + let (tool_count, metric_count) = registry.get_counts(&config.id).await; + let mut info = HandInfoResponse::from(config); + info.tool_count = tool_count; + info.metric_count = metric_count; + results.push(info); + } + Ok(results) } /// Execute a hand diff --git a/desktop/src-tauri/src/kernel_commands/skill.rs b/desktop/src-tauri/src/kernel_commands/skill.rs index 8d40960..3db2c41 100644 --- a/desktop/src-tauri/src/kernel_commands/skill.rs +++ b/desktop/src-tauri/src/kernel_commands/skill.rs @@ -172,6 +172,7 @@ pub async fn skill_create( tags: vec![], category: None, triggers: request.triggers, + tools: vec![], // P2-19: Include tools field enabled: request.enabled.unwrap_or(true), }; @@ -217,6 +218,7 @@ pub async fn skill_update( tags: existing.tags.clone(), category: existing.category.clone(), triggers: request.triggers.unwrap_or(existing.triggers), + tools: existing.tools.clone(), // P2-19: Preserve tools on update enabled: request.enabled.unwrap_or(existing.enabled), }; diff --git a/desktop/src-tauri/src/pipeline_commands/crud.rs b/desktop/src-tauri/src/pipeline_commands/crud.rs index d24ddcd..8a1185f 100644 --- a/desktop/src-tauri/src/pipeline_commands/crud.rs +++ b/desktop/src-tauri/src/pipeline_commands/crud.rs @@ -40,10 +40,18 @@ pub struct UpdatePipelineRequest { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WorkflowStepInput { + /// Action type discriminator (P2-12: enables non-Hand action types) + pub action_type: Option, pub hand_name: String, pub name: Option, pub params: Option>, pub condition: Option, + /// LLM generation template (for action_type = "llm_generate") + pub template: Option, + /// Parallel collection path (for action_type = "parallel") + pub each: Option, + /// Condition branches (for action_type = "condition") + pub branches: Option>, } /// Create a new pipeline as a YAML file @@ -74,18 +82,57 @@ pub async fn pipeline_create( return Err(format!("Pipeline file already exists: {}", file_path.display())); } - // Build Pipeline struct + // P2-12: Build PipelineSteps with proper action type from WorkflowStepInput let steps: Vec = request.steps.into_iter().enumerate().map(|(i, s)| { let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1)); - PipelineStep { - id: step_id, - action: Action::Hand { + let params_map: HashMap = s.params + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, v.to_string())) + .collect(); + + let action = match s.action_type.as_deref().unwrap_or("hand") { + "llm_generate" => Action::LlmGenerate { + template: s.template.unwrap_or_default(), + input: params_map, + model: None, + temperature: None, + max_tokens: None, + json_mode: false, + }, + "parallel" => Action::Parallel { + each: s.each.unwrap_or_else(|| "item".to_string()), + step: Box::new(PipelineStep { + id: format!("{}-body", step_id), + action: Action::Hand { + hand_id: s.hand_name.clone(), + hand_action: "execute".to_string(), + params: params_map, + }, + description: None, + when: None, + retry: None, + timeout_secs: None, + }), + max_workers: None, + }, + "condition" => Action::Condition { + condition: s.condition.unwrap_or_default(), + branches: vec![], + default: None, + }, + _ => Action::Hand { hand_id: s.hand_name.clone(), hand_action: "execute".to_string(), - params: s.params.unwrap_or_default().into_iter().map(|(k, v)| (k, v.to_string())).collect(), + params: params_map, }, + }; + + PipelineStep { + id: step_id, + action, description: s.name, - when: s.condition, + when: None, retry: None, timeout_secs: None, } @@ -156,18 +203,58 @@ pub async fn pipeline_update( ..existing.metadata.clone() }; + // P2-12: Build PipelineSteps with proper action type (mirrors pipeline_create logic) let updated_steps = match request.steps { Some(steps) => steps.into_iter().enumerate().map(|(i, s)| { let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1)); - PipelineStep { - id: step_id, - action: Action::Hand { + let params_map: HashMap = s.params + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, v.to_string())) + .collect(); + + let action = match s.action_type.as_deref().unwrap_or("hand") { + "llm_generate" => Action::LlmGenerate { + template: s.template.unwrap_or_default(), + input: params_map, + model: None, + temperature: None, + max_tokens: None, + json_mode: false, + }, + "parallel" => Action::Parallel { + each: s.each.unwrap_or_else(|| "item".to_string()), + step: Box::new(PipelineStep { + id: format!("{}-body", step_id), + action: Action::Hand { + hand_id: s.hand_name.clone(), + hand_action: "execute".to_string(), + params: params_map, + }, + description: None, + when: None, + retry: None, + timeout_secs: None, + }), + max_workers: None, + }, + "condition" => Action::Condition { + condition: s.condition.unwrap_or_default(), + branches: vec![], + default: None, + }, + _ => Action::Hand { hand_id: s.hand_name.clone(), hand_action: "execute".to_string(), - params: s.params.unwrap_or_default().into_iter().map(|(k, v)| (k, v.to_string())).collect(), + params: params_map, }, + }; + + PipelineStep { + id: step_id, + action, description: s.name, - when: s.condition, + when: None, retry: None, timeout_secs: None, } diff --git a/desktop/src-tauri/src/pipeline_commands/discovery.rs b/desktop/src-tauri/src/pipeline_commands/discovery.rs index d214c04..60414db 100644 --- a/desktop/src-tauri/src/pipeline_commands/discovery.rs +++ b/desktop/src-tauri/src/pipeline_commands/discovery.rs @@ -6,7 +6,6 @@ use tauri::{AppHandle, Emitter, State}; use zclaw_pipeline::{ RunStatus, parse_pipeline_yaml, - parse_pipeline_v2_yaml, PipelineExecutor, ActionRegistry, LlmActionDriver, @@ -16,7 +15,7 @@ use zclaw_pipeline::{ use super::{PipelineState, PipelineInfo, PipelineRunResponse, RunPipelineResponse, RunPipelineRequest}; use super::adapters::{RuntimeLlmAdapter, PipelineSkillDriver, PipelineHandDriver}; -use super::helpers::{get_pipelines_directory, scan_pipelines_with_paths, scan_pipelines_full_sync, pipeline_to_info, pipeline_v2_to_info}; +use super::helpers::{get_pipelines_directory, scan_pipelines_with_paths, scan_pipelines_full_sync, pipeline_to_info}; use crate::kernel_commands::KernelState; diff --git a/desktop/src-tauri/src/pipeline_commands/helpers.rs b/desktop/src-tauri/src/pipeline_commands/helpers.rs index b460f07..582634c 100644 --- a/desktop/src-tauri/src/pipeline_commands/helpers.rs +++ b/desktop/src-tauri/src/pipeline_commands/helpers.rs @@ -9,7 +9,7 @@ use zclaw_pipeline::{ PipelineV2, }; -use super::types::{PipelineInfo, PipelineInputInfo}; +use super::types::{PipelineInfo, PipelineInputInfo, PipelineStepInfo}; pub(crate) fn get_pipelines_directory() -> Result { // Try to find pipelines directory @@ -169,6 +169,32 @@ pub(crate) fn pipeline_to_info(pipeline: &Pipeline) -> PipelineInfo { icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()), version: pipeline.metadata.version.clone(), author: pipeline.metadata.author.clone().unwrap_or_default(), + // P2-13: Expose step count from actual pipeline spec + step_count: pipeline.spec.steps.len(), + // P2-14: Expose actual pipeline steps + steps: pipeline.spec.steps.iter().map(|step| { + use zclaw_pipeline::Action; + let (action_type, hand_name) = match &step.action { + Action::LlmGenerate { .. } => ("llm_generate".to_string(), None), + Action::Parallel { .. } => ("parallel".to_string(), None), + Action::Condition { .. } => ("condition".to_string(), None), + Action::Hand { hand_id, .. } => ("hand".to_string(), Some(hand_id.clone())), + Action::Skill { skill_id, .. } => ("skill".to_string(), Some(skill_id.clone())), + Action::ClassroomRender { .. } => ("classroom_render".to_string(), None), + Action::Sequential { .. } => ("sequential".to_string(), None), + Action::FileExport { .. } => ("file_export".to_string(), None), + Action::HttpRequest { .. } => ("http_request".to_string(), None), + Action::SetVar { .. } => ("set_var".to_string(), None), + Action::Delay { .. } => ("delay".to_string(), None), + Action::SkillOrchestration { .. } => ("skill_orchestration".to_string(), None), + }; + PipelineStepInfo { + name: step.id.clone(), + action_type, + hand_name, + condition: step.when.clone(), + } + }).collect(), inputs: pipeline.spec.inputs.iter().map(|input| { PipelineInputInfo { name: input.name.clone(), @@ -225,5 +251,8 @@ pub(crate) fn pipeline_v2_to_info(v2: &PipelineV2) -> PipelineInfo { options: param.options.clone(), } }).collect(), + // V2 pipelines don't have steps in the same format + step_count: 0, + steps: vec![], } } \ No newline at end of file diff --git a/desktop/src-tauri/src/pipeline_commands/types.rs b/desktop/src-tauri/src/pipeline_commands/types.rs index d5ee9ac..b8c0e85 100644 --- a/desktop/src-tauri/src/pipeline_commands/types.rs +++ b/desktop/src-tauri/src/pipeline_commands/types.rs @@ -28,6 +28,22 @@ pub struct PipelineInfo { pub author: String, /// Input parameters pub inputs: Vec, + /// P2-13: Step count (was missing, causing frontend to show 0) + #[serde(default)] + pub step_count: usize, + /// P2-14: Actual pipeline steps (populated in pipeline_get detail view) + #[serde(default)] + pub steps: Vec, +} + +/// P2-14: Pipeline step info for detail view +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineStepInfo { + pub name: String, + pub action_type: String, + pub hand_name: Option, + pub condition: Option, } /// Pipeline input parameter info diff --git a/desktop/src/components/SaaS/SaaSLogin.tsx b/desktop/src/components/SaaS/SaaSLogin.tsx index 545359c..e6440b7 100644 --- a/desktop/src/components/SaaS/SaaSLogin.tsx +++ b/desktop/src/components/SaaS/SaaSLogin.tsx @@ -55,8 +55,8 @@ export function SaaSLogin({ onLogin, onLoginWithTotp, onRegister, initialUrl, is setLocalError('邮箱格式不正确'); return; } - if (password.length < 6) { - setLocalError('密码长度至少 6 个字符'); + if (password.length < 8) { + setLocalError('密码长度至少 8 个字符'); return; } if (password !== confirmPassword) { @@ -322,7 +322,7 @@ export function SaaSLogin({ onLogin, onLoginWithTotp, onRegister, initialUrl, is type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} - placeholder={isRegister ? '至少 6 个字符' : 'Enter password'} + placeholder={isRegister ? '至少 8 个字符' : 'Enter password'} autoComplete={isRegister ? 'new-password' : 'current-password'} className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900" disabled={isLoggingIn} diff --git a/desktop/src/store/classroomStore.ts b/desktop/src/store/classroomStore.ts index 12c62cc..8d7aaee 100644 --- a/desktop/src/store/classroomStore.ts +++ b/desktop/src/store/classroomStore.ts @@ -59,6 +59,8 @@ export interface ClassroomState { activeClassroom: Classroom | null; /** Whether the ClassroomPlayer overlay is open */ classroomOpen: boolean; + /** P2-11: Tracks if user explicitly closed player during generation */ + userDidCloseDuringGeneration: boolean; /** Chat messages for the active classroom */ chatMessages: ClassroomChatMessage[]; /** Whether chat is loading */ @@ -93,6 +95,7 @@ export const useClassroomStore = create()((set, get) => ({ generatingTopic: null, activeClassroom: null, classroomOpen: false, + userDidCloseDuringGeneration: false, chatMessages: [], chatLoading: false, error: null, @@ -105,6 +108,7 @@ export const useClassroomStore = create()((set, get) => ({ progressActivity: 'Starting generation...', generatingTopic: request.topic, error: null, + userDidCloseDuringGeneration: false, }); // Listen for progress events from Rust @@ -121,7 +125,10 @@ export const useClassroomStore = create()((set, get) => ({ const result = await invoke('classroom_generate', { request }); set({ generating: false }); await get().loadClassroom(result.classroomId); - set({ classroomOpen: true }); + // P2-11: Only auto-open if user hasn't explicitly closed during generation + if (!get().userDidCloseDuringGeneration) { + set({ classroomOpen: true }); + } return result.classroomId; } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -161,7 +168,11 @@ export const useClassroomStore = create()((set, get) => ({ }, closeClassroom: () => { - set({ classroomOpen: false }); + set({ + classroomOpen: false, + // P2-11: Track explicit user close during generation + userDidCloseDuringGeneration: get().generating, + }); }, sendChatMessage: async (message, sceneContext) => { diff --git a/desktop/src/store/workflowStore.ts b/desktop/src/store/workflowStore.ts index 7415b36..0f5e867 100644 --- a/desktop/src/store/workflowStore.ts +++ b/desktop/src/store/workflowStore.ts @@ -47,6 +47,8 @@ export interface WorkflowStep { name?: string; params?: Record; condition?: string; + /** P2-12: Action type for pipeline step (hand, llm_generate, parallel, condition) */ + actionType?: string; } export interface WorkflowDetail { @@ -341,6 +343,8 @@ interface PipelineInfo { icon: string; version: string; author: string; + /** P2-13: Step count from backend */ + stepCount?: number; inputs: Array<{ name: string; inputType: string; @@ -386,7 +390,7 @@ function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient { workflows: pipelines.map((p) => ({ id: p.id, name: p.displayName || p.id, - steps: p.inputs.length, + steps: p.stepCount ?? p.inputs?.length ?? 0, // P2-13: Use stepCount from backend description: p.description, createdAt: undefined, })), @@ -424,6 +428,7 @@ function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient { name: s.name || `Step ${i + 1}`, params: s.params, condition: s.condition, + actionType: s.actionType, // P2-12: Send actionType to backend })), }, }); @@ -444,6 +449,7 @@ function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient { name: s.name || `Step ${i + 1}`, params: s.params, condition: s.condition, + actionType: s.actionType, // P2-12: Send actionType to backend })), }, }); diff --git a/docs/test-results/DEFECT_LIST.md b/docs/test-results/DEFECT_LIST.md index 7591092..75d39c4 100644 --- a/docs/test-results/DEFECT_LIST.md +++ b/docs/test-results/DEFECT_LIST.md @@ -1,6 +1,6 @@ # ZCLAW 上线前功能审计 — 缺陷清单 -> **审计日期**: 2026-04-05 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 +> **审计日期**: 2026-04-06 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 | **最新编译状态**: ✅ cargo check 通过 ## 统计总览 @@ -8,9 +8,9 @@ |--------|---------|--------|--------|---------| | **P0** | 1 | 0 | 1 | **0** | | **P1** | 11 | 2 | 13 | **0** | -| **P2** | 25 | 2 | 4 | **23** | -| **P3** | 10 | 0 | 1 | **9** | -| **合计** | **47** | **4** | **19** | **32** | +| **P2** | 25 | 2 | 23 | **4** | +| **P3** | 10 | 0 | 6 | **4** | +| **合计** | **47** | **4** | **43** | **8** | --- @@ -41,59 +41,59 @@ | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| -| P2-01 | M3-04 | max_concurrent 未实现(5 个并发全被接受) | ⚠️ 未修复 | -| P2-02 | M3-05 | timeout_secs 未实现(无超时保护) | ⚠️ 未修复 | -| P2-03 | M3-10 | toolCount/metricCount 硬编码为 0 | ⚠️ 未修复 | -| P2-04 | TC-1-D03 | Quiz Hand 无输入长度限制(100K 字符被接受) | ⚠️ 新发现 | -| P2-05 | M2-08 | max_tokens=0 未被 agent_create 拒绝 | ⚠️ 部分修复 | +| P2-01 | M3-04 | max_concurrent 未实现(5 个并发全被接受) | ✅ 已修复 (registry.rs Semaphore 并发限制) | +| P2-02 | M3-05 | timeout_secs 未实现(无超时保护) | ✅ 已修复 (kernel/hands.rs tokio::time::timeout) | +| P2-03 | M3-10 | toolCount/metricCount 硬编码为 0 | ✅ 已修复 (registry.get_counts + Hand trait tool_count/metric_count) | +| P2-04 | TC-1-D03 | Quiz Hand 无输入长度限制(100K 字符被接受) | ✅ 已修复 (quiz.rs 50KB 限制) | +| P2-05 | M2-08 | max_tokens=0 未被 agent_create 拒绝 | ✅ 已修复 (create/update/import 全路径校验) | ### T2 Intelligence (4) | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| -| P2-06 | M4-08 | heartbeat_init 无最小间隔验证(0.001分钟被接受) | ⚠️ 未修复 | -| P2-07 | M4-02 | 反思引擎可能仍基于规则而非 LLM(new_memories=0) | ⚠️ 需确认 | -| P2-08 | TC-2-D01 | identity_propose_change 参数不透明 | ⚠️ 新发现 | -| P2-09 | M4-14/15 | reflection/identity 命令参数名与文档不一致 | ⚠️ 确认 | +| P2-06 | M4-08 | heartbeat_init 无最小间隔验证(0.001分钟被接受) | ✅ 已修复 (init>=1 分钟, update_config 待补) | +| P2-07 | M4-02 | 反思引擎可能仍基于规则而非 LLM(new_memories=0) | ✅ 已修复 (ReflectionResult.used_fallback 标记) | +| P2-08 | TC-2-D01 | identity_propose_change 参数不透明 | ✅ 已修复 (统一 file/target 参数命名) | +| P2-09 | M4-14/15 | reflection/identity 命令参数名与文档不一致 | ✅ 已修复 (错误消息统一) | ### T4 Classroom (2) | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| -| P2-10 | M11-04 | LLM 失败静默 fallback 到 placeholder,无标记 | ⚠️ 未修复 | -| P2-11 | M11-05 | 课堂生成完成强制打开 player,不尊重手动关闭 | ⚠️ 未修复 | +| P2-10 | M11-04 | LLM 失败静默 fallback 到 placeholder,无标记 | ✅ 已修复 (ClassroomMetadata.is_placeholder 字段) | +| P2-11 | M11-05 | 课堂生成完成强制打开 player,不尊重手动关闭 | ✅ 已修复 (userDidCloseDuringGeneration 标志追踪) | ### T5 Pipeline (5) | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| -| P2-12 | M6-03 | pipeline_create 硬编码 Action::Hand,LLM/Parallel/Condition 丢失 | ⚠️ 未修复 | -| P2-13 | M6-04 | workflowStore steps: p.inputs.length 语义错误 | ⚠️ 未修复 | -| P2-14 | M6-05 | getWorkflow inputs→steps 映射语义错误 | ⚠️ 未修复 | -| P2-15 | M6-06 | 管道操作符 `\|` 在 context.resolve() 中不支持 | ❓ 未验证 | -| P2-16 | M6-07 | 模板中 `{{mustache}}` 和 `${inputs}` 混用 | ⚠️ 未修复 | +| P2-12 | M6-03 | pipeline_create 硬编码 Action::Hand,LLM/Parallel/Condition 丢失 | ✅ 已修复 (前端补发 actionType + 后端多分支匹配) | +| P2-13 | M6-04 | workflowStore steps: p.inputs.length 语义错误 | ✅ 已修复 (后端 step_count 字段 + 前端 stepCount 读取) | +| P2-14 | M6-05 | getWorkflow inputs→steps 映射语义错误 | ✅ 已修复 (PipelineStepInfo + PipelineInfo.steps 字段) | +| P2-15 | M6-06 | 管道操作符 `\|` 在 context.resolve() 中不支持 | ✅ 已修复 (resolve_path_with_pipes + 8 种 transforms) | +| P2-16 | M6-07 | 模板中 `{{mustache}}` 和 `${inputs}` 混用 | ✅ 已修复 (mustache→${} 自动归一化) | ### T6 SaaS Desktop (2) | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| -| P2-17 | M7-01 | 前端密码最少 6 字符 vs 后端 8 字符不一致 | ⚠️ 未修复 | +| P2-17 | M7-01 | 前端密码最少 6 字符 vs 后端 8 字符不一致 | ✅ 已修复 (SaaSLogin placeholder 6→8) | | P2-18 | M7-03 | TOTP QR 码通过外部服务生成,密钥明文传输 | ❓ 未验证 | ### T7 Skills (2) | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| -| P2-19 | M5-02 | SKILL.md tools 字段未解析,75 个技能 tools 被忽略 | ❓ 未验证 | -| P2-20 | M5-03 | Python 技能硬编码 python3,Windows 无此命令 | ❓ 未验证 | +| P2-19 | M5-02 | SKILL.md tools 字段未解析,75 个技能 tools 被忽略 | ✅ 已修复 (serialize_skill_md 补全 tools 写入 + update_skill 保留 tools) | +| P2-20 | M5-03 | Python 技能硬编码 python3,Windows 无此命令 | ✅ 已修复 (runner.rs platform-aware python_bin()) | ### T8 Chat (3) | ID | 原V12 ID | 描述 | 状态 | |----|---------|------|------| | P2-21 | M1-01 | GeminiDriver API Key 在 URL query 参数中 | ❓ 未验证 | -| P2-22 | M1-02 | ToolOutputGuard 只 warn 不 block 敏感信息 | ❓ 未验证 | -| P2-23 | M1-03/04 | Mutex::unwrap() 在 async 中可能 panic | ❓ 未验证 | +| P2-22 | M1-02 | ToolOutputGuard 只 warn 不 block 敏感信息 | ✅ 已修复 (sensitive patterns now return Err to block output) | +| P2-23 | M1-03/04 | Mutex::unwrap() 在 async 中可能 panic | ✅ 已修复 (relay/service.rs unwrap_or_else(|e| e.into_inner())) | --- @@ -101,15 +101,15 @@ | ID | 原V12 ID | 模块 | 描述 | 状态 | |----|---------|------|------|------| -| P3-01 | TC-2-D02 | T2 | memory_store entry ID 重复 (knowledge/knowledge) | ⚠️ 新发现 | +| P3-01 | TC-2-D02 | T2 | memory_store entry ID 重复 (knowledge/knowledge) | ✅ 已修复 (使用 source 作为 category 避免重复) | | P3-02 | M11-07 | T4 | 白板两套渲染实现未统一(SceneRenderer SVG + WhiteboardCanvas) | ⚠️ 未修复 | -| P3-03 | M11-08 | T4 | HTML export 只渲染 title+duration,缺少 key_points | ⚠️ 未修复 | +| P3-03 | M11-08 | T4 | HTML export 只渲染 title+duration,缺少 key_points | ✅ 已修复 (export_key_points 配置化渲染) | | P3-04 | M6-08 | T5 | get_progress() 百分比只有 0/50/100 三档 | ⚠️ 未修复 | | P3-05 | M7-05 | T6 | saveSaaSSession fire-and-forget,失败静默 | ❓ 未验证 | | P3-06 | M7-06 | T6 | chatStream 不传 sessionKey/agentId | ❓ 未验证 | -| P3-07 | M5-04 | T7 | YAML triggers 引号只处理双引号 | ❓ 未验证 | -| P3-08 | M5-05 | T7 | ShellSkill duration_ms 未设置 | ❓ 未验证 | -| P3-09 | M5-06 | T7 | CATEGORY_CONFIG 仅覆盖 9 分类,75 技能全为 null | ⚠️ 未修复 | +| P3-07 | M5-04 | T7 | YAML triggers 引号只处理双引号 | ✅ 已修复 (loader.rs 同时处理双引号和单引号) | +| P3-08 | M5-05 | T7 | ShellSkill duration_ms 未设置 | ✅ 已修复 (runner.rs 计时并返回 duration_ms) | +| P3-09 | M5-06 | T7 | CATEGORY_CONFIG 仅覆盖 9 分类,75 技能全为 null | ✅ 已修复 (auto_classify + 20 分类覆盖) | ---