mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
fix(kanban): preserve dashboard completion summaries
This commit is contained in:
parent
cca8587d35
commit
354502ee48
6 changed files with 236 additions and 4 deletions
|
|
@ -343,6 +343,27 @@ def build_parser(parent_subparsers: argparse._SubParsersAction) -> argparse.Argu
|
||||||
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
|
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
|
||||||
'"tests_run": 12}\'). Stored on the closing run.')
|
'"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 = sub.add_parser("block", help="Mark one or more tasks blocked")
|
||||||
p_block.add_argument("task_id")
|
p_block.add_argument("task_id")
|
||||||
p_block.add_argument("reason", nargs="*", help="Reason (also appended as a comment)")
|
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,
|
"claim": _cmd_claim,
|
||||||
"comment": _cmd_comment,
|
"comment": _cmd_comment,
|
||||||
"complete": _cmd_complete,
|
"complete": _cmd_complete,
|
||||||
|
"edit": _cmd_edit,
|
||||||
"block": _cmd_block,
|
"block": _cmd_block,
|
||||||
"unblock": _cmd_unblock,
|
"unblock": _cmd_unblock,
|
||||||
"archive": _cmd_archive,
|
"archive": _cmd_archive,
|
||||||
|
|
@ -1187,6 +1209,34 @@ def _cmd_complete(args: argparse.Namespace) -> int:
|
||||||
return 0 if not failed else 1
|
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:
|
def _cmd_block(args: argparse.Namespace) -> int:
|
||||||
reason = " ".join(args.reason).strip() if args.reason else None
|
reason = " ".join(args.reason).strip() if args.reason else None
|
||||||
author = _profile_author()
|
author = _profile_author()
|
||||||
|
|
|
||||||
|
|
@ -1917,6 +1917,73 @@ def complete_task(
|
||||||
return True
|
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(
|
def block_task(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
|
|
||||||
28
plugins/kanban/dashboard/dist/index.js
vendored
28
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -60,6 +60,22 @@
|
||||||
blocked: "Mark this task as blocked? The worker's claim is released.",
|
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 API = "/api/plugins/kanban";
|
||||||
const MIME_TASK = "text/x-hermes-task";
|
const MIME_TASK = "text/x-hermes-task";
|
||||||
|
|
||||||
|
|
@ -480,6 +496,8 @@
|
||||||
const moveTask = useCallback(function (taskId, newStatus) {
|
const moveTask = useCallback(function (taskId, newStatus) {
|
||||||
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
const confirmMsg = DESTRUCTIVE_TRANSITIONS[newStatus];
|
||||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||||
|
const patch = withCompletionSummary({ status: newStatus }, 1);
|
||||||
|
if (!patch) return;
|
||||||
setBoardData(function (b) {
|
setBoardData(function (b) {
|
||||||
if (!b) return b;
|
if (!b) return b;
|
||||||
let moved = null;
|
let moved = null;
|
||||||
|
|
@ -499,7 +517,7 @@
|
||||||
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
|
SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(taskId)}`, board), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ status: newStatus }),
|
body: JSON.stringify(patch),
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
setError(`Move failed: ${err.message || err}`);
|
setError(`Move failed: ${err.message || err}`);
|
||||||
loadBoard();
|
loadBoard();
|
||||||
|
|
@ -538,7 +556,9 @@
|
||||||
const applyBulk = useCallback(function (patch, confirmMsg) {
|
const applyBulk = useCallback(function (patch, confirmMsg) {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
if (confirmMsg && !window.confirm(confirmMsg)) 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), {
|
SDK.fetchJSON(withBoard(`${API}/tasks/bulk`, board), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -1426,10 +1446,12 @@
|
||||||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
const finalPatch = withCompletionSummary(patch, 1);
|
||||||
|
if (!finalPatch) return Promise.resolve();
|
||||||
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
|
return SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}`, boardSlug), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(patch),
|
body: JSON.stringify(finalPatch),
|
||||||
}).then(function () { load(); props.onRefresh(); });
|
}).then(function () { load(); props.onRefresh(); });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -630,6 +630,9 @@ class BulkTaskBody(BaseModel):
|
||||||
assignee: Optional[str] = None # "" or None = unassign
|
assignee: Optional[str] = None # "" or None = unassign
|
||||||
priority: Optional[int] = None
|
priority: Optional[int] = None
|
||||||
archive: bool = False
|
archive: bool = False
|
||||||
|
result: Optional[str] = None
|
||||||
|
summary: Optional[str] = None
|
||||||
|
metadata: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/bulk")
|
@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:
|
if payload.status is not None and not payload.archive:
|
||||||
s = payload.status
|
s = payload.status
|
||||||
if s == "done":
|
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":
|
elif s == "blocked":
|
||||||
ok = kanban_db.block_task(conn, tid)
|
ok = kanban_db.block_task(conn, tid)
|
||||||
elif s == "ready":
|
elif s == "ready":
|
||||||
|
|
|
||||||
|
|
@ -1389,6 +1389,48 @@ def test_cli_complete_with_summary_and_metadata(kanban_home):
|
||||||
assert r.metadata == {"files": 3}
|
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):
|
def test_cli_complete_bad_metadata_exits_nonzero(kanban_home):
|
||||||
conn = kb.connect()
|
conn = kb.connect()
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -561,6 +561,49 @@ def test_bulk_status_ready(client):
|
||||||
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
|
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):
|
def test_bulk_archive(client):
|
||||||
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
||||||
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue