diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 720cdb9e1e22..175d993277ff 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -2121,6 +2121,12 @@ ), h("div", { className: "hermes-kanban-card-title" }, t.title || tx(i18n, "untitled", "(untitled)")), + (progress || (t.link_counts && t.link_counts.children > 0) || t.result) + ? h("div", { + className: "hermes-kanban-card-summary-hint", + title: "Open this card to review Result + subtasks", + }, "↳ result + subtasks") + : null, h("div", { className: "hermes-kanban-card-row hermes-kanban-card-meta" }, t.assignee ? h("span", { className: "hermes-kanban-assignee", @@ -2414,6 +2420,26 @@ .catch(function (e) { setErr(String(e.message || e)); }); }; + const renderCommentRow = function (extraClassName, placeholder) { + return h("div", { className: cn("hermes-kanban-drawer-comment-row", extraClassName) }, + h(Input, { + value: newComment, + onChange: function (e) { setNewComment(e.target.value); }, + onKeyDown: function (e) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); handleComment(); + } + }, + placeholder: placeholder || tx(t, "addComment", "Add a comment… (Enter to submit)"), + className: "h-8 text-sm flex-1", + }), + h(Button, { + onClick: handleComment, + size: "sm", + }, tx(t, "comment", "Comment")), + ); + }; + const toggleHomeSubscription = function (platform, currentlySubscribed) { // Optimistic flip + busy flag to keep double-clicks idempotent. setHomeBusy(function (b) { return Object.assign({}, b, { [platform]: true }); }); @@ -2468,6 +2494,22 @@ 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, + renderCommentRow("hermes-kanban-drawer-comment-row--inline", + tx(t, "blockedCommentPlaceholder", "Add the missing info here… (Enter to submit)")), + ) + : null, data ? h(TaskDetail, { data, editing, setEditing, renderMarkdown: props.renderMarkdown, @@ -2485,27 +2527,34 @@ onToggleHomeSub: toggleHomeSubscription, onRefresh: props.onRefresh, }) : null, - data ? h("div", { className: "hermes-kanban-drawer-comment-row" }, - 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, "addComment", "Add a comment… (Enter to submit)"), - className: "h-8 text-sm flex-1", - }), - h(Button, { - onClick: handleComment, - size: "sm", - }, tx(t, "comment", "Comment")), - ) : null, + data ? renderCommentRow(null, tx(t, "addComment", "Add a comment… (Enter to submit)")) : null, ), ); } + 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, + ); + } + function TaskDetail(props) { const { t: i18n } = useI18n(); const t = props.data.task; @@ -2550,6 +2599,7 @@ onPatch: props.onPatch, onSpecify: props.onSpecify, }), + h(BlockedStatusPanel, { block: t.block }), h(DiagnosticsSection, { task: t, boardSlug: props.boardSlug, @@ -2562,6 +2612,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, @@ -2646,6 +2701,233 @@ ); } + // Selected task rollup: one dense section for the current task's own + // result/handoff plus all descendant subtask summaries/artifacts. This is + // intentionally table-first so a parent task can be reviewed without opening + // every child drawer and scrolling through runs/comments one by one. + 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: String(e.message || 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.reason || "not opened") }); + }); + }).catch(function (e) { + setOpenStatus(function (prev) { return Object.assign({}, prev, { [path]: String(e.message || 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.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, + ); + } + // Per-attempt history. Closed runs first (most recent last), then the // active run if any. Each row shows profile / outcome / elapsed / // summary. Collapsed by default when there are more than three runs. diff --git a/plugins/kanban/dashboard/dist/style.css b/plugins/kanban/dashboard/dist/style.css index 3bcfccb289b5..9cb6fb0638ec 100644 --- a/plugins/kanban/dashboard/dist/style.css +++ b/plugins/kanban/dashboard/dist/style.css @@ -261,6 +261,22 @@ word-break: break-word; } +.hermes-kanban-card-summary-hint { + width: fit-content; + max-width: 100%; + margin-top: -0.1rem; + padding: 0.12rem 0.4rem; + border: 1px solid color-mix(in srgb, var(--color-ring) 45%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-ring) 14%, transparent); + color: color-mix(in srgb, var(--color-ring) 70%, var(--color-foreground)); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1.2; +} + .hermes-kanban-card-meta { font-size: 0.7rem; color: var(--color-muted-foreground); @@ -524,6 +540,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; @@ -643,6 +704,191 @@ } .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 7b0cb1d791a7..6e57b900e55e 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 @@ -334,10 +338,644 @@ 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 + + +class OpenArtifactBody(BaseModel): + path: str + mode: str = Field("reveal", pattern="^(reveal|open)$") + + +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"], + "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 _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 _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) + latest_blocked_run = next( + (r for r in runs if r.get("outcome") == "blocked" or r.get("status") == "blocked"), + 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 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): + # Metadata sometimes stores {"path": "...", "label": "..."} entries in lists. + 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") + 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 task_row["workspace_kind"] == "scratch" else f"{task_row['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) + # A dangling link should not 500 the dashboard; omit missing nodes but keep stats.truncated. + 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) if include_comments else {tid: [] for tid in 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"], + "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 ["triage", "todo", "ready", "running", "blocked", "done", "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 # --------------------------------------------------------------------------- + @router.get("/board") def get_board( tenant: Optional[str] = Query(None, description="Filter to a single tenant"), @@ -411,6 +1049,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) @@ -421,6 +1063,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 @@ -463,6 +1111,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 # --------------------------------------------------------------------------- @@ -487,12 +1186,21 @@ def get_task(task_id: str, board: Optional[str] = Query(None)): 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)] + task_d["block"] = _blocked_state( + task.status, + runs=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)], + "runs": runs, } finally: conn.close() diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index d4c3f2adc474..402a6c2958ab 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -199,6 +199,206 @@ 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["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 + assert any(a["source"] == "comment.regex" and a["resolved_path"] == str(doc) for a in artifacts) + 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 # --------------------------------------------------------------------------- @@ -238,6 +438,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_drag_drop_move_todo_to_ready(client): """Direct status write: the drag-drop path for statuses without a dedicated verb (e.g. manually promoting todo -> ready).