diff --git a/backend/app/db.py b/backend/app/db.py index 290d0b0..c0afdcd 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -58,6 +58,9 @@ def init_db() -> None: scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")} if "summary" not in scols: conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT") + # Migration douce : projet (sous-dossier de travail) de la session. + if "project" not in scols: + conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT") # Table clé/valeur pour la configuration de l'agent. conn.execute( "CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)" @@ -69,19 +72,28 @@ def _now() -> float: # ── Sessions ───────────────────────────────────────────────────────────── -def create_session(title: str, model: str | None) -> dict: +def create_session( + title: str, model: str | None, project: str | None = None +) -> dict: sid = uuid.uuid4().hex now = _now() with _LOCK, _connect() as conn: conn.execute( - "INSERT INTO sessions (id, title, model, created_at, updated_at)" - " VALUES (?, ?, ?, ?, ?)", - (sid, title, model, now, now), + "INSERT INTO sessions (id, title, model, project, created_at, updated_at)" + " VALUES (?, ?, ?, ?, ?, ?)", + (sid, title, model, project, now, now), ) - return {"id": sid, "title": title, "model": model, + return {"id": sid, "title": title, "model": model, "project": project, "created_at": now, "updated_at": now, "message_count": 0} +def set_session_project(sid: str, project: str | None) -> None: + with _LOCK, _connect() as conn: + conn.execute( + "UPDATE sessions SET project = ? WHERE id = ?", (project, sid) + ) + + def list_sessions() -> list[dict]: with _LOCK, _connect() as conn: rows = conn.execute( diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py index c5a834f..eb3064b 100644 --- a/backend/app/routes/chat.py +++ b/backend/app/routes/chat.py @@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel -from .. import agent_config, coder, db, enhance, memory, rag, skills +from .. import agent_config, coder, db, enhance, memory, rag, skills, tools from .. import router as msg_router from ..tools import check_html, _safe_path from ..agent import run_agent @@ -87,7 +87,7 @@ def _session_code_context(history: list[dict]) -> tuple[str, list[str]]: et quelle demande d'origine. Les fichiers touchés servent aussi de cible par défaut pour Aider. """ - root = os.path.abspath(settings.workspace_dir) + root = tools.active_root() files: list[str] = [] for m in history: if m["role"] != "assistant": @@ -121,8 +121,8 @@ def _session_code_context(history: list[dict]) -> tuple[str, list[str]]: def _workspace_listing(limit: int = 40) -> list[str]: - """Chemins relatifs des fichiers du workspace (aperçu compact).""" - root = os.path.abspath(settings.workspace_dir) + """Chemins relatifs des fichiers de la racine active (aperçu compact).""" + root = tools.active_root() out: list[str] = [] for dirpath, dirnames, filenames in os.walk(root): dirnames[:] = [d for d in dirnames if not d.startswith(".")] @@ -145,7 +145,7 @@ def _mentioned_files(text: str) -> list[str]: Transmis au moteur code pour qu'Aider travaille directement sur les bons fichiers au lieu de deviner via la repo map. """ - root = os.path.abspath(settings.workspace_dir) + root = tools.active_root() out: list[str] = [] for raw in _FILE_MENTION.findall(text): rel = raw.replace("\\", "/").lstrip("./") diff --git a/backend/app/routes/sessions.py b/backend/app/routes/sessions.py index ba3d1fe..99fa1f3 100644 --- a/backend/app/routes/sessions.py +++ b/backend/app/routes/sessions.py @@ -4,7 +4,7 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException from pydantic import BaseModel -from .. import db +from .. import db, tools router = APIRouter(prefix="/api/sessions", tags=["sessions"]) @@ -12,10 +12,12 @@ router = APIRouter(prefix="/api/sessions", tags=["sessions"]) class CreateSession(BaseModel): title: str = "Nouvelle session" model: str | None = None + project: str | None = None -class RenameSession(BaseModel): - title: str +class UpdateSession(BaseModel): + title: str | None = None + project: str | None = None # "" = retour à la racine du workspace @router.get("") @@ -25,7 +27,9 @@ async def get_sessions() -> dict: @router.post("") async def post_session(req: CreateSession) -> dict: - return db.create_session(req.title, req.model) + if req.project and not tools.PROJECT_NAME.match(req.project): + raise HTTPException(400, "nom de projet invalide") + return db.create_session(req.title, req.model, req.project or None) @router.get("/{sid}") @@ -37,10 +41,16 @@ async def get_one(sid: str) -> dict: @router.patch("/{sid}") -async def patch_session(sid: str, req: RenameSession) -> dict: +async def patch_session(sid: str, req: UpdateSession) -> dict: if not db.get_session(sid): raise HTTPException(404, "session introuvable") - db.rename_session(sid, req.title) + if req.title is not None: + db.rename_session(sid, req.title) + if req.project is not None: + project = req.project or None + if project and not tools.PROJECT_NAME.match(project): + raise HTTPException(400, "nom de projet invalide") + db.set_session_project(sid, project) return {"ok": True} diff --git a/backend/app/tools.py b/backend/app/tools.py index 6dac043..5bc8c44 100644 --- a/backend/app/tools.py +++ b/backend/app/tools.py @@ -15,6 +15,7 @@ import os import re import shutil import subprocess +from contextvars import ContextVar import httpx @@ -25,8 +26,30 @@ class ToolError(Exception): """Erreur d'exécution d'un outil (message destiné au modèle).""" +# Projet actif pour la requête en cours : re-racine tous les outils sur +# workspace/. None = racine du workspace (comportement historique). +_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None) + +PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$") + + +def set_project(name: str | None) -> None: + """Fixe le projet actif de la requête (None = racine).""" + if name is not None and not PROJECT_NAME.match(name): + raise ToolError(f"nom de projet invalide : {name!r}") + _ACTIVE_PROJECT.set(name) + + +def active_root() -> str: + """Racine effective (workspace ou projet), créée si nécessaire.""" + return _workspace_root() + + def _workspace_root() -> str: root = os.path.abspath(settings.workspace_dir) + project = _ACTIVE_PROJECT.get() + if project: + root = os.path.join(root, project) os.makedirs(root, exist_ok=True) return root diff --git a/backend/tests/test_projects.py b/backend/tests/test_projects.py new file mode 100644 index 0000000..0a1965a --- /dev/null +++ b/backend/tests/test_projects.py @@ -0,0 +1,62 @@ +import os +import tempfile + +os.environ.setdefault("DATA_DIR", tempfile.mkdtemp()) +os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp()) + +import pytest # noqa: E402 + +from app import db, tools # noqa: E402 +from app.config import settings # noqa: E402 + +db.init_db() +_ROOT = os.path.abspath(settings.workspace_dir) + + +@pytest.fixture(autouse=True) +def _reset_project(): + tools.set_project(None) + yield + tools.set_project(None) + + +def test_racine_par_defaut(): + assert tools.active_root() == _ROOT + + +def test_set_project_reracine(): + tools.set_project("demo") + root = tools.active_root() + assert root == os.path.join(_ROOT, "demo") + assert os.path.isdir(root) # créée à la volée + + +def test_confinement_conserve(): + tools.set_project("demo") + with pytest.raises(tools.ToolError): + tools._safe_path("../hors-projet") + + +def test_nom_projet_invalide(): + for bad in ("../x", "UPPER", "a b", "", "x" * 50): + with pytest.raises(tools.ToolError): + tools.set_project(bad) + + +def test_session_porte_son_projet(): + s = db.create_session("t", None, project="demo") + assert db.get_session(s["id"])["project"] == "demo" + db.set_session_project(s["id"], None) + assert db.get_session(s["id"])["project"] is None + + +def test_aides_contexte_suivent_le_projet(): + from app.routes.chat import _mentioned_files, _workspace_listing + tools.set_project("ctxdemo") + root = tools.active_root() + with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f: + f.write("x = 1") + assert "app.py" in _workspace_listing() + assert _mentioned_files("corrige app.py") == ["app.py"] + tools.set_project(None) + assert _mentioned_files("corrige app.py") == [] # absent de la racine