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

@@ -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(),
},
};

View File

@@ -715,6 +715,17 @@ pub async fn heartbeat_init(
config: Option<HeartbeatConfig>,
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)

View File

@@ -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<IdentityChangeProposal, String> {
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))
}

View File

@@ -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<IdentityChangeProposal>,
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<Arc<dyn LlmDriver>>,
) -> 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

View File

@@ -79,7 +79,7 @@ impl From<zclaw_hands::HandConfig> 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

View File

@@ -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),
};

View File

@@ -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<String>,
pub hand_name: String,
pub name: Option<String>,
pub params: Option<HashMap<String, Value>>,
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
@@ -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<PipelineStep> = 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<String, String> = 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<String, String> = 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,
}

View File

@@ -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;

View File

@@ -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<PathBuf, String> {
// 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![],
}
}

View File

@@ -28,6 +28,22 @@ pub struct PipelineInfo {
pub author: String,
/// Input parameters
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

View File

@@ -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}

View File

@@ -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<ClassroomStore>()((set, get) => ({
generatingTopic: null,
activeClassroom: null,
classroomOpen: false,
userDidCloseDuringGeneration: false,
chatMessages: [],
chatLoading: false,
error: null,
@@ -105,6 +108,7 @@ export const useClassroomStore = create<ClassroomStore>()((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<ClassroomStore>()((set, get) => ({
const result = await invoke<GenerationResult>('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<ClassroomStore>()((set, get) => ({
},
closeClassroom: () => {
set({ classroomOpen: false });
set({
classroomOpen: false,
// P2-11: Track explicit user close during generation
userDidCloseDuringGeneration: get().generating,
});
},
sendChatMessage: async (message, sceneContext) => {

View File

@@ -47,6 +47,8 @@ export interface WorkflowStep {
name?: string;
params?: Record<string, unknown>;
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
})),
},
});