fix(migration): resolve workspace files from agents.defaults.workspace
OpenClaw users who started before the rebrand (when the project was clawd/clawdbot) often have a custom workspace directory configured via agents.defaults.workspace in openclaw.json (e.g. ~/clawd/ instead of ~/.openclaw/workspace/). The migration tool only checked hardcoded relative paths (workspace/, workspace-main/, workspace-assistant/) inside the source root, so files like MEMORY.md, skills, and daily memory in custom workspaces were silently skipped. This change: - Reads agents.defaults.workspace from openclaw.json at init time - Uses it as a final fallback in source_candidate() when files aren't found in the standard locations - Standard workspace paths are still preferred (custom is fallback only) - Custom workspace is only used when it's outside the source_root tree (avoids double-matching when workspace/ is the default) Adds two tests: - Custom workspace files are discovered and migrated - Standard workspace location is preferred over custom
This commit is contained in:
@@ -619,6 +619,25 @@ class Migrator:
|
|||||||
self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None
|
self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None
|
||||||
self.items: List[ItemResult] = []
|
self.items: List[ItemResult] = []
|
||||||
|
|
||||||
|
# Resolve the configured workspace directory from openclaw.json.
|
||||||
|
# Many users (especially those who started before the OpenClaw rebrand)
|
||||||
|
# have a custom workspace path (e.g. ~/clawd/) that differs from the
|
||||||
|
# default ~/.openclaw/workspace/. Reading agents.defaults.workspace
|
||||||
|
# lets source_candidate() find files in the actual workspace.
|
||||||
|
self._custom_workspace: Optional[Path] = None
|
||||||
|
oc_config = self._load_openclaw_config_early()
|
||||||
|
ws = (oc_config.get("agents", {}).get("defaults", {}).get("workspace") or "").strip()
|
||||||
|
if ws:
|
||||||
|
ws_path = Path(ws).expanduser().resolve()
|
||||||
|
# Only use it if it exists and is outside the source_root tree
|
||||||
|
# (otherwise the standard relative-path logic already covers it).
|
||||||
|
if ws_path.is_dir():
|
||||||
|
try:
|
||||||
|
ws_path.relative_to(self.source_root)
|
||||||
|
except ValueError:
|
||||||
|
# ws_path is outside source_root — use it as custom workspace
|
||||||
|
self._custom_workspace = ws_path
|
||||||
|
|
||||||
config = load_yaml_file(self.target_root / "config.yaml")
|
config = load_yaml_file(self.target_root / "config.yaml")
|
||||||
mem_cfg = config.get("memory", {}) if isinstance(config.get("memory"), dict) else {}
|
mem_cfg = config.get("memory", {}) if isinstance(config.get("memory"), dict) else {}
|
||||||
self.memory_limit = int(mem_cfg.get("memory_char_limit", DEFAULT_MEMORY_CHAR_LIMIT))
|
self.memory_limit = int(mem_cfg.get("memory_char_limit", DEFAULT_MEMORY_CHAR_LIMIT))
|
||||||
@@ -632,6 +651,18 @@ class Migrator:
|
|||||||
+ ", ".join(sorted(SKILL_CONFLICT_MODES))
|
+ ", ".join(sorted(SKILL_CONFLICT_MODES))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _load_openclaw_config_early(self) -> Dict[str, Any]:
|
||||||
|
"""Load openclaw.json during __init__ (before migrate() is called)."""
|
||||||
|
for name in ("openclaw.json", "clawdbot.json", "moltbot.json"):
|
||||||
|
config_path = self.source_root / name
|
||||||
|
if config_path.exists():
|
||||||
|
try:
|
||||||
|
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
return {}
|
||||||
|
|
||||||
def is_selected(self, option_id: str) -> bool:
|
def is_selected(self, option_id: str) -> bool:
|
||||||
return option_id in self.selected_options
|
return option_id in self.selected_options
|
||||||
|
|
||||||
@@ -673,6 +704,23 @@ class Migrator:
|
|||||||
alt = self.source_root / "workspace-main" / suffix
|
alt = self.source_root / "workspace-main" / suffix
|
||||||
if alt.exists():
|
if alt.exists():
|
||||||
return alt
|
return alt
|
||||||
|
|
||||||
|
# Final fallback: check the configured workspace directory from
|
||||||
|
# agents.defaults.workspace in openclaw.json. Users who started
|
||||||
|
# before the OpenClaw rebrand (when the project was named clawd /
|
||||||
|
# clawdbot) often have a custom workspace path outside ~/.openclaw/.
|
||||||
|
if self._custom_workspace:
|
||||||
|
for rel in relative_paths:
|
||||||
|
# Strip the leading "workspace/" or "workspace.default/"
|
||||||
|
# prefix to get the bare filename/subpath.
|
||||||
|
for prefix in ("workspace/", "workspace.default/"):
|
||||||
|
if rel.startswith(prefix):
|
||||||
|
suffix = rel[len(prefix):]
|
||||||
|
alt = self._custom_workspace / suffix
|
||||||
|
if alt.exists():
|
||||||
|
return alt
|
||||||
|
break
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def resolve_skill_destination(self, destination: Path) -> Path:
|
def resolve_skill_destination(self, destination: Path) -> Path:
|
||||||
|
|||||||
@@ -280,6 +280,102 @@ def test_migrator_records_preset_in_report(tmp_path: Path):
|
|||||||
assert report["selection"]["skill_conflict_mode"] == "skip"
|
assert report["selection"]["skill_conflict_mode"] == "skip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_candidate_finds_files_in_custom_workspace(tmp_path: Path):
|
||||||
|
"""When agents.defaults.workspace points outside ~/.openclaw, files should
|
||||||
|
be discovered there as a fallback."""
|
||||||
|
mod = load_module()
|
||||||
|
source = tmp_path / ".openclaw"
|
||||||
|
target = tmp_path / ".hermes"
|
||||||
|
custom_ws = tmp_path / "my-custom-workspace"
|
||||||
|
|
||||||
|
target.mkdir()
|
||||||
|
source.mkdir()
|
||||||
|
custom_ws.mkdir()
|
||||||
|
|
||||||
|
# No workspace/ directory inside .openclaw — files live in custom workspace
|
||||||
|
(custom_ws / "MEMORY.md").write_text("# Memory\n\n- custom workspace entry\n", encoding="utf-8")
|
||||||
|
(custom_ws / "SOUL.md").write_text("# Soul\n\nI am me.\n", encoding="utf-8")
|
||||||
|
(custom_ws / "skills" / "my-skill").mkdir(parents=True)
|
||||||
|
(custom_ws / "skills" / "my-skill" / "SKILL.md").write_text(
|
||||||
|
"---\nname: my-skill\ndescription: test\n---\n\nbody\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(custom_ws / "memory").mkdir()
|
||||||
|
(custom_ws / "memory" / "2026-01-01.md").write_text("- daily note\n", encoding="utf-8")
|
||||||
|
|
||||||
|
(source / "openclaw.json").write_text(
|
||||||
|
json.dumps({"agents": {"defaults": {"workspace": str(custom_ws)}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
migrator = mod.Migrator(
|
||||||
|
source_root=source,
|
||||||
|
target_root=target,
|
||||||
|
execute=True,
|
||||||
|
workspace_target=None,
|
||||||
|
overwrite=False,
|
||||||
|
migrate_secrets=False,
|
||||||
|
output_dir=target / "migration-report",
|
||||||
|
selected_options={"soul", "memory", "skills", "daily-memory"},
|
||||||
|
)
|
||||||
|
report = migrator.migrate()
|
||||||
|
|
||||||
|
# SOUL.md should have been found and migrated
|
||||||
|
assert (target / "SOUL.md").exists()
|
||||||
|
|
||||||
|
# MEMORY.md should have been found and migrated
|
||||||
|
assert (target / "memories" / "MEMORY.md").exists()
|
||||||
|
mem_content = (target / "memories" / "MEMORY.md").read_text(encoding="utf-8")
|
||||||
|
assert "custom workspace entry" in mem_content
|
||||||
|
|
||||||
|
# Skills should have been found and migrated
|
||||||
|
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "my-skill" / "SKILL.md"
|
||||||
|
assert imported_skill.exists()
|
||||||
|
|
||||||
|
migrated_kinds = {item["kind"] for item in report["items"] if item["status"] == "migrated"}
|
||||||
|
assert "soul" in migrated_kinds
|
||||||
|
assert "memory" in migrated_kinds
|
||||||
|
assert "skill" in migrated_kinds
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_candidate_prefers_standard_workspace_over_custom(tmp_path: Path):
|
||||||
|
"""When files exist in both ~/.openclaw/workspace/ and the custom workspace,
|
||||||
|
the standard location should win (custom is a fallback only)."""
|
||||||
|
mod = load_module()
|
||||||
|
source = tmp_path / ".openclaw"
|
||||||
|
target = tmp_path / ".hermes"
|
||||||
|
custom_ws = tmp_path / "my-custom-workspace"
|
||||||
|
|
||||||
|
target.mkdir()
|
||||||
|
custom_ws.mkdir()
|
||||||
|
(source / "workspace").mkdir(parents=True)
|
||||||
|
|
||||||
|
# File in both locations
|
||||||
|
(source / "workspace" / "SOUL.md").write_text("# Standard soul\n", encoding="utf-8")
|
||||||
|
(custom_ws / "SOUL.md").write_text("# Custom soul\n", encoding="utf-8")
|
||||||
|
|
||||||
|
(source / "openclaw.json").write_text(
|
||||||
|
json.dumps({"agents": {"defaults": {"workspace": str(custom_ws)}}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
migrator = mod.Migrator(
|
||||||
|
source_root=source,
|
||||||
|
target_root=target,
|
||||||
|
execute=True,
|
||||||
|
workspace_target=None,
|
||||||
|
overwrite=False,
|
||||||
|
migrate_secrets=False,
|
||||||
|
output_dir=target / "migration-report",
|
||||||
|
selected_options={"soul"},
|
||||||
|
)
|
||||||
|
migrator.migrate()
|
||||||
|
|
||||||
|
# Standard workspace location should have been preferred
|
||||||
|
content = (target / "SOUL.md").read_text(encoding="utf-8")
|
||||||
|
assert "Standard soul" in content
|
||||||
|
|
||||||
|
|
||||||
def test_migrator_exports_full_overflow_entries(tmp_path: Path):
|
def test_migrator_exports_full_overflow_entries(tmp_path: Path):
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
source = tmp_path / ".openclaw"
|
source = tmp_path / ".openclaw"
|
||||||
|
|||||||
Reference in New Issue
Block a user