From 110f3ca555457c87cac10d97d58c56b1256af966 Mon Sep 17 00:00:00 2001 From: SatoDri Date: Sun, 24 May 2026 12:43:52 +0500 Subject: [PATCH] feat(kanban): add read-only task summary tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /tasks/{id}/summary-tree and renders the tree in the task drawer: per-node status, links, latest run summary/metadata, display result, comments, blocked context, and artifact metadata (run metadata paths plus first-class task attachments), all read-only — no local file opener and no comment-path scraping. Co-Authored-By: Claude Fable 5 --- plugins/kanban/dashboard/dist/index.js | 165 +++++ plugins/kanban/dashboard/dist/style.css | 127 ++++ plugins/kanban/dashboard/plugin_api.py | 626 ++++++++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 286 ++++++++ 4 files changed, 1204 insertions(+) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 4dd7564f7d431..5b1d6db0061a0 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -3356,6 +3356,166 @@ ); } + function ArtifactList(props) { + const { t: i18n } = useI18n(); + const artifacts = props.artifacts || []; + if (!artifacts.length) { + return h("div", { className: "text-xs text-muted-foreground" }, + tx(i18n, "noArtifactMetadata", "No artifact metadata")); + } + return h("div", { className: "hermes-kanban-artifact-list" }, + artifacts.map(function (a, idx) { + const label = a.path || a.label || a.kind || "artifact"; + return h("div", { key: `${a.source || "artifact"}-${idx}`, className: "hermes-kanban-artifact-row" }, + h("span", { className: "hermes-kanban-artifact-kind" }, a.kind || "artifact"), + h("code", { className: "hermes-kanban-artifact-path", title: a.resolved_path || label }, label), + a.size != null ? h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" }, _fmtBytes(a.size)) : null, + h("span", { className: `hermes-kanban-artifact-state hermes-kanban-artifact-state--${a.availability || "unknown"}` }, a.availability || "unknown"), + ); + }), + ); + } + + function BlockedSummaryPanel(props) { + const { t: i18n } = useI18n(); + const block = props.block; + if (!block || !block.is_blocked) return null; + return h("div", { className: "hermes-kanban-blocked-summary" }, + h("div", { className: "hermes-kanban-blocked-summary-title" }, + tx(i18n, "blockedMissingInput", "Blocked — missing input needed")), + block.reason ? h("div", { className: "text-xs" }, block.reason) : null, + block.latest_relevant_comment ? h("div", { className: "text-xs text-muted-foreground" }, + `${tx(i18n, "latestComment", "Latest comment")}: ${block.latest_relevant_comment.body || ""}`) : null, + h("div", { className: "text-xs text-muted-foreground" }, + block.comment_prompt || tx(i18n, "blockedCommentPrompt", "Add the missing info as a comment, then unblock when ready.")), + ); + } + + // Latest-run line for a summary node: profile · outcome · age. The run's + // raw metadata sits behind a
so operators can inspect the + // handoff without leaving the tree. Read-only — no run controls here. + // props.shownResult is the text the node body already renders; the run + // summary only repeats here when an explicit task result shadows it. + function SummaryNodeRun(props) { + const { t: i18n } = useI18n(); + const run = props.run; + if (!run) return null; + const parts = [run.profile, run.outcome || run.status].filter(Boolean); + const when = run.ended_at || run.started_at; + if (when && timeAgo) parts.push(timeAgo(when)); + if ((props.runCount || 0) > 1) parts.push(`${props.runCount} ${tx(i18n, "runs", "runs")}`); + return h("div", { className: "hermes-kanban-summary-run" }, + h("span", { className: "hermes-kanban-summary-label" }, tx(i18n, "latestRun", "Latest run")), + h("span", { className: "text-xs text-muted-foreground" }, parts.join(" · ")), + run.summary && run.summary !== props.shownResult + ? h("div", { className: "hermes-kanban-summary-run-summary" }, run.summary) + : null, + run.metadata ? h("details", { className: "hermes-kanban-summary-run-meta" }, + h("summary", null, tx(i18n, "runMetadata", "metadata")), + h("pre", null, JSON.stringify(run.metadata, null, 2)), + ) : null, + ); + } + + // Important comments win over the recent tail; both arrive pre-sliced from + // the API (comment_limit). comment_count is the task's full total so the + // header can show "2 of 7" when the tree only carries a slice. + function SummaryNodeComments(props) { + const { t: i18n } = useI18n(); + const important = props.important || []; + const shown = important.length ? important : (props.recent || []); + if (!shown.length) return null; + const total = props.total != null ? props.total : shown.length; + const label = important.length + ? tx(i18n, "importantComments", "Important comments") + : tx(i18n, "recentComments", "Recent comments"); + return h("div", { className: "hermes-kanban-summary-comments" }, + h("div", { className: "hermes-kanban-summary-label" }, + total > shown.length ? `${label} (${shown.length} of ${total})` : `${label} (${total})`), + shown.map(function (c) { + return h("div", { key: c.id, className: "hermes-kanban-summary-comment" }, + h("span", { className: "hermes-kanban-comment-author" }, c.author || "anon"), + h("span", { className: "hermes-kanban-summary-comment-body" }, c.body || ""), + ); + }), + ); + } + + // Parent/child ids as plain text. Indentation already shows the traversal + // structure; this surfaces the payload's links contract, including extra + // parents that indentation can't express. + function SummaryNodeLinks(props) { + const { t: i18n } = useI18n(); + const parents = props.parentIds || []; + const children = props.childIds || []; + if (!parents.length && !children.length) return null; + const parts = []; + if (parents.length) parts.push(`${tx(i18n, "parents", "parents")}: ${parents.join(", ")}`); + if (children.length) parts.push(`${tx(i18n, "children", "children")}: ${children.join(", ")}`); + return h("div", { className: "hermes-kanban-summary-links" }, parts.join(" · ")); + } + + function TaskSummaryTreeSection(props) { + const { t: i18n } = useI18n(); + const [tree, setTree] = useState(null); + const [err, setErr] = useState(null); + const [expanded, setExpanded] = useState(true); + useEffect(function () { + let cancelled = false; + // Reset first: the drawer swaps tasks without remounting, so stale + // tree/err state from the previous task would render under the new + // task's detail view until this fetch resolves. + setTree(null); + setErr(null); + SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/summary-tree?comment_limit=3`, props.boardSlug)) + .then(function (d) { if (!cancelled) { setTree(d); setErr(null); } }) + .catch(function (e) { if (!cancelled) setErr(String(e.message || e)); }); + return function () { cancelled = true; }; + }, [props.taskId, props.boardSlug]); + + const head = tx(i18n, "summaryTree", "Summary tree"); + if (err) return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" }, + h("div", { className: "hermes-kanban-section-head" }, head), + h("div", { className: "text-xs text-destructive" }, err), + ); + if (!tree) return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" }, + h("div", { className: "hermes-kanban-section-head" }, head), + h("div", { className: "text-xs text-muted-foreground" }, tx(i18n, "loadingSummaryTree", "Loading summary tree…")), + ); + const nodes = (tree.order || []).map(function (id) { return tree.tasks && tree.tasks[id]; }).filter(Boolean); + return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" }, + h("button", { + type: "button", + className: "hermes-kanban-section-head hermes-kanban-summary-tree-toggle", + onClick: function () { setExpanded(!expanded); }, + }, `${head} (${tree.stats ? tree.stats.total : nodes.length}) ${expanded ? "▾" : "▸"}`), + expanded ? h("div", { className: "hermes-kanban-summary-tree-list" }, + nodes.map(function (node) { + const result = node.display_result || node.latest_summary || ""; + return h("div", { key: node.id, className: "hermes-kanban-summary-node", style: { marginLeft: `${Math.min(node.depth || 0, 8) * 14}px` } }, + h("div", { className: "hermes-kanban-summary-node-head" }, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[node.status]) }), + h("span", { className: "font-medium" }, node.title || node.id), + h("span", { className: "text-xs text-muted-foreground" }, node.status), + ), + h(SummaryNodeRun, { run: node.latest_run, runCount: node.run_count, shownResult: result }), + result ? h(MarkdownBlock, { source: result, enabled: props.renderMarkdown }) : null, + h(BlockedSummaryPanel, { block: node.block }), + h(SummaryNodeComments, { + important: node.important_comments, + recent: node.comments, + total: node.comment_count, + }), + h(SummaryNodeLinks, { parentIds: node.parents, childIds: node.children }), + h(ArtifactList, { artifacts: node.artifacts || [] }), + ); + }), + tree.stats && tree.stats.truncated ? h("div", { className: "text-xs text-muted-foreground" }, + tx(i18n, "summaryTreeTruncated", "Tree truncated — showing the first slice of descendants.")) : null, + ) : null, + ); + } + function TaskDetail(props) { const { t: i18n } = useI18n(); const t = props.data.task; @@ -3434,6 +3594,11 @@ onAddChild: props.onAddChild, onRemoveChild: props.onRemoveChild, }), + h(TaskSummaryTreeSection, { + taskId: t.id, + boardSlug: props.boardSlug, + renderMarkdown: props.renderMarkdown, + }), (function () { var finalResult = t.result || t.latest_summary || null; var isDone = t.status === "done"; diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 8f2a43c295ffd..b709b33b7775b 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -1590,3 +1590,130 @@ .hermes-kanban-trash-label { font-weight: 500; } + +/* ---- Summary tree ---------------------------------------------------- */ +.hermes-kanban-summary-tree-toggle { + width: 100%; + justify-content: space-between; + background: transparent; + border: 0; + padding: 0; + cursor: pointer; + color: inherit; +} +.hermes-kanban-summary-tree-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} +.hermes-kanban-summary-node { + border-left: 1px solid var(--color-border); + padding-left: 0.6rem; +} +.hermes-kanban-summary-node-head { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.25rem; +} +.hermes-kanban-artifact-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.35rem; +} +.hermes-kanban-artifact-row { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + font-size: 0.72rem; +} +.hermes-kanban-artifact-kind, +.hermes-kanban-artifact-state { + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.05rem 0.35rem; + color: var(--color-muted-foreground); + white-space: nowrap; +} +.hermes-kanban-artifact-path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background: color-mix(in srgb, var(--color-muted) 45%, transparent); + border-radius: 0.25rem; + padding: 0.05rem 0.25rem; +} +.hermes-kanban-artifact-state--available { + color: var(--color-success, #2fa36b); + border-color: color-mix(in srgb, var(--color-success, #2fa36b) 60%, var(--color-border)); +} +.hermes-kanban-blocked-summary { + margin: 0.35rem 0; + padding: 0.45rem 0.55rem; + border: 1px solid color-mix(in srgb, var(--color-warning, #d99a22) 55%, var(--color-border)); + border-radius: var(--radius); + background: color-mix(in srgb, var(--color-warning, #d99a22) 8%, transparent); +} +.hermes-kanban-blocked-summary-title { + font-size: 0.74rem; + font-weight: 600; + margin-bottom: 0.25rem; +} +.hermes-kanban-summary-label { + font-size: 0.72rem; + font-weight: 600; + color: var(--color-muted-foreground); + white-space: nowrap; +} +.hermes-kanban-summary-run { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 0.25rem; + font-size: 0.72rem; +} +.hermes-kanban-summary-run-summary { + flex-basis: 100%; + font-size: 0.72rem; + color: var(--color-muted-foreground); + overflow-wrap: anywhere; +} +.hermes-kanban-summary-run-meta summary { + cursor: pointer; + font-size: 0.72rem; + color: var(--color-muted-foreground); +} +.hermes-kanban-summary-run-meta pre { + max-height: 12rem; + overflow: auto; + font-size: 0.68rem; + background: color-mix(in srgb, var(--color-muted) 45%, transparent); + border-radius: 0.25rem; + padding: 0.35rem 0.45rem; +} +.hermes-kanban-summary-comments { + margin-top: 0.35rem; +} +.hermes-kanban-summary-comment { + display: flex; + align-items: baseline; + gap: 0.35rem; + font-size: 0.72rem; + min-width: 0; +} +.hermes-kanban-summary-comment-body { + color: var(--color-muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hermes-kanban-summary-links { + margin-top: 0.25rem; + font-size: 0.72rem; + color: var(--color-muted-foreground); + overflow-wrap: anywhere; +} diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index e280e54c8052d..3f567f1e58b1c 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -38,6 +38,7 @@ import asyncio import json import logging +import os import sqlite3 import time from dataclasses import asdict @@ -371,6 +372,605 @@ def _links_for(conn: sqlite3.Connection, task_id: str) -> dict[str, list[str]]: return {"parents": parents, "children": children} +# --------------------------------------------------------------------------- +# Selected-task summary tree helpers +# --------------------------------------------------------------------------- + +_ARTIFACT_KEYS: dict[str, str] = { + "workspace_path": "workspace", + "diff_path": "diff", + "output_path": "output", + "file_path": "file", + "folder_path": "folder", + "artifact_path": "artifact", + "artifact_paths": "artifact", + "document_path": "document", + "created_files": "created_file", + "changed_files": "changed_file", + "saved_files": "saved_file", +} +_IMPORTANT_COMMENT_TERMS = ( + "review-required", "handoff", "artifact", "output", "saved", + "created", "changed_files", "diff_path", "blocked", "decision", +) +_SUMMARY_TREE_MAX_DEPTH = 50 +_SUMMARY_TREE_MAX_NODES = 500 + + +def _walk_descendant_graph( + conn: sqlite3.Connection, + root_id: str, + *, + max_depth: int = _SUMMARY_TREE_MAX_DEPTH, + max_nodes: int = _SUMMARY_TREE_MAX_NODES, +) -> tuple[list[str], list[dict[str, Any]], dict[str, int], bool]: + """Return breadth-first descendant order and parent→child edges.""" + order: list[str] = [root_id] + depths: dict[str, int] = {root_id: 0} + edges: list[dict[str, Any]] = [] + queue: list[str] = [root_id] + expanded: set[str] = set() + truncated = False + + while queue: + parent_id = queue.pop(0) + if parent_id in expanded: + continue + expanded.add(parent_id) + parent_depth = depths.get(parent_id, 0) + if parent_depth >= max_depth: + if conn.execute("SELECT 1 FROM task_links WHERE parent_id = ? LIMIT 1", (parent_id,)).fetchone(): + truncated = True + continue + rows = conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (parent_id,), + ).fetchall() + for row in rows: + child_id = row["child_id"] + child_depth = parent_depth + 1 + edges.append({ + "parent_id": parent_id, + "child_id": child_id, + "relation": "blocks", + "depth": child_depth, + }) + if child_id not in depths: + if len(order) >= max_nodes: + truncated = True + continue + depths[child_id] = child_depth + order.append(child_id) + queue.append(child_id) + return order, edges, depths, truncated + + +def _rows_by_task_id(conn: sqlite3.Connection, task_ids: list[str]) -> dict[str, sqlite3.Row]: + if not task_ids: + return {} + placeholders = ",".join(["?"] * len(task_ids)) + return { + row["id"]: row + for row in conn.execute( + f"SELECT * FROM tasks WHERE id IN ({placeholders})", + tuple(task_ids), + ).fetchall() + } + + +def _links_for_many(conn: sqlite3.Connection, task_ids: list[str]) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + # Two queries so the bind count stays at N: interpolating the id list + # twice into one statement hits 1000 variables at the traversal cap + # (_SUMMARY_TREE_MAX_NODES), over the 999 default + # SQLITE_MAX_VARIABLE_NUMBER on SQLite < 3.32. + parents = {tid: [] for tid in task_ids} + children = {tid: [] for tid in task_ids} + if not task_ids: + return parents, children + placeholders = ",".join(["?"] * len(task_ids)) + for row in conn.execute( + f"SELECT parent_id, child_id FROM task_links WHERE child_id IN ({placeholders}) ORDER BY parent_id, child_id", + tuple(task_ids), + ).fetchall(): + parents[row["child_id"]].append(row["parent_id"]) + for row in conn.execute( + f"SELECT parent_id, child_id FROM task_links WHERE parent_id IN ({placeholders}) ORDER BY parent_id, child_id", + tuple(task_ids), + ).fetchall(): + children[row["parent_id"]].append(row["child_id"]) + return parents, children + + +def _run_row_dict(row: sqlite3.Row) -> dict[str, Any]: + try: + metadata = json.loads(row["metadata"]) if row["metadata"] else None + except Exception: + metadata = None + return { + "id": int(row["id"]), + "task_id": row["task_id"], + "profile": row["profile"], + "step_key": row["step_key"], + "status": row["status"], + "outcome": row["outcome"], + "started_at": row["started_at"], + "ended_at": row["ended_at"], + "summary": row["summary"], + "metadata": metadata, + "error": row["error"], + } + + +def _runs_for_many(conn: sqlite3.Connection, task_ids: list[str]) -> dict[str, list[dict[str, Any]]]: + runs = {tid: [] for tid in task_ids} + if not task_ids: + return runs + placeholders = ",".join(["?"] * len(task_ids)) + for row in conn.execute( + f""" + SELECT * FROM task_runs + WHERE task_id IN ({placeholders}) + ORDER BY task_id, COALESCE(ended_at, started_at) DESC, id DESC + """, + tuple(task_ids), + ).fetchall(): + runs.setdefault(row["task_id"], []).append(_run_row_dict(row)) + return runs + + +def _comments_for_many(conn: sqlite3.Connection, task_ids: list[str]) -> dict[str, list[dict[str, Any]]]: + comments = {tid: [] for tid in task_ids} + if not task_ids: + return comments + placeholders = ",".join(["?"] * len(task_ids)) + for row in conn.execute( + f"SELECT * FROM task_comments WHERE task_id IN ({placeholders}) ORDER BY task_id, created_at ASC, id ASC", + tuple(task_ids), + ).fetchall(): + comments.setdefault(row["task_id"], []).append({ + "id": row["id"], + "task_id": row["task_id"], + "author": row["author"], + "body": row["body"], + "created_at": row["created_at"], + }) + return comments + + +def _attachments_for_many(conn: sqlite3.Connection, task_ids: list[str]) -> dict[str, list[dict[str, Any]]]: + """Batch-load first-class task attachments (``task_attachments`` rows) + for every traversed task, mirroring ``kanban_db.list_attachments`` + ordering. One query for the whole tree instead of N per-task calls.""" + attachments = {tid: [] for tid in task_ids} + if not task_ids: + return attachments + placeholders = ",".join(["?"] * len(task_ids)) + for row in conn.execute( + f"SELECT * FROM task_attachments WHERE task_id IN ({placeholders}) ORDER BY task_id, created_at ASC, id ASC", + tuple(task_ids), + ).fetchall(): + attachments.setdefault(row["task_id"], []).append({ + "id": row["id"], + "task_id": row["task_id"], + "filename": row["filename"], + "stored_path": row["stored_path"], + "content_type": row["content_type"], + "size": row["size"] or 0, + "uploaded_by": row["uploaded_by"], + "created_at": row["created_at"], + }) + return attachments + + +def _events_for_many(conn: sqlite3.Connection, task_ids: list[str]) -> dict[str, list[dict[str, Any]]]: + events = {tid: [] for tid in task_ids} + if not task_ids: + return events + placeholders = ",".join(["?"] * len(task_ids)) + for row in conn.execute( + f"SELECT * FROM task_events WHERE task_id IN ({placeholders}) ORDER BY task_id, created_at ASC, id ASC", + tuple(task_ids), + ).fetchall(): + try: + payload = json.loads(row["payload"]) if row["payload"] else None + except Exception: + payload = None + events.setdefault(row["task_id"], []).append({ + "id": row["id"], + "task_id": row["task_id"], + "kind": row["kind"], + "payload": payload, + "created_at": row["created_at"], + "run_id": row["run_id"], + }) + return events + + +def _blocked_state( + task_status: str, + *, + runs: list[dict[str, Any]], + comments: list[dict[str, Any]], + events: list[dict[str, Any]], +) -> Optional[dict[str, Any]]: + if task_status != "blocked": + return None + latest_block = next((e for e in reversed(events) if e.get("kind") == "blocked"), None) + blocked_runs = [r for r in runs if r.get("outcome") == "blocked" or r.get("status") == "blocked"] + blocked_runs.sort( + key=lambda r: (int(r.get("ended_at") or r.get("started_at") or 0), int(r.get("id") or 0)), + reverse=True, + ) + latest_blocked_run = blocked_runs[0] if blocked_runs else None + reason = None + source = None + if latest_block and isinstance(latest_block.get("payload"), dict): + raw_reason = latest_block["payload"].get("reason") + if isinstance(raw_reason, str) and raw_reason.strip(): + reason = raw_reason.strip() + source = "event.payload.reason" + if not reason and latest_blocked_run: + raw_summary = latest_blocked_run.get("summary") or latest_blocked_run.get("error") + if isinstance(raw_summary, str) and raw_summary.strip(): + reason = raw_summary.strip() + source = "run.summary" + since = latest_block.get("created_at") if latest_block else None + relevant_comments = [c for c in comments if since is None or int(c.get("created_at") or 0) >= int(since)] + latest_comment = relevant_comments[-1] if relevant_comments else (comments[-1] if comments else None) + return { + "is_blocked": True, + "reason": reason, + "missing_info": reason, + "blocked_at": latest_block.get("created_at") if latest_block else None, + "event_id": latest_block.get("id") if latest_block else None, + "run_id": latest_block.get("run_id") if latest_block else (latest_blocked_run or {}).get("id"), + "source": source, + "latest_relevant_comment": latest_comment, + "comment_prompt": "Add the missing info as a comment, then unblock when ready.", + } + + +def _important_comments(comments: list[dict[str, Any]], *, comment_limit: int) -> list[dict[str, Any]]: + """Newest comments whose body matches an importance term, capped at + ``comment_limit``, returned in chronological order. + + No back-fill with ordinary comments: callers already get the recent + tail in ``comments``, so padding this list would make the two fields + redundant and leave the UI's recent-comments fallback unreachable. + """ + if comment_limit <= 0: + return [] + picked: list[dict[str, Any]] = [] + for c in reversed(comments): + body = (c.get("body") or "").lower() + if any(term in body for term in _IMPORTANT_COMMENT_TERMS): + picked.append(c) + if len(picked) >= comment_limit: + break + return list(reversed(picked)) + + +def _safe_path_text(value: Any) -> Optional[str]: + if not isinstance(value, str): + return None + text = value.strip() + if not text or "\x00" in text or "\n" in text or len(text) > 4096: + return None + return text + + +def _path_is_within(path: str, base: str) -> bool: + try: + base_resolved = Path(os.path.expanduser(base)).resolve(strict=False) + candidate_resolved = Path(os.path.expanduser(path)).resolve(strict=False) + candidate_resolved.relative_to(base_resolved) + return True + except Exception: + return False + + +def _artifact_record( + raw_path: str, + *, + kind: str, + source: str, + workspace_path: Optional[str], + workspace_kind: Optional[str], + run_id: Optional[int] = None, +) -> Optional[dict[str, Any]]: + text = _safe_path_text(raw_path) + if not text: + return None + + resolved_path = None + exists = None + is_dir = None + availability = "unknown" + reason = None + public_path: Optional[str] = text + + expanded = os.path.expanduser(text) + is_absolute = os.path.isabs(expanded) or (len(expanded) > 2 and expanded[1:3] in (":/", ":\\")) + if workspace_path and kind != "workspace": + try: + base = Path(os.path.expanduser(workspace_path)).resolve(strict=False) + candidate = Path(expanded) if is_absolute else base / expanded + resolved = candidate.resolve(strict=False) + resolved.relative_to(base) + resolved_path = str(resolved) + exists = resolved.exists() + is_dir = resolved.is_dir() if exists else None + availability = "available" if exists else "missing" + if not exists: + reason = "path does not exist" + except Exception: + availability = "outside_workspace" + public_path = None + resolved_path = None + reason = "path outside allowed workspace" + elif kind == "workspace" and text: + try: + resolved = Path(expanded).resolve(strict=False) + resolved_path = str(resolved) + exists = resolved.exists() + is_dir = resolved.is_dir() if exists else None + availability = "available" if exists else "missing" + except Exception: + availability = "unknown" + reason = "path could not be normalized" + elif is_absolute: + availability = "outside_workspace" + public_path = None + reason = "absolute path; no workspace base" + + if workspace_kind == "scratch": + availability = "scratch" + public_path = None + resolved_path = None + reason = "scratch workspace is temporary; not a durable artifact" + + return { + "path": public_path, + "resolved_path": resolved_path, + "label": public_path or kind.replace("_", " "), + "kind": kind, + "exists": exists, + "is_dir": is_dir, + "openable": False, + "user_actionable": availability == "available" and workspace_kind != "scratch", + "availability": availability, + "source": source, + "run_id": run_id, + "reason": reason, + } + + +def _values_for_artifact_key(value: Any) -> list[str]: + found: list[str] = [] + if isinstance(value, str): + text = _safe_path_text(value) + if text: + found.append(text) + elif isinstance(value, list): + for item in value: + found.extend(_values_for_artifact_key(item)) + elif isinstance(value, dict): + for key in ("path", "resolved_path", "file", "folder"): + if key in value: + found.extend(_values_for_artifact_key(value[key])) + if not found: + for item in value.values(): + found.extend(_values_for_artifact_key(item)) + return found + + +def _iter_artifact_metadata_values(value: Any, *, prefix: str = "run.metadata"): + if isinstance(value, dict): + for key, item in value.items(): + key_s = str(key) + source = f"{prefix}.{key_s}" + if key_s in _ARTIFACT_KEYS: + for path_value in _values_for_artifact_key(item): + yield key_s, source, path_value + if isinstance(item, (dict, list)): + yield from _iter_artifact_metadata_values(item, prefix=source) + elif isinstance(value, list): + for item in value: + if isinstance(item, (dict, list)): + yield from _iter_artifact_metadata_values(item, prefix=prefix) + + +def _attachment_artifact_record(att: dict[str, Any]) -> dict[str, Any]: + """Read-only artifact record for an uploaded task attachment. + + Attachments are first-class ``task_attachments`` rows whose blobs live + under the board's attachments root — not the task workspace — so they + skip the workspace containment check applied to run-metadata paths. + ``attachment_id`` lets the UI reuse the existing authenticated + ``GET /attachments/{id}`` download route; there is still no local-file + opener (``openable`` stays False). + """ + stored_path = att.get("stored_path") + exists = bool(stored_path) and Path(stored_path).is_file() + return { + "path": att.get("filename"), + "resolved_path": stored_path, + "label": att.get("filename") or f"attachment {att.get('id')}", + "kind": "attachment", + "exists": exists, + "is_dir": False if exists else None, + "openable": False, + "user_actionable": exists, + "availability": "available" if exists else "missing", + "source": "task.attachment", + "run_id": None, + "reason": None if exists else "attachment blob missing on disk", + "attachment_id": att.get("id"), + "size": att.get("size"), + "content_type": att.get("content_type"), + "uploaded_by": att.get("uploaded_by"), + } + + +def _extract_artifacts( + *, + task_row: sqlite3.Row, + runs: list[dict[str, Any]], + attachments: Optional[list[dict[str, Any]]] = None, +) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + seen: set[tuple[str, str, str]] = set() + workspace_path = task_row["workspace_path"] + workspace_kind = task_row["workspace_kind"] + + def add(record: Optional[dict[str, Any]]) -> None: + if not record: + return + key = (str(record.get("source")), str(record.get("kind")), str(record.get("resolved_path") or record.get("path") or record.get("label"))) + if key in seen: + return + seen.add(key) + artifacts.append(record) + + if workspace_path: + add(_artifact_record( + workspace_path, + kind="workspace", + source="task.workspace_path", + workspace_path=None, + workspace_kind=workspace_kind, + )) + + for att in attachments or []: + add(_attachment_artifact_record(att)) + + for run in runs: + metadata = run.get("metadata") + if not metadata: + continue + for artifact_key, source, path_value in _iter_artifact_metadata_values(metadata): + add(_artifact_record( + path_value, + kind=_ARTIFACT_KEYS.get(artifact_key, "artifact"), + source=source, + workspace_path=workspace_path, + workspace_kind=workspace_kind, + run_id=run.get("id"), + )) + return artifacts + + +def _artifact_state(artifacts: list[dict[str, Any]]) -> dict[str, Any]: + actionable = [a for a in artifacts if a.get("user_actionable")] + return { + "state": "available" if actionable else "absent", + "has_user_actionable": bool(actionable), + "user_actionable_count": len(actionable), + "candidate_count": len(artifacts), + "reason": None if actionable else ( + artifacts[0].get("reason") if artifacts else "no artifact metadata found" + ), + } + + +def _display_result(task_row: sqlite3.Row, latest_run: Optional[dict[str, Any]]) -> Optional[str]: + if task_row["result"]: + return task_row["result"] + if latest_run and latest_run.get("summary"): + return latest_run["summary"] + if latest_run and latest_run.get("error"): + outcome = latest_run.get("outcome") or latest_run.get("status") or "failed" + return f"{outcome}: {latest_run['error']}" + return None + + +def _summary_tree_payload( + conn: sqlite3.Connection, + root_id: str, + *, + include_comments: bool = True, + comment_limit: int = 3, +) -> dict[str, Any]: + if kanban_db.get_task(conn, root_id) is None: + raise HTTPException(status_code=404, detail=f"task {root_id} not found") + comment_limit = max(0, min(int(comment_limit), 20)) + order, edges, depths, truncated = _walk_descendant_graph(conn, root_id) + rows = _rows_by_task_id(conn, order) + order = [tid for tid in order if tid in rows] + parents, children = _links_for_many(conn, order) + runs_by_task = _runs_for_many(conn, order) + comments_by_task = _comments_for_many(conn, order) + events_by_task = _events_for_many(conn, order) + attachments_by_task = _attachments_for_many(conn, order) + + tasks: dict[str, dict[str, Any]] = {} + status_counts: dict[str, int] = {} + for tid in order: + row = rows[tid] + task_runs = runs_by_task.get(tid, []) + latest_run = task_runs[0] if task_runs else None + all_comments = comments_by_task.get(tid, []) + task_attachments = attachments_by_task.get(tid, []) + artifacts = _extract_artifacts( + task_row=row, runs=task_runs, attachments=task_attachments, + ) + status_counts[row["status"]] = status_counts.get(row["status"], 0) + 1 + tasks[tid] = { + "id": row["id"], + "title": row["title"], + "body": row["body"], + "status": row["status"], + "assignee": row["assignee"], + "priority": row["priority"], + "tenant": row["tenant"], + "created_at": row["created_at"], + "started_at": row["started_at"], + "completed_at": row["completed_at"], + "workspace_kind": row["workspace_kind"], + "workspace_path": row["workspace_path"], + "result": row["result"], + "current_step_key": row["current_step_key"] if "current_step_key" in row.keys() else None, + "workflow_template_id": row["workflow_template_id"] if "workflow_template_id" in row.keys() else None, + "latest_summary": latest_run.get("summary") if latest_run else None, + "display_result": _display_result(row, latest_run), + "latest_run": latest_run, + "run_count": len(task_runs), + "comments": all_comments[-comment_limit:] if include_comments and comment_limit else [], + "comment_count": len(all_comments), + "important_comments": _important_comments(all_comments, comment_limit=comment_limit) if include_comments else [], + "parents": parents.get(tid, []), + "children": children.get(tid, []), + "depth": depths.get(tid, 0), + "artifacts": artifacts, + "artifact_state": _artifact_state(artifacts), + "attachment_count": len(task_attachments), + "block": _blocked_state( + row["status"], + runs=task_runs, + comments=all_comments, + events=events_by_task.get(tid, []), + ), + } + + stats: dict[str, Any] = { + "total": len(order), + "max_depth": max((depths.get(tid, 0) for tid in order), default=0), + "truncated": truncated, + } + for status_name in list(BOARD_COLUMNS) + ["archived"]: + stats[status_name] = status_counts.get(status_name, 0) + for status_name, count in status_counts.items(): + stats.setdefault(status_name, count) + return { + "root_id": root_id, + "generated_at": int(time.time()), + "tasks": tasks, + "edges": [e for e in edges if e["parent_id"] in rows and e["child_id"] in rows], + "roots": [root_id], + "order": order, + "stats": stats, + } + # --------------------------------------------------------------------------- # GET /board # --------------------------------------------------------------------------- @@ -510,6 +1110,32 @@ def get_board( conn.close() +# --------------------------------------------------------------------------- +# GET /tasks/:id/summary-tree +# --------------------------------------------------------------------------- + +@router.get("/tasks/{task_id}/summary-tree") +def get_task_summary_tree( + task_id: str, + include_comments: bool = Query(True), + comment_limit: int = Query(3, ge=0, le=20), + depth: str = Query("all", description="Currently accepts 'all'; traversal is capped defensively."), + board: Optional[str] = Query(None), +): + _ = depth + board = _resolve_board(board) + conn = _conn(board=board) + try: + return _summary_tree_payload( + conn, + task_id, + include_comments=include_comments, + comment_limit=comment_limit, + ) + finally: + conn.close() + + # --------------------------------------------------------------------------- # GET /tasks/:id # --------------------------------------------------------------------------- diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 4c7f466ce8f30..8abaf784f9f90 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -392,6 +392,233 @@ def test_task_detail_404_on_unknown(client): assert r.status_code == 404 +def _complete_task(task_id: str, *, result=None, summary=None, metadata=None): + conn = kb.connect() + try: + assert kb.complete_task( + conn, + task_id, + result=result, + summary=summary, + metadata=metadata, + ) + finally: + conn.close() + + +def test_task_summary_tree_returns_descendants_edges_and_latest_run(client): + root = client.post( + "/api/plugins/kanban/tasks", + json={"title": "root", "assignee": "pm"}, + ).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "child", "assignee": "backendeng", "parents": [root["id"]]}, + ).json()["task"] + grandchild = client.post( + "/api/plugins/kanban/tasks", + json={"title": "grandchild", "assignee": "frontendeng", "parents": [child["id"]]}, + ).json()["task"] + + _complete_task(root["id"], result="root result wins", summary="root run summary") + _complete_task( + child["id"], + summary="child handoff summary", + metadata={"diff_path": "child.diff"}, + ) + + r = client.get(f"/api/plugins/kanban/tasks/{root['id']}/summary-tree") + assert r.status_code == 200, r.text + data = r.json() + + assert data["root_id"] == root["id"] + assert data["order"] == [root["id"], child["id"], grandchild["id"]] + assert {tuple((e["parent_id"], e["child_id"], e["depth"])) for e in data["edges"]} == { + (root["id"], child["id"], 1), + (child["id"], grandchild["id"], 2), + } + assert data["tasks"][root["id"]]["display_result"] == "root result wins" + assert data["tasks"][child["id"]]["display_result"] == "child handoff summary" + assert data["tasks"][child["id"]]["latest_run"]["outcome"] == "completed" + assert data["tasks"][child["id"]]["latest_run"]["profile"] == "backendeng" + assert data["tasks"][child["id"]]["latest_run"]["metadata"] == {"diff_path": "child.diff"} + assert data["tasks"][child["id"]]["run_count"] == 1 + assert data["tasks"][grandchild["id"]]["parents"] == [child["id"]] + assert data["tasks"][root["id"]]["children"] == [child["id"]] + assert data["stats"]["total"] == 3 + assert data["stats"]["max_depth"] == 2 + + +def test_task_summary_tree_lists_metadata_artifacts_without_comment_path_scraping(client, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + changed = workspace / "relative.py" + changed.write_text("print('hi')") + + task = client.post( + "/api/plugins/kanban/tasks", + json={ + "title": "artifact task", + "workspace_kind": "worktree", + "workspace_path": str(workspace), + }, + ).json()["task"] + _complete_task( + task["id"], + summary="artifact handoff", + metadata={ + "diff_path": "changes.diff", + "document_path": "docs/report.md", + "changed_files": ["relative.py"], + }, + ) + conn = kb.connect() + try: + kb.add_comment(conn, task["id"], "reviewer", "review-required handoff: see /etc/passwd") + finally: + conn.close() + + r = client.get(f"/api/plugins/kanban/tasks/{task['id']}/summary-tree?comment_limit=2") + assert r.status_code == 200, r.text + node = r.json()["tasks"][task["id"]] + artifacts = node["artifacts"] + assert {a["kind"] for a in artifacts} >= {"workspace", "diff", "document", "changed_file"} + assert all(a["source"] != "comment.regex" for a in artifacts) + assert all(a["openable"] is False for a in artifacts) + changed_artifact = next(a for a in artifacts if a["kind"] == "changed_file") + assert changed_artifact["path"] == "relative.py" + assert changed_artifact["resolved_path"] == str(changed) + assert changed_artifact["availability"] == "available" + assert node["important_comments"][0]["body"].startswith("review-required handoff") + assert node["comment_count"] == 1 + + +def test_task_summary_tree_redacts_metadata_artifacts_outside_workspace(client, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + outside = tmp_path / "outside.txt" + outside.write_text("outside") + + task = client.post( + "/api/plugins/kanban/tasks", + json={ + "title": "outside artifact", + "workspace_kind": "worktree", + "workspace_path": str(workspace), + }, + ).json()["task"] + _complete_task( + task["id"], + summary="attempted outside artifacts", + metadata={"file_path": str(outside), "changed_files": ["../outside.txt"]}, + ) + + r = client.get(f"/api/plugins/kanban/tasks/{task['id']}/summary-tree") + assert r.status_code == 200, r.text + artifacts = [ + a for a in r.json()["tasks"][task["id"]]["artifacts"] + if a["kind"] in {"file", "changed_file"} + ] + assert len(artifacts) == 2 + assert all(a["availability"] == "outside_workspace" for a in artifacts) + assert all(a["path"] is None and a["resolved_path"] is None for a in artifacts) + assert all(a["openable"] is False for a in artifacts) + + +def test_task_summary_tree_exposes_blocked_context(client): + task = client.post("/api/plugins/kanban/tasks", json={"title": "needs decision"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{task['id']}", + json={"status": "blocked", "block_reason": "choose the rollout window"}, + ) + assert r.status_code == 200 + conn = kb.connect() + try: + kb.add_comment(conn, task["id"], "pm", "Use Friday unless launch support says no") + finally: + conn.close() + + tree = client.get(f"/api/plugins/kanban/tasks/{task['id']}/summary-tree").json() + block = tree["tasks"][task["id"]]["block"] + assert block["reason"] == "choose the rollout window" + assert block["missing_info"] == "choose the rollout window" + assert block["latest_relevant_comment"]["body"].startswith("Use Friday") + assert block["comment_prompt"].startswith("Add the missing info") + + +def test_task_summary_tree_important_comments_stay_chronological_without_backfill(client): + """important_comments carries only term-matching comments, in + chronological order — no back-fill with ordinary comments (the recent + tail already ships separately in ``comments``).""" + task = client.post("/api/plugins/kanban/tasks", json={"title": "comments"}).json()["task"] + conn = kb.connect() + try: + kb.add_comment(conn, task["id"], "pm", "kickoff note") + kb.add_comment(conn, task["id"], "worker", "handoff: diff ready for review") + kb.add_comment(conn, task["id"], "worker", "artifact saved under out/") + kb.add_comment(conn, task["id"], "pm", "thanks!") + finally: + conn.close() + + node = client.get( + f"/api/plugins/kanban/tasks/{task['id']}/summary-tree?comment_limit=3" + ).json()["tasks"][task["id"]] + assert [c["body"] for c in node["important_comments"]] == [ + "handoff: diff ready for review", + "artifact saved under out/", + ] + assert [c["body"] for c in node["comments"]] == [ + "handoff: diff ready for review", + "artifact saved under out/", + "thanks!", + ] + assert node["comment_count"] == 4 + + +def test_task_summary_tree_includes_descendant_uploaded_attachments(client): + """First-class task attachments (task_attachments rows) on descendant + tasks must surface in the tree as read-only artifact records — batch + loaded, no local-file open path (openable stays False).""" + root = client.post( + "/api/plugins/kanban/tasks", + json={"title": "root", "assignee": "pm"}, + ).json()["task"] + child = client.post( + "/api/plugins/kanban/tasks", + json={"title": "child", "parents": [root["id"]]}, + ).json()["task"] + + content = b"quarterly numbers" + r = client.post( + f"/api/plugins/kanban/tasks/{child['id']}/attachments", + files={"file": ("report.csv", content, "text/csv")}, + ) + assert r.status_code == 200, r.text + uploaded = r.json()["attachment"] + + tree = client.get(f"/api/plugins/kanban/tasks/{root['id']}/summary-tree").json() + node = tree["tasks"][child["id"]] + assert node["attachment_count"] == 1 + att = next(a for a in node["artifacts"] if a["kind"] == "attachment") + assert att["source"] == "task.attachment" + assert att["path"] == "report.csv" + assert att["attachment_id"] == uploaded["id"] + assert att["size"] == len(content) + assert att["availability"] == "available" + assert att["openable"] is False + assert node["artifact_state"]["has_user_actionable"] is True + + # A row whose blob vanished stays listed but flips to missing — the + # metadata row is the source of truth (see kanban_db.delete_attachment). + Path(uploaded["stored_path"]).unlink() + tree = client.get(f"/api/plugins/kanban/tasks/{root['id']}/summary-tree").json() + att = next( + a for a in tree["tasks"][child["id"]]["artifacts"] if a["kind"] == "attachment" + ) + assert att["availability"] == "missing" + assert att["user_actionable"] is False + + # --------------------------------------------------------------------------- # PATCH /tasks/:id — status transitions # --------------------------------------------------------------------------- @@ -1174,6 +1401,65 @@ def test_dashboard_surfaces_ready_blocked_error_inline(): assert "setPatchErr(null)" in bundle +def test_dashboard_bundle_renders_summary_tree_without_local_open_endpoint(): + repo_root = Path(__file__).resolve().parents[2] + bundle = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + ).read_text(encoding="utf-8") + css = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css" + ).read_text(encoding="utf-8") + + assert "function TaskSummaryTreeSection(props)" in bundle + assert "summary-tree?comment_limit=3" in bundle + assert "function ArtifactList(props)" in bundle + assert "Blocked — missing input needed" in bundle + assert "/artifacts/open" not in bundle + assert "comment.regex" not in bundle + + assert ".hermes-kanban-summary-tree" in css + assert ".hermes-kanban-artifact-row" in css + assert ".hermes-kanban-blocked-summary" in css + + +def test_dashboard_bundle_renders_summary_tree_comments_links_and_runs(): + """The summary tree must render every field its payload contract + promises — latest run (with raw metadata), comments, links — not just + result/block/artifacts. Behavioral companion to the API-side tests.""" + repo_root = Path(__file__).resolve().parents[2] + bundle = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + ).read_text(encoding="utf-8") + css = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css" + ).read_text(encoding="utf-8") + + # Latest-run line, with the run's raw metadata behind a
and + # the run summary shown when an explicit task result shadows it. + assert "function SummaryNodeRun(props)" in bundle + assert "h(SummaryNodeRun, { run: node.latest_run, runCount: node.run_count, shownResult: result })" in bundle + assert "run.summary && run.summary !== props.shownResult" in bundle + assert "JSON.stringify(run.metadata, null, 2)" in bundle + + # Important comments win over the recent tail; full count in the header. + assert "function SummaryNodeComments(props)" in bundle + assert "important: node.important_comments" in bundle + assert "recent: node.comments" in bundle + assert "total: node.comment_count" in bundle + assert '"importantComments", "Important comments"' in bundle + + # Parent/child link ids. + assert "function SummaryNodeLinks(props)" in bundle + assert "h(SummaryNodeLinks, { parentIds: node.parents, childIds: node.children })" in bundle + + # Uploaded attachments arrive as sized artifact rows; still no opener. + assert "_fmtBytes(a.size)" in bundle + + assert ".hermes-kanban-summary-run" in css + assert ".hermes-kanban-summary-comment" in css + assert ".hermes-kanban-summary-links" in css + + def test_dashboard_dependency_selects_use_value_change_handler(): """Regression for the dependency selects in the task drawer: the add-parent / add-child dropdowns must wire through the shared