Files
ai-agent/backend/projects/routes.py
Gabriel Vidal 0ff9e40242 refactor(backend): split main.py into domain packages with app.state injection
main.py (5146 lines, 106 routes) becomes an assembly only: one package per
domain — core, files, settings, dashboards, conversations, diff,
notifications, forms, runs, accounts, workers, models, cron, agents,
projects, services, goals, memories, plans, templates — each exposing an
APIRouter; the flat domain modules move into their package behind a barrel
that keeps the old `import conversations` / `import projects` spellings.

The shared singletons (store, meta_store, hub, indexer, …) are built once by
core.state.build_state() and attached to app.state.ai; routes take them as
the `deps: State` dependency and helpers as an explicit `deps: AppState`.
conversations/pricing.py carries the per-model rates out of the parser.

Verified: route table and OpenAPI byte-identical; 90 read endpoints
golden-diffed against the monolith on a copy of the live data (identical);
write routes smoke-tested; 66 backend tests pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

181 lines
7.2 KiB
Python

"""Projects gallery routes, with per-project cost rollups and the .env editor."""
import pathlib
import threading
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import accounts as accounts_mod
import projects as projects_mod
import schemas
from conversations.cards import (
_account_project_fallback,
_summaries_for,
_summaries_with_account,
)
from core.http import _json_in_worker, _r
from core.state import AppState, State
from projects import costs as project_costs_mod
from projects import envfile as envfile_mod
router = APIRouter()
# ── projects gallery (the repo's projects/ dir) ──────────────────────────────
# Cost rollups walk every summary; memoized on the (summaries, sidecar)
# versions so repeat requests between changes are a dict lookup.
_costs_cache: tuple[tuple, dict] | None = None
_costs_lock = threading.Lock()
def _project_costs(deps: AppState, account: str | None = None) -> dict:
"""``{slug: spend aggregate}`` for one ``?account=`` view, plus each
project's ``byAccount`` split and owning ``account``.
The default (totals) view drops the excluded accounts' conversations —
except on a project an excluded account *owns* (orus-monorepo → work):
that card reports its own account's spend, flagged ``excludeFromTotals``,
so it neither reads $0 nor leaks into the gallery's totals."""
global _costs_cache
key = (deps.store.summaries_version, deps.meta_store.version, account or "")
with _costs_lock:
if _costs_cache and _costs_cache[0] == key:
return _costs_cache[1]
meta_all = deps.meta_store.all()
rows = _summaries_with_account(deps, )
fb = lambda s: _account_project_fallback(deps, s) # noqa: E731
per_acct = {a: project_costs_mod.costs_by_project(
[(p, s) for p, s, acct in rows if acct == a], meta_all, fb)
for a in accounts_mod.ids()}
view = project_costs_mod.costs_by_project(_summaries_for(deps, account), meta_all,
fb)
default_view = (account or "").strip().lower() in ("", "totals")
costs: dict[str, dict] = {}
for slug in set(view) | {sl for c in per_acct.values() for sl in c}:
owner = accounts_mod.account_for_projects([slug]) or accounts_mod.DEFAULT
excluded = not accounts_mod.counts_in_totals(owner)
agg = (per_acct[owner].get(slug) if excluded and default_view
else view.get(slug))
costs[slug] = {
**(agg or project_costs_mod._aggregate([])),
"account": owner,
"excludeFromTotals": excluded,
"byAccount": {a: {"cost": (c.get(slug) or {}).get("cost", 0.0),
"tokens": (c.get(slug) or {}).get("tokens", 0),
"conversations":
(c.get(slug) or {}).get("conversations", 0)}
for a, c in per_acct.items()},
}
with _costs_lock:
_costs_cache = (key, costs)
return costs
@router.get("/api/projects", responses=_r(schemas.ProjectsResponse))
@_json_in_worker
def projects_list(deps: State, account: str | None = None):
"""Card metadata for every directory under projects/ (newest commit first).
Each card carries a compact ``costs`` rollup (total $/tokens + conversation
count) aggregated from every conversation attributed to the project —
within the ``account`` view (unset = the totals; see ``_project_costs``)."""
costs = _project_costs(deps, account)
items = projects_mod.list_projects()
for it in items:
agg = costs.get(it["dir"])
owner = (accounts_mod.account_for_projects([it["dir"]])
or accounts_mod.DEFAULT)
it["account"] = owner
it["costs"] = {
"cost": agg["cost"] if agg else 0.0,
"tokens": agg["tokens"] if agg else 0,
"conversations": agg["conversations"] if agg else 0,
"account": owner,
"excludeFromTotals": not accounts_mod.counts_in_totals(owner),
"byAccount": agg["byAccount"] if agg else None,
}
return {"projects": items}
@router.get("/api/project", responses=_r(schemas.ProjectDetail))
def project_detail(deps: State, slug: str, account: str | None = None):
"""Full metadata + key-file contents (README/CLAUDE.md/index meta/og) for one project.
Also attaches a ``costs`` block: total/mean/median spend across the project's
conversations, a per-token-type breakdown, and mean cost per line of code."""
detail = projects_mod.project_detail(slug)
if detail is None:
raise HTTPException(404, "project not found")
agg = _project_costs(deps, account).get(slug) or project_costs_mod._aggregate([])
detail["account"] = (accounts_mod.account_for_projects([slug])
or accounts_mod.DEFAULT)
loc = projects_mod.count_loc(slug)
agg = {**agg, "loc": loc,
"meanCostPerLoc": (agg["cost"] / loc) if loc else 0.0}
detail["costs"] = agg
return detail
def _project_env_payload(slug: str, entry: pathlib.Path) -> dict:
env_path = entry / ".env"
ef = envfile_mod.ProjectEnvFile.load(str(env_path))
return {
"slug": slug,
"exists": env_path.is_file(),
"vars": [
{
"key": ent["key"],
"value": None if ent["secret"] else ent["value"],
"secret": ent["secret"],
"hasValue": bool(ent["value"]),
}
for ent in ef.entries()
],
}
@router.get("/api/project-env", responses=_r(schemas.ProjectEnvResponse))
def project_env(slug: str):
"""Key/value view of a project's `.env`. Vars below a `# --- secrets ---`
comment are secrets: their keys are listed but values never leave disk."""
entry = projects_mod._safe_entry(slug)
if entry is None:
raise HTTPException(404, "project not found")
return _project_env_payload(slug, entry)
class EnvSetItem(BaseModel):
key: str
# None = keep the value already on disk (used to move a var between
# sections without the UI ever having seen a secret's value).
value: str | None = None
secret: bool = False
class EnvSaveBody(BaseModel):
slug: str
set: list[EnvSetItem] = []
unset: list[str] = []
@router.put("/api/project-env", responses=_r(schemas.ProjectEnvResponse))
def project_env_save(body: EnvSaveBody):
"""Apply key/value edits to a project's `.env`, preserving comments, order
and the secrets section. Unsets run first, then sets."""
entry = projects_mod._safe_entry(body.slug)
if entry is None:
raise HTTPException(404, "project not found")
for item in body.set:
if not envfile_mod.KEY_RE.match(item.key):
raise HTTPException(400, f"invalid env key: {item.key!r}")
ef = envfile_mod.ProjectEnvFile.load(str(entry / ".env"))
for key in body.unset:
ef.unset(key)
current = {e["key"]: e["value"] for e in ef.entries()}
for item in body.set:
value = item.value if item.value is not None else current.get(item.key, "")
ef.set_var(item.key, value, secret=item.secret)
ef.save()
return _project_env_payload(body.slug, entry)