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>
187 lines
7.5 KiB
Python
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)
|