Skip to content
Closed
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
50 changes: 50 additions & 0 deletions hermes_cli/kanban.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
67 changes: 67 additions & 0 deletions hermes_cli/kanban_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -1916,6 +1916,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,
Expand Down
28 changes: 25 additions & 3 deletions plugins/kanban/dashboard/dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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" },
Expand Down Expand Up @@ -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(); });
};

Expand Down
10 changes: 9 additions & 1 deletion plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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":
Expand Down
42 changes: 42 additions & 0 deletions tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,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:
Expand Down
43 changes: 43 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,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"]
Expand Down
Loading