Skip to content

Commit 354502e

Browse files
LeonSGP43teknium1
authored andcommitted
fix(kanban): preserve dashboard completion summaries
1 parent cca8587 commit 354502e

6 files changed

Lines changed: 236 additions & 4 deletions

File tree

hermes_cli/kanban.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
343343
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
344344
'"tests_run": 12}\'). Stored on the closing run.')
345345

346+
p_edit = sub.add_parser(
347+
"edit",
348+
help="Edit recovery fields on an already-completed task",
349+
)
350+
p_edit.add_argument("task_id")
351+
p_edit.add_argument(
352+
"--result",
353+
required=True,
354+
help="Backfilled task result text for a done task",
355+
)
356+
p_edit.add_argument(
357+
"--summary",
358+
default=None,
359+
help="Structured handoff summary. Falls back to --result if omitted.",
360+
)
361+
p_edit.add_argument(
362+
"--metadata",
363+
default=None,
364+
help="JSON dict of structured facts to store on the latest completed run.",
365+
)
366+
346367
p_block = sub.add_parser("block", help="Mark one or more tasks blocked")
347368
p_block.add_argument("task_id")
348369
p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
@@ -581,6 +602,7 @@ def kanban_command(args: argparse.Namespace) -> int:
581602
"claim": _cmd_claim,
582603
"comment": _cmd_comment,
583604
"complete": _cmd_complete,
605+
"edit": _cmd_edit,
584606
"block": _cmd_block,
585607
"unblock": _cmd_unblock,
586608
"archive": _cmd_archive,
@@ -1187,6 +1209,34 @@ def _cmd_complete(args: argparse.Namespace) -> int:
11871209
return 0 if not failed else 1
11881210

11891211

1212+
def _cmd_edit(args: argparse.Namespace) -> int:
1213+
raw_meta = getattr(args, "metadata", None)
1214+
metadata = None
1215+
if raw_meta:
1216+
try:
1217+
metadata = json.loads(raw_meta)
1218+
if not isinstance(metadata, dict):
1219+
raise ValueError("must be a JSON object")
1220+
except (ValueError, json.JSONDecodeError) as exc:
1221+
print(f"kanban: --metadata: {exc}", file=sys.stderr)
1222+
return 2
1223+
with kb.connect() as conn:
1224+
if not kb.edit_completed_task_result(
1225+
conn,
1226+
args.task_id,
1227+
result=args.result,
1228+
summary=getattr(args, "summary", None),
1229+
metadata=metadata,
1230+
):
1231+
print(
1232+
f"cannot edit {args.task_id} (unknown id or task is not done)",
1233+
file=sys.stderr,
1234+
)
1235+
return 1
1236+
print(f"Edited {args.task_id}")
1237+
return 0
1238+
1239+
11901240
def _cmd_block(args: argparse.Namespace) -> int:
11911241
reason = " ".join(args.reason).strip() if args.reason else None
11921242
author = _profile_author()

hermes_cli/kanban_db.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,73 @@ def complete_task(
19171917
return True
19181918

19191919

1920+
def edit_completed_task_result(
1921+
conn: sqlite3.Connection,
1922+
task_id: str,
1923+
*,
1924+
result: str,
1925+
summary: Optional[str] = None,
1926+
metadata: Optional[dict] = None,
1927+
) -> bool:
1928+
"""Backfill the user-visible result for an already completed task."""
1929+
handoff_summary = summary if summary is not None else result
1930+
with write_txn(conn):
1931+
row = conn.execute(
1932+
"SELECT status FROM tasks WHERE id = ?", (task_id,),
1933+
).fetchone()
1934+
if not row or row["status"] != "done":
1935+
return False
1936+
conn.execute(
1937+
"UPDATE tasks SET result = ? WHERE id = ?",
1938+
(result, task_id),
1939+
)
1940+
run = conn.execute(
1941+
"""
1942+
SELECT id FROM task_runs
1943+
WHERE task_id = ?
1944+
AND outcome = 'completed'
1945+
ORDER BY COALESCE(ended_at, started_at, 0) DESC, id DESC
1946+
LIMIT 1
1947+
""",
1948+
(task_id,),
1949+
).fetchone()
1950+
run_id = int(run["id"]) if run else None
1951+
if run_id is None:
1952+
run_id = _synthesize_ended_run(
1953+
conn, task_id,
1954+
outcome="completed",
1955+
summary=handoff_summary,
1956+
metadata=metadata,
1957+
)
1958+
else:
1959+
conn.execute(
1960+
"UPDATE task_runs SET summary = ? WHERE id = ?",
1961+
(handoff_summary, run_id),
1962+
)
1963+
if metadata is not None:
1964+
conn.execute(
1965+
"UPDATE task_runs SET metadata = ? WHERE id = ?",
1966+
(json.dumps(metadata, ensure_ascii=False), run_id),
1967+
)
1968+
ev_summary = (
1969+
handoff_summary.strip().splitlines()[0][:400]
1970+
if handoff_summary else ""
1971+
)
1972+
_append_event(
1973+
conn, task_id, "edited",
1974+
{
1975+
"fields": (
1976+
["result", "summary"]
1977+
+ (["metadata"] if metadata is not None else [])
1978+
),
1979+
"result_len": len(result) if result else 0,
1980+
"summary": ev_summary or None,
1981+
},
1982+
run_id=run_id,
1983+
)
1984+
return True
1985+
1986+
19201987
def block_task(
19211988
conn: sqlite3.Connection,
19221989
task_id: str,

plugins/kanban/dashboard/dist/index.js

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@
6060
blocked: "Mark this task as blocked? The worker's claim is released.",
6161
};
6262

63+
function withCompletionSummary(patch, count) {
64+
if (!patch || patch.status !== "done") return patch;
65+
const label = count && count > 1 ? `${count} selected task(s)` : "this task";
66+
const value = window.prompt(
67+
`Completion summary for ${label}. This is stored as the task result.`,
68+
"",
69+
);
70+
if (value === null) return null;
71+
const summary = value.trim();
72+
if (!summary) {
73+
window.alert("Completion summary is required before marking a task done.");
74+
return null;
75+
}
76+
return Object.assign({}, patch, { result: summary, summary });
77+
}
78+
6379
const API = "/api/plugins/kanban";
6480
const MIME_TASK = "text/x-hermes-task";
6581

@@ -480,6 +496,8 @@
480496
const moveTask = useCallback(function (taskId, newStatus) {
481497
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
482498
if (confirmMsg && !window.confirm(confirmMsg)) return;
499+
const patch = withCompletionSummary({ status: newStatus }, 1);
500+
if (!patch) return;
483501
setBoardData(function (b) {
484502
if (!b) return b;
485503
let moved = null;
@@ -499,7 +517,7 @@
499517
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
500518
method: "PATCH",
501519
headers: { "Content-Type": "application/json" },
502-
body: JSON.stringify({ status: newStatus }),
520+
body: JSON.stringify(patch),
503521
}).catch(function (err) {
504522
setError(`Move failed: ${err.message || err}`);
505523
loadBoard();
@@ -538,7 +556,9 @@
538556
const applyBulk = useCallback(function (patch, confirmMsg) {
539557
if (selectedIds.size === 0) return;
540558
if (confirmMsg && !window.confirm(confirmMsg)) return;
541-
const body = Object.assign({ ids: Array.from(selectedIds) }, patch);
559+
const finalPatch = withCompletionSummary(patch, selectedIds.size);
560+
if (!finalPatch) return;
561+
const body = Object.assign({ ids: Array.from(selectedIds) }, finalPatch);
542562
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
543563
method: "POST",
544564
headers: { "Content-Type": "application/json" },
@@ -1426,10 +1446,12 @@
14261446
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
14271447
return Promise.resolve();
14281448
}
1449+
const finalPatch = withCompletionSummary(patch, 1);
1450+
if (!finalPatch) return Promise.resolve();
14291451
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
14301452
method: "PATCH",
14311453
headers: { "Content-Type": "application/json" },
1432-
body: JSON.stringify(patch),
1454+
body: JSON.stringify(finalPatch),
14331455
}).then(function () { load(); props.onRefresh(); });
14341456
};
14351457

plugins/kanban/dashboard/plugin_api.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,9 @@ class BulkTaskBody(BaseModel):
630630
assignee: Optional[str] = None # "" or None = unassign
631631
priority: Optional[int] = None
632632
archive: bool = False
633+
result: Optional[str] = None
634+
summary: Optional[str] = None
635+
metadata: Optional[dict] = None
633636

634637

635638
@router.post("/tasks/bulk")
@@ -660,7 +663,12 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
660663
if payload.status is not None and not payload.archive:
661664
s = payload.status
662665
if s == "done":
663-
ok = kanban_db.complete_task(conn, tid)
666+
ok = kanban_db.complete_task(
667+
conn, tid,
668+
result=payload.result,
669+
summary=payload.summary,
670+
metadata=payload.metadata,
671+
)
664672
elif s == "blocked":
665673
ok = kanban_db.block_task(conn, tid)
666674
elif s == "ready":

tests/hermes_cli/test_kanban_core_functionality.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home):
13891389
assert r.metadata == {"files": 3}
13901390

13911391

1392+
def test_cli_edit_backfills_result_on_done_task(kanban_home):
1393+
conn = kb.connect()
1394+
try:
1395+
tid = kb.create_task(conn, title="x", assignee="worker")
1396+
kb.complete_task(conn, tid)
1397+
finally:
1398+
conn.close()
1399+
1400+
meta = '{"source": "dashboard-recovery"}'
1401+
out = run_slash(
1402+
"edit " + tid
1403+
+ " --result \"DECIDED: done\""
1404+
+ " --summary \"DECIDED: done\""
1405+
+ " --metadata '" + meta + "'"
1406+
)
1407+
1408+
assert "Edited" in out
1409+
conn = kb.connect()
1410+
try:
1411+
task = kb.get_task(conn, tid)
1412+
run = kb.latest_run(conn, tid)
1413+
events = kb.list_events(conn, tid)
1414+
finally:
1415+
conn.close()
1416+
assert task.result == "DECIDED: done"
1417+
assert run.summary == "DECIDED: done"
1418+
assert run.metadata == {"source": "dashboard-recovery"}
1419+
assert events[-1].kind == "edited"
1420+
1421+
1422+
def test_cli_edit_rejects_non_done_task(kanban_home):
1423+
conn = kb.connect()
1424+
try:
1425+
tid = kb.create_task(conn, title="x", assignee="worker")
1426+
finally:
1427+
conn.close()
1428+
1429+
out = run_slash(f"edit {tid} --result nope")
1430+
1431+
assert "not done" in out
1432+
1433+
13921434
def test_cli_complete_bad_metadata_exits_nonzero(kanban_home):
13931435
conn = kb.connect()
13941436
try:

tests/plugins/test_kanban_dashboard_plugin.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,49 @@ def test_bulk_status_ready(client):
561561
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
562562

563563

564+
def test_bulk_status_done_forwards_completion_summary(client):
565+
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
566+
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
567+
568+
r = client.post(
569+
"/api/plugins/kanban/tasks/bulk",
570+
json={
571+
"ids": [a["id"], b["id"]],
572+
"status": "done",
573+
"result": "DECIDED: ship it",
574+
"summary": "DECIDED: ship it",
575+
"metadata": {"source": "dashboard"},
576+
},
577+
)
578+
579+
assert r.status_code == 200
580+
assert all(r["ok"] for r in r.json()["results"])
581+
conn = kb.connect()
582+
try:
583+
for tid in (a["id"], b["id"]):
584+
task = kb.get_task(conn, tid)
585+
run = kb.latest_run(conn, tid)
586+
assert task.status == "done"
587+
assert task.result == "DECIDED: ship it"
588+
assert run.summary == "DECIDED: ship it"
589+
assert run.metadata == {"source": "dashboard"}
590+
finally:
591+
conn.close()
592+
593+
594+
def test_dashboard_done_actions_prompt_for_completion_summary():
595+
repo_root = Path(__file__).resolve().parents[2]
596+
bundle = (
597+
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
598+
).read_text()
599+
600+
assert "withCompletionSummary" in bundle
601+
assert "Completion summary" in bundle
602+
assert "result: summary" in bundle
603+
assert "body: JSON.stringify(patch)" in bundle
604+
assert "body: JSON.stringify(finalPatch)" in bundle
605+
606+
564607
def test_bulk_archive(client):
565608
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
566609
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]

0 commit comments

Comments
 (0)