feat: add ecc2 legacy migration plan

This commit is contained in:
Affaan Mustafa
2026-04-10 10:54:49 -07:00
parent 0f028f38f6
commit d36e9c48a4

View File

@@ -584,6 +584,18 @@ enum MigrationCommands {
#[arg(long)]
json: bool,
},
/// Generate an actionable ECC2 migration plan from a legacy workspace audit
Plan {
/// Path to the legacy Hermes/OpenClaw workspace root
#[arg(long)]
source: PathBuf,
/// Write the plan to a file instead of stdout
#[arg(long)]
output: Option<PathBuf>,
/// Emit machine-readable JSON instead of the human summary
#[arg(long)]
json: bool,
},
}
#[derive(clap::Subcommand, Debug)]
@@ -914,6 +926,26 @@ struct LegacyMigrationAuditReport {
artifacts: Vec<LegacyMigrationArtifact>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct LegacyMigrationPlanStep {
category: String,
readiness: LegacyMigrationReadiness,
title: String,
target_surface: String,
source_paths: Vec<String>,
command_snippets: Vec<String>,
config_snippets: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct LegacyMigrationPlanReport {
source: String,
generated_at: String,
audit_summary: LegacyMigrationAuditSummary,
steps: Vec<LegacyMigrationPlanStep>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct RemoteDispatchHttpRequest {
task: String,
@@ -1650,6 +1682,25 @@ async fn main() -> Result<()> {
println!("{}", format_legacy_migration_audit_human(&report));
}
}
MigrationCommands::Plan {
source,
output,
json,
} => {
let audit = build_legacy_migration_audit_report(&source)?;
let plan = build_legacy_migration_plan_report(&audit);
let rendered = if json {
serde_json::to_string_pretty(&plan)?
} else {
format_legacy_migration_plan_human(&plan)
};
if let Some(path) = output {
std::fs::write(&path, &rendered)?;
println!("Migration plan written to {}", path.display());
} else {
println!("{rendered}");
}
}
},
Some(Commands::Graph { command }) => match command {
GraphCommands::AddEntity {
@@ -4797,6 +4848,145 @@ fn build_legacy_migration_next_steps(artifacts: &[LegacyMigrationArtifact]) -> V
steps
}
fn build_legacy_migration_plan_report(
audit: &LegacyMigrationAuditReport,
) -> LegacyMigrationPlanReport {
let mut steps = Vec::new();
for artifact in &audit.artifacts {
let step = match artifact.category.as_str() {
"scheduler" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Recreate Hermes/OpenClaw recurring jobs in ECC2 scheduler".to_string(),
target_surface: "ECC2 scheduler".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc schedule add --cron \"<legacy-cron>\" --task \"Translate legacy recurring job from cron/scheduler.py\"".to_string(),
"ecc schedule list".to_string(),
"ecc daemon".to_string(),
],
config_snippets: Vec::new(),
notes: artifact.notes.clone(),
},
"gateway_dispatch" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Replace legacy gateway intake with ECC2 remote dispatch".to_string(),
target_surface: "ECC2 remote dispatch".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc remote serve --bind 127.0.0.1:8787 --token <token>".to_string(),
"ecc remote add --task \"Translate legacy dispatch workflow\"".to_string(),
"ecc remote computer-use --goal \"Translate legacy browser/operator flow\"".to_string(),
],
config_snippets: Vec::new(),
notes: artifact.notes.clone(),
},
"memory_tool" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Port legacy memory tool usage to ECC2 deep memory".to_string(),
target_surface: "ECC2 context graph".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc graph add-observation --entity-id <id> --type migration_note --summary \"Imported legacy memory pattern\"".to_string(),
"ecc graph recall \"<query>\"".to_string(),
"ecc graph connectors".to_string(),
],
config_snippets: Vec::new(),
notes: artifact.notes.clone(),
},
"workspace_memory" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Import sanitized workspace memory through ECC2 connectors".to_string(),
target_surface: "ECC2 memory connectors".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc graph connector-sync hermes_workspace".to_string(),
"ecc graph recall \"<query>\"".to_string(),
],
config_snippets: vec![format!(
"[memory_connectors.hermes_workspace]\nkind = \"markdown_directory\"\npath = \"{}\"\nrecurse = true\ndefault_entity_type = \"legacy_workspace_note\"\ndefault_observation_type = \"legacy_workspace_memory\"",
Path::new(&audit.source).join("workspace").display()
)],
notes: artifact.notes.clone(),
},
"skills" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Translate reusable legacy skills into ECC-native surfaces".to_string(),
target_surface: "ECC skills / orchestration templates".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc template <template-name> --task \"<translated workflow goal>\"".to_string(),
],
config_snippets: vec![
"[orchestration_templates.legacy_workflow]\nproject = \"legacy-migration\"\ntask_group = \"legacy workflow\"\nagent = \"claude\"\nworktree = false\n\n[[orchestration_templates.legacy_workflow.steps]]\nname = \"operator\"\ntask = \"Translate and run the legacy workflow for {{task}}\"".to_string(),
],
notes: artifact.notes.clone(),
},
"tools" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Rebuild valuable legacy tools as ECC agents, hooks, commands, or harness runners".to_string(),
target_surface: "ECC agents / hooks / commands / harness runners".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc start --task \"Rebuild one legacy tool as an ECC-native command or hook\"".to_string(),
],
config_snippets: vec![
"[harness_runners.legacy-runner]\nprogram = \"<runner-binary>\"\nbase_args = []\nproject_markers = [\".legacy-runner\"]".to_string(),
],
notes: artifact.notes.clone(),
},
"plugins" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Translate legacy bridge plugins into ECC-native automation".to_string(),
target_surface: "ECC hooks / commands / skills".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: vec![
"ecc start --task \"Port one bridge plugin behavior into an ECC hook or command\"".to_string(),
],
config_snippets: Vec::new(),
notes: artifact.notes.clone(),
},
"env_services" => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: "Reconfigure local auth and connectors without importing secrets".to_string(),
target_surface: "Claude connectors / MCP / local API key setup".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: Vec::new(),
config_snippets: vec![
"# Re-enter connector auth locally; do not copy legacy secrets into ECC2.\n# Typical targets: Google Drive OAuth, GitHub, Stripe, Linear, browser creds.".to_string(),
],
notes: artifact.notes.clone(),
},
_ => LegacyMigrationPlanStep {
category: artifact.category.clone(),
readiness: artifact.readiness,
title: format!("Review legacy {} surface", artifact.category),
target_surface: "Manual ECC2 translation".to_string(),
source_paths: artifact.source_paths.clone(),
command_snippets: Vec::new(),
config_snippets: Vec::new(),
notes: artifact.notes.clone(),
},
};
steps.push(step);
}
LegacyMigrationPlanReport {
source: audit.source.clone(),
generated_at: chrono::Utc::now().to_rfc3339(),
audit_summary: audit.summary.clone(),
steps,
}
}
fn format_legacy_migration_audit_human(report: &LegacyMigrationAuditReport) -> String {
let mut lines = vec![
format!("Legacy migration audit: {}", report.source),
@@ -4855,6 +5045,53 @@ fn format_legacy_migration_readiness(readiness: LegacyMigrationReadiness) -> &'s
}
}
fn format_legacy_migration_plan_human(report: &LegacyMigrationPlanReport) -> String {
let mut lines = vec![
format!("Legacy migration plan: {}", report.source),
format!("Generated at: {}", report.generated_at),
format!(
"Audit summary: {} categories | ready now {} | manual translation {} | local auth {}",
report.audit_summary.artifact_categories_detected,
report.audit_summary.ready_now_categories,
report.audit_summary.manual_translation_categories,
report.audit_summary.local_auth_required_categories
),
];
if report.steps.is_empty() {
lines.push("No migration steps generated.".to_string());
return lines.join("\n");
}
lines.push(String::new());
lines.push("Plan".to_string());
for step in &report.steps {
lines.push(format!(
"- {} [{}] -> {}",
step.title,
format_legacy_migration_readiness(step.readiness),
step.target_surface
));
if !step.source_paths.is_empty() {
lines.push(format!(" sources {}", step.source_paths.join(", ")));
}
for command in &step.command_snippets {
lines.push(format!(" command {}", command));
}
for snippet in &step.config_snippets {
lines.push(" config".to_string());
for line in snippet.lines() {
lines.push(format!(" {}", line));
}
}
for note in &step.notes {
lines.push(format!(" note {}", note));
}
}
lines.join("\n")
}
fn format_graph_recall_human(
entries: &[session::ContextGraphRecallEntry],
session_id: Option<&str>,
@@ -7301,6 +7538,36 @@ mod tests {
}
}
#[test]
fn cli_parses_migrate_plan_command() {
let cli = Cli::try_parse_from([
"ecc",
"migrate",
"plan",
"--source",
"/tmp/hermes",
"--output",
"/tmp/plan.md",
])
.expect("migrate plan should parse");
match cli.command {
Some(Commands::Migrate {
command:
MigrationCommands::Plan {
source,
output,
json,
},
}) => {
assert_eq!(source, PathBuf::from("/tmp/hermes"));
assert_eq!(output, Some(PathBuf::from("/tmp/plan.md")));
assert!(!json);
}
_ => panic!("expected migrate plan subcommand"),
}
}
#[test]
fn legacy_migration_audit_report_maps_detected_artifacts() -> Result<()> {
let tempdir = TestDir::new("legacy-migration-audit")?;
@@ -7368,6 +7635,38 @@ mod tests {
Ok(())
}
#[test]
fn legacy_migration_plan_report_generates_workspace_connector_step() -> Result<()> {
let tempdir = TestDir::new("legacy-migration-plan")?;
let root = tempdir.path();
fs::create_dir_all(root.join("workspace/notes"))?;
fs::write(root.join("config.yaml"), "model: claude\n")?;
fs::write(root.join("workspace/notes/recovery.md"), "# recovery\n")?;
let audit = build_legacy_migration_audit_report(root)?;
let plan = build_legacy_migration_plan_report(&audit);
let workspace_step = plan
.steps
.iter()
.find(|step| step.category == "workspace_memory")
.expect("workspace memory step");
assert_eq!(workspace_step.readiness, LegacyMigrationReadiness::ReadyNow);
assert!(workspace_step
.config_snippets
.iter()
.any(|snippet| snippet.contains("[memory_connectors.hermes_workspace]")));
assert!(workspace_step
.command_snippets
.contains(&"ecc graph connector-sync hermes_workspace".to_string()));
let rendered = format_legacy_migration_plan_human(&plan);
assert!(rendered.contains("Legacy migration plan"));
assert!(rendered.contains("Import sanitized workspace memory through ECC2 connectors"));
Ok(())
}
#[test]
fn format_decisions_human_renders_details() {
let text = format_decisions_human(