diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 7f2fd573e745..728df034a8a8 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -2921,12 +2921,44 @@ loading ? h("div", { className: "p-4 text-sm text-muted-foreground" }, tx(t, "loadingDetail", "Loading…")) : err ? h("div", { className: "p-4 text-sm text-destructive" }, err) : + data && data.task && data.task.status === "blocked" + ? h("div", { className: "hermes-kanban-blocked-top" }, + h("div", { className: "hermes-kanban-blocked-top-head" }, + h("span", null, tx(t, "blockedNeedsInput", "Blocked — missing input needed")), + data.task.block && data.task.block.comment_prompt + ? h("span", { className: "hermes-kanban-summary-muted" }, data.task.block.comment_prompt) + : null, + ), + data.task.block && (data.task.block.missing_info || data.task.block.reason) + ? h("div", { className: "hermes-kanban-blocked-reason" }, + data.task.block.missing_info || data.task.block.reason) + : null, + h("div", { className: "hermes-kanban-drawer-comment-row hermes-kanban-drawer-comment-row--inline" }, + h(Input, { + value: newComment, + onChange: function (e) { setNewComment(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); handleComment(); + } + }, + placeholder: tx(t, "blockedCommentPlaceholder", "Add the missing info here… (Enter to submit)"), + className: "h-8 text-sm flex-1", + }), + h(Button, { + onClick: handleComment, + size: "sm", + }, tx(t, "comment", "Comment")), + ), + ) + : null, data ? h(TaskDetail, { data, editing, setEditing, renderMarkdown: props.renderMarkdown, allTasks: props.allTasks, assignees: props.assignees || [], boardSlug: boardSlug, + patchErr: patchErr, onPatch: doPatch, onSpecify: doSpecify, onDecompose: doDecompose, @@ -2960,6 +2992,257 @@ ); } + function BlockedStatusPanel(props) { + const { t } = useI18n(); + const block = props.block; + if (!block || !block.is_blocked) return null; + const reason = block.missing_info || block.reason; + return h("div", { className: "hermes-kanban-section hermes-kanban-blocked-status" }, + h("div", { className: "hermes-kanban-section-head" }, + tx(t, "blockedStatus", "Blocked status")), + h("div", { className: "hermes-kanban-blocked-reason" }, + reason || tx(t, "blockedNoReason", "Blocked, but no reason was recorded.")), + block.latest_relevant_comment + ? h("div", { className: "hermes-kanban-blocked-latest" }, + h("span", { className: "hermes-kanban-summary-muted" }, + tx(t, "latestRelevantComment", "Latest relevant comment") + ": "), + h("span", null, (block.latest_relevant_comment.body || "").slice(0, 500)), + ) + : null, + block.comment_prompt + ? h("div", { className: "hermes-kanban-summary-muted" }, block.comment_prompt) + : null, + ); + } + + // Selected task rollup: current task result/handoff plus descendant + // subtask summaries and artifact state, fetched lazily from the backend. + function TaskSummaryTreeSection(props) { + const { t } = useI18n(); + const [state, setState] = useState({ loading: false, data: null, err: null }); + const [expanded, setExpanded] = useState({}); + const [openStatus, setOpenStatus] = useState({}); + + const load = useCallback(function () { + setState(function (prev) { return { loading: true, data: prev.data, err: null }; }); + SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/summary-tree?comment_limit=3`, props.boardSlug)) + .then(function (d) { setState({ loading: false, data: d, err: null }); }) + .catch(function (e) { setState({ loading: false, data: null, err: parseApiErrorMessage(e) }); }); + }, [props.taskId, props.boardSlug]); + + useEffect(function () { load(); }, [load]); + + const toggle = function (id) { + setExpanded(function (prev) { + const next = Object.assign({}, prev); + next[id] = !next[id]; + return next; + }); + }; + + const copyPath = function (artifact) { + const path = artifact.resolved_path || artifact.path; + if (!path) return; + try { + const p = navigator.clipboard && navigator.clipboard.writeText(path); + if (p && p.then) { + p.then(function () { + setOpenStatus(function (prev) { return Object.assign({}, prev, { [path]: "copied" }); }); + }); + } else { + window.prompt(tx(t, "copyPath", "Copy path"), path); + } + } catch (_e) { + window.prompt(tx(t, "copyPath", "Copy path"), path); + } + }; + + const openArtifact = function (artifact) { + const path = artifact.resolved_path || artifact.path; + if (!path) return; + setOpenStatus(function (prev) { return Object.assign({}, prev, { [path]: "opening" }); }); + SDK.fetchJSON(withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/artifacts/open`, props.boardSlug), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: path, mode: artifact.is_dir ? "open" : "reveal" }), + }).then(function (res) { + setOpenStatus(function (prev) { + return Object.assign({}, prev, { [path]: res && res.ok ? "opened" : ((res && res.reason) || "not opened") }); + }); + }).catch(function (e) { + setOpenStatus(function (prev) { return Object.assign({}, prev, { [path]: parseApiErrorMessage(e) }); }); + }); + }; + + const data = state.data; + const root = data && data.tasks ? data.tasks[data.root_id] : null; + const ids = data && data.order ? data.order.filter(function (id) { return id !== data.root_id; }) : []; + const stats = data && data.stats ? data.stats : {}; + const doneChildren = ids.filter(function (id) { + return data.tasks[id] && data.tasks[id].status === "done"; + }).length; + + return h("div", { className: "hermes-kanban-section hermes-kanban-summary-tree" }, + h("div", { className: "hermes-kanban-section-head-row" }, + h("span", { className: "hermes-kanban-section-head" }, + tx(t, "resultTree", "Result + subtasks"), + data ? ` (${doneChildren}/${ids.length} done)` : ""), + h("button", { + type: "button", + onClick: load, + className: "hermes-kanban-edit-link", + title: tx(t, "refresh", "Refresh"), + }, state.loading ? tx(t, "loading", "Loading…") : tx(t, "refresh", "refresh")), + ), + state.err ? h("div", { className: "text-xs text-destructive" }, state.err) : null, + !state.err && state.loading && !data + ? h("div", { className: "text-xs text-muted-foreground" }, tx(t, "loading", "Loading…")) + : null, + root ? h("div", { className: "hermes-kanban-summary-root" }, + h("div", { className: "hermes-kanban-summary-root-head" }, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[root.status]) }), + h("strong", null, root.title || root.id), + h("code", null, root.id), + root.assignee ? h("span", { className: "hermes-kanban-summary-muted" }, `@${root.assignee}`) : null, + ), + root.display_result + ? h(MarkdownBlock, { source: root.display_result, enabled: props.renderMarkdown }) + : h("div", { className: "text-xs text-muted-foreground italic" }, + tx(t, "noResultYet", "— no result or run summary yet —")), + h(ArtifactButtons, { + artifacts: root.artifacts || [], + onOpen: openArtifact, + onCopy: copyPath, + openStatus: openStatus, + }), + ) : null, + ids.length === 0 && root + ? h("div", { className: "text-xs text-muted-foreground" }, + tx(t, "noSubtasks", "No linked subtasks.")) + : null, + ids.length > 0 ? h("div", { className: "hermes-kanban-summary-table" }, + h("div", { className: "hermes-kanban-summary-row hermes-kanban-summary-row--head" }, + h("span", null, tx(t, "task", "Task")), + h("span", null, tx(t, "status", "Status")), + h("span", null, tx(t, "assignee", "Assignee")), + h("span", null, tx(t, "result", "Result / latest run")), + h("span", null, tx(t, "artifacts", "Artifacts")), + ), + ids.map(function (id) { + const node = data.tasks[id]; + if (!node) return null; + const isOpen = expanded[id] || node.status === "blocked" || node.status === "done"; + const outcome = node.latest_run && (node.latest_run.outcome || node.latest_run.status); + const result = node.display_result || ""; + return h("div", { + key: id, + className: "hermes-kanban-summary-node", + style: { marginLeft: `${Math.min(node.depth || 0, 8) * 10}px` }, + }, + h("button", { + type: "button", + onClick: function () { toggle(id); }, + className: "hermes-kanban-summary-row", + }, + h("span", { className: "hermes-kanban-summary-task" }, + h("span", { className: "hermes-kanban-summary-caret" }, isOpen ? "▾" : "▸"), + h("code", null, id), + h("span", null, node.title || tx(t, "untitled", "(untitled)")), + ), + h("span", null, + h("span", { className: cn("hermes-kanban-dot", COLUMN_DOT[node.status]) }), + " ", node.status, + outcome ? h("span", { className: "hermes-kanban-summary-muted" }, ` / ${outcome}`) : null, + ), + h("span", null, node.assignee ? `@${node.assignee}` : "—"), + h("span", { className: "hermes-kanban-summary-preview" }, + result ? result.slice(0, 220) : tx(t, "noResultYet", "— no result or run summary yet —")), + h("span", null, + (node.artifacts && node.artifacts.length) ? `${node.artifacts.length}` : "—"), + ), + isOpen ? h("div", { className: "hermes-kanban-summary-expanded" }, + node.block && node.block.is_blocked + ? h(BlockedStatusPanel, { block: node.block }) + : null, + node.parents && node.parents.length + ? h("div", { className: "hermes-kanban-summary-muted" }, + `${tx(t, "parents", "Parents")}: ${node.parents.join(", ")}`) + : null, + result + ? h(MarkdownBlock, { source: result, enabled: props.renderMarkdown }) + : null, + node.latest_run && node.latest_run.metadata + ? h("details", { className: "hermes-kanban-run-meta-block" }, + h("summary", { className: "hermes-kanban-run-meta-label" }, "Metadata"), + h("code", { className: "hermes-kanban-run-meta" }, JSON.stringify(node.latest_run.metadata, null, 2)), + ) + : null, + node.important_comments && node.important_comments.length + ? h("div", { className: "hermes-kanban-summary-comments" }, + node.important_comments.map(function (c) { + return h("div", { key: c.id, className: "hermes-kanban-summary-comment" }, + h("span", { className: "hermes-kanban-summary-muted" }, `${c.author || "anon"}: `), + h("span", null, (c.body || "").slice(0, 500)), + ); + })) + : null, + h(ArtifactButtons, { + artifacts: node.artifacts || [], + onOpen: openArtifact, + onCopy: copyPath, + openStatus: openStatus, + }), + ) : null, + ); + }), + ) : null, + stats.truncated ? h("div", { className: "text-xs text-muted-foreground" }, + tx(t, "summaryTreeTruncated", "Summary tree truncated by safety limits.")) : null, + ); + } + + function ArtifactButtons(props) { + const { t } = useI18n(); + const artifacts = props.artifacts || []; + if (artifacts.length === 0) return null; + return h("div", { className: "hermes-kanban-artifacts" }, + artifacts.slice(0, 8).map(function (a, idx) { + const path = a.resolved_path || a.path; + const label = a.label || a.kind || "artifact"; + const availability = a.availability || (a.exists === false ? "missing" : (path ? "available" : "unavailable")); + const exists = availability === "available" ? (a.is_dir ? "folder" : "file") : availability; + const actionable = !!(path && a.user_actionable !== false && a.exists !== false && availability === "available"); + const status = path ? props.openStatus[path] : null; + const reason = a.reason || (availability === "missing" + ? tx(t, "artifactMissing", "Artifact missing") + : availability === "scratch" + ? tx(t, "artifactScratch", "Temporary workspace artifact; no durable path") + : tx(t, "artifactUnavailable", "No durable artifact path available")); + return h("span", { + key: `${path || label || idx}-${idx}`, + className: cn("hermes-kanban-artifact-chip", actionable ? "" : "hermes-kanban-artifact-chip--empty"), + }, + h("span", { className: "hermes-kanban-artifact-label", title: actionable ? path : reason }, `${label} · ${exists}`), + actionable ? h("button", { + type: "button", + className: "hermes-kanban-artifact-action", + onClick: function (e) { e.stopPropagation(); props.onCopy(a); }, + title: path, + }, tx(t, "copy", "copy")) : null, + actionable && a.openable ? h("button", { + type: "button", + className: "hermes-kanban-artifact-action", + onClick: function (e) { e.stopPropagation(); props.onOpen(a); }, + title: path, + }, a.is_dir ? tx(t, "open", "open") : tx(t, "reveal", "reveal")) : null, + !actionable ? h("span", { className: "hermes-kanban-artifact-empty" }, reason) : null, + status ? h("span", { className: "hermes-kanban-summary-muted" }, status) : null, + ); + }), + artifacts.length > 8 ? h("span", { className: "hermes-kanban-summary-muted" }, `+${artifacts.length - 8}`) : null, + ); + } + function TaskDetail(props) { const { t: i18n } = useI18n(); const t = props.data.task; @@ -3005,6 +3288,10 @@ onSpecify: props.onSpecify, onDecompose: props.onDecompose, }), + props.patchErr + ? h("div", { className: "hermes-kanban-msg-err" }, props.patchErr) + : null, + h(BlockedStatusPanel, { block: t.block }), h(DiagnosticsSection, { task: t, boardSlug: props.boardSlug, @@ -3017,6 +3304,11 @@ homeBusy: props.homeBusy || {}, onToggle: props.onToggleHomeSub, }), + h(TaskSummaryTreeSection, { + taskId: t.id, + boardSlug: props.boardSlug, + renderMarkdown: props.renderMarkdown, + }), h(BodyEditor, { task: t, renderMarkdown: props.renderMarkdown, diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 052fa4622c5b..b77689d8dd68 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -536,6 +536,51 @@ background: color-mix(in srgb, var(--color-card) 90%, transparent); } +.hermes-kanban-drawer-comment-row--inline { + padding: 0; + border-top: 0; + background: transparent; +} + +.hermes-kanban-blocked-top { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin: 0.75rem 0.75rem 0; + padding: 0.65rem; + border: 1px solid color-mix(in srgb, var(--color-destructive, var(--color-ring)) 35%, var(--color-border)); + border-radius: var(--radius-sm, 0.25rem); + background: color-mix(in srgb, var(--color-destructive, var(--color-ring)) 8%, var(--color-card)); +} + +.hermes-kanban-blocked-top-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.65rem; + font-size: 0.78rem; + font-weight: 700; +} + +.hermes-kanban-blocked-reason { + font-size: 0.78rem; + line-height: 1.45; + color: var(--color-foreground); + word-break: break-word; +} + +.hermes-kanban-blocked-status { + border-color: color-mix(in srgb, var(--color-destructive, var(--color-ring)) 30%, var(--color-border)); + background: color-mix(in srgb, var(--color-destructive, var(--color-ring)) 5%, transparent); +} + +.hermes-kanban-blocked-latest { + font-size: 0.74rem; + line-height: 1.45; + padding-left: 0.45rem; + border-left: 2px solid color-mix(in srgb, var(--color-border) 75%, transparent); +} + .hermes-kanban-count { display: inline-flex; gap: 0.2rem; @@ -654,6 +699,184 @@ } .hermes-kanban-dep-chip-x:hover { color: var(--color-destructive, #d14a4a); } +/* ---- Selected-task result/subtask rollup ---------------------------- */ + +.hermes-kanban-summary-tree { + border: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); + border-radius: var(--radius-sm, 0.25rem); + padding: 0.5rem; + background: color-mix(in srgb, var(--color-card) 92%, var(--color-foreground) 2%); +} + +.hermes-kanban-summary-root { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.45rem 0.5rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + background: color-mix(in srgb, var(--color-background) 70%, transparent); +} + +.hermes-kanban-summary-root-head { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.78rem; +} + +.hermes-kanban-summary-muted { + color: var(--color-muted-foreground); + font-size: 0.7rem; +} + +.hermes-kanban-summary-table { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + overflow: hidden; +} + +.hermes-kanban-summary-row { + appearance: none; + width: 100%; + display: grid; + grid-template-columns: minmax(210px, 1.4fr) 120px 96px minmax(180px, 1fr) 76px; + gap: 0.5rem; + align-items: start; + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); + background: transparent; + color: var(--color-foreground); + padding: 0.35rem 0.45rem; + text-align: left; + font-size: 0.72rem; +} + +button.hermes-kanban-summary-row { cursor: pointer; } +button.hermes-kanban-summary-row:hover { + background: color-mix(in srgb, var(--color-foreground) 5%, transparent); +} + +.hermes-kanban-summary-row--head { + background: color-mix(in srgb, var(--color-foreground) 5%, transparent); + color: var(--color-muted-foreground); + font-size: 0.66rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.hermes-kanban-summary-node:last-child > .hermes-kanban-summary-row { border-bottom: 0; } + +.hermes-kanban-summary-task { + display: flex; + align-items: flex-start; + gap: 0.3rem; + min-width: 0; +} + +.hermes-kanban-summary-task > span:last-child, +.hermes-kanban-summary-preview { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.hermes-kanban-summary-caret { + color: var(--color-muted-foreground); + width: 0.8rem; + flex: 0 0 auto; +} + +.hermes-kanban-summary-expanded { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.45rem 0.65rem 0.55rem 1.25rem; + border-bottom: 1px solid color-mix(in srgb, var(--color-border) 75%, transparent); + background: color-mix(in srgb, var(--color-foreground) 2.5%, transparent); +} + +.hermes-kanban-summary-comments { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.hermes-kanban-summary-comment { + font-size: 0.72rem; + border-left: 2px solid color-mix(in srgb, var(--color-ring) 35%, transparent); + padding-left: 0.4rem; +} + +.hermes-kanban-artifacts { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem; +} + +.hermes-kanban-artifact-chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + min-width: 0; + max-width: 100%; + padding: 0.12rem 0.25rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm, 0.25rem); + background: color-mix(in srgb, var(--color-foreground) 4%, transparent); + font-size: 0.68rem; +} + +.hermes-kanban-artifact-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 220px; +} + +.hermes-kanban-artifact-action { + appearance: none; + border: 0; + background: transparent; + color: var(--color-ring); + cursor: pointer; + padding: 0 0.1rem; + font-size: 0.68rem; + text-decoration: underline; + text-underline-offset: 2px; +} + +.hermes-kanban-artifact-action:hover { color: var(--color-foreground); } + +.hermes-kanban-artifact-chip--empty { + color: var(--color-muted-foreground); + background: color-mix(in srgb, var(--color-muted-foreground) 5%, transparent); +} + +.hermes-kanban-artifact-empty { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 260px; + color: var(--color-muted-foreground); +} + +@media (max-width: 900px) { + .hermes-kanban-summary-row { + grid-template-columns: minmax(180px, 1fr) 90px 72px; + } + .hermes-kanban-summary-row > span:nth-child(4), + .hermes-kanban-summary-row > span:nth-child(5) { + display: none; + } +} + /* ---- Inline edit affordances --------------------------------------- */ .hermes-kanban-editable { diff --git a/plugins/kanban/dashboard/plugin_api.py b/plugins/kanban/dashboard/plugin_api.py index 104f666c3008..ef31a9d6e9e3 100644 --- a/plugins/kanban/dashboard/plugin_api.py +++ b/plugins/kanban/dashboard/plugin_api.py @@ -40,9 +40,13 @@ import json import logging import os +import re import sqlite3 +import subprocess +import sys import time from dataclasses import asdict +from pathlib import Path from typing import Any, Optional from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status @@ -144,6 +148,11 @@ def _conn(board: Optional[str] = None): _CARD_SUMMARY_PREVIEW_CHARS = 200 +class OpenArtifactBody(BaseModel): + path: str + mode: str = Field("reveal", pattern="^(reveal|open)$") + + def _task_dict( task: kanban_db.Task, *, @@ -345,6 +354,647 @@ 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_RE = re.compile( + r"review-required|handoff|artifact|output|saved|created|changed_files|diff_path|blocked|decision|path", + re.IGNORECASE, +) +_COMMENT_PATH_RE = re.compile( + r"(?P~/(?:[^\s`'\"<>]+)|/(?:Users|tmp|var|private/var|Volumes|home)/(?:[^\s`'\"<>]+)|[A-Za-z]:[\\/](?:[^\s`'\"<>]+))" +) +_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 descendant node order and parent→child edges from root. + + The DB rejects cycles, but this reader is defensive because dashboard + endpoints should stay responsive even if an operator edits SQLite by hand. + """ + 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: + child_rows = conn.execute( + "SELECT 1 FROM task_links WHERE parent_id = ? LIMIT 1", (parent_id,) + ).fetchone() + if child_rows: + truncated = True + continue + for row in conn.execute( + "SELECT child_id FROM task_links WHERE parent_id = ? ORDER BY child_id", + (parent_id,), + ).fetchall(): + 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]]]: + 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}) OR parent_id IN ({placeholders}) ORDER BY parent_id, child_id", + tuple(task_ids) + tuple(task_ids), + ).fetchall(): + if row["child_id"] in parents: + parents[row["child_id"]].append(row["parent_id"]) + if row["parent_id"] in children: + children[row["parent_id"]].append(row["child_id"]) + return parents, children + + +def _run_dict_public(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"] if "step_key" in row.keys() else None, + "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_dict_public(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 _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]]: + """Return the current structured block prompt for a blocked task.""" + 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 + 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 reason else None + 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 _comment_slice(comments: list[dict[str, Any]], *, comment_limit: int) -> list[dict[str, Any]]: + if comment_limit <= 0: + return [] + return comments[-comment_limit:] + + +def _important_comments(comments: list[dict[str, Any]], *, comment_limit: int) -> list[dict[str, Any]]: + if comment_limit <= 0: + return [] + picked: list[dict[str, Any]] = [] + seen: set[int] = set() + for c in reversed(comments): + if _IMPORTANT_COMMENT_RE.search(c.get("body") or ""): + picked.append(c) + seen.add(int(c["id"])) + if len(picked) >= comment_limit: + return list(reversed(picked)) + for c in reversed(comments): + if int(c["id"]) not in seen: + 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 _normalise_artifact_path(raw: str, *, workspace_path: Optional[str] = None) -> tuple[str, Optional[str], bool, Optional[str]]: + label_path = raw + expanded = os.path.expanduser(raw) + is_absolute = os.path.isabs(expanded) or re.match(r"^[A-Za-z]:[\\/]", expanded) is not None + if is_absolute: + return label_path, str(Path(expanded)), True, None + if workspace_path: + return label_path, str(Path(os.path.expanduser(workspace_path)) / expanded), True, None + return label_path, None, False, "relative path; no workspace base" + + +def _path_is_within(path: str, base: str) -> bool: + try: + Path(path).resolve().relative_to(Path(base).resolve()) + return True + except Exception: + return False + + +def _artifact_record( + raw_path: str, + *, + kind: str, + source: str, + workspace_path: Optional[str] = None, + workspace_kind: Optional[str] = None, + run_id: Optional[int] = None, + comment_id: Optional[int] = None, +) -> Optional[dict[str, Any]]: + text = _safe_path_text(raw_path) + if not text: + return None + label_path, resolved_path, openable_base, reason = _normalise_artifact_path( + text, workspace_path=workspace_path, + ) + exists = None + is_dir = None + if resolved_path: + try: + p = Path(resolved_path) + exists = p.exists() + is_dir = p.is_dir() if exists else None + except OSError: + exists = None + is_dir = None + reason = reason or "path could not be checked" + if exists is False: + reason = reason or "path does not exist" + + scratch_workspace = workspace_kind == "scratch" + scratch_derived = bool( + scratch_workspace + and resolved_path + and workspace_path + and _path_is_within(resolved_path, workspace_path) + ) + if scratch_derived: + reason = "scratch workspace path is temporary; no durable artifact path exposed" + elif kind == "workspace" and scratch_workspace: + reason = "scratch workspace is temporary; not a durable artifact" + + user_actionable = bool(openable_base and resolved_path and exists) and not scratch_derived + if source == "comment.regex": + user_actionable = False + reason = reason or "comment-derived path is shown for context only" + if kind == "workspace" and workspace_kind == "scratch": + user_actionable = False + openable = user_actionable + if user_actionable: + availability = "available" + public_path = label_path + public_resolved = resolved_path + public_label = label_path + elif scratch_derived or (kind == "workspace" and workspace_kind == "scratch"): + availability = "scratch" + public_path = None + public_resolved = None + public_label = "scratch workspace artifact" + elif exists is False: + availability = "missing" + public_path = None + public_resolved = None + public_label = f"missing {kind.replace('_', ' ')}" + else: + availability = "unknown" + public_path = None + public_resolved = None + public_label = f"unavailable {kind.replace('_', ' ')}" + + return { + "path": public_path, + "resolved_path": public_resolved, + "label": public_label, + "kind": kind, + "exists": exists, + "is_dir": is_dir, + "openable": openable, + "user_actionable": user_actionable, + "availability": availability, + "source": source, + "run_id": run_id, + "comment_id": comment_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 _extract_artifacts( + *, + task_row: sqlite3.Row, + runs: list[dict[str, Any]], + comments: list[dict[str, Any]], +) -> list[dict[str, Any]]: + artifacts: list[dict[str, Any]] = [] + seen: set[tuple[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_path = record.get("resolved_path") or record.get("path") or record.get("label") + key = (str(key_path), str(record.get("kind") or "unknown")) + if key in seen: + return + seen.add(key) + artifacts.append(record) + + if workspace_path: + label = "scratch workspace (may be temporary)" if workspace_kind == "scratch" else f"{workspace_kind} workspace" + rec = _artifact_record( + workspace_path, + kind="workspace", + source="task.workspace_path", + workspace_path=None, + workspace_kind=workspace_kind, + ) + if rec: + rec["label"] = label + add(rec) + + 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, "unknown"), + source=source, + workspace_path=workspace_path, + workspace_kind=workspace_kind, + run_id=run.get("id"), + )) + + for comment in comments: + body = comment.get("body") or "" + for match in _COMMENT_PATH_RE.finditer(body): + add(_artifact_record( + match.group("path"), + kind="artifact", + source="comment.regex", + workspace_path=workspace_path, + workspace_kind=workspace_kind, + comment_id=comment.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")] + if actionable: + state = "available" + reason = None + elif artifacts: + state = "absent" + reasons = [a.get("reason") for a in artifacts if a.get("reason")] + reason = reasons[0] if reasons else "no durable user-actionable artifact path" + else: + state = "absent" + reason = "no artifact metadata found" + return { + "state": state, + "has_user_actionable": bool(actionable), + "user_actionable_count": len(actionable), + "candidate_count": len(artifacts), + "reason": reason, + } + + +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) + + 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, []) + status_counts[row["status"]] = status_counts.get(row["status"], 0) + 1 + artifacts = _extract_artifacts(task_row=row, runs=task_runs, comments=all_comments) + task_events = events_by_task.get(tid, []) + tasks[tid] = { + "id": row["id"], + "title": row["title"], + "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": _comment_slice(all_comments, comment_limit=comment_limit) if include_comments 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), + "block": _blocked_state( + row["status"], + runs=task_runs, + comments=all_comments, + events=task_events, + ), + } + + 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, + } + + +def _derived_artifact_path_lookup(payload: dict[str, Any]) -> dict[str, str]: + paths: dict[str, str] = {} + for node in payload.get("tasks", {}).values(): + for artifact in node.get("artifacts", []): + if not artifact.get("user_actionable") or not artifact.get("openable"): + continue + resolved = artifact.get("resolved_path") or artifact.get("path") + if not isinstance(resolved, str) or not resolved: + continue + for key in ("path", "resolved_path"): + value = artifact.get(key) + if isinstance(value, str) and value: + paths[value] = resolved + return paths + + +def _open_local_path(path: str, *, mode: str) -> dict[str, Any]: + resolved = str(Path(os.path.expanduser(path))) + p = Path(resolved) + if not p.exists(): + return {"ok": False, "reason": "path does not exist", "path": path, "resolved_path": resolved} + try: + if sys.platform == "darwin": + cmd = ["open", "-R", resolved] if mode == "reveal" and not p.is_dir() else ["open", resolved] + subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif sys.platform.startswith("linux"): + target = resolved if p.is_dir() or mode == "open" else str(p.parent) + subprocess.Popen(["xdg-open", target], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif sys.platform.startswith("win"): + if p.is_dir() or mode == "open": + os.startfile(resolved) # type: ignore[attr-defined] + else: + subprocess.Popen(["explorer", f"/select,{resolved}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + return {"ok": False, "reason": f"unsupported platform: {sys.platform}", "path": path, "resolved_path": resolved} + except Exception as exc: + return {"ok": False, "reason": str(exc), "path": path, "resolved_path": resolved} + return {"ok": True, "path": path, "resolved_path": resolved, "mode": mode} + + # --------------------------------------------------------------------------- # GET /board # --------------------------------------------------------------------------- @@ -432,6 +1082,10 @@ def get_board( # for boards with hundreds of tasks). Truncated to a card-size # preview here — the full text is available via /tasks/:id. summary_map = kanban_db.latest_summaries(conn, [t.id for t in tasks]) + blocked_ids = [t.id for t in tasks if t.status == "blocked"] + blocked_runs = _runs_for_many(conn, blocked_ids) + blocked_comments = _comments_for_many(conn, blocked_ids) + blocked_events = _events_for_many(conn, blocked_ids) for t in tasks: full = summary_map.get(t.id) @@ -442,6 +1096,12 @@ def get_board( d["link_counts"] = link_counts.get(t.id, {"parents": 0, "children": 0}) d["comment_count"] = comment_counts.get(t.id, 0) d["progress"] = progress.get(t.id) # None when the task has no children + d["block"] = _blocked_state( + t.status, + runs=blocked_runs.get(t.id, []), + comments=blocked_comments.get(t.id, []), + events=blocked_events.get(t.id, []), + ) diags = diagnostics_per_task.get(t.id) if diags: # Full list goes into the payload so the drawer can render @@ -484,6 +1144,57 @@ def get_board( conn.close() +# --------------------------------------------------------------------------- +# GET /tasks/:id/summary-tree + local artifact open +# --------------------------------------------------------------------------- + +@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`` is accepted for the frontend contract. The backend currently + # returns all descendants with hard safety caps so v1 callers avoid N+1 + # requests without needing a separate max-depth negotiation. + _ = 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() + + +@router.post("/tasks/{task_id}/artifacts/open") +def open_task_artifact( + task_id: str, + body: OpenArtifactBody, + board: Optional[str] = Query(None), +): + """Open/reveal a derived local artifact path without accepting arbitrary paths.""" + board = _resolve_board(board) + conn = _conn(board=board) + try: + payload = _summary_tree_payload(conn, task_id, include_comments=True, comment_limit=10) + finally: + conn.close() + path_lookup = _derived_artifact_path_lookup(payload) + requested = body.path + requested_resolved = str(Path(os.path.expanduser(requested))) + allowed_path = path_lookup.get(requested) or path_lookup.get(requested_resolved) + if not allowed_path: + return {"ok": False, "reason": "path is not a derived artifact for this task", "path": requested} + return _open_local_path(allowed_path, mode=body.mode) + + # --------------------------------------------------------------------------- # GET /tasks/:id # --------------------------------------------------------------------------- @@ -527,20 +1238,32 @@ def get_task( if diag_list: task_d["diagnostics"] = diag_list task_d["warnings"] = _warnings_summary_from_diagnostics(diag_list) + comments = [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)] + events = [_event_dict(e) for e in kanban_db.list_events(conn, task_id)] + runs = [ + _run_dict(r) + for r in kanban_db.list_runs( + conn, + task_id, + state_type=run_state_type, + state_name=run_state_name, + ) + ] + # The block prompt should always reflect the full task history, even + # when the caller filtered the run-history table in the drawer. + all_runs = [_run_dict(r) for r in kanban_db.list_runs(conn, task_id)] + task_d["block"] = _blocked_state( + task.status, + runs=all_runs, + comments=comments, + events=events, + ) return { "task": task_d, - "comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)], - "events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)], + "comments": comments, + "events": events, "links": _links_for(conn, task_id), - "runs": [ - _run_dict(r) - for r in kanban_db.list_runs( - conn, - task_id, - state_type=run_state_type, - state_name=run_state_name, - ) - ], + "runs": runs, } finally: conn.close() diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 5fa1881fa329..fe3f3491b546 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -282,6 +282,210 @@ 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": "/tmp/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": "/tmp/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["tasks"][root["id"]]["current_step_key"] is None + assert data["stats"]["total"] == 3 + assert data["stats"]["max_depth"] == 2 + + +def test_task_summary_tree_extracts_artifacts_and_important_comments(client, tmp_path): + workspace = tmp_path / "workspace" + workspace.mkdir() + (workspace / "relative.py").write_text("print('hi')") + output = tmp_path / "out.txt" + output.write_text("saved") + doc = tmp_path / "doc.md" + doc.write_text("doc") + + 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={ + "workspace_path": str(workspace), + "diff_path": str(tmp_path / "changes.diff"), + "output_path": str(output), + "file_path": str(output), + "folder_path": str(workspace), + "artifact_path": str(tmp_path / "artifact.bin"), + "artifact_paths": [str(tmp_path / "a.bin"), {"path": str(tmp_path / "b.bin")}], + "document_path": str(doc), + "created_files": [str(tmp_path / "created.txt")], + "changed_files": ["relative.py"], + "saved_files": [str(tmp_path / "saved.txt")], + }, + ) + conn = kb.connect() + try: + kb.add_comment(conn, task["id"], "reviewer", f"review-required handoff: saved artifact at {doc}") + 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"] + kinds = {a["kind"] for a in artifacts} + assert { + "workspace", "diff", "output", "file", "folder", "artifact", "document", "created_file", "changed_file", "saved_file", + } <= kinds + changed = next(a for a in artifacts if a["kind"] == "changed_file") + assert changed["path"] == "relative.py" + assert changed["resolved_path"] == str(workspace / "relative.py") + assert changed["exists"] is True + comment_artifact = next(a for a in artifacts if a["source"] == "comment.regex") + assert comment_artifact["user_actionable"] is False + assert comment_artifact["path"] is None + assert comment_artifact["resolved_path"] is None + assert node["important_comments"][0]["body"].startswith("review-required handoff") + assert node["comment_count"] == 1 + + +def test_task_summary_tree_multiple_parents_do_not_duplicate_node(client): + root = client.post("/api/plugins/kanban/tasks", json={"title": "root"}).json()["task"] + first = client.post("/api/plugins/kanban/tasks", json={"title": "first", "parents": [root["id"]]}).json()["task"] + second = client.post("/api/plugins/kanban/tasks", json={"title": "second", "parents": [root["id"]]}).json()["task"] + shared = client.post( + "/api/plugins/kanban/tasks", + json={"title": "shared", "parents": [first["id"], second["id"]]}, + ).json()["task"] + + r = client.get(f"/api/plugins/kanban/tasks/{root['id']}/summary-tree") + assert r.status_code == 200, r.text + data = r.json() + assert data["order"].count(shared["id"]) == 1 + assert set(data["tasks"][shared["id"]]["parents"]) == {first["id"], second["id"]} + assert {tuple((e["parent_id"], e["child_id"])) for e in data["edges"]} >= { + (first["id"], shared["id"]), + (second["id"], shared["id"]), + } + + +def test_open_artifact_endpoint_refuses_arbitrary_paths_and_handles_missing_allowed_path(client, tmp_path): + missing = tmp_path / "missing.txt" + task = client.post("/api/plugins/kanban/tasks", json={"title": "artifact open"}).json()["task"] + _complete_task(task["id"], summary="has missing artifact", metadata={"file_path": str(missing)}) + + r = client.post( + f"/api/plugins/kanban/tasks/{task['id']}/artifacts/open", + json={"path": "/etc/passwd", "mode": "reveal"}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is False + assert "not a derived artifact" in r.json()["reason"] + + r = client.post( + f"/api/plugins/kanban/tasks/{task['id']}/artifacts/open", + json={"path": str(missing), "mode": "reveal"}, + ) + assert r.status_code == 200 + assert r.json()["ok"] is False + assert "not a derived artifact" in r.json()["reason"] + + +def test_summary_tree_redacts_missing_artifact_paths(client, tmp_path): + missing = tmp_path / "missing-report.md" + task = client.post("/api/plugins/kanban/tasks", json={"title": "missing artifact"}).json()["task"] + _complete_task(task["id"], summary="expected doc", metadata={"document_path": str(missing)}) + + r = client.get(f"/api/plugins/kanban/tasks/{task['id']}/summary-tree") + assert r.status_code == 200, r.text + node = r.json()["tasks"][task["id"]] + doc_artifacts = [a for a in node["artifacts"] if a["kind"] == "document"] + assert doc_artifacts + assert all(a["availability"] == "missing" for a in doc_artifacts) + assert all(a["path"] is None and a["resolved_path"] is None for a in doc_artifacts) + assert node["artifact_state"]["state"] == "absent" + assert node["artifact_state"]["has_user_actionable"] is False + + +def test_summary_tree_redacts_scratch_workspace_artifact_paths(client, tmp_path): + workspace = tmp_path / "scratch" + workspace.mkdir() + doc = workspace / "report.md" + doc.write_text("temporary report") + + task = client.post( + "/api/plugins/kanban/tasks", + json={ + "title": "scratch artifact", + "workspace_kind": "scratch", + "workspace_path": str(workspace), + }, + ).json()["task"] + _complete_task(task["id"], summary="wrote doc", metadata={"document_path": str(doc)}) + + r = client.get(f"/api/plugins/kanban/tasks/{task['id']}/summary-tree") + assert r.status_code == 200, r.text + node = r.json()["tasks"][task["id"]] + assert node["artifact_state"] == { + "state": "absent", + "has_user_actionable": False, + "user_actionable_count": 0, + "candidate_count": 2, + "reason": "scratch workspace is temporary; not a durable artifact", + } + assert {a["availability"] for a in node["artifacts"]} == {"scratch"} + assert all(a["path"] is None and a["resolved_path"] is None for a in node["artifacts"]) + assert all(str(workspace) not in (a["label"] or "") for a in node["artifacts"]) + + # --------------------------------------------------------------------------- # PATCH /tasks/:id — status transitions # --------------------------------------------------------------------------- @@ -321,6 +525,33 @@ def test_patch_block_then_unblock(client): assert r.json()["task"]["status"] == "ready" +def test_blocked_task_exposes_current_missing_info_on_board_detail_and_summary_tree(client): + t = client.post("/api/plugins/kanban/tasks", json={"title": "needs decision"}).json()["task"] + r = client.patch( + f"/api/plugins/kanban/tasks/{t['id']}", + json={"status": "blocked", "block_reason": "choose the rollout window"}, + ) + assert r.status_code == 200 + conn = kb.connect() + try: + kb.add_comment(conn, t["id"], "pm", "Use Friday unless launch support says no") + finally: + conn.close() + + board = client.get("/api/plugins/kanban/board").json() + blocked_card = next(c for c in board["columns"] if c["name"] == "blocked")["tasks"][0] + assert blocked_card["block"]["reason"] == "choose the rollout window" + assert blocked_card["block"]["missing_info"] == "choose the rollout window" + assert blocked_card["block"]["latest_relevant_comment"]["body"].startswith("Use Friday") + + detail = client.get(f"/api/plugins/kanban/tasks/{t['id']}").json() + assert detail["task"]["block"]["source"] == "event.payload.reason" + assert detail["task"]["block"]["comment_prompt"].startswith("Add the missing info") + + tree = client.get(f"/api/plugins/kanban/tasks/{t['id']}/summary-tree").json() + assert tree["tasks"][t["id"]]["block"]["event_id"] == detail["task"]["block"]["event_id"] + + def test_patch_schedule_then_unblock(client): t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"] r = client.patch( @@ -1033,6 +1264,30 @@ def test_dashboard_dependency_selects_use_value_change_handler(): assert child_select in bundle +def test_dashboard_bundle_renders_summary_artifacts_and_blocked_ux(): + """The dashboard bundle should expose the summary-tree/artifact/block + affordances backed by the Kanban dashboard API. + """ + repo_root = Path(__file__).resolve().parents[2] + bundle = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + ).read_text() + css = ( + repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "style.css" + ).read_text() + + assert "function TaskSummaryTreeSection(props)" in bundle + assert "summary-tree?comment_limit=3" in bundle + assert "function ArtifactButtons(props)" in bundle + assert "/artifacts/open" in bundle + assert "Blocked — missing input needed" in bundle + assert "function BlockedStatusPanel(props)" in bundle + + assert ".hermes-kanban-summary-tree" in css + assert ".hermes-kanban-artifact-chip" in css + assert ".hermes-kanban-blocked-top" in css + + 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"]