mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
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.
This commit is contained in:
parent
e286e68756
commit
341912c224
4 changed files with 177 additions and 5 deletions
|
|
@ -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([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue