""" V5 Project Current State API — a compact, legible snapshot of where a space is *right now*. This is the founder-facing / agent-facing "what is happening, what changed, what needs my attention" surface. It is computed on demand from existing tables (commits, work_claims, chat_sessions) — no new schema, no events, no background jobs. The CLI renders the same payload via `smriti current`. Endpoint: GET /api/v5/current/spaces/{space_id} – packaged current-state snapshot """ from __future__ import annotations import uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.orm import Session from app.api.routes.chat import ( ActiveClaimSummary, _compute_space_divergence, _get_active_branch_heads, _get_active_claims, _get_latest_commit, _get_repo, ) from app.db.database import get_db from app.db.models import CommitModel router = APIRouter(prefix="/current", tags=["current-v5"]) # ── Caps & vocabulary ──────────────────────────────────────────────────────── # # Constants on purpose — this surface is "digestible by default." A caller # who needs unbounded history hits the lineage / metrics endpoints instead. INTENT_ORDER = ("implement", "review", "test", "docs", "investigate", "other") VALID_INTENTS = {"implement", "review", "test", "docs", "investigate"} RECENT_MILESTONES_CAP = 5 RECENT_ACTIVITY_CAP = 8 OPEN_QUESTIONS_CAP = 6 TASKS_PER_INTENT_CAP = 8 SUMMARY_PREVIEW_CHARS = 280 # ── Response schemas ───────────────────────────────────────────────────────── class CurrentDirection(BaseModel): """Where the project is headed right now — drawn from the latest main-branch checkpoint. All fields are None for a space with no checkpoints yet.""" objective: Optional[str] = None headline: Optional[str] = None # latest main checkpoint message/title summary: Optional[str] = None # truncated to SUMMARY_PREVIEW_CHARS checkpoint_id: Optional[uuid.UUID] = None checkpoint_hash: Optional[str] = None author_agent: Optional[str] = None updated_at: Optional[datetime] = None class CurrentCounts(BaseModel): """At-a-glance project size signals.""" checkpoints: int = 0 agents: int = 0 active_claims: int = 0 active_branches: int = 0 open_tasks: int = 0 # open structured tasks on the latest main checkpoint milestones: int = 0 # total milestone notes across all checkpoints class AttentionSignal(BaseModel): """One "needs my attention right now" item. kind is one of: - "open_question" — an unresolved question on the latest checkpoint - "divergence" — an active branch disagrees with main on decisions - "active_work" — an agent currently holds a claim severity is "info" or "warn". """ kind: str severity: str message: str class MilestoneEntry(BaseModel): """A milestone note and the checkpoint it annotates.""" checkpoint_id: uuid.UUID checkpoint_hash: str checkpoint_message: str note: str author_agent: Optional[str] = None created_at: datetime class CurrentTask(BaseModel): """A structured task, normalized from the latest checkpoint's task list.""" text: str id: Optional[str] = None intent_hint: Optional[str] = None blocked_by: Optional[str] = None status: str = "open" class RecentActivityEntry(BaseModel): """One recent checkpoint, newest-first. Spans all branches so the surface honestly reflects 'what has been happening'.""" checkpoint_id: uuid.UUID checkpoint_hash: str message: str author_agent: Optional[str] = None branch_name: str created_at: datetime has_milestone: bool = False class CurrentStateResponse(BaseModel): """Composite, packaged snapshot for `GET /current/spaces/{id}`. One round trip. Shared payload contract between this endpoint, the `smriti current` CLI command, and the Project Current State UI panel. """ space_id: uuid.UUID name: str description: Optional[str] = None current_direction: CurrentDirection counts: CurrentCounts attention: list[AttentionSignal] = Field(default_factory=list) active_work: list[ActiveClaimSummary] = Field(default_factory=list) recent_milestones: list[MilestoneEntry] = Field(default_factory=list) open_tasks_by_intent: dict[str, list[CurrentTask]] = Field(default_factory=dict) recent_activity: list[RecentActivityEntry] = Field(default_factory=list) # ── Helpers ────────────────────────────────────────────────────────────────── def _normalize_task(raw: object) -> Optional[CurrentTask]: """Coerce a raw task entry into a CurrentTask. Tasks in `CommitModel.tasks` (JSONB) are either legacy strings or structured dicts. Returns None for empty / unusable entries. """ if isinstance(raw, str): text = raw.strip() return CurrentTask(text=text) if text else None if isinstance(raw, dict): text = str(raw.get("text") or "").strip() if not text: return None status = raw.get("status") or "open" intent = raw.get("intent_hint") return CurrentTask( text=text, id=(raw.get("id") or None), intent_hint=(intent or None), blocked_by=(raw.get("blocked_by") or None), status=status if status in ("open", "done") else "open", ) return None # ── Endpoint ───────────────────────────────────────────────────────────────── @router.get("/spaces/{space_id}", response_model=CurrentStateResponse) def get_current_state(space_id: uuid.UUID, db: Session = Depends(get_db)): """Return the packaged current-state snapshot for a space. Contains: - space header (id, name, description) - current_direction — the latest main-branch checkpoint - counts — checkpoints, agents, active claims, active branches, open tasks, milestones - attention — open questions, branch divergence, and active claims, as a flat list of typed signals - active_work — active work claims (with worktree drift when bound) - recent_milestones — milestone notes, newest-first, capped - open_tasks_by_intent — open structured tasks from the latest main checkpoint, grouped by intent - recent_activity — recent checkpoints across all branches, capped All data is derived from existing tables. No schema, no events. """ repo = _get_repo(space_id, db) # One ordered fetch of every checkpoint, newest-first. The metrics # endpoint uses the same all-in-Python pattern — fast at smriti scale. all_commits = list( db.scalars( select(CommitModel) .where(CommitModel.repo_id == space_id) .order_by(CommitModel.created_at.desc()) ) ) main_head = _get_latest_commit(space_id, db) # ── current_direction ──────────────────────────────────────────── if main_head: direction = CurrentDirection( objective=(main_head.objective or None), headline=(main_head.message or None), summary=((main_head.summary or "")[:SUMMARY_PREVIEW_CHARS] or None), checkpoint_id=main_head.id, checkpoint_hash=main_head.commit_hash, author_agent=main_head.author_agent, updated_at=main_head.created_at, ) else: direction = CurrentDirection() # ── single pass: recent activity + milestone scan ──────────────── recent_activity: list[RecentActivityEntry] = [] recent_milestones: list[MilestoneEntry] = [] milestone_total = 0 for c in all_commits: notes = (c.metadata_ or {}).get("notes") or [] milestone_notes = [ n for n in notes if isinstance(n, dict) and n.get("kind") == "milestone" ] milestone_total += len(milestone_notes) if len(recent_activity) < RECENT_ACTIVITY_CAP: recent_activity.append( RecentActivityEntry( checkpoint_id=c.id, checkpoint_hash=c.commit_hash, message=c.message or "", author_agent=c.author_agent, branch_name=c.branch_name, created_at=c.created_at, has_milestone=bool(milestone_notes), ) ) for n in milestone_notes: if len(recent_milestones) >= RECENT_MILESTONES_CAP: break note_text = str(n.get("text") or "").strip() if not note_text: continue recent_milestones.append( MilestoneEntry( checkpoint_id=c.id, checkpoint_hash=c.commit_hash, checkpoint_message=c.message or "", note=note_text, author_agent=(n.get("author") or c.author_agent), created_at=(n.get("created_at") or c.created_at), ) ) # ── counts inputs ──────────────────────────────────────────────── agents = {c.author_agent for c in all_commits if c.author_agent} active_branch_commits = _get_active_branch_heads(space_id, db) active_claims = _get_active_claims(space_id, db) # ── open tasks grouped by intent (latest main checkpoint) ──────── open_tasks_by_intent: dict[str, list[CurrentTask]] = {} open_task_total = 0 if main_head: grouped: dict[str, list[CurrentTask]] = {k: [] for k in INTENT_ORDER} for raw in (main_head.tasks or []): task = _normalize_task(raw) if task is None or task.status == "done": continue intent = task.intent_hint if task.intent_hint in VALID_INTENTS else "other" grouped[intent].append(task) open_task_total += 1 # Fixed intent order; only non-empty groups appear in the dict. for intent in INTENT_ORDER: capped = grouped[intent][:TASKS_PER_INTENT_CAP] if capped: open_tasks_by_intent[intent] = capped counts = CurrentCounts( checkpoints=len(all_commits), agents=len(agents), active_claims=len(active_claims), active_branches=len(active_branch_commits), open_tasks=open_task_total, milestones=milestone_total, ) # ── attention signals ──────────────────────────────────────────── attention: list[AttentionSignal] = [] # Open questions on the latest main checkpoint. if main_head: open_qs = [ str(q).strip() for q in (main_head.open_questions or []) if str(q).strip() ] for text in open_qs[:OPEN_QUESTIONS_CAP]: attention.append( AttentionSignal(kind="open_question", severity="info", message=text) ) # Branch divergence — an active branch disagrees with main on decisions. if main_head and active_branch_commits: divergence = _compute_space_divergence(main_head, active_branch_commits) if divergence and divergence.pairs: for pair in divergence.pairs: attention.append( AttentionSignal( kind="divergence", severity="warn", message=( f"Branch '{pair.branch_name}' " f"({pair.branch_commit_hash[:7]}) diverges from main " f"on decisions — run compare to reconcile." ), ) ) # Active work — an agent currently holds a claim. Part of the # founder-facing "check before starting overlapping work" story. for claim in active_claims: attention.append( AttentionSignal( kind="active_work", severity="info", message=( f"{claim.agent} is working on \"{claim.scope}\" " f"[{claim.intent_type}] — check before starting " f"overlapping work." ), ) ) return CurrentStateResponse( space_id=repo.id, name=repo.name, description=repo.description, current_direction=direction, counts=counts, attention=attention, active_work=active_claims, recent_milestones=recent_milestones, open_tasks_by_intent=open_tasks_by_intent, recent_activity=recent_activity, )