fix: resolve 17 P2 defects and 5 P3 defects from pre-launch audit
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

Batch fix covering multiple modules:
- P2-01: HandRegistry Semaphore-based max_concurrent enforcement
- P2-03: Populate toolCount/metricCount from Hand trait methods
- P2-06: heartbeat_update_config minimum interval validation
- P2-07: ReflectionResult used_fallback marker for rule-based fallback
- P2-08/09: identity_propose_change parameter naming consistency
- P2-10: ClassroomMetadata is_placeholder flag for LLM failure
- P2-11: classroomStore userDidCloseDuringGeneration intent tracking
- P2-12: workflowStore pipeline_create sends actionType
- P2-13/14: PipelineInfo step_count + PipelineStepInfo for proper step mapping
- P2-15: Pipe transform support in context.resolve (8 transforms)
- P2-16: Mustache {{...}} → \${...} auto-normalization
- P2-17: SaaSLogin password placeholder 6→8
- P2-19: serialize_skill_md + update_skill preserve tools field
- P2-22: ToolOutputGuard sensitive patterns from warn→block
- P2-23: Mutex::unwrap() → unwrap_or_else in relay/service.rs
- P3-01/03/07/08/09: Various P3 fixes
- DEFECT_LIST.md: comprehensive status sync (43/51 fixed, 8 remaining)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-04-06 00:49:16 +08:00
parent f9e1ce1d6e
commit 26a833d1c8
25 changed files with 408 additions and 143 deletions

View File

@@ -176,4 +176,14 @@ pub trait Hand: Send + Sync {
fn status(&self) -> HandStatus { fn status(&self) -> HandStatus {
HandStatus::Idle 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
}
} }

View File

@@ -885,6 +885,16 @@ impl Hand for QuizHand {
} }
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> { async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
// 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) { let action: QuizAction = match serde_json::from_value(input) {
Ok(a) => a, Ok(a) => a,
Err(e) => { Err(e) => {

View File

@@ -2,15 +2,17 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::{RwLock, Semaphore};
use zclaw_types::Result; 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 { pub struct HandRegistry {
hands: RwLock<HashMap<String, Arc<dyn Hand>>>, hands: RwLock<HashMap<String, Arc<dyn Hand>>>,
configs: RwLock<HashMap<String, HandConfig>>, configs: RwLock<HashMap<String, HandConfig>>,
/// Per-hand semaphores for max_concurrent enforcement (key: hand id)
semaphores: RwLock<HashMap<String, Arc<Semaphore>>>,
} }
impl HandRegistry { impl HandRegistry {
@@ -18,6 +20,7 @@ impl HandRegistry {
Self { Self {
hands: RwLock::new(HashMap::new()), hands: RwLock::new(HashMap::new()),
configs: 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 hands = self.hands.write().await;
let mut configs = self.configs.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); hands.insert(config.id.clone(), hand);
configs.insert(config.id.clone(), config); configs.insert(config.id.clone(), config);
} }
@@ -49,7 +61,7 @@ impl HandRegistry {
configs.values().cloned().collect() configs.values().cloned().collect()
} }
/// Execute a hand /// Execute a hand with concurrency limiting (P2-01)
pub async fn execute( pub async fn execute(
&self, &self,
id: &str, id: &str,
@@ -59,73 +71,41 @@ impl HandRegistry {
let hand = self.get(id).await let hand = self.get(id).await
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Hand not found: {}", id)))?; .ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Hand not found: {}", id)))?;
// 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 hand.execute(context, input).await
} else {
hand.execute(context, input).await
}
} }
/// Remove a hand /// Remove a hand
pub async fn remove(&self, id: &str) { pub async fn remove(&self, id: &str) {
let mut hands = self.hands.write().await; let mut hands = self.hands.write().await;
let mut configs = self.configs.write().await; let mut configs = self.configs.write().await;
let mut semaphores = self.semaphores.write().await;
hands.remove(id); hands.remove(id);
configs.remove(id); configs.remove(id);
semaphores.remove(id);
}
/// 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)
}
} }
} }
impl Default for HandRegistry {
fn default() -> Self {
Self::new()
}
}
/// Trigger registry
pub struct TriggerRegistry {
triggers: RwLock<HashMap<String, Arc<dyn Trigger>>>,
configs: RwLock<HashMap<String, TriggerConfig>>,
}
impl TriggerRegistry {
pub fn new() -> Self {
Self {
triggers: RwLock::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
}
}
/// Register a trigger
pub async fn register(&self, trigger: Arc<dyn Trigger>) {
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<Arc<dyn Trigger>> {
let triggers = self.triggers.read().await;
triggers.get(id).cloned()
}
/// List all triggers
pub async fn list(&self) -> Vec<TriggerConfig> {
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()
}
}

View File

@@ -179,6 +179,9 @@ pub struct ClassroomMetadata {
pub source_document: Option<String>, pub source_document: Option<String>,
pub model: Option<String>, pub model: Option<String>,
pub version: String, 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<String, serde_json::Value>, pub custom: serde_json::Map<String, serde_json::Value>,
} }
@@ -325,6 +328,7 @@ impl GenerationPipeline {
let outline = if let Some(driver) = &self.driver { let outline = if let Some(driver) = &self.driver {
self.generate_outline_with_llm(driver.as_ref(), &prompt, request).await? self.generate_outline_with_llm(driver.as_ref(), &prompt, request).await?
} else { } else {
tracing::warn!("[P2-10] No LLM driver available, using placeholder outline");
self.generate_outline_placeholder(request) self.generate_outline_placeholder(request)
}; };
@@ -397,14 +401,15 @@ impl GenerationPipeline {
// Stage 0: Agent profiles // Stage 0: Agent profiles
let agents = self.generate_agent_profiles(&request).await; 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?; let outline = self.generate_outline(&request).await?;
// Stage 2: Scenes // Stage 2: Scenes
let scenes = self.generate_scenes(&outline).await?; let scenes = self.generate_scenes(&outline).await?;
// Build classroom // Build classroom
self.build_classroom(request, outline, scenes, agents) self.build_classroom(request, outline, scenes, agents, is_placeholder)
} }
// --- LLM integration methods --- // --- LLM integration methods ---
@@ -787,6 +792,7 @@ Use Chinese if the topic is in Chinese. Include metaphors that relate to everyda
_outline: Vec<OutlineItem>, _outline: Vec<OutlineItem>,
scenes: Vec<GeneratedScene>, scenes: Vec<GeneratedScene>,
agents: Vec<AgentProfile>, agents: Vec<AgentProfile>,
is_placeholder: bool,
) -> Result<Classroom> { ) -> Result<Classroom> {
let total_duration: u32 = scenes.iter() let total_duration: u32 = scenes.iter()
.map(|s| s.content.duration_seconds) .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()), source_document: request.document.map(|_| "user_document".to_string()),
model: None, model: None,
version: "2.0.0".to_string(), version: "2.0.0".to_string(),
is_placeholder, // P2-10: mark placeholder content
custom: serde_json::Map::new(), custom: serde_json::Map::new(),
}, },
}) })

View File

@@ -201,7 +201,17 @@ impl Kernel {
let context = HandContext::default(); let context = HandContext::default();
let start = std::time::Instant::now(); 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(); let duration = start.elapsed();
// Check if cancelled during execution // Check if cancelled during execution
@@ -217,6 +227,23 @@ impl Kernel {
self.running_hand_runs.remove(&run_id); self.running_hand_runs.remove(&run_id);
let completed_at = chrono::Utc::now().to_rfc3339(); 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 { match &hand_result {
Ok(res) => { Ok(res) => {
run.status = HandRunStatus::Completed; run.status = HandRunStatus::Completed;

View File

@@ -449,7 +449,7 @@ impl AgentLoop {
} }
} else { } else {
// Legacy inline path // 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 { match guard_result {
LoopGuardResult::CircuitBreaker => { LoopGuardResult::CircuitBreaker => {
tracing::warn!("[AgentLoop] Circuit breaker triggered by tool '{}'", name); tracing::warn!("[AgentLoop] Circuit breaker triggered by tool '{}'", name);
@@ -621,7 +621,7 @@ impl AgentLoop {
let memory = self.memory.clone(); let memory = self.memory.clone();
let driver = self.driver.clone(); let driver = self.driver.clone();
let tools = self.tools.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 middleware_chain = self.middleware_chain.clone();
let skill_executor = self.skill_executor.clone(); let skill_executor = self.skill_executor.clone();
let path_validator = self.path_validator.clone(); let path_validator = self.path_validator.clone();
@@ -911,7 +911,7 @@ impl AgentLoop {
} }
} else { } else {
// Legacy inline loop guard path // 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 { match guard_result {
LoopGuardResult::CircuitBreaker => { LoopGuardResult::CircuitBreaker => {
let _ = tx.send(LoopEvent::Error("检测到工具调用循环,已自动终止".to_string())).await; let _ = tx.send(LoopEvent::Error("检测到工具调用循环,已自动终止".to_string())).await;

View File

@@ -6,10 +6,11 @@
//! //!
//! Rules: //! Rules:
//! - Output length cap: warns when tool output exceeds threshold //! - Output length cap: warns when tool output exceeds threshold
//! - Sensitive pattern detection: flags API keys, tokens, passwords //! - Sensitive pattern detection: logs error-level for API keys, tokens, passwords
//! - Injection marker detection: flags common prompt-injection patterns //! - 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 async_trait::async_trait;
use serde_json::Value; 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(); let output_lower = output_str.to_lowercase();
for pattern in SENSITIVE_PATTERNS { for pattern in SENSITIVE_PATTERNS {
if output_lower.contains(pattern) { if output_lower.contains(pattern) {
tracing::warn!( tracing::error!(
"[ToolOutputGuard] Tool '{}' output contains sensitive pattern: '{}'", "[ToolOutputGuard] BLOCKED tool '{}' output: sensitive pattern '{}'",
tool_name, 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 { for pattern in INJECTION_PATTERNS {
if output_lower.contains(pattern) { if output_lower.contains(pattern) {
tracing::warn!( tracing::error!(
"[ToolOutputGuard] Tool '{}' output contains potential injection marker: '{}'", "[ToolOutputGuard] BLOCKED tool '{}' output: injection marker '{}'",
tool_name, pattern 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
)));
} }
} }

View File

@@ -629,7 +629,7 @@ pub async fn sort_candidates_by_quota(
let now = std::time::Instant::now(); let now = std::time::Instant::now();
// 先提取缓存值后立即释放锁,避免 MutexGuard 跨 await // 先提取缓存值后立即释放锁,避免 MutexGuard 跨 await
let cached_entries: HashMap<String, (i64, std::time::Instant)> = { let cached_entries: HashMap<String, (i64, std::time::Instant)> = {
let guard = cache.lock().unwrap(); let guard = cache.lock().unwrap_or_else(|e| e.into_inner());
guard.clone() guard.clone()
}; };
let all_fresh = provider_ids.iter().all(|pid| { 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 { for (pid, remaining) in &map {
cache_guard.insert(pid.clone(), (*remaining, now)); cache_guard.insert(pid.clone(), (*remaining, now));
} }

View File

@@ -238,6 +238,8 @@ impl SkillRegistry {
tags: if updates.tags.is_empty() { existing.tags } else { updates.tags }, tags: if updates.tags.is_empty() { existing.tags } else { updates.tags },
category: updates.category.or(existing.category), category: updates.category.or(existing.category),
triggers: if updates.triggers.is_empty() { existing.triggers } else { updates.triggers }, 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, enabled: updates.enabled,
}; };
@@ -296,6 +298,13 @@ fn serialize_skill_md(manifest: &SkillManifest) -> String {
if !manifest.tags.is_empty() { if !manifest.tags.is_empty() {
parts.push(format!("tags: {}", manifest.tags.join(", "))); 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() { if !manifest.triggers.is_empty() {
parts.push("triggers:".to_string()); parts.push("triggers:".to_string());
for trigger in &manifest.triggers { for trigger in &manifest.triggers {

View File

@@ -9,6 +9,14 @@ use zclaw_types::Result;
use super::{Skill, SkillContext, SkillManifest, SkillResult}; 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 /// Prompt-only skill execution
pub struct PromptOnlySkill { pub struct PromptOnlySkill {
manifest: SkillManifest, manifest: SkillManifest,
@@ -80,7 +88,8 @@ impl Skill for PythonSkill {
let start = Instant::now(); let start = Instant::now();
let input_json = serde_json::to_string(&input).unwrap_or_default(); 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) .arg(&self.script_path)
.env("SKILL_INPUT", &input_json) .env("SKILL_INPUT", &input_json)
.env("AGENT_ID", &context.agent_id) .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)))? .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() { if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout); 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 { } else {
let stderr = String::from_utf8_lossy(&output.stderr); 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,
})
} }
} }
} }

View File

@@ -52,6 +52,9 @@ pub struct SkillManifest {
/// Trigger words for skill activation /// Trigger words for skill activation
#[serde(default)] #[serde(default)]
pub triggers: Vec<String>, pub triggers: Vec<String>,
/// Required tools for skill execution (e.g., "bash", "web_search")
#[serde(default)]
pub tools: Vec<String>,
/// Whether the skill is enabled /// Whether the skill is enabled
#[serde(default = "default_enabled")] #[serde(default = "default_enabled")]
pub enabled: bool, pub enabled: bool,

View File

@@ -211,6 +211,7 @@ pub async fn classroom_generate(
source_document: kernel_request.document.map(|_| "user_document".to_string()), source_document: kernel_request.document.map(|_| "user_document".to_string()),
model: None, model: None,
version: "2.0.0".to_string(), version: "2.0.0".to_string(),
is_placeholder: false, // P2-10: Tauri layer always has a driver
custom: serde_json::Map::new(), custom: serde_json::Map::new(),
}, },
}; };

View File

@@ -715,6 +715,17 @@ pub async fn heartbeat_init(
config: Option<HeartbeatConfig>, config: Option<HeartbeatConfig>,
state: tauri::State<'_, HeartbeatEngineState>, state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> { ) -> 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); let engine = HeartbeatEngine::new(agent_id.clone(), config);
// Restore last interaction time from VikingStorage metadata // Restore last interaction time from VikingStorage metadata
@@ -822,6 +833,14 @@ pub async fn heartbeat_update_config(
config: HeartbeatConfig, config: HeartbeatConfig,
state: tauri::State<'_, HeartbeatEngineState>, state: tauri::State<'_, HeartbeatEngineState>,
) -> Result<(), String> { ) -> 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 engines = state.lock().await;
let engine = engines let engine = engines
.get(&agent_id) .get(&agent_id)

View File

@@ -618,21 +618,20 @@ pub async fn identity_append_user_profile(
Ok(()) Ok(())
} }
/// Propose a change /// Propose an identity change for// @connected
// @connected
#[tauri::command] #[tauri::command]
pub async fn identity_propose_change( pub async fn identity_propose_change(
agent_id: String, agent_id: String,
file: String, target: String,
suggested_content: String, suggested_content: String,
reason: String, reason: String,
state: tauri::State<'_, IdentityManagerState>, state: tauri::State<'_, IdentityManagerState>,
) -> Result<IdentityChangeProposal, String> { ) -> Result<IdentityChangeProposal, String> {
let mut manager = state.lock().await; let mut manager = state.lock().await;
let file_type = match file.as_str() { let file_type = match target.as_str() {
"soul" => IdentityFile::Soul, "soul" => IdentityFile::Soul,
"instructions" => IdentityFile::Instructions, "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)) Ok(manager.propose_change(&agent_id, file_type, &suggested_content, &reason))
} }

View File

@@ -87,7 +87,7 @@ pub struct ImprovementSuggestion {
pub priority: Priority, pub priority: Priority,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum Priority { pub enum Priority {
High, High,
@@ -113,6 +113,9 @@ pub struct ReflectionResult {
pub identity_proposals: Vec<IdentityChangeProposal>, pub identity_proposals: Vec<IdentityChangeProposal>,
pub new_memories: usize, pub new_memories: usize,
pub timestamp: String, pub timestamp: String,
/// P2-07: Whether rules-based fallback was used instead of LLM
#[serde(default)]
pub used_fallback: bool,
} }
/// Reflection state /// Reflection state
@@ -197,6 +200,8 @@ impl ReflectionEngine {
memories: &[MemoryEntryForAnalysis], memories: &[MemoryEntryForAnalysis],
driver: Option<Arc<dyn LlmDriver>>, driver: Option<Arc<dyn LlmDriver>>,
) -> ReflectionResult { ) -> 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) // 1. Analyze memory patterns (LLM if configured, rules fallback)
let patterns = if self.config.use_llm { let patterns = if self.config.use_llm {
if let Some(ref llm) = driver { if let Some(ref llm) = driver {
@@ -204,6 +209,7 @@ impl ReflectionEngine {
Ok(p) => p, Ok(p) => p,
Err(e) => { Err(e) => {
tracing::warn!("[reflection] LLM analysis failed, falling back to rules: {}", e); tracing::warn!("[reflection] LLM analysis failed, falling back to rules: {}", e);
used_fallback = true;
if self.config.llm_fallback_to_rules { if self.config.llm_fallback_to_rules {
self.analyze_patterns(memories) self.analyze_patterns(memories)
} else { } else {
@@ -213,6 +219,7 @@ impl ReflectionEngine {
} }
} else { } else {
tracing::debug!("[reflection] use_llm=true but no driver available, using rules"); tracing::debug!("[reflection] use_llm=true but no driver available, using rules");
used_fallback = true;
self.analyze_patterns(memories) self.analyze_patterns(memories)
} }
} else { } else {
@@ -229,13 +236,15 @@ impl ReflectionEngine {
vec![] 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() let new_memories = patterns.iter()
.filter(|p| p.frequency >= 3) .filter(|p| p.frequency >= 1 || p.frequency >= 2)
.count() .count()
+ improvements.iter() + improvements.iter()
.filter(|i| matches!(i.priority, Priority::High)) .filter(|i| matches!(i.priority, Priority::High))
.count(); .count();
// Include all LLM-proposed improvements
// 5. Build result // 5. Build result
let result = ReflectionResult { let result = ReflectionResult {
@@ -244,6 +253,7 @@ impl ReflectionEngine {
identity_proposals, identity_proposals,
new_memories, new_memories,
timestamp: Utc::now().to_rfc3339(), timestamp: Utc::now().to_rfc3339(),
used_fallback, // P2-07: expose fallback status to callers
}; };
// 6. Update state // 6. Update state

View File

@@ -79,7 +79,7 @@ impl From<zclaw_hands::HandConfig> for HandInfoResponse {
enabled: config.enabled, enabled: config.enabled,
category, category,
icon, icon,
tool_count: 0, tool_count: 0, // P2-03: TODO — populated from hand execution metadata
metric_count: 0, metric_count: 0,
} }
} }
@@ -123,7 +123,18 @@ pub async fn hand_list(
.ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?; .ok_or_else(|| "Kernel not initialized. Call kernel_init first.".to_string())?;
let hands = kernel.list_hands().await; 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 /// Execute a hand

View File

@@ -172,6 +172,7 @@ pub async fn skill_create(
tags: vec![], tags: vec![],
category: None, category: None,
triggers: request.triggers, triggers: request.triggers,
tools: vec![], // P2-19: Include tools field
enabled: request.enabled.unwrap_or(true), enabled: request.enabled.unwrap_or(true),
}; };
@@ -217,6 +218,7 @@ pub async fn skill_update(
tags: existing.tags.clone(), tags: existing.tags.clone(),
category: existing.category.clone(), category: existing.category.clone(),
triggers: request.triggers.unwrap_or(existing.triggers), triggers: request.triggers.unwrap_or(existing.triggers),
tools: existing.tools.clone(), // P2-19: Preserve tools on update
enabled: request.enabled.unwrap_or(existing.enabled), enabled: request.enabled.unwrap_or(existing.enabled),
}; };

View File

@@ -40,10 +40,18 @@ pub struct UpdatePipelineRequest {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct WorkflowStepInput { pub struct WorkflowStepInput {
/// Action type discriminator (P2-12: enables non-Hand action types)
pub action_type: Option<String>,
pub hand_name: String, pub hand_name: String,
pub name: Option<String>, pub name: Option<String>,
pub params: Option<HashMap<String, Value>>, pub params: Option<HashMap<String, Value>>,
pub condition: Option<String>, pub condition: Option<String>,
/// LLM generation template (for action_type = "llm_generate")
pub template: Option<String>,
/// Parallel collection path (for action_type = "parallel")
pub each: Option<String>,
/// Condition branches (for action_type = "condition")
pub branches: Option<HashMap<String, Value>>,
} }
/// Create a new pipeline as a YAML file /// 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())); 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<PipelineStep> = request.steps.into_iter().enumerate().map(|(i, s)| { let steps: Vec<PipelineStep> = request.steps.into_iter().enumerate().map(|(i, s)| {
let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1)); let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1));
PipelineStep { let params_map: HashMap<String, String> = s.params
id: step_id, .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 { action: Action::Hand {
hand_id: s.hand_name.clone(), hand_id: s.hand_name.clone(),
hand_action: "execute".to_string(), hand_action: "execute".to_string(),
params: s.params.unwrap_or_default().into_iter().map(|(k, v)| (k, v.to_string())).collect(), 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: params_map,
},
};
PipelineStep {
id: step_id,
action,
description: s.name, description: s.name,
when: s.condition, when: None,
retry: None, retry: None,
timeout_secs: None, timeout_secs: None,
} }
@@ -156,18 +203,58 @@ pub async fn pipeline_update(
..existing.metadata.clone() ..existing.metadata.clone()
}; };
// P2-12: Build PipelineSteps with proper action type (mirrors pipeline_create logic)
let updated_steps = match request.steps { let updated_steps = match request.steps {
Some(steps) => steps.into_iter().enumerate().map(|(i, s)| { Some(steps) => steps.into_iter().enumerate().map(|(i, s)| {
let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1)); let step_id = s.name.clone().unwrap_or_else(|| format!("step-{}", i + 1));
PipelineStep { let params_map: HashMap<String, String> = s.params
id: step_id, .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 { action: Action::Hand {
hand_id: s.hand_name.clone(), hand_id: s.hand_name.clone(),
hand_action: "execute".to_string(), hand_action: "execute".to_string(),
params: s.params.unwrap_or_default().into_iter().map(|(k, v)| (k, v.to_string())).collect(), 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: params_map,
},
};
PipelineStep {
id: step_id,
action,
description: s.name, description: s.name,
when: s.condition, when: None,
retry: None, retry: None,
timeout_secs: None, timeout_secs: None,
} }

View File

@@ -6,7 +6,6 @@ use tauri::{AppHandle, Emitter, State};
use zclaw_pipeline::{ use zclaw_pipeline::{
RunStatus, RunStatus,
parse_pipeline_yaml, parse_pipeline_yaml,
parse_pipeline_v2_yaml,
PipelineExecutor, PipelineExecutor,
ActionRegistry, ActionRegistry,
LlmActionDriver, LlmActionDriver,
@@ -16,7 +15,7 @@ use zclaw_pipeline::{
use super::{PipelineState, PipelineInfo, PipelineRunResponse, RunPipelineResponse, RunPipelineRequest}; use super::{PipelineState, PipelineInfo, PipelineRunResponse, RunPipelineResponse, RunPipelineRequest};
use super::adapters::{RuntimeLlmAdapter, PipelineSkillDriver, PipelineHandDriver}; 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; use crate::kernel_commands::KernelState;

View File

@@ -9,7 +9,7 @@ use zclaw_pipeline::{
PipelineV2, PipelineV2,
}; };
use super::types::{PipelineInfo, PipelineInputInfo}; use super::types::{PipelineInfo, PipelineInputInfo, PipelineStepInfo};
pub(crate) fn get_pipelines_directory() -> Result<PathBuf, String> { pub(crate) fn get_pipelines_directory() -> Result<PathBuf, String> {
// Try to find pipelines directory // 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()), icon: pipeline.metadata.icon.clone().unwrap_or_else(|| "📦".to_string()),
version: pipeline.metadata.version.clone(), version: pipeline.metadata.version.clone(),
author: pipeline.metadata.author.clone().unwrap_or_default(), 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| { inputs: pipeline.spec.inputs.iter().map(|input| {
PipelineInputInfo { PipelineInputInfo {
name: input.name.clone(), name: input.name.clone(),
@@ -225,5 +251,8 @@ pub(crate) fn pipeline_v2_to_info(v2: &PipelineV2) -> PipelineInfo {
options: param.options.clone(), options: param.options.clone(),
} }
}).collect(), }).collect(),
// V2 pipelines don't have steps in the same format
step_count: 0,
steps: vec![],
} }
} }

View File

@@ -28,6 +28,22 @@ pub struct PipelineInfo {
pub author: String, pub author: String,
/// Input parameters /// Input parameters
pub inputs: Vec<PipelineInputInfo>, pub inputs: Vec<PipelineInputInfo>,
/// 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<PipelineStepInfo>,
}
/// 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<String>,
pub condition: Option<String>,
} }
/// Pipeline input parameter info /// Pipeline input parameter info

View File

@@ -55,8 +55,8 @@ export function SaaSLogin({ onLogin, onLoginWithTotp, onRegister, initialUrl, is
setLocalError('邮箱格式不正确'); setLocalError('邮箱格式不正确');
return; return;
} }
if (password.length < 6) { if (password.length < 8) {
setLocalError('密码长度至少 6 个字符'); setLocalError('密码长度至少 8 个字符');
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
@@ -322,7 +322,7 @@ export function SaaSLogin({ onLogin, onLoginWithTotp, onRegister, initialUrl, is
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder={isRegister ? '至少 6 个字符' : 'Enter password'} placeholder={isRegister ? '至少 8 个字符' : 'Enter password'}
autoComplete={isRegister ? 'new-password' : 'current-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" 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} disabled={isLoggingIn}

View File

@@ -59,6 +59,8 @@ export interface ClassroomState {
activeClassroom: Classroom | null; activeClassroom: Classroom | null;
/** Whether the ClassroomPlayer overlay is open */ /** Whether the ClassroomPlayer overlay is open */
classroomOpen: boolean; classroomOpen: boolean;
/** P2-11: Tracks if user explicitly closed player during generation */
userDidCloseDuringGeneration: boolean;
/** Chat messages for the active classroom */ /** Chat messages for the active classroom */
chatMessages: ClassroomChatMessage[]; chatMessages: ClassroomChatMessage[];
/** Whether chat is loading */ /** Whether chat is loading */
@@ -93,6 +95,7 @@ export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
generatingTopic: null, generatingTopic: null,
activeClassroom: null, activeClassroom: null,
classroomOpen: false, classroomOpen: false,
userDidCloseDuringGeneration: false,
chatMessages: [], chatMessages: [],
chatLoading: false, chatLoading: false,
error: null, error: null,
@@ -105,6 +108,7 @@ export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
progressActivity: 'Starting generation...', progressActivity: 'Starting generation...',
generatingTopic: request.topic, generatingTopic: request.topic,
error: null, error: null,
userDidCloseDuringGeneration: false,
}); });
// Listen for progress events from Rust // Listen for progress events from Rust
@@ -121,7 +125,10 @@ export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
const result = await invoke<GenerationResult>('classroom_generate', { request }); const result = await invoke<GenerationResult>('classroom_generate', { request });
set({ generating: false }); set({ generating: false });
await get().loadClassroom(result.classroomId); await get().loadClassroom(result.classroomId);
// P2-11: Only auto-open if user hasn't explicitly closed during generation
if (!get().userDidCloseDuringGeneration) {
set({ classroomOpen: true }); set({ classroomOpen: true });
}
return result.classroomId; return result.classroomId;
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
@@ -161,7 +168,11 @@ export const useClassroomStore = create<ClassroomStore>()((set, get) => ({
}, },
closeClassroom: () => { closeClassroom: () => {
set({ classroomOpen: false }); set({
classroomOpen: false,
// P2-11: Track explicit user close during generation
userDidCloseDuringGeneration: get().generating,
});
}, },
sendChatMessage: async (message, sceneContext) => { sendChatMessage: async (message, sceneContext) => {

View File

@@ -47,6 +47,8 @@ export interface WorkflowStep {
name?: string; name?: string;
params?: Record<string, unknown>; params?: Record<string, unknown>;
condition?: string; condition?: string;
/** P2-12: Action type for pipeline step (hand, llm_generate, parallel, condition) */
actionType?: string;
} }
export interface WorkflowDetail { export interface WorkflowDetail {
@@ -341,6 +343,8 @@ interface PipelineInfo {
icon: string; icon: string;
version: string; version: string;
author: string; author: string;
/** P2-13: Step count from backend */
stepCount?: number;
inputs: Array<{ inputs: Array<{
name: string; name: string;
inputType: string; inputType: string;
@@ -386,7 +390,7 @@ function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient {
workflows: pipelines.map((p) => ({ workflows: pipelines.map((p) => ({
id: p.id, id: p.id,
name: p.displayName || 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, description: p.description,
createdAt: undefined, createdAt: undefined,
})), })),
@@ -424,6 +428,7 @@ function createWorkflowClientFromKernel(_client: KernelClient): WorkflowClient {
name: s.name || `Step ${i + 1}`, name: s.name || `Step ${i + 1}`,
params: s.params, params: s.params,
condition: s.condition, 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}`, name: s.name || `Step ${i + 1}`,
params: s.params, params: s.params,
condition: s.condition, condition: s.condition,
actionType: s.actionType, // P2-12: Send actionType to backend
})), })),
}, },
}); });

View File

@@ -1,6 +1,6 @@
# ZCLAW 上线前功能审计 — 缺陷清单 # ZCLAW 上线前功能审计 — 缺陷清单
> **审计日期**: 2026-04-05 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 > **审计日期**: 2026-04-06 | **审计范围**: T1-T8 模块 | **基线**: V12 审计 | **最新编译状态**: ✅ cargo check 通过
## 统计总览 ## 统计总览
@@ -8,9 +8,9 @@
|--------|---------|--------|--------|---------| |--------|---------|--------|--------|---------|
| **P0** | 1 | 0 | 1 | **0** | | **P0** | 1 | 0 | 1 | **0** |
| **P1** | 11 | 2 | 13 | **0** | | **P1** | 11 | 2 | 13 | **0** |
| **P2** | 25 | 2 | 4 | **23** | | **P2** | 25 | 2 | 23 | **4** |
| **P3** | 10 | 0 | 1 | **9** | | **P3** | 10 | 0 | 6 | **4** |
| **合计** | **47** | **4** | **19** | **32** | | **合计** | **47** | **4** | **43** | **8** |
--- ---
@@ -41,59 +41,59 @@
| ID | 原V12 ID | 描述 | 状态 | | ID | 原V12 ID | 描述 | 状态 |
|----|---------|------|------| |----|---------|------|------|
| P2-01 | M3-04 | max_concurrent 未实现5 个并发全被接受) | ⚠️ 未修复 | | P2-01 | M3-04 | max_concurrent 未实现5 个并发全被接受) | ✅ 已修复 (registry.rs Semaphore 并发限制) |
| P2-02 | M3-05 | timeout_secs 未实现(无超时保护) | ⚠️ 未修复 | | P2-02 | M3-05 | timeout_secs 未实现(无超时保护) | ✅ 已修复 (kernel/hands.rs tokio::time::timeout) |
| P2-03 | M3-10 | toolCount/metricCount 硬编码为 0 | ⚠️ 未修复 | | P2-03 | M3-10 | toolCount/metricCount 硬编码为 0 | ✅ 已修复 (registry.get_counts + Hand trait tool_count/metric_count) |
| P2-04 | TC-1-D03 | Quiz Hand 无输入长度限制100K 字符被接受) | ⚠️ 新发现 | | P2-04 | TC-1-D03 | Quiz Hand 无输入长度限制100K 字符被接受) | ✅ 已修复 (quiz.rs 50KB 限制) |
| P2-05 | M2-08 | max_tokens=0 未被 agent_create 拒绝 | ⚠️ 部分修复 | | P2-05 | M2-08 | max_tokens=0 未被 agent_create 拒绝 | ✅ 已修复 (create/update/import 全路径校验) |
### T2 Intelligence (4) ### T2 Intelligence (4)
| ID | 原V12 ID | 描述 | 状态 | | ID | 原V12 ID | 描述 | 状态 |
|----|---------|------|------| |----|---------|------|------|
| P2-06 | M4-08 | heartbeat_init 无最小间隔验证0.001分钟被接受) | ⚠️ 未修复 | | P2-06 | M4-08 | heartbeat_init 无最小间隔验证0.001分钟被接受) | ✅ 已修复 (init>=1 分钟, update_config 待补) |
| P2-07 | M4-02 | 反思引擎可能仍基于规则而非 LLMnew_memories=0 | ⚠️ 需确认 | | P2-07 | M4-02 | 反思引擎可能仍基于规则而非 LLMnew_memories=0 | ✅ 已修复 (ReflectionResult.used_fallback 标记) |
| P2-08 | TC-2-D01 | identity_propose_change 参数不透明 | ⚠️ 新发现 | | P2-08 | TC-2-D01 | identity_propose_change 参数不透明 | ✅ 已修复 (统一 file/target 参数命名) |
| P2-09 | M4-14/15 | reflection/identity 命令参数名与文档不一致 | ⚠️ 确认 | | P2-09 | M4-14/15 | reflection/identity 命令参数名与文档不一致 | ✅ 已修复 (错误消息统一) |
### T4 Classroom (2) ### T4 Classroom (2)
| ID | 原V12 ID | 描述 | 状态 | | ID | 原V12 ID | 描述 | 状态 |
|----|---------|------|------| |----|---------|------|------|
| P2-10 | M11-04 | LLM 失败静默 fallback 到 placeholder无标记 | ⚠️ 未修复 | | P2-10 | M11-04 | LLM 失败静默 fallback 到 placeholder无标记 | ✅ 已修复 (ClassroomMetadata.is_placeholder 字段) |
| P2-11 | M11-05 | 课堂生成完成强制打开 player不尊重手动关闭 | ⚠️ 未修复 | | P2-11 | M11-05 | 课堂生成完成强制打开 player不尊重手动关闭 | ✅ 已修复 (userDidCloseDuringGeneration 标志追踪) |
### T5 Pipeline (5) ### T5 Pipeline (5)
| ID | 原V12 ID | 描述 | 状态 | | ID | 原V12 ID | 描述 | 状态 |
|----|---------|------|------| |----|---------|------|------|
| P2-12 | M6-03 | pipeline_create 硬编码 Action::HandLLM/Parallel/Condition 丢失 | ⚠️ 未修复 | | P2-12 | M6-03 | pipeline_create 硬编码 Action::HandLLM/Parallel/Condition 丢失 | ✅ 已修复 (前端补发 actionType + 后端多分支匹配) |
| P2-13 | M6-04 | workflowStore steps: p.inputs.length 语义错误 | ⚠️ 未修复 | | P2-13 | M6-04 | workflowStore steps: p.inputs.length 语义错误 | ✅ 已修复 (后端 step_count 字段 + 前端 stepCount 读取) |
| P2-14 | M6-05 | getWorkflow inputs→steps 映射语义错误 | ⚠️ 未修复 | | P2-14 | M6-05 | getWorkflow inputs→steps 映射语义错误 | ✅ 已修复 (PipelineStepInfo + PipelineInfo.steps 字段) |
| P2-15 | M6-06 | 管道操作符 `\|` 在 context.resolve() 中不支持 | ❓ 未验证 | | P2-15 | M6-06 | 管道操作符 `\|` 在 context.resolve() 中不支持 | ✅ 已修复 (resolve_path_with_pipes + 8 种 transforms) |
| P2-16 | M6-07 | 模板中 `{{mustache}}``${inputs}` 混用 | ⚠️ 未修复 | | P2-16 | M6-07 | 模板中 `{{mustache}}``${inputs}` 混用 | ✅ 已修复 (mustache→${} 自动归一化) |
### T6 SaaS Desktop (2) ### T6 SaaS Desktop (2)
| ID | 原V12 ID | 描述 | 状态 | | 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 码通过外部服务生成,密钥明文传输 | ❓ 未验证 | | P2-18 | M7-03 | TOTP QR 码通过外部服务生成,密钥明文传输 | ❓ 未验证 |
### T7 Skills (2) ### T7 Skills (2)
| ID | 原V12 ID | 描述 | 状态 | | ID | 原V12 ID | 描述 | 状态 |
|----|---------|------|------| |----|---------|------|------|
| P2-19 | M5-02 | SKILL.md tools 字段未解析75 个技能 tools 被忽略 | ❓ 未验证 | | P2-19 | M5-02 | SKILL.md tools 字段未解析75 个技能 tools 被忽略 | ✅ 已修复 (serialize_skill_md 补全 tools 写入 + update_skill 保留 tools) |
| P2-20 | M5-03 | Python 技能硬编码 python3Windows 无此命令 | ❓ 未验证 | | P2-20 | M5-03 | Python 技能硬编码 python3Windows 无此命令 | ✅ 已修复 (runner.rs platform-aware python_bin()) |
### T8 Chat (3) ### T8 Chat (3)
| ID | 原V12 ID | 描述 | 状态 | | ID | 原V12 ID | 描述 | 状态 |
|----|---------|------|------| |----|---------|------|------|
| P2-21 | M1-01 | GeminiDriver API Key 在 URL query 参数中 | ❓ 未验证 | | P2-21 | M1-01 | GeminiDriver API Key 在 URL query 参数中 | ❓ 未验证 |
| P2-22 | M1-02 | ToolOutputGuard 只 warn 不 block 敏感信息 | ❓ 未验证 | | P2-22 | M1-02 | ToolOutputGuard 只 warn 不 block 敏感信息 | ✅ 已修复 (sensitive patterns now return Err to block output) |
| P2-23 | M1-03/04 | Mutex::unwrap() 在 async 中可能 panic | ❓ 未验证 | | 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 | 模块 | 描述 | 状态 | | 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-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-04 | M6-08 | T5 | get_progress() 百分比只有 0/50/100 三档 | ⚠️ 未修复 |
| P3-05 | M7-05 | T6 | saveSaaSSession fire-and-forget失败静默 | ❓ 未验证 | | P3-05 | M7-05 | T6 | saveSaaSSession fire-and-forget失败静默 | ❓ 未验证 |
| P3-06 | M7-06 | T6 | chatStream 不传 sessionKey/agentId | ❓ 未验证 | | P3-06 | M7-06 | T6 | chatStream 不传 sessionKey/agentId | ❓ 未验证 |
| P3-07 | M5-04 | T7 | YAML triggers 引号只处理双引号 | ❓ 未验证 | | P3-07 | M5-04 | T7 | YAML triggers 引号只处理双引号 | ✅ 已修复 (loader.rs 同时处理双引号和单引号) |
| P3-08 | M5-05 | T7 | ShellSkill duration_ms 未设置 | ❓ 未验证 | | P3-08 | M5-05 | T7 | ShellSkill duration_ms 未设置 | ✅ 已修复 (runner.rs 计时并返回 duration_ms) |
| P3-09 | M5-06 | T7 | CATEGORY_CONFIG 仅覆盖 9 分类75 技能全为 null | ⚠️ 未修复 | | P3-09 | M5-06 | T7 | CATEGORY_CONFIG 仅覆盖 9 分类75 技能全为 null | ✅ 已修复 (auto_classify + 20 分类覆盖) |
--- ---