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

169 lines
8.0 KiB
Python

"""The goals board and the goal-keeper Work button."""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import projects as projects_mod
import schemas
from conversations.catalog import _conversation_cards
from core.http import _json_in_worker, _r
from core.state import AppState, State
from goals import goalmd
from runs.service import _send_message
from services import catalog as svc_mod
router = APIRouter()
# ── goals board (every GOAL.md in the repo, with who's working on what) ───────
def _goal_activity(deps: AppState) -> dict[tuple[str, str], dict]:
"""Per-unit conversation activity, keyed by ``(kind, dir)``.
``agents`` counts the sessions *currently running* on that unit — the badge
the Goals page shows so you can see, at a glance, that three agents are
already on `brain` and none on `mail`. ``lastAt`` is the newest
conversation's end time; the board sorts on it, so the units you last
worked on come first.
A conversation is attributed exactly as everywhere else in this app: the
union of the transcript-mined ``projectsAuto``/``servicesAuto`` and the
manual sidecar lists (see ``_conv_meta``). One conversation can therefore
count toward several units, which is correct — an agent editing both a
project and its service really is working on both.
"""
out: dict[tuple[str, str], dict] = {}
for card in _conversation_cards(deps, full=False):
m = card.get("meta") or {}
ended = card.get("endedAt") or ""
running = m.get("state") == "running"
for kind, key in (("project", "projects"), ("service", "services")):
for d in (m.get(key) or []):
a = out.setdefault((kind, d),
{"agents": 0, "conversations": 0,
"lastAt": None, "running": []})
a["conversations"] += 1
if not a["lastAt"] or ended > a["lastAt"]:
a["lastAt"] = ended
if running:
a["agents"] += 1
a["running"].append({"id": card["id"],
"title": card.get("title") or "",
"startedAt": card.get("startedAt")})
return out
@router.get("/api/goals", responses=_r(schemas.GoalsResponse))
@_json_in_worker
def goals_list(deps: State):
"""Every project/service that keeps a GOAL.md, as a board.
One card per goal-bearing unit: its checklist progress, the version
milestones off its ``## Horizons``, the themed wishlist groups, the
goal-keeper's in-flight ``## Being worked on`` claims, and how many agent
sessions are running on it right now.
Sorted by most-recent conversation (the unit you last touched is the one
you most likely want to push), with never-worked-on goals last.
"""
activity = _goal_activity(deps, )
goals = []
for kind, resolve, items in (
# The root project lives at the repo root, not under projects/ — let the
# module resolve each slug rather than assuming <root>/<dir>.
("project", projects_mod.project_dir, projects_mod.list_projects()),
("service", lambda d: svc_mod.SERVICES_DIR / d, svc_mod.list_services()),
):
for it in items:
unit = resolve(it["dir"])
board = goalmd.goal_board(unit) if unit else None
if board is None:
continue # no GOAL.md — not on the board
act = activity.get((kind, it["dir"])) or {}
goals.append({
"kind": kind,
"dir": it["dir"],
"name": it["name"],
"description": it.get("description") or "",
"url": (it.get("url") if kind == "project"
else (it.get("urls") or [None])[0]),
"updatedAt": it.get("updatedAt"),
**board,
"agents": act.get("agents", 0),
"runningConversations": act.get("running", []),
"conversations": act.get("conversations", 0),
"lastConversationAt": act.get("lastAt"),
})
goals.sort(key=lambda g: g["lastConversationAt"] or "", reverse=True)
return {"goals": goals}
class GoalWorkBody(BaseModel):
kind: str # "project" | "service"
dir: str # the unit's directory name
item: str | None = None # a specific checklist item to work on (the detail
# page's per-item Work button); None ⇒ let the
# agent pick one (the Goals board's Work button)
@router.post("/api/goal-work", responses=_r(schemas.SpawnResult))
def goal_work(deps: State, body: GoalWorkBody):
"""Spawn a goal-keeper session pointed at one unit's GOAL.md.
The "Work" button on the Goals page (no ``item``) and the per-item Work
button on a detail page's checklist (``item`` set). Same machinery as any
other spawn (`_send_message` → host sidecar), the difference being the
prompt: it invokes the `goal-keeper` agent scoped to a single directory
instead of letting it choose among every GOAL.md in the repo, and it tells
the agent to claim its item in ``## Being worked on`` first — which is what
stops two agents launched from this page landing on the same checkbox. When
``item`` is given the agent is told to work *that* item rather than pick one.
"""
if body.kind not in ("project", "service"):
raise HTTPException(400, "kind must be 'project' or 'service'")
resolve = (projects_mod.project_dir if body.kind == "project"
else svc_mod._safe_entry)
unit = resolve(body.dir)
if unit is None or not (unit / goalmd.GOAL_FILE).is_file():
raise HTTPException(404, "no GOAL.md for that unit")
# `projects/<dir>` / `services/<dir>`, except the root project — whose
# GOAL.md is the repo's own, so it is named `.` (see projects.repo_rel).
rel = (projects_mod.repo_rel(body.dir) if body.kind == "project"
else f"services/{body.dir}")
item = (body.item or "").strip()
if item:
# The per-item Work button: the user already chose the checkbox, so the
# agent works exactly that one instead of surveying the wishlist.
pick = (
f"Use the goal-keeper agent to push `{rel}` forward by completing "
f"this specific item from its GOAL.md:\n\n {item}\n\n"
f"Read `{rel}/GOAL.md` for the full context around it. If that exact "
f"item is already listed under its `## Being worked on` section, "
f"stop — another agent has it — and say so.\n\n"
)
else:
pick = (
f"Use the goal-keeper agent to push `{rel}` forward by one item.\n\n"
f"Read `{rel}/GOAL.md` and pick ONE unchecked wishlist item — the "
f"smallest high-leverage step you can finish end-to-end this "
f"session. Skip anything already listed under its `## Being worked "
f"on` section: another agent has claimed it.\n\n"
)
prompt = (
pick +
f"Before you start, claim your item by appending a bullet to that "
f"`## Being worked on` section (create it after the North star if it "
f"doesn't exist), in the "
f"form `- [{body.dir}] <the item> — @<UTC ISO timestamp>`. Commit that "
f"claim on its own so other agents see it immediately. When the item "
f"lands, tick its checkbox and remove your claim line in the same "
f"commit.\n\n"
f"Then — still before you create a worktree — rename this conversation "
f"to the item you claimed, so its card says what you're building "
f"instead of repeating this prompt: `conv-meta title \"<the feature, a "
f"handful of words, imperative>\"`. Leave the project out of the title; "
f"the card is already tagged with it.\n\n"
f"Follow the repo conventions (worktree, commit skill, notify-done)."
)
return _send_message(deps, prompt)