feat(projets): racine active par contextvar + projet par session

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 4.8 committed 2026-07-19 10:32:37 +02:00
1 parent 25a88dd879
commit 8d0b57ad0d
5 files changed
+123 -16

No files matched your search

+17 -5
View File
@@ -58,6 +58,9 @@ def init_db() -> None:
scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")} scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")}
if "summary" not in scols: if "summary" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT") 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. # Table clé/valeur pour la configuration de l'agent.
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)" "CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
@@ -69,19 +72,28 @@ def _now() -> float:
# ── Sessions ───────────────────────────────────────────────────────────── # ── 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 sid = uuid.uuid4().hex
now = _now() now = _now()
with _LOCK, _connect() as conn: with _LOCK, _connect() as conn:
conn.execute( conn.execute(
"INSERT INTO sessions (id, title, model, created_at, updated_at)" "INSERT INTO sessions (id, title, model, project, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?)", " VALUES (?, ?, ?, ?, ?, ?)",
(sid, title, model, now, now), (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} "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]: def list_sessions() -> list[dict]:
with _LOCK, _connect() as conn: with _LOCK, _connect() as conn:
rows = conn.execute( rows = conn.execute(
+5 -5
View File
@@ -20,7 +20,7 @@ from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pydantic import BaseModel 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 .. import router as msg_router
from ..tools import check_html, _safe_path from ..tools import check_html, _safe_path
from ..agent import run_agent 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 et quelle demande d'origine. Les fichiers touchés servent aussi de cible
par défaut pour Aider. par défaut pour Aider.
""" """
root = os.path.abspath(settings.workspace_dir) root = tools.active_root()
files: list[str] = [] files: list[str] = []
for m in history: for m in history:
if m["role"] != "assistant": 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]: def _workspace_listing(limit: int = 40) -> list[str]:
"""Chemins relatifs des fichiers du workspace (aperçu compact).""" """Chemins relatifs des fichiers de la racine active (aperçu compact)."""
root = os.path.abspath(settings.workspace_dir) root = tools.active_root()
out: list[str] = [] out: list[str] = []
for dirpath, dirnames, filenames in os.walk(root): for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".")] 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 Transmis au moteur code pour qu'Aider travaille directement sur les bons
fichiers au lieu de deviner via la repo map. fichiers au lieu de deviner via la repo map.
""" """
root = os.path.abspath(settings.workspace_dir) root = tools.active_root()
out: list[str] = [] out: list[str] = []
for raw in _FILE_MENTION.findall(text): for raw in _FILE_MENTION.findall(text):
rel = raw.replace("\\", "/").lstrip("./") rel = raw.replace("\\", "/").lstrip("./")
+16 -6
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from .. import db from .. import db, tools
router = APIRouter(prefix="/api/sessions", tags=["sessions"]) router = APIRouter(prefix="/api/sessions", tags=["sessions"])
@@ -12,10 +12,12 @@ router = APIRouter(prefix="/api/sessions", tags=["sessions"])
class CreateSession(BaseModel): class CreateSession(BaseModel):
title: str = "Nouvelle session" title: str = "Nouvelle session"
model: str | None = None model: str | None = None
project: str | None = None
class RenameSession(BaseModel): class UpdateSession(BaseModel):
title: str title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
@router.get("") @router.get("")
@@ -25,7 +27,9 @@ async def get_sessions() -> dict:
@router.post("") @router.post("")
async def post_session(req: CreateSession) -> dict: 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}") @router.get("/{sid}")
@@ -37,10 +41,16 @@ async def get_one(sid: str) -> dict:
@router.patch("/{sid}") @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): if not db.get_session(sid):
raise HTTPException(404, "session introuvable") 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} return {"ok": True}
+23
View File
@@ -15,6 +15,7 @@ import os
import re import re
import shutil import shutil
import subprocess import subprocess
from contextvars import ContextVar
import httpx import httpx
@@ -25,8 +26,30 @@ class ToolError(Exception):
"""Erreur d'exécution d'un outil (message destiné au modèle).""" """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/<projet>. 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: def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir) 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) os.makedirs(root, exist_ok=True)
return root return root
+62
View File
@@ -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