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:
@@ -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
|
||||
# ==========================================
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user