Skip to content

Commit bb748c6

Browse files
committed
fix(migration): Preserve data and recover settings
- Limit session discovery to runtime storage paths so deep workspace content cannot cause all sessions to be skipped. - Use generic directory copying without workspace depth or entry limits; retain hash verification and source isolation. - Normalize the retired auto selector only in the legacy importer. - Share model validation and reference recovery between migration and startup, reporting repairs after target conflicts are resolved. - Preserve unusable models and credentials while disabling them, allowing startup without rewriting the original config file. - Treat identical device identities as duplicates without warnings. - Add regression coverage for deep and large trees, config recovery, source preservation, and device identity deduplication.
1 parent c6bb783 commit bb748c6

16 files changed

Lines changed: 1409 additions & 714 deletions

File tree

‎src/crates/assembly/core/src/service/config/manager.rs‎

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -399,10 +399,14 @@ impl ConfigManager {
399399
return Err(contract_error);
400400
}
401401

402-
let config: GlobalConfig = serde_json::from_value(config_value).map_err(|error| {
402+
let mut config: GlobalConfig = serde_json::from_value(config_value).map_err(|error| {
403403
OpenBitFunError::config(format!("Failed to deserialize config file: {error}"))
404404
})?;
405-
let validation_result = self.providers.validate_config(&config).await?;
405+
let mut diagnostics =
406+
openbitfun_config_contracts::normalization::recover_persisted_config(&mut config);
407+
let validation_result = self
408+
.validate_recovered_config(&mut config, &mut diagnostics)
409+
.await?;
406410
if !validation_result.valid {
407411
return Err(invalid_config_error(
408412
"Invalid configuration file",
@@ -411,11 +415,57 @@ impl ConfigManager {
411415
}
412416

413417
self.config = config;
414-
self.load_diagnostics.clear();
418+
self.load_diagnostics = diagnostics;
415419
debug!("Loaded OpenBitFun config from file without rewriting it");
416420
Ok(())
417421
}
418422

423+
// Catalog/provider-dependent reasoning checks cannot run in the offline
424+
// importer. Preserve unusable model records and surface a diagnostic while
425+
// allowing the remaining configuration to load. Each retry disables at
426+
// least one enabled model; unrelated validation errors remain errors.
427+
async fn validate_recovered_config(
428+
&self,
429+
config: &mut GlobalConfig,
430+
diagnostics: &mut Vec<super::types::ConfigDiagnostic>,
431+
) -> OpenBitFunResult<super::types::ConfigValidationResult> {
432+
loop {
433+
let result = self.providers.validate_config(config).await?;
434+
let mut recovered = false;
435+
for error in &result.errors {
436+
let Some(index) = error
437+
.path
438+
.strip_prefix("ai.models[")
439+
.and_then(|path| path.split_once(']'))
440+
.and_then(|(index, _)| index.parse::<usize>().ok())
441+
else {
442+
continue;
443+
};
444+
let Some(model) = config
445+
.ai
446+
.models
447+
.get_mut(index)
448+
.filter(|model| model.enabled)
449+
else {
450+
continue;
451+
};
452+
model.enabled = false;
453+
diagnostics.push(super::types::ConfigDiagnostic {
454+
path: error.path.clone(),
455+
message: error.message.clone(),
456+
code: "CONFIG_MODEL_DISABLED".into(),
457+
severity: super::types::ConfigDiagnosticSeverity::Warning,
458+
recoverability: super::types::ConfigDiagnosticRecoverability::ModelDisabled,
459+
});
460+
recovered = true;
461+
}
462+
if !recovered {
463+
return Ok(result);
464+
}
465+
diagnostics.extend(reconcile_model_references(config).diagnostics);
466+
}
467+
}
468+
419469
async fn try_repair_sparse_installer_config(
420470
&mut self,
421471
content: &str,
@@ -1034,6 +1084,54 @@ mod tests {
10341084
}
10351085
}
10361086

1087+
#[tokio::test]
1088+
async fn existing_config_recovers_semantics_without_rewriting_source() {
1089+
let temp = tempfile::tempdir().unwrap();
1090+
let path_manager = Arc::new(PathManager::with_user_root_for_tests(
1091+
temp.path().join("recovery"),
1092+
));
1093+
path_manager.initialize_user_directories().await.unwrap();
1094+
let mut config = GlobalConfig::default();
1095+
config.ai.stream_idle_timeout_secs = Some(0);
1096+
config.ai.default_models.primary = Some("missing".into());
1097+
config.ai.agent_model_defaults.subagents.default_selection =
1098+
super::super::types::SubagentModelSelection::fixed("missing-model");
1099+
config.app.logging.level = "invalid".into();
1100+
config.ai.models.push(super::super::types::AIModelConfig {
1101+
id: "broken".into(),
1102+
name: "Broken".into(),
1103+
provider: "openai".into(),
1104+
enabled: true,
1105+
context_window: Some(1),
1106+
api_key: "retained-secret".into(),
1107+
..Default::default()
1108+
});
1109+
let original = serde_json::to_vec(&config).unwrap();
1110+
tokio::fs::write(path_manager.app_config_file(), &original)
1111+
.await
1112+
.unwrap();
1113+
let manager = ConfigManager::new(manager_settings(path_manager.clone()))
1114+
.await
1115+
.unwrap();
1116+
assert!(!manager.config.ai.models[0].enabled);
1117+
assert_eq!(manager.config.ai.models[0].api_key, "retained-secret");
1118+
assert!(
1119+
manager
1120+
.providers
1121+
.validate_config(&manager.config)
1122+
.await
1123+
.unwrap()
1124+
.valid
1125+
);
1126+
assert!(!manager.load_diagnostics.is_empty());
1127+
assert_eq!(
1128+
tokio::fs::read(path_manager.app_config_file())
1129+
.await
1130+
.unwrap(),
1131+
original
1132+
);
1133+
}
1134+
10371135
#[tokio::test]
10381136
async fn sparse_installer_config_is_backed_up_and_repaired() {
10391137
let temp = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)