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>
169 lines
8.0 KiB
Python
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)
|