mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-10-07 02:58:07 +00:00
Add GET /api/v5/current/spaces/{id} — a packaged, computed-on-demand
snapshot of where a space is right now: current direction, counts,
attention signals (open questions, divergence, active work), active
work claims, open tasks grouped by intent, recent milestones, and
recent activity. Shared payload contract with the smriti current CLI
surface built in parallel.
Render it as the ProjectCurrentState panel at the top of LineagePage,
replacing the hand-rolled current-state summary. Extract a shared
_get_active_claims helper so the state and current endpoints report
active work identically. No schema changes.
350 lines
13 KiB
Python
350 lines
13 KiB
Python
"""
|
||
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,
|
||
)
|