diff --git a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py index 74e9d7dac3..5e0f76db28 100644 --- a/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py +++ b/optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py @@ -1803,30 +1803,34 @@ class Migrator: def migrate_cron_jobs(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() cron = config.get("cron") or {} - if not cron: - self.record("cron-jobs", None, None, "skipped", "No cron configuration found") - return - - # Archive the full cron config - if self.archive_dir and self.execute: - self.archive_dir.mkdir(parents=True, exist_ok=True) - dest = self.archive_dir / "cron-config.json" - dest.write_text(json.dumps(cron, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - self.record("cron-jobs", "openclaw.json cron.*", str(dest), "archived", - "Cron config archived. Use 'hermes cron' to recreate jobs manually.") - else: - self.record("cron-jobs", "openclaw.json cron.*", "archive/cron-config.json", - "archived", "Would archive cron config") - - # Also check for cron store files cron_store = self.source_root / "cron" + found_any = False + + # Archive the full cron config when present + if cron: + found_any = True + if self.archive_dir and self.execute: + self.archive_dir.mkdir(parents=True, exist_ok=True) + dest = self.archive_dir / "cron-config.json" + dest.write_text(json.dumps(cron, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + self.record("cron-jobs", "openclaw.json cron.*", str(dest), "archived", + "Cron config archived. Use 'hermes cron' to recreate jobs manually.") + else: + self.record("cron-jobs", "openclaw.json cron.*", "archive/cron-config.json", + "archived", "Would archive cron config") + + # Also check for cron store files even when config.cron is missing if cron_store.is_dir() and self.archive_dir: + found_any = True dest_cron = self.archive_dir / "cron-store" if self.execute: shutil.copytree(cron_store, dest_cron, dirs_exist_ok=True) self.record("cron-jobs", str(cron_store), str(dest_cron), "archived", "Cron job store archived") + if not found_any: + self.record("cron-jobs", None, None, "skipped", "No cron configuration found") + # ── Hooks ───────────────────────────────────────────────── def migrate_hooks_config(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() @@ -2454,6 +2458,15 @@ class Migrator: notes.append(f"- **{item.kind}**: {item.reason}") notes.append("") + has_cron_config_archive = any( + i.kind == "cron-jobs" and i.status == "archived" and i.destination and i.destination.endswith("cron-config.json") + for i in self.items + ) + has_cron_store_archive = any( + i.kind == "cron-jobs" and i.status == "archived" and i.destination and i.destination.endswith("cron-store") + for i in self.items + ) + notes.extend([ "## IMPORTANT: Archive the OpenClaw Directory", "", @@ -2475,7 +2488,14 @@ class Migrator: "- Run `hermes claw cleanup` to archive the OpenClaw directory (prevents state confusion)", "- Run `hermes setup` to configure any remaining settings", "- Run `hermes mcp list` to verify MCP servers were imported correctly", - "- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)", + ]) + + if has_cron_config_archive: + notes.append("- Run `hermes cron` to recreate scheduled tasks (see archive/cron-config.json)") + elif has_cron_store_archive: + notes.append("- Run `hermes cron` to recreate scheduled tasks (see archived cron-store)") + + notes.extend([ "- Run `hermes gateway install` if you need the gateway service", "- Review `~/.hermes/config.yaml` for any adjustments", "", diff --git a/tests/skills/test_openclaw_migration.py b/tests/skills/test_openclaw_migration.py index d4aa8f710e..99d126bed5 100644 --- a/tests/skills/test_openclaw_migration.py +++ b/tests/skills/test_openclaw_migration.py @@ -658,6 +658,47 @@ def test_workspace_agents_records_skip_when_missing(tmp_path: Path): assert wa_items[0]["status"] == "skipped" +def test_cron_store_is_archived_without_config_cron_section(tmp_path: Path): + """Bug fix: archive cron store even when openclaw.json has no top-level cron config.""" + mod = load_module() + source = tmp_path / ".openclaw" + target = tmp_path / ".hermes" + output_dir = target / "migration-report" + source.mkdir() + target.mkdir() + + (source / "openclaw.json").write_text(json.dumps({"channels": {}}), encoding="utf-8") + (source / "cron").mkdir(parents=True) + (source / "cron" / "jobs.json").write_text( + json.dumps({"version": 1, "jobs": [{"id": "job-1", "name": "demo"}]}), + encoding="utf-8", + ) + + migrator = mod.Migrator( + source_root=source, + target_root=target, + execute=True, + workspace_target=None, + overwrite=False, + migrate_secrets=False, + output_dir=output_dir, + selected_options={"cron-jobs"}, + ) + report = migrator.migrate() + + cron_items = [item for item in report["items"] if item["kind"] == "cron-jobs"] + archived_store = next( + (item for item in cron_items if item["destination"] and item["destination"].endswith("archive/cron-store")), + None, + ) + assert archived_store is not None + assert Path(archived_store["destination"]).joinpath("jobs.json").exists() + + notes_text = (output_dir / "MIGRATION_NOTES.md").read_text(encoding="utf-8") + assert "Run `hermes cron` to recreate scheduled tasks" in notes_text + assert "archive/cron-config.json" not in notes_text + + def test_skill_installs_cleanly_under_skills_guard(): skills_guard = load_skills_guard() result = skills_guard.scan_skill(