diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index f7eb9a2..e29d58f 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -936,6 +936,63 @@ def _compute_space_divergence( return DivergenceSummary(pairs=pairs) if pairs else None +def _get_active_claims( + repo_id: uuid.UUID, db: Session, limit: int = 10 +) -> list[ActiveClaimSummary]: + """Active, non-expired work claims for a space, most-recently-claimed first. + + Claims bound to an active worktree are enriched with cached git drift + (path, branch, dirty file count, ahead/behind vs origin/main, last + commit). Shared by `GET /spaces/{id}/state` and + `GET /api/v5/current/spaces/{id}` so both surfaces report active work + identically. + """ + now = _utcnow() + claims_stmt = ( + select(WorkClaim) + .where( + WorkClaim.repo_id == repo_id, + WorkClaim.status == "active", + WorkClaim.expires_at > now, + ) + .order_by(WorkClaim.claimed_at.desc()) + .limit(limit) + ) + active_claims: list[ActiveClaimSummary] = [] + for wc in db.scalars(claims_stmt): + base_hash = None + if wc.base_commit_id: + base_commit = db.get(CommitModel, wc.base_commit_id) + base_hash = base_commit.commit_hash[:7] if base_commit else None + worktree_summary = None + if wc.worktree_id: + worktree = db.get(WorkTree, wc.worktree_id) + if worktree and worktree.status == "active": + probed = _probe_worktree( + str(worktree.id), + worktree.path, + worktree.branch_name, + ) + if probed: + worktree_summary = ActiveWorktreeSummary(**probed) + active_claims.append( + ActiveClaimSummary( + id=wc.id, + agent=wc.agent, + branch_name=wc.branch_name, + scope=wc.scope, + task_id=wc.task_id, + worktree_id=wc.worktree_id, + worktree=worktree_summary, + intent_type=wc.intent_type, + claimed_at=wc.claimed_at, + expires_at=wc.expires_at, + base_commit_hash=base_hash, + ) + ) + return active_claims + + FRESHNESS_NEW_CHECKPOINTS_CAP = 5 @@ -1016,50 +1073,8 @@ def get_space_state( if main_head_commit and active_branch_commits: divergence = _compute_space_divergence(main_head_commit, active_branch_commits) - # Active work claims — query-time expiration filter. - now = _utcnow() - claims_stmt = ( - select(WorkClaim) - .where( - WorkClaim.repo_id == repo_id, - WorkClaim.status == "active", - WorkClaim.expires_at > now, - ) - .order_by(WorkClaim.claimed_at.desc()) - .limit(10) - ) - active_claims = [] - for wc in db.scalars(claims_stmt): - base_hash = None - if wc.base_commit_id: - base_commit = db.get(CommitModel, wc.base_commit_id) - base_hash = base_commit.commit_hash[:7] if base_commit else None - worktree_summary = None - if wc.worktree_id: - worktree = db.get(WorkTree, wc.worktree_id) - if worktree and worktree.status == "active": - probed = _probe_worktree( - str(worktree.id), - worktree.path, - worktree.branch_name, - ) - if probed: - worktree_summary = ActiveWorktreeSummary(**probed) - active_claims.append( - ActiveClaimSummary( - id=wc.id, - agent=wc.agent, - branch_name=wc.branch_name, - scope=wc.scope, - task_id=wc.task_id, - worktree_id=wc.worktree_id, - worktree=worktree_summary, - intent_type=wc.intent_type, - claimed_at=wc.claimed_at, - expires_at=wc.expires_at, - base_commit_hash=base_hash, - ) - ) + # Active work claims — extracted helper, shared with GET /api/v5/current. + active_claims = _get_active_claims(repo_id, db) # Freshness check: if since_commit_id is provided, determine whether # HEAD has moved and list new checkpoints since the caller's base. diff --git a/backend/app/api/routes/current.py b/backend/app/api/routes/current.py new file mode 100644 index 0000000..11d42ae --- /dev/null +++ b/backend/app/api/routes/current.py @@ -0,0 +1,350 @@ +""" +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, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 620bc32..9a574f5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -59,6 +59,7 @@ def create_app() -> FastAPI: claims, commits, context_git, + current, lineage, metrics, repos, @@ -78,6 +79,7 @@ def create_app() -> FastAPI: app.include_router(lineage.router, prefix="/api/v5", tags=["lineage-v5"]) app.include_router(claims.router, prefix="/api/v5", tags=["claims-v5"]) app.include_router(metrics.router, prefix="/api/v5", tags=["metrics-v5"]) + app.include_router(current.router, prefix="/api/v5", tags=["current-v5"]) app.include_router(worktrees.router, prefix="/api/v5", tags=["worktrees-v5"]) # ── Capabilities manifest ──────────────────────────────────────── diff --git a/backend/tests/integration/test_current_state.py b/backend/tests/integration/test_current_state.py new file mode 100644 index 0000000..bff62a4 --- /dev/null +++ b/backend/tests/integration/test_current_state.py @@ -0,0 +1,302 @@ +"""Integration tests for the Project Current State endpoint. + +Covers `GET /api/v5/current/spaces/{space_id}` end-to-end through the +FastAPI app with an in-memory SQLite session. No mocking. + +- Empty space returns a usable, fully-empty shape +- current_direction is drawn from the latest main checkpoint +- counts aggregate checkpoints / agents / claims / branches / tasks / milestones +- open_tasks_by_intent groups open structured tasks, excludes done tasks +- recent_milestones surfaces milestone notes, newest-first, capped at 5 +- recent_activity surfaces recent checkpoints, capped at 8 +- attention carries open_question, divergence, and active_work signals +- unknown space returns 404 +""" +from __future__ import annotations + +import uuid + +# ── Helpers (match the shape used in test_multi_branch_state.py) ───────────── + + +def _create_repo(client, name: str = "Current State Test") -> str: + r = client.post("/api/v2/repos", json={"name": name}) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def _create_session(client, repo_id: str, title: str = "test") -> str: + r = client.post( + f"/api/v4/chat/spaces/{repo_id}/sessions", + json={"title": title, "provider": "openrouter", "model": "mock"}, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def _commit(client, repo_id: str, session_id: str, message: str = "checkpoint", **kwargs) -> dict: + payload = {"repo_id": repo_id, "session_id": session_id, "message": message, **kwargs} + r = client.post("/api/v4/chat/commit", json=payload) + assert r.status_code == 201, r.text + return r.json() + + +def _fork(client, space_id: str, checkpoint_id: str, branch_name: str = "") -> dict: + r = client.post( + "/api/v5/lineage/sessions/fork", + json={ + "space_id": space_id, + "checkpoint_id": checkpoint_id, + "branch_name": branch_name, + "provider": "openrouter", + "model": "mock", + }, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _add_note( + client, checkpoint_id: str, text: str, kind: str = "note", author: str = "founder" +) -> dict: + r = client.post( + f"/api/v5/checkpoint/{checkpoint_id}/notes", + json={"text": text, "kind": kind, "author": author}, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _create_claim( + client, space_id: str, agent: str, scope: str, intent_type: str = "implement" +) -> dict: + r = client.post( + "/api/v5/claims", + json={"space_id": space_id, "agent": agent, "scope": scope, "intent_type": intent_type}, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _get_current(client, space_id: str) -> dict: + r = client.get(f"/api/v5/current/spaces/{space_id}") + assert r.status_code == 200, r.text + return r.json() + + +# ── Tests ──────────────────────────────────────────────────────────────────── + + +def test_current_empty_space(client): + """A space with no checkpoints returns a usable, fully-empty payload.""" + repo_id = _create_repo(client, "Empty Current") + + cur = _get_current(client, repo_id) + + assert cur["space_id"] == repo_id + assert cur["name"] == "Empty Current" + assert cur["current_direction"]["objective"] is None + assert cur["current_direction"]["checkpoint_id"] is None + assert cur["counts"] == { + "checkpoints": 0, + "agents": 0, + "active_claims": 0, + "active_branches": 0, + "open_tasks": 0, + "milestones": 0, + } + assert cur["attention"] == [] + assert cur["active_work"] == [] + assert cur["recent_milestones"] == [] + assert cur["open_tasks_by_intent"] == {} + assert cur["recent_activity"] == [] + + +def test_current_direction_from_latest_main_checkpoint(client): + """current_direction mirrors the latest main checkpoint.""" + repo_id = _create_repo(client, "Direction") + session_id = _create_session(client, repo_id) + commit = _commit( + client, + repo_id, + session_id, + message="Build the current-state layer", + objective="Ship Project Current State", + summary="Backend endpoint plus a UI panel.", + author_agent="claude-code", + ) + + cur = _get_current(client, repo_id) + + direction = cur["current_direction"] + assert direction["headline"] == "Build the current-state layer" + assert direction["objective"] == "Ship Project Current State" + assert direction["summary"] == "Backend endpoint plus a UI panel." + assert direction["checkpoint_id"] == commit["id"] + assert direction["checkpoint_hash"] == commit["commit_hash"] + assert direction["author_agent"] == "claude-code" + assert cur["counts"]["checkpoints"] == 1 + assert cur["counts"]["agents"] == 1 + assert len(cur["recent_activity"]) == 1 + assert cur["recent_activity"][0]["message"] == "Build the current-state layer" + assert cur["recent_activity"][0]["has_milestone"] is False + + +def test_current_open_tasks_grouped_by_intent(client): + """open_tasks_by_intent groups open tasks; done tasks are excluded.""" + repo_id = _create_repo(client, "Tasks") + session_id = _create_session(client, repo_id) + _commit( + client, + repo_id, + session_id, + message="Has tasks", + tasks=[ + { + "text": "Implement endpoint", + "intent_hint": "implement", + "id": "impl-1", + "status": "open", + }, + {"text": "Write tests", "intent_hint": "test", "status": "open"}, + {"text": "Update docs", "intent_hint": "docs", "status": "done"}, + {"text": "Unlabelled work", "status": "open"}, + ], + ) + + cur = _get_current(client, repo_id) + + grouped = cur["open_tasks_by_intent"] + assert set(grouped.keys()) == {"implement", "test", "other"} + assert "docs" not in grouped # its only task is done + assert len(grouped["implement"]) == 1 + assert grouped["implement"][0]["text"] == "Implement endpoint" + assert grouped["implement"][0]["id"] == "impl-1" + assert grouped["other"][0]["text"] == "Unlabelled work" + assert cur["counts"]["open_tasks"] == 3 # done task excluded + + +def test_current_recent_milestones(client): + """Milestone notes surface in recent_milestones; plain notes do not.""" + repo_id = _create_repo(client, "Milestones") + session_id = _create_session(client, repo_id) + commit = _commit(client, repo_id, session_id, message="Proof checkpoint") + _add_note(client, commit["id"], "First clean autonomous proof", kind="milestone") + _add_note(client, commit["id"], "just a plain note", kind="note") + + cur = _get_current(client, repo_id) + + assert len(cur["recent_milestones"]) == 1 + ms = cur["recent_milestones"][0] + assert ms["note"] == "First clean autonomous proof" + assert ms["checkpoint_hash"] == commit["commit_hash"] + assert ms["checkpoint_message"] == "Proof checkpoint" + assert cur["counts"]["milestones"] == 1 # plain note not counted + assert cur["recent_activity"][0]["has_milestone"] is True + + +def test_current_attention_open_questions(client): + """Open questions on the latest checkpoint become open_question signals.""" + repo_id = _create_repo(client, "Questions") + session_id = _create_session(client, repo_id) + _commit( + client, + repo_id, + session_id, + message="Has questions", + open_questions=["Should state be cached?", "How do we page large spaces?"], + ) + + cur = _get_current(client, repo_id) + + oq = [a for a in cur["attention"] if a["kind"] == "open_question"] + assert len(oq) == 2 + assert {a["message"] for a in oq} == { + "Should state be cached?", + "How do we page large spaces?", + } + assert all(a["severity"] == "info" for a in oq) + + +def test_current_attention_active_work(client): + """An active claim appears in active_work and as an active_work signal.""" + repo_id = _create_repo(client, "Active Work") + session_id = _create_session(client, repo_id) + _commit(client, repo_id, session_id, message="Base") + _create_claim(client, repo_id, agent="codex-local", scope="Wire the CLI surface") + + cur = _get_current(client, repo_id) + + assert cur["counts"]["active_claims"] == 1 + assert len(cur["active_work"]) == 1 + assert cur["active_work"][0]["agent"] == "codex-local" + + aw = [a for a in cur["attention"] if a["kind"] == "active_work"] + assert len(aw) == 1 + assert "codex-local" in aw[0]["message"] + assert "Wire the CLI surface" in aw[0]["message"] + + +def test_current_attention_divergence(client): + """A divergent active branch produces a divergence attention signal.""" + repo_id = _create_repo(client, "Divergence") + session_id = _create_session(client, repo_id) + base = _commit( + client, + repo_id, + session_id, + message="Base", + decisions=["Use Postgres for storage"], + ) + + fork = _fork(client, repo_id, base["id"], branch_name="sqlite-route") + _commit( + client, + repo_id, + fork["session_id"], + message="Try SQLite", + decisions=["Use embedded SQLite only", "Drop the Postgres dependency"], + ) + + cur = _get_current(client, repo_id) + + assert cur["counts"]["active_branches"] == 1 + div = [a for a in cur["attention"] if a["kind"] == "divergence"] + assert len(div) == 1 + assert div[0]["severity"] == "warn" + assert "sqlite-route" in div[0]["message"] + + +def test_current_recent_activity_capped_at_8(client): + """recent_activity is capped at 8 and ordered newest-first.""" + repo_id = _create_repo(client, "Many Checkpoints") + session_id = _create_session(client, repo_id) + for i in range(10): + _commit(client, repo_id, session_id, message=f"checkpoint-{i:02d}") + + cur = _get_current(client, repo_id) + + assert cur["counts"]["checkpoints"] == 10 + assert len(cur["recent_activity"]) == 8 + timestamps = [e["created_at"] for e in cur["recent_activity"]] + assert timestamps == sorted(timestamps, reverse=True) # newest-first + + +def test_current_recent_milestones_capped_at_5(client): + """recent_milestones caps at 5 even though counts.milestones is the true total.""" + repo_id = _create_repo(client, "Many Milestones") + session_id = _create_session(client, repo_id) + for i in range(6): + commit = _commit(client, repo_id, session_id, message=f"milestone-cp-{i}") + _add_note(client, commit["id"], f"Milestone {i}", kind="milestone") + + cur = _get_current(client, repo_id) + + assert len(cur["recent_milestones"]) == 5 + assert cur["counts"]["milestones"] == 6 + + +def test_current_unknown_space_returns_404(client): + """An unknown space id returns 404.""" + r = client.get(f"/api/v5/current/spaces/{uuid.uuid4()}") + assert r.status_code == 404 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ae40e40..bbbc35d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -361,6 +361,15 @@ export async function getSpaceState(spaceId: string): Promise(`/chat/spaces/${spaceId}/state`); } +/** + * Packaged "Project Current State" snapshot for a space — current direction, + * counts, attention signals, active work, open tasks by intent, recent + * milestones, and recent activity. One round trip. + */ +export async function getCurrentState(spaceId: string): Promise { + return requestV5(`/current/spaces/${spaceId}`); +} + export async function compareCheckpoints( aId: string, bId: string, diff --git a/frontend/src/components/ProjectCurrentState.tsx b/frontend/src/components/ProjectCurrentState.tsx new file mode 100644 index 0000000..6465bcd --- /dev/null +++ b/frontend/src/components/ProjectCurrentState.tsx @@ -0,0 +1,281 @@ +/** + * ProjectCurrentState — the packaged "where is this project right now" panel. + * + * Renders GET /api/v5/current/spaces/{id}: current direction, counts, + * attention signals, active work, open tasks grouped by intent, recent + * milestones, and recent activity. Compact and scannable — the + * founder-facing current-state surface. Empty sub-sections are elided. + */ + +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { getCurrentState } from '../api/client'; +import type { CurrentState } from '../types'; +import { Activity, AlertTriangle, Compass, ListChecks, Loader2, Star, Zap } from 'lucide-react'; + +const PANEL_STYLE = { + borderColor: 'var(--color-border, #27272a)', + background: 'var(--color-surface, #18181b)', +} as const; + +const INTENT_LABELS: Record = { + implement: 'Implement', + review: 'Review', + test: 'Test', + docs: 'Docs', + investigate: 'Investigate', + other: 'Other', +}; + +function fmt(d: string) { + return new Date(d).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function Section({ + icon: Icon, + title, + iconClass, + children, +}: { + icon: React.ComponentType<{ className?: string }>; + title: string; + iconClass?: string; + children: ReactNode; +}) { + return ( +
+
+ +

{title}

+
+ {children} +
+ ); +} + +function Stat({ n, label, tone }: { n: number; label: string; tone?: string }) { + return ( + + {n} {label} + {n === 1 ? '' : 's'} + + ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export function ProjectCurrentState({ spaceId }: { spaceId: string }) { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [err, setErr] = useState(''); + + useEffect(() => { + if (!spaceId) return; + setLoading(true); + setErr(''); + getCurrentState(spaceId) + .then(setState) + .catch((e: unknown) => setErr(e instanceof Error ? e.message : 'Failed to load current state')) + .finally(() => setLoading(false)); + }, [spaceId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (err || !state) { + return ( +
+

{err || 'No current state available.'}

+
+ ); + } + + const dir = state.current_direction; + const hasDirection = Boolean(dir.checkpoint_id); + const counts = state.counts; + const hasWarn = state.attention.some(a => a.severity === 'warn'); + const intentKeys = Object.keys(state.open_tasks_by_intent); + + return ( +
+ {/* Header */} +
+

{state.name}

+ {state.description &&

{state.description}

} +
+ + {/* Current direction */} + {hasDirection ? ( +
+
+ + Current direction +
+

{dir.headline}

+ {dir.objective &&

{dir.objective}

} +
+ {dir.author_agent && ( + + {dir.author_agent} + + )} + {dir.checkpoint_hash && {dir.checkpoint_hash.slice(0, 7)}} + {dir.updated_at && {fmt(dir.updated_at)}} +
+
+ ) : ( +

No checkpoints yet — this space has no recorded direction.

+ )} + + {/* Counts strip */} +
+ + + + + + +
+ + {/* Attention */} + {state.attention.length > 0 && ( +
+
+ + Needs attention +
+
    + {state.attention.map((a, i) => ( +
  • + • {a.message} +
  • + ))} +
+
+ )} + + {/* Active work */} + {state.active_work.length > 0 && ( +
+
+ {state.active_work.map(claim => ( +
+
+ + {claim.agent} + + + {claim.intent_type} + + + on {claim.branch_name} + + {claim.task_id && ( + + task: {claim.task_id} + + )} +
+

{claim.scope}

+
+ ))} +
+
+ )} + + {/* Open tasks by intent */} + {intentKeys.length > 0 && ( +
+
+ {intentKeys.map(intent => ( +
+

+ {INTENT_LABELS[intent] ?? intent} +

+
    + {state.open_tasks_by_intent[intent].map((t, i) => ( +
  • + + + {t.text} + {t.blocked_by && ( + — blocked by {t.blocked_by} + )} + +
  • + ))} +
+
+ ))} +
+
+ )} + + {/* Recent milestones */} + {state.recent_milestones.length > 0 && ( +
+
    + {state.recent_milestones.map((m, i) => ( +
  • +
    + {m.checkpoint_hash.slice(0, 7)} + {m.author_agent && {m.author_agent}} + {fmt(m.created_at)} +
    +

    {m.note}

    +
  • + ))} +
+
+ )} + + {/* Recent activity */} + {state.recent_activity.length > 0 && ( +
+
    + {state.recent_activity.map(a => ( +
  • + {a.checkpoint_hash.slice(0, 7)} + {a.has_milestone && } + {a.message} + {a.branch_name !== 'main' && ( + + {a.branch_name} + + )} + {a.author_agent && ( + {a.author_agent} + )} + {fmt(a.created_at)} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/LineagePage.tsx b/frontend/src/pages/LineagePage.tsx index 8756f94..de87354 100644 --- a/frontend/src/pages/LineagePage.tsx +++ b/frontend/src/pages/LineagePage.tsx @@ -18,15 +18,14 @@ import { useEffect, useRef, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { getLineage, getSpaceState, compareCheckpoints, forkSession } from '../api/client'; +import { ProjectCurrentState } from '../components/ProjectCurrentState'; import type { ActiveClaimSummary, CheckpointNode, SessionNode, LineageResponse, CompareResponse, - StructuredTask, } from '../types'; -import { normalizeTask } from '../types'; import { GitBranch, GitCommit, Loader2, X, ArrowLeft, Zap } from 'lucide-react'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -624,7 +623,6 @@ export function LineagePage() { const [lineage, setLineage] = useState(null); const [activeClaims, setActiveClaims] = useState([]); - const [spaceState, setSpaceState] = useState(null); const [loading, setLoading] = useState(true); const [err, setErr] = useState(''); @@ -646,7 +644,6 @@ export function LineagePage() { ]) .then(([lin, state]) => { setLineage(lin); - setSpaceState(state); setActiveClaims(state?.active_claims ?? []); }) .catch(e => setErr(e.message || 'Failed to load lineage')) @@ -752,101 +749,8 @@ export function LineagePage() { {lineage && !loading && (
- {/* Current-state summary panel */} - {spaceState && ( -
- {/* Project header */} -
-

- {spaceState.space.name} -

- {spaceState.space.description && ( -

{spaceState.space.description}

- )} -
- - {/* Current direction */} - {spaceState.commit && ( -
-
- Current direction -
-

{spaceState.commit.message}

- {spaceState.commit.objective && ( -

{spaceState.commit.objective}

- )} -
- {spaceState.commit.author_agent && ( - - {spaceState.commit.author_agent} - - )} - {fmt(spaceState.commit.created_at)} - {spaceState.commit.commit_hash?.slice(0, 7)} -
-
- )} - - {/* Status bar */} -
- - {lineage.checkpoints.length} checkpoint{lineage.checkpoints.length !== 1 ? 's' : ''} - - {(spaceState.active_branches?.length ?? 0) > 0 && ( - - {spaceState.active_branches.length} active branch{spaceState.active_branches.length !== 1 ? 'es' : ''} - - )} - {activeClaims.length > 0 && ( - - {activeClaims.length} active claim{activeClaims.length !== 1 ? 's' : ''} - - )} - {spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0 && ( - - ⚠ Divergence detected - - )} - {(spaceState.commit?.tasks?.length ?? 0) > 0 && (() => { - const tasks = (spaceState.commit.tasks ?? []).map(normalizeTask); - const openCount = tasks.filter((t: StructuredTask) => !t.status || t.status === 'open').length; - const doneCount = tasks.filter((t: StructuredTask) => t.status === 'done').length; - return ( - - {openCount} open task{openCount !== 1 ? 's' : ''} - {doneCount > 0 && ( - <>, {doneCount} done - )} - - ); - })()} -
- - {/* Needs attention signal */} - {(() => { - const reasons: string[] = []; - if (spaceState.divergence && (spaceState.divergence.pairs?.length ?? 0) > 0) - reasons.push('Branch divergence needs resolution'); - if (activeClaims.length > 0) - reasons.push(`${activeClaims.length} agent${activeClaims.length !== 1 ? 's' : ''} working — check before starting new work`); - if ((spaceState.commit?.open_questions?.length ?? 0) > 0) - reasons.push(`${spaceState.commit.open_questions.length} open question${spaceState.commit.open_questions.length !== 1 ? 's' : ''} from latest checkpoint`); - if (reasons.length === 0) return null; - return ( -
-
- ⚡ Needs attention -
-
    - {reasons.map((r, i) => ( -
  • • {r}
  • - ))} -
-
- ); - })()} -
- )} + {/* Project Current State panel */} + {spaceId && } {/* Active work claims */} {activeClaims.length > 0 && ( diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e1d1d41..d8a6962 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -237,6 +237,7 @@ export interface ActiveClaimSummary { branch_name: string; scope: string; intent_type: string; + task_id?: string | null; claimed_at: string; expires_at: string; base_commit_hash: string | null; @@ -319,3 +320,74 @@ export interface CheckpointReviewResponse { issues: ReviewIssue[]; suggestions: string[]; } + +// ── Project Current State (V5) ──────────────────────────────────────────────── +// +// Shape of GET /api/v5/current/spaces/{id} — the packaged "where is this +// project right now" payload. Shared contract with the `smriti current` +// CLI command and the ProjectCurrentState UI panel. + +export interface CurrentDirection { + objective: string | null; + headline: string | null; + summary: string | null; + checkpoint_id: string | null; + checkpoint_hash: string | null; + author_agent: string | null; + updated_at: string | null; +} + +export interface CurrentCounts { + checkpoints: number; + agents: number; + active_claims: number; + active_branches: number; + open_tasks: number; + milestones: number; +} + +export interface AttentionSignal { + kind: 'open_question' | 'divergence' | 'active_work'; + severity: 'info' | 'warn'; + message: string; +} + +export interface MilestoneEntry { + checkpoint_id: string; + checkpoint_hash: string; + checkpoint_message: string; + note: string; + author_agent: string | null; + created_at: string; +} + +export interface CurrentTask { + text: string; + id: string | null; + intent_hint: string | null; + blocked_by: string | null; + status: string; +} + +export interface RecentActivityEntry { + checkpoint_id: string; + checkpoint_hash: string; + message: string; + author_agent: string | null; + branch_name: string; + created_at: string; + has_milestone: boolean; +} + +export interface CurrentState { + space_id: string; + name: string; + description: string | null; + current_direction: CurrentDirection; + counts: CurrentCounts; + attention: AttentionSignal[]; + active_work: ActiveClaimSummary[]; + recent_milestones: MilestoneEntry[]; + open_tasks_by_intent: Record; + recent_activity: RecentActivityEntry[]; +}