feat(gateway/make): PR6 T6.14 — migrate-paths target + lifespan warning

`app/gateway/app.py:_check_path_migration_pending` runs at lifespan
startup and emits a single WARNING log when `{base_dir}/users/` still
has content — the operator's cue to run the new migration script. The
warning is best-effort (silent on permission errors) so it can never
escalate into a gateway boot failure; reads from the legacy tree keep
working via the `user_id` branch of `Paths.thread_dir` until the
migration runs.

The root Makefile gains a `migrate-paths` target that wraps
`scripts/migrate_paths_to_workspace.py`. Two opt-in env knobs:
- `DRY_RUN=1`           — pass `--dry-run` (preview only, no writes)
- `DEFAULT_WORKSPACE=…`  — claim un-assigned users under this workspace id
   (defaults to `legacy_workspace` to align with PR5's orphan-row bucket).
`help` documents the new target.

3 tests cover the warning's three states (legacy dir with content, dir
missing, dir empty).
This commit is contained in:
1445043649
2026-05-13 17:50:47 +08:00
parent 56f2c8d873
commit c5c66ccbfc
3 changed files with 84 additions and 0 deletions
+9
View File
@@ -31,6 +31,7 @@ help:
@echo " make start-daemon - Start prod services in background (daemon mode)"
@echo " make stop - Stop all running services"
@echo " make clean - Clean up processes and temporary files"
@echo " make migrate-paths - Migrate legacy users/ tree into workspaces/ layout (DRY_RUN=1 to preview)"
@echo ""
@echo "Docker Production Commands:"
@echo " make up - Build and start production Docker services (localhost:2026)"
@@ -144,6 +145,14 @@ clean: stop
@-rm -rf logs/*.log 2>/dev/null || true
@echo "✓ Cleanup complete"
# Lift legacy per-user paths into the per-workspace layout (PR6).
# Pass DRY_RUN=1 to log the migration plan without writing.
# DEFAULT_WORKSPACE=<wid> claims un-assigned users (defaults to legacy_workspace).
migrate-paths:
@cd backend && PYTHONPATH=. uv run python scripts/migrate_paths_to_workspace.py \
$(if $(filter 1 true,$(DRY_RUN)),--dry-run) \
$(if $(DEFAULT_WORKSPACE),--default-workspace $(DEFAULT_WORKSPACE))
# ==========================================
# Docker Development Commands
# ==========================================
+30
View File
@@ -175,6 +175,34 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
return migrated
def _check_path_migration_pending(app: FastAPI) -> None:
"""Warn the operator if the PR4 legacy user-isolation layout still has content.
PR6 routes every new write into ``{base_dir}/workspaces/{wid}/...`` via
``Paths``. Pre-PR6 installations have data at
``{base_dir}/users/{uid}/...`` that needs ``make migrate-paths`` to lift
it under a workspace. We emit a warning at boot rather than crashing so
the gateway keeps serving (reads from the legacy tree still work via the
user_id branch of ``Paths.thread_dir``), but with a loud signal to run
the migration script.
"""
from deerflow.config.paths import get_paths
legacy_users = get_paths().base_dir / "users"
if not legacy_users.exists():
return
try:
has_content = any(legacy_users.iterdir())
except OSError:
# Permission or transient FS issue — don't escalate; lifespan must succeed.
return
if has_content:
logger.warning(
"Legacy per-user layout detected at %s. Run `make migrate-paths` to lift it under the per-workspace layout (PR6).",
legacy_users,
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""Application lifespan handler."""
@@ -191,6 +219,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
config = get_gateway_config()
logger.info(f"Starting API Gateway on {config.host}:{config.port}")
_check_path_migration_pending(app)
# Initialize LangGraph runtime components (StreamBridge, RunManager, checkpointer, store)
async with langgraph_runtime(app):
logger.info("LangGraph runtime initialised")
@@ -0,0 +1,45 @@
"""PR6 T6.14 — lifespan warns when legacy users/ tree still has content."""
from __future__ import annotations
import logging
from pathlib import Path
from unittest.mock import patch
import pytest
from app.gateway.app import _check_path_migration_pending
class _FakePaths:
def __init__(self, base: Path):
self.base_dir = base
@pytest.fixture
def base(tmp_path: Path) -> Path:
return tmp_path
def _patch_paths(base: Path):
return patch("deerflow.config.paths.get_paths", return_value=_FakePaths(base))
def test_warns_when_legacy_users_dir_has_content(base: Path, caplog: pytest.LogCaptureFixture):
(base / "users" / "alice" / "threads" / "t1").mkdir(parents=True)
with _patch_paths(base), caplog.at_level(logging.WARNING, logger="app.gateway.app"):
_check_path_migration_pending(app=None) # type: ignore[arg-type]
assert any("make migrate-paths" in rec.message for rec in caplog.records)
def test_silent_when_legacy_users_dir_missing(base: Path, caplog: pytest.LogCaptureFixture):
with _patch_paths(base), caplog.at_level(logging.WARNING, logger="app.gateway.app"):
_check_path_migration_pending(app=None) # type: ignore[arg-type]
assert not any("migrate-paths" in rec.message for rec in caplog.records)
def test_silent_when_legacy_users_dir_empty(base: Path, caplog: pytest.LogCaptureFixture):
(base / "users").mkdir()
with _patch_paths(base), caplog.at_level(logging.WARNING, logger="app.gateway.app"):
_check_path_migration_pending(app=None) # type: ignore[arg-type]
assert not any("migrate-paths" in rec.message for rec in caplog.records)