Files
ai-agent/backend/projects/routes.py
Gabriel Vidal 81c455defa feat(users): scope every guest-reachable route to the caller
A guest sees the conversations they started or were shared (list,
detail, summary, search index, subagents, raw meta, diffs), the forms,
asks and pushes of those, and the projects they were granted. Their
ui-state lives under u.<id>.<key>; the SSE hub filters per subscriber.
Forms record answeredBy. Pushes from a guest's session skip the owner's
webhooks and go to the guest's channels (in-app, email).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:27:23 +02:00

187 lines
7.5 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
from users.caller import CallerDep
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, caller: CallerDep, 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()
if not caller.is_owner:
items = [it for it in items if caller.can(it["dir"], "view")]
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, caller: CallerDep, 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."""
if not caller.can(slug, "view"):
raise HTTPException(404, "project not found")
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)