diff --git a/backend/alembic/versions/2026_04_13_0100_f7a8b9cadbef_add_branch_disposition.py b/backend/alembic/versions/2026_04_13_0100_f7a8b9cadbef_add_branch_disposition.py new file mode 100644 index 0000000..4bacdc4 --- /dev/null +++ b/backend/alembic/versions/2026_04_13_0100_f7a8b9cadbef_add_branch_disposition.py @@ -0,0 +1,26 @@ +"""Add branch_disposition column to chat_sessions. + +Revision ID: f7a8b9cadbef +Revises: e6f7a8b9cadb +Create Date: 2026-04-13 01:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "f7a8b9cadbef" +down_revision = "e6f7a8b9cadb" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "chat_sessions", + sa.Column("branch_disposition", sa.String(20), server_default="active"), + ) + + +def downgrade() -> None: + op.drop_column("chat_sessions", "branch_disposition") diff --git a/backend/app/api/routes/chat.py b/backend/app/api/routes/chat.py index 10f5197..2246ff9 100644 --- a/backend/app/api/routes/chat.py +++ b/backend/app/api/routes/chat.py @@ -804,16 +804,34 @@ def get_head(repo_id: uuid.UUID, db: Session = Depends(get_db)): def _get_active_branch_heads( repo_id: uuid.UUID, db: Session, limit: int = ACTIVE_BRANCHES_CAP ) -> list[CommitModel]: - """Return the most recent checkpoint on each non-main branch of this space, - ordered by `created_at` descending, capped at `limit`. + """Return the most recent checkpoint on each non-main branch of this space + that has at least one session with branch_disposition == 'active'. + + Branches marked 'integrated' or 'abandoned' are excluded — they no + longer appear in ## Active branches or ## Divergence signal. They + remain in the lineage tree and in `smriti branch list` for history. Implemented in two steps so the SQL stays portable across the SQLAlchemy dialects we use in tests: first fetch every non-main - checkpoint (cheap — there are dozens, not millions, in a real - project), then collapse per branch in Python. A Postgres-only - `DISTINCT ON (branch_name)` would be marginally faster but would - break sqlite-backed unit tests for no real gain. + checkpoint, then collapse per branch in Python and filter by + session disposition. """ + # Step 1: find which branches have at least one active session. + active_branches_stmt = ( + select(ChatSession.branch_name) + .where( + ChatSession.repo_id == repo_id, + ChatSession.branch_name != "main", + ChatSession.branch_disposition == "active", + ) + .distinct() + ) + active_branch_names = set(db.scalars(active_branches_stmt).all()) + + if not active_branch_names: + return [] + + # Step 2: find the latest checkpoint per active branch. stmt = ( select(CommitModel) .where( @@ -827,6 +845,9 @@ def _get_active_branch_heads( for commit in db.scalars(stmt): if commit.branch_name in seen: continue + if commit.branch_name not in active_branch_names: + seen.add(commit.branch_name) + continue seen.add(commit.branch_name) heads.append(commit) if len(heads) >= limit: diff --git a/backend/app/api/routes/lineage.py b/backend/app/api/routes/lineage.py index 32059f4..1c3c826 100644 --- a/backend/app/api/routes/lineage.py +++ b/backend/app/api/routes/lineage.py @@ -331,6 +331,87 @@ def fork_session(payload: ForkSessionRequest, db: Session = Depends(get_db)): ) +# ── Branch disposition endpoint ──────────────────────────────────────────────── + +VALID_DISPOSITIONS = {"active", "integrated", "abandoned"} + + +class BranchDispositionRequest(BaseModel): + space_id: str + branch_name: str + disposition: str = Field( + description="One of: active, integrated, abandoned", + ) + + +class BranchDispositionResponse(BaseModel): + space_id: uuid.UUID + branch_name: str + disposition: str + sessions_updated: int + + +@router.patch("/branches/disposition", response_model=BranchDispositionResponse) +def set_branch_disposition( + payload: BranchDispositionRequest, + db: Session = Depends(get_db), +): + """Set the disposition of a branch (all sessions on that branch). + + Marks a branch as integrated, abandoned, or active. Sessions with + matching branch_name in the space have their branch_disposition + updated. This controls whether the branch appears in the + ## Active branches section of smriti state. + + Branch name is passed in the request body (not the URL) because + branch names frequently contain slashes (e.g. codex/config-reload) + which conflict with URL path routing. + + Reversible: setting back to 'active' re-shows the branch. + """ + try: + space_id = uuid.UUID(payload.space_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid space_id") + + _get_repo(space_id, db) + + if payload.disposition not in VALID_DISPOSITIONS: + raise HTTPException( + status_code=400, + detail=f"Invalid disposition '{payload.disposition}'. " + f"Must be one of: {', '.join(sorted(VALID_DISPOSITIONS))}", + ) + + # Find all sessions on this branch in this space. + stmt = ( + select(ChatSession) + .where( + ChatSession.repo_id == space_id, + ChatSession.branch_name == payload.branch_name, + ) + ) + sessions = list(db.scalars(stmt).all()) + + if not sessions: + raise HTTPException( + status_code=404, + detail=f"No sessions found on branch '{payload.branch_name}' in this space.", + ) + + for session in sessions: + session.branch_disposition = payload.disposition + + db.commit() + + return BranchDispositionResponse( + space_id=space_id, + branch_name=payload.branch_name, + disposition=payload.disposition, + sessions_updated=len(sessions), + ) + + # ── Lineage (branch tree) endpoint ──────────────────────────────────────────── @router.get("/spaces/{space_id}", response_model=LineageResponse) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index 43c93a5..ee11f85 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -211,6 +211,11 @@ class ChatSession(Base): ) # Branch identity — "main" for primary sessions, custom name for forks branch_name: Mapped[str] = mapped_column(String(255), default="main") + # Branch lifecycle — explicit disposition signal so agents and humans + # can mark a branch as integrated or abandoned, removing it from the + # ## Active branches section of smriti state without deleting history. + # Values: "active" (default, shown), "integrated" (hidden), "abandoned" (hidden). + branch_disposition: Mapped[str] = mapped_column(String(20), default="active") metadata_: Mapped[dict] = mapped_column("metadata", JSONB, default=dict) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow) updated_at: Mapped[datetime] = mapped_column( diff --git a/backend/tests/integration/test_branch_disposition.py b/backend/tests/integration/test_branch_disposition.py new file mode 100644 index 0000000..5f35e2b --- /dev/null +++ b/backend/tests/integration/test_branch_disposition.py @@ -0,0 +1,184 @@ +"""Integration tests for branch disposition / lifecycle. + +Covers: +- Setting disposition to integrated, abandoned, active +- Disposition filters branches from /state active_branches +- Disposition filters branches from /state divergence signal +- Reversibility: integrated → active re-shows the branch +- Nonexistent branch returns 404 +- Invalid disposition returns 400 +""" +from __future__ import annotations + + +def _create_repo(client, name="Disposition Test"): + 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, title="test"): + 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, session_id, message="checkpoint", **kwargs): + 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, checkpoint_id, branch_name=""): + r = client.post( + "/api/v5/lineage/sessions/fork", + json={ + "space_id": space_id, + "checkpoint_id": checkpoint_id, + "branch_name": branch_name, + }, + ) + assert r.status_code == 201, r.text + return r.json() + + +def _set_disposition(client, space_id, branch_name, disposition): + r = client.patch( + "/api/v5/lineage/branches/disposition", + json={ + "space_id": space_id, + "branch_name": branch_name, + "disposition": disposition, + }, + ) + return r + + +def _get_state(client, repo_id): + r = client.get(f"/api/v4/chat/spaces/{repo_id}/state") + assert r.status_code == 200, r.text + return r.json() + + +# ── Tests ───────────────────────────────────────────────────────────────────── + + +def test_set_disposition_integrated(client): + repo_id = _create_repo(client, "Integrated") + session_id = _create_session(client, repo_id) + base = _commit(client, repo_id, session_id, "base", decisions=["D1"]) + + fork = _fork(client, repo_id, base["id"], branch_name="side-work") + _commit(client, repo_id, fork["session_id"], "fork commit", decisions=["D2"]) + + r = _set_disposition(client, repo_id, "side-work", "integrated") + assert r.status_code == 200 + data = r.json() + assert data["disposition"] == "integrated" + assert data["sessions_updated"] == 1 + + +def test_integrated_branch_hidden_from_state(client): + """An integrated branch should not appear in active_branches or divergence.""" + repo_id = _create_repo(client, "Hidden After Integrated") + session_id = _create_session(client, repo_id) + base = _commit(client, repo_id, session_id, "base", decisions=["Use Pydantic"]) + + fork = _fork(client, repo_id, base["id"], branch_name="experiment") + _commit( + client, repo_id, fork["session_id"], + "experiment commit", decisions=["Use dataclasses"], + ) + + # Before disposition: branch visible + state_before = _get_state(client, repo_id) + assert len(state_before["active_branches"]) == 1 + assert state_before["active_branches"][0]["branch_name"] == "experiment" + + # Mark integrated + _set_disposition(client, repo_id, "experiment", "integrated") + + # After disposition: branch hidden + state_after = _get_state(client, repo_id) + assert len(state_after["active_branches"]) == 0 + assert state_after["divergence"] is None + + +def test_abandoned_branch_hidden_from_state(client): + repo_id = _create_repo(client, "Hidden After Abandoned") + session_id = _create_session(client, repo_id) + base = _commit(client, repo_id, session_id, "base") + + fork = _fork(client, repo_id, base["id"], branch_name="dead-end") + _commit(client, repo_id, fork["session_id"], "dead end commit") + + _set_disposition(client, repo_id, "dead-end", "abandoned") + + state = _get_state(client, repo_id) + assert len(state["active_branches"]) == 0 + + +def test_reversibility_active_restores_branch(client): + """Setting back to active re-shows the branch.""" + repo_id = _create_repo(client, "Reversible") + session_id = _create_session(client, repo_id) + base = _commit(client, repo_id, session_id, "base") + + fork = _fork(client, repo_id, base["id"], branch_name="temp") + _commit(client, repo_id, fork["session_id"], "temp commit") + + _set_disposition(client, repo_id, "temp", "integrated") + state_hidden = _get_state(client, repo_id) + assert len(state_hidden["active_branches"]) == 0 + + _set_disposition(client, repo_id, "temp", "active") + state_restored = _get_state(client, repo_id) + assert len(state_restored["active_branches"]) == 1 + assert state_restored["active_branches"][0]["branch_name"] == "temp" + + +def test_nonexistent_branch_returns_404(client): + repo_id = _create_repo(client, "No Branch") + r = _set_disposition(client, repo_id, "ghost-branch", "integrated") + assert r.status_code == 404 + + +def test_invalid_disposition_returns_400(client): + repo_id = _create_repo(client, "Bad Disposition") + session_id = _create_session(client, repo_id) + base = _commit(client, repo_id, session_id, "base") + fork = _fork(client, repo_id, base["id"], branch_name="b1") + + r = _set_disposition(client, repo_id, "b1", "archived") + assert r.status_code == 400 + assert "disposition" in r.json()["detail"].lower() + + +def test_only_active_branches_generate_divergence(client): + """Integrated branches should not trigger the divergence signal.""" + repo_id = _create_repo(client, "Divergence Filter") + session_id = _create_session(client, repo_id) + base = _commit( + client, repo_id, session_id, "base", + decisions=["Main decision"], + ) + + fork = _fork(client, repo_id, base["id"], branch_name="alt") + _commit( + client, repo_id, fork["session_id"], + "alt commit", decisions=["Alt decision"], + ) + + # Divergence exists before disposition + state_before = _get_state(client, repo_id) + assert state_before["divergence"] is not None + + _set_disposition(client, repo_id, "alt", "integrated") + + # Divergence gone after disposition + state_after = _get_state(client, repo_id) + assert state_after["divergence"] is None diff --git a/cli/smriti_cli/client.py b/cli/smriti_cli/client.py index 28a7b39..b80f5a6 100644 --- a/cli/smriti_cli/client.py +++ b/cli/smriti_cli/client.py @@ -203,6 +203,20 @@ class SmritiClient: params["include_expired"] = "true" return self._request("GET", "/api/v5/claims", params=params) + # ── Branch disposition ──────────────────────────────────────────── + + def close_branch(self, space_id: str, branch_name: str, disposition: str) -> dict: + """PATCH /api/v5/lineage/branches/disposition""" + return self._request( + "PATCH", + "/api/v5/lineage/branches/disposition", + json={ + "space_id": space_id, + "branch_name": branch_name, + "disposition": disposition, + }, + ) + # ── Checkpoints ────────────────────────────────────────────────── def create_chat_commit(self, payload: dict) -> dict: diff --git a/cli/smriti_cli/main.py b/cli/smriti_cli/main.py index bfb5618..1d3dddb 100644 --- a/cli/smriti_cli/main.py +++ b/cli/smriti_cli/main.py @@ -555,6 +555,22 @@ def cmd_skills_install(client: SmritiClient, args: argparse.Namespace) -> None: ) +# ── branch subcommand handlers ────────────────────────────────────────────── + + +def cmd_branch_close(client: SmritiClient, args: argparse.Namespace) -> None: + """Set the disposition of a branch (integrated, abandoned, or active).""" + space = client.resolve_space(args.space) + result = client.close_branch(space["id"], args.branch_name, args.disposition) + if args.json: + _print_json(result) + else: + print( + f"Branch `{result['branch_name']}` marked `{result['disposition']}` " + f"({result['sessions_updated']} session(s) updated)." + ) + + # ── claim subcommand handlers ─────────────────────────────────────────────── @@ -851,6 +867,27 @@ def _build_parser() -> argparse.ArgumentParser: compare_parser.add_argument("--json", action="store_true", help="Output structured JSON") compare_parser.set_defaults(func=cmd_compare) + # branch — branch lifecycle / disposition + branch_parser = subparsers.add_parser( + "branch", + help="Manage branch lifecycle (mark branches as integrated or abandoned)", + ) + branch_sub = branch_parser.add_subparsers(dest="subcommand", required=True) + + br_close = branch_sub.add_parser( + "close", + help="Set the disposition of a branch (integrated, abandoned, or active)", + ) + br_close.add_argument("space", help="Space name or UUID") + br_close.add_argument("branch_name", help="Branch name to update") + br_close.add_argument( + "--disposition", required=True, + choices=["integrated", "abandoned", "active"], + help="New disposition for the branch", + ) + br_close.add_argument("--json", action="store_true") + br_close.set_defaults(func=cmd_branch_close) + # claim — work claims for pre-work intent visibility claim_parser = subparsers.add_parser( "claim", diff --git a/cli/smriti_cli/mcp_server.py b/cli/smriti_cli/mcp_server.py index dd04a95..e72df5b 100644 --- a/cli/smriti_cli/mcp_server.py +++ b/cli/smriti_cli/mcp_server.py @@ -455,6 +455,41 @@ def smriti_delete_space(space: str) -> str: return f"Deleted space '{s['name']}' and its {len(commits)} checkpoint(s).\n" +@mcp.tool() +def smriti_close_branch( + space: str, + branch: str, + disposition: str = "integrated", +) -> str: + """Mark a branch as integrated, abandoned, or active. + + Branches marked integrated or abandoned stop appearing in the + ## Active branches and ## Divergence signal sections of + smriti_state. Their checkpoints remain in the lineage tree for + history — nothing is deleted. + + Setting back to active re-shows the branch. Fully reversible. + + Call this as part of your clean-finish workflow after a branch's + work has been merged or intentionally stopped. + + Args: + space: Space name or UUID. + branch: Branch name to update. + disposition: "integrated", "abandoned", or "active". + """ + client = _client() + try: + s = client.resolve_space(space) + result = client.close_branch(s["id"], branch, disposition) + except SmritiError as e: + _raise_from(e) + return ( + f"Branch `{result['branch_name']}` marked `{result['disposition']}` " + f"({result['sessions_updated']} session(s) updated)." + ) + + @mcp.tool() def smriti_claim( space: str,