feat(kanban): scope boards to a project

Boards gain an optional project_id. When set, the board's default_workdir
mirrors the project's primary repo and every new task inherits the project —
a deterministic worktree + branch per task — unless it names its own. New
GET /projects; board create/patch/list carry project_id + resolved name; the
create dialog defaults its workspace to the board's and allows a per-task
path override. Desktop: "Board settings…" gains a project picker.
This commit is contained in:
Brooklyn Nicholson 2026-07-18 23:23:25 -04:00
parent 9be67b7762
commit 027ef381a4
8 changed files with 518 additions and 20 deletions

View file

@ -610,6 +610,9 @@ class CreateTaskBody(BaseModel):
goal_max_turns: Optional[int] = None
model_override: Optional[str] = None
provider_override: Optional[str] = None
# Explicit project link; when omitted, create_task inherits the board's
# scoped project (if any) so a project-scoped board anchors every task.
project_id: Optional[str] = None
@router.post("/tasks")
@ -636,6 +639,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
goal_max_turns=payload.goal_max_turns,
model_override=payload.model_override,
provider_override=payload.provider_override,
project_id=payload.project_id,
board=board,
)
task = kanban_db.get_task(conn, task_id)
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
@ -2078,6 +2083,10 @@ class CreateBoardBody(BaseModel):
icon: Optional[str] = None
color: Optional[str] = None
default_workdir: Optional[str] = None
# First-class Project (id or slug) to scope the board to. When set, the
# board's default_workdir mirrors the project's primary repo and new tasks
# inherit the project (deterministic worktree + branch).
project_id: Optional[str] = None
switch: bool = False
@ -2089,6 +2098,38 @@ class RenameBoardBody(BaseModel):
# Board-level default project directory for new tasks. ``None`` =
# leave unchanged; empty string = clear; a path = validate + set.
default_workdir: Optional[str] = None
# Project scope (id or slug). ``None`` = leave unchanged; empty = clear;
# a value = resolve + set (and mirror default_workdir to its primary repo).
project_id: Optional[str] = None
def _resolve_project(ref: Optional[str]) -> tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve a project id/slug to ``(id, name, primary_path)``.
Returns ``(None, None, None)`` for a falsy ref. Raises 400 when a
non-empty ref doesn't resolve to an existing project.
"""
if not ref or not ref.strip():
return None, None, None
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
proj = pdb.get_project(pconn, ref.strip())
except Exception as exc:
raise HTTPException(status_code=500, detail=f"projects unavailable: {exc}")
if proj is None:
raise HTTPException(status_code=400, detail=f"project {ref!r} does not exist")
return proj.id, proj.name, (proj.primary_path or None)
def _projects_by_id() -> dict[str, Any]:
"""Map every project id -> Project (archived included) for annotation."""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
return {p.id: p for p in pdb.list_projects(pconn, include_archived=True)}
except Exception:
return {}
def _board_counts(slug: str) -> dict[str, int]:
@ -2120,11 +2161,40 @@ def _default_workspace_kind(board: dict[str, Any]) -> str:
return "dir"
@router.get("/projects")
def list_kanban_projects():
"""List first-class Hermes projects for board scoping.
Returns ``{projects: [{id, slug, name, primary_path, icon, color}]}``.
Archived projects are excluded a board can only be scoped to a live one.
"""
try:
from hermes_cli import projects_db as pdb
with pdb.connect_closing() as pconn:
projects = pdb.list_projects(pconn, include_archived=False)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"failed to list projects: {exc}")
return {
"projects": [
{
"id": p.id,
"slug": p.slug,
"name": p.name,
"primary_path": p.primary_path or "",
"icon": p.icon or "",
"color": p.color or "",
}
for p in projects
]
}
@router.get("/boards")
def list_boards(include_archived: bool = Query(False)):
"""Return every board on disk with task counts and the active slug."""
boards = kanban_db.list_boards(include_archived=include_archived)
current = kanban_db.get_current_board()
proj_map = _projects_by_id()
for b in boards:
b["is_current"] = (b["slug"] == current)
b["counts"] = _board_counts(b["slug"])
@ -2135,6 +2205,10 @@ def list_boards(include_archived: bool = Query(False)):
n for status, n in b["counts"].items() if status != "archived"
)
b["default_workspace_kind"] = _default_workspace_kind(b)
pid = b.get("project_id") or None
b["project_id"] = pid
proj = proj_map.get(pid) if pid else None
b["project_name"] = proj.name if proj else None
return {"boards": boards, "current": current}
@ -2164,6 +2238,11 @@ def create_board_endpoint(payload: CreateBoardBody):
default_workdir = None
if payload.default_workdir:
default_workdir = _validate_workdir(payload.default_workdir)
# A chosen project scopes the board: its primary repo becomes the default
# workdir (unless one was passed explicitly) and the link is stored.
project_id, _pname, primary_path = _resolve_project(payload.project_id)
if primary_path and not default_workdir:
default_workdir = primary_path
try:
meta = kanban_db.create_board(
payload.slug,
@ -2172,6 +2251,7 @@ def create_board_endpoint(payload: CreateBoardBody):
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
project_id=project_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@ -2181,6 +2261,7 @@ def create_board_endpoint(payload: CreateBoardBody):
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return {"board": meta, "current": kanban_db.get_current_board()}
@ -2199,6 +2280,17 @@ def rename_board(slug: str, payload: RenameBoardBody):
if payload.default_workdir is not None:
raw = payload.default_workdir.strip()
default_workdir = _validate_workdir(raw) if raw else ""
# project_id: None = leave; "" = clear; value = resolve + mirror its repo
# into default_workdir (unless the caller set default_workdir explicitly).
project_id: Optional[str] = None
project_name: Optional[str] = None
if payload.project_id is not None:
if payload.project_id.strip():
project_id, project_name, primary_path = _resolve_project(payload.project_id)
if primary_path and default_workdir is None:
default_workdir = primary_path
else:
project_id = "" # clear the scope
meta = kanban_db.write_board_metadata(
normed,
name=payload.name,
@ -2206,8 +2298,10 @@ def rename_board(slug: str, payload: RenameBoardBody):
icon=payload.icon,
color=payload.color,
default_workdir=default_workdir,
project_id=project_id,
)
meta["default_workspace_kind"] = _default_workspace_kind(meta)
_, meta["project_name"], _ = _resolve_project(meta.get("project_id"))
return {"board": meta}