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>
181 lines
7.2 KiB
Python
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)
|