Files
ai-agent/backend/plans/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

125 lines
4.9 KiB
Python

"""Plans routes, with the actuals joined from the linked conversations."""
import datetime
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import forms as forms_mod
import plans as plans_mod
import schemas
from conversations.catalog import _conv_card
from core.http import _r
from core.state import State
router = APIRouter()
# ── plans (data/plans/, written by the `plan` skill — on-machine, not tracked) ─
def _duration_ms(started: str | None, ended: str | None) -> int | None:
if not started or not ended:
return None
try:
a = datetime.datetime.fromisoformat(started.replace("Z", "+00:00"))
b = datetime.datetime.fromisoformat(ended.replace("Z", "+00:00"))
return max(0, int((b - a).total_seconds() * 1000))
except ValueError:
return None
@router.get("/api/plans", responses=_r(schemas.PlansResponse))
def plans_list():
"""All plan summaries (data/plans/*.md), newest-updated first."""
return {"plans": plans_mod.list_plans()}
@router.get("/api/plan", responses=_r(schemas.PlanDetail))
def plan_detail(deps: State, slug: str):
"""One plan + body, with the ACTUAL cost/time/tokens joined from the linked
conversation transcripts (shown next to the plan's own estimate)."""
plan = plans_mod.plan_detail(slug)
if plan is None:
raise HTTPException(404, "plan not found")
# sessionId → newest transcript summary for it.
by_sid: dict[str, tuple[str, dict]] = {}
for path, s in deps.store.all_summaries():
sid = s.get("sessionId")
if not sid or not s.get("messages"):
continue
prev = by_sid.get(sid)
if prev is None or (s.get("endedAt") or "") > (prev[1].get("endedAt") or ""):
by_sid[sid] = (path, s)
convs, cost, tokens, dur = [], 0.0, 0, 0
by_account: dict[str, float] = {}
for sid in plans_mod.linked_session_ids(plan):
hit = by_sid.get(sid)
if not hit:
continue
path, s = hit
card = _conv_card(deps, path, s)
card["startedAt"] = s.get("startedAt")
card["durationMs"] = _duration_ms(s.get("startedAt"), s.get("endedAt"))
convs.append(card)
cost += s.get("cost") or 0.0
tokens += s.get("tokens") or 0
dur += card["durationMs"] or 0
acct = card["meta"]["account"]
by_account[acct] = by_account.get(acct, 0.0) + (s.get("cost") or 0.0)
plan["conversations"] = convs
# byAccount: {account: cost} — a plan implemented on the work seat shows
# its actual as the seat's estimate, not as the owner's spend.
plan["actual"] = {"cost": cost, "tokens": tokens,
"durationMs": dur or None,
"byAccount": by_account} if convs else None
# Persisted answers: the newest submitted form this plan's questions
# produced (POST /api/plan/answers) — list() is newest-first.
for f in deps.form_store.list(limit=500):
if (f.get("type") == "plan" and f.get("status") == "submitted"
and f.get("url") == f"/plans/{plan['slug']}"):
plan["answers"] = f.get("answers")
plan["answersAt"] = f.get("submittedAt")
plan["answersFormId"] = f.get("id")
break
return plan
class PlanAnswersBody(BaseModel):
slug: str
answers: dict
@router.post("/api/plan/answers", responses=_r(schemas.FormRecord))
def plan_answers(deps: State, body: PlanAnswersBody):
"""Persist a plan's question-form answers.
Validates against the plan's ``## Questions`` spec, folds an ``## Answers``
section into the plan file itself (data/plans is the one writable corner of
the data mount), and records a submitted form (type ``plan``, linked to the
planning session) so the answers show on the Forms page and in the store."""
plan = plans_mod.plan_detail(body.slug)
if plan is None:
raise HTTPException(404, "plan not found")
qform = plan.get("questionForm")
if not qform or not qform.get("fields"):
raise HTTPException(400, "plan has no questions")
try:
checked = forms_mod.check_answers(qform["fields"], body.answers)
except ValueError as e:
raise HTTPException(400, str(e))
lines = forms_mod.answer_lines(qform["fields"], checked)
stamp = (datetime.datetime.now(datetime.timezone.utc)
.strftime("%Y-%m-%d %H:%M UTC"))
try:
plans_mod.write_answers(plan["slug"], lines, stamp)
except OSError as e:
raise HTTPException(503, f"plan file not writable: {e}")
form = deps.form_store.create(
f"Plan: {plan['title']}", qform["fields"],
description=qform.get("description") or "",
type_="plan", url=f"/plans/{plan['slug']}",
session_id=plan.get("sessionId") or "")
form = deps.form_store.submit(form["id"], checked)
deps.hub.publish({"type": "form", "id": form["id"],
"sessionId": form.get("sessionId") or ""})
return form