56f2c8d873
`scripts/migrate_paths_to_workspace.py` walks the PR4 per-user filesystem
layout and rewrites it under the PR6 per-workspace dimension:
- `{base}/users/{uid}/threads/{tid}/` → `{base}/workspaces/{wid}/threads/{tid}/`
- `{base}/users/{uid}/memory.json` → `{base}/workspaces/{wid}/users/{uid}/memory.json`
- `{base}/users/{uid}/agents/{name}/` → `{base}/workspaces/{wid}/users/{uid}/agents/{name}/`
`{wid}` is read from `users.default_workspace_id` in the local sqlite
DB; users without one fall back to `--default-workspace`, defaulting to
`legacy_workspace` (the same bucket PR5's row backfill uses for orphan
rows so the file-system and database alignments stay consistent).
Pre-existing destinations route the legacy copy to
`{base}/migration-conflicts/workspace-migration/...` for manual review;
empty source dirs are cleaned up. The script is idempotent and supports
`--dry-run` (writes nothing, still logs the full report).
9 tests cover: thread / memory / agent rewrite, dry-run no-write, the
fallback workspace path, conflict diversion, post-migration cleanup of
empty `users/` dirs, and the missing-DB / missing-table degraded
modes.
156 lines
6.6 KiB
Python
156 lines
6.6 KiB
Python
"""PR6 T6.13 — workspace-path migration script tests.
|
|
|
|
Builds the legacy ``users/{uid}/...`` tree under a temp base dir,
|
|
points a populated sqlite ``users`` table at it via the conventional
|
|
``deer-flow.db`` location, and asserts the migration produces the new
|
|
``workspaces/{wid}/...`` layout. Covers:
|
|
|
|
- threads / memory.json / custom agents all rewritten under the workspace
|
|
- ``--dry-run`` writes nothing
|
|
- pre-existing destinations get diverted to ``migration-conflicts/``
|
|
- users without a ``default_workspace_id`` fall back to the explicit flag
|
|
- empty legacy dirs are cleaned up
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.paths import Paths
|
|
from scripts.migrate_paths_to_workspace import (
|
|
LEGACY_WORKSPACE_FALLBACK,
|
|
_load_user_workspaces,
|
|
migrate,
|
|
)
|
|
|
|
|
|
def _build_legacy_tree(base: Path, *, user_id: str, thread_ids: tuple[str, ...] = (), with_memory: bool = False, agent_names: tuple[str, ...] = ()) -> None:
|
|
user_root = base / "users" / user_id
|
|
user_root.mkdir(parents=True, exist_ok=True)
|
|
for tid in thread_ids:
|
|
(user_root / "threads" / tid / "user-data" / "workspace").mkdir(parents=True, exist_ok=True)
|
|
(user_root / "threads" / tid / "user-data" / "workspace" / "marker.txt").write_text(f"{user_id}/{tid}", encoding="utf-8")
|
|
if with_memory:
|
|
(user_root / "memory.json").write_text(f'{{"user_id": "{user_id}"}}', encoding="utf-8")
|
|
for name in agent_names:
|
|
(user_root / "agents" / name).mkdir(parents=True, exist_ok=True)
|
|
(user_root / "agents" / name / "SOUL.md").write_text(f"# {name}", encoding="utf-8")
|
|
|
|
|
|
def _seed_db(base: Path, *, users: dict[str, str | None]) -> None:
|
|
db_path = base / "deer-flow.db"
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
conn.execute("CREATE TABLE users (id TEXT PRIMARY KEY, default_workspace_id TEXT)")
|
|
conn.executemany("INSERT INTO users (id, default_workspace_id) VALUES (?, ?)", list(users.items()))
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def base(tmp_path: Path) -> Path:
|
|
return tmp_path
|
|
|
|
|
|
def test_migrate_threads_under_workspace(base: Path):
|
|
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
|
_seed_db(base, users={"alice": "ws-alpha"})
|
|
paths = Paths(base)
|
|
|
|
report = migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
|
|
|
assert (base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").read_text(encoding="utf-8") == "alice/t1"
|
|
assert not (base / "users" / "alice" / "threads").exists()
|
|
assert {entry["asset"] for entry in report} == {"thread"}
|
|
|
|
|
|
def test_migrate_memory_and_agents_nested_under_workspace_and_user(base: Path):
|
|
_build_legacy_tree(base, user_id="alice", with_memory=True, agent_names=("code-reviewer",))
|
|
_seed_db(base, users={"alice": "ws-alpha"})
|
|
paths = Paths(base)
|
|
|
|
migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
|
|
|
assert (base / "workspaces" / "ws-alpha" / "users" / "alice" / "memory.json").exists()
|
|
assert (base / "workspaces" / "ws-alpha" / "users" / "alice" / "agents" / "code-reviewer" / "SOUL.md").exists()
|
|
|
|
|
|
def test_dry_run_writes_nothing(base: Path):
|
|
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",), with_memory=True, agent_names=("a1",))
|
|
_seed_db(base, users={"alice": "ws-alpha"})
|
|
paths = Paths(base)
|
|
|
|
report = migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=True)
|
|
|
|
# Source unchanged
|
|
assert (base / "users" / "alice" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").exists()
|
|
assert (base / "users" / "alice" / "memory.json").exists()
|
|
assert (base / "users" / "alice" / "agents" / "a1" / "SOUL.md").exists()
|
|
# No destination created
|
|
assert not (base / "workspaces").exists()
|
|
# Report still populated so operator sees what *would* happen
|
|
assert len(report) == 3
|
|
|
|
|
|
def test_fallback_workspace_used_when_user_has_no_default(base: Path):
|
|
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
|
_seed_db(base, users={"alice": None})
|
|
paths = Paths(base)
|
|
|
|
migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace="legacy_workspace", dry_run=False)
|
|
|
|
assert (base / "workspaces" / "legacy_workspace" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").exists()
|
|
|
|
|
|
def test_conflict_routes_legacy_to_migration_conflicts(base: Path):
|
|
# Pre-create the destination with a different marker so the move sees a conflict.
|
|
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
|
(base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace").mkdir(parents=True)
|
|
(base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").write_text("preexisting", encoding="utf-8")
|
|
_seed_db(base, users={"alice": "ws-alpha"})
|
|
paths = Paths(base)
|
|
|
|
report = migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
|
|
|
assert (base / "workspaces" / "ws-alpha" / "threads" / "t1" / "user-data" / "workspace" / "marker.txt").read_text(encoding="utf-8") == "preexisting"
|
|
conflict_marker = base / "migration-conflicts" / "workspace-migration" / "threads/ws-alpha" / "t1"
|
|
assert conflict_marker.exists()
|
|
assert any("conflict" in entry["action"] for entry in report)
|
|
|
|
|
|
def test_empty_users_dir_removed_after_full_migration(base: Path):
|
|
_build_legacy_tree(base, user_id="alice", thread_ids=("t1",))
|
|
_seed_db(base, users={"alice": "ws-alpha"})
|
|
paths = Paths(base)
|
|
|
|
migrate(paths, user_workspaces=_load_user_workspaces(paths), fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
|
|
|
assert not (base / "users").exists(), "Empty legacy users/ dir should be cleaned up"
|
|
|
|
|
|
def test_no_users_directory_is_noop(base: Path):
|
|
paths = Paths(base)
|
|
report = migrate(paths, user_workspaces={}, fallback_workspace=LEGACY_WORKSPACE_FALLBACK, dry_run=False)
|
|
assert report == []
|
|
|
|
|
|
def test_missing_db_returns_empty_mapping(base: Path):
|
|
paths = Paths(base)
|
|
assert _load_user_workspaces(paths) == {}
|
|
|
|
|
|
def test_db_without_users_table_returns_empty(base: Path):
|
|
db_path = base / "deer-flow.db"
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
conn.execute("CREATE TABLE other_table (x INTEGER)")
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
paths = Paths(base)
|
|
assert _load_user_workspaces(paths) == {}
|