From 341912c22407043650102cb4ed17ae672fc2b1e4 Mon Sep 17 00:00:00 2001 From: nehaaprasaad <278040658+nehaaprasaad@users.noreply.github.com> Date: Mon, 18 May 2026 21:22:26 -0700 Subject: [PATCH] feat(kanban): filter tasks by workflow fields and runs by status/outcome Salvages #26745 by @nehaaprasaad. Exposes filtering for the existing workflow_template_id and current_step_key columns: - list_tasks() accepts workflow_template_id and current_step_key kwargs - 'hermes kanban list' adds matching CLI flags - dashboard plugin_api also exposes the filters Resolved a small conflict in list_tasks signature alongside main's session_id and order_by additions; combined all three into the single filter list. --- hermes_cli/kanban.py | 76 +++++++++++++++++++++++++- hermes_cli/kanban_db.py | 22 ++++++++ plugins/kanban/dashboard/plugin_api.py | 43 ++++++++++++++- tests/hermes_cli/test_kanban_db.py | 41 ++++++++++++++ 4 files changed, 177 insertions(+), 5 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index c389fe02b00..8761241e436 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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)``. @@ -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") @@ -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( @@ -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)) @@ -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: @@ -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 @@ -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([ { diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 00ecca049fd..7d31e8e2ddf 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -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] = [] @@ -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: @@ -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] diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 2c5e595e567..2109fd7950b 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -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. @@ -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]] = {} @@ -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") @@ -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() diff --git a/tests/hermes_cli/test_kanban_db.py b/tests/hermes_cli/test_kanban_db.py index 16159e606ce..5904c9f2085 100644 --- a/tests/hermes_cli/test_kanban_db.py +++ b/tests/hermes_cli/test_kanban_db.py @@ -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")