From 354502ee483f4bd3a0b1b8ef650482762b2444e0 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Tue, 5 May 2026 11:07:13 +0800 Subject: [PATCH] fix(kanban): preserve dashboard completion summaries --- hermes_cli/kanban.py | 50 ++++++++++++++ hermes_cli/kanban_db.py | 67 +++++++++++++++++++ plugins/kanban/dashboard/dist/index.js | 28 +++++++- plugins/kanban/dashboard/plugin_api.py | 10 ++- .../test_kanban_core_functionality.py | 42 ++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 43 ++++++++++++ 6 files changed, 236 insertions(+), 4 deletions(-) diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 4d738eaff0..eb761a2afb 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -343,6 +343,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu help='JSON dict of structured facts (e.g. \'{"changed_files": [...], ' '"tests_run": 12}\'). Stored on the closing run.') + p_edit = sub.add_parser( + "edit", + help="Edit recovery fields on an already-completed task", + ) + p_edit.add_argument("task_id") + p_edit.add_argument( + "--result", + required=True, + help="Backfilled task result text for a done task", + ) + p_edit.add_argument( + "--summary", + default=None, + help="Structured handoff summary. Falls back to --result if omitted.", + ) + p_edit.add_argument( + "--metadata", + default=None, + help="JSON dict of structured facts to store on the latest completed run.", + ) + p_block = sub.add_parser("block", help="Mark one or more tasks blocked") p_block.add_argument("task_id") p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)") @@ -581,6 +602,7 @@ def kanban_command(args: argparse.Namespace) -> int: "claim": _cmd_claim, "comment": _cmd_comment, "complete": _cmd_complete, + "edit": _cmd_edit, "block": _cmd_block, "unblock": _cmd_unblock, "archive": _cmd_archive, @@ -1187,6 +1209,34 @@ def _cmd_complete(args: argparse.Namespace) -> int: return 0 if not failed else 1 +def _cmd_edit(args: argparse.Namespace) -> int: + raw_meta = getattr(args, "metadata", None) + metadata = None + if raw_meta: + try: + metadata = json.loads(raw_meta) + if not isinstance(metadata, dict): + raise ValueError("must be a JSON object") + except (ValueError, json.JSONDecodeError) as exc: + print(f"kanban: --metadata: {exc}", file=sys.stderr) + return 2 + with kb.connect() as conn: + if not kb.edit_completed_task_result( + conn, + args.task_id, + result=args.result, + summary=getattr(args, "summary", None), + metadata=metadata, + ): + print( + f"cannot edit {args.task_id} (unknown id or task is not done)", + file=sys.stderr, + ) + return 1 + print(f"Edited {args.task_id}") + return 0 + + def _cmd_block(args: argparse.Namespace) -> int: reason = " ".join(args.reason).strip() if args.reason else None author = _profile_author() diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index 6ca7894ee1..98607e3460 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -1917,6 +1917,73 @@ def complete_task( return True +def edit_completed_task_result( + conn: sqlite3.Connection, + task_id: str, + *, + result: str, + summary: Optional[str] = None, + metadata: Optional[dict] = None, +) -> bool: + """Backfill the user-visible result for an already completed task.""" + handoff_summary = summary if summary is not None else result + with write_txn(conn): + row = conn.execute( + "SELECT status FROM tasks WHERE id = ?", (task_id,), + ).fetchone() + if not row or row["status"] != "done": + return False + conn.execute( + "UPDATE tasks SET result = ? WHERE id = ?", + (result, task_id), + ) + run = conn.execute( + """ + SELECT id FROM task_runs + WHERE task_id = ? + AND outcome = 'completed' + ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC + LIMIT 1 + """, + (task_id,), + ).fetchone() + run_id = int(run["id"]) if run else None + if run_id is None: + run_id = _synthesize_ended_run( + conn, task_id, + outcome="completed", + summary=handoff_summary, + metadata=metadata, + ) + else: + conn.execute( + "UPDATE task_runs SET summary = ? WHERE id = ?", + (handoff_summary, run_id), + ) + if metadata is not None: + conn.execute( + "UPDATE task_runs SET metadata = ? WHERE id = ?", + (json.dumps(metadata, ensure_ascii=False), run_id), + ) + ev_summary = ( + handoff_summary.strip().splitlines()[0][:400] + if handoff_summary else "" + ) + _append_event( + conn, task_id, "edited", + { + "fields": ( + ["result", "summary"] + + (["metadata"] if metadata is not None else []) + ), + "result_len": len(result) if result else 0, + "summary": ev_summary or None, + }, + run_id=run_id, + ) + return True + + def block_task( conn: sqlite3.Connection, task_id: str, diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index ee1a25f065..67bc6d6d23 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -60,6 +60,22 @@ blocked: "Mark this task as blocked? The worker's claim is released.", }; + function withCompletionSummary(patch, count) { + if (!patch || patch.status !== "done") return patch; + const label = count && count > 1 ? `${count} selected task(s)` : "this task"; + const value = window.prompt( + `Completion summary for ${label}. This is stored as the task result.`, + "", + ); + if (value === null) return null; + const summary = value.trim(); + if (!summary) { + window.alert("Completion summary is required before marking a task done."); + return null; + } + return Object.assign({}, patch, { result: summary, summary }); + } + const API = "/api/plugins/kanban"; const MIME_TASK = "text/x-hermes-task"; @@ -480,6 +496,8 @@ const moveTask = useCallback(function (taskId, newStatus) { const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus]; if (confirmMsg && !window.confirm(confirmMsg)) return; + const patch = withCompletionSummary({ status: newStatus }, 1); + if (!patch) return; setBoardData(function (b) { if (!b) return b; let moved = null; @@ -499,7 +517,7 @@ SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ status: newStatus }), + body: JSON.stringify(patch), }).catch(function (err) { setError(`Move failed: ${err.message || err}`); loadBoard(); @@ -538,7 +556,9 @@ const applyBulk = useCallback(function (patch, confirmMsg) { if (selectedIds.size === 0) return; if (confirmMsg && !window.confirm(confirmMsg)) return; - const body = Object.assign({ ids: Array.from(selectedIds) }, patch); + const finalPatch = withCompletionSummary(patch, selectedIds.size); + if (!finalPatch) return; + const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch); SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), { method: "POST", headers: { "Content-Type": "application/json" }, @@ -1426,10 +1446,12 @@ if (opts && opts.confirm && !window.confirm(opts.confirm)) { return Promise.resolve(); } + const finalPatch = withCompletionSummary(patch, 1); + if (!finalPatch) return Promise.resolve(); return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), + body: JSON.stringify(finalPatch), }).then(function () { load(); props.onRefresh(); }); }; diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 2378baaac7..e1dfd9c190 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -630,6 +630,9 @@ class BulkTaskBody(BaseModel): assignee: Optional[str] = None # "" or None = unassign priority: Optional[int] = None archive: bool = False + result: Optional[str] = None + summary: Optional[str] = None + metadata: Optional[dict] = None @router.post("/tasks/bulk") @@ -660,7 +663,12 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)): if payload.status is not None and not payload.archive: s = payload.status if s == "done": - ok = kanban_db.complete_task(conn, tid) + ok = kanban_db.complete_task( + conn, tid, + result=payload.result, + summary=payload.summary, + metadata=payload.metadata, + ) elif s == "blocked": ok = kanban_db.block_task(conn, tid) elif s == "ready": diff --git a/tests/hermes_cli/test_kanban_core_functionality.py b/tests/hermes_cli/test_kanban_core_functionality.py index 6bc198ab99..6378af8e98 100644 --- a/tests/hermes_cli/test_kanban_core_functionality.py +++ b/tests/hermes_cli/test_kanban_core_functionality.py @@ -1389,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home): assert r.metadata == {"files": 3} +def test_cli_edit_backfills_result_on_done_task(kanban_home): + conn = kb.connect() + try: + tid = kb.create_task(conn, title="x", assignee="worker") + kb.complete_task(conn, tid) + finally: + conn.close() + + meta = '{"source": "dashboard-recovery"}' + out = run_slash( + "edit " + tid + + " --result \"DECIDED: done\"" + + " --summary \"DECIDED: done\"" + + " --metadata '" + meta + "'" + ) + + assert "Edited" in out + conn = kb.connect() + try: + task = kb.get_task(conn, tid) + run = kb.latest_run(conn, tid) + events = kb.list_events(conn, tid) + finally: + conn.close() + assert task.result == "DECIDED: done" + assert run.summary == "DECIDED: done" + assert run.metadata == {"source": "dashboard-recovery"} + assert events[-1].kind == "edited" + + +def test_cli_edit_rejects_non_done_task(kanban_home): + conn = kb.connect() + try: + tid = kb.create_task(conn, title="x", assignee="worker") + finally: + conn.close() + + out = run_slash(f"edit {tid} --result nope") + + assert "not done" in out + + def test_cli_complete_bad_metadata_exits_nonzero(kanban_home): conn = kb.connect() try: diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 32e40ceb4e..ca8f59cccf 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -561,6 +561,49 @@ def test_bulk_status_ready(client): assert {a["id"], b["id"], c2["id"]}.issubset(ids) +def test_bulk_status_done_forwards_completion_summary(client): + a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] + b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"] + + r = client.post( + "/api/plugins/kanban/tasks/bulk", + json={ + "ids": [a["id"], b["id"]], + "status": "done", + "result": "DECIDED: ship it", + "summary": "DECIDED: ship it", + "metadata": {"source": "dashboard"}, + }, + ) + + assert r.status_code == 200 + assert all(r["ok"] for r in r.json()["results"]) + conn = kb.connect() + try: + for tid in (a["id"], b["id"]): + task = kb.get_task(conn, tid) + run = kb.latest_run(conn, tid) + assert task.status == "done" + assert task.result == "DECIDED: ship it" + assert run.summary == "DECIDED: ship it" + assert run.metadata == {"source": "dashboard"} + finally: + conn.close() + + +def test_dashboard_done_actions_prompt_for_completion_summary(): + repo_root = Path(__file__).resolve().parents[2] + bundle = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + ).read_text() + + assert "withCompletionSummary" in bundle + assert "Completion summary" in bundle + assert "result: summary" in bundle + assert "body: JSON.stringify(patch)" in bundle + assert "body: JSON.stringify(finalPatch)" in bundle + + def test_bulk_archive(client): a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"] b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]