Files
CC-Switch/src-tauri/src/services/omo.rs
T
YoVinchen fb70f6d4b7 refactor(provider): unify OMO variant updates with atomic file-then-db writes and rollback
Consolidate the duplicated omo/omo-slim update branches into a single
match on the variant. Write the OMO config file from the in-memory
provider state *before* persisting to the database, so a file-write or
plugin-sync failure leaves the database unchanged. If `add_plugin`
fails after the config file is already written, roll back to the
previous on-disk contents via snapshot/restore.

Also:
- `sync_all_providers_to_live` now skips db-only providers
  (`live_config_managed == Some(false)`) instead of attempting to write
  them to live config.
- `import_{opencode,openclaw}_providers_from_live` mark imported
  providers as `live_config_managed: Some(true)` so they are correctly
  recognized during subsequent syncs.
- Extract OmoService helpers: `profile_data_from_provider`,
  `snapshot_config_file`, `restore_config_file`, `write_profile_config`,
  and the new public `write_provider_config_to_file`.
- Add 9 new tests covering sync skip, legacy restore, import marking,
  OMO persistence, file-write failure, and plugin-sync rollback.
2026-03-31 10:13:00 +08:00

508 lines
17 KiB
Rust

use crate::config::{atomic_write, write_json_file};
use crate::error::AppError;
use crate::opencode_config::get_opencode_dir;
use crate::provider::Provider;
use crate::store::AppState;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OmoLocalFileData {
pub agents: Option<Value>,
pub categories: Option<Value>,
pub other_fields: Option<Value>,
pub file_path: String,
pub last_modified: Option<String>,
}
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>);
// ── Variant descriptor ─────────────────────────────────────────
pub struct OmoVariant {
pub filename: &'static str,
pub category: &'static str,
pub provider_prefix: &'static str,
pub plugin_name: &'static str,
pub plugin_prefix: &'static str,
pub has_categories: bool,
pub label: &'static str,
pub import_label: &'static str,
}
pub const STANDARD: OmoVariant = OmoVariant {
filename: "oh-my-opencode.jsonc",
category: "omo",
provider_prefix: "omo-",
plugin_name: "oh-my-opencode@latest",
plugin_prefix: "oh-my-opencode",
has_categories: true,
label: "OMO",
import_label: "Imported",
};
pub const SLIM: OmoVariant = OmoVariant {
filename: "oh-my-opencode-slim.jsonc",
category: "omo-slim",
provider_prefix: "omo-slim-",
plugin_name: "oh-my-opencode-slim@latest",
plugin_prefix: "oh-my-opencode-slim",
has_categories: false,
label: "OMO Slim",
import_label: "Imported Slim",
};
// ── Service ────────────────────────────────────────────────────
pub struct OmoService;
impl OmoService {
// ── Path helpers ────────────────────────────────────────
fn config_path(v: &OmoVariant) -> PathBuf {
get_opencode_dir().join(v.filename)
}
fn resolve_local_config_path(v: &OmoVariant) -> Result<PathBuf, AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
return Ok(config_path);
}
let json_path = config_path.with_extension("json");
if json_path.exists() {
return Ok(json_path);
}
Err(AppError::OmoConfigNotFound)
}
fn read_jsonc_object(path: &Path) -> Result<Map<String, Value>, AppError> {
let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
let cleaned = Self::strip_jsonc_comments(&content);
let parsed: Value = serde_json::from_str(&cleaned)
.map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?;
parsed
.as_object()
.cloned()
.ok_or_else(|| AppError::Config("Expected JSON object".to_string()))
}
// ── Field extraction ───────────────────────────────────
fn extract_other_fields_with_keys(
obj: &Map<String, Value>,
known: &[&str],
) -> Map<String, Value> {
let mut other = Map::new();
for (k, v) in obj {
if !known.contains(&k.as_str()) {
other.insert(k.clone(), v.clone());
}
}
other
}
// ── Merge helpers ──────────────────────────────────────
fn insert_opt_value(result: &mut Map<String, Value>, key: &str, value: &Option<Value>) {
if let Some(v) = value {
result.insert(key.to_string(), v.clone());
}
}
fn insert_object_entries(result: &mut Map<String, Value>, value: Option<&Value>) {
if let Some(Value::Object(map)) = value {
for (k, v) in map {
result.insert(k.clone(), v.clone());
}
}
}
fn profile_data_from_provider(provider: &Provider, v: &OmoVariant) -> OmoProfileData {
let agents = provider.settings_config.get("agents").cloned();
let categories = if v.has_categories {
provider.settings_config.get("categories").cloned()
} else {
None
};
let other_fields = provider.settings_config.get("otherFields").cloned();
(agents, categories, other_fields)
}
fn snapshot_config_file(path: &Path) -> Result<Option<Vec<u8>>, AppError> {
if !path.exists() {
return Ok(None);
}
std::fs::read(path)
.map(Some)
.map_err(|e| AppError::io(path, e))
}
fn restore_config_file(path: &Path, snapshot: Option<&[u8]>) -> Result<(), AppError> {
match snapshot {
Some(bytes) => atomic_write(path, bytes),
None => {
if path.exists() {
std::fs::remove_file(path).map_err(|e| AppError::io(path, e))?;
}
Ok(())
}
}
}
fn write_profile_config(
v: &OmoVariant,
profile_data: Option<&OmoProfileData>,
) -> Result<(), AppError> {
let merged = Self::build_config(v, profile_data);
let config_path = Self::config_path(v);
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
}
let previous_contents = Self::snapshot_config_file(&config_path)?;
write_json_file(&config_path, &merged)?;
if let Err(err) = crate::opencode_config::add_plugin(v.plugin_name) {
if let Err(rollback_err) =
Self::restore_config_file(&config_path, previous_contents.as_deref())
{
log::warn!(
"Failed to roll back {} config after plugin sync error: {}",
v.label,
rollback_err
);
}
return Err(err);
}
log::info!("{} config written to {config_path:?}", v.label);
Ok(())
}
// ── Public API (variant-parameterized) ─────────────────
pub fn delete_config_file(v: &OmoVariant) -> Result<(), AppError> {
let config_path = Self::config_path(v);
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("{} config file deleted: {config_path:?}", v.label);
}
crate::opencode_config::remove_plugin_by_prefix(v.plugin_prefix)?;
Ok(())
}
pub fn write_config_to_file(state: &AppState, v: &OmoVariant) -> Result<(), AppError> {
let current_omo = state.db.get_current_omo_provider("opencode", v.category)?;
let profile_data = current_omo
.as_ref()
.map(|provider| Self::profile_data_from_provider(provider, v));
Self::write_profile_config(v, profile_data.as_ref())
}
pub fn write_provider_config_to_file(
provider: &Provider,
v: &OmoVariant,
) -> Result<(), AppError> {
let profile_data = Self::profile_data_from_provider(provider, v);
Self::write_profile_config(v, Some(&profile_data))
}
fn build_config(v: &OmoVariant, profile_data: Option<&OmoProfileData>) -> Value {
let mut result = Map::new();
if let Some((agents, categories, other_fields)) = profile_data {
Self::insert_object_entries(&mut result, other_fields.as_ref());
Self::insert_opt_value(&mut result, "agents", agents);
if v.has_categories {
Self::insert_opt_value(&mut result, "categories", categories);
}
}
Value::Object(result)
}
pub fn import_from_local(
state: &AppState,
v: &OmoVariant,
) -> Result<crate::provider::Provider, AppError> {
let actual_path = Self::resolve_local_config_path(v)?;
let obj = Self::read_jsonc_object(&actual_path)?;
let mut settings = Map::new();
if let Some(agents) = obj.get("agents") {
settings.insert("agents".to_string(), agents.clone());
}
if v.has_categories {
if let Some(categories) = obj.get("categories") {
settings.insert("categories".to_string(), categories.clone());
}
}
let other = Self::extract_other_fields_with_keys(&obj, &["agents", "categories"]);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let provider_id = format!("{}{}", v.provider_prefix, uuid::Uuid::new_v4());
let name = format!(
"{} {}",
v.import_label,
chrono::Local::now().format("%Y-%m-%d %H:%M")
);
let settings_config =
serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({}));
let provider = crate::provider::Provider {
id: provider_id,
name,
settings_config,
website_url: None,
category: Some(v.category.to_string()),
created_at: Some(chrono::Utc::now().timestamp_millis()),
sort_index: None,
notes: None,
meta: None,
icon: None,
icon_color: None,
in_failover_queue: false,
};
state.db.save_provider("opencode", &provider)?;
state
.db
.set_omo_provider_current("opencode", &provider.id, v.category)?;
Self::write_config_to_file(state, v)?;
Ok(provider)
}
pub fn read_local_file(v: &OmoVariant) -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path(v)?;
let metadata = std::fs::metadata(&actual_path).ok();
let last_modified = metadata
.and_then(|m| m.modified().ok())
.map(|t| chrono::DateTime::<chrono::Utc>::from(t).to_rfc3339());
let obj = Self::read_jsonc_object(&actual_path)?;
Ok(Self::build_local_file_data(
v,
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data(
v: &OmoVariant,
obj: &Map<String, Value>,
file_path: String,
last_modified: Option<String>,
) -> OmoLocalFileData {
let agents = obj.get("agents").cloned();
let categories = if v.has_categories {
obj.get("categories").cloned()
} else {
None
};
let other = Self::extract_other_fields_with_keys(obj, &["agents", "categories"]);
let other_fields = if other.is_empty() {
None
} else {
Some(Value::Object(other))
};
OmoLocalFileData {
agents,
categories,
other_fields,
file_path,
last_modified,
}
}
fn strip_jsonc_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escape = false;
while let Some(&c) = chars.peek() {
if in_string {
result.push(c);
chars.next();
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
in_string = false;
}
} else if c == '"' {
in_string = true;
result.push(c);
chars.next();
} else if c == '/' {
chars.next();
match chars.peek() {
Some('/') => {
chars.next();
while let Some(&nc) = chars.peek() {
if nc == '\n' {
break;
}
chars.next();
}
}
Some('*') => {
chars.next();
while let Some(nc) = chars.next() {
if nc == '*' {
if let Some(&'/') = chars.peek() {
chars.next();
break;
}
}
}
}
_ => {
result.push('/');
}
}
} else {
result.push(c);
chars.next();
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_jsonc_comments() {
let input = r#"{
// This is a comment
"key": "value", // inline comment
/* multi
line */
"key2": "val//ue"
}"#;
let result = OmoService::strip_jsonc_comments(input);
let parsed: Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["key"], "value");
assert_eq!(parsed["key2"], "val//ue");
}
#[test]
fn test_build_config_empty() {
let merged = OmoService::build_config(&STANDARD, None);
assert!(merged.is_object());
assert!(merged.as_object().unwrap().is_empty());
}
#[test]
fn test_build_config_with_profile() {
let agents = Some(serde_json::json!({
"sisyphus": { "model": "claude-opus-4-5" }
}));
let categories = None;
let other_fields = Some(serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["explore"]
}));
let profile_data = (agents, categories, other_fields);
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert_eq!(obj["$schema"], "https://example.com/schema.json");
assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"]));
assert!(obj.contains_key("agents"));
assert_eq!(obj["agents"]["sisyphus"]["model"], "claude-opus-4-5");
}
#[test]
fn test_build_local_file_data_keeps_all_non_agent_category_fields_in_other() {
let obj = serde_json::json!({
"$schema": "https://example.com/schema.json",
"disabled_agents": ["oracle"],
"agents": {
"sisyphus": { "model": "claude-opus-4-6" }
},
"categories": {
"code": { "model": "gpt-5.3" }
},
"custom_top_level": {
"enabled": true
}
});
let obj_map = obj.as_object().unwrap().clone();
let data = OmoService::build_local_file_data(
&STANDARD,
&obj_map,
"/tmp/oh-my-opencode.jsonc".to_string(),
None,
);
// All non-agents/categories fields should be in other_fields
let other = data.other_fields.unwrap();
let other_obj = other.as_object().unwrap();
assert_eq!(
other_obj.get("$schema").unwrap(),
"https://example.com/schema.json"
);
assert_eq!(
other_obj.get("disabled_agents").unwrap(),
&serde_json::json!(["oracle"])
);
assert_eq!(
other_obj.get("custom_top_level").unwrap(),
&serde_json::json!({"enabled": true})
);
// agents and categories should NOT be in other_fields
assert!(!other_obj.contains_key("agents"));
assert!(!other_obj.contains_key("categories"));
}
#[test]
fn test_build_config_ignores_non_object_other_fields() {
let agents = None;
let categories = None;
let other_fields = Some(serde_json::json!("profile_non_object"));
let profile_data = (agents, categories, other_fields);
let merged = OmoService::build_config(&STANDARD, Some(&profile_data));
let obj = merged.as_object().unwrap();
assert!(!obj.contains_key("profile_non_object"));
}
#[test]
fn test_build_config_slim_excludes_categories() {
let agents = Some(serde_json::json!({"orchestrator": {"model": "k2"}}));
let categories = Some(serde_json::json!({"code": {"model": "gpt"}}));
let other_fields = Some(serde_json::json!({
"$schema": "https://slim.schema",
"disabled_agents": ["oracle"]
}));
let profile_data = (agents, categories, other_fields);
let merged = OmoService::build_config(&SLIM, Some(&profile_data));
let obj = merged.as_object().unwrap();
// Slim should NOT include categories
assert!(!obj.contains_key("categories"));
// Slim SHOULD include these
assert_eq!(obj["$schema"], "https://slim.schema");
assert!(obj.contains_key("agents"));
assert!(obj.contains_key("disabled_agents"));
}
}