From c5c66ccbfc0f3d03444fd84a1b0abb10be88b8c2 Mon Sep 17 00:00:00 2001 From: 1445043649 <> Date: Wed, 13 May 2026 17:50:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway/make):=20PR6=20T6.14=20=E2=80=94?= =?UTF-8?q?=20migrate-paths=20target=20+=20lifespan=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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). --- Makefile | 9 ++++ backend/app/gateway/app.py | 30 +++++++++++++ .../test_path_migration_pending_warning.py | 45 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 backend/tests/test_path_migration_pending_warning.py diff --git a/Makefile b/Makefile index c60d9b9b..743137df 100644 --- a/Makefile +++ b/Makefile @@ -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= 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 # ========================================== diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 985bb96c..1f0684fa 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -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") diff --git a/backend/tests/test_path_migration_pending_warning.py b/backend/tests/test_path_migration_pending_warning.py new file mode 100644 index 00000000..c528792d --- /dev/null +++ b/backend/tests/test_path_migration_pending_warning.py @@ -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)