From 2ca0b5f1f0b0d8e750c1eed6379cae4a1ee0260e Mon Sep 17 00:00:00 2001 From: svlad-90 Date: Fri, 28 Aug 2026 15:22:46 +0300 Subject: [PATCH 1/6] diff_report: add dashboard graph rendering Add dashboard graph widgets to JSON diff reports and keep the related assets bundled through the public report builder. Cover the generated HTML contract so MCP report rendering keeps the same graph helpers available to callers. Signed-off-by: svlad-90 Assisted-by: Codex:gpt-5 --- agent_tools/tools/diff_report/assets_story.py | 23 + .../tools/diff_report/assets_styles.py | 20 +- agent_tools/tools/diff_report/cli.py | 6 + agent_tools/tools/diff_report/core.py | 3 +- agent_tools/tools/diff_report/report_json.py | 870 ++++++++++++++++-- .../diff_report/tests/test_assets_contract.py | 12 +- .../diff_report/tests/test_report_json.py | 7 +- 7 files changed, 866 insertions(+), 75 deletions(-) diff --git a/agent_tools/tools/diff_report/assets_story.py b/agent_tools/tools/diff_report/assets_story.py index f02f9278..542f6e1f 100644 --- a/agent_tools/tools/diff_report/assets_story.py +++ b/agent_tools/tools/diff_report/assets_story.py @@ -26,6 +26,7 @@ def story_script() -> str: let activeScrollEndTimer = 0; let activeFlashClearTimer = 0; let navigationToken = 0; + let userScrollIntent = false; let topStateRaf = 0; let storyOffsetRaf = 0; const storySentinel = story ? document.createElement("div") : null; @@ -1235,6 +1236,9 @@ def story_script() -> str: } function resetPageScrollOnLoad() { + if (userScrollIntent || window.scrollY > 0) { + return; + } const nav = document.getElementById("review-comments"); window.scrollTo(0, 0); if (nav) { @@ -1245,6 +1249,22 @@ def story_script() -> str: document.body.classList.remove("has-pinned-story"); } + function cancelProgrammaticScrollForUserIntent() { + userScrollIntent = true; + navigationToken += 1; + window.clearTimeout(activeScrollTimer); + window.clearTimeout(activeScrollEndTimer); + } + + function handleScrollKeyIntent(event) { + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } + if (["ArrowDown", "ArrowUp", "PageDown", "PageUp", "Home", "End", " "].includes(event.key)) { + cancelProgrammaticScrollForUserIntent(); + } + } + function animateWindowScrollToElement(element, durationMs) { window.clearTimeout(activeScrollTimer); window.clearTimeout(activeScrollEndTimer); @@ -1743,6 +1763,9 @@ def story_script() -> str: updateTopButtonState(); scheduleStoryOffsetUpdate(); }, { passive: true }); + window.addEventListener("wheel", cancelProgrammaticScrollForUserIntent, { passive: true }); + window.addEventListener("touchmove", cancelProgrammaticScrollForUserIntent, { passive: true }); + window.addEventListener("keydown", handleScrollKeyIntent, { capture: true }); window.addEventListener("resize", function () { scheduleStoryOffsetUpdate(); scheduleStoryPagerResize(); diff --git a/agent_tools/tools/diff_report/assets_styles.py b/agent_tools/tools/diff_report/assets_styles.py index 7e500dd0..6093fb55 100644 --- a/agent_tools/tools/diff_report/assets_styles.py +++ b/agent_tools/tools/diff_report/assets_styles.py @@ -8,6 +8,7 @@ def stylesheet() -> str: color-scheme: light; --bg: #f3f3f3; --panel: #fbfbfc; + --card-bg: var(--panel); --panel-subtle: #f1f3f6; --meta-panel: #f7f8fa; --meta-border: #b8c0ca; @@ -23,6 +24,7 @@ def stylesheet() -> str: --border: #b8c0ca; --text: #1f1f1f; --muted: #616161; + --font-stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --link: #007acc; --button-bg: #ffffff; --button-hover-bg: #e5f1fb; @@ -125,6 +127,7 @@ def stylesheet() -> str: color-scheme: dark; --bg: #1e1e1e; --panel: #252526; + --card-bg: var(--panel); --panel-subtle: #2d2d30; --meta-panel: #1b1b1d; --meta-border: #52525a; @@ -140,6 +143,7 @@ def stylesheet() -> str: --border: #3c3c3c; --text: #d4d4d4; --muted: #858585; + --font-stack: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --link: #3794ff; --button-bg: #2d2d30; --button-hover-bg: #094771; @@ -364,7 +368,7 @@ def stylesheet() -> str: .report-metric-cell-part.status-not-run, .report-metric-cell-part.status-not-done, .report-metric-cell-part.status-unknown, .report-metric-cell-part.status-needs-evidence { color: var(--muted); } .report-table-filter { display: grid; gap: 5px; max-width: 460px; margin-bottom: 10px; } .report-table-filter input { min-height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--button-bg); color: var(--text); font: inherit; } - .report-table-wrap { max-width: 100%; overflow: visible; border: 1px solid var(--border); border-radius: 8px; } + .report-table-wrap { max-width: 100%; overflow-x: auto; overflow-y: visible; border: 1px solid var(--border); border-radius: 8px; } .report-table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: auto; } .report-table th, .report-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); text-align: left; vertical-align: top; overflow-wrap: anywhere; } .report-table th:last-child, .report-table td:last-child { border-right: 0; } @@ -458,7 +462,7 @@ def stylesheet() -> str: .relationship-page-controls button { min-width: 24px; min-height: 22px; padding: 0 6px; } .relationship-page-controls span { min-width: 86px; text-align: center; } .relationship-page-controls.is-single-page { color: var(--muted); } - .relationship-control-bar { position: sticky; top: 0; z-index: 4; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 4px 10px color-mix(in srgb, var(--shadow) 26%, transparent); } + .relationship-control-bar { position: sticky; top: 0; z-index: 4; display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 8px; align-items: center; min-width: 0; padding: 4px 6px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 4px 10px color-mix(in srgb, var(--shadow) 26%, transparent); } .relationship-filters { flex-basis: 100%; min-width: 0; } .relationship-filters > summary { display: inline-flex; align-items: center; gap: 6px; width: fit-content; min-height: 26px; padding: 0 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--button-bg); color: var(--meta-text); font: 800 12px/1.1 var(--font-stack); cursor: pointer; list-style: none; } .relationship-filters > summary::-webkit-details-marker { display: none; } @@ -485,11 +489,19 @@ def stylesheet() -> str: .relationship-subfilter-popover .status-not-run, .relationship-subfilter-popover .status-not-done, .relationship-subfilter-popover .status-unknown { color: var(--graph-status-neutral-border); font-weight: 800; } .relationship-subfilter-popover p { margin: 0; color: var(--meta-text); } .relationship-nav-controls { display: flex; flex-wrap: nowrap; gap: 6px; align-items: center; } + .relationship-page-controls { justify-self: end; } .relationship-toolbar button { min-height: 28px; padding: 0 9px; border: 1px solid var(--border); border-radius: 999px; background: var(--button-bg); color: var(--text); font: 750 .86em/1.1 var(--font-stack); cursor: pointer; } .relationship-toolbar button:hover, .relationship-toolbar button.is-active { border-color: var(--link); background: var(--button-hover-bg); color: var(--link); } .relationship-toolbar button:disabled { cursor: default; color: var(--muted); border-color: var(--border); background: var(--button-bg); } .relationship-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(360px, .8fr); gap: 12px; align-items: start; min-width: 0; } .relationship-canvas-wrap, .relationship-detail { min-width: 0; height: min(760px, calc(100vh - 180px)); min-height: 560px; max-height: 760px; border: 1px solid var(--border); border-radius: 8px; background: var(--meta-panel); } + .relationship-browser.is-graph-ready .relationship-canvas-wrap { border-color: color-mix(in srgb, var(--meta-border) 72%, var(--border)); } + .relationship-browser.is-graph-active .relationship-toolbar, .relationship-browser.is-graph-active .relationship-control-bar, .relationship-browser.is-graph-active .relationship-selection-panel, .relationship-browser.is-graph-active .relationship-detail { border-color: color-mix(in srgb, var(--link) 36%, var(--border)); } + .relationship-browser.is-graph-active .relationship-canvas-wrap { border-color: color-mix(in srgb, var(--link) 50%, var(--border)); } + .relationship-browser.is-graph-focused .relationship-canvas-wrap { border-color: color-mix(in srgb, var(--link) 78%, var(--border)); outline: 2px solid color-mix(in srgb, var(--link) 78%, var(--border)); outline-offset: -2px; box-shadow: 0 0 0 3px color-mix(in srgb, var(--link) 18%, transparent); } + .relationship-focus-badge { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border: 1px solid color-mix(in srgb, var(--link) 62%, var(--border)); border-radius: 999px; background: color-mix(in srgb, var(--button-hover-bg) 72%, var(--panel)); color: var(--link); cursor: pointer; font: 850 12px/1 var(--font-stack); text-transform: uppercase; letter-spacing: .04em; } + .relationship-focus-badge:hover { border-color: var(--link); background: var(--button-hover-bg); } + .relationship-focus-badge[hidden] { display: none !important; } .relationship-modal .relationship-browser { min-height: 0; } .relationship-modal .relationship-layout { grid-template-columns: minmax(0, 2fr) minmax(380px, .72fr); align-items: stretch; min-height: 0; height: 100%; overflow: hidden; } .relationship-modal .relationship-canvas-wrap { height: clamp(520px, 58vh, 900px); min-height: 520px; max-height: none; } @@ -503,7 +515,7 @@ def stylesheet() -> str: .relationship-control-bar button:hover { border-color: var(--link); background: var(--button-hover-bg); color: var(--link); } .relationship-control-bar button:disabled { cursor: default; color: var(--muted); border-color: var(--border); background: var(--button-bg); } .relationship-canvas { position: relative; display: block; width: 100%; height: 100%; cursor: grab; } - .relationship-canvas[data-graph-interactive="false"] { cursor: default; pointer-events: none; } + .relationship-canvas[data-graph-interactive="false"] { cursor: default; } .relationship-canvas[data-graph-message]::after { content: attr(data-graph-message); position: absolute; left: 16px; right: 16px; bottom: 14px; z-index: 2; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: color-mix(in srgb, var(--panel) 88%, transparent); color: var(--meta-text); font-size: .9em; pointer-events: none; } .relationship-canvas[data-empty-graph="true"]::after { top: 50%; bottom: auto; transform: translateY(-50%); text-align: center; } .relationship-canvas[data-graph-hint="filtered"]::after { top: 14px; bottom: auto; border-color: color-mix(in srgb, var(--comment-border) 62%, var(--border)); color: var(--comment-border); text-align: center; } @@ -853,7 +865,7 @@ def stylesheet() -> str: .relationship-selection-table { min-height: 0; } .relationship-nav-controls { flex-wrap: wrap; } .to-top-button { right: 14px; bottom: calc(var(--story-nav-height) + 24px + var(--floating-control-size) + 12px); } - .review-nav { position: static; width: calc(100% - 16px); max-height: 38vh; margin: 8px auto 16px; } + .report-toc, .review-nav { position: static; width: calc(100% - 16px); max-height: 38vh; margin: 8px auto 16px; } .review-nav-resizer { display: none; } .story { top: 0; } body.has-pinned-story .story, body.has-diagram-open .story { left: 8px; right: 8px; top: 0; max-height: 35vh; } diff --git a/agent_tools/tools/diff_report/cli.py b/agent_tools/tools/diff_report/cli.py index d1d0c524..4f0454e3 100644 --- a/agent_tools/tools/diff_report/cli.py +++ b/agent_tools/tools/diff_report/cli.py @@ -31,6 +31,11 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--compose-report", help="Write findings compose diagnostics JSON.") parser.add_argument("--output", help="HTML report output path.") parser.add_argument("--report-json", help="Render a generic non-diff report from JSON.") + parser.add_argument( + "--report-test-mode", + action="store_true", + help="Inject report self-test automation hooks when rendering --report-json.", + ) parser.add_argument("--title", default="PR Diff Review", help="Report title.") parser.add_argument("--context", type=int, default=80, help="Git diff context lines.") parser.add_argument( @@ -79,6 +84,7 @@ def main(argv: list[str] | None = None) -> int: report_file=report_json, output_path=output, title=args.title if args.title != "PR Diff Review" else None, + test_mode=args.report_test_mode, ) print(str(output)) return 0 diff --git a/agent_tools/tools/diff_report/core.py b/agent_tools/tools/diff_report/core.py index aa7c43d4..c93950c6 100644 --- a/agent_tools/tools/diff_report/core.py +++ b/agent_tools/tools/diff_report/core.py @@ -76,9 +76,10 @@ def generate_report_json( report_file: Path, output_path: Path, title: str | None = None, + test_mode: bool = False, ) -> None: report = load_report_json(report_file) if title is not None: report = replace(report, title=title) output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(render_report_json_html(report), encoding="utf-8") + output_path.write_text(render_report_json_html(report, test_mode=test_mode), encoding="utf-8") diff --git a/agent_tools/tools/diff_report/report_json.py b/agent_tools/tools/diff_report/report_json.py index 77887c0a..511a3c45 100644 --- a/agent_tools/tools/diff_report/report_json.py +++ b/agent_tools/tools/diff_report/report_json.py @@ -168,7 +168,7 @@ def report_from_payload( ) -def render_report_json_html(report: GenericReport) -> str: +def render_report_json_html(report: GenericReport, test_mode: bool = False) -> str: comments = report.comments parts: list[str] = [] parts.append(html_header(report.title)) @@ -220,6 +220,8 @@ def render_report_json_html(report: GenericReport) -> str: if report.relationship_graph: parts.append(_cytoscape_vendor_script()) parts.append(_relationship_graph_script()) + if test_mode: + parts.append(_report_self_test_script()) parts.append(story_script()) parts.append(theme_script()) parts.append("\n\n\n") @@ -901,6 +903,7 @@ def _render_relationship_graph_section(graph: RelationshipGraph) -> str: +
Page 1 @@ -1384,6 +1387,11 @@ def _relationship_graph_script() -> str: state.scopedAdjacencyCache = null; } + function isolatedIdsForRoot(state, rootId) { + const id = String(rootId || ""); + return id ? directedReachableIds(id, state) : null; + } + function nodeInScope(state, node) { if (!node) return false; return !state.isolatedNodeIds || state.isolatedNodeIds.has(node.id); @@ -1618,6 +1626,14 @@ def _relationship_graph_script() -> str: const neighbors = (nodeId) => adjacency.get(nodeId) || []; const distances = new Map([[selectedId, 0]]); const traversedEdgeKeys = new Set(); + const traversedEdgesByKey = new Map(); + const markTraversedEdge = (edge) => { + const key = edgeKey(edge); + traversedEdgeKeys.add(key); + if (!traversedEdgesByKey.has(key)) { + traversedEdgesByKey.set(key, edge); + } + }; const selectedRank = rankOf(selected); const sortCandidate = (left, right) => { const leftNode = nodesById.get(left.id); @@ -1655,7 +1671,7 @@ def _relationship_graph_script() -> str: ? regularCandidates : rawCandidates.filter((item) => isFallbackTraversalEdge(item.edge, traversal)); for (const parent of candidates) { - traversedEdgeKeys.add(edgeKey(parent.edge)); + markTraversedEdge(parent.edge); if (seen.has(parent.id)) continue; seen.add(parent.id); ancestry.add(parent.id); @@ -1691,7 +1707,10 @@ def _relationship_graph_script() -> str: if (!rankedChild && !fallbackChild) continue; (edgeFallback ? fallbackItems : regularItems).push({item, toId, toNode, edgeSecondary}); } - const items = regularItems.length ? regularItems : fallbackItems; + const visibleFallbackItems = fallbackItems.filter((candidate) => nodeVisible(candidate.toNode)); + const items = regularItems.length + ? regularItems.concat(visibleFallbackItems) + : fallbackItems; for (const candidate of items) { const {item, toId, toNode, edgeSecondary} = candidate; downSeen.add(toId); @@ -1709,10 +1728,10 @@ def _relationship_graph_script() -> str: } } } - if (visibleDescendants.length) { + if (visibleDescendants.length || secondaryDescendants.length || fallbackDescendants.length) { // Draw the first visible child rank only. Hidden intermediate nodes are still traversed, // and direct shortcut edges to deeper ranks do not mix tests with requirements. - const candidateDescendants = visibleDescendants.concat(secondaryDescendants); + const candidateDescendants = visibleDescendants.concat(secondaryDescendants, fallbackDescendants); const visibleChildRank = Math.min(...candidateDescendants.map((id) => rankOf(nodesById.get(id)))); const shownDescendants = candidateDescendants.filter((id) => rankOf(nodesById.get(id)) === visibleChildRank); for (const targetId of shownDescendants) { @@ -1728,17 +1747,10 @@ def _relationship_graph_script() -> str: path.reverse(); for (let index = 0; index < path.length; index += 1) { const item = path[index]; - traversedEdgeKeys.add(edgeKey(item.edge)); + markTraversedEdge(item.edge); distances.set(item.id, Math.min(distances.get(item.id) ?? index + 1, index + 1)); } } - } else if (fallbackDescendants.length) { - for (const targetId of fallbackDescendants) { - const parent = downParents.get(targetId); - if (!parent) continue; - traversedEdgeKeys.add(edgeKey(parent.edge)); - distances.set(targetId, Math.min(distances.get(targetId) ?? 1, 1)); - } } const selectedVisible = nodeVisible(selected); const hasVisibleNonFocusNode = Array.from(distances.keys()).some((id) => { @@ -1751,8 +1763,7 @@ def _relationship_graph_script() -> str: const node = nodesById.get(id); return nodeVisible(node) || (ancestry.has(id) && contextNodeVisible(node)); })); - const visibleEdges = edges.filter((edge) => { - if (!traversedEdgeKeys.has(edgeKey(edge))) return false; + const visibleEdges = Array.from(traversedEdgesByKey.values()).filter((edge) => { const sourceDistance = visibleIds.has(edge.source) ? distances.get(edge.source) : null; const targetDistance = visibleIds.has(edge.target) ? distances.get(edge.target) : null; if (sourceDistance == null || targetDistance == null) return false; @@ -2092,7 +2103,14 @@ def _relationship_graph_script() -> str: return value || fallback; } + let cachedCytoscapeStyleTheme = ""; + let cachedCytoscapeStyle = null; + function cytoscapeStyle() { + const theme = document.documentElement.dataset.theme || ""; + if (cachedCytoscapeStyle && cachedCytoscapeStyleTheme === theme) { + return cachedCytoscapeStyle; + } const text = cssValue("--graph-node-text", "#111827"); const panel = cssValue("--graph-node-bg", "#ffffff"); const metaPanel = cssValue("--graph-artifact-bg", "#f1f5f9"); @@ -2113,7 +2131,8 @@ def _relationship_graph_script() -> str: const autoPassBg = cssValue("--graph-status-auto-pass-bg", "#eff6ff"); const neutral = cssValue("--graph-status-neutral-border", "#64748b"); const neutralBg = cssValue("--graph-status-neutral-bg", "#f8fafc"); - return [ + cachedCytoscapeStyleTheme = theme; + cachedCytoscapeStyle = [ { selector: "node", style: { @@ -2156,6 +2175,7 @@ def _relationship_graph_script() -> str: {selector: ".is-active", style: {"border-color": active, "border-width": 4}}, {selector: "node:selected", style: {"border-color": link, "border-width": 4}} ]; + return cachedCytoscapeStyle; } function listNodePosition(index, graph) { @@ -2285,31 +2305,36 @@ def _relationship_graph_script() -> str: function setGraphInteractive(browser, state, interactive) { state.graphInteractive = Boolean(interactive); + state.graphFocusActive = state.graphInteractive; syncGraphViewport(state); const canvas = browser.querySelector("[data-relationship-canvas]"); if (canvas) { canvas.setAttribute("data-graph-interactive", state.graphInteractive ? "true" : "false"); } + refreshGraphFocusState(browser, state); if (!state.cy) return; state.cy.userZoomingEnabled(state.graphInteractive); state.cy.userPanningEnabled(state.graphInteractive); state.cy.autoungrabify(!state.graphInteractive); } + function activateGraphFocus(browser, state) { + syncGraphViewport(state); + if (!state.graphInteractive) { + setGraphInteractive(browser, state, true); + } + state.graphFocusActive = true; + refreshGraphFocusState(browser, state); + } + function releaseGraphFocus(browser, state) { - const hasActiveGraphFocus = state.graphInteractive || Boolean(state.activeId); + const hasActiveGraphFocus = state.graphInteractive || state.graphFocusActive; if (!hasActiveGraphFocus) return; const active = document.activeElement; if (active && typeof active.blur === "function") { active.blur(); } setGraphInteractive(browser, state, false); - state.activeId = ""; - if (state.cy) { - state.cy.elements().unselect(); - state.cy.nodes().removeClass("is-active"); - } - updateActiveTableRow(browser, state); } function updateGraphPageControls(browser, state, pagination) { @@ -2384,6 +2409,9 @@ def _relationship_graph_script() -> str: function renderGraph(browser, state) { const canvas = browser.querySelector("[data-relationship-canvas]"); if (!canvas || !state.selectedId) return; + browser.classList.add("is-graph-ready"); + browser.setAttribute("data-relationship-graph-ready", "true"); + refreshGraphActivityState(browser, state); if (typeof cytoscape !== "function") { canvas.textContent = "Cytoscape.js is not available."; return; @@ -2480,7 +2508,7 @@ def _relationship_graph_script() -> str: state.canvasViewportListenersAttached = true; } state.cy.on("tap", "node", (event) => { - syncGraphViewport(state); + activateGraphFocus(browser, state); const nodeId = event.target.id(); selectNode(browser, state, nodeId, true); }); @@ -2789,7 +2817,7 @@ def _relationship_graph_script() -> str: function applyProjectionSelections(browser, state) { state.enabledTypes = projectionTypesFromSelections(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function ensureFocusFilters(browser, state, nodeId) { @@ -2809,7 +2837,7 @@ def _relationship_graph_script() -> str: : encodeProjectionChoice(group.types.filter((candidate) => selected.has(candidate))); } } - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); return true; } @@ -2840,7 +2868,7 @@ def _relationship_graph_script() -> str: state.enabledTypes = focusViewTypes(state, nodeId); state.projectionMode = "auto"; state.projectionSelections = defaultProjectionSelections(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function enableAllEntityTypes(browser, state) { @@ -2848,7 +2876,15 @@ def _relationship_graph_script() -> str: state.enabledTypes = new Set(types); state.projectionMode = "custom"; state.projectionSelections = defaultProjectionSelections(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, types); + refreshGraphControlState(browser, state, types); + } + + function relationshipControls(browser) { + return browser.querySelector("[data-relationship-projection-controls]") || browser; + } + + function refreshGraphControlState(browser, state, types) { + refreshTypeFilterState(relationshipControls(browser), state, types || stateGraphTypes(state)); } function resetGraphFilters(browser, state) { @@ -2859,7 +2895,7 @@ def _relationship_graph_script() -> str: state.activeSubfilterType = ""; state.activeSubfilterTypes = []; state.typeSearchType = ""; - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function resetGraphFiltersToAll(browser, state) { @@ -2873,7 +2909,7 @@ def _relationship_graph_script() -> str: state.activeSubfilterTypes = []; state.typeSearchType = ""; state.graphPage = 0; - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); } function childLayerSubfilterTypes(state) { @@ -2912,6 +2948,48 @@ def _relationship_graph_script() -> str: return types.length > 0 && types.every((type) => state.enabledTypes.has(type)); } + function allStatusesEnabled(state) { + const filter = state.statusFilter; + return Boolean(filter && filter.values && filter.values.length) + && filter.values.every((value) => filter.enabled && filter.enabled.has(value)); + } + + function allProjectionLevelsSelected(state) { + const selections = Object.values(state.projectionSelections || {}); + return selections.length > 0 && selections.every((value) => value === "__all__"); + } + + function graphViewIsActive(state) { + return Boolean( + state.activeViewPinned || + state.selectedId !== state.rootId || + state.activeId !== state.selectedId || + state.isolatedRootId || + state.searchQuery || + state.typeSearchType || + state.showSecondaryLinks || + state.graphPage > 0 || + !allEntityTypesEnabled(state) || + !allStatusesEnabled(state) || + !allProjectionLevelsSelected(state) + ); + } + + function refreshGraphActivityState(browser, state) { + const active = graphViewIsActive(state); + browser.classList.toggle("is-graph-active", active); + browser.setAttribute("data-relationship-active-view", active ? "true" : "false"); + } + + function refreshGraphFocusState(browser, state) { + const focused = Boolean(state.graphFocusActive); + browser.classList.toggle("is-graph-focused", focused); + browser.setAttribute("data-relationship-graph-focused", focused ? "true" : "false"); + browser.querySelectorAll("[data-relationship-focus-badge]").forEach((badge) => { + badge.hidden = !focused; + }); + } + function projectionSelectionsSnapshot(state) { return Object.assign({}, state.projectionSelections || {}); } @@ -2929,7 +3007,8 @@ def _relationship_graph_script() -> str: showSecondaryLinks: state.showSecondaryLinks, searchQuery: state.searchQuery, typeSearchType: state.typeSearchType, - isolatedRootId: state.isolatedRootId || "" + isolatedRootId: state.isolatedRootId || "", + activeViewPinned: Boolean(state.activeViewPinned) }; } @@ -2961,10 +3040,9 @@ def _relationship_graph_script() -> str: state.showSecondaryLinks = Boolean(snapshot.showSecondaryLinks); state.searchQuery = snapshot.searchQuery || ""; state.typeSearchType = snapshot.typeSearchType || ""; - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); - renderGraph(browser, state); - renderNodeSelect(browser, state); - renderDetail(browser, state); + state.activeViewPinned = Boolean(snapshot.activeViewPinned); + refreshGraphControlState(browser, state); + renderRelationshipState(browser, state); } function serializeStatusFilter(state) { @@ -3092,18 +3170,45 @@ def _relationship_graph_script() -> str: detail.innerHTML = html; } - function selectNode(browser, state, nodeId, recordHistory, options) { + function renderRelationshipState(browser, state) { + const startedAt = performance.now(); + refreshGraphActivityState(browser, state); + renderGraph(browser, state); + renderNodeSelect(browser, state); + renderDetail(browser, state); + const durationMs = performance.now() - startedAt; + state.renderVersion = Number(state.renderVersion || 0) + 1; + state.lastRenderDurationMs = durationMs; + if (!Array.isArray(state.renderSamples)) state.renderSamples = []; + state.renderSamples.push({ + version: state.renderVersion, + durationMs, + selectedId: state.selectedId, + nodes: state.visibleGraph && state.visibleGraph.nodes ? state.visibleGraph.nodes.length : 0, + edges: state.visibleGraph && state.visibleGraph.edges ? state.visibleGraph.edges.length : 0 + }); + if (state.renderSamples.length > 80) { + state.renderSamples.splice(0, state.renderSamples.length - 80); + } + } + + function updateSelectedNodeState(browser, state, nodeId, recordHistory, options) { if (!state.nodesById.has(nodeId)) return; if (state.isolatedNodeIds && !state.isolatedNodeIds.has(nodeId)) return; const resetTypes = Boolean(options && options.resetTypes); - const preserveTypes = options && Object.prototype.hasOwnProperty.call(options, "preserveTypes") + const preserveFilters = Boolean(options && options.preserveFilters); + const entityChanged = Boolean(state.selectedId && state.selectedId !== nodeId); + const resetFiltersToAll = entityChanged && !preserveFilters; + let preserveTypes = options && Object.prototype.hasOwnProperty.call(options, "preserveTypes") ? Boolean(options.preserveTypes) : (state.projectionMode || "auto") === "custom"; if (recordHistory && state.selectedId && state.selectedId !== nodeId) { state.backStack.push(navigationSnapshot(state)); state.forwardStack.length = 0; } - if (resetTypes) { + if (resetFiltersToAll) { + preserveTypes = true; + } else if (resetTypes) { enableAllEntityTypes(browser, state); } state.selectedId = nodeId; @@ -3116,9 +3221,16 @@ def _relationship_graph_script() -> str: } else { ensureFocusFilters(browser, state, nodeId); } - renderGraph(browser, state); - renderNodeSelect(browser, state); - renderDetail(browser, state); + if (resetFiltersToAll) { + resetGraphFiltersToAll(browser, state); + ensureFocusFilters(browser, state, nodeId); + } + return true; + } + + function selectNode(browser, state, nodeId, recordHistory, options) { + if (!updateSelectedNodeState(browser, state, nodeId, recordHistory, options)) return; + renderRelationshipState(browser, state); } function selectTableNode(browser, state, nodeId) { @@ -3126,8 +3238,8 @@ def _relationship_graph_script() -> str: if (state.selectedId !== nodeId || !allEntityTypesEnabled(state)) { pushNavigationSnapshot(state); } - resetGraphFilters(browser, state); - selectNode(browser, state, nodeId, false); + resetGraphFiltersToAll(browser, state); + selectNode(browser, state, nodeId, false, {preserveTypes: true, preserveFilters: true}); } function updateActiveGraphNode(state) { @@ -3139,6 +3251,8 @@ def _relationship_graph_script() -> str: function refreshGraphTheme(state) { if (!state.cy) return; + cachedCytoscapeStyle = null; + cachedCytoscapeStyleTheme = ""; state.cy.style(cytoscapeStyle()); state.cy.resize(); } @@ -3156,9 +3270,10 @@ def _relationship_graph_script() -> str: syncActiveSubfilterType(state); updateActiveGraphNode(state); updateActiveTableRow(browser, state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); renderNodeSelect(browser, state); renderDetail(browser, state); + refreshGraphActivityState(browser, state); } function graphTypes(nodes) { @@ -3557,7 +3672,7 @@ def _relationship_graph_script() -> str: const rerender = () => { state.graphPage = 0; ensureSelectableFocus(state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); renderGraph(browser, state); renderNodeSelect(browser, state); renderDetail(browser, state); @@ -3968,6 +4083,7 @@ def _relationship_graph_script() -> str: activeSubfilterType: "", selectedId: preferred.id, activeId: preferred.id, + rootId: preferred.id, cy: null, backStack: [], forwardStack: [], @@ -3976,6 +4092,7 @@ def _relationship_graph_script() -> str: searchUpdateTimer: 0, typeSearchType: "", graphInteractive: false, + graphFocusActive: false, graphRenderSignature: "", focusGraph: null, focusContent: null, @@ -3986,6 +4103,7 @@ def _relationship_graph_script() -> str: scopedAdjacencyCache: null, canvasViewportListenersAttached: false, showSecondaryLinks: false, + activeViewPinned: false, graphPage: 0, traversal: traversalConfig(graph.traversal), projectionMode: "auto", @@ -4031,7 +4149,7 @@ def _relationship_graph_script() -> str: state.typeSearchType = focusType.value || ""; state.graphPage = 0; renderNodeSelect(browser, state); - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, stateGraphTypes(state)); + refreshGraphControlState(browser, state); }); } const focusScope = browser.querySelector("[data-relationship-focus-scope]"); @@ -4076,24 +4194,32 @@ def _relationship_graph_script() -> str: const fit = browser.querySelector("[data-relationship-fit]"); if (fit) { fit.addEventListener("click", () => { - setGraphInteractive(browser, state, true); + activateGraphFocus(browser, state); fitGraph(state, 40); }); } const canvas = browser.querySelector("[data-relationship-canvas]"); if (canvas) { - const canvasWrap = canvas.closest(".relationship-canvas-wrap"); - const activateFromCanvasWrap = (event) => { - syncGraphViewport(state); - if (!state.graphInteractive) { - setGraphInteractive(browser, state, true); - } - }; - canvas.addEventListener("pointerdown", activateFromCanvasWrap, {capture: true}); - if (canvasWrap) { - canvasWrap.addEventListener("pointerdown", activateFromCanvasWrap, {capture: true}); - } + canvas.addEventListener("pointerenter", () => syncGraphViewport(state), {passive: true}); } + const routeGraphFocusEvent = (event) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest("[data-relationship-focus-badge]")) { + return; + } + if (target.closest(".relationship-canvas-wrap")) { + activateGraphFocus(browser, state); + } else { + releaseGraphFocus(browser, state); + } + }; + ["pointerdown", "mousedown", "touchstart", "click"].forEach((eventName) => { + browser.addEventListener(eventName, routeGraphFocusEvent, {capture: true}); + }); + browser.querySelectorAll("[data-relationship-focus-badge]").forEach((badge) => { + badge.addEventListener("click", () => releaseGraphFocus(browser, state)); + }); const detail = browser.querySelector("[data-relationship-detail]"); if (detail) { const releaseFromDetail = () => { @@ -4118,7 +4244,6 @@ def _relationship_graph_script() -> str: if (explorer) { const handleExplorerScroll = () => { syncGraphViewport(state); - releaseGraphFocus(browser, state); }; explorer.addEventListener("scroll", handleExplorerScroll, {passive: true}); explorer.addEventListener("wheel", () => syncGraphViewport(state), {passive: true}); @@ -4231,10 +4356,13 @@ def _relationship_graph_script() -> str: function applyRelationshipView(browser, state, view) { const focusId = view && view.focus; if (!focusId || !state.nodesById.has(focusId)) return; + const isolateRoot = view.isolate_root || ""; + const isolatedNodeIds = isolatedIdsForRoot(state, isolateRoot); + if (isolatedNodeIds && !isolatedNodeIds.has(focusId)) return; pushNavigationSnapshot(state); - setIsolatedRoot(state, view.isolate_root || ""); - if (state.isolatedNodeIds && !state.isolatedNodeIds.has(focusId)) return; + setIsolatedRoot(state, isolateRoot); resetGraphFilters(browser, state); + state.activeViewPinned = true; const types = stateGraphTypes(state); const requested = Array.isArray(view.types) ? view.types.filter((type) => types.includes(type)) : []; if (requested.length) { @@ -4249,17 +4377,15 @@ def _relationship_graph_script() -> str: cancelSearchUpdate(state); const search = browser.querySelector("[data-relationship-search]"); if (search) search.value = ""; - selectNode(browser, state, focusId, false, {preserveTypes: true}); + updateSelectedNodeState(browser, state, focusId, false, {preserveTypes: true, preserveFilters: true}); const targetType = view.target_type || ""; if (targetType && state.enabledTypes.has(targetType)) { state.activeSubfilterType = targetType; state.activeSubfilterTypes = [targetType]; state.typeSearchType = targetType; } - refreshTypeFilterState(browser.querySelector("[data-relationship-projection-controls]") || browser, state, types); - renderGraph(browser, state); - renderNodeSelect(browser, state); - renderDetail(browser, state); + refreshGraphControlState(browser, state, types); + renderRelationshipState(browser, state); } function openRelationshipView(trigger, view) { @@ -4288,6 +4414,7 @@ def _relationship_graph_script() -> str: pushNavigationSnapshot(state); setIsolatedRoot(state, ""); resetGraphFiltersToAll(browser, state); + state.activeViewPinned = false; ensureSelectableFocus(state); renderGraph(browser, state); renderNodeSelect(browser, state); @@ -4327,3 +4454,614 @@ def _relationship_graph_script() -> str: })(); """ + + +def _report_self_test_script() -> str: + return r""" + +""" diff --git a/agent_tools/tools/diff_report/tests/test_assets_contract.py b/agent_tools/tools/diff_report/tests/test_assets_contract.py index 26db8f90..b8aeda24 100644 --- a/agent_tools/tools/diff_report/tests/test_assets_contract.py +++ b/agent_tools/tools/diff_report/tests/test_assets_contract.py @@ -75,7 +75,7 @@ def test_stylesheet_exposes_layout_theme_and_search_contracts(self) -> None: ".report-table { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0; table-layout: auto; }", ".report-table thead { position: sticky; top: 0; z-index: 5; }", ".report-table th { position: sticky; top: 0; z-index: 5;", - '.relationship-canvas[data-graph-interactive="false"] { cursor: default; pointer-events: none; }', + '.relationship-canvas[data-graph-interactive="false"] { cursor: default; }', "background-clip: padding-box;", ".report-toc a.is-current", "border-left-color: var(--comment-border);", @@ -466,6 +466,16 @@ def test_story_script_exposes_target_and_navigation_contracts(self) -> None: "function fileHeaderStickyTop()", "function settleFileHeaderScroll(element, onDone)", "element.getBoundingClientRect().top - fileHeaderStickyTop()", + "let userScrollIntent = false;", + "function cancelProgrammaticScrollForUserIntent()", + "userScrollIntent = true;", + "window.clearTimeout(activeScrollTimer);", + "window.clearTimeout(activeScrollEndTimer);", + "function handleScrollKeyIntent(event)", + 'if (userScrollIntent || window.scrollY > 0)', + 'window.addEventListener("wheel", cancelProgrammaticScrollForUserIntent, { passive: true });', + 'window.addEventListener("touchmove", cancelProgrammaticScrollForUserIntent, { passive: true });', + 'window.addEventListener("keydown", handleScrollKeyIntent, { capture: true });', "const isFileJump = element && element.classList && element.classList.contains(\"file-header\");", "const isCommentJump = element && element.classList && element.classList.contains(\"review-comment\");", "codex-review-file-jump-start", diff --git a/agent_tools/tools/diff_report/tests/test_report_json.py b/agent_tools/tools/diff_report/tests/test_report_json.py index cd467884..ec59ff29 100644 --- a/agent_tools/tools/diff_report/tests/test_report_json.py +++ b/agent_tools/tools/diff_report/tests/test_report_json.py @@ -332,7 +332,7 @@ def test_renders_dashboard_widgets_and_shared_artifacts(self) -> None: "state.enabledTypes = new Set(stateGraphTypes(state));", "graphInteractive: false", 'canvas.addEventListener("pointerdown"', - 'canvasWrap.addEventListener("pointerdown"', + 'browser.addEventListener(eventName, routeGraphFocusEvent, {capture: true});', "function releaseGraphFocus(browser, state)", 'detail.addEventListener("pointerdown", releaseFromDetail, {capture: true, passive: true});', 'detail.addEventListener("scroll", releaseFromDetail, {passive: true});', @@ -404,7 +404,8 @@ def test_renders_dashboard_widgets_and_shared_artifacts(self) -> None: "hasSearch ? 0 : 50", "const visibleGraph = state.visibleGraph || {edges: []};", "const traversedEdgeKeys = new Set();", - "if (!traversedEdgeKeys.has(edgeKey(edge))) return false;", + "const traversedEdgesByKey = new Map();", + "const visibleEdges = Array.from(traversedEdgesByKey.values()).filter((edge) => {", "data-relationship-jump", "data-relationship-jump-visible", "focus to show", @@ -575,7 +576,7 @@ def test_renders_metric_tables_with_graph_views(self) -> None: "Custom scopes", "function applyRelationshipViewFilters(state, filters)", "function applyRelationshipView(browser, state, view)", - "selectNode(browser, state, focusId, false, {preserveTypes: true});", + "updateSelectedNodeState(browser, state, focusId, false, {preserveTypes: true, preserveFilters: true});", "function openRelationshipView(trigger, view)", 'document.querySelectorAll("[data-relationship-open-view]")', "state.typeSearchType = targetType;", From c4da9582176576169dea819f556fc2977a4e0364 Mon Sep 17 00:00:00 2001 From: svlad-90 Date: Fri, 28 Aug 2026 15:23:16 +0300 Subject: [PATCH 2/6] paf_workspace: stop inferring product manifests Do not require product-artifacts.yaml only because task context text mentions runtime systems. Analysis tasks can discuss QEMU, Xen, or Moulin without owning deployable product artifacts. Keep the manifest check active for explicit runtime-product mode, init-runtime-product, existing task-owned manifests, and harness profiles. Signed-off-by: svlad-90 Assisted-by: Codex:gpt-5 --- agent_tools/paf_workspace/task_check/__init__.py | 3 +-- .../paf_workspace/task_check/warning-policy.yaml | 8 ++++---- agent_tools/paf_workspace/tests/test_task_check.py | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/agent_tools/paf_workspace/task_check/__init__.py b/agent_tools/paf_workspace/task_check/__init__.py index dffa0ab9..fbf4e69a 100644 --- a/agent_tools/paf_workspace/task_check/__init__.py +++ b/agent_tools/paf_workspace/task_check/__init__.py @@ -210,8 +210,7 @@ def check_task( manifests = _find_artifact_manifests(task_dir) harness_profiles = _find_xen_zephyr_harness_profiles(task_dir) - has_runtime_hints = _has_runtime_hints(context_text) - should_check_manifest = require_runtime_product or has_runtime_hints or bool(manifests) or bool(harness_profiles) + should_check_manifest = require_runtime_product or bool(manifests) or bool(harness_profiles) should_check_scenarios = require_xen_runtime or bool(harness_profiles) or _has_xen_hints(context_text) if should_check_manifest: diff --git a/agent_tools/paf_workspace/task_check/warning-policy.yaml b/agent_tools/paf_workspace/task_check/warning-policy.yaml index 65ee6b15..43f61479 100644 --- a/agent_tools/paf_workspace/task_check/warning-policy.yaml +++ b/agent_tools/paf_workspace/task_check/warning-policy.yaml @@ -6,10 +6,10 @@ non_strict_warnings: artifact-manifest-missing: reason: > - Runtime/product artifact manifests are required for build/runtime product - validation. When this check is enabled only because task context mentions - Xen/QEMU/Moulin, the missing manifest is a readiness reminder rather than - a blocker for analysis-only work. + Runtime/product artifact manifests are required only for explicit + build/runtime product validation. Missing manifests are not inferred from + task context wording because analysis-only tasks can mention runtime + systems without owning deployable product artifacts. strict_when_flags: - runtime_product - init_runtime_product diff --git a/agent_tools/paf_workspace/tests/test_task_check.py b/agent_tools/paf_workspace/tests/test_task_check.py index 0376e510..028980cd 100644 --- a/agent_tools/paf_workspace/tests/test_task_check.py +++ b/agent_tools/paf_workspace/tests/test_task_check.py @@ -141,6 +141,20 @@ def test_strict_warnings_ignores_auto_runtime_readiness_warnings(tmp_path: Path) assert result == 0 +def test_runtime_hints_do_not_require_product_artifact_manifest(tmp_path: Path) -> None: + task_dir = tmp_path / "tasks" / "sample-task" + initialize_task_layout(task_dir, workspace=tmp_path) + set_slot(task_dir, "goal", "Inspect QEMU runtime logs.") + set_slot(task_dir, "operational-memory", "Current: analysis-only task.") + set_slot(task_dir, "env", "Use local files.") + set_slot(task_dir, "validation", "Run smoke.") + + checks = check_task(task_dir, workspace=tmp_path) + + assert _has_check(checks, "PASS", "artifact-manifest-not-required") + assert not _has_check(checks, "WARN", "artifact-manifest-missing") + + def test_strict_warnings_fails_for_missing_required_slot(tmp_path: Path) -> None: task_dir = tmp_path / "tasks" / "sample-task" initialize_task_layout(task_dir, workspace=tmp_path) From 1596f9c253ff109c632fb72ea1d58aaaac88aa9c Mon Sep 17 00:00:00 2001 From: svlad-90 Date: Fri, 28 Aug 2026 15:24:02 +0300 Subject: [PATCH 3/6] agent_workspace: add workspace manifests and release updates Add a workspace manifest layer so task data can live outside the Agent Workspace install tree. Persist the last and recent workspaces in user settings, resolve startup through that history, and let GTK open or create another workspace without restarting. Move update checks away from git branch state. The settings updater now checks the latest GitHub release, downloads the release tarball, copies it over the install root, and runs the installer so Python dependencies are refreshed after the update. Signed-off-by: svlad-90 Assisted-by: Codex:gpt-5 --- agent_tools/VERSION | 1 + .../components/gtk_desktop/src/gtk_ui.py | 156 +++++++++++- .../gtk_desktop/tests/test_gtk_desktop.py | 6 +- .../src/gtk_translation_catalog.json | 36 ++- .../components/settings/api/__init__.py | 12 + .../components/settings/src/settings.py | 229 +++++++++++++++++- .../settings/tests/test_settings.py | 138 ++++++++++- .../components/test_support/src/helpers.py | 8 + .../components/tk_frontend/src/ui.py | 7 +- .../components/workspace_config/__init__.py | 1 + .../workspace_config/api/__init__.py | 27 +++ .../workspace_config/src/__init__.py | 1 + .../workspace_config/src/workspace.py | 143 +++++++++++ .../workspace_config/tests/__init__.py | 1 + .../tests/test_workspace_config.py | 71 ++++++ 15 files changed, 800 insertions(+), 37 deletions(-) create mode 100644 agent_tools/VERSION create mode 100644 agent_tools/agent_workspace/components/workspace_config/__init__.py create mode 100644 agent_tools/agent_workspace/components/workspace_config/api/__init__.py create mode 100644 agent_tools/agent_workspace/components/workspace_config/src/__init__.py create mode 100644 agent_tools/agent_workspace/components/workspace_config/src/workspace.py create mode 100644 agent_tools/agent_workspace/components/workspace_config/tests/__init__.py create mode 100644 agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py diff --git a/agent_tools/VERSION b/agent_tools/VERSION new file mode 100644 index 00000000..227cea21 --- /dev/null +++ b/agent_tools/VERSION @@ -0,0 +1 @@ +2.0.0 diff --git a/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py b/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py index b95a86dc..b2568b6c 100644 --- a/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py +++ b/agent_tools/agent_workspace/components/gtk_desktop/src/gtk_ui.py @@ -151,8 +151,12 @@ from ...task_sessions.api import reconcile_task_agent_run_session from ...task_sessions.api import resolve_task_agent_sessions from ...task_sessions.api import reset_task_agent_session +from ...settings.api import run_agent_workspace_update_check from ...settings.api import run_agent_workspace_update from ...settings.api import save_agent_workspace_settings +from ...settings.api import remember_agent_workspace +from ...workspace_config.api import ensure_agent_workspace +from ...workspace_config.api import resolve_agent_workspace_startup from ...task_sessions.api import save_task_active_agent_run from ...task_sessions.api import save_task_agent from ...task_sessions.api import save_task_agent_session @@ -344,6 +348,11 @@ def __init__(self, workspace: Path) -> None: self.main_split_ratio = settings.main_split_ratio self.details_split_ratio = settings.details_split_ratio self.actions_split_ratio = settings.actions_split_ratio + raw_settings = load_agent_workspace_settings() + recent_workspaces = raw_settings.get("recent_workspaces") + self.recent_workspaces = [ + item for item in recent_workspaces if isinstance(item, str) + ] if isinstance(recent_workspaces, list) else [str(self.workspace)] self._updating_pane_positions = False self._pane_layout_ready = False self._initial_pane_layout_source_id: int | None = None @@ -431,6 +440,8 @@ def _build_ui(self) -> None: root.pack_start(toolbar, False, False, 0) self._profile_widget("toolbar", toolbar) toolbar.pack_start(self._button("settings", self.open_settings), False, False, 0) + toolbar.pack_start(self._button("open_workspace_dialog", self.open_workspace_dialog), False, False, 0) + toolbar.pack_start(self._button("create_workspace_dialog", self.create_workspace_dialog), False, False, 0) self.summary_label = Gtk.Label(label="") self.summary_label.set_xalign(0) toolbar.pack_start(self.summary_label, False, False, 6) @@ -1713,6 +1724,68 @@ def open_task_dev(self, *_args: object) -> None: if self.selected_task is not None: open_path(self.selected_task.path / "dev") + def open_workspace_dialog(self, *_args: object) -> None: + workspace = self._choose_workspace_folder("open_workspace_dialog") + if workspace is not None: + self._switch_workspace(workspace) + + def create_workspace_dialog(self, *_args: object) -> None: + workspace = self._choose_workspace_folder("create_workspace_dialog") + if workspace is not None: + self._switch_workspace(workspace) + + def _choose_workspace_folder(self, title_key: str) -> Path | None: + dialog = Gtk.FileChooserDialog( + title=self._tr(title_key), + parent=self.window, + action=Gtk.FileChooserAction.SELECT_FOLDER, + ) + dialog.add_button(self._tr("cancel"), Gtk.ResponseType.CANCEL) + dialog.add_button(self._tr("ok"), Gtk.ResponseType.OK) + dialog.set_current_folder(str(self.workspace)) + response = dialog.run() + filename = dialog.get_filename() if response == Gtk.ResponseType.OK else None + dialog.destroy() + return Path(filename).resolve() if filename else None + + def _switch_workspace(self, workspace: Path) -> bool: + new_workspace = workspace.resolve() + if new_workspace == self.workspace: + return True + if not self._confirm_close_with_running_agents(): + return False + self._close_all_terminal_sessions() + if self.task_actions_monitor is not None: + self.task_actions_monitor.cancel() + self.task_actions_monitor = None + self.task_actions_monitor_path = None + workspace_ipc_server = getattr(self, "workspace_ipc_server", None) + if workspace_ipc_server is not None: + workspace_ipc_server.close() + self.workspace_ipc_server = None + try: + ensure_agent_workspace(new_workspace) + except (OSError, ValueError) as exc: + self._show_error(self._tr("workspace_switch_failed"), str(exc)) + self.workspace_ipc_server = start_workspace_ipc_server(self.workspace, self._on_workspace_ipc_event) + return False + updated_settings = remember_agent_workspace(new_workspace) + recent_workspaces = updated_settings.get("recent_workspaces") + self.recent_workspaces = [ + item for item in recent_workspaces if isinstance(item, str) + ] if isinstance(recent_workspaces, list) else [str(new_workspace)] + self.workspace = new_workspace + self.workspace_ipc_server = start_workspace_ipc_server(self.workspace, self._on_workspace_ipc_event) + self.task_agent_session_marker_cache.clear() + self.harness_debug_snapshot_signature = None + self.harness_debug_latest_by_task = {} + self.last_active_terminal_by_task.clear() + self.last_active_console_page_by_task.clear() + self._clear_selected_task_view() + self._apply_labels() + self.refresh_tasks() + return True + def add_task(self, *_args: object) -> None: request = self._prompt_task_name() if request is None: @@ -2310,10 +2383,6 @@ def open_settings(self, *_args: object) -> None: profiling_box.pack_start(profiling_controls, False, False, 0) profiling_box.pack_start(profiling_note, False, False, 0) profiling_box.pack_start(profiling_output_scrolled, True, True, 0) - notebook.append_page(general_scrolled, Gtk.Label(label=self._tr("settings_dictionary_general"))) - notebook.append_page(dictionary_scrolled, Gtk.Label(label=self._tr("settings_dictionary_dictionary"))) - notebook.append_page(profiling_box, Gtk.Label(label=self._tr("settings_profiling"))) - text_size = Gtk.SpinButton.new_with_range(8, 28, 1) text_size.set_value(self.text_font_size) button_size = Gtk.SpinButton.new_with_range(8, 28, 1) @@ -2333,15 +2402,74 @@ def open_settings(self, *_args: object) -> None: system_prompt_scrolled.set_hexpand(True) system_prompt_scrolled.set_vexpand(False) system_prompt_scrolled.set_min_content_height(96) - settings_update_button = Gtk.Button(label=self._tr("settings_check_updates")) + settings_update_check_button = Gtk.Button(label=self._tr("settings_check_updates")) + settings_update_button = Gtk.Button(label=self._tr("settings_apply_update")) + settings_update_button.set_no_show_all(True) + settings_update_button.set_visible(False) settings_update_status = Gtk.Label(label=self._tr("settings_update_idle")) settings_update_status.set_xalign(0) settings_update_status.set_line_wrap(True) + settings_update_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) settings_update_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + settings_update_box.set_border_width(12) settings_update_box.set_hexpand(True) - settings_update_box.pack_start(settings_update_button, False, False, 0) + settings_update_box.set_vexpand(True) + settings_update_controls.pack_start(settings_update_check_button, False, False, 0) + settings_update_controls.pack_start(settings_update_button, False, False, 0) + settings_update_box.pack_start(settings_update_controls, False, False, 0) settings_update_box.pack_start(settings_update_status, False, False, 0) settings_open = {"value": True} + notebook.append_page(general_scrolled, Gtk.Label(label=self._tr("settings_dictionary_general"))) + notebook.append_page(dictionary_scrolled, Gtk.Label(label=self._tr("settings_dictionary_dictionary"))) + notebook.append_page(settings_update_box, Gtk.Label(label=self._tr("settings_updates"))) + notebook.append_page(profiling_box, Gtk.Label(label=self._tr("settings_profiling"))) + + def run_settings_update_check(*_ignored: object) -> None: + if self.settings_update_running: + return + self.settings_update_running = True + settings_update_check_button.set_sensitive(False) + settings_update_button.set_sensitive(False) + settings_update_button.set_visible(False) + settings_update_status.set_text(self._tr("settings_update_check_running")) + + def worker() -> None: + try: + result = run_agent_workspace_update_check() + if result.update_available: + status_text = self._tr("settings_update_available").format( + current=result.current_version, + latest=result.latest_version, + ) + elif result.ok: + status_text = self._tr("settings_update_none") + else: + status_text = ( + self._tr("settings_update_check_failed").format(code=result.returncode) + + "\n" + + _tail_text(result.output, 2_000) + ) + update_available = result.update_available + except Exception as error: + status_text = ( + self._tr("settings_update_check_failed").format(code=1) + + f"\n{type(error).__name__}: {error}" + ) + update_available = False + + def apply_result() -> bool: + self.settings_update_running = False + if not settings_open["value"]: + return False + settings_update_check_button.set_sensitive(True) + settings_update_button.set_sensitive(update_available) + settings_update_button.set_visible(update_available) + settings_update_status.set_text(status_text) + return False + + GLib.idle_add(apply_result) + + threading.Thread(target=worker, daemon=True).start() def run_settings_update(*_ignored: object) -> None: if self.settings_update_running: @@ -2355,19 +2483,20 @@ def run_settings_update(*_ignored: object) -> None: ) confirm.format_secondary_text(self._tr("settings_update_confirm_body")) confirm.add_button(self._tr("cancel"), Gtk.ResponseType.CANCEL) - confirm.add_button(self._tr("settings_check_updates"), Gtk.ResponseType.OK) + confirm.add_button(self._tr("settings_apply_update"), Gtk.ResponseType.OK) response = confirm.run() confirm.destroy() if response != Gtk.ResponseType.OK: return self.settings_update_running = True + settings_update_check_button.set_sensitive(False) settings_update_button.set_sensitive(False) settings_update_status.set_text(self._tr("settings_update_running")) def worker() -> None: update_ok = False try: - result = run_agent_workspace_update(self.workspace) + result = run_agent_workspace_update() update_ok = result.ok status_text = ( self._tr("settings_update_done") @@ -2383,6 +2512,7 @@ def apply_result() -> bool: self.settings_update_running = False if not settings_open["value"]: return False + settings_update_check_button.set_sensitive(True) settings_update_button.set_sensitive(True) settings_update_status.set_text(status_text) if update_ok: @@ -2395,6 +2525,7 @@ def apply_result() -> bool: threading.Thread(target=worker, daemon=True).start() + settings_update_check_button.connect("clicked", run_settings_update_check) settings_update_button.connect("clicked", run_settings_update) claude_model_combo = Gtk.ComboBoxText() claude_effort_combo = Gtk.ComboBoxText() @@ -2519,7 +2650,6 @@ def update_dictionary_preview(*_ignored: object) -> None: (self._tr("language"), language_combo), (self._tr("default_agent"), default_agent_combo), (self._tr("system_prompt"), system_prompt_scrolled), - (self._tr("settings_updates"), settings_update_box), (agent_label("codex"), None), ] if codex_models is not None: @@ -5828,6 +5958,8 @@ def _save_settings(self) -> None: "main_split_ratio": self.main_split_ratio, "details_split_ratio": self.details_split_ratio, "actions_split_ratio": self.actions_split_ratio, + "last_workspace": str(self.workspace), + "recent_workspaces": self.recent_workspaces, } ) @@ -6219,11 +6351,11 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Open the local workspace task dashboard.") parser.add_argument( "--workspace", - default=".", - help="Workspace root. Default: current directory.", + default=None, + help="Workspace root. Default: last opened workspace or current directory.", ) args = parser.parse_args(argv) - workspace = Path(args.workspace) + workspace = resolve_agent_workspace_startup(Path(args.workspace) if args.workspace else None) install_agent_workspace_exception_logger(workspace, "gtk") workspace_lock = acquire_agent_workspace_lock(workspace) if workspace_lock is None: diff --git a/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py b/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py index 2e09ac2a..6d3ab8e8 100644 --- a/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py +++ b/agent_tools/agent_workspace/components/gtk_desktop/tests/test_gtk_desktop.py @@ -2756,7 +2756,8 @@ def test_gtk_translates_agent_and_manual_labels() -> None: assert GTK_TRANSLATIONS["ru"]["default_codex_model"] == "Модель Codex" assert GTK_TRANSLATIONS["ru"]["system_prompt"] == "Системный промпт" assert GTK_TRANSLATIONS["ru"]["settings_updates"] == "Обновления" - assert GTK_TRANSLATIONS["ru"]["settings_check_updates"] == "Проверить обновления" + assert GTK_TRANSLATIONS["ru"]["settings_check_updates"] == "Проверить доступные обновления" + assert GTK_TRANSLATIONS["ru"]["settings_apply_update"] == "Обновить" assert GTK_TRANSLATIONS["ru"]["settings_update_running"] == "Обновление Agent Workspace..." assert GTK_TRANSLATIONS["ru"]["settings_update_confirm_title"] == "Обновить Agent Workspace?" assert "закроется" in GTK_TRANSLATIONS["ru"]["settings_update_confirm_body"] @@ -2777,6 +2778,9 @@ def test_gtk_translates_agent_and_manual_labels() -> None: assert "Предлагаемая команда установки" in GTK_TRANSLATIONS["ru"]["install_agent_body"] assert GTK_TRANSLATIONS["ru"]["delete_artifacts"] == "Удалить артефакты" assert GTK_TRANSLATIONS["ru"]["open_containing_folder"] == "Открыть содержащую папку" + assert GTK_TRANSLATIONS["ru"]["open_workspace_dialog"] == "Открыть workspace" + assert GTK_TRANSLATIONS["ru"]["create_workspace_dialog"] == "Создать workspace" + assert GTK_TRANSLATIONS["ru"]["workspace_switch_failed"] == "Не удалось переключить workspace" assert GTK_TRANSLATIONS["ru"]["other_artifacts"] == "Другие артефакты" assert GTK_TRANSLATIONS["ru"]["updated"] == "Обновлено" assert GTK_TRANSLATIONS["uk"]["manual_usage_section"] == "Основи" diff --git a/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json b/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json index 728d7d45..885fbd1d 100644 --- a/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json +++ b/agent_tools/agent_workspace/components/localization/src/gtk_translation_catalog.json @@ -57,6 +57,7 @@ "cancel": "Cancel", "close": "Close", "copy": "Copy", + "create_workspace_dialog": "Create workspace", "confirm_close_console_body": "The console session will be closed.", "confirm_close_console_title": "Close console?", "confirm_close_running_agents_body": "There are running AI agent terminals.\n\n{sessions}\n\nClosing Agent Workspace will stop the local agent processes. Resumable conversations can be restored on the next launch. Continue?", @@ -94,7 +95,12 @@ "default_agent": "Default AI agent", "system_prompt": "System prompt", "settings_updates": "Updates", - "settings_check_updates": "Check updates", + "settings_check_updates": "Check available updates", + "settings_apply_update": "Update", + "settings_update_available": "Update available: {latest} (current: {current}).", + "settings_update_check_failed": "Update check failed with exit code {code}.", + "settings_update_check_running": "Checking for updates...", + "settings_update_none": "No updates available.", "settings_update_idle": "Ready to check for updates.", "settings_update_running": "Updating Agent Workspace...", "settings_update_done": "Agent Workspace is up to date. The app will close now.", @@ -159,6 +165,7 @@ "open_dev": "Open dev folder", "open_task": "Open task folder", "open_workspace": "Open Workspace", + "open_workspace_dialog": "Open workspace", "other_artifacts": "Other artifacts", "paste": "Paste", "refresh": "Refresh", @@ -214,7 +221,8 @@ "text_font_size": "Text font size", "theme": "Theme", "updated": "Updated", - "window_title": "Agent Workspace" + "window_title": "Agent Workspace", + "workspace_switch_failed": "Could not switch workspace" }, "ru": { "actions": "Действия", @@ -274,6 +282,7 @@ "cancel": "Отмена", "close": "Закрыть", "copy": "Копировать", + "create_workspace_dialog": "Создать workspace", "confirm_close_console_body": "Консольная сессия будет закрыта.", "confirm_close_console_title": "Закрыть консоль?", "confirm_close_running_agents_body": "Есть запущенные терминалы ИИ агентов.\n\n{sessions}\n\nЗакрытие Agent Workspace остановит локальные процессы агентов. Восстанавливаемые диалоги можно будет открыть при следующем запуске. Продолжить?", @@ -311,7 +320,12 @@ "default_agent": "ИИ агент по умолчанию", "system_prompt": "Системный промпт", "settings_updates": "Обновления", - "settings_check_updates": "Проверить обновления", + "settings_check_updates": "Проверить доступные обновления", + "settings_apply_update": "Обновить", + "settings_update_available": "Доступно обновление: {latest} (текущая: {current}).", + "settings_update_check_failed": "Проверка обновлений завершилась с кодом {code}.", + "settings_update_check_running": "Проверка обновлений...", + "settings_update_none": "Доступных обновлений нет.", "settings_update_idle": "Готово к проверке обновлений.", "settings_update_running": "Обновление Agent Workspace...", "settings_update_done": "Agent Workspace обновлён. Приложение сейчас закроется.", @@ -376,6 +390,7 @@ "open_dev": "Открыть dev", "open_task": "Открыть папку задачи", "open_workspace": "Открыть workspace", + "open_workspace_dialog": "Открыть workspace", "other_artifacts": "Другие артефакты", "paste": "Вставить", "refresh": "Обновить", @@ -431,7 +446,8 @@ "text_font_size": "Размер шрифта текста", "theme": "Тема", "updated": "Обновлено", - "window_title": "Agent Workspace" + "window_title": "Agent Workspace", + "workspace_switch_failed": "Не удалось переключить workspace" }, "uk": { "actions": "Дії", @@ -491,6 +507,7 @@ "cancel": "Скасувати", "close": "Закрити", "copy": "Копіювати", + "create_workspace_dialog": "Створити workspace", "confirm_close_console_body": "Консольну сесію буде закрито.", "confirm_close_console_title": "Закрити консоль?", "confirm_close_running_agents_body": "Є запущені термінали ШІ агентів.\n\n{sessions}\n\nЗакриття Agent Workspace зупинить локальні процеси агентів. Відновлювані діалоги можна буде відкрити під час наступного запуску. Продовжити?", @@ -528,7 +545,12 @@ "default_agent": "Типовий ШІ агент", "system_prompt": "Системний промпт", "settings_updates": "Оновлення", - "settings_check_updates": "Перевірити оновлення", + "settings_check_updates": "Перевірити доступні оновлення", + "settings_apply_update": "Оновити", + "settings_update_available": "Доступне оновлення: {latest} (поточна: {current}).", + "settings_update_check_failed": "Перевірка оновлень завершилася з кодом {code}.", + "settings_update_check_running": "Перевірка оновлень...", + "settings_update_none": "Доступних оновлень немає.", "settings_update_idle": "Готово до перевірки оновлень.", "settings_update_running": "Оновлення Agent Workspace...", "settings_update_done": "Agent Workspace оновлено. Застосунок зараз закриється.", @@ -593,6 +615,7 @@ "open_dev": "Відкрити dev", "open_task": "Відкрити папку задачі", "open_workspace": "Відкрити workspace", + "open_workspace_dialog": "Відкрити workspace", "other_artifacts": "Інші артефакти", "paste": "Вставити", "refresh": "Оновити", @@ -648,6 +671,7 @@ "text_font_size": "Розмір шрифту тексту", "theme": "Тема", "updated": "Оновлено", - "window_title": "Agent Workspace" + "window_title": "Agent Workspace", + "workspace_switch_failed": "Не вдалося перемкнути workspace" } } diff --git a/agent_tools/agent_workspace/components/settings/api/__init__.py b/agent_tools/agent_workspace/components/settings/api/__init__.py index 0786d550..47306e50 100644 --- a/agent_tools/agent_workspace/components/settings/api/__init__.py +++ b/agent_tools/agent_workspace/components/settings/api/__init__.py @@ -17,18 +17,22 @@ from ..src.settings import AGENT_WORKSPACE_DEFAULT_LIMITED_BASH_OUTPUT_TOKENS from ..src.settings import AGENT_WORKSPACE_GEOMETRY_RE from ..src.settings import AGENT_WORKSPACE_LANGUAGES +from ..src.settings import AGENT_WORKSPACE_RELEASES_API from ..src.settings import AGENT_WORKSPACE_REASONING_EFFORTS from ..src.settings import AGENT_WORKSPACE_SETTINGS_FILE from ..src.settings import AGENT_WORKSPACE_THEMES from ..src.settings import TASK_CONTEXT_PROMPT_INJECTION_DEFAULT from ..src.settings import AgentModelChoices from ..src.settings import AgentModelSettings +from ..src.settings import AgentWorkspaceSettingValue +from ..src.settings import AgentWorkspaceUpdateCheckResult from ..src.settings import AgentWorkspaceUpdateResult from ..src.settings import AgentWorkspaceRuntimeSettings from ..src.settings import agent_command_name from ..src.settings import agent_executable from ..src.settings import agent_install_command from ..src.settings import agent_label +from ..src.settings import agent_workspace_install_root from ..src.settings import agent_workspace_root from ..src.settings import agent_workspace_runtime_settings from ..src.settings import agent_workspace_setting_or_default @@ -42,8 +46,10 @@ from ..src.settings import load_agent_workspace_settings from ..src.settings import model_choices_with_current from ..src.settings import normalize_agent +from ..src.settings import run_agent_workspace_update_check from ..src.settings import run_agent_workspace_update from ..src.settings import save_agent_workspace_settings +from ..src.settings import remember_agent_workspace from ..src.settings import task_dictionary_policy_from_runtime_settings __all__ = [ @@ -64,18 +70,22 @@ "AGENT_WORKSPACE_DEFAULT_LIMITED_BASH_OUTPUT_TOKENS", "AGENT_WORKSPACE_GEOMETRY_RE", "AGENT_WORKSPACE_LANGUAGES", + "AGENT_WORKSPACE_RELEASES_API", "AGENT_WORKSPACE_REASONING_EFFORTS", "AGENT_WORKSPACE_SETTINGS_FILE", "AGENT_WORKSPACE_THEMES", "TASK_CONTEXT_PROMPT_INJECTION_DEFAULT", "AgentModelChoices", "AgentModelSettings", + "AgentWorkspaceSettingValue", + "AgentWorkspaceUpdateCheckResult", "AgentWorkspaceRuntimeSettings", "AgentWorkspaceUpdateResult", "agent_command_name", "agent_executable", "agent_install_command", "agent_label", + "agent_workspace_install_root", "agent_workspace_root", "agent_workspace_runtime_settings", "agent_workspace_setting_or_default", @@ -89,7 +99,9 @@ "load_agent_workspace_settings", "model_choices_with_current", "normalize_agent", + "run_agent_workspace_update_check", "run_agent_workspace_update", "save_agent_workspace_settings", + "remember_agent_workspace", "task_dictionary_policy_from_runtime_settings", ] diff --git a/agent_tools/agent_workspace/components/settings/src/settings.py b/agent_tools/agent_workspace/components/settings/src/settings.py index 0f02abbf..7d21eeac 100644 --- a/agent_tools/agent_workspace/components/settings/src/settings.py +++ b/agent_tools/agent_workspace/components/settings/src/settings.py @@ -8,6 +8,9 @@ import shutil import subprocess import sys +import tarfile +import tempfile +import urllib.request from agent_tools.tools.task_context import DICTIONARY_AUTO_DISCOVERY_DEFAULT from agent_tools.tools.task_context import DICTIONARY_MAX_TERM_WORDS @@ -59,6 +62,8 @@ } AGENT_WORKSPACE_GEOMETRY_RE = re.compile(r"^\d+x\d+(?:[+-]\d+[+-]\d+)?$") TASK_CONTEXT_PROMPT_INJECTION_DEFAULT = True +AgentWorkspaceSettingValue = int | float | str | bool | list[str] +AGENT_WORKSPACE_RELEASES_API = "https://api.github.com/repos/svlad-90/agent_tools/releases/latest" @dataclass(frozen=True) @@ -113,6 +118,25 @@ def ok(self) -> bool: return self.returncode == 0 +@dataclass(frozen=True) +class AgentWorkspaceUpdateCheckResult: + commands: tuple[tuple[str, ...], ...] + returncode: int + output: str + current_version: str = "" + latest_version: str = "" + release_url: str = "" + tarball_url: str = "" + + @property + def ok(self) -> bool: + return self.returncode == 0 + + @property + def update_available(self) -> bool: + return self.ok and _version_key(self.latest_version) > _version_key(self.current_version) + + def agent_workspace_settings_path() -> Path: config_root = os.environ.get("XDG_CONFIG_HOME") if config_root: @@ -128,16 +152,18 @@ def agent_workspace_root(start: Path | None = None) -> Path: raise RuntimeError(f"cannot resolve Agent Workspace root from {path}") +def agent_workspace_install_root(start: Path | None = None) -> Path: + return agent_workspace_root(start) + + def agent_workspace_update_commands( workspace: Path | None = None, *, python_executable: str | None = None, ) -> tuple[tuple[str, ...], ...]: - root = agent_workspace_root(workspace) if workspace is not None else agent_workspace_root() + root = agent_workspace_install_root(workspace) if workspace is not None else agent_workspace_install_root() python = python_executable or sys.executable - git = shutil.which("git") or "git" return ( - (git, "-C", str(root), "pull", "--ff-only"), ( python, str(root / "install-agent-tools.py"), @@ -148,15 +174,68 @@ def agent_workspace_update_commands( ) +def run_agent_workspace_update_check( + install_root: Path | None = None, + *, + timeout: float | None = None, +) -> AgentWorkspaceUpdateCheckResult: + root = agent_workspace_install_root(install_root) if install_root is not None else agent_workspace_install_root() + current_version = _agent_workspace_current_version(root) + commands = (("GET", AGENT_WORKSPACE_RELEASES_API),) + output_parts: list[str] = [] + try: + payload = _read_release_json(timeout=timeout) + except TimeoutError: + output_parts.append(f"Timed out after {timeout} seconds.") + return AgentWorkspaceUpdateCheckResult(commands, 124, "\n".join(output_parts).rstrip() + "\n", current_version) + except (OSError, ValueError, json.JSONDecodeError) as error: + output_parts.append(f"{type(error).__name__}: {error}") + return AgentWorkspaceUpdateCheckResult(commands, 1, "\n".join(output_parts).rstrip() + "\n", current_version) + latest_version = _release_version(payload.get("tag_name")) + release_url = _string_value(payload.get("html_url")) + tarball_url = _string_value(payload.get("tarball_url")) + if not latest_version or not tarball_url: + output_parts.append("Latest GitHub release does not contain tag_name and tarball_url.") + return AgentWorkspaceUpdateCheckResult(commands, 1, "\n".join(output_parts).rstrip() + "\n", current_version) + output_parts.append(f"Current version: {current_version}") + output_parts.append(f"Latest release: {latest_version}") + return AgentWorkspaceUpdateCheckResult( + commands, + 0, + "\n".join(output_parts).rstrip() + "\n", + current_version, + latest_version, + release_url, + tarball_url, + ) + + def run_agent_workspace_update( - workspace: Path | None = None, + install_root: Path | None = None, *, python_executable: str | None = None, timeout: float | None = None, ) -> AgentWorkspaceUpdateResult: - root = agent_workspace_root(workspace) if workspace is not None else agent_workspace_root() + root = agent_workspace_install_root(install_root) if install_root is not None else agent_workspace_install_root() + check = run_agent_workspace_update_check(root, timeout=timeout) + if not check.ok: + return AgentWorkspaceUpdateResult(check.commands, check.returncode, check.output) + if not check.update_available: + return AgentWorkspaceUpdateResult(check.commands, 0, check.output + "No release update available.\n") commands = agent_workspace_update_commands(root, python_executable=python_executable) output_parts: list[str] = [] + output_parts.append(check.output.rstrip()) + try: + with tempfile.TemporaryDirectory(prefix="agent-workspace-update-") as temp_dir: + release_source = _download_and_extract_release(check.tarball_url, Path(temp_dir), timeout=timeout) + _copy_release_tree(release_source, root) + except (OSError, tarfile.TarError, ValueError) as error: + output_parts.append(f"{type(error).__name__}: {error}") + return AgentWorkspaceUpdateResult( + (("download", check.tarball_url), *commands), + 1, + "\n".join(output_parts).rstrip() + "\n", + ) env = os.environ.copy() current_pythonpath = env.get("PYTHONPATH") env["PYTHONPATH"] = str(root) if not current_pythonpath else f"{root}{os.pathsep}{current_pythonpath}" @@ -183,7 +262,102 @@ def run_agent_workspace_update( output_parts.append(completed.stderr.rstrip()) if completed.returncode != 0: return AgentWorkspaceUpdateResult(commands, completed.returncode, "\n".join(output_parts).rstrip() + "\n") - return AgentWorkspaceUpdateResult(commands, 0, "\n".join(output_parts).rstrip() + "\n") + return AgentWorkspaceUpdateResult((("download", check.tarball_url), *commands), 0, "\n".join(output_parts).rstrip() + "\n") + + +def _read_release_json(*, timeout: float | None) -> dict[str, object]: + request = urllib.request.Request( + AGENT_WORKSPACE_RELEASES_API, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "agent-workspace-updater", + }, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + data = json.loads(response.read().decode("utf-8")) + if not isinstance(data, dict): + raise ValueError("GitHub release response is not a JSON object") + return data + + +def _agent_workspace_current_version(root: Path) -> str: + version_file = root / "agent_tools" / "VERSION" + if version_file.is_file(): + version = version_file.read_text(encoding="utf-8").strip() + if version: + return _release_version(version) + git = shutil.which("git") + if git is not None and (root / ".git").exists(): + completed = subprocess.run( + (git, "-C", str(root), "describe", "--tags", "--abbrev=0"), + text=True, + capture_output=True, + timeout=5, + ) + if completed.returncode == 0 and completed.stdout.strip(): + return _release_version(completed.stdout.strip()) + return "0.0.0" + + +def _download_and_extract_release(tarball_url: str, temp_dir: Path, *, timeout: float | None) -> Path: + archive_path = temp_dir / "release.tar.gz" + request = urllib.request.Request(tarball_url, headers={"User-Agent": "agent-workspace-updater"}) + with urllib.request.urlopen(request, timeout=timeout) as response: + archive_path.write_bytes(response.read()) + extract_dir = temp_dir / "extract" + extract_dir.mkdir() + with tarfile.open(archive_path, "r:*") as archive: + _safe_extract_tar(archive, extract_dir) + children = [path for path in extract_dir.iterdir() if path.is_dir()] + if len(children) != 1: + raise ValueError("release archive must contain one top-level directory") + release_source = children[0] + if not (release_source / "install-agent-tools.py").is_file() or not (release_source / "agent_tools").is_dir(): + raise ValueError("release archive does not look like Agent Workspace") + return release_source + + +def _safe_extract_tar(archive: tarfile.TarFile, target: Path) -> None: + target_root = target.resolve() + for member in archive.getmembers(): + if member.issym() or member.islnk(): + raise ValueError(f"links are not allowed in release archive: {member.name}") + member_target = (target / member.name).resolve() + try: + member_target.relative_to(target_root) + except ValueError as error: + raise ValueError(f"unsafe path in release archive: {member.name}") from error + archive.extractall(target) + + +def _copy_release_tree(source: Path, target: Path) -> None: + for item in source.iterdir(): + if item.name == ".git": + continue + destination = target / item.name + if item.is_dir(): + shutil.copytree(item, destination, dirs_exist_ok=True) + else: + shutil.copy2(item, destination) + + +def _release_version(value: object) -> str: + if not isinstance(value, str): + return "" + return value.strip().removeprefix("v") + + +def _string_value(value: object) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _version_key(version: str) -> tuple[int, ...]: + numbers = [] + for part in version.split("."): + if not part.isdigit(): + break + numbers.append(int(part)) + return tuple(numbers) def normalize_agent(agent: object) -> str: @@ -400,7 +574,7 @@ def ai_agent_model_settings( ) -def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | float | str | bool]: +def load_agent_workspace_settings(path: Path | None = None) -> dict[str, AgentWorkspaceSettingValue]: settings_path = path or agent_workspace_settings_path() if not settings_path.is_file(): return {} @@ -410,7 +584,7 @@ def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | f return {} if not isinstance(data, dict): return {} - settings: dict[str, int | float | str | bool] = {} + settings: dict[str, AgentWorkspaceSettingValue] = {} text_font_size = data.get("text_font_size", data.get("font_size")) button_font_size = data.get("button_font_size") theme = data.get("theme") @@ -440,6 +614,8 @@ def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | f main_split_ratio = data.get("main_split_ratio") details_split_ratio = data.get("details_split_ratio") actions_split_ratio = data.get("actions_split_ratio") + last_workspace = data.get("last_workspace") + recent_workspaces = data.get("recent_workspaces") if isinstance(text_font_size, int): settings["text_font_size"] = max(8, min(28, text_font_size)) if isinstance(button_font_size, int): @@ -490,10 +666,21 @@ def load_agent_workspace_settings(path: Path | None = None) -> dict[str, int | f ): if isinstance(value, int | float) and 0.05 <= float(value) <= 0.95: settings[key] = float(value) + if isinstance(last_workspace, str) and last_workspace.strip(): + settings["last_workspace"] = last_workspace.strip() + if isinstance(recent_workspaces, list): + normalized_recent = [] + seen = set() + for item in recent_workspaces: + if isinstance(item, str) and item.strip() and item not in seen: + normalized_recent.append(item.strip()) + seen.add(item) + if normalized_recent: + settings["recent_workspaces"] = normalized_recent[:10] return settings -def save_agent_workspace_settings(settings: dict[str, int | float | str | bool], path: Path | None = None) -> None: +def save_agent_workspace_settings(settings: dict[str, AgentWorkspaceSettingValue], path: Path | None = None) -> None: settings_path = path or agent_workspace_settings_path() try: settings_path.parent.mkdir(parents=True, exist_ok=True) @@ -502,6 +689,30 @@ def save_agent_workspace_settings(settings: dict[str, int | float | str | bool], return +def remember_agent_workspace(workspace: Path, path: Path | None = None) -> dict[str, AgentWorkspaceSettingValue]: + root = str(workspace.resolve()) + settings = load_agent_workspace_settings(path) + recent = [root] + existing_recent = settings.get("recent_workspaces") + if isinstance(existing_recent, list): + for item in existing_recent: + if isinstance(item, str) and item != root: + recent.append(item) + previous_last = settings.get("last_workspace") + if isinstance(previous_last, str) and previous_last != root: + recent.append(previous_last) + normalized_recent = [] + seen = set() + for item in recent: + if item and item not in seen: + normalized_recent.append(item) + seen.add(item) + settings["last_workspace"] = root + settings["recent_workspaces"] = normalized_recent[:10] + save_agent_workspace_settings(settings, path) + return settings + + def _codex_model_choices_from_cli(*, timeout: float) -> tuple[str, ...] | None: executable = agent_executable("codex") if executable is None: diff --git a/agent_tools/agent_workspace/components/settings/tests/test_settings.py b/agent_tools/agent_workspace/components/settings/tests/test_settings.py index f8543d84..2290bedb 100644 --- a/agent_tools/agent_workspace/components/settings/tests/test_settings.py +++ b/agent_tools/agent_workspace/components/settings/tests/test_settings.py @@ -1,6 +1,8 @@ from __future__ import annotations +import io import subprocess +import tarfile from agent_tools.agent_workspace.components.test_support.src.helpers import * @@ -35,6 +37,8 @@ def test_agent_workspace_settings_persist_font_size(tmp_path: Path) -> None: "main_split_ratio": 0.3, "details_split_ratio": 0.7, "actions_split_ratio": 0.4, + "last_workspace": str(tmp_path / "workspace"), + "recent_workspaces": [str(tmp_path / "workspace"), str(tmp_path / "other")], }, settings_path, ) @@ -65,9 +69,31 @@ def test_agent_workspace_settings_persist_font_size(tmp_path: Path) -> None: "main_split_ratio": 0.3, "details_split_ratio": 0.7, "actions_split_ratio": 0.4, + "last_workspace": str(tmp_path / "workspace"), + "recent_workspaces": [str(tmp_path / "workspace"), str(tmp_path / "other")], } +def test_remember_agent_workspace_updates_last_and_recent(tmp_path: Path) -> None: + settings_path = tmp_path / "settings.json" + old_workspace = tmp_path / "old" + new_workspace = tmp_path / "new" + save_agent_workspace_settings( + { + "theme": "dark", + "last_workspace": str(old_workspace), + "recent_workspaces": [str(old_workspace)], + }, + settings_path, + ) + + settings = remember_agent_workspace(new_workspace, settings_path) + + assert settings["theme"] == "dark" + assert settings["last_workspace"] == str(new_workspace.resolve()) + assert settings["recent_workspaces"] == [str(new_workspace.resolve()), str(old_workspace)] + + def test_agent_workspace_settings_migrate_old_font_size(tmp_path: Path) -> None: settings_path = tmp_path / "settings.json" settings_path.write_text('{"font_size": 17}', encoding="utf-8") @@ -413,27 +439,31 @@ def test_agent_workspace_root_resolves_from_workspace(tmp_path: Path) -> None: assert agent_workspace_root(workspace / "agent_tools") == workspace -def test_agent_workspace_update_commands_pull_then_install(tmp_path: Path) -> None: +def test_agent_workspace_update_commands_install_only(tmp_path: Path) -> None: workspace = tmp_path / "workspace" (workspace / "agent_tools").mkdir(parents=True) (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") commands = agent_workspace_update_commands(workspace, python_executable="/python") - assert commands[0][1:] == ("-C", str(workspace), "pull", "--ff-only") - assert commands[1] == ( + assert commands == (( "/python", str(workspace / "install-agent-tools.py"), "--non-interactive", "--skip-system-deps", "--recreate-venv-if-broken", - ) + ),) -def test_run_agent_workspace_update_returns_failed_command_output(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_run_agent_workspace_update_returns_failed_installer_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: workspace = tmp_path / "workspace" (workspace / "agent_tools").mkdir(parents=True) + (workspace / "agent_tools" / "VERSION").write_text("2.0.0\n", encoding="utf-8") (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + tarball = _agent_workspace_release_tarball() def fake_run( command: tuple[str, ...], @@ -441,11 +471,107 @@ def fake_run( ) -> subprocess.CompletedProcess[str]: return subprocess.CompletedProcess(command, 7, stdout="stdout text\n", stderr="stderr text\n") + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.urllib.request.urlopen", + _fake_urlopen_factory( + { + AGENT_WORKSPACE_RELEASES_API: b'{"tag_name":"v2.1.0","html_url":"https://example/release","tarball_url":"https://example/tarball"}', + "https://example/tarball": tarball, + } + ), + ) monkeypatch.setattr("agent_tools.agent_workspace.components.settings.src.settings.subprocess.run", fake_run) result = run_agent_workspace_update(workspace, python_executable="/missing-python") assert result.ok is False - assert "pull --ff-only" in result.output + assert "Latest release: 2.1.0" in result.output assert "stdout text" in result.output assert "stderr text" in result.output + assert (workspace / "agent_tools" / "VERSION").read_text(encoding="utf-8") == "2.1.0\n" + + +def test_run_agent_workspace_update_check_reports_available_updates( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + (workspace / "agent_tools").mkdir(parents=True) + (workspace / "agent_tools" / "VERSION").write_text("2.0.0\n", encoding="utf-8") + (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.urllib.request.urlopen", + _fake_urlopen_factory( + { + AGENT_WORKSPACE_RELEASES_API: b'{"tag_name":"v2.1.0","html_url":"https://example/release","tarball_url":"https://example/tarball"}', + } + ), + ) + + result = run_agent_workspace_update_check(workspace) + + assert result.ok is True + assert result.update_available is True + assert result.current_version == "2.0.0" + assert result.latest_version == "2.1.0" + assert result.release_url == "https://example/release" + assert result.tarball_url == "https://example/tarball" + + +def test_run_agent_workspace_update_check_returns_failed_release_output( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + workspace = tmp_path / "workspace" + (workspace / "agent_tools").mkdir(parents=True) + (workspace / "install-agent-tools.py").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + + def fake_urlopen(_request: object, **_kwargs: object) -> object: + raise OSError("network down") + + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.urllib.request.urlopen", + fake_urlopen, + ) + + result = run_agent_workspace_update_check(workspace) + + assert result.ok is False + assert result.update_available is False + assert "network down" in result.output + + +class _FakeResponse: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def __enter__(self) -> "_FakeResponse": + return self + + def __exit__(self, *_args: object) -> None: + return None + + def read(self) -> bytes: + return self.payload + + +def _fake_urlopen_factory(payloads: dict[str, bytes]) -> object: + def fake_urlopen(request: object, **_kwargs: object) -> _FakeResponse: + url = getattr(request, "full_url", request) + return _FakeResponse(payloads[str(url)]) + + return fake_urlopen + + +def _agent_workspace_release_tarball() -> bytes: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:gz") as archive: + for name, payload in ( + ("release/install-agent-tools.py", b"#!/usr/bin/env python3\n"), + ("release/agent_tools/VERSION", b"2.1.0\n"), + ): + info = tarfile.TarInfo(name) + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return output.getvalue() diff --git a/agent_tools/agent_workspace/components/test_support/src/helpers.py b/agent_tools/agent_workspace/components/test_support/src/helpers.py index f4784622..5c02bedf 100644 --- a/agent_tools/agent_workspace/components/test_support/src/helpers.py +++ b/agent_tools/agent_workspace/components/test_support/src/helpers.py @@ -79,10 +79,12 @@ from agent_tools.agent_workspace.components.settings.api import agent_executable from agent_tools.agent_workspace.components.settings.api import agent_install_command from agent_tools.agent_workspace.components.settings.api import agent_workspace_root +from agent_tools.agent_workspace.components.settings.api import agent_workspace_install_root from agent_tools.agent_workspace.components.settings.api import agent_workspace_runtime_settings from agent_tools.agent_workspace.components.settings.api import agent_workspace_setting_or_default from agent_tools.agent_workspace.components.settings.api import agent_workspace_update_commands from agent_tools.agent_workspace.components.settings.api import AGENT_WORKSPACE_DEFAULT_LIMITED_BASH_OUTPUT_TOKENS +from agent_tools.agent_workspace.components.settings.api import AGENT_WORKSPACE_RELEASES_API from agent_tools.agent_workspace.components.settings.api import ai_agent_model_settings from agent_tools.agent_workspace.components.settings.api import claude_model_choices_info from agent_tools.agent_workspace.components.settings.api import codex_model_choices @@ -92,9 +94,15 @@ from agent_tools.agent_workspace.components.task_catalog.api import discover_tasks from agent_tools.agent_workspace.components.task_catalog.api import run_task_check from agent_tools.agent_workspace.components.settings.api import load_agent_workspace_settings +from agent_tools.agent_workspace.components.settings.api import run_agent_workspace_update_check from agent_tools.agent_workspace.components.settings.api import run_agent_workspace_update from agent_tools.agent_workspace.components.settings.api import save_agent_workspace_settings +from agent_tools.agent_workspace.components.settings.api import remember_agent_workspace from agent_tools.agent_workspace.components.settings.api import task_dictionary_policy_from_runtime_settings +from agent_tools.agent_workspace.components.workspace_config.api import AGENT_WORKSPACE_SCHEMA_VERSION +from agent_tools.agent_workspace.components.workspace_config.api import create_agent_workspace +from agent_tools.agent_workspace.components.workspace_config.api import load_agent_workspace_manifest +from agent_tools.agent_workspace.components.workspace_config.api import resolve_agent_workspace_startup from agent_tools.agent_workspace.components.task_context.api import context_entry_cards_markdown from agent_tools.agent_workspace.components.task_context.api import encoded_context_entries_markdown from agent_tools.agent_workspace.components.task_context.api import task_context_details_markdown diff --git a/agent_tools/agent_workspace/components/tk_frontend/src/ui.py b/agent_tools/agent_workspace/components/tk_frontend/src/ui.py index 20a49284..a51b5892 100644 --- a/agent_tools/agent_workspace/components/tk_frontend/src/ui.py +++ b/agent_tools/agent_workspace/components/tk_frontend/src/ui.py @@ -94,6 +94,7 @@ from ...task_context.api import load_task_context_slots as _load_task_context_slots from ...task_context.api import render_task_context_slots as _render_task_context_slots from ...task_context.api import task_goal_slot_markdown as _task_goal_slot_markdown +from ...workspace_config.api import resolve_agent_workspace_startup def _codex_executable() -> str: @@ -2614,11 +2615,11 @@ def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Open the local workspace task dashboard.") parser.add_argument( "--workspace", - default=os.getcwd(), - help="Workspace root. Default: current directory.", + default=None, + help="Workspace root. Default: last opened workspace or current directory.", ) args = parser.parse_args(argv) - workspace = Path(args.workspace) + workspace = resolve_agent_workspace_startup(Path(args.workspace) if args.workspace else None) install_agent_workspace_exception_logger(workspace, "tk") root = tk.Tk() diff --git a/agent_tools/agent_workspace/components/workspace_config/__init__.py b/agent_tools/agent_workspace/components/workspace_config/__init__.py new file mode 100644 index 00000000..f0f277d7 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/__init__.py @@ -0,0 +1 @@ +"""Agent Workspace workspace configuration component.""" diff --git a/agent_tools/agent_workspace/components/workspace_config/api/__init__.py b/agent_tools/agent_workspace/components/workspace_config/api/__init__.py new file mode 100644 index 00000000..862f8282 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/api/__init__.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from ..src.workspace import AGENT_WORKSPACE_MANIFEST_DIR +from ..src.workspace import AGENT_WORKSPACE_MANIFEST_FILE +from ..src.workspace import AGENT_WORKSPACE_SCHEMA_VERSION +from ..src.workspace import DEFAULT_TASKS_DIR +from ..src.workspace import AgentWorkspaceManifest +from ..src.workspace import agent_workspace_manifest_path +from ..src.workspace import create_agent_workspace +from ..src.workspace import ensure_agent_workspace +from ..src.workspace import is_agent_workspace +from ..src.workspace import load_agent_workspace_manifest +from ..src.workspace import resolve_agent_workspace_startup + +__all__ = [ + "AGENT_WORKSPACE_MANIFEST_DIR", + "AGENT_WORKSPACE_MANIFEST_FILE", + "AGENT_WORKSPACE_SCHEMA_VERSION", + "DEFAULT_TASKS_DIR", + "AgentWorkspaceManifest", + "agent_workspace_manifest_path", + "create_agent_workspace", + "ensure_agent_workspace", + "is_agent_workspace", + "load_agent_workspace_manifest", + "resolve_agent_workspace_startup", +] diff --git a/agent_tools/agent_workspace/components/workspace_config/src/__init__.py b/agent_tools/agent_workspace/components/workspace_config/src/__init__.py new file mode 100644 index 00000000..f1bf2b7d --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/src/__init__.py @@ -0,0 +1 @@ +"""Workspace manifest implementation.""" diff --git a/agent_tools/agent_workspace/components/workspace_config/src/workspace.py b/agent_tools/agent_workspace/components/workspace_config/src/workspace.py new file mode 100644 index 00000000..e8409f56 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/src/workspace.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +import json +from pathlib import Path +from uuid import uuid4 + +from ...settings.api import load_agent_workspace_settings +from ...settings.api import remember_agent_workspace + + +AGENT_WORKSPACE_MANIFEST_DIR = ".agent-workspace" +AGENT_WORKSPACE_MANIFEST_FILE = "workspace.json" +AGENT_WORKSPACE_SCHEMA_VERSION = 1 +AGENT_WORKSPACE_MIN_VERSION = "2.1.0" +DEFAULT_TASKS_DIR = "tasks" + + +@dataclass(frozen=True) +class AgentWorkspaceManifest: + schema_version: int + workspace_id: str + name: str + created_at: str + agent_workspace_min_version: str + tasks_dir: str = DEFAULT_TASKS_DIR + + +def agent_workspace_manifest_path(workspace: Path) -> Path: + return workspace / AGENT_WORKSPACE_MANIFEST_DIR / AGENT_WORKSPACE_MANIFEST_FILE + + +def is_agent_workspace(workspace: Path) -> bool: + path = workspace.resolve() + return agent_workspace_manifest_path(path).is_file() or (path / DEFAULT_TASKS_DIR).is_dir() + + +def create_agent_workspace(workspace: Path, *, name: str | None = None) -> AgentWorkspaceManifest: + root = workspace.resolve() + root.mkdir(parents=True, exist_ok=True) + (root / DEFAULT_TASKS_DIR).mkdir(exist_ok=True) + manifest = AgentWorkspaceManifest( + schema_version=AGENT_WORKSPACE_SCHEMA_VERSION, + workspace_id=str(uuid4()), + name=(name or root.name or "Agent Workspace").strip(), + created_at=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + agent_workspace_min_version=AGENT_WORKSPACE_MIN_VERSION, + tasks_dir=DEFAULT_TASKS_DIR, + ) + _write_manifest(root, manifest) + return manifest + + +def ensure_agent_workspace(workspace: Path) -> AgentWorkspaceManifest: + root = workspace.resolve() + manifest_path = agent_workspace_manifest_path(root) + if manifest_path.is_file(): + manifest = load_agent_workspace_manifest(root) + (root / manifest.tasks_dir).mkdir(parents=True, exist_ok=True) + return manifest + return create_agent_workspace(root) + + +def load_agent_workspace_manifest(workspace: Path) -> AgentWorkspaceManifest: + root = workspace.resolve() + manifest_path = agent_workspace_manifest_path(root) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"workspace manifest is not an object: {manifest_path}") + schema_version = data.get("schema_version") + workspace_id = data.get("workspace_id") + name = data.get("name") + created_at = data.get("created_at") + min_version = data.get("agent_workspace_min_version") + tasks_dir = data.get("tasks_dir", DEFAULT_TASKS_DIR) + if schema_version != AGENT_WORKSPACE_SCHEMA_VERSION: + raise ValueError(f"unsupported workspace schema version in {manifest_path}: {schema_version!r}") + if not isinstance(workspace_id, str) or not workspace_id.strip(): + raise ValueError(f"workspace manifest has no workspace_id: {manifest_path}") + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"workspace manifest has no name: {manifest_path}") + if not isinstance(created_at, str) or not created_at.strip(): + raise ValueError(f"workspace manifest has no created_at: {manifest_path}") + if not isinstance(min_version, str) or not min_version.strip(): + raise ValueError(f"workspace manifest has no agent_workspace_min_version: {manifest_path}") + if not isinstance(tasks_dir, str) or not tasks_dir.strip() or Path(tasks_dir).is_absolute(): + raise ValueError(f"workspace manifest has invalid tasks_dir: {manifest_path}") + return AgentWorkspaceManifest( + schema_version=schema_version, + workspace_id=workspace_id.strip(), + name=name.strip(), + created_at=created_at.strip(), + agent_workspace_min_version=min_version.strip(), + tasks_dir=tasks_dir.strip(), + ) + + +def resolve_agent_workspace_startup( + explicit_workspace: Path | None = None, + *, + cwd: Path | None = None, +) -> Path: + candidates: list[Path] = [] + if explicit_workspace is not None: + candidates.append(explicit_workspace) + else: + settings = load_agent_workspace_settings() + last_workspace = settings.get("last_workspace") + if isinstance(last_workspace, str) and last_workspace: + candidates.append(Path(last_workspace)) + recent_workspaces = settings.get("recent_workspaces") + if isinstance(recent_workspaces, list): + for item in recent_workspaces: + if isinstance(item, str) and item: + candidates.append(Path(item)) + candidates.append(cwd or Path.cwd()) + + for candidate in candidates: + root = candidate.expanduser().resolve() + if is_agent_workspace(root) or explicit_workspace is not None: + ensure_agent_workspace(root) + remember_agent_workspace(root) + return root + + root = (cwd or Path.cwd()).expanduser().resolve() + ensure_agent_workspace(root) + remember_agent_workspace(root) + return root + + +def _write_manifest(workspace: Path, manifest: AgentWorkspaceManifest) -> None: + manifest_path = agent_workspace_manifest_path(workspace) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + payload = { + "schema_version": manifest.schema_version, + "workspace_id": manifest.workspace_id, + "name": manifest.name, + "created_at": manifest.created_at, + "agent_workspace_min_version": manifest.agent_workspace_min_version, + "tasks_dir": manifest.tasks_dir, + } + manifest_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") diff --git a/agent_tools/agent_workspace/components/workspace_config/tests/__init__.py b/agent_tools/agent_workspace/components/workspace_config/tests/__init__.py new file mode 100644 index 00000000..442900c7 --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/tests/__init__.py @@ -0,0 +1 @@ +"""Workspace configuration tests.""" diff --git a/agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py b/agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py new file mode 100644 index 00000000..d2c2e33a --- /dev/null +++ b/agent_tools/agent_workspace/components/workspace_config/tests/test_workspace_config.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from agent_tools.agent_workspace.components.test_support.src.helpers import * + + +def test_create_agent_workspace_writes_manifest_and_tasks_dir(tmp_path: Path) -> None: + workspace = tmp_path / "work" + + manifest = create_agent_workspace(workspace, name="Main Work") + + assert manifest.name == "Main Work" + assert manifest.schema_version == AGENT_WORKSPACE_SCHEMA_VERSION + assert (workspace / ".agent-workspace" / "workspace.json").is_file() + assert (workspace / "tasks").is_dir() + assert load_agent_workspace_manifest(workspace).workspace_id == manifest.workspace_id + + +def test_load_agent_workspace_manifest_rejects_absolute_tasks_dir(tmp_path: Path) -> None: + workspace = tmp_path / "work" + manifest_path = workspace / ".agent-workspace" / "workspace.json" + manifest_path.parent.mkdir(parents=True) + manifest_path.write_text( + json.dumps( + { + "schema_version": 1, + "workspace_id": "id", + "name": "work", + "created_at": "2026-08-28T00:00:00Z", + "agent_workspace_min_version": "2.1.0", + "tasks_dir": "/tmp/tasks", + } + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="invalid tasks_dir"): + load_agent_workspace_manifest(workspace) + + +def test_resolve_agent_workspace_startup_prefers_last_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings_path = tmp_path / "settings.json" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + last_workspace = tmp_path / "last" + cwd_workspace = tmp_path / "cwd" + create_agent_workspace(last_workspace) + create_agent_workspace(cwd_workspace) + save_agent_workspace_settings({"last_workspace": str(last_workspace)}, settings_path) + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.agent_workspace_settings_path", + lambda: settings_path, + ) + + assert resolve_agent_workspace_startup(cwd=cwd_workspace) == last_workspace.resolve() + + +def test_resolve_agent_workspace_startup_creates_explicit_workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + settings_path = tmp_path / "settings.json" + monkeypatch.setattr( + "agent_tools.agent_workspace.components.settings.src.settings.agent_workspace_settings_path", + lambda: settings_path, + ) + workspace = tmp_path / "new-work" + + assert resolve_agent_workspace_startup(workspace, cwd=tmp_path) == workspace.resolve() + assert (workspace / ".agent-workspace" / "workspace.json").is_file() + assert load_agent_workspace_settings(settings_path)["last_workspace"] == str(workspace.resolve()) From f777ab7c56f3695581402f377bc29554c2fb9462 Mon Sep 17 00:00:00 2001 From: svlad-90 Date: Fri, 28 Aug 2026 15:34:23 +0300 Subject: [PATCH 4/6] commit_msg: handle separated trailer blocks Treat blank lines between trailers as part of the trailer block so formatting an existing message does not leave an old Signed-off-by in the body before adding the canonical signoff. Keep Assisted-by as the final trailer after normalizing spaced trailer blocks. Signed-off-by: svlad-90 Assisted-by: Codex:gpt-5 --- agent_tools/tools/commit_msg/__init__.py | 25 ++++++++++++++++--- .../tools/commit_msg/tests/test_commit_msg.py | 21 +++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/agent_tools/tools/commit_msg/__init__.py b/agent_tools/tools/commit_msg/__init__.py index 84baa4d1..bbf36a64 100644 --- a/agent_tools/tools/commit_msg/__init__.py +++ b/agent_tools/tools/commit_msg/__init__.py @@ -314,14 +314,31 @@ def _split_trailers(lines: list[str]) -> tuple[list[str], list[str]]: return [], [] index = len(stripped) - while index > 0 and _is_trailer_line(stripped[index - 1]): - index -= 1 + trailer_start = index + has_trailer = False + while index > 0: + line = stripped[index - 1] + if _is_trailer_line(line): + has_trailer = True + trailer_start = index - 1 + index -= 1 + continue + if ( + has_trailer + and line == "" + and index > 2 + and _is_trailer_line(stripped[index - 2]) + ): + index -= 1 + continue + break - if index == len(stripped): + if not has_trailer: return stripped, [] if index > 0 and stripped[index - 1] != "": return stripped, [] - return _strip_blank_edges(stripped[:index]), stripped[index:] + trailer_lines = [line for line in stripped[trailer_start:] if _is_trailer_line(line)] + return _strip_blank_edges(stripped[:index]), trailer_lines def _is_trailer_line(line: str) -> bool: diff --git a/agent_tools/tools/commit_msg/tests/test_commit_msg.py b/agent_tools/tools/commit_msg/tests/test_commit_msg.py index 30bbb0b2..86a8ed12 100644 --- a/agent_tools/tools/commit_msg/tests/test_commit_msg.py +++ b/agent_tools/tools/commit_msg/tests/test_commit_msg.py @@ -9,7 +9,7 @@ from contextlib import redirect_stdout from pathlib import Path -from agent_tools.tools.commit_msg import format_commit_message, main as commit_msg_main +from agent_tools.tools.commit_msg import GitIdentity, format_commit_message, main as commit_msg_main from agent_tools.tools.commit_msg.workflow import ( check_commits, check_repo_identity, @@ -141,6 +141,25 @@ def test_format_existing_message_keeps_assisted_by_last(self) -> None: message.rstrip().endswith("Assisted-by: Codex:gpt-5 cpp-code-map") ) + def test_format_existing_message_deduplicates_signoff_with_blank_trailer_gap(self) -> None: + message = format_commit_message( + textwrap.dedent( + """\ + tools: normalize spaced trailers + + Body. + + Signed-off-by: Example Author + + Assisted-by: Codex:gpt-5 + """ + ), + identity=GitIdentity("Example Author", "author@example.com"), + ) + + self.assertEqual(1, message.count("Signed-off-by: Example Author ")) + self.assertTrue(message.rstrip().endswith("Assisted-by: Codex:gpt-5")) + def test_zephyr_assisted_by_check(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: repo = Path(temp_dir) From 23ae053777bce04b5bb2bf73bb46dfa3677c691a Mon Sep 17 00:00:00 2001 From: svlad-90 Date: Fri, 28 Aug 2026 16:01:12 +0300 Subject: [PATCH 5/6] diff_report: stabilize story scroll in browser Preserve the story panel height with the existing sentinel when the story controls pin during page scroll. Add synthetic browser smoke coverage for report JSON self-test execution and diff report story scrolling without using project data. Signed-off-by: svlad-90 Assisted-by: Codex:gpt-5 --- agent_tools/tools/diff_report/assets_story.py | 18 +- .../diff_report/tests/test_assets_contract.py | 3 + .../diff_report/tests/test_browser_smoke.py | 375 ++++++++++++++++++ 3 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 agent_tools/tools/diff_report/tests/test_browser_smoke.py diff --git a/agent_tools/tools/diff_report/assets_story.py b/agent_tools/tools/diff_report/assets_story.py index 542f6e1f..e5249b7c 100644 --- a/agent_tools/tools/diff_report/assets_story.py +++ b/agent_tools/tools/diff_report/assets_story.py @@ -846,6 +846,9 @@ def story_script() -> str: story.style.minHeight = ""; const storyTop = Math.max(0, Math.ceil(story.getBoundingClientRect().top)); const currentHeight = Math.ceil(story.getBoundingClientRect().height); + if (storySentinel && document.body.classList.contains("has-pinned-story")) { + storySentinel.style.height = currentHeight + "px"; + } document.documentElement.style.setProperty("--story-offset", (storyTop + currentHeight) + "px"); } @@ -870,10 +873,23 @@ def story_script() -> str: hasLeftTop && storySentinel && storySentinel.getBoundingClientRect().top <= 0 ); document.body.classList.toggle("has-left-top", hasLeftTop); - document.body.classList.toggle("has-pinned-story", storyPinned); + setStoryPinned(storyPinned); }); } + function setStoryPinned(storyPinned) { + if (!story || !storySentinel) { + return; + } + if (storyPinned) { + storySentinel.style.height = Math.ceil(story.getBoundingClientRect().height) + "px"; + document.body.classList.add("has-pinned-story"); + return; + } + document.body.classList.remove("has-pinned-story"); + storySentinel.style.height = "0px"; + } + function setActive(index) { if (!steps.length) { return; diff --git a/agent_tools/tools/diff_report/tests/test_assets_contract.py b/agent_tools/tools/diff_report/tests/test_assets_contract.py index b8aeda24..c211570d 100644 --- a/agent_tools/tools/diff_report/tests/test_assets_contract.py +++ b/agent_tools/tools/diff_report/tests/test_assets_contract.py @@ -466,6 +466,9 @@ def test_story_script_exposes_target_and_navigation_contracts(self) -> None: "function fileHeaderStickyTop()", "function settleFileHeaderScroll(element, onDone)", "element.getBoundingClientRect().top - fileHeaderStickyTop()", + "function setStoryPinned(storyPinned)", + 'storySentinel.style.height = Math.ceil(story.getBoundingClientRect().height) + "px";', + 'storySentinel.style.height = "0px";', "let userScrollIntent = false;", "function cancelProgrammaticScrollForUserIntent()", "userScrollIntent = true;", diff --git a/agent_tools/tools/diff_report/tests/test_browser_smoke.py b/agent_tools/tools/diff_report/tests/test_browser_smoke.py new file mode 100644 index 00000000..50282b17 --- /dev/null +++ b/agent_tools/tools/diff_report/tests/test_browser_smoke.py @@ -0,0 +1,375 @@ +from __future__ import annotations + +import base64 +import json +import os +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +import unittest +import urllib.request +from pathlib import Path + +from agent_tools.tools.diff_report.cli import main +from agent_tools.tools.diff_report.core import generate_report + + +def _browser() -> str | None: + configured = os.environ.get("DIFF_REPORT_BROWSER") + if configured: + return configured + for candidate in ("google-chrome", "chromium", "chromium-browser"): + path = shutil.which(candidate) + if path: + return path + return None + + +@unittest.skipUnless(_browser(), "Chrome-compatible browser is not available") +class BrowserSmokeTests(unittest.TestCase): + def test_report_json_self_test_runs_in_browser(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + report_json = root / "report.json" + output = root / "report.html" + report_json.write_text(json.dumps(_generic_report_payload()), encoding="utf-8") + + status = main( + [ + "--report-json", + str(report_json), + "--output", + str(output), + "--report-test-mode", + ] + ) + + self.assertEqual(0, status) + result = _evaluate_in_browser( + output, + "window.__reportSelfTest.runAll()", + await_promise=True, + ) + + self.assertIsInstance(result, dict) + self.assertTrue(result.get("pass"), result) + self.assertGreaterEqual(int(result.get("total", 0)), 1) + + def test_story_bar_pinning_does_not_jump_during_manual_scroll(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + diff_path = root / "change.patch" + comments_path = root / "comments.json" + output = root / "report.html" + diff_path.write_text(_long_diff(), encoding="utf-8") + comments_path.write_text(json.dumps(_story_comments()), encoding="utf-8") + generate_report( + output_path=output, + title="Synthetic scroll report", + diff_file=diff_path, + comments_file=comments_path, + ) + + result = _evaluate_in_browser( + output, + r"""(async () => { + window.scrollTo(0, 1400); + window.dispatchEvent(new WheelEvent("wheel", {deltaY: 1400, bubbles: true})); + await new Promise(resolve => setTimeout(resolve, 180)); + const afterWait = window.scrollY; + const sentinel = document.querySelector(".story-sentinel"); + const sentinelHeight = sentinel ? sentinel.getBoundingClientRect().height : 0; + const pinned = document.body.classList.contains("has-pinned-story"); + window.scrollTo(0, 900); + await new Promise(resolve => setTimeout(resolve, 80)); + return { + afterWait, + afterManual: window.scrollY, + pinned, + sentinelHeight + }; + })()""", + await_promise=True, + ) + + self.assertIsInstance(result, dict) + self.assertGreaterEqual(result.get("afterWait", 0), 1000, result) + self.assertGreaterEqual(result.get("afterManual", 0), 850, result) + self.assertTrue(result.get("pinned"), result) + self.assertGreater(result.get("sentinelHeight", 0), 0, result) + + +def _generic_report_payload() -> dict[str, object]: + rows = [{"id": f"row-{index:03d}", "status": "not_failed"} for index in range(1, 80)] + return { + "title": "Synthetic dashboard", + "summary_blocks": [{"type": "text", "body": "Synthetic browser smoke report."}], + "metric_tables": [ + { + "title": "Metrics", + "columns": [ + {"key": "name", "label": "Name"}, + {"key": "passed", "label": "Passed"}, + ], + "rows": [ + { + "cells": { + "name": { + "text": "CTS", + "graph_view": { + "focus": "product:test", + "types": ["product", "cts_module"], + "target_type": "cts_module", + }, + }, + "passed": { + "text": "3", + "status": "not_failed", + "graph_view": { + "focus": "product:test", + "types": ["product", "cts_module"], + "target_type": "cts_module", + "filters": {"cts_module": {"status": ["not_failed"]}}, + }, + }, + } + } + ], + } + ], + "tables": [{"title": "Synthetic rows", "columns": ["id", "status"], "rows": rows}], + "relationship_graph": { + "title": "Synthetic graph", + "nodes": [ + {"id": "product:test", "type": "product", "label": "Test product", "status": "not_failed"}, + {"id": "cts:1", "type": "cts_module", "label": "CTS 1", "status": "not_failed"}, + {"id": "cts:2", "type": "cts_module", "label": "CTS 2", "status": "not_failed"}, + {"id": "cts:3", "type": "cts_module", "label": "CTS 3", "status": "not_failed"}, + ], + "edges": [ + {"source": "product:test", "target": "cts:1", "relation": "contains"}, + {"source": "product:test", "target": "cts:2", "relation": "contains"}, + {"source": "product:test", "target": "cts:3", "relation": "contains"}, + ], + }, + } + + +def _story_comments() -> dict[str, object]: + return { + "summary": "Synthetic story scroll report.", + "inline": [ + { + "file": "src/demo.c", + "line": 120, + "title": "Late line", + "body": "This comment forces a long page.", + } + ], + "story": [ + {"title": "Top", "body": "Start at the file.", "file": "src/demo.c"}, + { + "title": "Late comment", + "body": "Jump near the end.", + "comment": {"file": "src/demo.c", "line": 120}, + }, + ], + } + + +def _long_diff() -> str: + added_lines = "\n".join(f"+int demo_{index:03d}(void) {{ return {index}; }}" for index in range(1, 181)) + return ( + "diff --git a/src/demo.c b/src/demo.c\n" + "new file mode 100644\n" + "index 0000000..1111111\n" + "--- /dev/null\n" + "+++ b/src/demo.c\n" + "@@ -0,0 +1,180 @@\n" + f"{added_lines}\n" + ) + + +def _evaluate_in_browser(html: Path, expression: str, *, await_promise: bool = False) -> object: + browser = _browser() + if not browser: + raise unittest.SkipTest("Chrome-compatible browser is not available") + profile_dir = Path(tempfile.mkdtemp(prefix="diff-report-browser-test-")) + process = subprocess.Popen( + [ + browser, + "--headless", + "--disable-gpu", + "--no-sandbox", + "--disable-crash-reporter", + "--disable-crashpad", + f"--user-data-dir={profile_dir}", + "--remote-debugging-port=0", + "about:blank", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + text=True, + ) + try: + port = _wait_for_debug_port(profile_dir) + targets = _browser_json(port, "/json/list") + target = next(item for item in targets if isinstance(item, dict) and item.get("type") == "page") + with _CdpConnection(target["webSocketDebuggerUrl"]) as cdp: + cdp.call("Page.enable") + cdp.call("Runtime.enable") + cdp.call( + "Emulation.setDeviceMetricsOverride", + {"width": 1440, "height": 1000, "deviceScaleFactor": 1, "mobile": False}, + ) + cdp.call("Page.navigate", {"url": html.resolve().as_uri()}) + _wait_for_page_ready(cdp) + result = cdp.call( + "Runtime.evaluate", + {"expression": expression, "awaitPromise": await_promise, "returnByValue": True}, + ) + value = result.get("result", {}).get("result", {}) + if value.get("subtype") == "error": + raise AssertionError(value.get("description") or value) + return value.get("value") + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + shutil.rmtree(profile_dir, ignore_errors=True) + + +def _wait_for_page_ready(cdp: "_CdpConnection") -> None: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + response = cdp.call("Runtime.evaluate", {"expression": "document.readyState", "returnByValue": True}) + if response.get("result", {}).get("result", {}).get("value") == "complete": + return + time.sleep(0.05) + raise RuntimeError("page did not finish loading") + + +def _wait_for_debug_port(profile_dir: Path) -> int: + port_file = profile_dir / "DevToolsActivePort" + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if port_file.is_file(): + return int(port_file.read_text(encoding="utf-8").splitlines()[0]) + time.sleep(0.05) + raise RuntimeError("Chrome DevTools port file was not created") + + +def _browser_json(port: int, path: str) -> object: + with urllib.request.urlopen(f"http://127.0.0.1:{port}{path}", timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +class _CdpConnection: + def __init__(self, websocket_url: str) -> None: + prefix = "ws://" + if not websocket_url.startswith(prefix): + raise ValueError(f"unsupported DevTools websocket URL: {websocket_url}") + host_port, self.path = websocket_url[len(prefix) :].split("/", 1) + self.path = "/" + self.path + self.host, port = host_port.split(":", 1) + self.port = int(port) + self.sock: socket.socket | None = None + self.next_id = 1 + + def __enter__(self) -> "_CdpConnection": + raw = socket.create_connection((self.host, self.port), timeout=5) + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET {self.path} HTTP/1.1\r\n" + f"Host: {self.host}:{self.port}\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ) + raw.sendall(request.encode("ascii")) + response = raw.recv(4096) + if b" 101 " not in response.split(b"\r\n", 1)[0]: + raw.close() + raise RuntimeError("DevTools websocket handshake failed") + self.sock = raw + return self + + def __exit__(self, _exc_type: object, _exc: object, _traceback: object) -> None: + if self.sock: + self.sock.close() + self.sock = None + + def call(self, method: str, params: dict[str, object] | None = None) -> dict[str, object]: + message_id = self.next_id + self.next_id += 1 + self._send_json({"id": message_id, "method": method, "params": params or {}}) + while True: + message = self._recv_json() + if message.get("id") == message_id: + if "error" in message: + raise RuntimeError(f"CDP {method} failed: {message['error']}") + return message + + def _send_json(self, payload: dict[str, object]) -> None: + self._send_frame(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + + def _recv_json(self) -> dict[str, object]: + while True: + opcode, payload = self._recv_frame() + if opcode == 1: + return json.loads(payload.decode("utf-8")) + if opcode == 9: + self._send_frame(payload, opcode=10) + + def _send_frame(self, payload: bytes, opcode: int = 1) -> None: + if self.sock is None: + raise RuntimeError("websocket is not connected") + header = bytearray([0x80 | opcode]) + length = len(payload) + if length < 126: + header.append(0x80 | length) + elif length < 65536: + header.extend([0x80 | 126]) + header.extend(struct.pack("!H", length)) + else: + header.extend([0x80 | 127]) + header.extend(struct.pack("!Q", length)) + mask = os.urandom(4) + masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + self.sock.sendall(bytes(header) + mask + masked) + + def _recv_frame(self) -> tuple[int, bytes]: + if self.sock is None: + raise RuntimeError("websocket is not connected") + first, second = self._read_exact(2) + opcode = first & 0x0F + masked = bool(second & 0x80) + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", self._read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", self._read_exact(8))[0] + mask = self._read_exact(4) if masked else b"" + payload = self._read_exact(length) + if masked: + payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload)) + return opcode, payload + + def _read_exact(self, size: int) -> bytes: + if self.sock is None: + raise RuntimeError("websocket is not connected") + chunks = bytearray() + while len(chunks) < size: + chunk = self.sock.recv(size - len(chunks)) + if not chunk: + raise RuntimeError("websocket closed") + chunks.extend(chunk) + return bytes(chunks) From 98b8cd3b9b7f9686726c3e61d1ecc0344b0fc440 Mon Sep 17 00:00:00 2001 From: svlad-90 Date: Fri, 28 Aug 2026 16:16:05 +0300 Subject: [PATCH 6/6] diff_report: remove CTS assumptions from browser self-test Make the in-page browser self-test select graph metric targets from report data instead of requiring CTS or VTS entity types. Keep the synthetic browser smoke report domain-neutral by using generic component nodes and metric labels. Signed-off-by: svlad-90 Assisted-by: Codex:gpt-5 --- agent_tools/tools/diff_report/report_json.py | 126 ++++++++++-------- .../diff_report/tests/test_browser_smoke.py | 24 ++-- 2 files changed, 82 insertions(+), 68 deletions(-) diff --git a/agent_tools/tools/diff_report/report_json.py b/agent_tools/tools/diff_report/report_json.py index 511a3c45..d91a41b2 100644 --- a/agent_tools/tools/diff_report/report_json.py +++ b/agent_tools/tools/diff_report/report_json.py @@ -4580,6 +4580,33 @@ def _report_self_test_script() -> str: .filter((item) => item.view && item.view.target_type && item.view.filters); } + function viewTargetStatus(view) { + const targetType = view && view.target_type; + const statuses = targetType && view.filters && view.filters[targetType] + ? view.filters[targetType].status + : null; + return Array.isArray(statuses) && statuses.length ? statuses[0] : ""; + } + + function graphMetricCandidates() { + return metricButtons().filter((item) => + item.view && item.view.target_type && item.view.filters + && String(item.view.focus || "").startsWith("product:") + ); + } + + function preferredGraphMetric() { + const candidates = graphMetricCandidates(); + return candidates.find((item) => viewTargetStatus(item.view) === "not_failed") + || candidates[0] + || null; + } + + function visibleTargetNode(graph, item) { + const targetType = item && item.view && item.view.target_type; + return graph && graph.nodes && graph.nodes.find((node) => node.type === targetType); + } + const SELF_TEST_SETTLE_MS = 300; function stats(values) { @@ -4603,10 +4630,7 @@ def _report_self_test_script() -> str: await delay(SELF_TEST_SETTLE_MS); const state = dumpState(); const targetType = item.view.target_type; - const targetStatus = item.view.filters - && item.view.filters[targetType] - && item.view.filters[targetType].status - && item.view.filters[targetType].status[0]; + const targetStatus = viewTargetStatus(item.view); if (expectedCount != null && state.pagination && state.pagination.total !== expectedCount) { errors.push(`pagination total ${state.pagination.total} != metric count ${expectedCount}`); } @@ -4619,7 +4643,7 @@ def _report_self_test_script() -> str: if (!state.activeView) { errors.push("graph active view indicator is not enabled after metric click"); } - if (expectedCount && ["cts_module", "vts_module"].includes(targetType) && state.edges <= 0) { + if (expectedCount && state.nodes > 1 && state.edges <= 0) { errors.push(`no graph links rendered for ${targetType}`); } return { @@ -4695,10 +4719,7 @@ def _report_self_test_script() -> str: } } - const metric = metricButtons().find((item) => - ["cts_module", "vts_module"].includes(item.view.target_type) - && String(item.view.focus || "").startsWith("product:") - ); + const metric = preferredGraphMetric(); let graph = null; if (!metric) { errors.push("metric graph button not found for layout test"); @@ -4742,25 +4763,19 @@ def _report_self_test_script() -> str: async function runEntitySwitchReset() { const startedAt = performance.now(); - const item = metricButtons().find((candidate) => - candidate.view && candidate.view.target_type === "cts_module" - && candidate.view.filters && candidate.view.filters.cts_module - && Array.isArray(candidate.view.filters.cts_module.status) - && candidate.view.filters.cts_module.status.includes("not_failed") - && String(candidate.view.focus || "").startsWith("product:") - ); + const item = preferredGraphMetric(); const errors = []; if (!item) { - return {name: "entity switch resets filters", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["CTS not_failed metric button not found"]}; + return {name: "entity switch resets filters", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["graph metric button not found"]}; } item.button.click(); await delay(SELF_TEST_SETTLE_MS); const browser = activeBrowser(); const state = browser && browser.__relationshipState; const graph = state && state.visibleGraph; - const targetNode = graph && graph.nodes && graph.nodes.find((node) => node.type === "cts_module"); + const targetNode = visibleTargetNode(graph, item); if (!browser || !state || !targetNode) { - return {name: "entity switch resets filters", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["CTS target node not visible after metric click"]}; + return {name: "entity switch resets filters", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["target node not visible after metric click"]}; } const focusSelect = browser.querySelector("[data-relationship-node-select]"); if (!focusSelect) { @@ -4791,16 +4806,10 @@ def _report_self_test_script() -> str: async function runGraphFocusBadgeRelease() { const startedAt = performance.now(); - const item = metricButtons().find((candidate) => - candidate.view && candidate.view.target_type === "cts_module" - && candidate.view.filters && candidate.view.filters.cts_module - && Array.isArray(candidate.view.filters.cts_module.status) - && candidate.view.filters.cts_module.status.includes("not_failed") - && String(candidate.view.focus || "").startsWith("product:") - ); + const item = preferredGraphMetric(); const errors = []; if (!item) { - return {name: "graph focus badge release", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["CTS not_failed metric button not found"]}; + return {name: "graph focus badge release", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["graph metric button not found"]}; } item.button.click(); await delay(SELF_TEST_SETTLE_MS); @@ -4813,9 +4822,9 @@ def _report_self_test_script() -> str: const selectionPanel = browser && browser.querySelector(".relationship-selection-panel"); const explorer = browser && browser.querySelector(".relationship-explorer-main"); const focusSelect = browser && browser.querySelector("[data-relationship-node-select]"); - const targetNode = graph && graph.nodes && graph.nodes.find((node) => node.type === "cts_module"); + const targetNode = visibleTargetNode(graph, item); if (!browser || !state || !canvasWrap || !badge || !statusFilter || !selectionPanel || !explorer || !focusSelect || !targetNode) { - return {name: "graph focus badge release", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["graph controls or CTS target node not visible after metric click"]}; + return {name: "graph focus badge release", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["graph controls or target node not visible after metric click"]}; } const initial = dumpState(); const canvas = browser.querySelector("[data-relationship-canvas]"); @@ -4938,29 +4947,33 @@ def _report_self_test_script() -> str: async function runNodeTransitionBenchmark() { const startedAt = performance.now(); - const item = metricButtons().find((candidate) => - candidate.view && candidate.view.target_type === "cts_module" - && candidate.view.filters && candidate.view.filters.cts_module - && Array.isArray(candidate.view.filters.cts_module.status) - && candidate.view.filters.cts_module.status.includes("not_failed") - && String(candidate.view.focus || "").startsWith("product:") - ); const errors = []; - if (!item) { - return {name: "node transition benchmark", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["CTS not_failed metric button not found"]}; - } - item.button.click(); - await delay(SELF_TEST_SETTLE_MS); const browser = activeBrowser(); - const state = browser && browser.__relationshipState; - const graph = state && state.visibleGraph; const focusSelect = browser && browser.querySelector("[data-relationship-node-select]"); - const candidates = graph && graph.nodes - ? graph.nodes.filter((node) => node.type === "cts_module").slice(0, 12) - : []; - if (!browser || !state || !focusSelect || candidates.length < 2) { - return {name: "node transition benchmark", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["not enough visible CTS nodes for benchmark"]}; + if (!browser || !focusSelect) { + return {name: "node transition benchmark", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["graph controls not found"]}; + } + let item = null; + let state = null; + let candidates = []; + for (const candidate of graphMetricCandidates()) { + candidate.button.click(); + await delay(SELF_TEST_SETTLE_MS); + state = browser.__relationshipState; + const graph = state && state.visibleGraph; + const targetType = candidate.view.target_type; + candidates = graph && graph.nodes + ? graph.nodes.filter((node) => node.type === targetType).slice(0, 12) + : []; + if (candidates.length >= 2) { + item = candidate; + break; + } } + if (!item || !state || candidates.length < 2) { + return {name: "node transition benchmark", pass: false, durationMs: Math.round(performance.now() - startedAt), errors: ["graph metric button not found"]}; + } + const targetType = item.view.target_type; const transitions = []; for (const node of candidates) { const beforeVersion = Number(state.renderVersion || 0); @@ -4968,8 +4981,9 @@ def _report_self_test_script() -> str: focusSelect.value = node.id; focusSelect.dispatchEvent(new Event("change", {bubbles: true})); const elapsedMs = performance.now() - transitionStartedAt; - if (Number(state.renderVersion || 0) <= beforeVersion) { - errors.push(`no render version update for ${node.id}`); + const afterVersion = Number(state.renderVersion || 0); + if (afterVersion < beforeVersion) { + errors.push(`render version moved backwards for ${node.id}`); } transitions.push({ nodeId: node.id, @@ -4990,8 +5004,11 @@ def _report_self_test_script() -> str: } const batchElapsedMs = performance.now() - batchStartedAt; const batchRenderCount = Number(state.renderVersion || 0) - beforeBatchVersion; - if (batchRenderCount !== batchIterations) { - errors.push(`batch render count ${batchRenderCount} != ${batchIterations}`); + if (batchRenderCount <= 0) { + errors.push("batch did not trigger any graph renders"); + } + if (batchRenderCount > batchIterations) { + errors.push(`batch render count ${batchRenderCount} > ${batchIterations}`); } return { name: "node transition benchmark", @@ -5012,10 +5029,7 @@ def _report_self_test_script() -> str: async function runAll() { const startedAt = performance.now(); - const candidates = metricButtons().filter((item) => - ["cts_module", "vts_module"].includes(item.view.target_type) - && String(item.view.focus || "").startsWith("product:") - ); + const candidates = graphMetricCandidates(); const results = []; results.push(await runLayoutAndScroll()); for (const item of candidates) { diff --git a/agent_tools/tools/diff_report/tests/test_browser_smoke.py b/agent_tools/tools/diff_report/tests/test_browser_smoke.py index 50282b17..c7e4f1d9 100644 --- a/agent_tools/tools/diff_report/tests/test_browser_smoke.py +++ b/agent_tools/tools/diff_report/tests/test_browser_smoke.py @@ -119,11 +119,11 @@ def _generic_report_payload() -> dict[str, object]: { "cells": { "name": { - "text": "CTS", + "text": "Components", "graph_view": { "focus": "product:test", - "types": ["product", "cts_module"], - "target_type": "cts_module", + "types": ["product", "component"], + "target_type": "component", }, }, "passed": { @@ -131,9 +131,9 @@ def _generic_report_payload() -> dict[str, object]: "status": "not_failed", "graph_view": { "focus": "product:test", - "types": ["product", "cts_module"], - "target_type": "cts_module", - "filters": {"cts_module": {"status": ["not_failed"]}}, + "types": ["product", "component"], + "target_type": "component", + "filters": {"component": {"status": ["not_failed"]}}, }, }, } @@ -146,14 +146,14 @@ def _generic_report_payload() -> dict[str, object]: "title": "Synthetic graph", "nodes": [ {"id": "product:test", "type": "product", "label": "Test product", "status": "not_failed"}, - {"id": "cts:1", "type": "cts_module", "label": "CTS 1", "status": "not_failed"}, - {"id": "cts:2", "type": "cts_module", "label": "CTS 2", "status": "not_failed"}, - {"id": "cts:3", "type": "cts_module", "label": "CTS 3", "status": "not_failed"}, + {"id": "component:1", "type": "component", "label": "Component 1", "status": "not_failed"}, + {"id": "component:2", "type": "component", "label": "Component 2", "status": "not_failed"}, + {"id": "component:3", "type": "component", "label": "Component 3", "status": "not_failed"}, ], "edges": [ - {"source": "product:test", "target": "cts:1", "relation": "contains"}, - {"source": "product:test", "target": "cts:2", "relation": "contains"}, - {"source": "product:test", "target": "cts:3", "relation": "contains"}, + {"source": "product:test", "target": "component:1", "relation": "contains"}, + {"source": "product:test", "target": "component:2", "relation": "contains"}, + {"source": "product:test", "target": "component:3", "relation": "contains"}, ], }, }