roboticus-cli 0.11.4

CLI commands and migration engine for the Roboticus agent runtime
Documentation
//! Migration transform coordinator.
//!
//! Shared types and helpers live here; each migration area has its own module.

mod transform_agents;
mod transform_channels;
mod transform_config;
mod transform_cron;
mod transform_personality;
mod transform_sessions;
mod transform_skills;

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use super::{AreaResult, MigrationArea, SafetyVerdict, copy_dir_recursive, scan_directory_safety};

// ── Re-exports ───────────────────────────────────────────────────────

pub(crate) use transform_agents::{export_agents, import_agents};
pub(crate) use transform_channels::{export_channels, import_channels};
pub(crate) use transform_config::{export_config, import_config};
pub(crate) use transform_cron::{export_cron, import_cron};
#[allow(unused_imports)] // Used by migrate::mod tests via `transform::titlecase` etc.
pub(crate) use transform_personality::{
    export_personality, import_personality, markdown_to_personality_toml,
    personality_toml_to_markdown, titlecase,
};
pub(crate) use transform_sessions::{export_sessions, import_sessions};
pub(crate) use transform_skills::{export_skills, import_skills};

// ── Legacy data structures ───────────────────────────────────────────

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct LegacyConfig {
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub provider: Option<String>,
    #[serde(default)]
    pub api_key: Option<String>,
    #[serde(default)]
    pub api_url: Option<String>,
    #[serde(default)]
    pub temperature: Option<f64>,
    #[serde(default)]
    pub max_tokens: Option<u32>,
    #[serde(default)]
    pub system_prompt: Option<String>,
    #[serde(default)]
    pub channels: Option<LegacyChannels>,
    #[serde(default, deserialize_with = "deserialize_cron")]
    pub cron: Option<Vec<LegacyCronJob>>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

fn deserialize_cron<'de, D>(deserializer: D) -> Result<Option<Vec<LegacyCronJob>>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
    match value {
        None => Ok(None),
        Some(serde_json::Value::Array(arr)) => {
            let jobs: Vec<LegacyCronJob> =
                serde_json::from_value(serde_json::Value::Array(arr)).unwrap_or_default();
            Ok(Some(jobs))
        }
        Some(serde_json::Value::Object(_)) => Ok(None),
        _ => Ok(None),
    }
}

#[derive(Debug, Deserialize, Serialize, Default)]
pub(crate) struct LegacyChannels {
    #[serde(default)]
    pub telegram: Option<LegacyTelegramChannel>,
    #[serde(default)]
    pub whatsapp: Option<LegacyWhatsappChannel>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct LegacyTelegramChannel {
    #[serde(default, alias = "botToken")]
    pub token: Option<String>,
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct LegacyWhatsappChannel {
    #[serde(default)]
    pub token: Option<String>,
    #[serde(default, alias = "phoneNumberId")]
    pub phone_id: Option<String>,
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct LegacyCronJob {
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub name: Option<String>,
    #[serde(default)]
    pub schedule: Option<serde_json::Value>,
    #[serde(default)]
    pub command: Option<String>,
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default)]
    pub payload: Option<serde_json::Value>,
    #[serde(flatten)]
    pub extra: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize)]
pub(crate) struct LegacyJobsFile {
    #[serde(default)]
    pub jobs: Vec<LegacyCronJob>,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct LegacySession {
    #[serde(default)]
    pub id: Option<String>,
    #[serde(default)]
    pub agent_id: Option<String>,
    #[serde(default)]
    pub created_at: Option<String>,
    #[serde(default)]
    pub messages: Option<Vec<LegacyMessage>>,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct LegacyMessage {
    #[serde(default)]
    pub role: Option<String>,
    #[serde(default)]
    pub content: Option<String>,
    #[serde(default)]
    pub timestamp: Option<String>,
}

#[derive(Debug, Deserialize)]
struct LegacyJSONLLine {
    #[serde(default, rename = "type")]
    line_type: Option<String>,
    #[allow(dead_code)]
    #[serde(default)]
    id: Option<String>,
    #[serde(default)]
    timestamp: Option<String>,
    #[serde(default)]
    message: Option<LegacyJSONLMessage>,
}

#[derive(Debug, Deserialize)]
struct LegacyJSONLMessage {
    #[serde(default)]
    role: Option<String>,
    #[serde(default)]
    content: Option<serde_json::Value>,
    #[serde(default)]
    timestamp: Option<serde_json::Value>,
}

impl LegacyJSONLMessage {
    fn into_message(self, line_ts: Option<&str>) -> Option<LegacyMessage> {
        let role = self.role?;
        let content = match self.content? {
            serde_json::Value::String(s) => s,
            serde_json::Value::Array(arr) => arr
                .iter()
                .filter_map(|v| v.get("text").and_then(|t| t.as_str()))
                .collect::<Vec<_>>()
                .join("\n"),
            _ => return None,
        };
        if content.is_empty() {
            return None;
        }
        let ts = self
            .timestamp
            .and_then(|v| match v {
                serde_json::Value::String(s) => Some(s),
                serde_json::Value::Number(n) => {
                    Some(chrono::DateTime::from_timestamp_millis(n.as_i64()?)?.to_rfc3339())
                }
                _ => None,
            })
            .or_else(|| line_ts.map(String::from));
        Some(LegacyMessage {
            role: Some(role),
            content: Some(content),
            timestamp: ts,
        })
    }
}

// ── Helpers ────────────────────────────────────────────────────────────

pub(crate) fn qt(s: &str) -> String {
    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}

pub(crate) fn qt_ml(s: &str) -> String {
    format!("\"\"\"\n{}\n\"\"\"", s)
}

fn uuid_v4() -> String {
    uuid::Uuid::new_v4().to_string()
}
fn now_iso() -> String {
    chrono::Utc::now().to_rfc3339()
}

fn err(area: MigrationArea, msg: String) -> AreaResult {
    AreaResult {
        area,
        success: false,
        items_processed: 0,
        warnings: vec![],
        error: Some(msg),
    }
}

fn store_in_keystore(key: &str, value: &str, ks_path: &Path) -> Result<(), String> {
    let ks = roboticus_core::keystore::Keystore::new(ks_path.to_path_buf());
    ks.unlock_machine().map_err(|e| e.to_string())?;
    ks.set(key, value).map_err(|e| e.to_string())
}

fn read_from_keystore(key: &str, ks_path: &Path) -> Option<String> {
    let ks = roboticus_core::keystore::Keystore::new(ks_path.to_path_buf());
    if ks.unlock_machine().is_err() {
        return None;
    }
    ks.get(key)
}

fn resolve_channel_token_for_export(
    channel_table: &toml::map::Map<String, toml::Value>,
    obj: &mut serde_json::Map<String, serde_json::Value>,
    warnings: &mut Vec<String>,
    channel_name: &str,
    ks_path: &Path,
) -> bool {
    if let Some(token_ref) = channel_table.get("token_ref").and_then(|v| v.as_str())
        && let Some(ks_name) = token_ref.strip_prefix("keystore:")
    {
        if let Some(val) = read_from_keystore(ks_name, ks_path) {
            obj.insert("token".into(), serde_json::Value::String(val));
            return true;
        }
        warnings.push(format!(
            "Keystore key \"{ks_name}\" not found; {channel_name} token omitted"
        ));
    }
    if let Some(env) = channel_table.get("token_env").and_then(|v| v.as_str()) {
        if let Ok(tok) = std::env::var(env) {
            obj.insert("token".into(), serde_json::Value::String(tok));
            return true;
        }
        warnings.push(format!(
            "Env var {env} not set; {channel_name} token omitted"
        ));
    }
    false
}

// ── Tests ────────────────────────────────────────────────────────────

// Tests remain in this file since they exercise the public API of the
// transform module and reference types/helpers defined here.
#[cfg(test)]
mod tests;