{"repo": "cdavid817/vanehub-ai", "files": {"src-tauri/src/contexts/tooling/cli_config/api.rs": {"cd7cca79453d578d189adda2e37c923a6c954ba5": {"t": 1785746254.3165758, "n": 74890, "h": "5625cc3a76311599", "c": "use super::application::{\n CliConfigCredentialPort, CliConfigRepository, CliGlobalConfigPort, ProjectionOutcome,\n};\nuse super::domain::{\n validate_profile_input, validate_supported_agent, AppliedStateRecord,\n ApplyCliConfigProfileInput, CliConfigAppliedState, CliConfigApplyResult,\n CliConfigDiscoveryCandidate, CliConfigDiscoveryResult, CliConfigDiscoveryState,\n CliConfigDriftResolution, CliConfigDriftState, CliConfigError, CliConfigPayload,\n CliConfigProfile, CliConfigStartupSyncResult, CliConfigStartupSyncState, CliConfigStatus,\n CliConfigValidationState, DeleteCliConfigProfileInput, ImportCliConfigProfileInput,\n ImportDiscoveredCliConfigInput, ImportDiscoveredCliConfigResult, ProfileRecord,\n SaveCliConfigProfileInput, SkippedCliConfigDiscoveryCandidate, PAYLOAD_VERSION,\n SUPPORTED_AGENT_IDS,\n};\nuse crate::contexts::operations::api::{DiagnosticLog, DiagnosticLogPort, LogSeverity};\nuse chrono::Utc;\nuse std::collections::{BTreeMap, BTreeSet};\nuse std::sync::{Arc, Mutex};\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub(crate) struct CliConfigApi {\n repository: Arc,\n credentials: Arc,\n live: Arc,\n logging: Arc,\n apply_locks: Arc>>>,\n startup_sync: Arc>>,\n}\n\nimpl CliConfigApi {\n pub(crate) fn new(\n repository: Arc,\n credentials: Arc,\n live: Arc,\n logging: Arc,\n ) -> Self {\n let apply_locks = super::domain::SUPPORTED_AGENT_IDS\n .into_iter()\n .map(|agent_id| (agent_id.to_string(), Arc::new(Mutex::new(()))))\n .collect();\n let startup_sync = SUPPORTED_AGENT_IDS\n .into_iter()\n .map(|agent_id| {\n (\n agent_id.to_string(),\n startup_sync_result(agent_id, CliConfigStartupSyncState::Pending),\n )\n })\n .collect();\n Self {\n repository,\n credentials,\n live,\n logging,\n apply_locks: Arc::new(apply_locks),\n startup_sync: Arc::new(Mutex::new(startup_sync)),\n }\n }\n\n pub(crate) fn synchronize_startup(&self) -> Vec {\n SUPPORTED_AGENT_IDS\n .into_iter()\n .map(|agent_id| {\n let result = match self.synchronize_startup_agent(agent_id) {\n Ok(result) => result,\n Err(error) => {\n let mut result =\n startup_sync_result(agent_id, CliConfigStartupSyncState::Warning);\n result.skipped = 1;\n result.warnings.push(startup_sync_warning(&error));\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n result\n }\n };\n self.write_log(\n if result.state == CliConfigStartupSyncState::Warning {\n LogSeverity::Warn\n } else {\n LogSeverity::Info\n },\n \"cli.config.startup.sync\",\n agent_id,\n None,\n None,\n &format!(\n \"startup CLI configuration sync completed: state={:?}, imported={}, updated={}, skipped={}\",\n result.state, result.imported, result.updated, result.skipped\n ),\n );\n if let Ok(mut stored) = self.startup_sync.lock() {\n stored.insert(agent_id.to_string(), result.clone());\n }\n result\n })\n .collect()\n }\n\n fn synchronize_startup_agent(\n &self,\n agent_id: &str,\n ) -> Result {\n if agent_id == \"opencode\" {\n return self.synchronize_opencode_startup();\n }\n let existing = self.repository.list_profiles(agent_id)?;\n if !existing.is_empty() {\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Skipped);\n result.skipped = 1;\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n return Ok(result);\n }\n let discovery = self.live.discover_current(agent_id)?;\n let Some(candidate) = discovery.candidates.into_iter().next() else {\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Unchanged);\n result.warnings = discovery.warnings;\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n return Ok(result);\n };\n let profile_id = format!(\"{agent_id}-default\");\n let imported_credential = candidate.credential.is_some();\n let record = self.persist_live_profile(\n agent_id,\n profile_id,\n \"default\".to_string(),\n candidate.payload,\n candidate.credential.as_deref().map(String::as_str),\n None,\n )?;\n let inspection = self.live.inspect(agent_id, None, Some(&record))?;\n let applied = AppliedStateRecord {\n agent_id: agent_id.to_string(),\n profile_id: Some(record.id.clone()),\n projection_fingerprint: inspection.managed_fingerprint.clone(),\n live_fingerprint: inspection.managed_fingerprint,\n drift_state: CliConfigDriftState::Applied,\n applied_at: Utc::now().to_rfc3339(),\n applied_payload: Some(record.payload.clone()),\n managed_keys: record.managed_keys.clone(),\n };\n if let Err(error) = self.repository.save_applied_state(&applied) {\n let credential_restored =\n !imported_credential || self.credentials.delete(agent_id, &record.id).is_ok();\n let profile_restored = self.repository.delete_profile(agent_id, &record.id).is_ok();\n if !credential_restored || !profile_restored {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Imported);\n result.imported = 1;\n result.warnings = discovery.warnings;\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n Ok(result)\n }\n\n fn synchronize_opencode_startup(&self) -> Result {\n let agent_id = \"opencode\";\n let discovery = self.live.discover_current(agent_id)?;\n let mut existing = self.repository.list_profiles(agent_id)?;\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Unchanged);\n result.warnings = discovery.warnings;\n for candidate in discovery.candidates {\n let provider_id = match &candidate.payload {\n CliConfigPayload::Opencode { provider_id, .. } => provider_id.clone(),\n _ => continue,\n };\n let matched = existing.iter().find(|profile| {\n matches!(\n &profile.payload,\n CliConfigPayload::Opencode {\n provider_id: existing_id,\n ..\n } if existing_id == &provider_id\n )\n });\n let profile_id = match matched {\n Some(profile) => profile.id.clone(),\n None => self.next_profile_id(&format!(\"opencode-{provider_id}\"))?,\n };\n let prior = matched.cloned();\n let prior_secret = self.credentials.load(agent_id, &profile_id)?;\n let incoming_secret = candidate.credential.as_deref().map(String::as_str);\n let changed = prior.as_ref().is_none_or(|profile| {\n profile.payload != candidate.payload\n || profile.name != candidate.suggested_name\n || prior_secret.as_deref().map(String::as_str) != incoming_secret\n });\n if !changed {\n result.skipped += 1;\n continue;\n }\n let record = self.persist_live_profile(\n agent_id,\n profile_id,\n candidate.suggested_name,\n candidate.payload,\n incoming_secret,\n prior.as_ref(),\n )?;\n if prior.is_some() {\n result.updated += 1;\n } else {\n result.imported += 1;\n existing.push(record);\n }\n }\n result.state = if result.updated > 0 {\n CliConfigStartupSyncState::Updated\n } else if result.imported > 0 {\n CliConfigStartupSyncState::Imported\n } else {\n CliConfigStartupSyncState::Unchanged\n };\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n Ok(result)\n }\n\n fn persist_live_profile(\n &self,\n agent_id: &str,\n profile_id: String,\n name: String,\n payload: CliConfigPayload,\n credential: Option<&str>,\n existing: Option<&ProfileRecord>,\n ) -> Result {\n payload.validate()?;\n if payload.agent_id() != agent_id {\n return Err(CliConfigError::Validation(\n \"live configuration payload does not match the selected Agent\".into(),\n ));\n }\n if credential\n .is_some_and(|secret| secret.is_empty() || secret.chars().any(char::is_control))\n {\n return Err(CliConfigError::Validation(\n \"live configuration credential is invalid\".into(),\n ));\n }\n let sort_position = match existing {\n Some(profile) => profile.sort_position,\n None => self.repository.list_profiles(agent_id)?.len() as i64,\n };\n let previous_secret = self.credentials.load(agent_id, &profile_id)?;\n match credential {\n Some(secret) => self.credentials.store(agent_id, &profile_id, secret)?,\n None if previous_secret.is_some() => self.credentials.delete(agent_id, &profile_id)?,\n None => {}\n }\n let now = Utc::now().to_rfc3339();\n let record = ProfileRecord {\n id: profile_id.clone(),\n agent_id: agent_id.to_string(),\n name,\n payload_version: PAYLOAD_VERSION,\n managed_keys: payload.managed_keys(),\n payload,\n source_preset_id: None,\n source_preset_version: None,\n created_at: existing\n .map(|profile| profile.created_at.clone())\n .unwrap_or_else(|| now.clone()),\n updated_at: now,\n sort_position,\n };\n if let Err(error) = self.repository.save_profile(&record) {\n if self\n .restore_credential(\n agent_id,\n &profile_id,\n previous_secret.as_deref().map(String::as_str),\n )\n .is_err()\n {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n Ok(record)\n }\n\n pub(crate) fn list_profiles(\n &self,\n agent_id: &str,\n ) -> Result, CliConfigError> {\n validate_supported_agent(agent_id)?;\n let status = self.inspect_status(agent_id)?;\n self.repository\n .list_profiles(agent_id)?\n .into_iter()\n .map(|record| self.profile_view(record, &status))\n .collect()\n }\n\n pub(crate) fn inspect_status(&self, agent_id: &str) -> Result {\n validate_supported_agent(agent_id)?;\n let applied = self.repository.applied_state(agent_id)?;\n let applied_profile = applied\n .as_ref()\n .map(|state| self.applied_profile_snapshot(agent_id, state))\n .transpose()?\n .flatten();\n let inspection = self\n .live\n .inspect(agent_id, applied.as_ref(), applied_profile.as_ref())?;\n Ok(CliConfigStatus {\n agent_id: agent_id.to_string(),\n applied_profile_id: applied.as_ref().and_then(|state| state.profile_id.clone()),\n drift_state: inspection.state,\n resolved_paths: inspection\n .paths\n .into_iter()\n .map(|path| path.display().to_string())\n .collect(),\n last_applied_at: applied.map(|state| state.applied_at),\n simulated: false,\n startup_sync: self.startup_sync_result(agent_id),\n })\n }\n\n fn startup_sync_result(&self, agent_id: &str) -> CliConfigStartupSyncResult {\n self.startup_sync\n .lock()\n .ok()\n .and_then(|results| results.get(agent_id).cloned())\n .unwrap_or_else(|| startup_sync_result(agent_id, CliConfigStartupSyncState::Warning))\n }\n\n pub(crate) fn save_profile(\n &self,\n input: SaveCliConfigProfileInput,\n ) -> Result {\n let agent_id = input.agent_id.clone();\n let result = self.save_profile_inner(input);\n self.log_result(\n \"cli.config.profile.save\",\n &agent_id,\n result.as_ref().ok().map(|p| p.id.as_str()),\n &result,\n );\n result\n }\n\n fn save_profile_inner(\n &self,\n input: SaveCliConfigProfileInput,\n ) -> Result {\n validate_profile_input(&input)?;\n if input.credential.is_some() && input.remove_credential {\n return Err(CliConfigError::Validation(\n \"credential cannot be replaced and removed in one request\".into(),\n ));\n }\n if let Some(source_id) = input.source_preset_id.as_deref() {\n let compatible_prefix = format!(\"{}-\", input.agent_id);\n if !source_id.starts_with(&compatible_prefix) {\n return Err(CliConfigError::Validation(\n \"preset is incompatible with the selected Agent\".into(),\n ));\n }\n }\n\n let existing = input\n .id\n .as_deref()\n .map(|id| self.repository.get_profile(&input.agent_id, id))\n .transpose()?;\n let id = match &existing {\n Some(profile) => profile.id.clone(),\n None => self.next_profile_id(&input.name)?,\n };\n let previous_secret = self.credentials.load(&input.agent_id, &id)?;\n if existing.is_none()\n && input.payload.requires_credential()\n && input.credential.is_none()\n && !input.remove_credential\n {\n return Err(CliConfigError::CredentialRequired);\n }\n\n let wrote_credential = if let Some(secret) = input.credential.as_deref() {\n self.credentials.store(&input.agent_id, &id, secret)?;\n true\n } else if input.remove_credential {\n self.credentials.delete(&input.agent_id, &id)?;\n true\n } else {\n false\n };\n\n let now = Utc::now().to_rfc3339();\n let record = ProfileRecord {\n id: id.clone(),\n agent_id: input.agent_id.clone(),\n name: input.name.trim().to_string(),\n payload_version: PAYLOAD_VERSION,\n managed_keys: input.payload.managed_keys(),\n payload: input.payload,\n source_preset_id: input.source_preset_id,\n source_preset_version: input.source_preset_version,\n created_at: existing\n .as_ref()\n .map(|profile| profile.created_at.clone())\n .unwrap_or_else(|| now.clone()),\n updated_at: now,\n sort_position: existing\n .as_ref()\n .map(|profile| profile.sort_position)\n .unwrap_or(self.repository.list_profiles(&input.agent_id)?.len() as i64),\n };\n if let Err(error) = self.repository.save_profile(&record) {\n if wrote_credential {\n self.restore_credential(\n &input.agent_id,\n &id,\n previous_secret.as_deref().map(String::as_str),\n )?;\n }\n return Err(error);\n }\n let status = self.inspect_status(&input.agent_id)?;\n self.profile_view(record, &status)\n }\n\n pub(crate) fn duplicate_profile(\n &self,\n agent_id: &str,\n profile_id: &str,\n ) -> Result {\n validate_supported_agent(agent_id)?;\n let source = self.repository.get_profile(agent_id, profile_id)?;\n let credential = self.credentials.load(agent_id, profile_id)?;\n let names = self\n .repository\n .list_profiles(agent_id)?\n .into_iter()\n .map(|profile| profile.name)\n .collect::>();\n let name = unique_copy_name(&source.name, &names);\n self.save_profile(SaveCliConfigProfileInput {\n id: None,\n agent_id: source.agent_id,\n name,\n payload: source.payload,\n source_preset_id: source.source_preset_id,\n source_preset_version: source.source_preset_version,\n credential: credential.as_deref().map(ToString::to_string),\n remove_credential: false,\n })\n }\n\n pub(crate) fn delete_profile(\n &self,\n input: DeleteCliConfigProfileInput,\n ) -> Result<(), CliConfigError> {\n validate_supported_agent(&input.agent_id)?;\n let profile = self\n .repository\n .get_profile(&input.agent_id, &input.profile_id)?;\n let applied = self.repository.applied_state(&input.agent_id)?;\n let is_applied = applied\n .as_ref()\n .and_then(|state| state.profile_id.as_deref())\n == Some(input.profile_id.as_str());\n if is_applied && !input.detach_applied {\n return Err(CliConfigError::Validation(\n \"applied profile must be detached before deletion\".into(),\n ));\n }\n let previous_secret = self.credentials.load(&input.agent_id, &input.profile_id)?;\n if is_applied {\n self.repository.clear_applied_state(&input.agent_id)?;\n }\n if let Err(error) = self.credentials.delete(&input.agent_id, &input.profile_id) {\n if let Some(state) = &applied {\n self.repository.save_applied_state(state)?;\n }\n return Err(error);\n }\n if let Err(error) = self\n .repository\n .delete_profile(&input.agent_id, &input.profile_id)\n {\n self.restore_credential(\n &input.agent_id,\n &input.profile_id,\n previous_secret.as_deref().map(String::as_str),\n )?;\n if let Some(state) = &applied {\n self.repository.save_applied_state(state)?;\n }\n return Err(error);\n }\n self.write_log(\n LogSeverity::Info,\n \"cli.config.profile.delete\",\n &input.agent_id,\n Some(&profile.id),\n None,\n \"deleted CLI configuration profile\",\n );\n Ok(())\n }\n\n pub(crate) fn import_current(\n &self,\n input: ImportCliConfigProfileInput,\n ) -> Result {\n validate_supported_agent(&input.agent_id)?;\n let imported = self.live.import_current(&input.agent_id)?;\n let result = self.save_profile(SaveCliConfigProfileInput {\n id: None,\n agent_id: input.agent_id.clone(),\n name: input.name,\n payload: imported.payload,\n source_preset_id: None,\n source_preset_version: None,\n credential: imported.credential.as_deref().map(ToString::to_string),\n remove_credential: false,\n });\n self.log_result(\n \"cli.config.profile.import\",\n &input.agent_id,\n result.as_ref().ok().map(|profile| profile.id.as_str()),\n &result,\n );\n result\n }\n\n pub(crate) fn discover_profiles(\n &self,\n agent_id: &str,\n ) -> Result {\n validate_supported_agent(agent_id)?;\n let resolved_paths = self\n .live\n .paths(agent_id)?\n .into_iter()\n .map(|path| path.display().to_string())\n .collect::>();\n match self.live.discover_current(agent_id) {\n Ok(discovery) => {\n let candidate_paths = discovery\n .paths\n .iter()\n .map(|path| path.display().to_string())\n .collect::>();\n Ok(CliConfigDiscoveryResult {\n agent_id: agent_id.to_string(),\n state: CliConfigDiscoveryState::Available,\n candidates: discovery\n .candidates\n .into_iter()\n .map(|candidate| CliConfigDiscoveryCandidate {\n candidate_key: candidate.candidate_key,\n suggested_name: candidate.suggested_name,\n provider_name: candidate.provider_name,\n endpoint: candidate.endpoint,\n model: candidate.model,\n credential_detected: candidate.credential.is_some(),\n is_default: candidate.is_default,\n resolved_paths: candidate_paths.clone(),\n })\n .collect(),\n resolved_paths: candidate_paths,\n warnings: discovery.warnings,\n error: None,\n simulated: false,\n })\n }\n Err(CliConfigError::Parse { path, message: _ }) => Ok(CliConfigDiscoveryResult {\n agent_id: agent_id.to_string(),\n state: CliConfigDiscoveryState::ParseError,\n candidates: Vec::new(),\n resolved_paths,\n warnings: Vec::new(),\n error: Some(format!(\n \"Could not parse {path} as a supported CLI configuration.\"\n )),\n simulated: false,\n }),\n Err(error) => Err(error),\n }\n }\n\n pub(crate) fn import_discovered_profiles(\n &self,\n input: ImportDiscoveredCliConfigInput,\n ) -> Result {\n validate_supported_agent(&input.agent_id)?;\n if input.candidate_keys.is_empty() {\n return Err(CliConfigError::Validation(\n \"at least one discovered candidate must be selected\".into(),\n ));\n }\n if input.candidate_keys.len() > 64 {\n return Err(CliConfigError::Validation(\n \"too many discovered candidates were selected\".into(),\n ));\n }\n let requested = input.candidate_keys.into_iter().collect::>();\n let discovery = self.live.discover_current(&input.agent_id)?;\n let available = discovery\n .candidates\n .iter()\n .map(|candidate| candidate.candidate_key.clone())\n .collect::>();\n if let Some(missing) = requested.difference(&available).next() {\n return Err(CliConfigError::Validation(format!(\n \"discovered candidate is no longer available: {missing}\"\n )));\n }\n let mut existing_names = self\n .repository\n .list_profiles(&input.agent_id)?\n .into_iter()\n .map(|profile| profile.name.trim().to_lowercase())\n .collect::>();\n let mut imported = Vec::new();\n let mut skipped = Vec::new();\n for candidate in discovery\n .candidates\n .into_iter()\n .filter(|candidate| requested.contains(&candidate.candidate_key))\n {\n let normalized_name = candidate.suggested_name.trim().to_lowercase();\n if existing_names.contains(&normalized_name) {\n skipped.push(SkippedCliConfigDiscoveryCandidate {\n candidate_key: candidate.candidate_key,\n suggested_name: candidate.suggested_name,\n reason: \"name-conflict\".into(),\n });\n continue;\n }\n let profile = self.save_profile(SaveCliConfigProfileInput {\n id: None,\n agent_id: input.agent_id.clone(),\n name: candidate.suggested_name,\n payload: candidate.payload,\n source_preset_id: None,\n source_preset_version: None,\n credential: candidate.credential.as_deref().map(ToString::to_string),\n remove_credential: false,\n })?;\n existing_names.insert(normalized_name);\n imported.push(profile);\n }\n self.write_log(\n LogSeverity::Info,\n \"cli.config.discovery.import\",\n &input.agent_id,\n None,\n None,\n &format!(\n \"imported {} discovered CLI configurations and skipped {}\",\n imported.len(),\n skipped.len()\n ),\n );\n Ok(ImportDiscoveredCliConfigResult { imported, skipped })\n }\n\n pub(crate) fn apply_profile(\n &self,\n input: ApplyCliConfigProfileInput,\n ) -> Result {\n validate_supported_agent(&input.agent_id)?;\n let operation_id = Uuid::new_v4().to_string();\n let result = self.apply_profile_inner(&input);\n let apply_result = match result {\n Ok((profile, outcome, backfilled_profile_id)) => apply_result(\n operation_id,\n &profile,\n outcome,\n if backfilled_profile_id.is_some() {\n Some(CliConfigDriftResolution::ImportCurrent)\n } else {\n input.drift_resolution.clone()\n },\n backfilled_profile_id,\n ),\n Err(error) => failed_apply_result(\n operation_id,\n &input,\n self.live.paths(&input.agent_id).unwrap_or_default(),\n &error,\n ),\n };\n self.write_apply_log(&apply_result);\n Ok(apply_result)\n }\n\n fn apply_profile_inner(\n &self,\n input: &ApplyCliConfigProfileInput,\n ) -> Result<(ProfileRecord, ProjectionOutcome, Option), CliConfigError> {\n let apply_lock = self\n .apply_locks\n .get(&input.agent_id)\n .cloned()\n .ok_or(CliConfigError::Repository)?;\n let _apply_guard = apply_lock.lock().map_err(|_| CliConfigError::Repository)?;\n let profile = self\n .repository\n .get_profile(&input.agent_id, &input.profile_id)?;\n profile.payload.validate()?;\n let credential = self.credentials.load(&input.agent_id, &input.profile_id)?;\n if profile.payload.requires_credential() && credential.is_none() {\n return Err(CliConfigError::CredentialRequired);\n }\n let previous_state = self.repository.applied_state(&input.agent_id)?;\n let mut previous_profile = previous_state\n .as_ref()\n .map(|state| self.applied_profile_snapshot(&input.agent_id, state))\n .transpose()?\n .flatten();\n let inspection = self.live.inspect(\n &input.agent_id,\n previous_state.as_ref(),\n previous_profile.as_ref(),\n )?;\n if inspection.state == CliConfigDriftState::Malformed {\n return Err(CliConfigError::Parse {\n path: inspection\n .paths\n .first()\n .map(|path| path.display().to_string())\n .unwrap_or_else(|| \"configuration\".into()),\n message: \"existing configuration is malformed\".into(),\n });\n }\n let switching_exclusive_profile = input.agent_id != \"opencode\"\n && previous_profile\n .as_ref()\n .is_some_and(|previous| previous.id != profile.id);\n let backfilled_profile_id = if switching_exclusive_profile {\n let previous = previous_profile\n .as_ref()\n .ok_or(CliConfigError::Repository)?;\n let (updated, source_fingerprint) = self.backfill_profile(&input.agent_id, previous)?;\n let id = updated.id.clone();\n previous_profile = Some(updated);\n Some((id, source_fingerprint))\n } else {\n None\n };\n let expected_live_fingerprint = backfilled_profile_id\n .as_ref()\n .map(|(_, fingerprint)| fingerprint.clone())\n .unwrap_or(inspection.managed_fingerprint);\n let backfilled_profile_id = backfilled_profile_id.map(|(id, _)| id);\n\n let outcome = self.live.apply(\n &profile,\n previous_profile.as_ref(),\n credential.as_deref().map(|secret| secret.as_str()),\n input.confirm_auth_file_replacement,\n &expected_live_fingerprint,\n )?;\n let applied = AppliedStateRecord {\n agent_id: input.agent_id.clone(),\n profile_id: Some(profile.id.clone()),\n projection_fingerprint: outcome.projection_fingerprint.clone(),\n live_fingerprint: outcome.live_fingerprint.clone(),\n drift_state: CliConfigDriftState::Applied,\n applied_at: Utc::now().to_rfc3339(),\n applied_payload: Some(profile.payload.clone()),\n managed_keys: profile.managed_keys.clone(),\n };\n if let Err(error) = self.repository.save_applied_state(&applied) {\n let live_restored = self.live.restore(&outcome).is_ok();\n let state_restored = if let Some(previous) = &previous_state {\n self.repository.save_applied_state(previous).is_ok()\n } else {\n self.repository.clear_applied_state(&input.agent_id).is_ok()\n };\n if !live_restored || !state_restored {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n Ok((profile, outcome, backfilled_profile_id))\n }\n\n fn backfill_profile(\n &self,\n agent_id: &str,\n previous: &ProfileRecord,\n ) -> Result<(ProfileRecord, String), CliConfigError> {\n let imported = self.live.import_current(agent_id)?;\n let source_fingerprint = imported.source_fingerprint.clone();\n imported.payload.validate()?;\n let mut updated = previous.clone();\n updated.payload = imported.payload;\n updated.managed_keys = previous\n .managed_keys\n .iter()\n .cloned()\n .chain(updated.payload.managed_keys())\n .collect::>()\n .into_iter()\n .collect();\n updated.updated_at = Utc::now().to_rfc3339();\n let previous_secret = self.credentials.load(agent_id, &updated.id)?;\n let incoming_secret = imported.credential.as_deref().map(String::as_str);\n match incoming_secret {\n Some(secret) => self.credentials.store(agent_id, &updated.id, secret)?,\n None if previous_secret.is_some() => self.credentials.delete(agent_id, &updated.id)?,\n None => {}\n }\n if let Err(error) = self.repository.save_profile(&updated) {\n if self\n .restore_credential(\n agent_id,\n &updated.id,\n previous_secret.as_deref().map(String::as_str),\n )\n .is_err()\n {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n self.write_log(\n LogSeverity::Info,\n \"cli.config.profile.backfill\",\n agent_id,\n Some(&updated.id),\n None,\n \"backfilled current live configuration into the leaving profile\",\n );\n Ok((updated, source_fingerprint))\n }\n\n fn profile_view(\n &self,\n record: ProfileRecord,\n status: &CliConfigStatus,\n ) -> Result {\n let credential_configured = self\n .credentials\n .load(&record.agent_id, &record.id)?\n .is_some();\n let validation_state = if record.payload.validate().is_err() {\n CliConfigValidationState::Invalid\n } else if record.payload.requires_credential() && !credential_configured {\n CliConfigValidationState::NeedsCredential\n } else {\n CliConfigValidationState::Valid\n };\n let applied_state = if status.applied_profile_id.as_deref() == Some(record.id.as_str()) {\n if status.drift_state == CliConfigDriftState::Applied {\n CliConfigAppliedState::Applied\n } else {\n CliConfigAppliedState::Drifted\n }\n } else {\n CliConfigAppliedState::Saved\n };\n Ok(CliConfigProfile {\n id: record.id,\n agent_id: record.agent_id,\n name: record.name,\n payload_version: record.payload_version,\n payload: record.payload,\n source_preset_id: record.source_preset_id,\n source_preset_version: record.source_preset_version,\n credential_configured,\n validation_state,\n applied_state,\n created_at: record.created_at,\n updated_at: record.updated_at,\n })\n }\n\n fn applied_profile_snapshot(\n &self,\n agent_id: &str,\n state: &AppliedStateRecord,\n ) -> Result, CliConfigError> {\n let Some(profile_id) = state.profile_id.as_deref() else {\n return Ok(None);\n };\n let mut profile = self.repository.get_profile(agent_id, profile_id)?;\n if let Some(payload) = &state.applied_payload {\n profile.payload = payload.clone();\n }\n if !state.managed_keys.is_empty() {\n profile.managed_keys = state.managed_keys.clone();\n }\n Ok(Some(profile))\n }\n\n fn next_profile_id(&self, name: &str) -> Result {\n let base = slug(name);\n if !self.repository.profile_id_exists(&base)? {\n return Ok(base);\n }\n for suffix in 2..10_000 {\n let candidate = format!(\"{base}-{suffix}\");\n if !self.repository.profile_id_exists(&candidate)? {\n return Ok(candidate);\n }\n }\n Err(CliConfigError::Repository)\n }\n\n fn restore_credential(\n &self,\n agent_id: &str,\n profile_id: &str,\n previous: Option<&str>,\n ) -> Result<(), CliConfigError> {\n match previous {\n Some(secret) => self.credentials.store(agent_id, profile_id, secret),\n None => self.credentials.delete(agent_id, profile_id),\n }\n }\n\n fn log_result(\n &self,\n category: &str,\n agent_id: &str,\n profile_id: Option<&str>,\n result: &Result,\n ) {\n self.write_log(\n if result.is_ok() {\n LogSeverity::Info\n } else {\n LogSeverity::Error\n },\n category,\n agent_id,\n profile_id,\n None,\n if result.is_ok() {\n \"CLI configuration operation succeeded\"\n } else {\n \"CLI configuration operation failed\"\n },\n );\n }\n\n fn write_log(\n &self,\n severity: LogSeverity,\n category: &str,\n agent_id: &str,\n profile_id: Option<&str>,\n operation_id: Option<&str>,\n message: &str,\n ) {\n let mut context = BTreeMap::from([(\"agentId\".into(), agent_id.to_string())]);\n if let Some(profile_id) = profile_id {\n context.insert(\"profileId\".into(), profile_id.to_string());\n }\n if let Some(operation_id) = operation_id {\n context.insert(\"operationId\".into(), operation_id.to_string());\n }\n context.insert(\n \"status\".into(),\n if severity == LogSeverity::Error {\n \"failed\"\n } else {\n \"succeeded\"\n }\n .into(),\n );\n let _ = self.logging.write_diagnostic(DiagnosticLog {\n severity,\n category: category.into(),\n message: message.into(),\n context,\n });\n }\n\n fn write_apply_log(&self, result: &CliConfigApplyResult) {\n let mut context = BTreeMap::from([\n (\"agentId\".into(), result.agent_id.clone()),\n (\"profileId\".into(), result.profile_id.clone()),\n (\"operationId\".into(), result.operation_id.clone()),\n (\"status\".into(), result.status.clone()),\n (\"restored\".into(), result.restored.to_string()),\n ]);\n let target_files = result\n .affected_paths\n .iter()\n .filter_map(|path| std::path::Path::new(path).file_name())\n .map(|name| name.to_string_lossy())\n .collect::>()\n .join(\",\");\n if !target_files.is_empty() {\n context.insert(\"targetFiles\".into(), target_files);\n }\n let succeeded = result.status == \"succeeded\";\n let _ = self.logging.write_diagnostic(DiagnosticLog {\n severity: if succeeded {\n LogSeverity::Info\n } else {\n LogSeverity::Error\n },\n category: \"cli.config.profile.apply\".into(),\n message: if succeeded {\n \"applied CLI configuration profile\"\n } else {\n \"CLI configuration profile application failed\"\n }\n .into(),\n context,\n });\n }\n}\n\nfn startup_sync_result(\n agent_id: &str,\n state: CliConfigStartupSyncState,\n) -> CliConfigStartupSyncResult {\n CliConfigStartupSyncResult {\n agent_id: agent_id.to_string(),\n state,\n imported: 0,\n updated: 0,\n skipped: 0,\n warnings: Vec::new(),\n synchronized_at: None,\n simulated: false,\n }\n}\n\nfn startup_sync_warning(error: &CliConfigError) -> String {\n match error {\n CliConfigError::Parse { .. } => {\n \"The local CLI configuration could not be parsed; startup synchronization was skipped.\"\n }\n CliConfigError::Filesystem { .. } => {\n \"The local CLI configuration could not be read; startup synchronization was skipped.\"\n }\n CliConfigError::Credential | CliConfigError::CredentialRequired => {\n \"The local CLI credential could not be synchronized safely.\"\n }\n CliConfigError::RollbackIncomplete => {\n \"Startup synchronization could not fully restore an interrupted profile update.\"\n }\n _ => \"Startup synchronization could not be completed.\",\n }\n .to_string()\n}\n\nfn apply_result(\n operation_id: String,\n profile: &ProfileRecord,\n outcome: ProjectionOutcome,\n drift_resolution: Option,\n backfilled_profile_id: Option,\n) -> CliConfigApplyResult {\n CliConfigApplyResult {\n operation_id,\n status: \"succeeded\".into(),\n agent_id: profile.agent_id.clone(),\n profile_id: profile.id.clone(),\n affected_paths: outcome\n .paths\n .into_iter()\n .map(|path| path.display().to_string())\n .collect(),\n drift_resolution,\n backfilled_profile_id,\n warnings: outcome.warnings,\n restart_required: true,\n simulated: false,\n restored: outcome.restored,\n error: None,\n }\n}\n\nfn failed_apply_result(\n operation_id: String,\n input: &ApplyCliConfigProfileInput,\n paths: Vec,\n error: &CliConfigError,\n) -> CliConfigApplyResult {\n CliConfigApplyResult {\n operation_id,\n status: \"failed\".into(),\n agent_id: input.agent_id.clone(),\n profile_id: input.profile_id.clone(),\n affected_paths: paths\n .into_iter()\n .map(|path| path.display().to_string())\n .collect(),\n drift_resolution: input.drift_resolution.clone(),\n backfilled_profile_id: None,\n warnings: Vec::new(),\n restart_required: false,\n simulated: false,\n restored: !matches!(error, CliConfigError::RollbackIncomplete),\n error: Some(error.to_string()),\n }\n}\n\nfn slug(value: &str) -> String {\n let mut output = String::new();\n let mut separator = false;\n for character in value.trim().chars() {\n if character.is_ascii_alphanumeric() {\n output.push(character.to_ascii_lowercase());\n separator = false;\n } else if !separator && !output.is_empty() {\n output.push('-');\n separator = true;\n }\n }\n while output.ends_with('-') {\n output.pop();\n }\n if output.is_empty() {\n \"profile\".into()\n } else {\n output\n }\n}\n\nfn unique_copy_name(source: &str, existing: &[String]) -> String {\n let base = format!(\"{source} Copy\");\n if !existing.contains(&base) {\n return base;\n }\n for suffix in 2..10_000 {\n let candidate = format!(\"{base} {suffix}\");\n if !existing.contains(&candidate) {\n return candidate;\n }\n }\n format!(\"{base} {}\", Uuid::new_v4())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::contexts::operations::api::OperationsError;\n use crate::contexts::tooling::cli_config::application::{ImportedLiveConfig, LiveInspection};\n use crate::contexts::tooling::cli_config::domain::{\n ApplyCliConfigProfileInput, ClaudeAuthMode, CliConfigDriftResolution, CliConfigPayload,\n ImportDiscoveredCliConfigInput, SaveCliConfigProfileInput,\n };\n use crate::contexts::tooling::cli_config::infrastructure::NativeCliGlobalConfigAdapter;\n use std::path::PathBuf;\n use std::sync::atomic::{AtomicUsize, Ordering};\n use std::thread;\n use std::time::Duration;\n\n #[derive(Default)]\n struct MemoryRepository {\n profiles: Mutex>,\n applied: Mutex>,\n fail_next_save: AtomicUsize,\n }\n\n impl CliConfigRepository for MemoryRepository {\n fn list_profiles(&self, agent_id: &str) -> Result, CliConfigError> {\n Ok(self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .iter()\n .filter(|profile| profile.agent_id == agent_id)\n .cloned()\n .collect())\n }\n\n fn get_profile(\n &self,\n agent_id: &str,\n profile_id: &str,\n ) -> Result {\n self.profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .iter()\n .find(|profile| profile.agent_id == agent_id && profile.id == profile_id)\n .cloned()\n .ok_or(CliConfigError::NotFound)\n }\n\n fn profile_id_exists(&self, profile_id: &str) -> Result {\n Ok(self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .iter()\n .any(|profile| profile.id == profile_id))\n }\n\n fn save_profile(&self, profile: &ProfileRecord) -> Result<(), CliConfigError> {\n if self.fail_next_save.swap(0, Ordering::SeqCst) > 0 {\n return Err(CliConfigError::Repository);\n }\n let mut profiles = self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?;\n profiles.retain(|candidate| candidate.id != profile.id);\n profiles.push(profile.clone());\n Ok(())\n }\n\n fn delete_profile(&self, agent_id: &str, profile_id: &str) -> Result<(), CliConfigError> {\n let mut profiles = self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?;\n let before = profiles.len();\n profiles.retain(|profile| profile.agent_id != agent_id || profile.id != profile_id);\n if profiles.len() == before {\n return Err(CliConfigError::NotFound);\n }\n Ok(())\n }\n\n fn applied_state(\n &self,\n agent_id: &str,\n ) -> Result, CliConfigError> {\n Ok(self\n .applied\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .get(agent_id)\n .cloned())\n }\n\n fn save_applied_state(&self, state: &AppliedStateRecord) -> Result<(), CliConfigError> {\n self.applied\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .insert(state.agent_id.clone(), state.clone());\n Ok(())\n }\n\n fn clear_applied_state(&self, agent_id: &str) -> Result<(), CliConfigError> {\n self.applied\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .remove(agent_id);\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct MemoryCredentials {\n values: Mutex>,\n }\n\n impl CliConfigCredentialPort for MemoryCredentials {\n fn load(\n &self,\n agent_id: &str,\n profile_id: &str,\n ) -> Result>, CliConfigError> {\n Ok(self\n .values\n .lock()\n .map_err(|_| CliConfigError::Credential)?\n .get(&(agent_id.to_string(), profile_id.to_string()))\n .cloned()\n .map(zeroize::Zeroizing::new))\n }\n\n fn store(\n &self,\n agent_id: &str,\n profile_id: &str,\n secret: &str,\n ) -> Result<(), CliConfigError> {\n self.values\n .lock()\n .map_err(|_| CliConfigError::Credential)?\n .insert(\n (agent_id.to_string(), profile_id.to_string()),\n secret.to_string(),\n );\n Ok(())\n }\n\n fn delete(&self, agent_id: &str, profile_id: &str) -> Result<(), CliConfigError> {\n self.values\n .lock()\n .map_err(|_| CliConfigError::Credential)?\n .remove(&(agent_id.to_string(), profile_id.to_string()));\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct SerialProbeLiveConfig {\n active: AtomicUsize,\n max_active: AtomicUsize,\n }\n\n impl CliGlobalConfigPort for SerialProbeLiveConfig {\n fn paths(&self, _agent_id: &str) -> Result, CliConfigError> {\n Ok(vec![PathBuf::from(\"safe-config\")])\n }\n\n fn inspect(\n &self,\n agent_id: &str,\n _applied: Option<&AppliedStateRecord>,\n _applied_profile: Option<&ProfileRecord>,\n ) -> Result {\n Ok(LiveInspection {\n paths: self.paths(agent_id)?,\n state: CliConfigDriftState::Detached,\n managed_fingerprint: \"before\".into(),\n })\n }\n\n fn import_current(&self, _agent_id: &str) -> Result {\n Err(CliConfigError::NotFound)\n }\n\n fn apply(\n &self,\n _profile: &ProfileRecord,\n _previous: Option<&ProfileRecord>,\n _credential: Option<&str>,\n _confirm_auth_file_replacement: bool,\n _expected_live_fingerprint: &str,\n ) -> Result {\n let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;\n self.max_active.fetch_max(active, Ordering::SeqCst);\n thread::sleep(Duration::from_millis(30));\n self.active.fetch_sub(1, Ordering::SeqCst);\n Ok(ProjectionOutcome {\n paths: vec![PathBuf::from(\"safe-config\")],\n projection_fingerprint: \"projection\".into(),\n live_fingerprint: \"live\".into(),\n warnings: Vec::new(),\n restored: true,\n backups: Vec::new(),\n })\n }\n\n fn restore(&self, _outcome: &ProjectionOutcome) -> Result<(), CliConfigError> {\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct NoopLog;\n\n impl DiagnosticLogPort for NoopLog {\n fn write_diagnostic(&self, _log: DiagnosticLog) -> Result<(), OperationsError> {\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct CapturingLog {\n entries: Mutex>,\n }\n\n impl DiagnosticLogPort for CapturingLog {\n fn write_diagnostic(&self, log: DiagnosticLog) -> Result<(), OperationsError> {\n self.entries.lock().expect(\"log entries\").push(log);\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct RollbackFailLiveConfig;\n\n impl CliGlobalConfigPort for RollbackFailLiveConfig {\n fn paths(&self, _agent_id: &str) -> Result, CliConfigError> {\n Ok(vec![PathBuf::from(\"safe-config\")])\n }\n\n fn inspect(\n &self,\n agent_id: &str,\n _applied: Option<&AppliedStateRecord>,\n _applied_profile: Option<&ProfileRecord>,\n ) -> Result {\n Ok(LiveInspection {\n paths: self.paths(agent_id)?,\n state: CliConfigDriftState::Detached,\n managed_fingerprint: \"before\".into(),\n })\n }\n\n fn import_current(&self, _agent_id: &str) -> Result {\n Err(CliConfigError::NotFound)\n }\n\n fn apply(\n &self,\n _profile: &ProfileRecord,\n _previous: Option<&ProfileRecord>,\n _credential: Option<&str>,\n _confirm_auth_file_replacement: bool,\n _expected_live_fingerprint: &str,\n ) -> Result {\n Err(CliConfigError::RollbackIncomplete)\n }\n\n fn restore(&self, _outcome: &ProjectionOutcome) -> Result<(), CliConfigError> {\n Err(CliConfigError::RollbackIncomplete)\n }\n }\n\n #[derive(Default)]\n struct DriftImportLiveConfig;\n\n impl CliGlobalConfigPort for DriftImportLiveConfig {\n fn paths(&self, _agent_id: &str) -> Result, CliConfigError> {\n Ok(vec![PathBuf::from(\"safe-config\")])\n }\n\n fn inspect(\n &self,\n agent_id: &str,\n _applied: Option<&AppliedStateRecord>,\n _applied_profile: Option<&ProfileRecord>,\n ) -> Result {\n Ok(LiveInspection {\n paths: self.paths(agent_id)?,\n state: CliConfigDriftState::Drifted,\n managed_fingerprint: \"drifted\".into(),\n })\n }\n\n fn import_current(&self, _agent_id: &str) -> Result {\n Ok(ImportedLiveConfig {\n payload: CliConfigPayload::ClaudeCode {\n base_url: \"https://drifted.example.com\".into(),\n auth_mode: ClaudeAuthMode::AuthToken,\n model: \"drifted-model\".into(),\n haiku_model: \"drifted-model\".into(),\n sonnet_model: \"drifted-model\".into(),\n opus_model: \"drifted-model\".into(),\n advanced_env: BTreeMap::new(),\n },\n credential: Some(zeroize::Zeroizing::new(\"new-secret\".into())),\n source_fingerprint: \"file:drifted\".into(),\n })\n }\n\n fn apply(\n &self,\n _profile: &ProfileRecord,\n _previous: Option<&ProfileRecord>,\n _credential: Option<&str>,\n _confirm_auth_file_replacement: bool,\n _expected_live_fingerprint: &str,\n ) -> Result {\n Err(CliConfigError::Repository)\n }\n\n fn restore(&self, _outcome: &ProjectionOutcome) -> Result<(), CliConfigError> {\n Ok(())\n }\n }\n\n fn claude_without_credential(name: &str) -> SaveCliConfigProfileInput {\n SaveCliConfigProfileInput {\n id: None,\n agent_id: \"claude-code\".into(),\n name: name.into(),\n payload: CliConfigPayload::ClaudeCode {\n base_url: \"https://api.anthropic.com\".into(),\n auth_mode: ClaudeAuthMode::None,\n model: \"claude-sonnet-4-6\".into(),\n haiku_model: \"claude-haiku-4-5\".into(),\n sonnet_model: \"claude-sonnet-4-6\".into(),\n opus_model: \"claude-opus-4-6\".into(),\n advanced_env: BTreeMap::new(),\n },\n source_preset_id: None,\n source_preset_version: None,\n credential: None,\n remove_credential: false,\n }\n }\n\n #[test]\n fn profile_ids_are_stable_safe_slugs() {\n assert_eq!(slug(\" OpenRouter Primary \"), \"openrouter-primary\");\n assert_eq!(slug(\"智谱 GLM\"), \"glm\");\n assert_eq!(slug(\"智谱\"), \"profile\");\n }\n\n #[test]\n fn duplicate_names_do_not_collide() {\n assert_eq!(unique_copy_name(\"DeepSeek\", &[]), \"DeepSeek Copy\");\n assert_eq!(\n unique_copy_name(\"DeepSeek\", &[\"DeepSeek Copy\".into()]),\n \"DeepSeek Copy 2\"\n );\n }\n\n #[test]\n fn discovered_import_redacts_credentials_and_skips_case_insensitive_name_conflicts() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let path = directory.path().join(\".claude\").join(\"settings.json\");\n std::fs::create_dir_all(path.parent().expect(\"parent\")).expect(\"directory\");\n std::fs::write(\n &path,\n r#\"{\"env\":{\"ANTHROPIC_AUTH_TOKEN\":\"discovery-secret\",\"ANTHROPIC_MODEL\":\"claude-sonnet-4-6\"}}\"#,\n )\n .expect(\"fixture\");\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository,\n credentials.clone(),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n\n let discovery = api.discover_profiles(\"claude-code\").expect(\"discovery\");\n assert_eq!(discovery.candidates.len(), 1);\n assert!(discovery.candidates[0].credential_detected);\n let serialized = serde_json::to_string(&discovery).expect(\"serialize discovery\");\n assert!(!serialized.contains(\"discovery-secret\"));\n\n let first = api\n .import_discovered_profiles(ImportDiscoveredCliConfigInput {\n agent_id: \"claude-code\".into(),\n candidate_keys: vec![\"current\".into()],\n })\n .expect(\"first import\");\n assert_eq!(first.imported.len(), 1);\n assert!(credentials\n .load(\"claude-code\", &first.imported[0].id)\n .expect(\"credential\")\n .is_some());\n\n let second = api\n .import_discovered_profiles(ImportDiscoveredCliConfigInput {\n agent_id: \"claude-code\".into(),\n candidate_keys: vec![\"current\".into()],\n })\n .expect(\"conflict is reported\");\n assert!(second.imported.is_empty());\n assert_eq!(second.skipped.len(), 1);\n assert_eq!(second.skipped[0].reason, \"name-conflict\");\n\n std::fs::write(&path, r#\"{\"env\":{\"ANTHROPIC_AUTH_TOKEN\":\"must-not-leak\"}\"#)\n .expect(\"malformed fixture\");\n let malformed = api\n .discover_profiles(\"claude-code\")\n .expect(\"parse state is data\");\n assert_eq!(malformed.state, CliConfigDiscoveryState::ParseError);\n assert!(malformed.candidates.is_empty());\n assert!(!serde_json::to_string(&malformed)\n .expect(\"serialize malformed result\")\n .contains(\"must-not-leak\"));\n }\n\n #[test]\n fn command_errors_do_not_render_secret_values() {\n let rendered = CliConfigError::Credential.to_string();\n assert!(!rendered.contains(\"sk-secret\"));\n assert_eq!(rendered, \"secure credential operation failed\");\n }\n\n #[test]\n fn concurrent_applies_for_one_agent_are_serialized_end_to_end() {\n let repository = Arc::new(MemoryRepository::default());\n let live = Arc::new(SerialProbeLiveConfig::default());\n let api = CliConfigApi::new(\n repository,\n Arc::new(MemoryCredentials::default()),\n live.clone(),\n Arc::new(NoopLog),\n );\n let first = api\n .save_profile(claude_without_credential(\"First\"))\n .expect(\"first profile\");\n let second = api\n .save_profile(claude_without_credential(\"Second\"))\n .expect(\"second profile\");\n\n let first_api = api.clone();\n let first_handle = thread::spawn(move || {\n first_api.apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: first.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n });\n let second_api = api.clone();\n let second_handle = thread::spawn(move || {\n second_api.apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: second.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n });\n\n first_handle\n .join()\n .expect(\"first thread\")\n .expect(\"first apply\");\n second_handle\n .join()\n .expect(\"second thread\")\n .expect(\"second apply\");\n assert_eq!(live.max_active.load(Ordering::SeqCst), 1);\n }\n\n #[test]\n fn failed_profile_update_restores_the_previous_credential() {\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository.clone(),\n credentials.clone(),\n Arc::new(SerialProbeLiveConfig::default()),\n Arc::new(NoopLog),\n );\n let mut create = claude_without_credential(\"Credential profile\");\n if let CliConfigPayload::ClaudeCode { auth_mode, .. } = &mut create.payload {\n *auth_mode = ClaudeAuthMode::AuthToken;\n }\n create.credential = Some(\"old-secret\".into());\n let saved = api.save_profile(create.clone()).expect(\"initial profile\");\n\n repository.fail_next_save.store(1, Ordering::SeqCst);\n create.id = Some(saved.id.clone());\n create.credential = Some(\"new-secret\".into());\n assert!(matches!(\n api.save_profile(create),\n Err(CliConfigError::Repository)\n ));\n let restored = credentials\n .load(\"claude-code\", &saved.id)\n .expect(\"credential load\")\n .expect(\"restored credential\");\n assert_eq!(restored.as_str(), \"old-secret\");\n }\n\n #[test]\n fn applied_snapshot_removes_keys_owned_before_profile_edit() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let repository = Arc::new(MemoryRepository::default());\n let api = CliConfigApi::new(\n repository,\n Arc::new(MemoryCredentials::default()),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n let mut first_input = claude_without_credential(\"First\");\n if let CliConfigPayload::ClaudeCode { advanced_env, .. } = &mut first_input.payload {\n advanced_env.insert(\"VANEHUB_OLD_MANAGED\".into(), \"old\".into());\n }\n let first = api\n .save_profile(first_input.clone())\n .expect(\"first profile\");\n let first_apply = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: first.id.clone(),\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"first apply\");\n assert_eq!(first_apply.status, \"succeeded\");\n\n first_input.id = Some(first.id);\n if let CliConfigPayload::ClaudeCode { advanced_env, .. } = &mut first_input.payload {\n advanced_env.clear();\n }\n api.save_profile(first_input).expect(\"edit applied profile\");\n let second = api\n .save_profile(claude_without_credential(\"Second\"))\n .expect(\"second profile\");\n let second_apply = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: second.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"second apply\");\n assert_eq!(second_apply.status, \"succeeded\");\n\n let settings =\n std::fs::read_to_string(directory.path().join(\".claude\").join(\"settings.json\"))\n .expect(\"settings\");\n assert!(!settings.contains(\"VANEHUB_OLD_MANAGED\"));\n }\n\n #[test]\n fn apply_failure_returns_restoration_status_and_redacted_log() {\n let repository = Arc::new(MemoryRepository::default());\n let logging = Arc::new(CapturingLog::default());\n let api = CliConfigApi::new(\n repository,\n Arc::new(MemoryCredentials::default()),\n Arc::new(RollbackFailLiveConfig),\n logging.clone(),\n );\n let profile = api\n .save_profile(claude_without_credential(\"Failure\"))\n .expect(\"profile\");\n let result = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: profile.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"structured failure\");\n\n assert_eq!(result.status, \"failed\");\n assert!(!result.restored);\n assert_eq!(\n result.error.as_deref(),\n Some(\"configuration rollback was incomplete\")\n );\n let entries = logging.entries.lock().expect(\"logs\");\n let apply_log = entries\n .iter()\n .find(|entry| entry.category == \"cli.config.profile.apply\")\n .expect(\"apply log\");\n assert_eq!(\n apply_log.context.get(\"status\").map(String::as_str),\n Some(\"failed\")\n );\n assert_eq!(\n apply_log.context.get(\"restored\").map(String::as_str),\n Some(\"false\")\n );\n assert!(!format!(\"{apply_log:?}\").contains(\"secret\"));\n }\n\n #[test]\n fn drift_import_restores_previous_credential_when_profile_save_fails() {\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository.clone(),\n credentials.clone(),\n Arc::new(DriftImportLiveConfig),\n Arc::new(NoopLog),\n );\n let mut previous_input = claude_without_credential(\"Previous\");\n if let CliConfigPayload::ClaudeCode { auth_mode, .. } = &mut previous_input.payload {\n *auth_mode = ClaudeAuthMode::AuthToken;\n }\n previous_input.credential = Some(\"old-secret\".into());\n let previous = api.save_profile(previous_input).expect(\"previous profile\");\n let target = api\n .save_profile(claude_without_credential(\"Target\"))\n .expect(\"target profile\");\n repository\n .save_applied_state(&AppliedStateRecord {\n agent_id: \"claude-code\".into(),\n profile_id: Some(previous.id.clone()),\n projection_fingerprint: \"projection\".into(),\n live_fingerprint: \"live\".into(),\n drift_state: CliConfigDriftState::Applied,\n applied_at: \"2026-01-01T00:00:00Z\".into(),\n applied_payload: Some(previous.payload.clone()),\n managed_keys: previous.payload.managed_keys(),\n })\n .expect(\"applied state\");\n repository.fail_next_save.store(1, Ordering::SeqCst);\n\n let result = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: target.id,\n drift_resolution: Some(CliConfigDriftResolution::ImportCurrent),\n confirm_auth_file_replacement: false,\n })\n .expect(\"structured failure\");\n assert_eq!(result.status, \"failed\");\n assert!(result.restored);\n let restored = credentials\n .load(\"claude-code\", &previous.id)\n .expect(\"credential load\")\n .expect(\"restored credential\");\n assert_eq!(restored.as_str(), \"old-secret\");\n }\n\n #[test]\n fn startup_sync_bootstraps_an_exclusive_profile_once_without_rewriting_live() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let settings_path = directory.path().join(\".claude\").join(\"settings.json\");\n std::fs::create_dir_all(settings_path.parent().expect(\"settings parent\"))\n .expect(\"create settings directory\");\n let live_bytes = br#\"{\"env\":{\"ANTHROPIC_BASE_URL\":\"https://external.example.com\",\"ANTHROPIC_MODEL\":\"claude-external\"}}\"#;\n std::fs::write(&settings_path, live_bytes).expect(\"write live settings\");\n let repository = Arc::new(MemoryRepository::default());\n let api = CliConfigApi::new(\n repository.clone(),\n Arc::new(MemoryCredentials::default()),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n\n let first = api.synchronize_startup();\n let claude = first\n .iter()\n .find(|result| result.agent_id == \"claude-code\")\n .expect(\"claude startup result\");\n assert_eq!(claude.state, CliConfigStartupSyncState::Imported);\n assert_eq!(claude.imported, 1);\n let profiles = repository\n .list_profiles(\"claude-code\")\n .expect(\"list imported profiles\");\n assert_eq!(profiles.len(), 1);\n assert_eq!(profiles[0].id, \"claude-code-default\");\n assert_eq!(profiles[0].name, \"default\");\n assert_eq!(\n repository\n .applied_state(\"claude-code\")\n .expect(\"applied state\")\n .and_then(|state| state.profile_id),\n Some(\"claude-code-default\".into())\n );\n assert_eq!(\n std::fs::read(&settings_path).expect(\"read live\"),\n live_bytes\n );\n\n let second = api.synchronize_startup();\n assert_eq!(\n second\n .iter()\n .find(|result| result.agent_id == \"claude-code\")\n .expect(\"second claude result\")\n .state,\n CliConfigStartupSyncState::Skipped\n );\n assert_eq!(\n repository\n .list_profiles(\"claude-code\")\n .expect(\"list profiles after second startup\")\n .len(),\n 1\n );\n }\n\n #[test]\n fn startup_sync_upserts_opencode_by_provider_id_without_deleting_missing_profiles() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let config_path = directory\n .path()\n .join(\".config\")\n .join(\"opencode\")\n .join(\"opencode.json\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\"))\n .expect(\"create config directory\");\n std::fs::write(\n &config_path,\n r#\"{\"model\":\"deepseek/deepseek-chat\",\"provider\":{\"deepseek\":{\"name\":\"DeepSeek\",\"npm\":\"@ai-sdk/openai-compatible\",\"options\":{\"baseURL\":\"https://api.deepseek.com/v1\",\"apiKey\":\"startup-secret\"},\"models\":{\"deepseek-chat\":{\"name\":\"DeepSeek Chat\"}}}}}\"#,\n )\n .expect(\"write OpenCode config\");\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository.clone(),\n credentials.clone(),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n\n let first = api.synchronize_startup();\n assert_eq!(\n first\n .iter()\n .find(|result| result.agent_id == \"opencode\")\n .expect(\"OpenCode startup result\")\n .imported,\n 1\n );\n let imported = repository\n .list_profiles(\"opencode\")\n .expect(\"OpenCode profiles\")\n .into_iter()\n .next()\n .expect(\"imported profile\");\n assert!(credentials\n .load(\"opencode\", &imported.id)\n .expect(\"credential lookup\")\n .is_some());\n\n std::fs::write(\n &config_path,\n r#\"{\"model\":\"deepseek/deepseek-reasoner\",\"provider\":{\"deepseek\":{\"name\":\"DeepSeek Updated\",\"npm\":\"@ai-sdk/openai-compatible\",\"options\":{\"baseURL\":\"https://api.deepseek.com/v2\",\"apiKey\":\"updated-secret\"},\"models\":{\"deepseek-reasoner\":{\"name\":\"DeepSeek Reasoner\"}}}}}\"#,\n )\n .expect(\"update OpenCode config\");\n let second = api.synchronize_startup();\n assert_eq!(\n second\n .iter()\n .find(|result| result.agent_id == \"opencode\")\n .expect(\"updated OpenCode result\")\n .updated,\n 1\n );\n let updated = repository\n .get_profile(\"opencode\", &imported.id)\n .expect(\"updated profile\");\n assert_eq!(updated.name, \"DeepSeek Updated\");\n assert!(matches!(\n updated.payload,\n CliConfigPayload::Opencode { ref base_url, ref default_model, .. }\n if base_url == \"https://api.deepseek.com/v2\" && default_model == \"deepseek-reasoner\"\n ));\n\n std::fs::write(&config_path, r#\"{\"provider\":{}}\"#).expect(\"remove live provider\");\n api.synchronize_startup();\n assert!(repository.get_profile(\"opencode\", &imported.id).is_ok());\n }\n\n #[test]\n fn exclusive_switch_backfills_external_live_changes_without_a_resolution_choice() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let repository = Arc::new(MemoryRepository::default());\n let api = CliConfigApi::new(\n repository.clone(),\n Arc::new(MemoryCredentials::default()),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n let previous = api\n .save_profile(claude_without_credential(\"Previous\"))\n .expect(\"previous profile\");\n let target = api\n .save_profile(claude_without_credential(\"Target\"))\n .expect(\"target profile\");\n assert_eq!(\n api.apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: previous.id.clone(),\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"apply previous\")\n .status,\n \"succeeded\"\n );\n let settings_path = directory.path().join(\".claude\").join(\"settings.json\");\n std::fs::write(\n &settings_path,\n r#\"{\"env\":{\"ANTHROPIC_BASE_URL\":\"https://edited.example.com\",\"ANTHROPIC_MODEL\":\"claude-edited\"}}\"#,\n )\n .expect(\"external edit\");\n\n let switched = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: target.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"switch after external edit\");\n assert_eq!(switched.status, \"succeeded\");\n assert_eq!(switched.backfilled_profile_id, Some(previous.id.clone()));\n assert_eq!(\n switched.drift_resolution,\n Some(CliConfigDriftResolution::ImportCurrent)\n );\n let stored = repository\n .get_profile(\"claude-code\", &previous.id)\n .expect(\"backfilled profile\");\n assert!(matches!(\n stored.payload,\n CliConfigPayload::ClaudeCode { ref base_url, ref model, .. }\n if base_url == \"https://edited.example.com\" && model == \"claude-edited\"\n ));\n }\n}\n"}, "1fabf4a4d94f158d201e0aae0e22385888b8ab1f": {"t": 1785761113.409329, "n": 74890, "h": "5625cc3a76311599", "c": "use super::application::{\n CliConfigCredentialPort, CliConfigRepository, CliGlobalConfigPort, ProjectionOutcome,\n};\nuse super::domain::{\n validate_profile_input, validate_supported_agent, AppliedStateRecord,\n ApplyCliConfigProfileInput, CliConfigAppliedState, CliConfigApplyResult,\n CliConfigDiscoveryCandidate, CliConfigDiscoveryResult, CliConfigDiscoveryState,\n CliConfigDriftResolution, CliConfigDriftState, CliConfigError, CliConfigPayload,\n CliConfigProfile, CliConfigStartupSyncResult, CliConfigStartupSyncState, CliConfigStatus,\n CliConfigValidationState, DeleteCliConfigProfileInput, ImportCliConfigProfileInput,\n ImportDiscoveredCliConfigInput, ImportDiscoveredCliConfigResult, ProfileRecord,\n SaveCliConfigProfileInput, SkippedCliConfigDiscoveryCandidate, PAYLOAD_VERSION,\n SUPPORTED_AGENT_IDS,\n};\nuse crate::contexts::operations::api::{DiagnosticLog, DiagnosticLogPort, LogSeverity};\nuse chrono::Utc;\nuse std::collections::{BTreeMap, BTreeSet};\nuse std::sync::{Arc, Mutex};\nuse uuid::Uuid;\n\n#[derive(Clone)]\npub(crate) struct CliConfigApi {\n repository: Arc,\n credentials: Arc,\n live: Arc,\n logging: Arc,\n apply_locks: Arc>>>,\n startup_sync: Arc>>,\n}\n\nimpl CliConfigApi {\n pub(crate) fn new(\n repository: Arc,\n credentials: Arc,\n live: Arc,\n logging: Arc,\n ) -> Self {\n let apply_locks = super::domain::SUPPORTED_AGENT_IDS\n .into_iter()\n .map(|agent_id| (agent_id.to_string(), Arc::new(Mutex::new(()))))\n .collect();\n let startup_sync = SUPPORTED_AGENT_IDS\n .into_iter()\n .map(|agent_id| {\n (\n agent_id.to_string(),\n startup_sync_result(agent_id, CliConfigStartupSyncState::Pending),\n )\n })\n .collect();\n Self {\n repository,\n credentials,\n live,\n logging,\n apply_locks: Arc::new(apply_locks),\n startup_sync: Arc::new(Mutex::new(startup_sync)),\n }\n }\n\n pub(crate) fn synchronize_startup(&self) -> Vec {\n SUPPORTED_AGENT_IDS\n .into_iter()\n .map(|agent_id| {\n let result = match self.synchronize_startup_agent(agent_id) {\n Ok(result) => result,\n Err(error) => {\n let mut result =\n startup_sync_result(agent_id, CliConfigStartupSyncState::Warning);\n result.skipped = 1;\n result.warnings.push(startup_sync_warning(&error));\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n result\n }\n };\n self.write_log(\n if result.state == CliConfigStartupSyncState::Warning {\n LogSeverity::Warn\n } else {\n LogSeverity::Info\n },\n \"cli.config.startup.sync\",\n agent_id,\n None,\n None,\n &format!(\n \"startup CLI configuration sync completed: state={:?}, imported={}, updated={}, skipped={}\",\n result.state, result.imported, result.updated, result.skipped\n ),\n );\n if let Ok(mut stored) = self.startup_sync.lock() {\n stored.insert(agent_id.to_string(), result.clone());\n }\n result\n })\n .collect()\n }\n\n fn synchronize_startup_agent(\n &self,\n agent_id: &str,\n ) -> Result {\n if agent_id == \"opencode\" {\n return self.synchronize_opencode_startup();\n }\n let existing = self.repository.list_profiles(agent_id)?;\n if !existing.is_empty() {\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Skipped);\n result.skipped = 1;\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n return Ok(result);\n }\n let discovery = self.live.discover_current(agent_id)?;\n let Some(candidate) = discovery.candidates.into_iter().next() else {\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Unchanged);\n result.warnings = discovery.warnings;\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n return Ok(result);\n };\n let profile_id = format!(\"{agent_id}-default\");\n let imported_credential = candidate.credential.is_some();\n let record = self.persist_live_profile(\n agent_id,\n profile_id,\n \"default\".to_string(),\n candidate.payload,\n candidate.credential.as_deref().map(String::as_str),\n None,\n )?;\n let inspection = self.live.inspect(agent_id, None, Some(&record))?;\n let applied = AppliedStateRecord {\n agent_id: agent_id.to_string(),\n profile_id: Some(record.id.clone()),\n projection_fingerprint: inspection.managed_fingerprint.clone(),\n live_fingerprint: inspection.managed_fingerprint,\n drift_state: CliConfigDriftState::Applied,\n applied_at: Utc::now().to_rfc3339(),\n applied_payload: Some(record.payload.clone()),\n managed_keys: record.managed_keys.clone(),\n };\n if let Err(error) = self.repository.save_applied_state(&applied) {\n let credential_restored =\n !imported_credential || self.credentials.delete(agent_id, &record.id).is_ok();\n let profile_restored = self.repository.delete_profile(agent_id, &record.id).is_ok();\n if !credential_restored || !profile_restored {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Imported);\n result.imported = 1;\n result.warnings = discovery.warnings;\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n Ok(result)\n }\n\n fn synchronize_opencode_startup(&self) -> Result {\n let agent_id = \"opencode\";\n let discovery = self.live.discover_current(agent_id)?;\n let mut existing = self.repository.list_profiles(agent_id)?;\n let mut result = startup_sync_result(agent_id, CliConfigStartupSyncState::Unchanged);\n result.warnings = discovery.warnings;\n for candidate in discovery.candidates {\n let provider_id = match &candidate.payload {\n CliConfigPayload::Opencode { provider_id, .. } => provider_id.clone(),\n _ => continue,\n };\n let matched = existing.iter().find(|profile| {\n matches!(\n &profile.payload,\n CliConfigPayload::Opencode {\n provider_id: existing_id,\n ..\n } if existing_id == &provider_id\n )\n });\n let profile_id = match matched {\n Some(profile) => profile.id.clone(),\n None => self.next_profile_id(&format!(\"opencode-{provider_id}\"))?,\n };\n let prior = matched.cloned();\n let prior_secret = self.credentials.load(agent_id, &profile_id)?;\n let incoming_secret = candidate.credential.as_deref().map(String::as_str);\n let changed = prior.as_ref().is_none_or(|profile| {\n profile.payload != candidate.payload\n || profile.name != candidate.suggested_name\n || prior_secret.as_deref().map(String::as_str) != incoming_secret\n });\n if !changed {\n result.skipped += 1;\n continue;\n }\n let record = self.persist_live_profile(\n agent_id,\n profile_id,\n candidate.suggested_name,\n candidate.payload,\n incoming_secret,\n prior.as_ref(),\n )?;\n if prior.is_some() {\n result.updated += 1;\n } else {\n result.imported += 1;\n existing.push(record);\n }\n }\n result.state = if result.updated > 0 {\n CliConfigStartupSyncState::Updated\n } else if result.imported > 0 {\n CliConfigStartupSyncState::Imported\n } else {\n CliConfigStartupSyncState::Unchanged\n };\n result.synchronized_at = Some(Utc::now().to_rfc3339());\n Ok(result)\n }\n\n fn persist_live_profile(\n &self,\n agent_id: &str,\n profile_id: String,\n name: String,\n payload: CliConfigPayload,\n credential: Option<&str>,\n existing: Option<&ProfileRecord>,\n ) -> Result {\n payload.validate()?;\n if payload.agent_id() != agent_id {\n return Err(CliConfigError::Validation(\n \"live configuration payload does not match the selected Agent\".into(),\n ));\n }\n if credential\n .is_some_and(|secret| secret.is_empty() || secret.chars().any(char::is_control))\n {\n return Err(CliConfigError::Validation(\n \"live configuration credential is invalid\".into(),\n ));\n }\n let sort_position = match existing {\n Some(profile) => profile.sort_position,\n None => self.repository.list_profiles(agent_id)?.len() as i64,\n };\n let previous_secret = self.credentials.load(agent_id, &profile_id)?;\n match credential {\n Some(secret) => self.credentials.store(agent_id, &profile_id, secret)?,\n None if previous_secret.is_some() => self.credentials.delete(agent_id, &profile_id)?,\n None => {}\n }\n let now = Utc::now().to_rfc3339();\n let record = ProfileRecord {\n id: profile_id.clone(),\n agent_id: agent_id.to_string(),\n name,\n payload_version: PAYLOAD_VERSION,\n managed_keys: payload.managed_keys(),\n payload,\n source_preset_id: None,\n source_preset_version: None,\n created_at: existing\n .map(|profile| profile.created_at.clone())\n .unwrap_or_else(|| now.clone()),\n updated_at: now,\n sort_position,\n };\n if let Err(error) = self.repository.save_profile(&record) {\n if self\n .restore_credential(\n agent_id,\n &profile_id,\n previous_secret.as_deref().map(String::as_str),\n )\n .is_err()\n {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n Ok(record)\n }\n\n pub(crate) fn list_profiles(\n &self,\n agent_id: &str,\n ) -> Result, CliConfigError> {\n validate_supported_agent(agent_id)?;\n let status = self.inspect_status(agent_id)?;\n self.repository\n .list_profiles(agent_id)?\n .into_iter()\n .map(|record| self.profile_view(record, &status))\n .collect()\n }\n\n pub(crate) fn inspect_status(&self, agent_id: &str) -> Result {\n validate_supported_agent(agent_id)?;\n let applied = self.repository.applied_state(agent_id)?;\n let applied_profile = applied\n .as_ref()\n .map(|state| self.applied_profile_snapshot(agent_id, state))\n .transpose()?\n .flatten();\n let inspection = self\n .live\n .inspect(agent_id, applied.as_ref(), applied_profile.as_ref())?;\n Ok(CliConfigStatus {\n agent_id: agent_id.to_string(),\n applied_profile_id: applied.as_ref().and_then(|state| state.profile_id.clone()),\n drift_state: inspection.state,\n resolved_paths: inspection\n .paths\n .into_iter()\n .map(|path| path.display().to_string())\n .collect(),\n last_applied_at: applied.map(|state| state.applied_at),\n simulated: false,\n startup_sync: self.startup_sync_result(agent_id),\n })\n }\n\n fn startup_sync_result(&self, agent_id: &str) -> CliConfigStartupSyncResult {\n self.startup_sync\n .lock()\n .ok()\n .and_then(|results| results.get(agent_id).cloned())\n .unwrap_or_else(|| startup_sync_result(agent_id, CliConfigStartupSyncState::Warning))\n }\n\n pub(crate) fn save_profile(\n &self,\n input: SaveCliConfigProfileInput,\n ) -> Result {\n let agent_id = input.agent_id.clone();\n let result = self.save_profile_inner(input);\n self.log_result(\n \"cli.config.profile.save\",\n &agent_id,\n result.as_ref().ok().map(|p| p.id.as_str()),\n &result,\n );\n result\n }\n\n fn save_profile_inner(\n &self,\n input: SaveCliConfigProfileInput,\n ) -> Result {\n validate_profile_input(&input)?;\n if input.credential.is_some() && input.remove_credential {\n return Err(CliConfigError::Validation(\n \"credential cannot be replaced and removed in one request\".into(),\n ));\n }\n if let Some(source_id) = input.source_preset_id.as_deref() {\n let compatible_prefix = format!(\"{}-\", input.agent_id);\n if !source_id.starts_with(&compatible_prefix) {\n return Err(CliConfigError::Validation(\n \"preset is incompatible with the selected Agent\".into(),\n ));\n }\n }\n\n let existing = input\n .id\n .as_deref()\n .map(|id| self.repository.get_profile(&input.agent_id, id))\n .transpose()?;\n let id = match &existing {\n Some(profile) => profile.id.clone(),\n None => self.next_profile_id(&input.name)?,\n };\n let previous_secret = self.credentials.load(&input.agent_id, &id)?;\n if existing.is_none()\n && input.payload.requires_credential()\n && input.credential.is_none()\n && !input.remove_credential\n {\n return Err(CliConfigError::CredentialRequired);\n }\n\n let wrote_credential = if let Some(secret) = input.credential.as_deref() {\n self.credentials.store(&input.agent_id, &id, secret)?;\n true\n } else if input.remove_credential {\n self.credentials.delete(&input.agent_id, &id)?;\n true\n } else {\n false\n };\n\n let now = Utc::now().to_rfc3339();\n let record = ProfileRecord {\n id: id.clone(),\n agent_id: input.agent_id.clone(),\n name: input.name.trim().to_string(),\n payload_version: PAYLOAD_VERSION,\n managed_keys: input.payload.managed_keys(),\n payload: input.payload,\n source_preset_id: input.source_preset_id,\n source_preset_version: input.source_preset_version,\n created_at: existing\n .as_ref()\n .map(|profile| profile.created_at.clone())\n .unwrap_or_else(|| now.clone()),\n updated_at: now,\n sort_position: existing\n .as_ref()\n .map(|profile| profile.sort_position)\n .unwrap_or(self.repository.list_profiles(&input.agent_id)?.len() as i64),\n };\n if let Err(error) = self.repository.save_profile(&record) {\n if wrote_credential {\n self.restore_credential(\n &input.agent_id,\n &id,\n previous_secret.as_deref().map(String::as_str),\n )?;\n }\n return Err(error);\n }\n let status = self.inspect_status(&input.agent_id)?;\n self.profile_view(record, &status)\n }\n\n pub(crate) fn duplicate_profile(\n &self,\n agent_id: &str,\n profile_id: &str,\n ) -> Result {\n validate_supported_agent(agent_id)?;\n let source = self.repository.get_profile(agent_id, profile_id)?;\n let credential = self.credentials.load(agent_id, profile_id)?;\n let names = self\n .repository\n .list_profiles(agent_id)?\n .into_iter()\n .map(|profile| profile.name)\n .collect::>();\n let name = unique_copy_name(&source.name, &names);\n self.save_profile(SaveCliConfigProfileInput {\n id: None,\n agent_id: source.agent_id,\n name,\n payload: source.payload,\n source_preset_id: source.source_preset_id,\n source_preset_version: source.source_preset_version,\n credential: credential.as_deref().map(ToString::to_string),\n remove_credential: false,\n })\n }\n\n pub(crate) fn delete_profile(\n &self,\n input: DeleteCliConfigProfileInput,\n ) -> Result<(), CliConfigError> {\n validate_supported_agent(&input.agent_id)?;\n let profile = self\n .repository\n .get_profile(&input.agent_id, &input.profile_id)?;\n let applied = self.repository.applied_state(&input.agent_id)?;\n let is_applied = applied\n .as_ref()\n .and_then(|state| state.profile_id.as_deref())\n == Some(input.profile_id.as_str());\n if is_applied && !input.detach_applied {\n return Err(CliConfigError::Validation(\n \"applied profile must be detached before deletion\".into(),\n ));\n }\n let previous_secret = self.credentials.load(&input.agent_id, &input.profile_id)?;\n if is_applied {\n self.repository.clear_applied_state(&input.agent_id)?;\n }\n if let Err(error) = self.credentials.delete(&input.agent_id, &input.profile_id) {\n if let Some(state) = &applied {\n self.repository.save_applied_state(state)?;\n }\n return Err(error);\n }\n if let Err(error) = self\n .repository\n .delete_profile(&input.agent_id, &input.profile_id)\n {\n self.restore_credential(\n &input.agent_id,\n &input.profile_id,\n previous_secret.as_deref().map(String::as_str),\n )?;\n if let Some(state) = &applied {\n self.repository.save_applied_state(state)?;\n }\n return Err(error);\n }\n self.write_log(\n LogSeverity::Info,\n \"cli.config.profile.delete\",\n &input.agent_id,\n Some(&profile.id),\n None,\n \"deleted CLI configuration profile\",\n );\n Ok(())\n }\n\n pub(crate) fn import_current(\n &self,\n input: ImportCliConfigProfileInput,\n ) -> Result {\n validate_supported_agent(&input.agent_id)?;\n let imported = self.live.import_current(&input.agent_id)?;\n let result = self.save_profile(SaveCliConfigProfileInput {\n id: None,\n agent_id: input.agent_id.clone(),\n name: input.name,\n payload: imported.payload,\n source_preset_id: None,\n source_preset_version: None,\n credential: imported.credential.as_deref().map(ToString::to_string),\n remove_credential: false,\n });\n self.log_result(\n \"cli.config.profile.import\",\n &input.agent_id,\n result.as_ref().ok().map(|profile| profile.id.as_str()),\n &result,\n );\n result\n }\n\n pub(crate) fn discover_profiles(\n &self,\n agent_id: &str,\n ) -> Result {\n validate_supported_agent(agent_id)?;\n let resolved_paths = self\n .live\n .paths(agent_id)?\n .into_iter()\n .map(|path| path.display().to_string())\n .collect::>();\n match self.live.discover_current(agent_id) {\n Ok(discovery) => {\n let candidate_paths = discovery\n .paths\n .iter()\n .map(|path| path.display().to_string())\n .collect::>();\n Ok(CliConfigDiscoveryResult {\n agent_id: agent_id.to_string(),\n state: CliConfigDiscoveryState::Available,\n candidates: discovery\n .candidates\n .into_iter()\n .map(|candidate| CliConfigDiscoveryCandidate {\n candidate_key: candidate.candidate_key,\n suggested_name: candidate.suggested_name,\n provider_name: candidate.provider_name,\n endpoint: candidate.endpoint,\n model: candidate.model,\n credential_detected: candidate.credential.is_some(),\n is_default: candidate.is_default,\n resolved_paths: candidate_paths.clone(),\n })\n .collect(),\n resolved_paths: candidate_paths,\n warnings: discovery.warnings,\n error: None,\n simulated: false,\n })\n }\n Err(CliConfigError::Parse { path, message: _ }) => Ok(CliConfigDiscoveryResult {\n agent_id: agent_id.to_string(),\n state: CliConfigDiscoveryState::ParseError,\n candidates: Vec::new(),\n resolved_paths,\n warnings: Vec::new(),\n error: Some(format!(\n \"Could not parse {path} as a supported CLI configuration.\"\n )),\n simulated: false,\n }),\n Err(error) => Err(error),\n }\n }\n\n pub(crate) fn import_discovered_profiles(\n &self,\n input: ImportDiscoveredCliConfigInput,\n ) -> Result {\n validate_supported_agent(&input.agent_id)?;\n if input.candidate_keys.is_empty() {\n return Err(CliConfigError::Validation(\n \"at least one discovered candidate must be selected\".into(),\n ));\n }\n if input.candidate_keys.len() > 64 {\n return Err(CliConfigError::Validation(\n \"too many discovered candidates were selected\".into(),\n ));\n }\n let requested = input.candidate_keys.into_iter().collect::>();\n let discovery = self.live.discover_current(&input.agent_id)?;\n let available = discovery\n .candidates\n .iter()\n .map(|candidate| candidate.candidate_key.clone())\n .collect::>();\n if let Some(missing) = requested.difference(&available).next() {\n return Err(CliConfigError::Validation(format!(\n \"discovered candidate is no longer available: {missing}\"\n )));\n }\n let mut existing_names = self\n .repository\n .list_profiles(&input.agent_id)?\n .into_iter()\n .map(|profile| profile.name.trim().to_lowercase())\n .collect::>();\n let mut imported = Vec::new();\n let mut skipped = Vec::new();\n for candidate in discovery\n .candidates\n .into_iter()\n .filter(|candidate| requested.contains(&candidate.candidate_key))\n {\n let normalized_name = candidate.suggested_name.trim().to_lowercase();\n if existing_names.contains(&normalized_name) {\n skipped.push(SkippedCliConfigDiscoveryCandidate {\n candidate_key: candidate.candidate_key,\n suggested_name: candidate.suggested_name,\n reason: \"name-conflict\".into(),\n });\n continue;\n }\n let profile = self.save_profile(SaveCliConfigProfileInput {\n id: None,\n agent_id: input.agent_id.clone(),\n name: candidate.suggested_name,\n payload: candidate.payload,\n source_preset_id: None,\n source_preset_version: None,\n credential: candidate.credential.as_deref().map(ToString::to_string),\n remove_credential: false,\n })?;\n existing_names.insert(normalized_name);\n imported.push(profile);\n }\n self.write_log(\n LogSeverity::Info,\n \"cli.config.discovery.import\",\n &input.agent_id,\n None,\n None,\n &format!(\n \"imported {} discovered CLI configurations and skipped {}\",\n imported.len(),\n skipped.len()\n ),\n );\n Ok(ImportDiscoveredCliConfigResult { imported, skipped })\n }\n\n pub(crate) fn apply_profile(\n &self,\n input: ApplyCliConfigProfileInput,\n ) -> Result {\n validate_supported_agent(&input.agent_id)?;\n let operation_id = Uuid::new_v4().to_string();\n let result = self.apply_profile_inner(&input);\n let apply_result = match result {\n Ok((profile, outcome, backfilled_profile_id)) => apply_result(\n operation_id,\n &profile,\n outcome,\n if backfilled_profile_id.is_some() {\n Some(CliConfigDriftResolution::ImportCurrent)\n } else {\n input.drift_resolution.clone()\n },\n backfilled_profile_id,\n ),\n Err(error) => failed_apply_result(\n operation_id,\n &input,\n self.live.paths(&input.agent_id).unwrap_or_default(),\n &error,\n ),\n };\n self.write_apply_log(&apply_result);\n Ok(apply_result)\n }\n\n fn apply_profile_inner(\n &self,\n input: &ApplyCliConfigProfileInput,\n ) -> Result<(ProfileRecord, ProjectionOutcome, Option), CliConfigError> {\n let apply_lock = self\n .apply_locks\n .get(&input.agent_id)\n .cloned()\n .ok_or(CliConfigError::Repository)?;\n let _apply_guard = apply_lock.lock().map_err(|_| CliConfigError::Repository)?;\n let profile = self\n .repository\n .get_profile(&input.agent_id, &input.profile_id)?;\n profile.payload.validate()?;\n let credential = self.credentials.load(&input.agent_id, &input.profile_id)?;\n if profile.payload.requires_credential() && credential.is_none() {\n return Err(CliConfigError::CredentialRequired);\n }\n let previous_state = self.repository.applied_state(&input.agent_id)?;\n let mut previous_profile = previous_state\n .as_ref()\n .map(|state| self.applied_profile_snapshot(&input.agent_id, state))\n .transpose()?\n .flatten();\n let inspection = self.live.inspect(\n &input.agent_id,\n previous_state.as_ref(),\n previous_profile.as_ref(),\n )?;\n if inspection.state == CliConfigDriftState::Malformed {\n return Err(CliConfigError::Parse {\n path: inspection\n .paths\n .first()\n .map(|path| path.display().to_string())\n .unwrap_or_else(|| \"configuration\".into()),\n message: \"existing configuration is malformed\".into(),\n });\n }\n let switching_exclusive_profile = input.agent_id != \"opencode\"\n && previous_profile\n .as_ref()\n .is_some_and(|previous| previous.id != profile.id);\n let backfilled_profile_id = if switching_exclusive_profile {\n let previous = previous_profile\n .as_ref()\n .ok_or(CliConfigError::Repository)?;\n let (updated, source_fingerprint) = self.backfill_profile(&input.agent_id, previous)?;\n let id = updated.id.clone();\n previous_profile = Some(updated);\n Some((id, source_fingerprint))\n } else {\n None\n };\n let expected_live_fingerprint = backfilled_profile_id\n .as_ref()\n .map(|(_, fingerprint)| fingerprint.clone())\n .unwrap_or(inspection.managed_fingerprint);\n let backfilled_profile_id = backfilled_profile_id.map(|(id, _)| id);\n\n let outcome = self.live.apply(\n &profile,\n previous_profile.as_ref(),\n credential.as_deref().map(|secret| secret.as_str()),\n input.confirm_auth_file_replacement,\n &expected_live_fingerprint,\n )?;\n let applied = AppliedStateRecord {\n agent_id: input.agent_id.clone(),\n profile_id: Some(profile.id.clone()),\n projection_fingerprint: outcome.projection_fingerprint.clone(),\n live_fingerprint: outcome.live_fingerprint.clone(),\n drift_state: CliConfigDriftState::Applied,\n applied_at: Utc::now().to_rfc3339(),\n applied_payload: Some(profile.payload.clone()),\n managed_keys: profile.managed_keys.clone(),\n };\n if let Err(error) = self.repository.save_applied_state(&applied) {\n let live_restored = self.live.restore(&outcome).is_ok();\n let state_restored = if let Some(previous) = &previous_state {\n self.repository.save_applied_state(previous).is_ok()\n } else {\n self.repository.clear_applied_state(&input.agent_id).is_ok()\n };\n if !live_restored || !state_restored {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n Ok((profile, outcome, backfilled_profile_id))\n }\n\n fn backfill_profile(\n &self,\n agent_id: &str,\n previous: &ProfileRecord,\n ) -> Result<(ProfileRecord, String), CliConfigError> {\n let imported = self.live.import_current(agent_id)?;\n let source_fingerprint = imported.source_fingerprint.clone();\n imported.payload.validate()?;\n let mut updated = previous.clone();\n updated.payload = imported.payload;\n updated.managed_keys = previous\n .managed_keys\n .iter()\n .cloned()\n .chain(updated.payload.managed_keys())\n .collect::>()\n .into_iter()\n .collect();\n updated.updated_at = Utc::now().to_rfc3339();\n let previous_secret = self.credentials.load(agent_id, &updated.id)?;\n let incoming_secret = imported.credential.as_deref().map(String::as_str);\n match incoming_secret {\n Some(secret) => self.credentials.store(agent_id, &updated.id, secret)?,\n None if previous_secret.is_some() => self.credentials.delete(agent_id, &updated.id)?,\n None => {}\n }\n if let Err(error) = self.repository.save_profile(&updated) {\n if self\n .restore_credential(\n agent_id,\n &updated.id,\n previous_secret.as_deref().map(String::as_str),\n )\n .is_err()\n {\n return Err(CliConfigError::RollbackIncomplete);\n }\n return Err(error);\n }\n self.write_log(\n LogSeverity::Info,\n \"cli.config.profile.backfill\",\n agent_id,\n Some(&updated.id),\n None,\n \"backfilled current live configuration into the leaving profile\",\n );\n Ok((updated, source_fingerprint))\n }\n\n fn profile_view(\n &self,\n record: ProfileRecord,\n status: &CliConfigStatus,\n ) -> Result {\n let credential_configured = self\n .credentials\n .load(&record.agent_id, &record.id)?\n .is_some();\n let validation_state = if record.payload.validate().is_err() {\n CliConfigValidationState::Invalid\n } else if record.payload.requires_credential() && !credential_configured {\n CliConfigValidationState::NeedsCredential\n } else {\n CliConfigValidationState::Valid\n };\n let applied_state = if status.applied_profile_id.as_deref() == Some(record.id.as_str()) {\n if status.drift_state == CliConfigDriftState::Applied {\n CliConfigAppliedState::Applied\n } else {\n CliConfigAppliedState::Drifted\n }\n } else {\n CliConfigAppliedState::Saved\n };\n Ok(CliConfigProfile {\n id: record.id,\n agent_id: record.agent_id,\n name: record.name,\n payload_version: record.payload_version,\n payload: record.payload,\n source_preset_id: record.source_preset_id,\n source_preset_version: record.source_preset_version,\n credential_configured,\n validation_state,\n applied_state,\n created_at: record.created_at,\n updated_at: record.updated_at,\n })\n }\n\n fn applied_profile_snapshot(\n &self,\n agent_id: &str,\n state: &AppliedStateRecord,\n ) -> Result, CliConfigError> {\n let Some(profile_id) = state.profile_id.as_deref() else {\n return Ok(None);\n };\n let mut profile = self.repository.get_profile(agent_id, profile_id)?;\n if let Some(payload) = &state.applied_payload {\n profile.payload = payload.clone();\n }\n if !state.managed_keys.is_empty() {\n profile.managed_keys = state.managed_keys.clone();\n }\n Ok(Some(profile))\n }\n\n fn next_profile_id(&self, name: &str) -> Result {\n let base = slug(name);\n if !self.repository.profile_id_exists(&base)? {\n return Ok(base);\n }\n for suffix in 2..10_000 {\n let candidate = format!(\"{base}-{suffix}\");\n if !self.repository.profile_id_exists(&candidate)? {\n return Ok(candidate);\n }\n }\n Err(CliConfigError::Repository)\n }\n\n fn restore_credential(\n &self,\n agent_id: &str,\n profile_id: &str,\n previous: Option<&str>,\n ) -> Result<(), CliConfigError> {\n match previous {\n Some(secret) => self.credentials.store(agent_id, profile_id, secret),\n None => self.credentials.delete(agent_id, profile_id),\n }\n }\n\n fn log_result(\n &self,\n category: &str,\n agent_id: &str,\n profile_id: Option<&str>,\n result: &Result,\n ) {\n self.write_log(\n if result.is_ok() {\n LogSeverity::Info\n } else {\n LogSeverity::Error\n },\n category,\n agent_id,\n profile_id,\n None,\n if result.is_ok() {\n \"CLI configuration operation succeeded\"\n } else {\n \"CLI configuration operation failed\"\n },\n );\n }\n\n fn write_log(\n &self,\n severity: LogSeverity,\n category: &str,\n agent_id: &str,\n profile_id: Option<&str>,\n operation_id: Option<&str>,\n message: &str,\n ) {\n let mut context = BTreeMap::from([(\"agentId\".into(), agent_id.to_string())]);\n if let Some(profile_id) = profile_id {\n context.insert(\"profileId\".into(), profile_id.to_string());\n }\n if let Some(operation_id) = operation_id {\n context.insert(\"operationId\".into(), operation_id.to_string());\n }\n context.insert(\n \"status\".into(),\n if severity == LogSeverity::Error {\n \"failed\"\n } else {\n \"succeeded\"\n }\n .into(),\n );\n let _ = self.logging.write_diagnostic(DiagnosticLog {\n severity,\n category: category.into(),\n message: message.into(),\n context,\n });\n }\n\n fn write_apply_log(&self, result: &CliConfigApplyResult) {\n let mut context = BTreeMap::from([\n (\"agentId\".into(), result.agent_id.clone()),\n (\"profileId\".into(), result.profile_id.clone()),\n (\"operationId\".into(), result.operation_id.clone()),\n (\"status\".into(), result.status.clone()),\n (\"restored\".into(), result.restored.to_string()),\n ]);\n let target_files = result\n .affected_paths\n .iter()\n .filter_map(|path| std::path::Path::new(path).file_name())\n .map(|name| name.to_string_lossy())\n .collect::>()\n .join(\",\");\n if !target_files.is_empty() {\n context.insert(\"targetFiles\".into(), target_files);\n }\n let succeeded = result.status == \"succeeded\";\n let _ = self.logging.write_diagnostic(DiagnosticLog {\n severity: if succeeded {\n LogSeverity::Info\n } else {\n LogSeverity::Error\n },\n category: \"cli.config.profile.apply\".into(),\n message: if succeeded {\n \"applied CLI configuration profile\"\n } else {\n \"CLI configuration profile application failed\"\n }\n .into(),\n context,\n });\n }\n}\n\nfn startup_sync_result(\n agent_id: &str,\n state: CliConfigStartupSyncState,\n) -> CliConfigStartupSyncResult {\n CliConfigStartupSyncResult {\n agent_id: agent_id.to_string(),\n state,\n imported: 0,\n updated: 0,\n skipped: 0,\n warnings: Vec::new(),\n synchronized_at: None,\n simulated: false,\n }\n}\n\nfn startup_sync_warning(error: &CliConfigError) -> String {\n match error {\n CliConfigError::Parse { .. } => {\n \"The local CLI configuration could not be parsed; startup synchronization was skipped.\"\n }\n CliConfigError::Filesystem { .. } => {\n \"The local CLI configuration could not be read; startup synchronization was skipped.\"\n }\n CliConfigError::Credential | CliConfigError::CredentialRequired => {\n \"The local CLI credential could not be synchronized safely.\"\n }\n CliConfigError::RollbackIncomplete => {\n \"Startup synchronization could not fully restore an interrupted profile update.\"\n }\n _ => \"Startup synchronization could not be completed.\",\n }\n .to_string()\n}\n\nfn apply_result(\n operation_id: String,\n profile: &ProfileRecord,\n outcome: ProjectionOutcome,\n drift_resolution: Option,\n backfilled_profile_id: Option,\n) -> CliConfigApplyResult {\n CliConfigApplyResult {\n operation_id,\n status: \"succeeded\".into(),\n agent_id: profile.agent_id.clone(),\n profile_id: profile.id.clone(),\n affected_paths: outcome\n .paths\n .into_iter()\n .map(|path| path.display().to_string())\n .collect(),\n drift_resolution,\n backfilled_profile_id,\n warnings: outcome.warnings,\n restart_required: true,\n simulated: false,\n restored: outcome.restored,\n error: None,\n }\n}\n\nfn failed_apply_result(\n operation_id: String,\n input: &ApplyCliConfigProfileInput,\n paths: Vec,\n error: &CliConfigError,\n) -> CliConfigApplyResult {\n CliConfigApplyResult {\n operation_id,\n status: \"failed\".into(),\n agent_id: input.agent_id.clone(),\n profile_id: input.profile_id.clone(),\n affected_paths: paths\n .into_iter()\n .map(|path| path.display().to_string())\n .collect(),\n drift_resolution: input.drift_resolution.clone(),\n backfilled_profile_id: None,\n warnings: Vec::new(),\n restart_required: false,\n simulated: false,\n restored: !matches!(error, CliConfigError::RollbackIncomplete),\n error: Some(error.to_string()),\n }\n}\n\nfn slug(value: &str) -> String {\n let mut output = String::new();\n let mut separator = false;\n for character in value.trim().chars() {\n if character.is_ascii_alphanumeric() {\n output.push(character.to_ascii_lowercase());\n separator = false;\n } else if !separator && !output.is_empty() {\n output.push('-');\n separator = true;\n }\n }\n while output.ends_with('-') {\n output.pop();\n }\n if output.is_empty() {\n \"profile\".into()\n } else {\n output\n }\n}\n\nfn unique_copy_name(source: &str, existing: &[String]) -> String {\n let base = format!(\"{source} Copy\");\n if !existing.contains(&base) {\n return base;\n }\n for suffix in 2..10_000 {\n let candidate = format!(\"{base} {suffix}\");\n if !existing.contains(&candidate) {\n return candidate;\n }\n }\n format!(\"{base} {}\", Uuid::new_v4())\n}\n\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::contexts::operations::api::OperationsError;\n use crate::contexts::tooling::cli_config::application::{ImportedLiveConfig, LiveInspection};\n use crate::contexts::tooling::cli_config::domain::{\n ApplyCliConfigProfileInput, ClaudeAuthMode, CliConfigDriftResolution, CliConfigPayload,\n ImportDiscoveredCliConfigInput, SaveCliConfigProfileInput,\n };\n use crate::contexts::tooling::cli_config::infrastructure::NativeCliGlobalConfigAdapter;\n use std::path::PathBuf;\n use std::sync::atomic::{AtomicUsize, Ordering};\n use std::thread;\n use std::time::Duration;\n\n #[derive(Default)]\n struct MemoryRepository {\n profiles: Mutex>,\n applied: Mutex>,\n fail_next_save: AtomicUsize,\n }\n\n impl CliConfigRepository for MemoryRepository {\n fn list_profiles(&self, agent_id: &str) -> Result, CliConfigError> {\n Ok(self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .iter()\n .filter(|profile| profile.agent_id == agent_id)\n .cloned()\n .collect())\n }\n\n fn get_profile(\n &self,\n agent_id: &str,\n profile_id: &str,\n ) -> Result {\n self.profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .iter()\n .find(|profile| profile.agent_id == agent_id && profile.id == profile_id)\n .cloned()\n .ok_or(CliConfigError::NotFound)\n }\n\n fn profile_id_exists(&self, profile_id: &str) -> Result {\n Ok(self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .iter()\n .any(|profile| profile.id == profile_id))\n }\n\n fn save_profile(&self, profile: &ProfileRecord) -> Result<(), CliConfigError> {\n if self.fail_next_save.swap(0, Ordering::SeqCst) > 0 {\n return Err(CliConfigError::Repository);\n }\n let mut profiles = self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?;\n profiles.retain(|candidate| candidate.id != profile.id);\n profiles.push(profile.clone());\n Ok(())\n }\n\n fn delete_profile(&self, agent_id: &str, profile_id: &str) -> Result<(), CliConfigError> {\n let mut profiles = self\n .profiles\n .lock()\n .map_err(|_| CliConfigError::Repository)?;\n let before = profiles.len();\n profiles.retain(|profile| profile.agent_id != agent_id || profile.id != profile_id);\n if profiles.len() == before {\n return Err(CliConfigError::NotFound);\n }\n Ok(())\n }\n\n fn applied_state(\n &self,\n agent_id: &str,\n ) -> Result, CliConfigError> {\n Ok(self\n .applied\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .get(agent_id)\n .cloned())\n }\n\n fn save_applied_state(&self, state: &AppliedStateRecord) -> Result<(), CliConfigError> {\n self.applied\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .insert(state.agent_id.clone(), state.clone());\n Ok(())\n }\n\n fn clear_applied_state(&self, agent_id: &str) -> Result<(), CliConfigError> {\n self.applied\n .lock()\n .map_err(|_| CliConfigError::Repository)?\n .remove(agent_id);\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct MemoryCredentials {\n values: Mutex>,\n }\n\n impl CliConfigCredentialPort for MemoryCredentials {\n fn load(\n &self,\n agent_id: &str,\n profile_id: &str,\n ) -> Result>, CliConfigError> {\n Ok(self\n .values\n .lock()\n .map_err(|_| CliConfigError::Credential)?\n .get(&(agent_id.to_string(), profile_id.to_string()))\n .cloned()\n .map(zeroize::Zeroizing::new))\n }\n\n fn store(\n &self,\n agent_id: &str,\n profile_id: &str,\n secret: &str,\n ) -> Result<(), CliConfigError> {\n self.values\n .lock()\n .map_err(|_| CliConfigError::Credential)?\n .insert(\n (agent_id.to_string(), profile_id.to_string()),\n secret.to_string(),\n );\n Ok(())\n }\n\n fn delete(&self, agent_id: &str, profile_id: &str) -> Result<(), CliConfigError> {\n self.values\n .lock()\n .map_err(|_| CliConfigError::Credential)?\n .remove(&(agent_id.to_string(), profile_id.to_string()));\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct SerialProbeLiveConfig {\n active: AtomicUsize,\n max_active: AtomicUsize,\n }\n\n impl CliGlobalConfigPort for SerialProbeLiveConfig {\n fn paths(&self, _agent_id: &str) -> Result, CliConfigError> {\n Ok(vec![PathBuf::from(\"safe-config\")])\n }\n\n fn inspect(\n &self,\n agent_id: &str,\n _applied: Option<&AppliedStateRecord>,\n _applied_profile: Option<&ProfileRecord>,\n ) -> Result {\n Ok(LiveInspection {\n paths: self.paths(agent_id)?,\n state: CliConfigDriftState::Detached,\n managed_fingerprint: \"before\".into(),\n })\n }\n\n fn import_current(&self, _agent_id: &str) -> Result {\n Err(CliConfigError::NotFound)\n }\n\n fn apply(\n &self,\n _profile: &ProfileRecord,\n _previous: Option<&ProfileRecord>,\n _credential: Option<&str>,\n _confirm_auth_file_replacement: bool,\n _expected_live_fingerprint: &str,\n ) -> Result {\n let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;\n self.max_active.fetch_max(active, Ordering::SeqCst);\n thread::sleep(Duration::from_millis(30));\n self.active.fetch_sub(1, Ordering::SeqCst);\n Ok(ProjectionOutcome {\n paths: vec![PathBuf::from(\"safe-config\")],\n projection_fingerprint: \"projection\".into(),\n live_fingerprint: \"live\".into(),\n warnings: Vec::new(),\n restored: true,\n backups: Vec::new(),\n })\n }\n\n fn restore(&self, _outcome: &ProjectionOutcome) -> Result<(), CliConfigError> {\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct NoopLog;\n\n impl DiagnosticLogPort for NoopLog {\n fn write_diagnostic(&self, _log: DiagnosticLog) -> Result<(), OperationsError> {\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct CapturingLog {\n entries: Mutex>,\n }\n\n impl DiagnosticLogPort for CapturingLog {\n fn write_diagnostic(&self, log: DiagnosticLog) -> Result<(), OperationsError> {\n self.entries.lock().expect(\"log entries\").push(log);\n Ok(())\n }\n }\n\n #[derive(Default)]\n struct RollbackFailLiveConfig;\n\n impl CliGlobalConfigPort for RollbackFailLiveConfig {\n fn paths(&self, _agent_id: &str) -> Result, CliConfigError> {\n Ok(vec![PathBuf::from(\"safe-config\")])\n }\n\n fn inspect(\n &self,\n agent_id: &str,\n _applied: Option<&AppliedStateRecord>,\n _applied_profile: Option<&ProfileRecord>,\n ) -> Result {\n Ok(LiveInspection {\n paths: self.paths(agent_id)?,\n state: CliConfigDriftState::Detached,\n managed_fingerprint: \"before\".into(),\n })\n }\n\n fn import_current(&self, _agent_id: &str) -> Result {\n Err(CliConfigError::NotFound)\n }\n\n fn apply(\n &self,\n _profile: &ProfileRecord,\n _previous: Option<&ProfileRecord>,\n _credential: Option<&str>,\n _confirm_auth_file_replacement: bool,\n _expected_live_fingerprint: &str,\n ) -> Result {\n Err(CliConfigError::RollbackIncomplete)\n }\n\n fn restore(&self, _outcome: &ProjectionOutcome) -> Result<(), CliConfigError> {\n Err(CliConfigError::RollbackIncomplete)\n }\n }\n\n #[derive(Default)]\n struct DriftImportLiveConfig;\n\n impl CliGlobalConfigPort for DriftImportLiveConfig {\n fn paths(&self, _agent_id: &str) -> Result, CliConfigError> {\n Ok(vec![PathBuf::from(\"safe-config\")])\n }\n\n fn inspect(\n &self,\n agent_id: &str,\n _applied: Option<&AppliedStateRecord>,\n _applied_profile: Option<&ProfileRecord>,\n ) -> Result {\n Ok(LiveInspection {\n paths: self.paths(agent_id)?,\n state: CliConfigDriftState::Drifted,\n managed_fingerprint: \"drifted\".into(),\n })\n }\n\n fn import_current(&self, _agent_id: &str) -> Result {\n Ok(ImportedLiveConfig {\n payload: CliConfigPayload::ClaudeCode {\n base_url: \"https://drifted.example.com\".into(),\n auth_mode: ClaudeAuthMode::AuthToken,\n model: \"drifted-model\".into(),\n haiku_model: \"drifted-model\".into(),\n sonnet_model: \"drifted-model\".into(),\n opus_model: \"drifted-model\".into(),\n advanced_env: BTreeMap::new(),\n },\n credential: Some(zeroize::Zeroizing::new(\"new-secret\".into())),\n source_fingerprint: \"file:drifted\".into(),\n })\n }\n\n fn apply(\n &self,\n _profile: &ProfileRecord,\n _previous: Option<&ProfileRecord>,\n _credential: Option<&str>,\n _confirm_auth_file_replacement: bool,\n _expected_live_fingerprint: &str,\n ) -> Result {\n Err(CliConfigError::Repository)\n }\n\n fn restore(&self, _outcome: &ProjectionOutcome) -> Result<(), CliConfigError> {\n Ok(())\n }\n }\n\n fn claude_without_credential(name: &str) -> SaveCliConfigProfileInput {\n SaveCliConfigProfileInput {\n id: None,\n agent_id: \"claude-code\".into(),\n name: name.into(),\n payload: CliConfigPayload::ClaudeCode {\n base_url: \"https://api.anthropic.com\".into(),\n auth_mode: ClaudeAuthMode::None,\n model: \"claude-sonnet-4-6\".into(),\n haiku_model: \"claude-haiku-4-5\".into(),\n sonnet_model: \"claude-sonnet-4-6\".into(),\n opus_model: \"claude-opus-4-6\".into(),\n advanced_env: BTreeMap::new(),\n },\n source_preset_id: None,\n source_preset_version: None,\n credential: None,\n remove_credential: false,\n }\n }\n\n #[test]\n fn profile_ids_are_stable_safe_slugs() {\n assert_eq!(slug(\" OpenRouter Primary \"), \"openrouter-primary\");\n assert_eq!(slug(\"智谱 GLM\"), \"glm\");\n assert_eq!(slug(\"智谱\"), \"profile\");\n }\n\n #[test]\n fn duplicate_names_do_not_collide() {\n assert_eq!(unique_copy_name(\"DeepSeek\", &[]), \"DeepSeek Copy\");\n assert_eq!(\n unique_copy_name(\"DeepSeek\", &[\"DeepSeek Copy\".into()]),\n \"DeepSeek Copy 2\"\n );\n }\n\n #[test]\n fn discovered_import_redacts_credentials_and_skips_case_insensitive_name_conflicts() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let path = directory.path().join(\".claude\").join(\"settings.json\");\n std::fs::create_dir_all(path.parent().expect(\"parent\")).expect(\"directory\");\n std::fs::write(\n &path,\n r#\"{\"env\":{\"ANTHROPIC_AUTH_TOKEN\":\"discovery-secret\",\"ANTHROPIC_MODEL\":\"claude-sonnet-4-6\"}}\"#,\n )\n .expect(\"fixture\");\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository,\n credentials.clone(),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n\n let discovery = api.discover_profiles(\"claude-code\").expect(\"discovery\");\n assert_eq!(discovery.candidates.len(), 1);\n assert!(discovery.candidates[0].credential_detected);\n let serialized = serde_json::to_string(&discovery).expect(\"serialize discovery\");\n assert!(!serialized.contains(\"discovery-secret\"));\n\n let first = api\n .import_discovered_profiles(ImportDiscoveredCliConfigInput {\n agent_id: \"claude-code\".into(),\n candidate_keys: vec![\"current\".into()],\n })\n .expect(\"first import\");\n assert_eq!(first.imported.len(), 1);\n assert!(credentials\n .load(\"claude-code\", &first.imported[0].id)\n .expect(\"credential\")\n .is_some());\n\n let second = api\n .import_discovered_profiles(ImportDiscoveredCliConfigInput {\n agent_id: \"claude-code\".into(),\n candidate_keys: vec![\"current\".into()],\n })\n .expect(\"conflict is reported\");\n assert!(second.imported.is_empty());\n assert_eq!(second.skipped.len(), 1);\n assert_eq!(second.skipped[0].reason, \"name-conflict\");\n\n std::fs::write(&path, r#\"{\"env\":{\"ANTHROPIC_AUTH_TOKEN\":\"must-not-leak\"}\"#)\n .expect(\"malformed fixture\");\n let malformed = api\n .discover_profiles(\"claude-code\")\n .expect(\"parse state is data\");\n assert_eq!(malformed.state, CliConfigDiscoveryState::ParseError);\n assert!(malformed.candidates.is_empty());\n assert!(!serde_json::to_string(&malformed)\n .expect(\"serialize malformed result\")\n .contains(\"must-not-leak\"));\n }\n\n #[test]\n fn command_errors_do_not_render_secret_values() {\n let rendered = CliConfigError::Credential.to_string();\n assert!(!rendered.contains(\"sk-secret\"));\n assert_eq!(rendered, \"secure credential operation failed\");\n }\n\n #[test]\n fn concurrent_applies_for_one_agent_are_serialized_end_to_end() {\n let repository = Arc::new(MemoryRepository::default());\n let live = Arc::new(SerialProbeLiveConfig::default());\n let api = CliConfigApi::new(\n repository,\n Arc::new(MemoryCredentials::default()),\n live.clone(),\n Arc::new(NoopLog),\n );\n let first = api\n .save_profile(claude_without_credential(\"First\"))\n .expect(\"first profile\");\n let second = api\n .save_profile(claude_without_credential(\"Second\"))\n .expect(\"second profile\");\n\n let first_api = api.clone();\n let first_handle = thread::spawn(move || {\n first_api.apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: first.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n });\n let second_api = api.clone();\n let second_handle = thread::spawn(move || {\n second_api.apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: second.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n });\n\n first_handle\n .join()\n .expect(\"first thread\")\n .expect(\"first apply\");\n second_handle\n .join()\n .expect(\"second thread\")\n .expect(\"second apply\");\n assert_eq!(live.max_active.load(Ordering::SeqCst), 1);\n }\n\n #[test]\n fn failed_profile_update_restores_the_previous_credential() {\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository.clone(),\n credentials.clone(),\n Arc::new(SerialProbeLiveConfig::default()),\n Arc::new(NoopLog),\n );\n let mut create = claude_without_credential(\"Credential profile\");\n if let CliConfigPayload::ClaudeCode { auth_mode, .. } = &mut create.payload {\n *auth_mode = ClaudeAuthMode::AuthToken;\n }\n create.credential = Some(\"old-secret\".into());\n let saved = api.save_profile(create.clone()).expect(\"initial profile\");\n\n repository.fail_next_save.store(1, Ordering::SeqCst);\n create.id = Some(saved.id.clone());\n create.credential = Some(\"new-secret\".into());\n assert!(matches!(\n api.save_profile(create),\n Err(CliConfigError::Repository)\n ));\n let restored = credentials\n .load(\"claude-code\", &saved.id)\n .expect(\"credential load\")\n .expect(\"restored credential\");\n assert_eq!(restored.as_str(), \"old-secret\");\n }\n\n #[test]\n fn applied_snapshot_removes_keys_owned_before_profile_edit() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let repository = Arc::new(MemoryRepository::default());\n let api = CliConfigApi::new(\n repository,\n Arc::new(MemoryCredentials::default()),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n let mut first_input = claude_without_credential(\"First\");\n if let CliConfigPayload::ClaudeCode { advanced_env, .. } = &mut first_input.payload {\n advanced_env.insert(\"VANEHUB_OLD_MANAGED\".into(), \"old\".into());\n }\n let first = api\n .save_profile(first_input.clone())\n .expect(\"first profile\");\n let first_apply = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: first.id.clone(),\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"first apply\");\n assert_eq!(first_apply.status, \"succeeded\");\n\n first_input.id = Some(first.id);\n if let CliConfigPayload::ClaudeCode { advanced_env, .. } = &mut first_input.payload {\n advanced_env.clear();\n }\n api.save_profile(first_input).expect(\"edit applied profile\");\n let second = api\n .save_profile(claude_without_credential(\"Second\"))\n .expect(\"second profile\");\n let second_apply = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: second.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"second apply\");\n assert_eq!(second_apply.status, \"succeeded\");\n\n let settings =\n std::fs::read_to_string(directory.path().join(\".claude\").join(\"settings.json\"))\n .expect(\"settings\");\n assert!(!settings.contains(\"VANEHUB_OLD_MANAGED\"));\n }\n\n #[test]\n fn apply_failure_returns_restoration_status_and_redacted_log() {\n let repository = Arc::new(MemoryRepository::default());\n let logging = Arc::new(CapturingLog::default());\n let api = CliConfigApi::new(\n repository,\n Arc::new(MemoryCredentials::default()),\n Arc::new(RollbackFailLiveConfig),\n logging.clone(),\n );\n let profile = api\n .save_profile(claude_without_credential(\"Failure\"))\n .expect(\"profile\");\n let result = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: profile.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"structured failure\");\n\n assert_eq!(result.status, \"failed\");\n assert!(!result.restored);\n assert_eq!(\n result.error.as_deref(),\n Some(\"configuration rollback was incomplete\")\n );\n let entries = logging.entries.lock().expect(\"logs\");\n let apply_log = entries\n .iter()\n .find(|entry| entry.category == \"cli.config.profile.apply\")\n .expect(\"apply log\");\n assert_eq!(\n apply_log.context.get(\"status\").map(String::as_str),\n Some(\"failed\")\n );\n assert_eq!(\n apply_log.context.get(\"restored\").map(String::as_str),\n Some(\"false\")\n );\n assert!(!format!(\"{apply_log:?}\").contains(\"secret\"));\n }\n\n #[test]\n fn drift_import_restores_previous_credential_when_profile_save_fails() {\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository.clone(),\n credentials.clone(),\n Arc::new(DriftImportLiveConfig),\n Arc::new(NoopLog),\n );\n let mut previous_input = claude_without_credential(\"Previous\");\n if let CliConfigPayload::ClaudeCode { auth_mode, .. } = &mut previous_input.payload {\n *auth_mode = ClaudeAuthMode::AuthToken;\n }\n previous_input.credential = Some(\"old-secret\".into());\n let previous = api.save_profile(previous_input).expect(\"previous profile\");\n let target = api\n .save_profile(claude_without_credential(\"Target\"))\n .expect(\"target profile\");\n repository\n .save_applied_state(&AppliedStateRecord {\n agent_id: \"claude-code\".into(),\n profile_id: Some(previous.id.clone()),\n projection_fingerprint: \"projection\".into(),\n live_fingerprint: \"live\".into(),\n drift_state: CliConfigDriftState::Applied,\n applied_at: \"2026-01-01T00:00:00Z\".into(),\n applied_payload: Some(previous.payload.clone()),\n managed_keys: previous.payload.managed_keys(),\n })\n .expect(\"applied state\");\n repository.fail_next_save.store(1, Ordering::SeqCst);\n\n let result = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: target.id,\n drift_resolution: Some(CliConfigDriftResolution::ImportCurrent),\n confirm_auth_file_replacement: false,\n })\n .expect(\"structured failure\");\n assert_eq!(result.status, \"failed\");\n assert!(result.restored);\n let restored = credentials\n .load(\"claude-code\", &previous.id)\n .expect(\"credential load\")\n .expect(\"restored credential\");\n assert_eq!(restored.as_str(), \"old-secret\");\n }\n\n #[test]\n fn startup_sync_bootstraps_an_exclusive_profile_once_without_rewriting_live() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let settings_path = directory.path().join(\".claude\").join(\"settings.json\");\n std::fs::create_dir_all(settings_path.parent().expect(\"settings parent\"))\n .expect(\"create settings directory\");\n let live_bytes = br#\"{\"env\":{\"ANTHROPIC_BASE_URL\":\"https://external.example.com\",\"ANTHROPIC_MODEL\":\"claude-external\"}}\"#;\n std::fs::write(&settings_path, live_bytes).expect(\"write live settings\");\n let repository = Arc::new(MemoryRepository::default());\n let api = CliConfigApi::new(\n repository.clone(),\n Arc::new(MemoryCredentials::default()),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n\n let first = api.synchronize_startup();\n let claude = first\n .iter()\n .find(|result| result.agent_id == \"claude-code\")\n .expect(\"claude startup result\");\n assert_eq!(claude.state, CliConfigStartupSyncState::Imported);\n assert_eq!(claude.imported, 1);\n let profiles = repository\n .list_profiles(\"claude-code\")\n .expect(\"list imported profiles\");\n assert_eq!(profiles.len(), 1);\n assert_eq!(profiles[0].id, \"claude-code-default\");\n assert_eq!(profiles[0].name, \"default\");\n assert_eq!(\n repository\n .applied_state(\"claude-code\")\n .expect(\"applied state\")\n .and_then(|state| state.profile_id),\n Some(\"claude-code-default\".into())\n );\n assert_eq!(\n std::fs::read(&settings_path).expect(\"read live\"),\n live_bytes\n );\n\n let second = api.synchronize_startup();\n assert_eq!(\n second\n .iter()\n .find(|result| result.agent_id == \"claude-code\")\n .expect(\"second claude result\")\n .state,\n CliConfigStartupSyncState::Skipped\n );\n assert_eq!(\n repository\n .list_profiles(\"claude-code\")\n .expect(\"list profiles after second startup\")\n .len(),\n 1\n );\n }\n\n #[test]\n fn startup_sync_upserts_opencode_by_provider_id_without_deleting_missing_profiles() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let config_path = directory\n .path()\n .join(\".config\")\n .join(\"opencode\")\n .join(\"opencode.json\");\n std::fs::create_dir_all(config_path.parent().expect(\"config parent\"))\n .expect(\"create config directory\");\n std::fs::write(\n &config_path,\n r#\"{\"model\":\"deepseek/deepseek-chat\",\"provider\":{\"deepseek\":{\"name\":\"DeepSeek\",\"npm\":\"@ai-sdk/openai-compatible\",\"options\":{\"baseURL\":\"https://api.deepseek.com/v1\",\"apiKey\":\"startup-secret\"},\"models\":{\"deepseek-chat\":{\"name\":\"DeepSeek Chat\"}}}}}\"#,\n )\n .expect(\"write OpenCode config\");\n let repository = Arc::new(MemoryRepository::default());\n let credentials = Arc::new(MemoryCredentials::default());\n let api = CliConfigApi::new(\n repository.clone(),\n credentials.clone(),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n\n let first = api.synchronize_startup();\n assert_eq!(\n first\n .iter()\n .find(|result| result.agent_id == \"opencode\")\n .expect(\"OpenCode startup result\")\n .imported,\n 1\n );\n let imported = repository\n .list_profiles(\"opencode\")\n .expect(\"OpenCode profiles\")\n .into_iter()\n .next()\n .expect(\"imported profile\");\n assert!(credentials\n .load(\"opencode\", &imported.id)\n .expect(\"credential lookup\")\n .is_some());\n\n std::fs::write(\n &config_path,\n r#\"{\"model\":\"deepseek/deepseek-reasoner\",\"provider\":{\"deepseek\":{\"name\":\"DeepSeek Updated\",\"npm\":\"@ai-sdk/openai-compatible\",\"options\":{\"baseURL\":\"https://api.deepseek.com/v2\",\"apiKey\":\"updated-secret\"},\"models\":{\"deepseek-reasoner\":{\"name\":\"DeepSeek Reasoner\"}}}}}\"#,\n )\n .expect(\"update OpenCode config\");\n let second = api.synchronize_startup();\n assert_eq!(\n second\n .iter()\n .find(|result| result.agent_id == \"opencode\")\n .expect(\"updated OpenCode result\")\n .updated,\n 1\n );\n let updated = repository\n .get_profile(\"opencode\", &imported.id)\n .expect(\"updated profile\");\n assert_eq!(updated.name, \"DeepSeek Updated\");\n assert!(matches!(\n updated.payload,\n CliConfigPayload::Opencode { ref base_url, ref default_model, .. }\n if base_url == \"https://api.deepseek.com/v2\" && default_model == \"deepseek-reasoner\"\n ));\n\n std::fs::write(&config_path, r#\"{\"provider\":{}}\"#).expect(\"remove live provider\");\n api.synchronize_startup();\n assert!(repository.get_profile(\"opencode\", &imported.id).is_ok());\n }\n\n #[test]\n fn exclusive_switch_backfills_external_live_changes_without_a_resolution_choice() {\n let directory = tempfile::tempdir().expect(\"temporary home\");\n let repository = Arc::new(MemoryRepository::default());\n let api = CliConfigApi::new(\n repository.clone(),\n Arc::new(MemoryCredentials::default()),\n Arc::new(NativeCliGlobalConfigAdapter::with_home(\n directory.path().to_path_buf(),\n )),\n Arc::new(NoopLog),\n );\n let previous = api\n .save_profile(claude_without_credential(\"Previous\"))\n .expect(\"previous profile\");\n let target = api\n .save_profile(claude_without_credential(\"Target\"))\n .expect(\"target profile\");\n assert_eq!(\n api.apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: previous.id.clone(),\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"apply previous\")\n .status,\n \"succeeded\"\n );\n let settings_path = directory.path().join(\".claude\").join(\"settings.json\");\n std::fs::write(\n &settings_path,\n r#\"{\"env\":{\"ANTHROPIC_BASE_URL\":\"https://edited.example.com\",\"ANTHROPIC_MODEL\":\"claude-edited\"}}\"#,\n )\n .expect(\"external edit\");\n\n let switched = api\n .apply_profile(ApplyCliConfigProfileInput {\n agent_id: \"claude-code\".into(),\n profile_id: target.id,\n drift_resolution: None,\n confirm_auth_file_replacement: false,\n })\n .expect(\"switch after external edit\");\n assert_eq!(switched.status, \"succeeded\");\n assert_eq!(switched.backfilled_profile_id, Some(previous.id.clone()));\n assert_eq!(\n switched.drift_resolution,\n Some(CliConfigDriftResolution::ImportCurrent)\n );\n let stored = repository\n .get_profile(\"claude-code\", &previous.id)\n .expect(\"backfilled profile\");\n assert!(matches!(\n stored.payload,\n CliConfigPayload::ClaudeCode { ref base_url, ref model, .. }\n if base_url == \"https://edited.example.com\" && model == \"claude-edited\"\n ));\n }\n}\n"}}, "docs/superpowers/plans/2026-08-05-onepiece-vector-search-phase-1.md": {"f9239dcdd552bd2a5cbb030218d66ab94c787f21": {"t": 1786068467.6310582, "n": 117950, "h": "7b2db9d16a56f4cf", "c": "# OnePiece 向量检索 · 第 1 期实现计划\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** 给 OnePiece 的跨会话记忆装上\"向量 + 关键词\"混合检索,并以模型可主动调用的 `recall` 工具暴露出来。\n\n**Architecture:** 新建 `retrieval` 对等上下文,自持 `retrieval_documents` 表(内容 + f32 BLOB 向量 + FTS5 影子表)。后台 worker 以\"差集协调(reconcile)\"为真源异步补齐 embedding,保存路径完全不感知索引。检索时向量路(Rust 暴力余弦)与关键词路(FTS5 bm25)双路召回后用 RRF 融合,任一路失败即降级而非报错。embedding 端点与凭据由 `agent_runtime` 通过两条新增跨上下文 api 提供,`retrieval` 不知道 Profile 与凭据存储的存在。\n\n**Tech Stack:** Rust / Tauri 2.x / rusqlite(SQLite + FTS5 trigram)/ sha2 / uuid / reqwest(复用 `platform/network`)/ React 19 + TypeScript strict / Tailwind / vitest / Playwright\n\n**依据规范:** `docs/superpowers/specs/2026-08-05-onepiece-vector-search-design.md`(下称\"设计文档\",本计划中的 §N 均指其章节)\n\n---\n\n## Global Constraints\n\n以下约束来自 `AGENTS.md` 与设计文档,**每个任务都隐含包含本节全部内容**:\n\n- 包管理只用 **npm**。仓库有 `package-lock.json`,禁止 pnpm/yarn(`pnpm-lock.yaml` 若出现是污染,删掉不要提交)。\n- TypeScript strict:禁止 `any`,禁止 `// @ts-ignore`(必须绕过时用 `// @ts-expect-error` 并写明原因)。\n- React 只用函数组件 + Hooks,禁止 class component,**单文件不超过 300 行**。\n- 样式只用 Tailwind:不写内联 `style`,不引入 styled-components / CSS Modules / 其他 UI 组件库。\n- 状态管理只用 React 内置 state/context。\n- **React 组件禁止直接调用 Tauri `invoke()`**,必须经 `src/services/agent-service.ts` 的服务接口。\n- `src/services/tauri-agent-client.ts` 与 `src/services/web-agent-client.ts` **必须保持接口一致,新增能力两处同时改**。\n- Rust:跨 Tauri command 边界的错误必须是 `Result` 或自定义 error enum;`unwrap()`/`expect()` 仅限测试代码。\n- 上下文边界:`agent_runtime` 只通过 `retrieval::api` 交互,**不 import** `retrieval` 的 repository 或 infrastructure;反向同理(设计文档 §4.3)。\n- 日志遵循 `openspec/specs/unified-log-management/spec.md`。**绝不落盘**:记忆内容、query 原文、API key、provider 响应体。query 只记长度与哈希(设计文档 §8.2)。\n- 注释只写\"为什么这样做\",不写代码翻译式注释。\n- **每个 Rust 任务提交前必须跑 `cargo fmt --manifest-path src-tauri/Cargo.toml --all --check`。** CI 在 `.github/workflows/ci.yml:212` gate 这一条,但 `AGENTS.md` 的「校验命令」清单**漏了它**——照那份清单做会攒下格式漂移,直到推上去才炸。\n- **`#[allow(dead_code)]` 一律 item 级(禁止模块级),且注释必须写明是哪个任务让它可达、届时移除。** 本上下文在 Task 12 的 bootstrap 之前没有任何东西被真实构造,所以大量项在此之前都是死代码。两条实测出来的机制,判断归属时必须知道:\n - **被一个仍然死掉的函数调用,不会让被调方变活。** 所以\"Task N 会调用它\"不等于\"Task N 之后可以移除属性\"——要看让 *调用方* 变活的是哪个任务。\n - **`#[allow(dead_code)]` 加在 inherent impl 的方法上,会连带屏蔽该方法调用到的一切。** 因此单独摘掉某一项的属性去跑 `cargo check` 会得到\"没有告警\"的假信号;必须连同其调用方的属性一起摘才能得到可信结论。\n - `unused_imports` 与 `dead_code` 规则不同:前者只看名字有没有被引用,不受可达性约束。\n- 可调常量集中定义在一处,不散落调用点(设计文档 §5.2)。本期四个:批大小 `32`、轮询 `5` 分钟、重试 `5` 次、截断 `8000` 字符。\n- 每个任务结束时必须能独立跑通其自身测试;全量验收命令见 Task 17。\n\n---\n\n## File Structure\n\n### 新建(Rust)\n\n```\nsrc-tauri/src/contexts/retrieval/\n├─ mod.rs # 挂 api/application/domain/infrastructure 四个子模块\n├─ api.rs # 唯一跨上下文出口:index/remove/search/配置/状态/重建\n├─ domain/\n│ ├─ mod.rs\n│ ├─ document.rs # RetrievalDocument / SourceKind / IndexState / FailureCategory / document_id / content_hash\n│ ├─ query.rs # RetrievalQuery / RetrievalScope / ScoredHit / MatchedVia / Degradation / escape_fts_query\n│ ├─ vector.rs # f32 BLOB 编解码 + 余弦\n│ ├─ fusion.rs # RRF 融合(纯函数,不碰 I/O)\n│ └─ error.rs # RetrievalError\n├─ application/\n│ ├─ mod.rs\n│ ├─ ports.rs # EmbeddingPort / EmbeddingEndpointPort / RetrievalDocumentRepository / RetrievalConfigurationRepository / MemorySourcePort\n│ ├─ indexing_service.rs # reconcile + 批处理 worker 一轮\n│ └─ search_service.rs # 双路召回 + RRF + 回查 + 降级\n└─ infrastructure/\n ├─ mod.rs\n ├─ schema.rs # 迁移 42 的建表/索引/FTS/trigger\n ├─ sqlite_repository.rs # RetrievalDocumentRepository 实现\n ├─ configuration_repository.rs # RetrievalConfigurationRepository 实现\n └─ openai_embedding_adapter.rs # EmbeddingPort 的 openai-compatible HTTP 实现\n```\n\n```\nsrc-tauri/src/bootstrap/retrieval.rs # 装配 + 后台 worker + EmbeddingEndpointPort 的跨上下文适配器\nsrc-tauri/src/commands/retrieval/ # 每命令一文件:get/save 配置、列 embedding 模型、查状态、重建\n```\n\n### 修改(Rust)\n\n- `src-tauri/src/contexts/mod.rs` — 注册 `retrieval` 模块\n- `src-tauri/src/platform/database/migrations.rs` — 注册迁移 42\n- `src-tauri/src/contexts/agent_runtime/application/service.rs:71-87` — `is_chat_model` 改为从统一的模型类别判定派生\n- `src-tauri/src/contexts/agent_runtime/api.rs` — 新增 `resolve_embedding_endpoint` / `list_embedding_models`\n- `src-tauri/src/contexts/agent_runtime/application/tool_catalog.rs` — 新增 `RECALL_TOOL_NAME`、`recall_tool_definition()`、risk tier\n- `src-tauri/src/contexts/agent_runtime/infrastructure/api_process_adapter.rs` — `resolve_tool_catalog` 注入条件性 `recall`;`execute_tool_call` 路由 `recall`\n- `src-tauri/src/commands/agent_runtime/delete_agent_memory.rs` — 删除后撤销索引\n- `src-tauri/src/bootstrap/mod.rs`、`src-tauri/src/lib.rs` — 装配与命令注册\n\n### 修改(前端)\n\n- `src/services/agent-service.ts` — 5 个新方法(设计文档 §7.4)\n- `src/services/tauri-agent-client.ts`、`src/services/web-agent-client.ts` — 同时实现\n- `src/settings/pages/agents/onepiece-retrieval-section.tsx`(**新建**)— 检索配置区块,独立文件避免 `onepiece-configuration-panel.tsx`(现 133 行)膨胀过 300 行\n- `src/settings/pages/agents/onepiece-configuration-panel.tsx` — 挂载新区块\n- `src/types/agent.ts` — 新增 `RetrievalConfiguration` / `RetrievalIndexStatus` / `EmbeddingModelOption`\n- `tests/e2e/onepiece-retrieval.spec.ts`(**新建**)\n\n---\n\n### Task 1: OpenSpec 提案\n\n`AGENTS.md` 规定:任何新功能必须先在 `openspec/changes/` 起 proposal 并通过校验,**再动代码**。本任务不写任何产品代码。\n\n**Files:**\n- Create: `openspec/changes/add-retrieval-vector-search/.openspec.yaml`\n- Create: `openspec/changes/add-retrieval-vector-search/proposal.md`\n- Create: `openspec/changes/add-retrieval-vector-search/design.md`\n- Create: `openspec/changes/add-retrieval-vector-search/tasks.md`\n- Create: `openspec/changes/add-retrieval-vector-search/specs/retrieval-vector-search/spec.md`\n- Create: `openspec/changes/add-retrieval-vector-search/specs/agent-cross-session-memory/spec.md`\n\n**Interfaces:**\n- Consumes: 设计文档全文\n- Produces: change id `add-retrieval-vector-search`,后续所有任务的 spec 依据\n\n- [ ] **Step 1: 建目录与 `.openspec.yaml`**\n\n```yaml\nschema: spec-driven\ncreated: 2026-08-05\n```\n\n- [ ] **Step 2: 写 `proposal.md`**\n\n四个 H2 小节,格式照 `openspec/changes/add-gemini-cli-terminal-usage-tracking/proposal.md`:\n\n```markdown\n## Why\n\n`agent-cross-session-memory` 目前按时间倒序、在 4000 字符预算内把记忆注入 system prompt。\n`api_process_adapter.rs:909-914` 的注释写明这是 \"real retrieval 的有界替代品\",并记录 design.md\n刻意推迟了 vector search/embeddings。会话一多,最相关的记忆就会被更晚但无关的记忆挤出预算。\n本提案接替那个被推迟的决定。\n\n## What Changes\n\n- 新建 `retrieval` 对等上下文:自持 `retrieval_documents` 表,同一行同时承载内容、f32 向量与 FTS5 影子索引。\n- 索引以差集协调(reconcile)为真源、后台异步补齐,保存记忆的路径不因 embedding 网络失败而失败。\n- 检索为向量 + FTS5 双路召回、RRF 融合;任一路不可用即降级返回,不报错。\n- 新增模型可主动调用的 `recall` 工具,scope 由运行时注入而非模型指定。\n- OnePiece 配置页新增\"检索\"区块:来源 Profile、embedding 模型、索引状态、重建索引。\n- **Non-goal(明确不做)**:分块、rerank 模型、ANN 索引、embedding 模型自动选择、改动现有 recency 注入链路、索引 `messages` 与本地文件。\n\n## Capabilities\n\n### New Capabilities\n- `retrieval-vector-search`:检索内核、索引流水线、降级语义、`recall` 工具契约、检索配置。\n\n### Modified Capabilities\n- `agent-cross-session-memory`:记忆被删除时必须撤销其检索索引;现有 recency 注入行为本期不变。\n\n## Impact\n\n- 新增 SQLite 迁移 42 `retrieval-vector-index`(含 FTS5 虚拟表与三个 trigger)。\n- Rust:新增 `contexts/retrieval/`、`bootstrap/retrieval.rs`、`commands/retrieval/`;\n 修改 `contexts/mod.rs`、`platform/database/migrations.rs`、`agent_runtime` 的\n `api.rs` / `application/service.rs` / `application/tool_catalog.rs` /\n `infrastructure/api_process_adapter.rs`。\n- 前端:`agent-service.ts` 新增 5 个方法,`tauri-agent-client.ts` 与 `web-agent-client.ts` 同步实现。\n- Web/mock runtime 保证契约形状与可观察行为对等,不保证排序算法等价。\n- 未配置 embedding 时全部现有行为不变:`recall` 不注册,recency 注入照常。\n```\n\n- [ ] **Step 3: 写 `design.md`**\n\n把 `docs/superpowers/specs/2026-08-05-onepiece-vector-search-design.md` 的 §3(关键决策表)、§4.2(数据模型)、§4.3(跨上下文依赖方向)、§6.1(降级矩阵)搬进来。**不要写成\"见另一份文档\"**——OpenSpec 归档后必须自包含。\n\n- [ ] **Step 4: 写新能力 delta spec**\n\n`specs/retrieval-vector-search/spec.md`,以 `## ADDED Requirements` 开头。至少覆盖下列 requirement,每条至少一个 `#### Scenario:`(格式照 `add-gemini-cli-terminal-usage-tracking/specs/usage-statistics/spec.md`):\n\n```markdown\n## ADDED Requirements\n\n### Requirement: Retrieval scope is runtime-injected\nThe system SHALL derive retrieval scope from the session runtime and SHALL NOT accept agent id or workspace folder as model-supplied tool input.\n\n#### Scenario: Model cannot widen its own scope\n- **WHEN** the model invokes the recall tool\n- **THEN** the system SHALL scope the search to the session's own agent id and workspace folder\n- **AND** the tool input schema SHALL NOT expose any scope parameter\n\n### Requirement: Retrieval failure never fails generation\nThe system SHALL return a successful tool result describing unavailability when retrieval fails, and SHALL NOT surface retrieval failure as a generation error.\n\n#### Scenario: Embedding provider unreachable during search\n- **WHEN** query embedding fails while retrieval is configured\n- **THEN** the system SHALL return keyword-only results marked `degraded: keyword_only`\n\n#### Scenario: Keyword path fails\n- **WHEN** the FTS5 query fails\n- **THEN** the system SHALL return vector-only results marked `degraded: vector_only`\n\n#### Scenario: Both paths yield nothing\n- **WHEN** neither path returns a hit\n- **THEN** the system SHALL return an empty result list and SHALL NOT report an error\n\n### Requirement: Saving a memory never depends on indexing\nThe system SHALL persist an agent memory without requiring its retrieval index entry to be written in the same operation.\n\n#### Scenario: Indexing backend unavailable at save time\n- **WHEN** a memory is saved while the embedding provider is unreachable\n- **THEN** the save SHALL succeed\n- **AND** the memory SHALL become searchable by keyword immediately and by vector once background indexing converges\n\n### Requirement: Vector recall only compares same-model embeddings\nThe system SHALL restrict vector recall to rows whose stored embedding model equals the currently configured embedding model.\n\n#### Scenario: Embedding model changed\n- **WHEN** the configured embedding model differs from a row's stored embedding model\n- **THEN** that row SHALL be excluded from vector recall\n- **AND** that row SHALL remain reachable through the keyword path\n- **AND** the system SHALL re-queue that row for background re-indexing\n\n### Requirement: Retrieval tool is registered only when configured\nThe system SHALL offer the recall tool to the model only when an embedding source is configured.\n\n#### Scenario: No embedding configured\n- **WHEN** no embedding source is configured\n- **THEN** the recall tool SHALL NOT appear in the tool catalog\n- **AND** existing recency-based memory injection SHALL continue unchanged\n\n### Requirement: Retrieval logging excludes sensitive content\nThe system SHALL NOT persist memory content, raw query text, credentials, or provider response bodies to logs.\n\n#### Scenario: Query logged for diagnostics\n- **WHEN** a retrieval executes\n- **THEN** the system SHALL log only the query's length and hash alongside scope hash, candidate count, per-path hit counts, and duration\n\n### Requirement: Web runtime contract parity\nThe Web/mock runtime SHALL expose the same retrieval contract shape and observable behavior as the desktop runtime, and SHALL NOT issue network requests.\n\n#### Scenario: Web runtime search\n- **WHEN** retrieval is invoked in the Web/mock runtime\n- **THEN** it SHALL return the same result structure, the same degraded semantics, and treat empty results as success\n- **AND** it MAY rank by a simple term-overlap score rather than reproducing vector similarity\n```\n\n- [ ] **Step 5: 写被修改能力的 delta spec**\n\n`specs/agent-cross-session-memory/spec.md`:\n\n```markdown\n## ADDED Requirements\n\n### Requirement: Deleting a memory revokes its retrieval index\nThe system SHALL revoke the retrieval index entry for a deleted memory, and SHALL NOT return deleted memories from retrieval even if an index entry survives.\n\n#### Scenario: Memory deleted while index revocation fails\n- **WHEN** a memory is deleted and its index revocation call fails\n- **THEN** retrieval SHALL NOT return that memory, because results are resolved against the source table\n- **AND** background reconciliation SHALL remove the orphaned index entry\n```\n\n- [ ] **Step 6: 写 `tasks.md`**\n\n按本计划 Task 2–17 逐条列出,勾选框格式 `- [ ]`。\n\n- [ ] **Step 7: 校验**\n\nRun: `openspec validate add-retrieval-vector-search --strict`\nExpected: 通过,无 error。\n\nRun: `openspec validate --specs --strict`\nExpected: 通过。\n\n- [ ] **Step 8: 提交**\n\n```bash\ngit add openspec/changes/add-retrieval-vector-search\ngit commit -m \"spec: propose retrieval vector search phase 1\"\n```\n\n---\n\n### Task 2: `retrieval` 上下文骨架与领域类型\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/mod.rs`\n- Create: `src-tauri/src/contexts/retrieval/domain/mod.rs`\n- Create: `src-tauri/src/contexts/retrieval/domain/document.rs`\n- Create: `src-tauri/src/contexts/retrieval/domain/error.rs`\n- Modify: `src-tauri/src/contexts/mod.rs`\n\n**Interfaces:**\n- Produces: `SourceKind`、`IndexState`、`FailureCategory`、`RetrievalDocument`、`document_id(SourceKind, &str) -> String`、`content_hash(&str) -> String`、`RetrievalError`。Task 5 起全部依赖这些名字与签名(Task 3、4 不碰它们)。\n\n- [ ] **Step 1: 写失败的测试**\n\n`src-tauri/src/contexts/retrieval/domain/document.rs` 末尾:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn document_id_is_deterministic_and_namespaced_by_source_kind() {\n assert_eq!(\n document_id(SourceKind::AgentMemory, \"mem-1\"),\n \"agent_memory:mem-1\"\n );\n assert_eq!(\n document_id(SourceKind::AgentMemory, \"mem-1\"),\n document_id(SourceKind::AgentMemory, \"mem-1\")\n );\n }\n\n #[test]\n fn content_hash_is_stable_and_distinguishes_content() {\n let first = content_hash(\"Uses npm, not pnpm.\");\n assert_eq!(first, content_hash(\"Uses npm, not pnpm.\"));\n assert_ne!(first, content_hash(\"Uses pnpm.\"));\n assert_eq!(first.len(), 64);\n assert!(first.chars().all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));\n }\n\n #[test]\n fn source_kind_round_trips_through_its_persisted_form() {\n assert_eq!(SourceKind::AgentMemory.as_str(), \"agent_memory\");\n assert_eq!(SourceKind::parse(\"agent_memory\"), Some(SourceKind::AgentMemory));\n assert_eq!(SourceKind::parse(\"nonsense\"), None);\n }\n\n #[test]\n fn index_state_round_trips_through_its_persisted_form() {\n // 先钉死字面量再验往返:只验往返的话,`as_str` 与 `parse` 被对称改成别的字符串\n // 仍然会通过,而那等于悄悄改掉了已落盘数据的格式。\n assert_eq!(IndexState::Pending.as_str(), \"pending\");\n assert_eq!(IndexState::Indexed.as_str(), \"indexed\");\n assert_eq!(IndexState::Failed.as_str(), \"failed\");\n for state in [IndexState::Pending, IndexState::Indexed, IndexState::Failed] {\n assert_eq!(IndexState::parse(state.as_str()), Some(state));\n }\n assert_eq!(IndexState::parse(\"nonsense\"), None);\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::domain::document`\nExpected: FAIL —— 编译错误 `unresolved module` / `cannot find function document_id`。\n\n- [ ] **Step 3: 写最小实现**\n\n`src-tauri/src/contexts/retrieval/domain/document.rs`(测试模块保留在文件末尾):\n\n```rust\nuse sha2::{Digest, Sha256};\n\n/// 索引来源类别。第 1 期只有 `AgentMemory`;第 2/3 期扩展 `session_message`、`workspace_file`。\n/// 字符串形式是持久化格式,改动即破坏既有索引行。\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum SourceKind {\n AgentMemory,\n}\n\nimpl SourceKind {\n pub(crate) fn as_str(self) -> &'static str {\n match self {\n Self::AgentMemory => \"agent_memory\",\n }\n }\n\n pub(crate) fn parse(value: &str) -> Option {\n match value {\n \"agent_memory\" => Some(Self::AgentMemory),\n _ => None,\n }\n }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum IndexState {\n Pending,\n Indexed,\n Failed,\n}\n\nimpl IndexState {\n pub(crate) fn as_str(self) -> &'static str {\n match self {\n Self::Pending => \"pending\",\n Self::Indexed => \"indexed\",\n Self::Failed => \"failed\",\n }\n }\n\n pub(crate) fn parse(value: &str) -> Option {\n match value {\n \"pending\" => Some(Self::Pending),\n \"indexed\" => Some(Self::Indexed),\n \"failed\" => Some(Self::Failed),\n _ => None,\n }\n }\n}\n\n/// 决定重试策略:`Auth`/`InvalidRequest` 是确定性失败,重试只会烧配额(设计文档 §5.2)。\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum FailureCategory {\n Auth,\n InvalidRequest,\n RateLimit,\n Network,\n}\n\nimpl FailureCategory {\n pub(crate) fn as_str(self) -> &'static str {\n match self {\n Self::Auth => \"auth\",\n Self::InvalidRequest => \"invalid_request\",\n Self::RateLimit => \"rate_limit\",\n Self::Network => \"network\",\n }\n }\n\n pub(crate) fn is_retryable(self) -> bool {\n matches!(self, Self::RateLimit | Self::Network)\n }\n}\n\n#[derive(Debug, Clone, PartialEq)]\npub(crate) struct RetrievalDocument {\n pub(crate) id: String,\n pub(crate) source_kind: SourceKind,\n pub(crate) source_id: String,\n pub(crate) scope_agent_id: String,\n /// 空串哨兵表示\"无工作区文件夹\",与 `agent_memories.folder` 一致(`memory_schema.rs:4-6`)。\n pub(crate) scope_folder: String,\n pub(crate) content: String,\n pub(crate) content_hash: String,\n pub(crate) index_state: IndexState,\n pub(crate) attempt_count: u32,\n pub(crate) embedding_model: Option,\n}\n\n/// 确定性主键,与 `UNIQUE (source_kind, source_id)` 同源——reconcile 因此可以直接 upsert,\n/// 不必先查后插。\npub(crate) fn document_id(source_kind: SourceKind, source_id: &str) -> String {\n format!(\"{}:{}\", source_kind.as_str(), source_id)\n}\n\npub(crate) fn content_hash(content: &str) -> String {\n bytes_to_hex(&Sha256::digest(content.as_bytes()))\n}\n\nfn bytes_to_hex(bytes: &[u8]) -> String {\n const HEX: &[u8; 16] = b\"0123456789abcdef\";\n let mut output = String::with_capacity(bytes.len() * 2);\n for byte in bytes {\n output.push(HEX[(byte >> 4) as usize] as char);\n output.push(HEX[(byte & 0x0f) as usize] as char);\n }\n output\n}\n```\n\n`src-tauri/src/contexts/retrieval/domain/error.rs`:\n\n```rust\n/// 上下文内部错误。在 `api.rs` 边界转换;跨 Tauri command 边界按 AGENTS.md 转为 `Result`。\n#[derive(Debug, Clone, PartialEq)]\npub(crate) enum RetrievalError {\n Storage(String),\n Embedding(String),\n NotConfigured,\n /// 两路召回都失败。与\"两路都可用但没命中\"必须是不同的结果:把\"搜不了\"报告成\n /// \"没有\",会让模型据此断定用户从没提过某事。\n Unavailable,\n}\n\nimpl std::fmt::Display for RetrievalError {\n fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n match self {\n Self::Storage(message) => write!(formatter, \"retrieval storage error: {message}\"),\n Self::Embedding(message) => write!(formatter, \"embedding error: {message}\"),\n Self::NotConfigured => write!(formatter, \"retrieval is not configured\"),\n Self::Unavailable => write!(formatter, \"retrieval is temporarily unavailable\"),\n }\n }\n}\n```\n\n`src-tauri/src/contexts/retrieval/domain/mod.rs`:\n\n```rust\npub(crate) mod document;\npub(crate) mod error;\n\npub(crate) use document::{\n content_hash, document_id, FailureCategory, IndexState, RetrievalDocument, SourceKind,\n};\npub(crate) use error::RetrievalError;\n```\n\n`src-tauri/src/contexts/retrieval/mod.rs`:\n\n```rust\n//! 语义 + 关键词混合检索内核。第 1 期只索引跨会话记忆(`add-retrieval-vector-search`)。\n\npub(crate) mod domain;\n```\n\n`src-tauri/src/contexts/mod.rs` 的模块列表按字母序插入一行(在 `operations` 之后):\n\n```rust\npub(crate) mod retrieval;\n```\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::domain::document`\nExpected: PASS,4 个测试。\n\nRun: `cargo clippy --manifest-path src-tauri/Cargo.toml`\nExpected: 无 warning。未被使用的 pub(crate) 项若触发 `dead_code`,本任务先给 `RetrievalDocument` 与 `FailureCategory` 加 `#[allow(dead_code)]`;**Task 5 的仓储 trait 签名会用上这两个类型,届时必须删除该属性**(留着它会长期掩盖真实的死代码)。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval src-tauri/src/contexts/mod.rs\ngit commit -m \"feat(retrieval): add context skeleton and domain document types\"\n```\n\n---\n\n### Task 3: 纯算法——余弦、f32 BLOB 编解码、RRF、FTS 转义\n\n三者都是无 I/O 纯函数,放在一个任务里:它们没有互相依赖,但共同构成\"可以独立于数据库验证的算法内核\",一个 reviewer 一次看完最省事。\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/domain/vector.rs`\n- Create: `src-tauri/src/contexts/retrieval/domain/fusion.rs`\n- Create: `src-tauri/src/contexts/retrieval/domain/query.rs`\n- Modify: `src-tauri/src/contexts/retrieval/domain/mod.rs`\n\n**Interfaces:**\n- Consumes: 无\n- Produces:\n - `encode_embedding(&[f32]) -> Vec`、`decode_embedding(&[u8]) -> Option>`、`cosine_similarity(&[f32], &[f32]) -> Option`\n - `fuse_with_rrf(&[Vec]) -> Vec<(String, f64)>`\n - `escape_fts_query(&str) -> String`\n - `RetrievalScope { agent_id: String, folder: String }`、`RetrievalQuery { text: String, scope: RetrievalScope, limit: usize }`、`MatchedVia`、`Degradation`、`ScoredHit`\n\n- [ ] **Step 1: 写失败的测试**\n\n`vector.rs` 末尾:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn embedding_round_trips_through_its_blob_form() {\n let original = vec![0.0_f32, 1.5, -2.25, 1e-7];\n let decoded = decode_embedding(&encode_embedding(&original)).expect(\"decodes\");\n assert_eq!(decoded, original);\n }\n\n #[test]\n fn blob_with_a_length_that_is_not_a_multiple_of_four_is_rejected() {\n assert_eq!(decode_embedding(&[0, 0, 0]), None);\n }\n\n #[test]\n fn cosine_of_identical_vectors_is_one() {\n let similarity = cosine_similarity(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect(\"similarity\");\n assert!((similarity - 1.0).abs() < 1e-6, \"got {similarity}\");\n }\n\n #[test]\n fn cosine_of_orthogonal_vectors_is_zero() {\n let similarity = cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]).expect(\"similarity\");\n assert!(similarity.abs() < 1e-6, \"got {similarity}\");\n }\n\n #[test]\n fn cosine_of_opposite_vectors_is_negative_one() {\n let similarity = cosine_similarity(&[1.0, 2.0], &[-1.0, -2.0]).expect(\"similarity\");\n assert!((similarity + 1.0).abs() < 1e-6, \"got {similarity}\");\n }\n\n #[test]\n fn cosine_rejects_dimension_mismatch_and_zero_vectors() {\n assert_eq!(cosine_similarity(&[1.0, 2.0], &[1.0]), None);\n assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 2.0]), None);\n }\n}\n```\n\n`fusion.rs` 末尾:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn ids(ranking: &[(String, f64)]) -> Vec<&str> {\n ranking.iter().map(|(id, _)| id.as_str()).collect()\n }\n\n #[test]\n fn a_document_ranked_first_by_both_paths_wins() {\n let vector = vec![\"a\".to_string(), \"b\".to_string(), \"c\".to_string()];\n let keyword = vec![\"a\".to_string(), \"c\".to_string()];\n assert_eq!(ids(&fuse_with_rrf(&[vector, keyword]))[0], \"a\");\n }\n\n #[test]\n fn a_document_found_by_both_paths_beats_one_found_only_higher_by_a_single_path() {\n // b: 两路各第 2 名 → 2/62;a: 只有向量路第 1 名 → 1/61。两次中游胜过一次头名。\n let vector = vec![\"a\".to_string(), \"b\".to_string()];\n let keyword = vec![\"c\".to_string(), \"b\".to_string()];\n assert_eq!(ids(&fuse_with_rrf(&[vector, keyword]))[0], \"b\");\n }\n\n #[test]\n fn an_empty_path_does_not_affect_the_other_path_order() {\n let vector = vec![\"a\".to_string(), \"b\".to_string()];\n assert_eq!(ids(&fuse_with_rrf(&[vector, Vec::new()])), vec![\"a\", \"b\"]);\n }\n\n #[test]\n fn fusing_nothing_yields_nothing() {\n assert!(fuse_with_rrf(&[Vec::new(), Vec::new()]).is_empty());\n }\n\n #[test]\n fn ties_are_broken_deterministically_by_id() {\n let first = vec![\"b\".to_string(), \"a\".to_string()];\n let second = vec![\"a\".to_string(), \"b\".to_string()];\n assert_eq!(ids(&fuse_with_rrf(&[first, second])), vec![\"a\", \"b\"]);\n }\n}\n```\n\n`query.rs` 末尾:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn a_plain_query_becomes_a_quoted_phrase() {\n assert_eq!(escape_fts_query(\"npm not pnpm\"), \"\\\"npm not pnpm\\\"\");\n }\n\n #[test]\n fn embedded_double_quotes_are_doubled_not_dropped() {\n assert_eq!(escape_fts_query(\"use \\\"npm\\\"\"), \"\\\"use \\\"\\\"npm\\\"\\\"\\\"\");\n }\n\n #[test]\n fn fts_operators_lose_their_syntactic_meaning() {\n for raw in [\"a OR b\", \"a NEAR b\", \"prefix*\", \"col:value\", \"-excluded\"] {\n let escaped = escape_fts_query(raw);\n assert!(escaped.starts_with('\"') && escaped.ends_with('\"'), \"{escaped}\");\n assert!(escaped.contains(raw), \"{escaped} should carry {raw} verbatim\");\n }\n }\n\n #[test]\n fn whitespace_only_queries_escape_to_an_empty_phrase() {\n assert_eq!(escape_fts_query(\" \"), \"\\\"\\\"\");\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::domain`\nExpected: FAIL —— `cannot find function encode_embedding` 等。\n\n- [ ] **Step 3: 写最小实现**\n\n`vector.rs`:\n\n```rust\n/// 存储格式:f32 little-endian 连续字节。选它而不是 JSON 数组,是因为向量路要在候选集上做\n/// 暴力扫描,反序列化开销直接进热路径。\npub(crate) fn encode_embedding(values: &[f32]) -> Vec {\n let mut bytes = Vec::with_capacity(values.len() * 4);\n for value in values {\n bytes.extend_from_slice(&value.to_le_bytes());\n }\n bytes\n}\n\npub(crate) fn decode_embedding(bytes: &[u8]) -> Option> {\n if bytes.len() % 4 != 0 {\n return None;\n }\n Some(\n bytes\n .chunks_exact(4)\n .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))\n .collect(),\n )\n}\n\n/// 维度不一致或任一侧为零向量时返回 `None`——没有有意义的相似度可言,交由调用方跳过该候选,\n/// 而不是伪造一个 0.0 混进排名。\npub(crate) fn cosine_similarity(left: &[f32], right: &[f32]) -> Option {\n if left.len() != right.len() || left.is_empty() {\n return None;\n }\n let mut dot = 0.0_f64;\n let mut left_norm = 0.0_f64;\n let mut right_norm = 0.0_f64;\n for (a, b) in left.iter().zip(right.iter()) {\n dot += f64::from(*a) * f64::from(*b);\n left_norm += f64::from(*a) * f64::from(*a);\n right_norm += f64::from(*b) * f64::from(*b);\n }\n if left_norm == 0.0 || right_norm == 0.0 {\n return None;\n }\n Some((dot / (left_norm.sqrt() * right_norm.sqrt())) as f32)\n}\n```\n\n`fusion.rs`:\n\n```rust\nuse std::collections::HashMap;\n\n/// RRF 的平滑常数。60 是原论文的取值,作用是压低头部名次的边际优势,让\"两路都中游\"\n/// 胜过\"一路头名、另一路缺席\"——这正是混合检索想要的行为。\nconst RRF_SMOOTHING: f64 = 60.0;\n\n/// 输入是若干条已排好序的 id 列表(每条代表一路召回),输出按融合分降序。\n/// 同分时按 id 升序,保证同样输入永远给出同样顺序——否则测试与 UI 都会闪。\npub(crate) fn fuse_with_rrf(rankings: &[Vec]) -> Vec<(String, f64)> {\n let mut scores: HashMap<&str, f64> = HashMap::new();\n for ranking in rankings {\n for (index, id) in ranking.iter().enumerate() {\n let rank = index as f64 + 1.0;\n *scores.entry(id.as_str()).or_insert(0.0) += 1.0 / (RRF_SMOOTHING + rank);\n }\n }\n let mut fused: Vec<(String, f64)> = scores\n .into_iter()\n .map(|(id, score)| (id.to_string(), score))\n .collect();\n fused.sort_by(|left, right| {\n right\n .1\n .partial_cmp(&left.1)\n .unwrap_or(std::cmp::Ordering::Equal)\n .then_with(|| left.0.cmp(&right.0))\n });\n fused\n}\n```\n\n`query.rs`:\n\n```rust\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) struct RetrievalScope {\n pub(crate) agent_id: String,\n /// 空串表示\"无工作区文件夹\",与 `agent_memories.folder` 的哨兵一致。\n pub(crate) folder: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) struct RetrievalQuery {\n pub(crate) text: String,\n pub(crate) scope: RetrievalScope,\n pub(crate) limit: usize,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum MatchedVia {\n Vector,\n Keyword,\n Both,\n}\n\nimpl MatchedVia {\n pub(crate) fn as_str(self) -> &'static str {\n match self {\n Self::Vector => \"vector\",\n Self::Keyword => \"keyword\",\n Self::Both => \"both\",\n }\n }\n}\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub(crate) enum Degradation {\n KeywordOnly,\n VectorOnly,\n}\n\nimpl Degradation {\n pub(crate) fn as_str(self) -> &'static str {\n match self {\n Self::KeywordOnly => \"keyword_only\",\n Self::VectorOnly => \"vector_only\",\n }\n }\n}\n\n#[derive(Debug, Clone, PartialEq)]\npub(crate) struct ScoredHit {\n pub(crate) source_id: String,\n pub(crate) content: String,\n pub(crate) created_at: String,\n pub(crate) score: f64,\n pub(crate) matched_via: MatchedVia,\n}\n\n/// 把整条 query 转义成**单个 FTS5 字符串字面量**。\n///\n/// 仓库里唯一的既有 FTS 消费方 `contexts/workspaces/infrastructure/output_search.rs:36-47`\n/// 是把原始串直接塞进 `MATCH ?1` 的,只挡空串与超长。这里不能照抄:`recall` 的 query 由模型\n/// 自由生成,含 `\"` `*` `:` `-` `OR` `NEAR` 时 FTS5 会按查询语法解析,轻则语义跑偏,重则整条\n/// 语句报错。转义成短语后,trigram tokenizer 下的子串匹配正是我们想要的行为。\npub(crate) fn escape_fts_query(raw: &str) -> String {\n let mut escaped = String::with_capacity(raw.len() + 2);\n escaped.push('\"');\n for character in raw.trim().chars() {\n if character == '\"' {\n escaped.push('\"');\n }\n escaped.push(character);\n }\n escaped.push('\"');\n escaped\n}\n```\n\n`domain/mod.rs` 追加:\n\n```rust\npub(crate) mod fusion;\npub(crate) mod query;\npub(crate) mod vector;\n\npub(crate) use fusion::fuse_with_rrf;\npub(crate) use query::{\n escape_fts_query, Degradation, MatchedVia, RetrievalQuery, RetrievalScope, ScoredHit,\n};\npub(crate) use vector::{cosine_similarity, decode_embedding, encode_embedding};\n```\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::domain`\nExpected: PASS,共 19 个测试(Task 2 的 4 个 + 本任务 15 个)。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval/domain\ngit commit -m \"feat(retrieval): add cosine, RRF fusion, and FTS query escaping\"\n```\n\n---\n\n### Task 4: 迁移 42 与 schema\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/infrastructure/mod.rs`\n- Create: `src-tauri/src/contexts/retrieval/infrastructure/schema.rs`\n- Modify: `src-tauri/src/contexts/retrieval/mod.rs`\n- Modify: `src-tauri/src/platform/database/migrations.rs`(在 `41 onepiece-provider-endpoints` 之后追加)\n\n**Interfaces:**\n- Consumes: 无\n- Produces: `apply_retrieval_schema(&Connection) -> Result<(), DatabaseError>`,Task 5/6 的仓储依赖其建出的表\n\n**核实过的现状:** 当前最新迁移是 41 `onepiece-provider-endpoints`(`migrations.rs:236`),所以 42 是下一个号。FTS 写法照搬 `apply_session_message_search_migration`(`migrations.rs:274-301`)的 external-content + 三 trigger + `tokenize='trigram'`。\n\n- [ ] **Step 1: 写失败的测试**\n\n`schema.rs` 末尾:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n fn migrated_connection() -> Connection {\n let connection = Connection::open_in_memory().expect(\"database\");\n apply_retrieval_schema(&connection).expect(\"first apply\");\n connection\n }\n\n #[test]\n fn schema_is_idempotent() {\n let connection = migrated_connection();\n apply_retrieval_schema(&connection).expect(\"second apply\");\n\n let table_count: i64 = connection\n .query_row(\n \"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'retrieval_documents'\",\n [],\n |row| row.get(0),\n )\n .expect(\"table count\");\n assert_eq!(table_count, 1);\n }\n\n #[test]\n fn inserting_a_document_populates_the_fts_shadow_table() {\n let connection = migrated_connection();\n connection\n .execute(\n \"INSERT INTO retrieval_documents\n (id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash, created_at, updated_at)\n VALUES ('agent_memory:m1','agent_memory','m1','a','', 'uses npm not pnpm', 'h', 't', 't')\",\n [],\n )\n .expect(\"insert\");\n\n let hits: i64 = connection\n .query_row(\n \"SELECT COUNT(*) FROM retrieval_documents_fts WHERE retrieval_documents_fts MATCH '\\\"npm\\\"'\",\n [],\n |row| row.get(0),\n )\n .expect(\"fts count\");\n assert_eq!(hits, 1);\n }\n\n #[test]\n fn updating_content_replaces_the_fts_entry() {\n let connection = migrated_connection();\n connection\n .execute(\n \"INSERT INTO retrieval_documents\n (id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash, created_at, updated_at)\n VALUES ('agent_memory:m1','agent_memory','m1','a','', 'uses npm', 'h', 't', 't')\",\n [],\n )\n .expect(\"insert\");\n connection\n .execute(\n \"UPDATE retrieval_documents SET content = 'uses cargo' WHERE id = 'agent_memory:m1'\",\n [],\n )\n .expect(\"update\");\n\n let stale: i64 = connection\n .query_row(\n \"SELECT COUNT(*) FROM retrieval_documents_fts WHERE retrieval_documents_fts MATCH '\\\"npm\\\"'\",\n [],\n |row| row.get(0),\n )\n .expect(\"stale count\");\n let fresh: i64 = connection\n .query_row(\n \"SELECT COUNT(*) FROM retrieval_documents_fts WHERE retrieval_documents_fts MATCH '\\\"cargo\\\"'\",\n [],\n |row| row.get(0),\n )\n .expect(\"fresh count\");\n assert_eq!(stale, 0);\n assert_eq!(fresh, 1);\n }\n\n #[test]\n fn deleting_a_document_clears_its_fts_entry() {\n let connection = migrated_connection();\n connection\n .execute(\n \"INSERT INTO retrieval_documents\n (id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash, created_at, updated_at)\n VALUES ('agent_memory:m1','agent_memory','m1','a','', 'uses npm', 'h', 't', 't')\",\n [],\n )\n .expect(\"insert\");\n connection\n .execute(\"DELETE FROM retrieval_documents WHERE id = 'agent_memory:m1'\", [])\n .expect(\"delete\");\n\n let hits: i64 = connection\n .query_row(\n \"SELECT COUNT(*) FROM retrieval_documents_fts WHERE retrieval_documents_fts MATCH '\\\"npm\\\"'\",\n [],\n |row| row.get(0),\n )\n .expect(\"fts count\");\n assert_eq!(hits, 0);\n }\n\n #[test]\n fn the_same_source_cannot_be_indexed_twice() {\n let connection = migrated_connection();\n connection\n .execute(\n \"INSERT INTO retrieval_documents\n (id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash, created_at, updated_at)\n VALUES ('agent_memory:m1','agent_memory','m1','a','', 'x', 'h', 't', 't')\",\n [],\n )\n .expect(\"first insert\");\n\n // 第二行**换一个 id**,否则 PRIMARY KEY 冲突会与 UNIQUE 冲突混在一起:\n // 那样即使 UNIQUE (source_kind, source_id) 被删掉,这条测试照样通过。\n let duplicate_source = connection.execute(\n \"INSERT INTO retrieval_documents\n (id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash, created_at, updated_at)\n VALUES ('agent_memory:m1-dup','agent_memory','m1','a','', 'x', 'h', 't', 't')\",\n [],\n );\n\n assert!(duplicate_source.is_err());\n }\n\n #[test]\n fn the_configuration_table_holds_at_most_one_row() {\n let connection = migrated_connection();\n connection\n .execute(\n \"INSERT INTO retrieval_configuration (id, source_profile_id, embedding_model, updated_at)\n VALUES (1, 'p1', 'text-embedding-3-small', 't')\",\n [],\n )\n .expect(\"singleton insert\");\n assert!(connection\n .execute(\n \"INSERT INTO retrieval_configuration (id, source_profile_id, embedding_model, updated_at)\n VALUES (2, 'p2', 'other', 't')\",\n [],\n )\n .is_err());\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::schema`\nExpected: FAIL —— `cannot find function apply_retrieval_schema`。\n\n- [ ] **Step 3: 写最小实现**\n\n`src-tauri/src/contexts/retrieval/infrastructure/schema.rs`:\n\n```rust\nuse crate::platform::database::DatabaseError;\nuse rusqlite::Connection;\n\n/// 迁移 42 `retrieval-vector-index`(`add-retrieval-vector-search`)。\n///\n/// scope 冗余进本表而不是每次 JOIN 回源表:检索先按 `scope_agent_id + scope_folder` 过滤再\n/// 暴力扫描候选集,这是\"不建 ANN 索引也够快\"成立的前提。\n/// FTS 建在本表而非 `agent_memories`:第 2/3 期的源表不同,统一在本表做 FTS 才能让混合检索\n/// 只实现一次。\n/// 不建到 `agent_memories` 的外键:跨期源表不同,靠 `source_kind + source_id` 逻辑关联,\n/// 检索结果一律回查源表,源已删则跳过。\npub(crate) fn apply_retrieval_schema(connection: &Connection) -> Result<(), DatabaseError> {\n connection.execute_batch(\n r#\"\n CREATE TABLE IF NOT EXISTS retrieval_documents (\n id TEXT PRIMARY KEY,\n source_kind TEXT NOT NULL,\n source_id TEXT NOT NULL,\n scope_agent_id TEXT NOT NULL,\n scope_folder TEXT NOT NULL DEFAULT '',\n content TEXT NOT NULL,\n content_hash TEXT NOT NULL,\n index_state TEXT NOT NULL DEFAULT 'pending',\n attempt_count INTEGER NOT NULL DEFAULT 0,\n failure_category TEXT,\n embedding_model TEXT,\n embedding_dimensions INTEGER,\n embedding BLOB,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n UNIQUE (source_kind, source_id)\n );\n\n CREATE INDEX IF NOT EXISTS idx_retrieval_documents_scope\n ON retrieval_documents (source_kind, scope_agent_id, scope_folder, index_state);\n CREATE INDEX IF NOT EXISTS idx_retrieval_documents_queue\n ON retrieval_documents (index_state, updated_at);\n\n CREATE VIRTUAL TABLE IF NOT EXISTS retrieval_documents_fts USING fts5(\n content,\n content='retrieval_documents',\n content_rowid='rowid',\n tokenize='trigram'\n );\n\n CREATE TRIGGER IF NOT EXISTS retrieval_documents_fts_insert\n AFTER INSERT ON retrieval_documents BEGIN\n INSERT INTO retrieval_documents_fts(rowid, content) VALUES (new.rowid, new.content);\n END;\n CREATE TRIGGER IF NOT EXISTS retrieval_documents_fts_delete\n AFTER DELETE ON retrieval_documents BEGIN\n INSERT INTO retrieval_documents_fts(retrieval_documents_fts, rowid, content)\n VALUES ('delete', old.rowid, old.content);\n END;\n CREATE TRIGGER IF NOT EXISTS retrieval_documents_fts_update\n AFTER UPDATE OF content ON retrieval_documents BEGIN\n INSERT INTO retrieval_documents_fts(retrieval_documents_fts, rowid, content)\n VALUES ('delete', old.rowid, old.content);\n INSERT INTO retrieval_documents_fts(rowid, content) VALUES (new.rowid, new.content);\n END;\n\n -- 单例配置行。retrieval 拥有自己的配置表,而不是借用 desktop 上下文的 settings KV 表,\n -- 避免为读一条自有配置去依赖另一个上下文的 api。\n CREATE TABLE IF NOT EXISTS retrieval_configuration (\n id INTEGER PRIMARY KEY CHECK (id = 1),\n source_profile_id TEXT,\n embedding_model TEXT,\n updated_at TEXT NOT NULL\n );\n \"#,\n )?;\n Ok(())\n}\n```\n\n`src-tauri/src/contexts/retrieval/infrastructure/mod.rs`:\n\n```rust\npub(crate) mod schema;\n\npub(crate) use schema::apply_retrieval_schema;\n```\n\n`src-tauri/src/contexts/retrieval/mod.rs` 追加一行 `pub(crate) mod infrastructure;`。\n\n`migrations.rs` 在 41 的 `apply_migration(...)` 之后、`Ok(())` 之前插入:\n\n```rust\n apply_migration(\n conn,\n 42,\n \"retrieval-vector-index\",\n crate::contexts::retrieval::infrastructure::apply_retrieval_schema,\n )?;\n```\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::schema`\nExpected: PASS,6 个测试。\n\n- [ ] **Step 5: 确认迁移回归覆盖,不新增测试**\n\n**不要**为迁移 42 新写一条\"重开数据库看数据还在\"的测试。`apply_migration`(`migrations.rs:557-581`)是版本门控的:第一次 `NativeDatabase::new` 就已经在空库上跑完迁移 42 并把版本记进 `schema_migrations`,第二次打开直接短路。那样的测试**从未让迁移 42 的 DDL 在有数据的库上执行过**,只证明了\"全迁移完的库能重开\",而这已被 `platform/database/mod.rs` 的 `reopening_is_idempotent_and_preserves_existing_records` 覆盖。\n\n真正的覆盖来自 `src-tauri/src/migration_fixture_tests.rs` 已有的 `legacy_v1_fixture_upgrades_without_losing_records`:它把冻结的旧版 fixture 灌进裸连接、直接调 `migrate()`、再断言指定的旧行存活。本任务只需把它的版本范围断言从 `(1..=41)` 改成 `(1..=42)`。\n\n同理,`src-tauri/src/platform/database/mod.rs` 有两处硬编码 `migration_count == 41` 的断言,也必须一并改成 `42`——这是新增迁移的必然连带修改,属于本任务范围,不是范围蔓延。\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml migration_fixture_tests`\nExpected: PASS,其中 `legacy_v1_fixture_upgrades_without_losing_records` 覆盖迁移 42 在真实旧数据上的执行。\n\n- [ ] **Step 6: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval src-tauri/src/platform/database/migrations.rs src-tauri/src/migration_fixture_tests.rs\ngit commit -m \"feat(retrieval): add migration 42 with FTS5 shadow index\"\n```\n\n---\n\n### Task 5: 文档仓储\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/infrastructure/sqlite_repository.rs`\n- Create: `src-tauri/src/contexts/retrieval/application/mod.rs`\n- Create: `src-tauri/src/contexts/retrieval/application/ports.rs`\n- Modify: `src-tauri/src/contexts/retrieval/infrastructure/mod.rs`、`src-tauri/src/contexts/retrieval/mod.rs`\n- Modify: `src-tauri/src/contexts/retrieval/domain/document.rs`(本任务的 trait 签名用上了 `RetrievalDocument` 与 `FailureCategory`,删掉 Task 2 临时加的 `#[allow(dead_code)]`)\n\n**Interfaces:**\n- Consumes: Task 2 的 `SourceKind/IndexState/FailureCategory/RetrievalDocument/document_id`;Task 3 的 `encode_embedding/decode_embedding/escape_fts_query`\n- Produces: trait `RetrievalDocumentRepository` 与实现 `SqliteRetrievalDocumentRepository`,方法签名如下(Task 7/8/9 依赖):\n\n```rust\npub(crate) trait RetrievalDocumentRepository: Send + Sync {\n fn upsert_pending(&self, document: &RetrievalDocument) -> Result<(), RetrievalError>;\n fn list_indexed_source_ids(&self, source_kind: SourceKind) -> Result, RetrievalError>;\n fn delete_by_source(&self, source_kind: SourceKind, source_id: &str) -> Result<(), RetrievalError>;\n fn claim_pending_batch(&self, source_kind: SourceKind, limit: usize) -> Result, RetrievalError>;\n fn store_embedding(&self, id: &str, model: &str, embedding: &[f32]) -> Result<(), RetrievalError>;\n fn record_failure(&self, id: &str, category: FailureCategory, give_up: bool) -> Result<(), RetrievalError>;\n fn vector_candidates(&self, scope: &RetrievalScope, source_kind: SourceKind, model: &str) -> Result)>, RetrievalError>;\n fn keyword_candidates(&self, scope: &RetrievalScope, source_kind: SourceKind, query: &str, limit: usize) -> Result, RetrievalError>;\n fn index_status(&self, agent_id: &str) -> Result;\n fn requeue_all(&self, agent_id: &str) -> Result<(), RetrievalError>;\n}\n```\n\n`list_indexed_source_ids` 返回 `(source_id, content_hash)`——reconcile 只需要这两列就能算出全部三类待办。\n`vector_candidates` 与 `keyword_candidates` 返回的是 `source_id`(不是行 id),因为消费方要拿它回查源表。\n\n- [ ] **Step 1: 写失败的测试**\n\n`sqlite_repository.rs` 末尾。fixture 照搬 `memory_repository.rs:170-200` 的 `TempDirectory` + `NativeDatabase`:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::TempDirectory;\n\n struct Fixture {\n _directory: TempDirectory,\n database: NativeDatabase,\n repository: SqliteRetrievalDocumentRepository,\n }\n\n impl Fixture {\n fn new(label: &str) -> Self {\n let directory = TempDirectory::new(label);\n let database =\n NativeDatabase::new(directory.path().to_path_buf()).expect(\"test database\");\n Self {\n repository: SqliteRetrievalDocumentRepository::new(database.clone()),\n database,\n _directory: directory,\n }\n }\n\n /// `attempt_count` 不进 `RetrievalIndexStatus`——它是 worker 的内部记账,不该出现在\n /// UI 契约里。但重试与重建的正确性正取决于它,所以测试直接读列。\n fn attempt_count(&self, source_id: &str) -> i64 {\n self.database\n .connection()\n .expect(\"connection\")\n .query_row(\n \"SELECT attempt_count FROM retrieval_documents WHERE source_id = ?1\",\n params![source_id],\n |row| row.get(0),\n )\n .expect(\"attempt count\")\n }\n }\n\n fn document(source_id: &str, agent: &str, folder: &str, content: &str) -> RetrievalDocument {\n RetrievalDocument {\n id: document_id(SourceKind::AgentMemory, source_id),\n source_kind: SourceKind::AgentMemory,\n source_id: source_id.to_string(),\n scope_agent_id: agent.to_string(),\n scope_folder: folder.to_string(),\n content: content.to_string(),\n content_hash: content_hash(content),\n index_state: IndexState::Pending,\n attempt_count: 0,\n embedding_model: None,\n }\n }\n\n fn scope(agent: &str, folder: &str) -> RetrievalScope {\n RetrievalScope { agent_id: agent.to_string(), folder: folder.to_string() }\n }\n\n #[test]\n fn upsert_is_idempotent_and_refreshes_content_and_hash() {\n let fixture = Fixture::new(\"retrieval upsert idempotent\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"uses npm\")).expect(\"first\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"uses cargo\")).expect(\"second\");\n\n let indexed = fixture\n .repository\n .list_indexed_source_ids(SourceKind::AgentMemory)\n .expect(\"list\");\n assert_eq!(indexed.len(), 1);\n assert_eq!(indexed[0].0, \"m1\");\n assert_eq!(indexed[0].1, content_hash(\"uses cargo\"));\n }\n\n #[test]\n fn upserting_unchanged_content_preserves_index_state_and_failure_bookkeeping() {\n let fixture = Fixture::new(\"retrieval upsert preserves state\");\n let unchanged = document(\"m1\", \"a\", \"\", \"uses npm\");\n fixture.repository.upsert_pending(&unchanged).expect(\"first upsert\");\n fixture\n .repository\n .store_embedding(\n &document_id(SourceKind::AgentMemory, \"m1\"),\n \"model-a\",\n &[1.0, 0.0],\n )\n .expect(\"store\");\n\n // 内容没变的重复 reconcile 不能把已索引行打回 pending——否则每一轮轮询都会重烧\n // 一次 embedding 配额。上面那条 idempotent 测试改的是内容,只覆盖了重置分支。\n fixture.repository.upsert_pending(&unchanged).expect(\"second upsert\");\n\n let status = fixture.repository.index_status(\"a\").expect(\"status\");\n assert_eq!(status.indexed, 1, \"an unchanged upsert must not reset index_state\");\n assert_eq!(status.pending, 0);\n assert_eq!(\n fixture\n .repository\n .vector_candidates(&scope(\"a\", \"\"), SourceKind::AgentMemory, \"model-a\")\n .expect(\"candidates\")\n .len(),\n 1,\n \"the stored embedding must survive an unchanged upsert\"\n );\n }\n\n #[test]\n fn storing_an_embedding_marks_the_row_indexed_and_clears_failure_state() {\n let fixture = Fixture::new(\"retrieval store embedding\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"uses npm\")).expect(\"upsert\");\n fixture\n .repository\n .record_failure(&document_id(SourceKind::AgentMemory, \"m1\"), FailureCategory::Network, false)\n .expect(\"failure\");\n\n fixture\n .repository\n .store_embedding(&document_id(SourceKind::AgentMemory, \"m1\"), \"model-a\", &[1.0, 0.0])\n .expect(\"store\");\n\n let candidates = fixture\n .repository\n .vector_candidates(&scope(\"a\", \"\"), SourceKind::AgentMemory, \"model-a\")\n .expect(\"candidates\");\n assert_eq!(candidates.len(), 1);\n assert_eq!(candidates[0].0, \"m1\");\n assert_eq!(candidates[0].1, vec![1.0, 0.0]);\n\n // 光看 vector_candidates 命中证明不了\"清空失败状态\"——它的 WHERE 根本不看\n // failure_category 与 attempt_count,那两个子句被删掉这条测试照样过。\n let status = fixture.repository.index_status(\"a\").expect(\"status\");\n assert_eq!(status.indexed, 1);\n assert_eq!(\n status.last_failure_category, None,\n \"store_embedding must clear the failure category\"\n );\n assert_eq!(\n fixture.attempt_count(\"m1\"),\n 0,\n \"store_embedding must reset the attempt count\"\n );\n }\n\n #[test]\n fn vector_candidates_exclude_rows_embedded_with_a_different_model() {\n let fixture = Fixture::new(\"retrieval model mismatch\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"x\")).expect(\"upsert\");\n fixture\n .repository\n .store_embedding(&document_id(SourceKind::AgentMemory, \"m1\"), \"old-model\", &[1.0, 0.0])\n .expect(\"store\");\n\n let candidates = fixture\n .repository\n .vector_candidates(&scope(\"a\", \"\"), SourceKind::AgentMemory, \"new-model\")\n .expect(\"candidates\");\n assert!(candidates.is_empty());\n }\n\n #[test]\n fn candidates_never_cross_agent_or_folder_boundaries() {\n let fixture = Fixture::new(\"retrieval scope isolation\");\n for (source_id, agent, folder) in [\n (\"m1\", \"a\", \"D:/one\"),\n (\"m2\", \"a\", \"D:/two\"),\n (\"m3\", \"b\", \"D:/one\"),\n ] {\n fixture\n .repository\n .upsert_pending(&document(source_id, agent, folder, \"shared content\"))\n .expect(\"upsert\");\n fixture\n .repository\n .store_embedding(&document_id(SourceKind::AgentMemory, source_id), \"m\", &[1.0, 0.0])\n .expect(\"store\");\n }\n\n let vectors = fixture\n .repository\n .vector_candidates(&scope(\"a\", \"D:/one\"), SourceKind::AgentMemory, \"m\")\n .expect(\"vectors\");\n let keywords = fixture\n .repository\n .keyword_candidates(&scope(\"a\", \"D:/one\"), SourceKind::AgentMemory, \"\\\"shared\\\"\", 10)\n .expect(\"keywords\");\n\n assert_eq!(vectors.iter().map(|(id, _)| id.as_str()).collect::>(), vec![\"m1\"]);\n assert_eq!(keywords, vec![\"m1\".to_string()]);\n }\n\n #[test]\n fn keyword_candidates_find_pending_rows_because_fts_does_not_wait_for_the_worker() {\n let fixture = Fixture::new(\"retrieval keyword pending\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"uses npm not pnpm\")).expect(\"upsert\");\n\n let hits = fixture\n .repository\n .keyword_candidates(&scope(\"a\", \"\"), SourceKind::AgentMemory, \"\\\"pnpm\\\"\", 10)\n .expect(\"keywords\");\n assert_eq!(hits, vec![\"m1\".to_string()]);\n }\n\n #[test]\n fn claim_pending_batch_respects_its_limit_and_skips_indexed_rows() {\n let fixture = Fixture::new(\"retrieval claim batch\");\n for source_id in [\"m1\", \"m2\", \"m3\"] {\n fixture\n .repository\n .upsert_pending(&document(source_id, \"a\", \"\", \"content\"))\n .expect(\"upsert\");\n }\n fixture\n .repository\n .store_embedding(&document_id(SourceKind::AgentMemory, \"m1\"), \"m\", &[1.0])\n .expect(\"store\");\n\n let batch = fixture\n .repository\n .claim_pending_batch(SourceKind::AgentMemory, 2)\n .expect(\"batch\");\n assert_eq!(batch.len(), 2);\n assert!(batch.iter().all(|document| document.source_id != \"m1\"));\n }\n\n #[test]\n fn giving_up_marks_failed_while_a_retryable_failure_stays_pending() {\n let fixture = Fixture::new(\"retrieval failure states\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"x\")).expect(\"m1\");\n fixture.repository.upsert_pending(&document(\"m2\", \"a\", \"\", \"x\")).expect(\"m2\");\n\n fixture\n .repository\n .record_failure(&document_id(SourceKind::AgentMemory, \"m1\"), FailureCategory::Auth, true)\n .expect(\"give up\");\n fixture\n .repository\n .record_failure(&document_id(SourceKind::AgentMemory, \"m2\"), FailureCategory::Network, false)\n .expect(\"retry later\");\n\n let status = fixture.repository.index_status(\"a\").expect(\"status\");\n assert_eq!(status.failed, 1);\n assert_eq!(status.pending, 1);\n assert_eq!(status.indexed, 0);\n // `last_failure_category` 取的是**最近更新**的那一条,也就是后写入的 m2(network),\n // 不是先写入的 m1(auth)。\n assert_eq!(status.last_failure_category.as_deref(), Some(\"network\"));\n }\n\n #[test]\n fn requeue_all_resets_failures_and_attempt_counts() {\n let fixture = Fixture::new(\"retrieval requeue\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"x\")).expect(\"upsert\");\n fixture\n .repository\n .record_failure(&document_id(SourceKind::AgentMemory, \"m1\"), FailureCategory::Auth, true)\n .expect(\"failure\");\n // 先断言它确实被计过数,否则下面那条\"归零\"断言证明不了任何事。\n assert_eq!(fixture.attempt_count(\"m1\"), 1, \"the failure must have counted an attempt\");\n\n fixture.repository.requeue_all(\"a\").expect(\"requeue\");\n\n let status = fixture.repository.index_status(\"a\").expect(\"status\");\n assert_eq!(status.failed, 0);\n assert_eq!(status.pending, 1);\n assert_eq!(status.last_failure_category, None);\n assert_eq!(fixture.attempt_count(\"m1\"), 0, \"requeue_all must reset the attempt count\");\n }\n\n #[test]\n fn index_status_spans_every_folder_of_the_agent() {\n let fixture = Fixture::new(\"retrieval status folders\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"D:/one\", \"x\")).expect(\"m1\");\n fixture.repository.upsert_pending(&document(\"m2\", \"a\", \"D:/two\", \"x\")).expect(\"m2\");\n fixture.repository.upsert_pending(&document(\"m3\", \"b\", \"D:/one\", \"x\")).expect(\"m3\");\n\n let status = fixture.repository.index_status(\"a\").expect(\"status\");\n assert_eq!(status.pending, 2);\n }\n\n #[test]\n fn delete_by_source_removes_the_row_and_its_fts_entry() {\n let fixture = Fixture::new(\"retrieval delete\");\n fixture.repository.upsert_pending(&document(\"m1\", \"a\", \"\", \"uses npm\")).expect(\"upsert\");\n\n fixture\n .repository\n .delete_by_source(SourceKind::AgentMemory, \"m1\")\n .expect(\"delete\");\n\n let keywords = fixture\n .repository\n .keyword_candidates(&scope(\"a\", \"\"), SourceKind::AgentMemory, \"\\\"npm\\\"\", 10)\n .expect(\"keywords\");\n assert!(keywords.is_empty());\n assert!(fixture\n .repository\n .list_indexed_source_ids(SourceKind::AgentMemory)\n .expect(\"list\")\n .is_empty());\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::sqlite_repository`\nExpected: FAIL —— `cannot find type SqliteRetrievalDocumentRepository`。\n\n- [ ] **Step 3: 写 ports 与实现**\n\n`src-tauri/src/contexts/retrieval/application/ports.rs` 先只放本任务需要的两项:\n\n```rust\nuse crate::contexts::retrieval::domain::{\n FailureCategory, RetrievalDocument, RetrievalError, RetrievalScope, SourceKind,\n};\n\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub(crate) struct RetrievalIndexStatus {\n pub(crate) indexed: u32,\n pub(crate) pending: u32,\n pub(crate) failed: u32,\n /// 只给类别,不带原始错误文本——错误体可能含凭据或 provider 响应内容(设计文档 §8.2)。\n pub(crate) last_failure_category: Option,\n}\n\npub(crate) trait RetrievalDocumentRepository: Send + Sync {\n fn upsert_pending(&self, document: &RetrievalDocument) -> Result<(), RetrievalError>;\n fn list_indexed_source_ids(\n &self,\n source_kind: SourceKind,\n ) -> Result, RetrievalError>;\n fn delete_by_source(\n &self,\n source_kind: SourceKind,\n source_id: &str,\n ) -> Result<(), RetrievalError>;\n fn claim_pending_batch(\n &self,\n source_kind: SourceKind,\n limit: usize,\n ) -> Result, RetrievalError>;\n fn store_embedding(\n &self,\n id: &str,\n model: &str,\n embedding: &[f32],\n ) -> Result<(), RetrievalError>;\n fn record_failure(\n &self,\n id: &str,\n category: FailureCategory,\n give_up: bool,\n ) -> Result<(), RetrievalError>;\n fn vector_candidates(\n &self,\n scope: &RetrievalScope,\n source_kind: SourceKind,\n model: &str,\n ) -> Result)>, RetrievalError>;\n fn keyword_candidates(\n &self,\n scope: &RetrievalScope,\n source_kind: SourceKind,\n query: &str,\n limit: usize,\n ) -> Result, RetrievalError>;\n fn index_status(&self, agent_id: &str) -> Result;\n fn requeue_all(&self, agent_id: &str) -> Result<(), RetrievalError>;\n}\n```\n\n`application/mod.rs`:\n\n```rust\npub(crate) mod ports;\n\npub(crate) use ports::{RetrievalDocumentRepository, RetrievalIndexStatus};\n```\n\n`sqlite_repository.rs` 实现要点(完整实现按下列 SQL 逐个方法填;此处给出全部关键语句):\n\n```rust\nuse crate::contexts::retrieval::application::{RetrievalDocumentRepository, RetrievalIndexStatus};\nuse crate::contexts::retrieval::domain::{\n content_hash, decode_embedding, document_id, encode_embedding, FailureCategory, IndexState,\n RetrievalDocument, RetrievalError, RetrievalScope, SourceKind,\n};\nuse crate::platform::clock::SystemClock;\nuse crate::platform::database::NativeDatabase;\nuse rusqlite::{params, Row};\n\n#[derive(Clone)]\npub(crate) struct SqliteRetrievalDocumentRepository {\n database: NativeDatabase,\n}\n\nimpl SqliteRetrievalDocumentRepository {\n pub(crate) fn new(database: NativeDatabase) -> Self {\n Self { database }\n }\n}\n```\n\n各方法的 SQL:\n\n```sql\n-- upsert_pending:内容变了才重置索引状态,否则重复 reconcile 会把已索引行反复打回 pending。\nINSERT INTO retrieval_documents\n (id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash,\n index_state, attempt_count, failure_category, created_at, updated_at)\nVALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending', 0, NULL, ?8, ?8)\nON CONFLICT(id) DO UPDATE SET\n content = excluded.content,\n content_hash = excluded.content_hash,\n scope_agent_id = excluded.scope_agent_id,\n scope_folder = excluded.scope_folder,\n index_state = CASE WHEN retrieval_documents.content_hash = excluded.content_hash\n THEN retrieval_documents.index_state ELSE 'pending' END,\n attempt_count = CASE WHEN retrieval_documents.content_hash = excluded.content_hash\n THEN retrieval_documents.attempt_count ELSE 0 END,\n failure_category = CASE WHEN retrieval_documents.content_hash = excluded.content_hash\n THEN retrieval_documents.failure_category ELSE NULL END,\n updated_at = excluded.updated_at;\n\n-- list_indexed_source_ids\nSELECT source_id, content_hash FROM retrieval_documents WHERE source_kind = ?1;\n\n-- delete_by_source\nDELETE FROM retrieval_documents WHERE source_kind = ?1 AND source_id = ?2;\n\n-- claim_pending_batch\nSELECT id, source_kind, source_id, scope_agent_id, scope_folder, content, content_hash,\n index_state, attempt_count, embedding_model\nFROM retrieval_documents\nWHERE source_kind = ?1 AND index_state = 'pending'\nORDER BY updated_at ASC\nLIMIT ?2;\n\n-- store_embedding\nUPDATE retrieval_documents\nSET embedding = ?2, embedding_model = ?3, embedding_dimensions = ?4,\n index_state = 'indexed', failure_category = NULL, attempt_count = 0, updated_at = ?5\nWHERE id = ?1;\n\n-- record_failure:give_up 决定终态,attempt_count 由本语句自增,调用方不需要先读。\nUPDATE retrieval_documents\nSET attempt_count = attempt_count + 1,\n failure_category = ?2,\n index_state = CASE WHEN ?3 THEN 'failed' ELSE 'pending' END,\n updated_at = ?4\nWHERE id = ?1;\n\n-- vector_candidates:刻意不取 content,只拉 BLOB;内容统一由消费方回查源表提供。\nSELECT source_id, embedding FROM retrieval_documents\nWHERE source_kind = ?1 AND scope_agent_id = ?2 AND scope_folder = ?3\n AND index_state = 'indexed' AND embedding_model = ?4 AND embedding IS NOT NULL;\n\n-- keyword_candidates\nSELECT d.source_id FROM retrieval_documents d\nJOIN retrieval_documents_fts f ON f.rowid = d.rowid\nWHERE retrieval_documents_fts MATCH ?1\n AND d.source_kind = ?2 AND d.scope_agent_id = ?3 AND d.scope_folder = ?4\nORDER BY bm25(retrieval_documents_fts)\nLIMIT ?5;\n\n-- index_status\nSELECT\n SUM(index_state = 'indexed'), SUM(index_state = 'pending'), SUM(index_state = 'failed'),\n (SELECT failure_category FROM retrieval_documents\n WHERE scope_agent_id = ?1 AND failure_category IS NOT NULL\n ORDER BY updated_at DESC LIMIT 1)\nFROM retrieval_documents WHERE scope_agent_id = ?1;\n\n-- requeue_all\nUPDATE retrieval_documents\nSET index_state = 'pending', attempt_count = 0, failure_category = NULL, updated_at = ?2\nWHERE scope_agent_id = ?1;\n```\n\n时间戳一律用 `SystemClock.rfc3339()`。行读取写一个 `DocumentRow::read(&Row) -> rusqlite::Result` + `into_document()`,照 `memory_repository.rs:115-152` 的 `MemoryRow` 写法;`SourceKind`/`IndexState` 解析失败时返回 `RetrievalError::Storage`。rusqlite 错误统一经一个 `fn storage_error(error: rusqlite::Error) -> RetrievalError` 转换。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::sqlite_repository`\nExpected: PASS,10 个测试。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval\ngit commit -m \"feat(retrieval): add SQLite document repository with scope-filtered recall\"\n```\n\n---\n\n### Task 6: 配置仓储\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/infrastructure/configuration_repository.rs`\n- Modify: `src-tauri/src/contexts/retrieval/application/ports.rs`、`application/mod.rs`、`infrastructure/mod.rs`\n\n**Interfaces:**\n- Produces:\n\n```rust\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub(crate) struct RetrievalConfiguration {\n pub(crate) source_profile_id: Option,\n pub(crate) embedding_model: Option,\n}\n\nimpl RetrievalConfiguration {\n /// 两者齐备才算\"已配置\"——缺任一个都无法发起一次 embedding 调用。\n pub(crate) fn resolved_model(&self) -> Option<(&str, &str)>;\n}\n\npub(crate) trait RetrievalConfigurationRepository: Send + Sync {\n fn load(&self) -> Result;\n fn save(&self, profile_id: &str, embedding_model: &str) -> Result<(), RetrievalError>;\n}\n```\n\n- [ ] **Step 1: 写失败的测试**\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use crate::test_support::TempDirectory;\n\n fn fixture(label: &str) -> (TempDirectory, SqliteRetrievalConfigurationRepository) {\n let directory = TempDirectory::new(label);\n let database = NativeDatabase::new(directory.path().to_path_buf()).expect(\"database\");\n (directory, SqliteRetrievalConfigurationRepository::new(database))\n }\n\n #[test]\n fn an_unconfigured_database_loads_an_empty_configuration() {\n let (_directory, repository) = fixture(\"retrieval config empty\");\n let configuration = repository.load().expect(\"load\");\n assert_eq!(configuration, RetrievalConfiguration::default());\n assert_eq!(configuration.resolved_model(), None);\n }\n\n #[test]\n fn saving_twice_updates_the_single_row_instead_of_failing() {\n let (_directory, repository) = fixture(\"retrieval config overwrite\");\n repository.save(\"profile-a\", \"model-a\").expect(\"first save\");\n repository.save(\"profile-b\", \"model-b\").expect(\"second save\");\n\n let configuration = repository.load().expect(\"load\");\n assert_eq!(configuration.resolved_model(), Some((\"profile-b\", \"model-b\")));\n }\n\n #[test]\n fn a_configuration_missing_either_half_is_not_resolved() {\n assert_eq!(\n RetrievalConfiguration {\n source_profile_id: Some(\"p\".to_string()),\n embedding_model: None,\n }\n .resolved_model(),\n None\n );\n assert_eq!(\n RetrievalConfiguration {\n source_profile_id: None,\n embedding_model: Some(\"m\".to_string()),\n }\n .resolved_model(),\n None\n );\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::configuration_repository`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现**\n\n`resolved_model`:\n\n```rust\nimpl RetrievalConfiguration {\n pub(crate) fn resolved_model(&self) -> Option<(&str, &str)> {\n let profile = self.source_profile_id.as_deref()?;\n let model = self.embedding_model.as_deref()?;\n (!profile.is_empty() && !model.is_empty()).then_some((profile, model))\n }\n}\n```\n\n仓储 SQL:\n\n```sql\n-- load\nSELECT source_profile_id, embedding_model FROM retrieval_configuration WHERE id = 1;\n\n-- save\nINSERT INTO retrieval_configuration (id, source_profile_id, embedding_model, updated_at)\nVALUES (1, ?1, ?2, ?3)\nON CONFLICT(id) DO UPDATE SET\n source_profile_id = excluded.source_profile_id,\n embedding_model = excluded.embedding_model,\n updated_at = excluded.updated_at;\n```\n\n`load` 在无行时返回 `RetrievalConfiguration::default()`,不是错误。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::configuration_repository`\nExpected: PASS,3 个测试。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval\ngit commit -m \"feat(retrieval): add singleton retrieval configuration repository\"\n```\n\n---\n\n### Task 7: 索引服务的差集协调\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/application/indexing_service.rs`\n- Modify: `src-tauri/src/contexts/retrieval/application/ports.rs`、`application/mod.rs`\n\n**Interfaces:**\n- Consumes: Task 5 的 `RetrievalDocumentRepository`\n- Produces:\n\n```rust\n/// retrieval 从源上下文取快照的消费侧契约。第 1 期唯一实现是 agent_runtime 的记忆表适配器。\npub(crate) trait IndexSourcePort: Send + Sync {\n fn snapshot(&self) -> Result, RetrievalError>;\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub(crate) struct IndexSourceRecord {\n pub(crate) source_id: String,\n pub(crate) agent_id: String,\n pub(crate) folder: String,\n pub(crate) content: String,\n /// 检索结果要带上它(Task 9 的 `ScoredHit.created_at`),且它只存在于源表——\n /// 索引行刻意不复制这个字段,避免又多一处会陈旧的副本。\n pub(crate) created_at: String,\n}\n\npub(crate) struct IndexingService { /* repository + source */ }\n\nimpl IndexingService {\n pub(crate) fn reconcile(&self) -> Result;\n}\n\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub(crate) struct ReconcileOutcome {\n pub(crate) added: usize,\n pub(crate) invalidated: usize,\n pub(crate) orphans_removed: usize,\n}\n```\n\n- [ ] **Step 1: 写失败的测试**\n\n用内存 fake,不碰数据库。**这套 fake 是 Task 8/9 的模板,后续任务按需给它补方法即可。**\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n use std::sync::Mutex;\n\n struct FakeSource {\n records: Vec,\n }\n\n impl IndexSourcePort for FakeSource {\n fn snapshot(&self) -> Result, RetrievalError> {\n Ok(self.records.clone())\n }\n }\n\n #[derive(Default)]\n struct FakeRepository {\n /// 已存在的索引行:(source_id, content_hash)\n rows: Vec<(String, String)>,\n upserted: Mutex>,\n deleted: Mutex>,\n }\n\n impl RetrievalDocumentRepository for FakeRepository {\n fn upsert_pending(&self, document: &RetrievalDocument) -> Result<(), RetrievalError> {\n self.upserted.lock().expect(\"lock\").push(document.source_id.clone());\n Ok(())\n }\n\n fn list_indexed_source_ids(\n &self,\n _source_kind: SourceKind,\n ) -> Result, RetrievalError> {\n Ok(self.rows.clone())\n }\n\n fn delete_by_source(\n &self,\n _source_kind: SourceKind,\n source_id: &str,\n ) -> Result<(), RetrievalError> {\n self.deleted.lock().expect(\"lock\").push(source_id.to_string());\n Ok(())\n }\n\n // reconcile 只用到上面三个方法。其余方法在本套测试中不可达,走 unimplemented!()。\n fn claim_pending_batch(\n &self,\n _source_kind: SourceKind,\n _limit: usize,\n ) -> Result, RetrievalError> {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n fn store_embedding(\n &self,\n _id: &str,\n _model: &str,\n _embedding: &[f32],\n ) -> Result<(), RetrievalError> {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n fn record_failure(\n &self,\n _id: &str,\n _category: FailureCategory,\n _give_up: bool,\n ) -> Result<(), RetrievalError> {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n fn vector_candidates(\n &self,\n _scope: &RetrievalScope,\n _source_kind: SourceKind,\n _model: &str,\n ) -> Result)>, RetrievalError> {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n fn keyword_candidates(\n &self,\n _scope: &RetrievalScope,\n _source_kind: SourceKind,\n _query: &str,\n _limit: usize,\n ) -> Result, RetrievalError> {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n fn index_status(&self, _agent_id: &str) -> Result {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n fn requeue_all(&self, _agent_id: &str) -> Result<(), RetrievalError> {\n unimplemented!(\"not exercised by reconcile tests\")\n }\n }\n\n fn record(source_id: &str, content: &str) -> IndexSourceRecord {\n IndexSourceRecord {\n source_id: source_id.to_string(),\n agent_id: \"a\".to_string(),\n folder: String::new(),\n content: content.to_string(),\n created_at: \"2026-08-05T00:00:00Z\".to_string(),\n }\n }\n\n fn indexed(source_id: &str, content: &str) -> (String, String) {\n (source_id.to_string(), content_hash(content))\n }\n\n /// 装配一个只依赖两个 fake 的服务,返回服务与仓储句柄以便事后断言调用记录。\n fn service(\n records: Vec,\n rows: Vec<(String, String)>,\n ) -> (IndexingService, Arc) {\n let repository = Arc::new(FakeRepository { rows, ..FakeRepository::default() });\n let service = IndexingService::new(\n repository.clone(),\n Arc::new(FakeSource { records }),\n );\n (service, repository)\n }\n\n #[test]\n fn a_source_record_with_no_index_row_is_added() {\n let (service, repository) = service(vec![record(\"m1\", \"uses npm\")], Vec::new());\n\n let outcome = service.reconcile().expect(\"reconcile\");\n\n assert_eq!(outcome.added, 1);\n assert_eq!(outcome.invalidated, 0);\n assert_eq!(outcome.orphans_removed, 0);\n assert_eq!(*repository.upserted.lock().expect(\"lock\"), vec![\"m1\".to_string()]);\n }\n\n #[test]\n fn a_content_change_invalidates_the_existing_index_row() {\n let (service, repository) = service(\n vec![record(\"m1\", \"uses cargo\")],\n vec![indexed(\"m1\", \"uses npm\")],\n );\n\n let outcome = service.reconcile().expect(\"reconcile\");\n\n assert_eq!(outcome.invalidated, 1);\n assert_eq!(outcome.added, 0);\n assert_eq!(*repository.upserted.lock().expect(\"lock\"), vec![\"m1\".to_string()]);\n }\n\n #[test]\n fn an_index_row_whose_source_disappeared_is_removed() {\n let (service, repository) = service(Vec::new(), vec![indexed(\"m1\", \"uses npm\")]);\n\n let outcome = service.reconcile().expect(\"reconcile\");\n\n assert_eq!(outcome.orphans_removed, 1);\n assert_eq!(*repository.deleted.lock().expect(\"lock\"), vec![\"m1\".to_string()]);\n }\n\n #[test]\n fn an_unchanged_record_is_left_alone() {\n let (service, repository) = service(\n vec![record(\"m1\", \"uses npm\")],\n vec![indexed(\"m1\", \"uses npm\")],\n );\n\n let outcome = service.reconcile().expect(\"reconcile\");\n\n assert_eq!(outcome, ReconcileOutcome::default());\n assert!(repository.upserted.lock().expect(\"lock\").is_empty());\n assert!(repository.deleted.lock().expect(\"lock\").is_empty());\n }\n\n #[test]\n fn all_three_kinds_of_work_are_handled_in_one_pass() {\n let (service, repository) = service(\n vec![record(\"m1\", \"new\"), record(\"m2\", \"changed\")],\n vec![indexed(\"m2\", \"original\"), indexed(\"m3\", \"orphan\")],\n );\n\n let outcome = service.reconcile().expect(\"reconcile\");\n\n assert_eq!(outcome.added, 1);\n assert_eq!(outcome.invalidated, 1);\n assert_eq!(outcome.orphans_removed, 1);\n assert_eq!(*repository.deleted.lock().expect(\"lock\"), vec![\"m3\".to_string()]);\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::application::indexing_service`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现**\n\n```rust\n/// 索引的真源是这一次差集协调,而**不是**保存路径上的双写。\n///\n/// 在 `SqliteAgentMemoryRepository::save` 里顺手插一条索引行看似更简单,但那会引入\n/// \"入队写失败 → 该记忆永远搜不到\"的静默漏洞:保存成功了,用户以为记住了,检索却永远看不见。\n/// 协调式的代价只是最多延迟一个周期,而且顺带把历史存量记忆回填掉,不需要单独的数据迁移脚本。\npub(crate) fn reconcile(&self) -> Result {\n let records = self.source.snapshot()?;\n let existing: HashMap = self\n .repository\n .list_indexed_source_ids(SourceKind::AgentMemory)?\n .into_iter()\n .collect();\n\n let mut outcome = ReconcileOutcome::default();\n let mut live: HashSet<&str> = HashSet::new();\n for record in &records {\n live.insert(record.source_id.as_str());\n let hash = content_hash(&record.content);\n match existing.get(&record.source_id) {\n Some(existing_hash) if existing_hash == &hash => continue,\n Some(_) => outcome.invalidated += 1,\n None => outcome.added += 1,\n }\n self.repository.upsert_pending(&RetrievalDocument {\n id: document_id(SourceKind::AgentMemory, &record.source_id),\n source_kind: SourceKind::AgentMemory,\n source_id: record.source_id.clone(),\n scope_agent_id: record.agent_id.clone(),\n scope_folder: record.folder.clone(),\n content: record.content.clone(),\n content_hash: hash,\n index_state: IndexState::Pending,\n attempt_count: 0,\n embedding_model: None,\n })?;\n }\n\n // 孤儿清理是 §5.3 显式撤销失败时的兜底。少了它,一次失败的撤销调用会让索引行永久残留。\n for source_id in existing.keys() {\n if !live.contains(source_id.as_str()) {\n self.repository\n .delete_by_source(SourceKind::AgentMemory, source_id)?;\n outcome.orphans_removed += 1;\n }\n }\n Ok(outcome)\n}\n```\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::application::indexing_service`\nExpected: PASS,5 个测试。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval\ngit commit -m \"feat(retrieval): reconcile index against its source instead of dual-writing\"\n```\n\n---\n\n### Task 8: 索引 worker 的批处理、失败分类与重试\n\n**Files:**\n- Modify: `src-tauri/src/contexts/retrieval/application/indexing_service.rs`\n- Modify: `src-tauri/src/contexts/retrieval/application/ports.rs`(加 `EmbeddingPort`)\n\n**Interfaces:**\n- Produces:\n\n```rust\n/// 可调常量集中在此,不散落调用点(设计文档 §5.2)。\npub(crate) const EMBEDDING_BATCH_SIZE: usize = 32;\npub(crate) const RECONCILE_POLL_INTERVAL_SECONDS: u64 = 300;\npub(crate) const MAX_EMBEDDING_ATTEMPTS: u32 = 5;\npub(crate) const RETRY_BACKOFF_SECONDS: [u64; 5] = [1, 4, 15, 60, 300];\n/// 超长内容 embedding 前截断;FTS 仍索引全文,所以长记忆的尾部仍可被关键词命中。\npub(crate) const EMBEDDING_CONTENT_LIMIT: usize = 8000;\n\npub(crate) trait EmbeddingPort: Send + Sync {\n fn embed(&self, model: &str, inputs: &[String]) -> Result>, EmbeddingFailure>;\n}\n\npub(crate) struct EmbeddingFailure {\n pub(crate) category: FailureCategory,\n pub(crate) message: String,\n}\n\nimpl IndexingService {\n pub(crate) fn process_pending_batch(&self, model: &str) -> Result;\n}\n\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub(crate) struct BatchOutcome {\n pub(crate) succeeded: usize,\n pub(crate) failed: usize,\n /// 设计文档 §8.2 要求失败日志带错误类别。服务本身不打日志,所以类别必须随结构化结果\n /// 一起交给 Task 12 的 worker 调用点——否则运维排查卡住的索引时分不清 auth / rate_limit /\n /// network,而那恰恰是最常见失败最有用的那条诊断。\n pub(crate) last_failure_category: Option,\n}\n```\n\n- [ ] **Step 1: 写失败的测试**\n\n```rust\n#[test]\nfn a_successful_batch_stores_one_embedding_per_document() { /* fake 返回 N 个向量 → succeeded = N */ }\n\n#[test]\nfn an_auth_failure_gives_up_immediately_without_burning_quota() {\n // fake 返回 FailureCategory::Auth → record_failure(give_up = true),即使 attempt_count 是 0\n}\n\n#[test]\nfn an_invalid_request_failure_gives_up_immediately() { /* 同上,category = InvalidRequest */ }\n\n#[test]\nfn a_network_failure_below_the_attempt_ceiling_stays_retryable() {\n // attempt_count = 1 的行 + Network → record_failure(give_up = false)\n}\n\n#[test]\nfn a_network_failure_at_the_attempt_ceiling_gives_up() {\n // attempt_count = MAX_EMBEDDING_ATTEMPTS - 1 → record_failure(give_up = true)\n}\n\n#[test]\nfn content_longer_than_the_limit_is_truncated_before_embedding() {\n // 输入 8001 字符 → fake 收到的字符串长度恰好 EMBEDDING_CONTENT_LIMIT\n}\n\n#[test]\nfn a_batch_never_exceeds_its_size_limit() {\n // 仓储里 40 条 pending → claim_pending_batch 被以 EMBEDDING_BATCH_SIZE 调用\n}\n\n#[test]\nfn a_provider_returning_the_wrong_number_of_vectors_fails_the_batch_without_storing_anything() {\n // fake 返回 2 个向量但批里有 3 条 → 不调用 store_embedding,全部按 InvalidRequest 记失败\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::application::indexing_service`\nExpected: FAIL —— `no method named process_pending_batch`。\n\n- [ ] **Step 3: 写实现**\n\n```rust\npub(crate) fn process_pending_batch(&self, model: &str) -> Result {\n let batch = self\n .repository\n .claim_pending_batch(SourceKind::AgentMemory, EMBEDDING_BATCH_SIZE)?;\n if batch.is_empty() {\n return Ok(BatchOutcome::default());\n }\n let inputs: Vec = batch\n .iter()\n .map(|document| truncate_for_embedding(&document.content))\n .collect();\n\n match self.embeddings.embed(model, &inputs) {\n // 数量对不上说明 provider 的响应与请求不成对,不能靠位置把向量配给文档——\n // 错配的向量比没有向量更糟:它会安静地污染检索结果。\n Ok(vectors) if vectors.len() != batch.len() => {\n for document in &batch {\n self.repository\n .record_failure(&document.id, FailureCategory::InvalidRequest, true)?;\n }\n Ok(BatchOutcome { succeeded: 0, failed: batch.len() })\n }\n Ok(vectors) => {\n for (document, vector) in batch.iter().zip(vectors.iter()) {\n self.repository.store_embedding(&document.id, model, vector)?;\n }\n Ok(BatchOutcome { succeeded: batch.len(), failed: 0 })\n }\n Err(failure) => {\n for document in &batch {\n let give_up = !failure.category.is_retryable()\n || document.attempt_count + 1 >= MAX_EMBEDDING_ATTEMPTS;\n self.repository\n .record_failure(&document.id, failure.category, give_up)?;\n }\n Ok(BatchOutcome { succeeded: 0, failed: batch.len() })\n }\n }\n}\n\n/// 按字符而非字节截断——按字节切会把多字节 UTF-8 字符劈成两半并 panic。\nfn truncate_for_embedding(content: &str) -> String {\n content.chars().take(EMBEDDING_CONTENT_LIMIT).collect()\n}\n```\n\n**日志归属(设计文档 §8.2)**:`reconcile` 与 `process_pending_batch` 都**不自己打日志**——两者都返回结构化结果(`ReconcileOutcome` / `BatchOutcome`),由 Task 12 的 worker 循环在调用点统一记录。这样应用服务保持纯粹、可用 fake 无副作用地测试,日志格式也只有一处定义。\n\nworker 调用点要记的内容:批次完成记 `info`,字段为批大小、耗时、成功/失败条数、模型 id;失败记 `warn`,字段为错误类别与 `attempt_count`。**不记内容、不记响应体**。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::application::indexing_service`\nExpected: PASS,13 个测试(Task 7 的 5 个 + 本任务 8 个)。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval\ngit commit -m \"feat(retrieval): batch embedding with failure-category-aware retry\"\n```\n\n---\n\n### Task 9: 检索服务与降级\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/application/search_service.rs`\n- Modify: `src-tauri/src/contexts/retrieval/application/mod.rs`\n\n**Interfaces:**\n- Consumes: Task 3 的 `fuse_with_rrf/cosine_similarity/escape_fts_query`、Task 5 的仓储、Task 6 的配置仓储、Task 8 的 `EmbeddingPort`、Task 7 的 `IndexSourcePort`\n- Produces:\n\n```rust\n#[derive(Debug, Clone, PartialEq)]\npub(crate) struct SearchOutcome {\n pub(crate) hits: Vec,\n pub(crate) degraded: Option,\n}\n\nimpl SearchService {\n pub(crate) fn search(&self, query: &RetrievalQuery) -> Result;\n}\n```\n\n`search` 只在\"未配置\"时返回 `Err(RetrievalError::NotConfigured)`;其余一切失败都走降级,不返回 Err。\n\n- [ ] **Step 1: 写失败的测试**\n\n```rust\n#[test]\nfn both_paths_healthy_yields_no_degradation_and_marks_overlap_as_both() { }\n\n#[test]\nfn a_hit_found_only_by_the_vector_path_is_marked_vector() { }\n\n#[test]\nfn a_hit_found_only_by_the_keyword_path_is_marked_keyword() { }\n\n#[test]\nfn query_embedding_failure_degrades_to_keyword_only_instead_of_erroring() {\n // fake embedding 返回 Err → outcome.degraded == Some(Degradation::KeywordOnly),且 hits 非空\n}\n\n#[test]\nfn keyword_path_failure_degrades_to_vector_only_instead_of_erroring() {\n // fake 仓储的 keyword_candidates 返回 Err → degraded == Some(Degradation::VectorOnly)\n}\n\n#[test]\nfn both_paths_available_but_empty_is_success_not_an_error() {\n // 两路都可用、都没命中 → Ok,hits 为空,degraded 为 None\n}\n\n#[test]\nfn both_paths_failing_reports_unavailable_rather_than_an_empty_result() {\n // fake embedding 返回 Err 且 fake 仓储的 keyword_candidates 也返回 Err\n // → Err(RetrievalError::Unavailable),**不是** Ok(空列表)。\n // 这一条与上一条成对存在:区分\"搜不了\"和\"没有\"正是它们的全部意义。\n assert_eq!(service.search(&query).unwrap_err(), RetrievalError::Unavailable);\n}\n\n#[test]\nfn results_are_truncated_to_the_requested_limit() { }\n\n#[test]\nfn a_hit_whose_source_row_is_gone_is_skipped_rather_than_returned_stale() {\n // 候选里有 m1、m2,但源快照只有 m1 → 结果只含 m1\n}\n\n#[test]\nfn an_unconfigured_service_reports_not_configured() {\n assert_eq!(service.search(&query).unwrap_err(), RetrievalError::NotConfigured);\n}\n\n#[test]\nfn the_query_is_escaped_before_reaching_fts() {\n // query 文本 `a OR b` → fake 仓储收到的 query 参数是 \"\\\"a OR b\\\"\"\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::application::search_service`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现**\n\n```rust\n/// 双路召回 → RRF 融合 → 回查源表。\n///\n/// 铁律:检索失败**永不**让生成失败(设计文档 §8.1)。除\"未配置\"外,任何一路的失败都只是\n/// 降级,因为把一个可选增强能力的故障冒泡成生成失败是不可接受的。\npub(crate) fn search(&self, query: &RetrievalQuery) -> Result {\n let configuration = self.configuration.load()?;\n let Some((_profile, model)) = configuration.resolved_model() else {\n return Err(RetrievalError::NotConfigured);\n };\n let over_fetch = query.limit.saturating_mul(4).max(query.limit);\n\n let vector_ranking = self.vector_ranking(query, model, over_fetch);\n let keyword_ranking = self\n .repository\n .keyword_candidates(\n &query.scope,\n SourceKind::AgentMemory,\n &escape_fts_query(&query.text),\n over_fetch,\n )\n .ok();\n\n // 两路都失败必须与\"两路都可用但都没命中\"区分开。复用已有的 Err 路径而不是新增一种\n // 降级值:Task 13 的 `execute_recall` 已有分支会把 Err 转成**成功的**工具结果\n // \"检索暂时不可用\",所以 §8.1 的铁律仍然成立,且不必新增一套模型要理解的词汇。\n let degraded = match (&vector_ranking, &keyword_ranking) {\n (None, None) => return Err(RetrievalError::Unavailable),\n (None, Some(_)) => Some(Degradation::KeywordOnly),\n (Some(_), None) => Some(Degradation::VectorOnly),\n _ => None,\n };\n let vector_ids = vector_ranking.unwrap_or_default();\n let keyword_ids = keyword_ranking.unwrap_or_default();\n\n let fused = fuse_with_rrf(&[vector_ids.clone(), keyword_ids.clone()]);\n let in_vector: HashSet<&str> = vector_ids.iter().map(String::as_str).collect();\n let in_keyword: HashSet<&str> = keyword_ids.iter().map(String::as_str).collect();\n\n // 回查源表拿权威内容:索引行可能陈旧,源已删则跳过——这保证已删记忆永不外泄,\n // 也是显式撤销失败时的第一道兜底(§5.3)。\n let sources: HashMap = self\n .source\n .snapshot()?\n .into_iter()\n .map(|record| (record.source_id.clone(), record))\n .collect();\n\n let hits = fused\n .into_iter()\n .filter_map(|(source_id, score)| {\n let record = sources.get(&source_id)?;\n let matched_via = match (\n in_vector.contains(source_id.as_str()),\n in_keyword.contains(source_id.as_str()),\n ) {\n (true, true) => MatchedVia::Both,\n (true, false) => MatchedVia::Vector,\n _ => MatchedVia::Keyword,\n };\n Some(ScoredHit {\n source_id,\n content: record.content.clone(),\n created_at: record.created_at.clone(),\n score,\n matched_via,\n })\n })\n .take(query.limit)\n .collect();\n\n Ok(SearchOutcome { hits, degraded })\n}\n\n/// `None` 表示这一路整体不可用(query embedding 失败或候选查询失败),交由调用方降级;\n/// 空 `Vec` 表示这一路可用但没有命中,是正常结果。\nfn vector_ranking(&self, query: &RetrievalQuery, model: &str, limit: usize) -> Option> {\n // `std::slice::from_ref` 而非 `&[query.text.clone()]`:后者会触发 clippy 的\n // `cloned_ref_to_slice_refs`,而本仓库 CI 跑的是 `-D warnings`。\n let embedded = self\n .embeddings\n .embed(model, std::slice::from_ref(&query.text))\n .ok()?;\n let query_vector = embedded.into_iter().next()?;\n let candidates = self\n .repository\n .vector_candidates(&query.scope, SourceKind::AgentMemory, model)\n .ok()?;\n let mut scored: Vec<(String, f32)> = candidates\n .into_iter()\n .filter_map(|(source_id, vector)| {\n cosine_similarity(&query_vector, &vector).map(|score| (source_id, score))\n })\n .collect();\n scored.sort_by(|left, right| {\n right\n .1\n .partial_cmp(&left.1)\n .unwrap_or(std::cmp::Ordering::Equal)\n .then_with(|| left.0.cmp(&right.0))\n });\n Some(scored.into_iter().take(limit).map(|(id, _)| id).collect())\n}\n```\n\n日志:检索执行记 `debug`,字段为 scope 哈希、候选集大小、两路命中数、耗时;降级记 `warn`,只记降级原因类别。**query 只记长度与哈希,绝不记原文。**\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::application::search_service`\nExpected: PASS,11 个测试(Step 1 列了 11 条——\"两路都失败\"那条是降级裁决定案后补的)。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval\ngit commit -m \"feat(retrieval): hybrid search with RRF fusion and graceful degradation\"\n```\n\n---\n\n### Task 10: openai-compatible embedding 适配器\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/infrastructure/openai_embedding_adapter.rs`\n- Modify: `src-tauri/src/contexts/retrieval/infrastructure/mod.rs`\n\n**Interfaces:**\n- Consumes: Task 8 的 `EmbeddingPort`/`EmbeddingFailure`\n- Produces: `HttpEmbeddingAdapter`,以及 `application/ports.rs` 中新增的消费侧契约 `EmbeddingEndpointPort`(设计文档 §4.3 明确它属于 `retrieval::application::ports`,**不要**放进 infrastructure;其实现由 Task 12 的 bootstrap 适配器提供)\n\n```rust\n#[derive(Debug, Clone)]\npub(crate) struct ResolvedEmbeddingEndpoint {\n pub(crate) base_url: String,\n pub(crate) credential: String,\n}\n\npub(crate) trait EmbeddingEndpointPort: Send + Sync {\n fn resolve(&self, profile_id: &str) -> Result;\n}\n```\n\n**惯例对齐:** HTTP 走 `crate::platform::network`(`onepiece_model_discovery.rs:7-13` 已经这么用),复用代理与超时设置;`platform/network` 只保留共享的代理与凭据探测,不承载 embedding 协议细节。响应体大小上限照 `onepiece_model_discovery.rs:20` 的 `MAX_RESPONSE_BYTES`。\n\n- [ ] **Step 1: 写失败的测试**\n\n失败分类是纯映射,单独抽函数就能不起 HTTP 服务器地测:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn http_status_maps_to_its_failure_category() {\n assert_eq!(category_for_status(401), FailureCategory::Auth);\n assert_eq!(category_for_status(403), FailureCategory::Auth);\n assert_eq!(category_for_status(400), FailureCategory::InvalidRequest);\n assert_eq!(category_for_status(404), FailureCategory::InvalidRequest);\n assert_eq!(category_for_status(429), FailureCategory::RateLimit);\n assert_eq!(category_for_status(500), FailureCategory::Network);\n assert_eq!(category_for_status(503), FailureCategory::Network);\n }\n\n #[test]\n fn a_non_https_endpoint_is_rejected_before_any_request_is_made() {\n // 照 onepiece_model_discovery.rs:44-46 的既有约束\n }\n\n #[test]\n fn the_response_envelope_is_parsed_in_index_order_not_arrival_order() {\n let body = r#\"{\"data\":[{\"index\":1,\"embedding\":[0.5]},{\"index\":0,\"embedding\":[0.25]}]}\"#;\n let vectors = parse_embedding_response(body).expect(\"parse\");\n assert_eq!(vectors, vec![vec![0.25], vec![0.5]]);\n }\n\n #[test]\n fn a_malformed_response_is_an_invalid_request_failure_not_a_panic() {\n assert!(parse_embedding_response(\"not json\").is_err());\n }\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::openai_embedding_adapter`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现**\n\n请求体:`POST {base_url}/embeddings`,`{\"model\": model, \"input\": inputs}`,头部 `Authorization: Bearer `、`Accept: application/json`。\n\n```rust\nfn category_for_status(status: u16) -> FailureCategory {\n match status {\n 401 | 403 => FailureCategory::Auth,\n 429 => FailureCategory::RateLimit,\n 400..=499 => FailureCategory::InvalidRequest,\n _ => FailureCategory::Network,\n }\n}\n\n/// provider 不保证 `data` 按请求顺序返回,必须按每项自带的 `index` 重排——\n/// 否则向量会被错配到别的文档上,安静地污染检索结果。\nfn parse_embedding_response(body: &str) -> Result>, EmbeddingFailure> {\n let envelope: EmbeddingEnvelope = serde_json::from_str(body).map_err(|error| EmbeddingFailure {\n category: FailureCategory::InvalidRequest,\n // 只带行列位置,**不带 `{error}`**:serde_json 的 Display 会把出错处的原值回显出来\n // (例如 `invalid type: string \"oops\"`),那等于把 provider 响应体的片段塞进诊断信息。\n message: format!(\n \"malformed embedding response at line {} column {}\",\n error.line(),\n error.column()\n ),\n })?;\n let mut entries = envelope.data;\n entries.sort_by_key(|entry| entry.index);\n Ok(entries.into_iter().map(|entry| entry.embedding).collect())\n}\n\n#[derive(serde::Deserialize)]\nstruct EmbeddingEnvelope {\n data: Vec,\n}\n\n#[derive(serde::Deserialize)]\nstruct EmbeddingEntry {\n #[serde(default)]\n index: usize,\n embedding: Vec,\n}\n```\n\n超时、reqwest 错误 → `FailureCategory::Network`。\n\n`EmbeddingEndpointPort::resolve` 失败 → `FailureCategory::InvalidRequest`(**不是** `Network`)。resolve 是本地 profile/凭据查找,不发网络请求;profile 被删或凭据被撕都是确定性错误,重试 5 次跨 25 分钟只是把问题拖晚暴露。归为不可重试后立即标 `failed`,配置页的失败计数马上可见,用户改完配置点\"重建索引\"即可恢复——这与 `document.rs:56` 写明的重试哲学一致。\n\n**凭据只在进程内传给适配器,不写日志、不进错误消息。**\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::infrastructure::openai_embedding_adapter`\nExpected: PASS,4 个测试。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval\ngit commit -m \"feat(retrieval): add openai-compatible embedding adapter\"\n```\n\n---\n\n### Task 11: `agent_runtime` 侧的两条跨上下文契约\n\n**Files:**\n- Modify: `src-tauri/src/contexts/agent_runtime/application/service.rs:71-87`\n- Create: `src-tauri/src/contexts/agent_runtime/application/model_category.rs`\n- Modify: `src-tauri/src/contexts/agent_runtime/api.rs`\n- Modify: `src-tauri/src/contexts/agent_runtime/application/mod.rs`\n\n**Interfaces:**\n- Produces(`agent_runtime::api` 新增两个方法):\n\n```rust\npub(crate) fn resolve_embedding_endpoint(&self, profile_id: &str)\n -> Result;\npub(crate) fn list_embedding_models(&self, profile_id: &str, transient_credential: Option<&str>)\n -> Result, AgentRuntimeApplicationError>;\n\npub(crate) struct EmbeddingEndpointView {\n pub(crate) base_url: String,\n pub(crate) interface_format: String,\n pub(crate) credential: String,\n}\n```\n\n**核实过的现状:** `is_chat_model`(`service.rs:71-87`)是一个排除关键词表(`embedding`/`rerank`/`whisper`/`tts`/`audio`/`image`/`moderation`/`realtime`/`sora`/`stable-diffusion`)。embedding 过滤器必须与它**由同一份判定派生**,否则两处各自维护关键词表必然漂移。\n\n- [ ] **Step 1: 写失败的测试**\n\n`model_category.rs` 末尾:\n\n```rust\n#[cfg(test)]\nmod tests {\n use super::*;\n\n #[test]\n fn chat_and_embedding_classifications_are_mutually_exclusive() {\n // `embed-english-v3.0` 是这条测试的关键样本:它命中 EMBEDDING_KEYWORDS 的 `embed-`,\n // 而 NON_CHAT_KEYWORDS 若只有 `embedding` 就漏掉它,于是两个分类同时为真。\n for id in [\n \"gpt-4o\",\n \"text-embedding-3-small\",\n \"embed-english-v3.0\",\n \"bge-reranker\",\n \"whisper-1\",\n ] {\n assert!(\n !(is_chat_model(id) && is_embedding_model(id)),\n \"{id} classified as both\"\n );\n }\n }\n\n #[test]\n fn embedding_models_are_recognized_case_insensitively() {\n assert!(is_embedding_model(\"text-embedding-3-small\"));\n assert!(is_embedding_model(\"TEXT-EMBEDDING-ADA-002\"));\n assert!(is_embedding_model(\"bge-m3-embedding\"));\n }\n\n #[test]\n fn chat_models_are_not_embedding_models() {\n for id in [\"gpt-4o\", \"deepseek-chat\", \"claude-opus-4-8\"] {\n assert!(!is_embedding_model(id), \"{id}\");\n assert!(is_chat_model(id), \"{id}\");\n }\n }\n\n #[test]\n fn non_chat_non_embedding_models_belong_to_neither() {\n for id in [\"whisper-1\", \"dall-e-3\", \"bge-reranker-v2\"] {\n assert!(!is_chat_model(id), \"{id}\");\n assert!(!is_embedding_model(id), \"{id}\");\n }\n }\n}\n```\n\n第四个测试是关键:它锁死\"embedding 过滤器不是 `is_chat_model` 的简单取反\"——rerank/whisper 两者都不属于。\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml agent_runtime::application::model_category`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现并把 `service.rs` 改为复用**\n\n```rust\n//! 模型类别判定的唯一真源。chat 与 embedding 两个过滤器必须从这里派生——\n//! 两处各自维护关键词表,迟早会在新增模型时漂移成互相矛盾的判断。\n\nconst NON_CHAT_KEYWORDS: &[&str] = &[\n // `embed-` 是本次新增的一项(其余沿用 service.rs:71-87 的原表)。少了它,Cohere 的真实 id\n // `embed-english-v3.0` 会同时满足 is_chat_model 与 is_embedding_model,在配置页的聊天模型\n // 选择器和 embedding 选择器里各出现一次——这个双重列出是本特性引入 embedding 选择器才产生的。\n \"embedding\", \"embed-\", \"rerank\", \"whisper\", \"tts\", \"audio\", \"image\", \"moderation\", \"realtime\",\n \"sora\", \"stable-diffusion\",\n];\n\nconst EMBEDDING_KEYWORDS: &[&str] = &[\"embedding\", \"embed-\"];\n\npub(crate) fn is_chat_model(id: &str) -> bool {\n let id = id.to_ascii_lowercase();\n !NON_CHAT_KEYWORDS.iter().any(|excluded| id.contains(excluded))\n}\n\npub(crate) fn is_embedding_model(id: &str) -> bool {\n let id = id.to_ascii_lowercase();\n EMBEDDING_KEYWORDS.iter().any(|keyword| id.contains(keyword))\n}\n```\n\n`service.rs:71-87` 的 `fn is_chat_model` **删除**,改为 `use super::model_category::is_chat_model;`。\n\n`api.rs` 新增两个方法:`resolve_embedding_endpoint` 从已保存 Profile 解析 base_url/interface_format/凭据;`list_embedding_models` 复用现有 model discovery 的 HTTP 与凭据路径(`HttpOnePieceModelDiscoveryAdapter`),但用 `is_embedding_model` 过滤。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml agent_runtime`\nExpected: PASS。既有 model discovery 测试必须全绿——`is_chat_model` 行为不能变。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/agent_runtime\ngit commit -m \"feat(agent-runtime): derive chat and embedding filters from one model category source\"\n```\n\n---\n\n### Task 12: `retrieval::api`、bootstrap 装配与后台 worker\n\n**Files:**\n- Create: `src-tauri/src/contexts/retrieval/api.rs`\n- Create: `src-tauri/src/bootstrap/retrieval.rs`\n- Modify: `src-tauri/src/contexts/retrieval/mod.rs`、`src-tauri/src/bootstrap/mod.rs`\n\n**Interfaces:**\n- Produces:\n\n```rust\nimpl RetrievalApi {\n pub(crate) fn search(&self, agent_id: &str, folder: Option<&str>, query: &str, limit: usize)\n -> Result;\n pub(crate) fn remove(&self, source_kind: SourceKind, source_id: &str) -> Result<(), RetrievalError>;\n pub(crate) fn is_configured(&self) -> bool;\n pub(crate) fn configuration(&self) -> Result;\n pub(crate) fn save_configuration(&self, profile_id: &str, model: &str) -> Result<(), RetrievalError>;\n pub(crate) fn index_status(&self, agent_id: &str) -> Result;\n pub(crate) fn rebuild(&self, agent_id: &str) -> Result<(), RetrievalError>;\n pub(crate) fn wake_worker(&self);\n}\n```\n\n`is_configured()` 必须**永不返回错误也永不阻塞**——它在每次生成的工具集解析路径上被调用(Task 13)。内部读一次配置,失败即视为未配置。\n\n- [ ] **Step 1: 写失败的测试**\n\n`api.rs` 的测试用 fake 仓储装配:\n\n```rust\n#[test]\nfn an_unconfigured_api_reports_not_configured_without_erroring() {\n assert!(!api.is_configured());\n}\n\n#[test]\nfn a_configuration_load_failure_is_treated_as_unconfigured_rather_than_propagated() {\n // fake 配置仓储返回 Err → is_configured() == false,不 panic 不 Err\n}\n\n#[test]\nfn search_scopes_a_folderless_session_to_the_empty_string_sentinel() {\n // folder = None → 传给 SearchService 的 scope.folder == \"\"\n}\n\n#[test]\nfn remove_delegates_to_the_repository_delete() { }\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval::api`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现与 bootstrap**\n\n`bootstrap/retrieval.rs` 负责三件事:\n\n1. 装配 `SqliteRetrievalDocumentRepository` + `SqliteRetrievalConfigurationRepository` + `HttpEmbeddingAdapter` + 记忆源适配器 + `EmbeddingEndpointPort` 适配器(后者调 `agent_runtime::api::resolve_embedding_endpoint`,**这是 `retrieval` 与 `agent_runtime` 唯一的连接点**,写在 bootstrap 里所以两个上下文互不 import 对方的 infrastructure)。\n2. 注册后台 worker 线程,与既有 background jobs 同层。\n3. 暴露 `RetrievalApi` 给 command 层。\n\nworker 循环:\n\n```rust\n// 三种驱动方式:启动时跑一轮(顺带回填历史存量记忆,不需要单独的数据迁移脚本)、\n// 保存记忆后的唤醒信号(不写库、不等待、失败无害)、定时兜底轮询(信号丢失时最多延迟一个周期)。\nloop {\n if let Err(error) = service.reconcile() { /* warn 日志,继续 */ }\n if let Some((_, model)) = configuration.load().ok().and_then(|c| c.resolved_model().map(|(p, m)| (p.to_string(), m.to_string()))) {\n // 串行执行,不并发冲击速率限制。\n //\n // **不能写成 `while ... { if succeeded == 0 && failed == 0 { break } }`。**\n // `record_failure(give_up = false)` 把行留在 `pending`,于是 `failed > 0` 恒成立,\n // 循环会零延迟重新领取同一批、全速轰击 provider。必须在连续失败时按\n // `RETRY_BACKOFF_SECONDS` 递增退避(这也正是那个常量的用途),并在拿到干净批次时归零。\n // 退避期间要能被唤醒信号打断——否则用户改完配置点\"重建索引\"会看上去毫无反应达数分钟。\n drain_pending_batches_with_backoff(&service, &model, &wakeups);\n }\n wait_for_signal_or_timeout(Duration::from_secs(RECONCILE_POLL_INTERVAL_SECONDS));\n}\n```\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml retrieval`\nExpected: PASS,全部 retrieval 测试。\n\nRun: `cargo check --manifest-path src-tauri/Cargo.toml`\nExpected: 退出码 0。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/retrieval src-tauri/src/bootstrap\ngit commit -m \"feat(retrieval): publish api facade and wire the background indexing worker\"\n```\n\n---\n\n### Task 13: `recall` 工具\n\n**Files:**\n- Modify: `src-tauri/src/contexts/agent_runtime/application/tool_catalog.rs`\n- Modify: `src-tauri/src/contexts/agent_runtime/infrastructure/api_process_adapter.rs`\n\n**Interfaces:**\n- Consumes: Task 12 的 `RetrievalApi::{search, is_configured, wake_worker}`\n- Produces: `RECALL_TOOL_NAME`、`recall_tool_definition()`,以及 `agent_runtime` 侧的消费契约(定义在 `agent_runtime/application/ports.rs`,实现由 Task 12 的 bootstrap 装配,从而 `agent_runtime` 不 import `retrieval` 的 infrastructure):\n\n```rust\npub(crate) struct AgentRetrievalHit {\n pub(crate) content: String,\n pub(crate) created_at: String,\n pub(crate) matched_via: String,\n}\n\npub(crate) struct AgentRetrievalOutcome {\n pub(crate) hits: Vec,\n pub(crate) degraded: Option,\n}\n\npub(crate) trait AgentRetrievalPort: Send + Sync {\n /// 每次生成的工具集解析路径上都会调用,因此必须永不阻塞、永不返回错误。\n fn is_configured(&self) -> bool;\n fn search(\n &self,\n agent_id: &str,\n folder: Option<&str>,\n query: &str,\n limit: usize,\n ) -> Result;\n /// 保存记忆后的唤醒信号:不写库、不等待、失败无害。\n fn notify_source_changed(&self);\n}\n```\n\n这层投影 struct 刻意不复用 `retrieval` 的 `ScoredHit`——`source_id` 与 `score` 是内部字段,不该跨上下文边界流出。\n\n**核实过的现状(设计文档 §7.2 已据此更正):** `resolve_tool_catalog()`(`api_process_adapter.rs:729-761`)**已经**是唯一的生产解析点,只在 `api_process_adapter.rs:475` 被调用一次。`anthropic_provider.rs:323` 与 `openai_compatible_provider.rs:309` 两处都在 `#[cfg(test)] mod tests` 内,不是生产路径——**没有\"三处收口\"要做**。\n\n会被打破、必须一并更新的既有断言:`tool_catalog.rs:147`(`catalog.len() == 3`)、`tool_catalog.rs:157`(plan mode `len() == 2`)、`api_process_adapter.rs:2888`(`tools.len() == 259`)、`api_process_adapter.rs:2941`(`tools == plan_mode_tool_catalog()`)。\n\n- [ ] **Step 1: 写失败的测试**\n\n`tool_catalog.rs`:\n\n```rust\n#[test]\nfn the_recall_tool_never_exposes_scope_to_the_model() {\n // scope 若进 schema,模型就能构造参数读别的 agent 或别的项目的记忆。这是安全边界。\n let definition = recall_tool_definition();\n let properties = definition.input_schema[\"properties\"].as_object().expect(\"properties\");\n assert!(properties.contains_key(\"query\"));\n assert!(properties.contains_key(\"limit\"));\n assert_eq!(properties.len(), 2);\n for forbidden in [\"agent_id\", \"agentId\", \"folder\", \"scope\", \"project\"] {\n assert!(!properties.contains_key(forbidden), \"{forbidden} must not be model-supplied\");\n }\n assert_eq!(definition.input_schema[\"required\"], json!([\"query\"]));\n}\n\n#[test]\nfn recall_auto_approves_for_the_same_reason_remember_does() {\n assert_eq!(\n risk_tier_for(RECALL_TOOL_NAME, &json!({\"query\": \"npm\"})),\n ToolRiskTier::AutoApprove\n );\n}\n\n#[test]\nfn the_fixed_catalog_stays_unconditional_and_excludes_recall() {\n // tool_catalog()/plan_mode_tool_catalog() 保持纯函数、不感知配置;\n // 条件性只存在于 resolve_tool_catalog()。\n assert!(tool_catalog().iter().all(|tool| tool.name != RECALL_TOOL_NAME));\n assert!(plan_mode_tool_catalog().iter().all(|tool| tool.name != RECALL_TOOL_NAME));\n}\n```\n\n`api_process_adapter.rs`:\n\n```rust\n#[test]\nfn resolve_tool_catalog_omits_recall_when_retrieval_is_not_configured() { }\n\n#[test]\nfn resolve_tool_catalog_offers_recall_when_retrieval_is_configured() { }\n\n#[test]\nfn plan_mode_offers_recall_when_configured_because_planning_needs_history_most() { }\n\n#[test]\nfn recall_returns_a_successful_result_when_retrieval_fails_so_generation_continues() {\n // fake RetrievalApi 返回 Err → outcome.is_error == false,output 告知模型检索暂时不可用\n}\n\n#[test]\nfn recall_scope_comes_from_the_session_not_from_model_input() {\n // 模型传 {\"query\":\"x\",\"agent_id\":\"other\"} → fake 收到的 agent_id 仍是会话自身的\n}\n\n#[test]\nfn recall_clamps_its_limit_to_the_documented_bounds() {\n // limit 缺省 → 5;limit = 0 → 1;limit = 999 → 20\n}\n\n#[test]\nfn recall_projects_away_internal_fields() {\n // 返回体只含 content / created_at / matched_via,不含 source_id 与 score\n}\n\n#[test]\nfn recall_surfaces_degradation_only_when_degraded() {\n // 正常 → 无 degraded 键;降级 → degraded == \"keyword_only\"\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml agent_runtime`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现**\n\n`tool_catalog.rs`:\n\n```rust\npub(crate) const RECALL_TOOL_NAME: &str = \"recall\";\n\n/// scope(agent id 与工作区文件夹)**刻意不进 schema**:它由运行时从会话上下文注入,模型无法\n/// 指定——否则模型可构造参数读取其他 agent 或其他项目的记忆。这是安全边界,不是省事。\npub(crate) fn recall_tool_definition() -> ToolDefinition {\n ToolDefinition {\n name: RECALL_TOOL_NAME.to_string(),\n description: \"Search your saved memories for this project by meaning, not just keywords. Use when the user refers to something from an earlier session, or when you need context that isn't in the current conversation.\".to_string(),\n input_schema: json!({\n \"type\": \"object\",\n \"properties\": {\n \"query\": {\n \"type\": \"string\",\n \"description\": \"What to look for, in natural language.\"\n },\n \"limit\": {\n \"type\": \"integer\",\n \"description\": \"How many memories to return. Defaults to 5, capped at 20.\"\n }\n },\n \"required\": [\"query\"]\n }),\n }\n}\n```\n\n`risk_tier_for` 增加一条 match 臂,理由与 `remember` 同源(`tool_catalog.rs:120-123`):\n\n```rust\n // 只读本应用自身存储,不触碰用户文件系统,不执行任何外部动作。唯一新增出网面是 query\n // 文本发往 embedding provider,而索引时记忆内容本就已经发出过,不构成新增暴露面。\n RECALL_TOOL_NAME => ToolRiskTier::AutoApprove,\n```\n\n`resolve_tool_catalog` 增加 `retrieval_available: bool` 形参,两个分支都注入:\n\n```rust\n if plan_mode {\n let mut tools = plan_mode_tool_catalog();\n // plan mode 同样提供 recall:只读,且规划阶段最需要历史上下文。\n if retrieval_available {\n tools.push(recall_tool_definition());\n }\n return tools;\n }\n let mut tools = tool_catalog();\n if retrieval_available {\n tools.push(recall_tool_definition());\n }\n```\n\n`execute_tool_call` 在 `REMEMBER_TOOL_NAME` 分支旁增加 `recall`(同样在工作区文件夹门禁**之前**,理由相同:只碰本应用存储):\n\n```rust\n if name == RECALL_TOOL_NAME {\n return execute_recall(input, agent_id, workspace_folder, retrieval);\n }\n```\n\n```rust\n/// 检索失败时**不返回 Err**,而是返回正常的工具结果告知模型\"检索暂时不可用\",让生成继续。\n/// 把可选增强能力的故障冒泡成生成失败是不可接受的(设计文档 §8.1)。\nfn execute_recall(\n input: &Value,\n agent_id: &str,\n workspace_folder: Option<&str>,\n retrieval: &dyn AgentRetrievalPort,\n) -> ToolExecutionOutcome {\n let query = input.get(\"query\").and_then(Value::as_str).unwrap_or_default().trim();\n if query.is_empty() {\n return ToolExecutionOutcome {\n output: \"No query was provided to recall.\".to_string(),\n is_error: true,\n };\n }\n let limit = input\n .get(\"limit\")\n .and_then(Value::as_u64)\n .unwrap_or(5)\n .clamp(1, 20) as usize;\n match retrieval.search(agent_id, workspace_folder, query, limit) {\n Ok(outcome) => ToolExecutionOutcome {\n output: serde_json::to_string(&recall_payload(&outcome)).unwrap_or_else(|_| \"{\\\"results\\\":[]}\".to_string()),\n is_error: false,\n },\n Err(_) => ToolExecutionOutcome {\n output: \"Memory search is temporarily unavailable. Continue without it.\".to_string(),\n is_error: false,\n },\n }\n}\n```\n\n`recall_payload` 只投影 `content` / `created_at` / `matched_via`——`source_id` 与 `score` 是内部字段,对模型没有决策价值,只增加 token 消耗并为幻觉提供素材。`degraded` 仅在降级时出现。\n\n`agent_runtime` 通过一个 `AgentRetrievalPort` trait 消费 `retrieval::api`,实现在 bootstrap 装配(保持 `agent_runtime` 不 import `retrieval` 的 infrastructure)。\n\n- [ ] **Step 4: 更新被打破的既有断言**\n\n- `tool_catalog.rs:147` / `:157`:`tool_catalog()` 与 `plan_mode_tool_catalog()` 本身不变,长度断言保持 3 / 2,另加 Step 1 的第三个新测试。\n- `api_process_adapter.rs:2888`:该用例用 `retrieval_available = false` 调用,`259` 不变;另加一个 `true` 的用例断言 `260` 且末位是 `recall`。\n- `api_process_adapter.rs:2941`:改为 `assert_eq!(tools, plan_mode_tool_catalog())` 仅在 `retrieval_available = false` 时成立,配置时另断言多一个 `recall`。\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml`\nExpected: PASS,全绿。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/contexts/agent_runtime\ngit commit -m \"feat(agent-runtime): add the conditional recall tool with runtime-injected scope\"\n```\n\n---\n\n### Task 14: 记忆变更时的索引挂钩(删除撤销 + 保存唤醒)\n\n设计文档 §5.1 的三种 worker 驱动方式中,\"启动时跑一轮\"与\"定时兜底轮询\"在 Task 12 已实现,第三种\"保存记忆后发唤醒信号\"在这里补齐;§5.3 的删除撤销也在这里。两者都是\"记忆表变更 → 通知索引\"的同一类挂钩,同一个 reviewer 一次看完。\n\n**Files:**\n- Modify: `src-tauri/src/commands/agent_runtime/delete_agent_memory.rs`\n- Modify: `src-tauri/src/contexts/agent_runtime/infrastructure/api_process_adapter.rs`(`execute_remember` 保存成功后发信号)\n- Modify: `src-tauri/src/contexts/agent_runtime/api.rs`\n\n**Interfaces:**\n- Consumes: Task 12 的 `RetrievalApi::{remove, wake_worker}`;Task 13 的 `AgentRetrievalPort::notify_source_changed`\n\n- [ ] **Step 1: 写失败的测试**\n\n```rust\n#[test]\nfn deleting_a_memory_revokes_its_retrieval_index_entry() {\n let retrieval = FakeRetrieval::default();\n\n delete_agent_memory_with(&memories, &retrieval, \"m1\").expect(\"delete\");\n\n assert_eq!(*retrieval.removed.lock().expect(\"lock\"), vec![\"m1\".to_string()]);\n}\n\n#[test]\nfn a_failed_revocation_does_not_fail_the_delete() {\n // 两道兜底让这里可以安全地吞掉错误:检索侧回查源表保证已删记忆不会被返回,\n // reconcile 的孤儿清理最终删掉残留行。\n let retrieval = FakeRetrieval { remove_fails: true, ..FakeRetrieval::default() };\n\n let result = delete_agent_memory_with(&memories, &retrieval, \"m1\");\n\n assert!(result.is_ok());\n}\n\n#[test]\nfn saving_a_memory_wakes_the_indexing_worker() {\n let retrieval = FakeRetrieval::default();\n\n let outcome = execute_remember(\n &json!({\"content\": \"Uses npm.\"}),\n \"test-agent\",\n None,\n &memories,\n &retrieval,\n );\n\n assert!(!outcome.is_error);\n assert_eq!(retrieval.wake_calls.load(Ordering::SeqCst), 1);\n}\n\n#[test]\nfn a_rejected_memory_does_not_wake_the_worker() {\n // 空内容被拒 → 没有新记忆要索引,不该白唤醒一轮\n let retrieval = FakeRetrieval::default();\n\n let outcome = execute_remember(&json!({\"content\": \" \"}), \"test-agent\", None, &memories, &retrieval);\n\n assert!(outcome.is_error);\n assert_eq!(retrieval.wake_calls.load(Ordering::SeqCst), 0);\n}\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml agent_runtime`\nExpected: FAIL。\n\n- [ ] **Step 3: 写实现**\n\n删除侧:删除成功后调用 `retrieval.remove(SourceKind::AgentMemory, memory_id)`,失败只记 `warn` 日志,不改变命令返回值。\n\n保存侧:`execute_remember`(`api_process_adapter.rs:1288-1315`)在 `memories.save(...)` 返回 `Ok` 之后调 `retrieval.notify_source_changed()`。该调用**不写库、不等待、失败无害**——它只是把兜底轮询的最长延迟从一个周期缩短到近实时;信号丢了也只是慢一点,绝不能反过来让保存失败。因此签名返回 `()`,不返回 `Result`。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml agent_runtime`\nExpected: PASS,4 个新测试全绿,既有 `execute_tool_call_routes_remember_*` 测试(`api_process_adapter.rs:2623`、`:2647`)也必须仍然通过。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/commands src-tauri/src/contexts/agent_runtime\ngit commit -m \"feat(agent-runtime): hook memory saves and deletes into retrieval indexing\"\n```\n\n---\n\n### Task 15: Tauri commands 与前端服务边界\n\n**Files:**\n- Create: `src-tauri/src/commands/retrieval/mod.rs` 及 5 个命令文件(每命令一文件,按功能域分组)\n- Modify: `src-tauri/src/lib.rs`(注册命令)\n- Modify: `src/types/agent.ts`\n- Modify: `src/services/agent-service.ts`\n- Modify: `src/services/tauri-agent-client.ts`\n- Modify: `src/services/web-agent-client.ts`\n- Test: `src/services/web-agent-client.test.ts`\n\n**Interfaces:**\n- Produces(设计文档 §7.4,两个 client 必须同时实现):\n\n```ts\ngetRetrievalConfiguration(): Promise;\nsaveRetrievalConfiguration(profileId: string, modelId: string): Promise;\nlistEmbeddingModels(profileId: string, transientCredential?: string): Promise;\ngetRetrievalIndexStatus(agentId: string): Promise;\nrebuildRetrievalIndex(agentId: string): Promise;\n```\n\n配置是全局单例;状态与重建按 agent 聚合该 agent 名下**所有** `scope_folder` 的行。\n\n```ts\nexport interface RetrievalConfiguration {\n sourceProfileId: string | null;\n embeddingModel: string | null;\n}\n\nexport interface RetrievalIndexStatus {\n indexed: number;\n pending: number;\n failed: number;\n lastFailureCategory: string | null;\n}\n\nexport interface EmbeddingModelOption {\n id: string;\n displayName: string;\n}\n```\n\n- [ ] **Step 1: 写失败的测试**\n\n`src/services/web-agent-client.test.ts` 追加:\n\n```ts\nit('returns an unconfigured retrieval configuration by default', async () => {\n const client = createWebAgentClient();\n await expect(client.getRetrievalConfiguration()).resolves.toEqual({\n sourceProfileId: null,\n embeddingModel: null,\n });\n});\n\nit('round-trips a saved retrieval configuration', async () => {\n const client = createWebAgentClient();\n await client.saveRetrievalConfiguration('profile-a', 'text-embedding-3-small');\n await expect(client.getRetrievalConfiguration()).resolves.toEqual({\n sourceProfileId: 'profile-a',\n embeddingModel: 'text-embedding-3-small',\n });\n});\n\nit('reports index status without issuing any network request', async () => {\n const fetchSpy = vi.spyOn(globalThis, 'fetch');\n const client = createWebAgentClient();\n await expect(client.getRetrievalIndexStatus('agent-1')).resolves.toEqual({\n indexed: expect.any(Number),\n pending: expect.any(Number),\n failed: expect.any(Number),\n lastFailureCategory: null,\n });\n expect(fetchSpy).not.toHaveBeenCalled();\n});\n\nit('rebuilding the index resets failures in the mock runtime', async () => {\n const client = createWebAgentClient();\n await client.rebuildRetrievalIndex('agent-1');\n const status = await client.getRetrievalIndexStatus('agent-1');\n expect(status.failed).toBe(0);\n});\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `env -u all_proxy -u ALL_PROXY npm run test -- web-agent-client`\nExpected: FAIL —— `client.getRetrievalConfiguration is not a function`。\n\n- [ ] **Step 3: 写实现**\n\nRust 侧 5 个命令,签名一律 `Result`:`get_retrieval_configuration`、`save_retrieval_configuration`、`list_embedding_models`、`get_retrieval_index_status`、`rebuild_retrieval_index`。在 `lib.rs` 的 `invoke_handler` 中注册。\n\n`agent-service.ts` 接口追加 5 个方法;`tauri-agent-client.ts` 用 `invoke()` 实现;`web-agent-client.ts` 用内存 mock 实现,**不发任何网络请求**。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `env -u all_proxy -u ALL_PROXY npm run test -- web-agent-client`\nExpected: PASS。\n\nRun: `npm run lint`\nExpected: 无 error。\n\nRun: `cargo test --manifest-path src-tauri/Cargo.toml`\nExpected: PASS。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src-tauri/src/commands src-tauri/src/lib.rs src/types src/services\ngit commit -m \"feat(retrieval): expose retrieval configuration across the service boundary\"\n```\n\n---\n\n### Task 16: 配置区块 UI\n\n**Files:**\n- Create: `src/settings/pages/agents/onepiece-retrieval-section.tsx`\n- Create: `src/settings/pages/agents/onepiece-retrieval-section.test.tsx`\n- Modify: `src/settings/pages/agents/onepiece-configuration-panel.tsx`\n\n新建独立文件而非塞进 `onepiece-configuration-panel.tsx`(现 133 行):区块含 Profile 选择、模型发现、状态展示、重建动作四块状态,并进去会逼近 300 行上限。\n\n**Interfaces:**\n- Consumes: Task 15 的 5 个 service 方法\n\n- [ ] **Step 1: 写失败的测试**\n\n```tsx\nit('lists only openai-compatible profiles as embedding sources', () => {\n // Anthropic 没有 embeddings API——列出 anthropic profile 只会让用户配出一个必然失败的组合\n});\n\nit('stays visible but not configurable when no openai-compatible profile exists', () => {\n // 与 onepiece-native-agent spec \"未配置时仍可见且给出可操作状态\"的既有做法一致,不隐藏\n expect(screen.getByText(/需要一个 openai-compatible/)).toBeInTheDocument();\n});\n\nit('renders indexed, pending, and failed counts', () => { });\n\nit('shows only the failure category, never raw error text', () => {\n // 原始错误可能含凭据或 provider 响应内容\n});\n\nit('requeues everything when rebuild is confirmed', async () => {\n // 断言 rebuildRetrievalIndex 被调用,且随后重新拉取状态\n});\n\nit('loads embedding models through the service boundary, never invoke()', async () => {\n // 组件禁止直接调 Tauri invoke()\n});\n```\n\n- [ ] **Step 2: 运行测试确认失败**\n\nRun: `env -u all_proxy -u ALL_PROXY npm run test -- onepiece-retrieval-section`\nExpected: FAIL —— 模块不存在。\n\n- [ ] **Step 3: 写实现**\n\n函数组件 + Hooks,Tailwind 类名,无内联 style,文件保持 300 行以内。四块内容:来源 Profile 选择器(只列 `interfaceFormat === 'openai-compatible'`)、embedding 模型选择器(调 `listEmbeddingModels`)、索引状态(已索引/待索引/失败 + 最近失败类别)、重建索引按钮。\n\n在 `onepiece-configuration-panel.tsx` 中挂载该区块。\n\n- [ ] **Step 4: 运行测试确认通过**\n\nRun: `env -u all_proxy -u ALL_PROXY npm run test -- onepiece-retrieval-section`\nExpected: PASS,6 个测试。\n\nRun: `npm run lint`\nExpected: 无 error。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add src/settings/pages/agents\ngit commit -m \"feat(settings): add the OnePiece retrieval configuration section\"\n```\n\n---\n\n### Task 17: E2E 与全量验收\n\n**Files:**\n- Create: `tests/e2e/onepiece-retrieval.spec.ts`\n\n**注意(本机环境):** Playwright CLI 在本机需要去掉 SOCKS5 代理变量才能跑,所有 `npx playwright` 命令必须加 `env -u all_proxy -u ALL_PROXY` 前缀。\n\n- [ ] **Step 1: 写 E2E**\n\n一条即可(设计文档 §9.6),走 mock adapter,**不打真实 API**:\n\n```ts\ntest('configuring an embedding source moves the index from pending to indexed', async ({ page }) => {\n // 1. 打开 OnePiece 配置页的\"检索\"区块\n // 2. 选择一个 openai-compatible profile 与一个 embedding 模型并保存\n // 3. 触发索引\n // 4. 断言状态由 pending 变为 indexed\n});\n```\n\n- [ ] **Step 2: 运行 E2E**\n\nRun: `env -u all_proxy -u ALL_PROXY npx playwright test tests/e2e/onepiece-retrieval.spec.ts`\nExpected: PASS,1 passed。\n\n- [ ] **Step 3: 全量验收(AGENTS.md 规定,必须全部跑通)**\n\n```bash\nnpm run lint\nnpm run test\nnpm run build\ncargo fmt --manifest-path src-tauri/Cargo.toml --all --check\ncargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings\ncargo test --manifest-path src-tauri/Cargo.toml\ncargo check --manifest-path src-tauri/Cargo.toml\nopenspec validate --specs --strict\n```\n\n前三条与后三条来自 AGENTS.md;中间两条是 AGENTS.md 漏掉但 CI 实际 gate 的(`.github/workflows/ci.yml:212` 与 clippy 步骤),必须一并跑。\n\n每条都必须看到实际输出确认通过——**不要凭\"应该没问题\"下结论**。任何一条失败就停下来修,不要继续。\n\n- [ ] **Step 4: 勾掉 OpenSpec tasks 并记录实现验证结果**\n\n更新 `openspec/changes/add-retrieval-vector-search/tasks.md` 的全部勾选框,并在其中记录 Step 3 六条命令的实际结果。\n\n- [ ] **Step 5: 提交**\n\n```bash\ngit add tests/e2e openspec/changes/add-retrieval-vector-search\ngit commit -m \"test(retrieval): add end-to-end coverage and record verification results\"\n```\n\n---\n\n## 附:本计划相对设计文档的两处更正\n\n实现时以本计划为准,两处已同步回设计文档:\n\n1. **§7.2 \"工具集解析收口\"的前提是误读。** `resolve_tool_catalog()`(`api_process_adapter.rs:729-761`)已经是唯一的生产解析点。设计文档原先列举的另两处 `anthropic_provider.rs:323`、`openai_compatible_provider.rs:309` 都在 `#[cfg(test)] mod tests` 内。没有\"三处收口\"的既有隐患要修,只需在既有单一函数里注入条件性 `recall`(Task 13)。\n\n2. **§6 的 FTS 转义没有现成 helper 可复用。** 仓库里唯一的 FTS 消费方 `contexts/workspaces/infrastructure/output_search.rs:36-47` 把原始串直接塞进 `MATCH ?1`,只挡空串与超长。`recall` 的 query 由模型自由生成,必须自己实现转义(Task 3)。\n"}}}}