mirror of
https://github.com/himanshudongre/smriti.git
synced 2026-08-28 05:14:59 +00:00
Add branch disposition for lifecycle closure: integrated, abandoned, active
This commit is contained in:
parent
cff02163c3
commit
405592da55
8 changed files with 409 additions and 6 deletions
|
|
@ -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")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
184
backend/tests/integration/test_branch_disposition.py
Normal file
184
backend/tests/integration/test_branch_disposition.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue