Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,25 @@ def _task_to_dict(t: kb.Task) -> dict[str, Any]:
"result": t.result,
"skills": list(t.skills) if t.skills else [],
"max_retries": t.max_retries,
<<<<<<< HEAD
"session_id": t.session_id,
=======
"workflow_template_id": t.workflow_template_id,
"current_step_key": t.current_step_key,
>>>>>>> 503702ea9 (kanban: filter tasks by workflow fields and runs by status/outcome)
}


def _run_state_kwargs(args: argparse.Namespace) -> Optional[dict[str, str]]:
st = getattr(args, "state_type", None)
sn = getattr(args, "state_name", None)
if (st is None) != (sn is None):
return None
if st is None:
return {}
return {"state_type": st, "state_name": sn}


def _parse_workspace_flag(value: str) -> tuple[str, Optional[str]]:
"""Parse ``--workspace`` into ``(kind, path|None)``.

Expand Down Expand Up @@ -351,16 +366,42 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
help="Include archived tasks")
p_list.add_argument("--json", action="store_true")
p_list.add_argument(
<<<<<<< HEAD
"--sort",
default=None,
choices=sorted(kb.VALID_SORT_ORDERS.keys()),
help="Sort order for listed tasks (default: priority)",
=======
"--workflow-template-id",
default=None,
metavar="ID",
help="Restrict to tasks with this workflow_template_id",
)
p_list.add_argument(
"--step-key",
default=None,
dest="current_step_key",
metavar="KEY",
help="Restrict to tasks with this current_step_key",
>>>>>>> 503702ea9 (kanban: filter tasks by workflow fields and runs by status/outcome)
)

# --- show ---
p_show = sub.add_parser("show", help="Show a task with comments + events")
p_show.add_argument("task_id")
p_show.add_argument("--json", action="store_true")
p_show.add_argument(
"--state-type",
choices=("status", "outcome"),
default=None,
help="With --state-name: filter listed runs by task_runs column",
)
p_show.add_argument(
"--state-name",
default=None,
metavar="VALUE",
help="With --state-type: keep runs whose column equals this value",
)

# --- assign ---
p_assign = sub.add_parser("assign", help="Assign or reassign a task")
Expand Down Expand Up @@ -607,6 +648,18 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
)
p_runs.add_argument("task_id")
p_runs.add_argument("--json", action="store_true")
p_runs.add_argument(
"--state-type",
choices=("status", "outcome"),
default=None,
help="With --state-name: filter runs by task_runs column",
)
p_runs.add_argument(
"--state-name",
default=None,
metavar="VALUE",
help="With --state-type: keep runs whose column equals this value",
)

# --- heartbeat (worker liveness signal) ---
p_hb = sub.add_parser(
Expand Down Expand Up @@ -1285,7 +1338,12 @@ def _cmd_list(args: argparse.Namespace) -> int:
tenant=args.tenant,
session_id=args.session,
include_archived=args.archived,
<<<<<<< HEAD
order_by=getattr(args, "sort", None),
=======
workflow_template_id=args.workflow_template_id,
current_step_key=args.current_step_key,
>>>>>>> 503702ea9 (kanban: filter tasks by workflow fields and runs by status/outcome)
)
if getattr(args, "json", False):
print(json.dumps([_task_to_dict(t) for t in tasks], indent=2, ensure_ascii=False))
Expand Down Expand Up @@ -1314,6 +1372,13 @@ def _cmd_list(args: argparse.Namespace) -> int:


def _cmd_show(args: argparse.Namespace) -> int:
rsk = _run_state_kwargs(args)
if rsk is None:
print(
"kanban show: pass both --state-type and --state-name, or omit both",
file=sys.stderr,
)
return 2
with kb.connect() as conn:
task = kb.get_task(conn, args.task_id)
if not task:
Expand All @@ -1323,7 +1388,7 @@ def _cmd_show(args: argparse.Namespace) -> int:
events = kb.list_events(conn, args.task_id)
parents = kb.parent_ids(conn, args.task_id)
children = kb.child_ids(conn, args.task_id)
runs = kb.list_runs(conn, args.task_id)
runs = kb.list_runs(conn, args.task_id, **rsk)
# Workers hand off via ``task_runs.summary`` (kanban-worker skill);
# ``tasks.result`` is left NULL unless the caller explicitly passed
# ``result=``. Surfacing the latest summary here keeps ``show`` from
Expand Down Expand Up @@ -2205,8 +2270,15 @@ def _cmd_log(args: argparse.Namespace) -> int:

def _cmd_runs(args: argparse.Namespace) -> int:
"""Show attempt history for a task."""
rsk = _run_state_kwargs(args)
if rsk is None:
print(
"kanban runs: pass both --state-type and --state-name, or omit both",
file=sys.stderr,
)
return 2
with kb.connect() as conn:
runs = kb.list_runs(conn, args.task_id)
runs = kb.list_runs(conn, args.task_id, **rsk)
if getattr(args, "json", False):
print(json.dumps([
{
Expand Down
22 changes: 22 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,8 @@ def list_tasks(
include_archived: bool = False,
limit: Optional[int] = None,
order_by: Optional[str] = None,
workflow_template_id: Optional[str] = None,
current_step_key: Optional[str] = None,
) -> list[Task]:
query = "SELECT * FROM tasks WHERE 1=1"
params: list[Any] = []
Expand All @@ -1605,6 +1607,12 @@ def list_tasks(
if session_id is not None:
query += " AND session_id = ?"
params.append(session_id)
if workflow_template_id is not None:
query += " AND workflow_template_id = ?"
params.append(workflow_template_id)
if current_step_key is not None:
query += " AND current_step_key = ?"
params.append(current_step_key)
if not include_archived and status != "archived":
query += " AND status != 'archived'"
if order_by is not None:
Expand Down Expand Up @@ -5854,17 +5862,31 @@ def list_runs(
task_id: str,
*,
include_active: bool = True,
state_type: Optional[str] = None,
state_name: Optional[str] = None,
) -> list[Run]:
"""Return all runs for ``task_id`` in start order.

``include_active=True`` (default) includes the currently-running
attempt if any. Set False to return only closed runs (useful for
"how many prior attempts have there been?" checks).

When ``state_type`` and ``state_name`` are set, restrict to rows
where that column equals ``state_name`` (``state_type`` is
``status`` or ``outcome``). Both must be passed together.
"""
if (state_type is None) ^ (state_name is None):
raise ValueError("state_type and state_name must both be set or both omitted")
if state_type is not None:
if state_type not in ("status", "outcome"):
raise ValueError("state_type must be 'status' or 'outcome'")
q = "SELECT * FROM task_runs WHERE task_id = ?"
params: list[Any] = [task_id]
if not include_active:
q += " AND ended_at IS NOT NULL"
if state_type is not None:
q += f" AND {state_type} = ?"
params.append(state_name)
q += " ORDER BY started_at ASC, id ASC"
rows = conn.execute(q, params).fetchall()
return [Run.from_row(r) for r in rows]
Expand Down
43 changes: 40 additions & 3 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,12 @@ def get_board(
tenant: Optional[str] = Query(None, description="Filter to a single tenant"),
include_archived: bool = Query(False),
board: Optional[str] = Query(None, description="Kanban board slug (omit for current)"),
workflow_template_id: Optional[str] = Query(
None, description="Restrict to tasks using this workflow template id",
),
current_step_key: Optional[str] = Query(
None, description="Restrict to tasks at this workflow step key",
),
):
"""Return the full board grouped by status column.

Expand All @@ -368,7 +374,11 @@ def get_board(
conn = _conn(board=board)
try:
tasks = kanban_db.list_tasks(
conn, tenant=tenant, include_archived=include_archived
conn,
tenant=tenant,
include_archived=include_archived,
workflow_template_id=workflow_template_id,
current_step_key=current_step_key,
)
# Pre-fetch link counts per task (cheap: one query).
link_counts: dict[str, dict[str, int]] = {}
Expand Down Expand Up @@ -479,10 +489,29 @@ def get_board(
# ---------------------------------------------------------------------------

@router.get("/tasks/{task_id}")
def get_task(task_id: str, board: Optional[str] = Query(None)):
def get_task(
task_id: str,
board: Optional[str] = Query(None),
run_state_type: Optional[str] = Query(
None, description="With run_state_name: filter runs by column 'status' or 'outcome'",
),
run_state_name: Optional[str] = Query(
None, description="With run_state_type: exact value for that run column",
),
):
board = _resolve_board(board)
conn = _conn(board=board)
try:
if (run_state_type is None) ^ (run_state_name is None):
raise HTTPException(
status_code=400,
detail="run_state_type and run_state_name must be passed together or omitted",
)
if run_state_type is not None and run_state_type not in ("status", "outcome"):
raise HTTPException(
status_code=400,
detail="run_state_type must be 'status' or 'outcome'",
)
task = kanban_db.get_task(conn, task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
Expand All @@ -503,7 +532,15 @@ def get_task(task_id: str, board: Optional[str] = Query(None)):
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
"links": _links_for(conn, task_id),
"runs": [_run_dict(r) for r in kanban_db.list_runs(conn, task_id)],
"runs": [
_run_dict(r)
for r in kanban_db.list_runs(
conn,
task_id,
state_type=run_state_type,
state_name=run_state_name,
)
],
}
finally:
conn.close()
Expand Down
41 changes: 41 additions & 0 deletions tests/hermes_cli/test_kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,47 @@ def test_tenant_column_filters_listings(kanban_home):
assert [t.title for t in biz_b] == ["b1"]


def test_list_tasks_filters_workflow_template_and_step(kanban_home):
with kb.connect() as conn:
ta = kb.create_task(conn, title="alpha")
tb = kb.create_task(conn, title="beta")
conn.execute(
"UPDATE tasks SET workflow_template_id=?, current_step_key=? WHERE id=?",
("wf1", "step_x", ta),
)
conn.execute(
"UPDATE tasks SET workflow_template_id=?, current_step_key=? WHERE id=?",
("wf1", "step_y", tb),
)
conn.commit()
by_wf = kb.list_tasks(conn, workflow_template_id="wf1")
by_step = kb.list_tasks(conn, current_step_key="step_x")
assert {x.id for x in by_wf} == {ta, tb}
assert [x.id for x in by_step] == [ta]


def test_list_runs_state_filter_requires_pair_and_valid_type(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="t", assignee="alice")
with kb.connect() as conn:
with pytest.raises(ValueError, match="both"):
kb.list_runs(conn, tid, state_type="status", state_name=None)
with pytest.raises(ValueError, match="both"):
kb.list_runs(conn, tid, state_type=None, state_name="done")
with pytest.raises(ValueError, match="state_type"):
kb.list_runs(conn, tid, state_type="nope", state_name="done")


def test_list_runs_filters_by_outcome_value(kanban_home):
with kb.connect() as conn:
tid = kb.create_task(conn, title="t", assignee="alice")
kb.complete_task(conn, tid, summary="ok")
matching = kb.list_runs(conn, tid, state_type="outcome", state_name="completed")
empty = kb.list_runs(conn, tid, state_type="outcome", state_name="blocked")
assert matching
assert not empty


def test_tenant_propagates_to_events(kanban_home):
with kb.connect() as conn:
t = kb.create_task(conn, title="tenant-task", tenant="biz-a")
Expand Down
Loading