mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
9be67b7762
commit
027ef381a4
8 changed files with 518 additions and 20 deletions
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue