From eeb97f35890cbb5e268d90b8b935738309248226 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 07:53:36 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20make=20?= =?UTF-8?q?Console=20a=20usable=20read-only=20operator=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface doctor FAIL findings instead of ready/unknown, copy a real doctor command, make family/events/channel tabs switch panes, flush the --no-open URL, and stop inventing spawn child names. Co-authored-by: Dandre Yang --- CHANGELOG.md | 14 + src/dyro/console/_inspect_worker.py | 1 + src/dyro/console/assets.py | 8 +- src/dyro/console/assets/app.js | 295 ++++++++++++++++---- src/dyro/console/assets/styles.css | 22 +- src/dyro/console/inspection.py | 33 ++- src/dyro/console/launcher.py | 12 +- src/dyro/console/overview.py | 166 ++++++++++-- tests/support/console_operator.mjs | 400 ++++++++++++++++++++++++++++ tests/test_console_assets.py | 5 + tests/test_console_inspection.py | 36 +++ tests/test_console_launcher.py | 24 ++ tests/test_console_operator.py | 90 +++++++ tests/test_console_overview.py | 88 +++++- 14 files changed, 1101 insertions(+), 93 deletions(-) create mode 100644 tests/support/console_operator.mjs create mode 100644 tests/test_console_operator.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e203e..d6bf305 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- Console operator surface: treat doctor FAIL findings as something the + page must show even when `next` reports ready with empty commands. + Overview heading and 现在需要你 surface those FAILs; the primary copy + command is `doctor` (or another allowlisted next command), never a + bare `dyro --workspace `. +- Console tabs `#w//family|events|channel` show only that pane. + Refresh re-fetches the overview and any open workspace so captured-at + moves. Spawn/merge/sync copy no longer invents `_new`. +- `dyro console --no-open` flushes the one-time URL. A missing or + expired bootstrap tells the operator to run `dyro console` again + instead of spinning on session setup. Empty twin/inventory copy is + one honest sentence; line badges do not paint 未检查 when a FAIL + finding exists for that line. + ## 0.7.9 - 2026-08-21 - Console P4: workspace detail now projects a read-only operator twin after diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index 76fe5a3..f55a139 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -55,6 +55,7 @@ def _unavailable_summary(alias: str, code: str) -> dict[str, object]: "reason": code, "command": f"dyro --workspace {alias} doctor", }, + "findings": [], "snapshot_sha256": "", "proof_inspection": "not_inspected", } diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index c6d3cc9..ec2284d 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -27,13 +27,13 @@ class ConsoleAsset: ), "app.js": ( "text/javascript; charset=utf-8", - "19a665f3da67838802cefaaab6a9d5030e585e3d9a9828002ee796355cafac52", - 85461, + "80ea029462b4c8231b7d0e1e87b29348aaff6d26e8683d8c6afd13921958ef53", + 91406, ), "styles.css": ( "text/css; charset=utf-8", - "8bdb1b8db171a130e2f1d5de0d432b8c9ce9c7c55722b8a2f043befcab8db057", - 24589, + "6752c01db706e12fdcd768a4974916f9586299e4312e9e01db0f29e762b40f78", + 24441, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index 1f229e2..96f7638 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -106,6 +106,21 @@ const ATTENTION_REASON_LABELS = { POLICY_DISALLOWS_OPERATION: "当前策略不允许这一步", HOME_GUIDANCE: "可以先打开这个项目看看", WORKSPACE_UNAVAILABLE: "这个项目现在读不到", + MISSING_ORIGIN: "远端跟踪分支不存在", + MISSING_WORKTREE: "工作树缺失", + BRANCH_MISMATCH: "开发线不在约定分支", + REPOSITORY_UNAVAILABLE: "仓库不可用", + UPSTREAM_MISMATCH: "上游跟踪不匹配", + COMMON_DIR: "Git 公共目录不匹配", + SYMLINK: "工作树链接方式不对", + EXTERNAL_POLICY: "外部策略未满足", + DOCTOR_FAIL: "doctor 检查未通过", +}; +const LIVE_TABS = ["family", "events", "channel"]; +const LIVE_PANE_IDS = { + family: "family-pane", + events: "event-pane", + channel: "channel-pane", }; const PROOF_INSPECTION_LABELS = { not_inspected: "尚未核验证据", inspected: "已单独检查证据" }; const PROOF_KIND_LABELS = { @@ -301,7 +316,7 @@ function consumeFragment() { const alias = parts[1] || ""; const tab = parts[2] || "family"; state.focus = alias && SAFE_ID.test(alias) ? alias : ""; - state.detailTab = ["family", "events", "channel"].includes(tab) ? tab : "family"; + state.detailTab = LIVE_TABS.includes(tab) ? tab : "family"; return ""; } const values = new URLSearchParams(raw); @@ -315,7 +330,7 @@ function consumeFragment() { } function setWorkspaceRoute(alias, tab) { - const safeTab = ["family", "events", "channel"].includes(tab) ? tab : "family"; + const safeTab = LIVE_TABS.includes(tab) ? tab : "family"; state.detailTab = safeTab; const hash = alias && SAFE_ID.test(alias) ? `#w/${alias}/${safeTab}` : ""; window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${hash}`); @@ -337,9 +352,10 @@ async function exchange(bootstrap) { sessionStorage.setItem(TOKEN_KEY, state.bearer); } -async function request(path, key) { +async function request(path, key, options = {}) { const headers = { Authorization: `Bearer ${state.bearer}` }; - const etag = state.etags.get(key); + const force = Boolean(options && options.force); + const etag = force ? "" : state.etags.get(key); if (etag) headers["If-None-Match"] = etag; const response = await fetch(path, { headers, cache: "no-store", credentials: "omit" }); if (response.status === 304) return null; @@ -374,6 +390,14 @@ async function requestWrite(path, payload) { return body; } +function sessionMissingMessage() { + return "本地会话尚未建立。请重新运行 dyro console,并用终端给出的一次性地址打开。"; +} + +function sessionExpiredMessage() { + return "一次性本地会话已失效。请重新运行 dyro console,再用新的地址打开。"; +} + function expireSession() { state.bearer = ""; state.etags.clear(); @@ -392,6 +416,7 @@ function addBadge(parent, label, level = "") { } function workspaceAttention(summary) { + if (workspaceHasFail(summary)) return 0; const attention = summary.attention_counts || {}; if (count(attention.repair_required)) return 0; if (count(attention.needs_user)) return 1; @@ -409,10 +434,57 @@ function priorityWorkspace(workspaces) { .find((summary) => text(summary.recommendation && summary.recommendation.command)); } +function failFindings(summary) { + const findings = Array.isArray(summary && summary.findings) ? summary.findings : []; + return findings.filter((item) => text(item && item.status) === "FAIL"); +} + +function workspaceHasFail(summary) { + return failFindings(summary).length > 0; +} + +function isBareWorkspaceCommand(command, alias) { + return Boolean(alias) && command === `dyro --workspace ${alias}`; +} + +function recommendedCommand(summary) { + const alias = text(summary && summary.alias); + if (!SAFE_ID.test(alias)) return ""; + const command = text(summary && summary.recommendation && summary.recommendation.command); + const doctor = `dyro --workspace ${alias} doctor`; + const yes = "--" + "yes"; + const push = "--" + "push"; + if (workspaceHasFail(summary)) { + if ( + command + && !isBareWorkspaceCommand(command, alias) + && !command.includes(yes) + && !command.includes(push) + ) { + return command; + } + return doctor; + } + if (!command || isBareWorkspaceCommand(command, alias)) return ""; + return command; +} + +function findingLabels(summary) { + return failFindings(summary).map((item) => { + const label = displayLabel(item.reason, ATTENTION_REASON_LABELS); + const line = text(item.line); + return line ? `${line}:${label}` : label; + }); +} + function overviewState(attention, workspaces) { + if (Array.isArray(workspaces) && workspaces.some(workspaceHasFail)) return "需要修复"; if (count(attention && attention.repair_required)) return "需要修复"; if (count(attention && attention.needs_user)) return "等待你的处理"; if (unavailableWorkspaceCount(workspaces)) return "状态不完整"; + if (Array.isArray(workspaces) && workspaces.some((summary) => text(summary.health) === "degraded")) { + return "状态不完整"; + } if (count(attention && attention.ready)) return "有工作可推进"; if (count(attention && attention.waiting)) return "等待外部条件"; if (count(attention && attention.paused)) return "存在已暂停工作"; @@ -424,6 +496,8 @@ function workspaceMatter(summary) { if (text(summary && summary.availability) !== "available") { return "这个项目现在读不到"; } + const fails = findingLabels(summary); + if (fails.length) return fails.join(";"); const reason = text(summary && summary.recommendation && summary.recommendation.reason); if (reason && reason !== "HOME_GUIDANCE") { return displayLabel(reason, ATTENTION_REASON_LABELS); @@ -442,6 +516,7 @@ function needsYouWorkspaces(workspaces) { return workspaces .filter((summary) => { if (text(summary.availability) !== "available") return true; + if (workspaceHasFail(summary)) return true; const attention = summary.attention_counts || {}; return Boolean(count(attention.repair_required) || count(attention.needs_user)); }) @@ -487,7 +562,7 @@ function renderNeedsYou(workspaces, total) { const why = element("p", workspaceMatter(summary)); const actions = element("div"); actions.className = "needs-you-actions"; - const command = text(summary.recommendation && summary.recommendation.command); + const command = recommendedCommand(summary); if (command) { const copy = element("button", "复制命令"); copy.type = "button"; @@ -510,8 +585,7 @@ function renderPrimaryAction(workspaces) { const why = $("primary-why"); const command = $("primary-command"); const button = $("primary-copy"); - const recommendation = summary && summary.recommendation; - const nextCommand = text(recommendation && recommendation.command); + const nextCommand = recommendedCommand(summary); if (!summary || !nextCommand) { guidance.textContent = "下一步 · 还没有可执行的建议"; if (why) why.textContent = "等项目状态可读之后,这里会给出一条可以贴到终端的命令。"; @@ -698,10 +772,10 @@ function renderInventoryList(title, items, describe) { section.append(element("h3", title)); if (!items.length) { section.append(element("p", title === "开发线" - ? "没有开发线。回终端跑 dyro setup 或 line spawn。" + ? "没有开发线。" : title === "任务" - ? "没有任务。回终端建 Task 后再打开这一页。" - : "没有目标。回终端建 Objective 后再看计划。")); + ? "没有任务。" + : "没有目标。")); return section; } const list = element("ul"); @@ -889,7 +963,7 @@ function renderTwinPlan() { section.append(element("h4", "计划")); const plan = state.operatorTwin && Array.isArray(state.operatorTwin.plan) ? state.operatorTwin.plan : []; if (!plan.length) { - section.append(element("p", "没有目标。先在终端建 Objective,再回到这页看计划。")); + section.append(element("p", "没有目标。")); return section; } const lanes = element("div"); @@ -985,7 +1059,7 @@ function renderTwinRunning() { section.append(element("h4", "谁在跑")); const running = state.operatorTwin && Array.isArray(state.operatorTwin.running) ? state.operatorTwin.running : []; if (!running.length) { - section.append(element("p", "没有进行中的任务。要派人,回终端跑 dispatch。")); + section.append(element("p", "没有进行中的任务。")); } else { const list = element("ul"); list.className = "twin-running-list"; @@ -1130,6 +1204,7 @@ function renderWorkspaceAttention(data) { section.append(element("p", "工作区不可读取,关注项未知。")); return section; } + const fails = findingLabels(data && data.workspace); const items = []; for (const objective of Array.isArray(data && data.objectives) ? data.objectives : []) { for (const item of Array.isArray(objective.attention) ? objective.attention : []) { @@ -1137,10 +1212,16 @@ function renderWorkspaceAttention(data) { } } items.sort((left, right) => attentionKindRank(left.item) - attentionKindRank(right.item)); - if (!items.length) { + if (!items.length && !fails.length) { section.append(element("p", "摘要未列出关注项。")); return section; } + if (fails.length) { + const failList = element("ul"); + for (const label of fails) failList.append(element("li", label)); + section.append(failList); + if (!items.length) return section; + } const list = element("ul"); for (const entry of items) list.append(element("li", describeAttentionItem(entry.item, entry.objective))); section.append(list); @@ -1294,7 +1375,7 @@ function dryRunCommands(alias, parent, child) { ]; } -function renderFamilyTree(alias, lines, tasks) { +function renderFamilyTree(alias, lines, tasks, findings) { const section = element("section"); section.className = "live-pane family-pane"; section.id = "family-pane"; @@ -1322,7 +1403,7 @@ function renderFamilyTree(alias, lines, tasks) { button.addEventListener("click", () => { state.familyParent = parent; const tree = $("family-tree"); - if (tree) tree.replaceWith(renderFamilyGraph(alias, lines, parent, tasks)); + if (tree) tree.replaceWith(renderFamilyGraph(alias, lines, parent, tasks, findings)); resetChannelState(); resetArtifactState(); loadChannel(alias, parent).catch((error) => { @@ -1337,11 +1418,20 @@ function renderFamilyTree(alias, lines, tasks) { } section.append(nav); } - section.append(renderFamilyGraph(alias, lines, selected, tasks)); + section.append(renderFamilyGraph(alias, lines, selected, tasks, findings)); return section; } -function familyBadges(id, tasks, unread = 0) { +function lineFailFindings(findings, lineId) { + if (!Array.isArray(findings)) return []; + return findings.filter((item) => { + if (text(item && item.status) !== "FAIL") return false; + const line = text(item.line); + return !line || line === lineId; + }); +} + +function familyBadges(id, tasks, unread = 0, findings = []) { const marks = element("p"); marks.className = "family-badges"; const unreadBadge = element("span", `未读 ${count(unread)}`); @@ -1351,11 +1441,28 @@ function familyBadges(id, tasks, unread = 0) { return marks; } const busy = lineInProgress(tasks, id); - // Git cleanliness and origin binding are not inspected. Unread is overlay-only. - for (const label of ["未检查", "未检查", busy ? "进行中" : "空闲"]) { - const badge = element("span", label); - badge.className = "family-badge"; - marks.append(badge); + const fails = lineFailFindings(findings, id); + if (fails.length) { + const seen = new Set(); + for (const item of fails) { + const label = displayLabel(item.reason, ATTENTION_REASON_LABELS); + if (seen.has(label)) continue; + seen.add(label); + const badge = element("span", label); + badge.className = "family-badge"; + badge.dataset.level = "danger"; + marks.append(badge); + } + const status = element("span", busy ? "进行中" : "空闲"); + status.className = "family-badge"; + marks.append(status); + } else { + // Git cleanliness and origin binding are not inspected. Unread is overlay-only. + for (const label of ["未检查", "未检查", busy ? "进行中" : "空闲"]) { + const badge = element("span", label); + badge.className = "family-badge"; + marks.append(badge); + } } marks.append(unreadBadge); return marks; @@ -1365,7 +1472,7 @@ function edgeLabel(parent, child, live) { return live ? `${parent} → ${child} · 刚有合入或同步` : `${parent} → ${child}`; } -function renderFamilyJack(id, role, tasks) { +function renderFamilyJack(id, role, tasks, findings) { const jack = element("div"); jack.className = role === "parent" ? "family-jack is-focus" : "family-jack"; jack.dataset.member = id; @@ -1376,22 +1483,22 @@ function renderFamilyJack(id, role, tasks) { role === "parent" ? "父线" : role === "operator" ? "操作者" : "子线", ); label.className = "family-role"; - jack.append(title, label, familyBadges(id, tasks)); + jack.append(title, label, familyBadges(id, tasks, 0, findings)); return jack; } -function renderFamilyGraph(alias, lines, parent, tasks) { +function renderFamilyGraph(alias, lines, parent, tasks, findings) { const wrap = element("div"); wrap.id = "family-tree"; wrap.className = "family-tree family-bay"; const children = familyChildren(lines, parent); const stage = element("div"); stage.className = "family-stage"; - stage.append(renderFamilyJack(parent, "parent", tasks)); + stage.append(renderFamilyJack(parent, "parent", tasks, findings)); const outbound = element("div"); outbound.className = "family-outbound"; if (!children.length) { - outbound.append(element("p", "还没有子线。开子线:把下面的 dry-run 贴到终端。")); + outbound.append(element("p", "还没有子线。")); } for (const id of children) { const run = element("div"); @@ -1401,16 +1508,23 @@ function renderFamilyGraph(alias, lines, parent, tasks) { thread.className = live ? "family-edge live" : "family-edge"; thread.dataset.from = parent; thread.dataset.to = id; - run.append(thread, renderFamilyJack(id, "child", tasks)); + run.append(thread, renderFamilyJack(id, "child", tasks, findings)); outbound.append(run); } - stage.append(outbound, renderFamilyJack("operator", "operator", tasks)); + stage.append(outbound, renderFamilyJack("operator", "operator", tasks, findings)); wrap.append(stage); const actions = element("div"); actions.className = "family-actions"; - const child = children[0] || `${parent}_new`; - for (const command of dryRunCommands(alias, parent, child)) { - actions.append(commandRow(command)); + const child = children[0] || ""; + if (!SAFE_ID.test(child)) { + const note = element("p", "先在终端想好子线名"); + note.className = "family-spawn-disabled"; + note.setAttribute("aria-disabled", "true"); + actions.append(note); + } else { + for (const command of dryRunCommands(alias, parent, child)) { + actions.append(commandRow(command)); + } } wrap.append(element("p", "复制区只有 dry-run。页面不会执行 spawn、合入或同步。")); wrap.append(actions); @@ -2145,6 +2259,29 @@ async function refreshFamilyUnread(alias, parent) { } } +function applyLiveTab(root, tab) { + const id = LIVE_TABS.includes(tab) ? tab : "family"; + state.detailTab = id; + if (!root) return id; + root.dataset.tab = id; + for (const pane of root.querySelectorAll ? root.querySelectorAll(".live-pane") : []) { + const paneId = text(pane.id); + const match = paneId === LIVE_PANE_IDS[id]; + pane.hidden = !match; + if (match) pane.removeAttribute("hidden"); + else pane.setAttribute("hidden", ""); + } + const tabs = root.querySelector ? root.querySelector(".live-tabs") : null; + const buttons = tabs && tabs.querySelectorAll ? tabs.querySelectorAll("button[data-tab]") : []; + for (const item of buttons) { + const selected = text(item.dataset.tab) === id; + item.setAttribute("aria-selected", selected ? "true" : "false"); + if (selected) item.setAttribute("aria-current", "true"); + else item.removeAttribute("aria-current"); + } + return id; +} + function renderLivePanes(alias, data) { const root = element("div"); root.className = "live-panes"; @@ -2152,27 +2289,33 @@ function renderLivePanes(alias, data) { const tabs = element("div"); tabs.className = "live-tabs"; tabs.setAttribute("role", "tablist"); + tabs.setAttribute("aria-label", "工作区面板"); for (const [id, label] of [["family", "家族"], ["events", "事件"], ["channel", "频道"]]) { const button = element("button", label); button.type = "button"; button.dataset.tab = id; - if (state.detailTab === id) button.setAttribute("aria-current", "true"); + button.setAttribute("role", "tab"); + button.setAttribute("aria-controls", LIVE_PANE_IDS[id]); button.addEventListener("click", () => { - state.detailTab = id; + applyLiveTab(root, id); setWorkspaceRoute(alias, id); - root.dataset.tab = id; - for (const item of tabs.querySelectorAll("button")) { - if (text(item.dataset.tab) === id) item.setAttribute("aria-current", "true"); - else item.removeAttribute("aria-current"); - } }); tabs.append(button); } - root.dataset.tab = state.detailTab; root.append(tabs); const lines = Array.isArray(data && data.lines) ? data.lines : []; const tasks = Array.isArray(data && data.tasks) ? data.tasks : []; - root.append(renderFamilyTree(alias, lines, tasks), renderEventPane(), renderChannelPane(alias)); + const findings = Array.isArray(data && data.workspace && data.workspace.findings) + ? data.workspace.findings + : []; + const family = renderFamilyTree(alias, lines, tasks, findings); + const events = renderEventPane(); + const channel = renderChannelPane(alias); + family.setAttribute("role", "tabpanel"); + events.setAttribute("role", "tabpanel"); + channel.setAttribute("role", "tabpanel"); + root.append(family, events, channel); + applyLiveTab(root, state.detailTab || "family"); return root; } @@ -2186,7 +2329,7 @@ function resetEventState() { resetArtifactState(); } -async function loadWorkspace(alias, silent = false) { +async function loadWorkspace(alias, silent = false, force = false) { if (!SAFE_ID.test(alias)) return; if (state.detailAlias && state.detailAlias !== alias) { resetEventState(); @@ -2194,7 +2337,11 @@ async function loadWorkspace(alias, silent = false) { resetChannelState(); } try { - const payload = await request(`/api/v1/workspaces/${encodeURIComponent(alias)}`, `workspace:${alias}`); + const payload = await request( + `/api/v1/workspaces/${encodeURIComponent(alias)}`, + `workspace:${alias}`, + { force }, + ); if (!payload) return; const summary = payload.data && payload.data.workspace; if (!summary) throw new Error("WORKSPACE_UNAVAILABLE"); @@ -2232,7 +2379,7 @@ async function loadWorkspace(alias, silent = false) { const quiet = element("div"); quiet.className = "workspace-quiet"; quiet.append(grid, renderInventory(payload.data)); - const command = text(summary.recommendation && summary.recommendation.command); + const command = recommendedCommand(summary); if (command) quiet.append(commandRow(command)); quiet.append(await loadProofInspect(alias)); room.append(meta, hero, renderOperatorTwin(payload.data), quiet); @@ -2267,9 +2414,9 @@ function showError(error) { list.append(notice); } -async function refresh({ includeSystem = false } = {}) { +async function refresh({ includeSystem = false, force = false } = {}) { try { - const payload = await request("/api/v1/overview?limit=100", "overview"); + const payload = await request("/api/v1/overview?limit=100", "overview", { force }); if (payload) { renderOverview(payload); state.partial = Boolean(payload.freshness && payload.freshness.partial); @@ -2279,6 +2426,9 @@ async function refresh({ includeSystem = false } = {}) { } else { renderSystem(state.system); } + if (force && state.detailAlias) { + await loadWorkspace(state.detailAlias, true, true); + } setStatus( state.partial ? "已连上本地页面;有些项目还读不到,这页不会改你的文件。" : "已连上本地页面;这页只看状态,不会改你的文件。", false, @@ -2301,7 +2451,7 @@ function scheduleRefresh() { } async function start() { - $("refresh").addEventListener("click", async () => { await refresh({ includeSystem: true }); scheduleRefresh(); }); + $("refresh").addEventListener("click", async () => { await refresh({ includeSystem: true, force: true }); scheduleRefresh(); }); $("primary-copy").addEventListener("click", () => { const button = $("primary-copy"); const command = text(button.dataset.command); @@ -2324,11 +2474,31 @@ async function start() { startEventLive(state.detailAlias).catch(() => {}); } }); + window.addEventListener("hashchange", () => { + const raw = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : ""; + if (!raw.startsWith("w/")) return; + const parts = raw.split("/").filter(Boolean); + const alias = parts[1] || ""; + const tab = parts[2] || "family"; + if (alias !== state.detailAlias) return; + applyLiveTab($("live-panes"), tab); + }); const bootstrap = consumeFragment(); try { - if (bootstrap) await exchange(bootstrap); - else state.bearer = sessionStorage.getItem(TOKEN_KEY) || ""; - if (!state.bearer) throw new Error("SESSION_EXPIRED"); + if (bootstrap) { + try { + await exchange(bootstrap); + } catch (_) { + setStatus(sessionExpiredMessage(), true); + return; + } + } else { + state.bearer = sessionStorage.getItem(TOKEN_KEY) || ""; + } + if (!state.bearer) { + setStatus(sessionMissingMessage(), true); + return; + } const meta = await request("/api/v1/meta", "meta"); if (meta) { const surfaces = meta.data && (meta.data.surfaces || meta.data.capabilities); @@ -2364,3 +2534,28 @@ globalThis.__dyroTwinLive = { emptyTwin, getState: () => state, }; +globalThis.__dyroConsoleTest = { + overviewState, + workspaceMatter, + needsYouWorkspaces, + recommendedCommand, + isBareWorkspaceCommand, + failFindings, + renderOverview, + renderNeedsYou, + renderPrimaryAction, + renderLivePanes, + applyLiveTab, + renderFamilyGraph, + familyBadges, + familyChildren, + dryRunCommands, + request, + refresh, + loadWorkspace, + sessionMissingMessage, + sessionExpiredMessage, + getState: () => state, + LIVE_TABS, + LIVE_PANE_IDS, +}; diff --git a/src/dyro/console/assets/styles.css b/src/dyro/console/assets/styles.css index 47ae53f..b1915a7 100644 --- a/src/dyro/console/assets/styles.css +++ b/src/dyro/console/assets/styles.css @@ -521,17 +521,28 @@ footer { border-top: 1px solid var(--border); color: var(--mute); font-size: .8r .live-panes { display: grid; gap: .7rem; - grid-template-columns: minmax(16rem, 1.55fr) minmax(11rem, .75fr) minmax(12rem, .95fr); + grid-template-columns: 1fr; margin: 0; padding: 0; } -.live-tabs { display: none; } +.live-tabs { + display: flex; + flex-wrap: wrap; + gap: .35rem; + margin-bottom: .65rem; +} +.live-tabs button[aria-selected="true"], +.live-tabs button[aria-current="true"] { + box-shadow: 0 0 0 1px var(--filament); +} .live-pane { background: var(--plate); border: 1px solid var(--border); border-radius: var(--radius-medium); padding: .85rem; } +.live-pane[hidden] { display: none !important; } +.family-spawn-disabled { color: var(--mute); } .live-pane h3 { font-family: var(--display); font-size: 1.05rem; font-weight: 600; margin: 0 0 .45rem; } .live-pane p, .live-pane ul { color: var(--mute); font-size: .82rem; margin: 0; } .family-pane { @@ -669,13 +680,6 @@ footer { border-top: 1px solid var(--border); color: var(--mute); font-size: .8r @media (max-width: 767px) { .live-panes { grid-template-columns: 1fr; } - .live-tabs { display: flex; gap: .35rem; margin-bottom: .65rem; } - .live-panes[data-tab="family"] .event-pane, - .live-panes[data-tab="family"] .channel-pane, - .live-panes[data-tab="events"] .family-pane, - .live-panes[data-tab="events"] .channel-pane, - .live-panes[data-tab="channel"] .family-pane, - .live-panes[data-tab="channel"] .event-pane { display: none; } .family-stage { grid-template-columns: 1fr; perspective: none; } .family-jack.is-focus, .family-jack[data-role="child"], diff --git a/src/dyro/console/inspection.py b/src/dyro/console/inspection.py index 93065cc..a19c728 100644 --- a/src/dyro/console/inspection.py +++ b/src/dyro/console/inspection.py @@ -87,10 +87,25 @@ "task_status_counts", "attention_counts", "recommendation", + "findings", "snapshot_sha256", "proof_inspection", } ) +_FINDING_KEYS = frozenset({"status", "reason", "line"}) +_FINDING_REASONS = frozenset( + { + "MISSING_ORIGIN", + "MISSING_WORKTREE", + "BRANCH_MISMATCH", + "REPOSITORY_UNAVAILABLE", + "UPSTREAM_MISMATCH", + "COMMON_DIR", + "SYMLINK", + "EXTERNAL_POLICY", + "DOCTOR_FAIL", + } +) class IsolatedOverviewService: @@ -780,6 +795,7 @@ def _validate_summary(cls, value: object) -> None: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") if value.get("proof_inspection") != "not_inspected": raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + cls._validate_findings(value.get("findings")) @classmethod def _validate_inventory(cls, data: dict[str, object]) -> None: @@ -1103,11 +1119,26 @@ def _safe_command(command: object, alias: object) -> bool: escaped_alias = re.escape(alias) return bool( re.fullmatch( - rf"dyro --workspace {escaped_alias}(?: (?:doctor|objective (?:explain|tick|attention) [A-Za-z0-9][A-Za-z0-9._-]{{0,79}}))?", + rf"dyro --workspace {escaped_alias} (?:doctor|objective (?:explain|tick|attention) [A-Za-z0-9][A-Za-z0-9._-]{{0,79}})", command, ) ) + @classmethod + def _validate_findings(cls, value: object) -> None: + if not isinstance(value, list) or len(value) > 32: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + for item in value: + if not isinstance(item, dict) or set(item) != _FINDING_KEYS: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if item.get("status") != "FAIL": + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if item.get("reason") not in _FINDING_REASONS: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + line = item.get("line") + if line != "" and not cls._safe_alias(line): + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + @staticmethod def _validate_attention_counts(value: object) -> None: if not isinstance(value, dict) or set(value) != _ATTENTION_KINDS: diff --git a/src/dyro/console/launcher.py b/src/dyro/console/launcher.py index 3369c03..9bc7b42 100644 --- a/src/dyro/console/launcher.py +++ b/src/dyro/console/launcher.py @@ -103,22 +103,22 @@ def launch_console( try: url = _bootstrap_url(server, initial_workspace) if no_open: - print("Console 已就绪。以下 URL 单次可用,并在 60 秒后失效:") - print(url) + print("Console 已就绪。以下 URL 单次可用,并在 60 秒后失效:", flush=True) + print(url, flush=True) else: try: opened = browser_open(url) except Exception: opened = False if opened is False: - print("无法自动打开浏览器。以下 URL 单次可用,并在 60 秒后失效:") - print(url) + print("无法自动打开浏览器。以下 URL 单次可用,并在 60 秒后失效:", flush=True) + print(url, flush=True) else: - print(f"Console 已在 {server.origin} 打开;按 Ctrl-C 停止。") + print(f"Console 已在 {server.origin} 打开;按 Ctrl-C 停止。", flush=True) try: serve(server) except KeyboardInterrupt: - print("Console 已停止。") + print("Console 已停止。", flush=True) finally: try: server.shutdown() diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index 2284eeb..e2138fa 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -31,6 +31,7 @@ capture_workspace_read_snapshot, inspect_workspace_read_snapshot, ) +from ..workspace import doctor as load_doctor_findings, is_missing_origin_finding from .models import ConsoleEnvelope from .read_model import proof_inspect_data, workspace_envelope from .redaction import REDACTED, safe_id, safe_title @@ -48,6 +49,15 @@ "paused": 3, "waiting": 4, } +_FINDING_LIMIT = 32 +_LINE_FINDING = re.compile( + r"^FAIL (?:line|hotfix):([A-Za-z0-9][A-Za-z0-9._-]{0,79})/" +) +_CONSOLE_COMMAND = re.compile( + r"^dyro --workspace ([A-Za-z0-9][A-Za-z0-9._-]{0,79}) " + r"(?:doctor|objective (?:explain|tick|attention) " + r"[A-Za-z0-9][A-Za-z0-9._-]{0,79})$" +) class ConsoleOverviewError(Exception): @@ -89,6 +99,76 @@ def _empty_inventory() -> dict[str, list[dict[str, object]]]: return {"lines": [], "tasks": [], "objectives": []} +def _fail_findings(summary: object) -> list[dict[str, str]]: + if not isinstance(summary, dict): + return [] + raw = summary.get("findings") + if not isinstance(raw, list): + return [] + return [ + { + "status": _safe_code(item.get("status")), + "reason": _safe_code(item.get("reason")), + "line": _safe_code(item.get("line")) if item.get("line") else "", + } + for item in raw + if isinstance(item, dict) and _safe_code(item.get("status")) == "FAIL" + ] + + +def _console_command(command: object, alias: str) -> str: + """Allowlisted read command. Never a bare workspace invocation or --yes.""" + if not isinstance(command, str) or not isinstance(alias, str): + return "" + if "--yes" in command or "--push" in command: + return "" + if not _CONSOLE_COMMAND.fullmatch(command): + return "" + expected = f"dyro --workspace {alias} " + return command if command.startswith(expected) else "" + + +def _project_doctor_findings(raw: object) -> list[dict[str, str]]: + """Path-free FAIL rows. PASS/WARN stay out; messages never carry paths.""" + if not isinstance(raw, list): + return [] + projected: list[dict[str, str]] = [] + seen: set[tuple[str, str, str]] = set() + for item in raw: + if not isinstance(item, str) or not item.startswith("FAIL"): + continue + if is_missing_origin_finding(item): + reason = "MISSING_ORIGIN" + elif ": missing worktree" in item: + reason = "MISSING_WORKTREE" + elif ": missing or not Git:" in item: + reason = "REPOSITORY_UNAVAILABLE" + elif "expected upstream" in item: + reason = "UPSTREAM_MISMATCH" + elif ": expected " in item and ", found " in item: + reason = "BRANCH_MISMATCH" + elif "common-dir" in item: + reason = "COMMON_DIR" + elif "symlink" in item or "anchor-reference" in item: + reason = "SYMLINK" + elif "external Profile" in item: + reason = "EXTERNAL_POLICY" + else: + reason = "DOCTOR_FAIL" + match = _LINE_FINDING.search(item) + line = _safe_code(match.group(1)) if match else "" + if line == "REDACTED": + line = "" + key = ("FAIL", reason, line) + if key in seen: + continue + seen.add(key) + projected.append({"status": "FAIL", "reason": reason, "line": line}) + if len(projected) >= _FINDING_LIMIT: + break + return projected + + def _without_proof_decay(items: object) -> list[dict[str, object]]: return [ dict(item) @@ -164,6 +244,7 @@ def __init__( | None = None, update_loader: Callable[[], UpdateState] = load_update_state, version_loader: Callable[[], str] = lambda: __version__, + doctor_loader: Callable[[Config], list[str]] = load_doctor_findings, ) -> None: if cursor_secret is not None and ( not isinstance(cursor_secret, bytes) or len(cursor_secret) < 32 @@ -178,6 +259,7 @@ def __init__( self._summary_loader = summary_loader self._update_loader = update_loader self._version_loader = version_loader + self._doctor_loader = doctor_loader def page( self, @@ -598,6 +680,7 @@ def _capture( "reason": "WORKSPACE_UNAVAILABLE", "command": f"dyro --workspace {safe_alias} doctor", }, + "findings": [], "snapshot_sha256": "", "proof_inspection": "not_inspected", }, @@ -621,8 +704,14 @@ def _capture( status = _safe_code(task.get("status")) task_status_counts[status] = task_status_counts.get(status, 0) + 1 attention = self._workspace_attention(objectives) - partial = freshness.get("state") != "fresh" - health = "degraded" if partial else "healthy" + findings: list[dict[str, str]] = [] + try: + findings = _project_doctor_findings(self._doctor_loader(config)) + except (DyroError, ValidationError, OSError, UnicodeError, TypeError, AttributeError): + warning_codes.add("DOCTOR_UNAVAILABLE") + partial = freshness.get("state") != "fresh" or bool(warning_codes) + fails = [item for item in findings if item.get("status") == "FAIL"] + health = "degraded" if partial or fails else "healthy" summary = { "alias": safe_alias, "display_name": safe_title(getattr(config, "name", workspace.get("name"))), @@ -639,7 +728,10 @@ def _capture( "task_count": len(tasks), "task_status_counts": dict(sorted(task_status_counts.items())), "attention_counts": attention["counts"], - "recommendation": self._recommendation(safe_alias, attention["items"]), + "recommendation": self._recommendation( + safe_alias, attention["items"], findings=findings + ), + "findings": findings, "snapshot_sha256": str(envelope.get("snapshot_sha256", "")), "proof_inspection": "not_inspected", } @@ -681,27 +773,50 @@ def _workspace_attention( return {"counts": counts, "items": items} def _recommendation( - self, alias: str, attention: object + self, + alias: str, + attention: object, + findings: object = None, + commands: object = None, ) -> dict[str, str] | None: + doctor = f"dyro --workspace {alias} doctor" + next_command = "" + if isinstance(commands, list): + for raw in commands: + next_command = _console_command(raw, alias) + if next_command: + break + fails = [ + item + for item in findings + if isinstance(item, dict) and _safe_code(item.get("status")) == "FAIL" + ] if isinstance(findings, list) else [] + if fails: + reason = _safe_code(fails[0].get("reason")) + if reason in {"", "REDACTED"}: + reason = "DOCTOR_FAIL" + return {"reason": reason, "command": next_command or doctor} if not isinstance(attention, list) or not attention: return { "reason": "HOME_GUIDANCE", - "command": f"dyro --workspace {alias}", + "command": next_command or doctor, } item = attention[0] if not isinstance(item, dict): - return None + return {"reason": "HOME_GUIDANCE", "command": next_command or doctor} objective_id = _safe_code(item.get("objective_id")) + follow_up = " ".join( + ( + "dyro", + "--workspace", + alias, + *follow_up_from_kind(_safe_code(item.get("kind")), objective_id), + ) + ) + command = _console_command(follow_up, alias) or next_command or doctor return { "reason": _safe_code(item.get("reason")), - "command": " ".join( - ( - "dyro", - "--workspace", - alias, - *follow_up_from_kind(_safe_code(item.get("kind")), objective_id), - ) - ), + "command": command, } def _attention_counts(self, summaries: list[dict[str, object]]) -> dict[str, int]: @@ -736,6 +851,23 @@ def _highest_priority(self, summaries: list[dict[str, object]]) -> dict[str, str counts = _safe_mapping(summary.get("attention_counts")) if not isinstance(recommendation, dict): continue + fails = _fail_findings(summary) + if fails: + reason = _safe_code(fails[0].get("reason")) + if reason in {"", "REDACTED"}: + reason = "DOCTOR_FAIL" + candidates.append( + ( + 0, + str(summary.get("alias", "")), + { + "alias": _safe_code(summary.get("alias")), + "kind": "repair_required", + "reason": reason, + }, + ) + ) + continue for kind, priority in _ATTENTION_PRIORITY.items(): if counts.get(kind, 0): candidates.append( @@ -757,7 +889,11 @@ def _highest_priority(self, summaries: list[dict[str, object]]) -> dict[str, str @staticmethod def _summary_sort_key(summary: dict[str, object]) -> tuple[int, str]: counts = _safe_mapping(summary.get("attention_counts")) - if summary.get("availability") == "unavailable" or counts.get("repair_required", 0): + if ( + summary.get("availability") == "unavailable" + or counts.get("repair_required", 0) + or _fail_findings(summary) + ): priority = 0 elif counts.get("needs_user", 0): priority = 1 diff --git a/tests/support/console_operator.mjs b/tests/support/console_operator.mjs new file mode 100644 index 0000000..85d73d5 --- /dev/null +++ b/tests/support/console_operator.mjs @@ -0,0 +1,400 @@ +import fs from "node:fs"; +import path from "node:path"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const code = fs.readFileSync( + path.join(rootDir, "src/dyro/console/assets/app.js"), + "utf8", +); + +function collect(node, out = []) { + out.push(node); + for (const child of node.children || []) collect(child, out); + return out; +} + +function matchSelector(node, selector) { + if (selector.startsWith("#")) return node.id === selector.slice(1); + if (selector.startsWith(".")) { + return String(node.className).split(/\s+/).filter(Boolean).includes(selector.slice(1)); + } + const attr = selector.match(/^(\w+)?\[([A-Za-z0-9_-]+)(?:="([^"]*)"|='([^']*)')?\]$/); + if (attr) { + const [, tag, key, double, single] = attr; + const value = double === undefined ? single : double; + if (tag && node.tagName !== tag.toUpperCase()) return false; + if (key.startsWith("data-")) { + const dataKey = key.slice(5); + const current = node.dataset[dataKey]; + return value === undefined ? Boolean(current) : current === value; + } + const current = node.attrs[key]; + return value === undefined ? current != null && current !== "" : current === value; + } + return node.tagName === String(selector).toUpperCase(); +} + +function queryAll(scope, selector) { + const parts = String(selector).trim().split(/\s+/).filter(Boolean); + let current = [scope]; + for (const part of parts) { + const next = []; + for (const node of current) { + for (const child of collect(node).slice(1)) { + if (matchSelector(child, part)) next.push(child); + } + } + current = next; + } + return current; +} + +function collectText(node) { + const bits = []; + const walk = (item) => { + if (!item) return; + if (item.textContent) bits.push(String(item.textContent)); + for (const child of item.children || []) walk(child); + }; + walk(node); + return bits.join("\n"); +} + +function fakeNode(tag) { + const node = { + tagName: String(tag).toUpperCase(), + children: [], + className: "", + id: "", + hidden: false, + type: "", + textContent: "", + disabled: false, + dataset: {}, + attrs: {}, + listeners: {}, + classList: { + add(...names) { + const current = new Set(String(node.className).split(/\s+/).filter(Boolean)); + for (const name of names) current.add(name); + node.className = [...current].join(" "); + }, + toggle(name, force) { + const current = new Set(String(node.className).split(/\s+/).filter(Boolean)); + const on = force === undefined ? !current.has(name) : Boolean(force); + if (on) current.add(name); + else current.delete(name); + node.className = [...current].join(" "); + }, + }, + get firstChild() { + return node.children[0] || null; + }, + get lastChild() { + return node.children.length ? node.children[node.children.length - 1] : null; + }, + append(...items) { + for (const item of items) node.children.push(item); + }, + addEventListener(type, fn) { + node.listeners[type] = node.listeners[type] || []; + node.listeners[type].push(fn); + }, + click() { + for (const fn of node.listeners.click || []) fn({ target: node }); + }, + replaceWith() {}, + replaceChildren(...items) { + node.children = items.slice(); + }, + setAttribute(name, value) { + node.attrs[name] = String(value); + if (name === "hidden") node.hidden = true; + if (name.startsWith("data-")) node.dataset[name.slice(5)] = String(value); + }, + removeAttribute(name) { + delete node.attrs[name]; + if (name === "hidden") node.hidden = false; + if (name.startsWith("data-")) delete node.dataset[name.slice(5)]; + }, + getAttribute(name) { + if (name === "hidden") return node.hidden ? "" : null; + return Object.prototype.hasOwnProperty.call(node.attrs, name) ? node.attrs[name] : null; + }, + querySelector(selector) { + return queryAll(node, selector)[0] || null; + }, + querySelectorAll(selector) { + return queryAll(node, selector); + }, + focus() {}, + }; + return node; +} + +const nodesById = new Map(); +function seed(id) { + const node = fakeNode("div"); + node.id = id; + nodesById.set(id, node); + return node; +} + +for (const id of [ + "overview-heading", + "overview-summary", + "captured-at", + "attention-counts", + "needs-you", + "primary-guidance", + "primary-why", + "primary-command", + "primary-copy", + "task-status-counts", + "workspace-list", + "session-status", + "system-panel", + "system-note", + "system-update", +]) { + seed(id); +} + +const fetchCalls = []; +let fetchImpl = async () => ({ + ok: false, + status: 404, + headers: { get: () => null }, + json: async () => null, +}); + +const context = vm.createContext({ + console, + Set, + Map, + JSON, + Date, + Number, + Boolean, + String, + Array, + Object, + Math, + Error, + TextDecoder, + URLSearchParams, + document: { + hidden: false, + getElementById: (id) => nodesById.get(id) || null, + createElement: (name) => fakeNode(name), + createTextNode: (value) => { + const node = fakeNode("#text"); + node.textContent = String(value); + return node; + }, + addEventListener() {}, + }, + window: { + location: { hash: "", pathname: "/", search: "" }, + history: { replaceState() {} }, + setTimeout() {}, + clearTimeout() {}, + addEventListener() {}, + }, + sessionStorage: { + getItem() { + return null; + }, + setItem() {}, + removeItem() {}, + }, + fetch: async (path, init = {}) => { + fetchCalls.push({ path, headers: { ...(init.headers || {}) }, method: init.method || "GET" }); + return fetchImpl(path, init); + }, + navigator: { clipboard: { writeText: async () => {} } }, +}); +context.globalThis = context; +vm.runInContext(code, context); + +const api = context.__dyroConsoleTest; +if (!api || typeof api.renderLivePanes !== "function") { + throw new Error("console operator test API is not loaded"); +} + +function emptyAttention() { + return { + repair_required: 0, + needs_user: 0, + ready: 0, + paused: 0, + waiting: 0, + }; +} + +function visiblePaneIds(root) { + return (root.querySelectorAll(".live-pane") || []) + .filter((pane) => !pane.hidden) + .map((pane) => pane.id); +} + +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const action = input.action; +let result = {}; + +if (action === "fail_overview") { + api.renderOverview({ + captured_at: "2026-08-21T07:00:00Z", + freshness: { partial: false, warnings: [] }, + data: { + total_workspaces: 1, + attention_counts: emptyAttention(), + task_status_counts: {}, + workspaces: [ + { + alias: "core", + display_name: "core", + availability: "available", + health: "degraded", + findings: [ + { status: "FAIL", reason: "MISSING_ORIGIN", line: "core" }, + { status: "FAIL", reason: "MISSING_ORIGIN", line: "core_pay" }, + { status: "FAIL", reason: "MISSING_ORIGIN", line: "release_a" }, + ], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core" }, + attention_counts: emptyAttention(), + }, + ], + }, + }); + result = { + heading: nodesById.get("overview-heading").textContent, + primary: nodesById.get("primary-command").textContent, + command: nodesById.get("primary-copy").dataset.command, + needsYou: collectText(nodesById.get("needs-you")), + }; +} else if (action === "tabs") { + const live = api.renderLivePanes("core", { + workspace: { findings: [] }, + lines: [ + { id: "core", parent: "" }, + { id: "core_pay", parent: "core" }, + ], + tasks: [], + }); + const tablist = live.querySelector('[role="tablist"]'); + const buttons = tablist ? tablist.querySelectorAll("button[data-tab]") : []; + const seen = []; + for (const button of buttons) { + button.click(); + seen.push({ + tab: button.dataset.tab, + hashTab: api.getState().detailTab, + visible: visiblePaneIds(live), + }); + } + result = { + tabs: buttons.map((button) => button.dataset.tab), + switches: seen, + }; +} else if (action === "spawn") { + const empty = api.renderFamilyGraph("core", [{ id: "core", parent: "" }], "core", [], []); + const withChild = api.renderFamilyGraph( + "core", + [ + { id: "core", parent: "" }, + { id: "core_pay", parent: "core" }, + ], + "core", + [], + [], + ); + result = { + empty: collectText(empty), + withChild: collectText(withChild), + }; +} else if (action === "badges") { + const fail = api.familyBadges("core", [], 0, [ + { status: "FAIL", reason: "MISSING_ORIGIN", line: "core" }, + ]); + const unknown = api.familyBadges("core_pay", [], 0, [ + { status: "FAIL", reason: "MISSING_ORIGIN", line: "core" }, + ]); + result = { + fail: collectText(fail), + unknown: collectText(unknown), + }; +} else if (action === "refresh") { + api.getState().bearer = "token"; + api.getState().etags.set("overview", '"old-etag"'); + fetchImpl = async () => ({ + ok: true, + status: 200, + headers: { get: (name) => (name === "ETag" ? '"new-etag"' : null) }, + json: async () => ({ captured_at: "2026-08-21T08:00:00Z" }), + }); + await api.request("/api/v1/overview?limit=100", "overview"); + const cached = { ...fetchCalls[0] }; + fetchCalls.length = 0; + await api.request("/api/v1/overview?limit=100", "overview", { force: true }); + const forced = { ...fetchCalls[0] }; + api.renderOverview({ + captured_at: "2026-08-21T07:00:00Z", + data: { + total_workspaces: 1, + attention_counts: emptyAttention(), + task_status_counts: {}, + workspaces: [ + { + alias: "core", + display_name: "core", + availability: "available", + health: "healthy", + findings: [], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, + attention_counts: emptyAttention(), + }, + ], + }, + }); + const before = nodesById.get("captured-at").textContent; + api.renderOverview({ + captured_at: "2026-08-21T08:01:00Z", + data: { + total_workspaces: 1, + attention_counts: emptyAttention(), + task_status_counts: {}, + workspaces: [ + { + alias: "core", + display_name: "core", + availability: "available", + health: "healthy", + findings: [], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, + attention_counts: emptyAttention(), + }, + ], + }, + }); + result = { + cachedHasMatch: Boolean(cached.headers["If-None-Match"]), + forcedHasMatch: Boolean(forced.headers["If-None-Match"]), + before, + after: nodesById.get("captured-at").textContent, + }; +} else if (action === "session") { + result = { + missing: api.sessionMissingMessage(), + expired: api.sessionExpiredMessage(), + }; +} else if (action === "empty") { + const graph = api.renderFamilyGraph("core", [], "core", [], []); + result = { text: collectText(graph) }; +} else { + throw new Error(`unknown action ${action}`); +} + +process.stdout.write(JSON.stringify(result)); diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py index a2740bc..6a38b40 100644 --- a/tests/test_console_assets.py +++ b/tests/test_console_assets.py @@ -155,6 +155,11 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: self.assertIn("document.hidden".encode(), script.body) self.assertIn("刚有合入或同步".encode(), script.body) self.assertIn("未检查".encode(), script.body) + self.assertIn("先在终端想好子线名".encode(), script.body) + self.assertIn("pane.hidden".encode(), script.body) + self.assertIn("force: true".encode(), script.body) + self.assertIn("本地会话尚未建立".encode(), script.body) + self.assertNotIn("${parent}_new".encode(), script.body) self.assertNotIn("干净".encode(), script.body) self.assertNotIn("远端已绑定".encode(), script.body) self.assertNotIn(b"fonts.googleapis", script.body) diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index bb363f2..e91d2c6 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -642,6 +642,42 @@ def test_isolated_command_allowlist_rejects_task_next(self) -> None: "dyro --workspace demo task next", "demo" ) ) + self.assertFalse( + IsolatedOverviewService._safe_command("dyro --workspace demo", "demo") + ) + + def test_missing_origin_fail_is_not_ready_or_a_bare_workspace_command(self) -> None: + from dyro.config import load + from dyro.workspace import create_line, spawn_line + + config = load(self.root) + create_line(config, line_id="core", branch="feat/core", base="main") + spawn_line(config, "core", "pay") + create_line( + config, + line_id="release_a", + branch="hotfix/release_a", + base="main", + kind="hotfix", + ) + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + + overview = service.page(limit=1) + card = overview["data"]["workspaces"][0] + reasons = {(item["reason"], item["line"]) for item in card["findings"]} + + self.assertIn(("MISSING_ORIGIN", "core"), reasons) + self.assertIn(("MISSING_ORIGIN", "core_pay"), reasons) + self.assertIn(("MISSING_ORIGIN", "release_a"), reasons) + self.assertEqual(card["recommendation"]["command"], "dyro --workspace demo doctor") + self.assertNotEqual(card["recommendation"]["command"], "dyro --workspace demo") + self.assertEqual(card["health"], "degraded") + self.assertNotEqual(card["recommendation"]["reason"], "HOME_GUIDANCE") + self.assertNotIn(str(self.root), repr(overview)) def test_worker_cannot_serve_or_write_artifacts_via_a_mutation_op(self) -> None: from dyro.config import load diff --git a/tests/test_console_launcher.py b/tests/test_console_launcher.py index 9b76427..a87d873 100644 --- a/tests/test_console_launcher.py +++ b/tests/test_console_launcher.py @@ -75,6 +75,30 @@ def test_no_open_prints_the_one_time_fragment_url(self) -> None: self.assertFalse(factory.call_args is None) browser.assert_not_called() + def test_no_open_flushes_the_one_time_url_so_a_pipe_still_sees_it(self) -> None: + printed: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def capture(*args: object, **kwargs: object) -> None: + printed.append((args, kwargs)) + + server = _Server() + with patch("builtins.print", side_effect=capture): + launch_console( + port=0, + no_open=True, + browser_open=Mock(return_value=True), + server_factory=Mock(return_value=server), + serve=lambda _: None, + ) + + url_calls = [ + kwargs + for args, kwargs in printed + if args and isinstance(args[0], str) and "#bootstrap=" in args[0] + ] + self.assertTrue(url_calls) + self.assertTrue(all(item.get("flush") is True for item in url_calls)) + def test_failed_browser_open_prints_manual_recovery_url(self) -> None: _, output, _, _ = self._launch(browser_result=False) diff --git a/tests/test_console_operator.py b/tests/test_console_operator.py new file mode 100644 index 0000000..e0681fd --- /dev/null +++ b/tests/test_console_operator.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + + +HARNESS = Path(__file__).resolve().parent / "support" / "console_operator.mjs" + + +def _run(action: str) -> dict[str, object]: + node = shutil.which("node") + if not node: + raise AssertionError("node is required to exercise the console operator surface") + completed = subprocess.run( + [node, str(HARNESS)], + input=json.dumps({"action": action}), + text=True, + capture_output=True, + check=False, + ) + if completed.returncode != 0: + raise AssertionError(completed.stderr or completed.stdout or "operator harness failed") + return json.loads(completed.stdout) + + +class ConsoleOperatorSurfaceTests(unittest.TestCase): + def test_fail_findings_and_empty_commands_are_not_unknown_or_bare(self) -> None: + result = _run("fail_overview") + + self.assertNotEqual(result["heading"], "关注项未知") + self.assertEqual(result["heading"], "需要修复") + self.assertNotEqual(result["command"], "dyro --workspace core") + self.assertEqual(result["command"], "dyro --workspace core doctor") + self.assertNotIn("摘要未列出关注项", result["needsYou"]) + self.assertIn("core", result["needsYou"]) + self.assertIn("release_a", result["needsYou"]) + + def test_tablist_switch_changes_visible_section_ids(self) -> None: + result = _run("tabs") + + self.assertEqual(result["tabs"], ["family", "events", "channel"]) + visible = {row["tab"]: row["visible"] for row in result["switches"]} + self.assertEqual(visible["family"], ["family-pane"]) + self.assertEqual(visible["events"], ["event-pane"]) + self.assertEqual(visible["channel"], ["channel-pane"]) + self.assertEqual( + [row["hashTab"] for row in result["switches"]], + ["family", "events", "channel"], + ) + + def test_spawn_copy_does_not_invent_a_child_name(self) -> None: + result = _run("spawn") + + self.assertIn("先在终端想好子线名", result["empty"]) + self.assertNotIn("core_new", result["empty"]) + self.assertNotIn("line spawn core core_new", result["empty"]) + self.assertIn("line spawn core core_pay", result["withChild"]) + self.assertIn("--dry-run", result["withChild"]) + self.assertNotIn("--yes", result["withChild"]) + + def test_line_fail_badge_does_not_paint_unknown_pair(self) -> None: + result = _run("badges") + + self.assertIn("远端跟踪分支不存在", result["fail"]) + self.assertNotIn("未检查", result["fail"]) + self.assertIn("未检查", result["unknown"]) + + def test_forced_refresh_omits_etag_and_moves_captured_at(self) -> None: + result = _run("refresh") + + self.assertTrue(result["cachedHasMatch"]) + self.assertFalse(result["forcedHasMatch"]) + self.assertNotEqual(result["before"], result["after"]) + self.assertIn("读取于", result["after"]) + + def test_missing_bootstrap_explains_how_to_open_again(self) -> None: + result = _run("session") + + self.assertIn("尚未建立", result["missing"]) + self.assertIn("dyro console", result["missing"]) + self.assertIn("dyro console", result["expired"]) + self.assertNotIn("正在建立安全本地会话", result["missing"]) + self.assertNotIn("正在建立安全本地会话", result["expired"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index 621971e..7a24ff8 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -29,6 +29,7 @@ def _snapshot( failure_code: str = "OBJECTIVES_UNAVAILABLE", proof_inspection: str = "not_inspected", proofs: tuple[WorkspaceProofObservation, ...] = (), + attention: tuple[ObjectiveAttentionObservation, ...] | None = None, ) -> WorkspaceReadSnapshot: observed_at = datetime(2026, 8, 4, 12, 0, tzinfo=timezone.utc) failures = () @@ -86,11 +87,15 @@ def _snapshot( selected_actions=(), blocked_actions=(), attention=( - ObjectiveAttentionObservation( - kind=attention_kind, - subject_id="TASK-A", - reason=reason, - ), + ( + ObjectiveAttentionObservation( + kind=attention_kind, + subject_id="TASK-A", + reason=reason, + ), + ) + if attention is None + else attention ), contract_sha256="c" * 64, scope_sha256="d" * 64, @@ -143,6 +148,7 @@ def config_loader(root: Path) -> SimpleNamespace: snapshot_loader=lambda config: self.snapshots[config.name], clock=lambda: datetime(2026, 8, 4, 12, 5, tzinfo=timezone.utc), cursor_secret=b"k" * 32, + doctor_loader=lambda config: [], ) def test_paginates_stably_prioritizes_attention_and_never_exposes_roots(self) -> None: @@ -186,13 +192,74 @@ def test_rejects_tampered_or_stale_cursor_without_falling_back_to_an_offset(self with self.assertRaisesRegex(ConsoleOverviewError, "OVERVIEW_CURSOR_INVALID"): self.service.page(cursor=cursor, limit=1) - def test_empty_attention_recommends_the_guided_home_not_task_next(self) -> None: - recommendation = self.service._recommendation("alpha", []) + def test_empty_attention_recommends_doctor_not_a_bare_workspace_invocation(self) -> None: + recommendation = self.service._recommendation("core", []) self.assertEqual( recommendation, - {"reason": "HOME_GUIDANCE", "command": "dyro --workspace alpha"}, + {"reason": "HOME_GUIDANCE", "command": "dyro --workspace core doctor"}, ) + self.assertNotEqual(recommendation["command"], "dyro --workspace core") + + def test_fail_findings_and_empty_commands_recommend_doctor_not_bare_home(self) -> None: + recommendation = self.service._recommendation( + "core", + [], + findings=[ + {"status": "FAIL", "reason": "MISSING_ORIGIN", "line": "core"}, + {"status": "FAIL", "reason": "MISSING_ORIGIN", "line": "release_a"}, + ], + commands=[], + ) + + self.assertEqual(recommendation["command"], "dyro --workspace core doctor") + self.assertNotEqual(recommendation["command"], "dyro --workspace core") + self.assertEqual(recommendation["reason"], "MISSING_ORIGIN") + self.assertNotEqual(recommendation["reason"], "HOME_GUIDANCE") + + def test_fail_findings_project_path_free_and_degrade_health(self) -> None: + self.registry = WorkspaceRegistry( + default="core", + workspaces=(WorkspaceRecord("core", self.alpha_root),), + ) + self.snapshots["Alpha Project"] = _snapshot( + name="Alpha Project", + attention=(), + ) + service = ConsoleOverviewService( + registry_loader=lambda: self.registry, + config_loader=self.service._config_loader, + snapshot_loader=lambda config: self.snapshots[config.name], + clock=self.service._clock, + cursor_secret=b"k" * 32, + doctor_loader=lambda config: [ + "FAIL line:core/api: missing origin/feat/core", + "FAIL line:core_pay/api: missing origin/feat/core_pay", + "FAIL hotfix:release_a/api: missing origin/hotfix/release_a", + "FAIL repository api: missing or not Git: /private/secret", + ], + ) + + page = service.page() + card = page["data"]["workspaces"][0] + + self.assertEqual(card["alias"], "core") + self.assertEqual(card["health"], "degraded") + self.assertEqual( + {(item["reason"], item["line"]) for item in card["findings"]}, + { + ("MISSING_ORIGIN", "core"), + ("MISSING_ORIGIN", "core_pay"), + ("MISSING_ORIGIN", "release_a"), + ("REPOSITORY_UNAVAILABLE", ""), + }, + ) + self.assertEqual(card["recommendation"]["command"], "dyro --workspace core doctor") + self.assertNotEqual(card["recommendation"]["command"], "dyro --workspace core") + self.assertEqual(page["data"]["highest_priority"]["kind"], "repair_required") + self.assertEqual(page["data"]["highest_priority"]["reason"], "MISSING_ORIGIN") + self.assertNotIn("/private", repr(page)) + self.assertNotIn("secret", repr(card["findings"])) def test_attention_recommends_the_same_follow_up_as_next(self) -> None: self.assertEqual( @@ -270,6 +337,7 @@ def test_single_workspace_reuses_the_same_summary_and_rejects_unsafe_aliases(sel page = self.service.page(limit=3) self.assertEqual(payload["data"]["workspace"]["alias"], "alpha") + self.assertEqual(payload["data"]["workspace"]["findings"], []) self.assertEqual(payload["data"]["workspace"]["proof_inspection"], "not_inspected") self.assertEqual(payload["data"]["lines"][0]["id"], "alpha") self.assertEqual(payload["data"]["lines"][0]["parent"], "") @@ -301,6 +369,7 @@ def test_overview_task_status_counts_ignore_unavailable_workspaces(self) -> None snapshot_loader=self.service._snapshot_loader, clock=self.service._clock, cursor_secret=b"k" * 32, + doctor_loader=lambda config: [], ) payload = service.page() @@ -312,6 +381,7 @@ def test_unavailable_workspace_keeps_empty_inventory_keys(self) -> None: payload = self.service.workspace("broken") self.assertEqual(payload["data"]["workspace"]["availability"], "unavailable") + self.assertEqual(payload["data"]["workspace"]["findings"], []) self.assertEqual(payload["data"]["workspace"]["proof_inspection"], "not_inspected") self.assertEqual(payload["data"]["lines"], []) self.assertEqual(payload["data"]["tasks"], []) @@ -347,6 +417,7 @@ def summary_loader(config: object) -> WorkspaceReadSnapshot: inspect_loader=lambda config: inspected, clock=lambda: datetime(2026, 8, 4, 12, 5, tzinfo=timezone.utc), cursor_secret=b"k" * 32, + doctor_loader=lambda config: [], ) leaked = ConsoleOverviewService( registry_loader=lambda: self.registry, @@ -355,6 +426,7 @@ def summary_loader(config: object) -> WorkspaceReadSnapshot: inspect_loader=lambda config: inspected, clock=lambda: datetime(2026, 8, 4, 12, 5, tzinfo=timezone.utc), cursor_secret=b"k" * 32, + doctor_loader=lambda config: [], ) summary = leaked.workspace("alpha") self.assertEqual(summary["data"]["workspace"]["proof_inspection"], "not_inspected") From 54aeabd8dfb90c2cf62d002f4e35480e815e7fff Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 08:19:09 +0000 Subject: [PATCH 2/3] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20keep=20?= =?UTF-8?q?vanished=20registry=20rows=20off=20the=20command=20center?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unscoped overview no longer lets missing roots, including dead /tmp/dyro-test-* aliases, win 现在需要你 or the primary CTA. Timeout and missing-root cards use different copy and sort below a healthy default. Family picker stays on root parents plus focus; empty overlays with lines but no Task/Objective say so in one sentence. Co-authored-by: Dandre Yang --- CHANGELOG.md | 8 ++ src/dyro/console/_inspect_worker.py | 69 +++++----- src/dyro/console/assets.py | 8 +- src/dyro/console/assets/app.js | 190 ++++++++++++++++++++++++---- src/dyro/console/assets/styles.css | 13 +- src/dyro/console/inspection.py | 11 ++ src/dyro/console/overview.py | 130 ++++++++++++++----- tests/support/console_operator.mjs | 150 ++++++++++++++++++++++ tests/test_console_assets.py | 7 + tests/test_console_inspection.py | 118 ++++++++++++++++- tests/test_console_operator.py | 47 +++++++ tests/test_console_overview.py | 99 ++++++++++++++- 12 files changed, 744 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bf305..2c57817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ instead of spinning on session setup. Empty twin/inventory copy is one honest sentence; line badges do not paint 未检查 when a FAIL finding exists for that line. +- Unscoped `dyro console` no longer lets vanished registry rows + (missing roots, including dead `/tmp/dyro-test-*` aliases) win + 现在需要你 or the primary CTA. Those cards fold under 读不到. + Inspection timeout is not painted as “project gone”: missing-root + and read-timeout get different copy and sort below a healthy + default workspace. Family picker defaults to root parents plus the + focused parent. An overlay with lines but no Task/Objective says + so in one sentence instead of only “没有目标”. ## 0.7.9 - 2026-08-21 diff --git a/src/dyro/console/_inspect_worker.py b/src/dyro/console/_inspect_worker.py index f55a139..4700787 100644 --- a/src/dyro/console/_inspect_worker.py +++ b/src/dyro/console/_inspect_worker.py @@ -20,7 +20,15 @@ from ..config import load from ..hub import WorkspaceRecord, WorkspaceRegistry -from .overview import ConsoleOverviewError, ConsoleOverviewService +from .overview import ( + ConsoleOverviewError, + ConsoleOverviewService, + WORKSPACE_MISSING_ROOT, + WORKSPACE_TIMEOUT, + WORKSPACE_UNAVAILABLE, + unavailable_workspace_summary, + workspace_root_missing, +) _CURSOR_SECRET_ENV = "DYRO_CONSOLE_CURSOR_SECRET" @@ -31,34 +39,7 @@ def _unavailable_summary(alias: str, code: str) -> dict[str, object]: - return { - "alias": alias, - "display_name": alias, - "is_default": False, - "availability": "unavailable", - "health": "unavailable", - "freshness": "partial", - "repository_count": 0, - "line_count": 0, - "objective_count": 0, - "active_objective_count": 0, - "task_count": 0, - "task_status_counts": {}, - "attention_counts": { - "repair_required": 0, - "needs_user": 0, - "ready": 0, - "paused": 0, - "waiting": 0, - }, - "recommendation": { - "reason": code, - "command": f"dyro --workspace {alias} doctor", - }, - "findings": [], - "snapshot_sha256": "", - "proof_inspection": "not_inspected", - } + return unavailable_workspace_summary(alias, False, reason=code) def _capture_workspace_summary( @@ -68,6 +49,14 @@ def _capture_workspace_summary( ) -> None: """Capture one workspace only, returning a JSON-safe value through IPC.""" try: + if workspace_root_missing(record.root): + result_queue.put( + { + "summary": _unavailable_summary(record.name, WORKSPACE_MISSING_ROOT), + "warnings": [WORKSPACE_MISSING_ROOT], + } + ) + return registry = WorkspaceRegistry( default=record.name if is_default else "", workspaces=(record,), @@ -80,8 +69,8 @@ def _capture_workspace_summary( except Exception: result_queue.put( { - "summary": _unavailable_summary(record.name, "WORKSPACE_UNAVAILABLE"), - "warnings": ["WORKSPACE_UNAVAILABLE"], + "summary": _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE), + "warnings": [WORKSPACE_UNAVAILABLE], } ) @@ -90,8 +79,8 @@ def _parse_child_result( value: object, record: WorkspaceRecord, *, is_default: bool ) -> tuple[dict[str, object], set[str]]: if not isinstance(value, dict): - return _unavailable_summary(record.name, "WORKSPACE_UNAVAILABLE"), { - "WORKSPACE_UNAVAILABLE" + return _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE), { + WORKSPACE_UNAVAILABLE } summary = value.get("summary") warnings = value.get("warnings") @@ -100,8 +89,8 @@ def _parse_child_result( or not isinstance(warnings, list) or not all(isinstance(item, str) for item in warnings) ): - return _unavailable_summary(record.name, "WORKSPACE_UNAVAILABLE"), { - "WORKSPACE_UNAVAILABLE" + return _unavailable_summary(record.name, WORKSPACE_UNAVAILABLE), { + WORKSPACE_UNAVAILABLE } copied = dict(summary) copied["alias"] = record.name @@ -138,8 +127,8 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None: finish( record, { - "summary": _unavailable_summary(record.name, "WORKSPACE_TIMEOUT"), - "warnings": ["WORKSPACE_TIMEOUT"], + "summary": _unavailable_summary(record.name, WORKSPACE_TIMEOUT), + "warnings": [WORKSPACE_TIMEOUT], }, default=record.name == registry.default, ) @@ -148,8 +137,8 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None: finish( record, { - "summary": _unavailable_summary(record.name, "WORKSPACE_TIMEOUT"), - "warnings": ["WORKSPACE_TIMEOUT"], + "summary": _unavailable_summary(record.name, WORKSPACE_TIMEOUT), + "warnings": [WORKSPACE_TIMEOUT], }, default=record.name == registry.default, ) @@ -189,7 +178,7 @@ def finish(record: WorkspaceRecord, value: object, *, default: bool) -> None: if has_value: finish(record, value, default=record.name == registry.default) else: - code = "WORKSPACE_TIMEOUT" if timed_out else "WORKSPACE_UNAVAILABLE" + code = WORKSPACE_TIMEOUT if timed_out else WORKSPACE_UNAVAILABLE finish( record, { diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index ec2284d..fb736f1 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -27,13 +27,13 @@ class ConsoleAsset: ), "app.js": ( "text/javascript; charset=utf-8", - "80ea029462b4c8231b7d0e1e87b29348aaff6d26e8683d8c6afd13921958ef53", - 91406, + "3750ebe72935668862ee99a032b3576f97a12724f5d24cf2bb212155d5c6a290", + 96631, ), "styles.css": ( "text/css; charset=utf-8", - "6752c01db706e12fdcd768a4974916f9586299e4312e9e01db0f29e762b40f78", - 24441, + "1a95a44a76c6f884b6d839e6b404fc79a8977ea963f2ec65a014410dde1c2947", + 24718, ), } diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index 96f7638..0df1bde 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -28,6 +28,8 @@ const state = { artifactBlobs: new Map(), operatorTwin: null, twinTasks: [], + twinLines: [], + twinObjectives: [], twinAfterSeq: 0, twinOverlayComplete: false, }; @@ -106,6 +108,8 @@ const ATTENTION_REASON_LABELS = { POLICY_DISALLOWS_OPERATION: "当前策略不允许这一步", HOME_GUIDANCE: "可以先打开这个项目看看", WORKSPACE_UNAVAILABLE: "这个项目现在读不到", + WORKSPACE_MISSING_ROOT: "登记还在,工作区目录已经不在了", + WORKSPACE_TIMEOUT: "读取超时,项目还在,只是这次没读完", MISSING_ORIGIN: "远端跟踪分支不存在", MISSING_WORKTREE: "工作树缺失", BRANCH_MISMATCH: "开发线不在约定分支", @@ -242,6 +246,28 @@ function unavailableWorkspaceCount(workspaces) { return workspaces.filter((summary) => text(summary.availability) !== "available").length; } +function materialUnavailableCount(workspaces) { + if (!Array.isArray(workspaces)) return 0; + return workspaces.filter((summary) => { + const reason = unavailableReason(summary); + return reason === "other" || reason === "read_timeout"; + }).length; +} + +function describeOverviewMix(workspaces, total) { + const ghosts = workspaces.filter(isMissingRootWorkspace).length; + const timeouts = workspaces.filter(isTimedOutWorkspace).length; + const other = workspaces.filter((summary) => unavailableReason(summary) === "other").length; + const parts = [`${total} 个本地项目`]; + if (ghosts) parts.push(`${ghosts} 个登记目录已经不在了`); + if (timeouts) parts.push(`${timeouts} 个这次读取超时`); + if (other) parts.push(`${other} 个现在读不到`); + if (parts.length === 1) { + return `${total} 个本地项目。先看需要你的事,再把命令贴到终端。`; + } + return `${parts.join("。")}。下面先列出需要你处理的事。`; +} + function displayLabel(value, labels) { const raw = text(value); return labels[raw] || raw || "未提供"; @@ -426,12 +452,36 @@ function workspaceAttention(summary) { return 5; } +function unavailableReason(summary) { + if (text(summary && summary.availability) === "available") return ""; + const field = text(summary && summary.unavailable_reason); + if (field === "missing_root" || field === "read_timeout" || field === "other") return field; + const reason = text(summary && summary.recommendation && summary.recommendation.reason); + if (reason === "WORKSPACE_MISSING_ROOT") return "missing_root"; + if (reason === "WORKSPACE_TIMEOUT") return "read_timeout"; + if (text(summary && summary.availability) !== "available") return "other"; + return ""; +} + +function isMissingRootWorkspace(summary) { + return unavailableReason(summary) === "missing_root"; +} + +function isTimedOutWorkspace(summary) { + return unavailableReason(summary) === "read_timeout"; +} + +function commandCenterCandidate(summary) { + if (!summary || text(summary.availability) !== "available") return false; + return Boolean(recommendedCommand(summary)); +} + function priorityWorkspace(workspaces) { const focused = workspaces.find((summary) => text(summary.alias) === state.focus); - if (focused && text(focused.recommendation && focused.recommendation.command)) return focused; + if (commandCenterCandidate(focused)) return focused; return [...workspaces] .sort((left, right) => workspaceAttention(left) - workspaceAttention(right)) - .find((summary) => text(summary.recommendation && summary.recommendation.command)); + .find(commandCenterCandidate); } function failFindings(summary) { @@ -450,6 +500,8 @@ function isBareWorkspaceCommand(command, alias) { function recommendedCommand(summary) { const alias = text(summary && summary.alias); if (!SAFE_ID.test(alias)) return ""; + const unread = unavailableReason(summary); + if (unread === "missing_root" || unread === "read_timeout") return ""; const command = text(summary && summary.recommendation && summary.recommendation.command); const doctor = `dyro --workspace ${alias} doctor`; const yes = "--" + "yes"; @@ -481,7 +533,7 @@ function overviewState(attention, workspaces) { if (Array.isArray(workspaces) && workspaces.some(workspaceHasFail)) return "需要修复"; if (count(attention && attention.repair_required)) return "需要修复"; if (count(attention && attention.needs_user)) return "等待你的处理"; - if (unavailableWorkspaceCount(workspaces)) return "状态不完整"; + if (materialUnavailableCount(workspaces)) return "状态不完整"; if (Array.isArray(workspaces) && workspaces.some((summary) => text(summary.health) === "degraded")) { return "状态不完整"; } @@ -492,7 +544,17 @@ function overviewState(attention, workspaces) { return "状态不完整"; } +function workspaceHealthLabel(summary) { + const reason = unavailableReason(summary); + if (reason === "read_timeout") return "读取未完成"; + if (reason === "missing_root") return "目录不在了"; + return displayLabel(summary && summary.health, HEALTH_LABELS); +} + function workspaceMatter(summary) { + const unread = unavailableReason(summary); + if (unread === "missing_root") return "登记还在,工作区目录已经不在了"; + if (unread === "read_timeout") return "读取超时,项目还在,只是这次没读完"; if (text(summary && summary.availability) !== "available") { return "这个项目现在读不到"; } @@ -515,7 +577,7 @@ function needsYouWorkspaces(workspaces) { if (!Array.isArray(workspaces)) return []; return workspaces .filter((summary) => { - if (text(summary.availability) !== "available") return true; + if (text(summary.availability) !== "available") return false; if (workspaceHasFail(summary)) return true; const attention = summary.attention_counts || {}; return Boolean(count(attention.repair_required) || count(attention.needs_user)); @@ -704,7 +766,7 @@ function renderWorkspaceCard(summary) { health.className = "workspace-signal"; health.append(element("span", "状态")); health.firstChild.className = "workspace-signal-label"; - addBadge(health, displayLabel(summary.health, HEALTH_LABELS), attentionLevel(summary)); + addBadge(health, workspaceHealthLabel(summary), attentionLevel(summary)); card.append(health); const matter = element("div"); @@ -739,12 +801,9 @@ function renderOverview(payload) { if (!data || !Array.isArray(data.workspaces)) throw new Error("OVERVIEW_UNAVAILABLE"); const total = count(data.total_workspaces); const attention = data.attention_counts || {}; - const unavailable = unavailableWorkspaceCount(data.workspaces); $("overview-heading").textContent = total ? overviewState(attention, data.workspaces) : "尚未登记工作区"; $("overview-summary").textContent = total - ? unavailable - ? `${total} 个项目里有 ${unavailable} 个现在读不到。下面先列出需要你处理的事。` - : `${total} 个本地项目。先看需要你的事,再把命令贴到终端。` + ? describeOverviewMix(data.workspaces, total) : "还没有登记项目。可运行 dyro setup、dyro join 或 dyro workspace add。"; $("captured-at").textContent = text(payload.captured_at) ? `读取于 ${new Date(text(payload.captured_at)).toLocaleString("zh-CN")}` : ""; renderCounts(data.attention_counts || {}); @@ -759,7 +818,16 @@ function renderOverview(payload) { list.append(empty); return; } - for (const summary of data.workspaces) list.append(renderWorkspaceCard(summary)); + const readable = data.workspaces.filter((summary) => text(summary.availability) === "available"); + const unread = data.workspaces.filter((summary) => text(summary.availability) !== "available"); + for (const summary of readable) list.append(renderWorkspaceCard(summary)); + if (unread.length) { + const fold = element("details"); + fold.className = "unread-workspaces"; + fold.append(element("summary", `读不到 · ${unread.length} 个`)); + for (const summary of unread) fold.append(renderWorkspaceCard(summary)); + list.append(fold); + } } function hasSurface(name) { @@ -795,6 +863,10 @@ function renderInventory(data) { const lines = Array.isArray(data && data.lines) ? data.lines : []; const tasks = Array.isArray(data && data.tasks) ? data.tasks : []; const objectives = Array.isArray(data && data.objectives) ? data.objectives : []; + if (lines.length && !tasks.length && !objectives.length) { + root.append(element("p", emptyOverlayCopy(lines.length))); + return root; + } root.append( renderInventoryList("开发线", lines, (line) => { const kind = displayLabel(line.kind, LINE_KIND_LABELS); @@ -1104,13 +1176,40 @@ function renderTwinRunning() { return section; } +function emptyOverlayCopy(lineCount) { + return `这条 overlay 有 ${lineCount} 条线,还没有 Task / Objective;空的计划 / 谁在跑是预期的。`; +} + +function twinHasWork(twin) { + if (!twin) return false; + if (Array.isArray(twin.plan) && twin.plan.length) return true; + if (Array.isArray(twin.running) && twin.running.length) return true; + if (Array.isArray(twin.phases)) { + return twin.phases.some((column) => Array.isArray(column.tasks) && column.tasks.length); + } + return false; +} + +function overlayHasLinesWithoutWork() { + return Boolean( + state.twinLines.length + && !state.twinTasks.length + && !state.twinObjectives.length + && !twinHasWork(state.operatorTwin) + ); +} + function buildOperatorTwin() { const section = element("section"); section.className = "operator-twin"; section.id = "operator-twin"; section.append(element("h3", "这一家现在怎样")); section.append(element("p", "计划、里程碑、阶段和谁在跑。页面不另造 backlog,也不改任务。")); - section.append(renderTwinPlan(), renderTwinPhases(), renderTwinRunning()); + if (overlayHasLinesWithoutWork()) { + section.append(element("p", emptyOverlayCopy(state.twinLines.length))); + } else { + section.append(renderTwinPlan(), renderTwinPhases(), renderTwinRunning()); + } const summary = element("div"); summary.id = "twin-task-summary"; summary.className = "twin-task-summary"; @@ -1122,6 +1221,8 @@ function buildOperatorTwin() { function renderOperatorTwin(data) { state.operatorTwin = twinFromData(data); state.twinTasks = Array.isArray(data && data.tasks) ? data.tasks : []; + state.twinLines = Array.isArray(data && data.lines) ? data.lines : []; + state.twinObjectives = Array.isArray(data && data.objectives) ? data.objectives : []; state.twinAfterSeq = state.operatorTwin.projected_seq; state.twinOverlayComplete = state.operatorTwin.overlay_complete === true; return buildOperatorTwin(); @@ -1201,7 +1302,14 @@ function renderWorkspaceAttention(data) { section.append(element("h3", "需要关注")); const available = text(data && data.workspace && data.workspace.availability) === "available"; if (!available) { - section.append(element("p", "工作区不可读取,关注项未知。")); + const reason = unavailableReason(data && data.workspace); + if (reason === "read_timeout") { + section.append(element("p", "这次读取超时,关注项还没读到。项目还在。")); + } else if (reason === "missing_root") { + section.append(element("p", "登记还在,工作区目录已经不在了。")); + } else { + section.append(element("p", "工作区不可读取,关注项未知。")); + } return section; } const fails = findingLabels(data && data.workspace); @@ -1360,7 +1468,40 @@ function familyChildren(lines, parentId) { } function familyParents(lines) { - return lines.map((line) => text(line.id)).filter((id) => SAFE_ID.test(id)); + const ids = []; + const seen = new Set(); + for (const line of Array.isArray(lines) ? lines : []) { + const id = text(line && line.id); + if (!SAFE_ID.test(id) || text(line && line.parent) || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + const focused = text(state.familyParent); + if (focused && SAFE_ID.test(focused) && !seen.has(focused)) { + const exists = (Array.isArray(lines) ? lines : []).some((line) => text(line && line.id) === focused); + if (exists) ids.push(focused); + } + return ids; +} + +function selectFamilyParent(alias, lines, parent, tasks, findings) { + state.familyParent = parent; + const pane = $("family-pane"); + if (pane) { + pane.replaceWith(renderFamilyTree(alias, lines, tasks, findings)); + } else { + const tree = $("family-tree"); + if (tree) tree.replaceWith(renderFamilyGraph(alias, lines, parent, tasks, findings)); + } + resetChannelState(); + resetArtifactState(); + loadChannel(alias, parent).catch((error) => { + if (error && error.message === "SESSION_EXPIRED") expireSession(); + }); + loadArtifacts(alias, parent).catch((error) => { + if (error && error.message === "SESSION_EXPIRED") expireSession(); + }); + refreshFamilyUnread(alias, parent).catch(() => {}); } function lineInProgress(tasks, lineId) { @@ -1401,18 +1542,7 @@ function renderFamilyTree(alias, lines, tasks, findings) { button.className = "secondary"; if (parent === selected) button.setAttribute("aria-current", "true"); button.addEventListener("click", () => { - state.familyParent = parent; - const tree = $("family-tree"); - if (tree) tree.replaceWith(renderFamilyGraph(alias, lines, parent, tasks, findings)); - resetChannelState(); - resetArtifactState(); - loadChannel(alias, parent).catch((error) => { - if (error && error.message === "SESSION_EXPIRED") expireSession(); - }); - loadArtifacts(alias, parent).catch((error) => { - if (error && error.message === "SESSION_EXPIRED") expireSession(); - }); - refreshFamilyUnread(alias, parent).catch(() => {}); + selectFamilyParent(alias, lines, parent, tasks, findings); }); nav.append(button); } @@ -1508,7 +1638,11 @@ function renderFamilyGraph(alias, lines, parent, tasks, findings) { thread.className = live ? "family-edge live" : "family-edge"; thread.dataset.from = parent; thread.dataset.to = id; - run.append(thread, renderFamilyJack(id, "child", tasks, findings)); + const jack = renderFamilyJack(id, "child", tasks, findings); + jack.addEventListener("click", () => { + selectFamilyParent(alias, lines, id, tasks, findings); + }); + run.append(thread, jack); outbound.append(run); } stage.append(outbound, renderFamilyJack("operator", "operator", tasks, findings)); @@ -2547,9 +2681,13 @@ globalThis.__dyroConsoleTest = { renderLivePanes, applyLiveTab, renderFamilyGraph, + renderFamilyTree, + familyParents, familyBadges, familyChildren, dryRunCommands, + unavailableReason, + priorityWorkspace, request, refresh, loadWorkspace, diff --git a/src/dyro/console/assets/styles.css b/src/dyro/console/assets/styles.css index b1915a7..690e6d1 100644 --- a/src/dyro/console/assets/styles.css +++ b/src/dyro/console/assets/styles.css @@ -260,6 +260,17 @@ code { } .workspace-list { min-height: 1px; } +.unread-workspaces { + border-bottom: 1px solid var(--border); + margin: 0; + padding: .75rem 1.15rem 1rem; +} +.unread-workspaces > summary { + color: var(--mute); + cursor: pointer; + font-size: .88rem; +} +.unread-workspaces .workspace-row { padding-left: 0; } .workspace-row { align-items: center; border-bottom: 1px solid var(--border); gap: .75rem; padding: 1rem 1.15rem; } .workspace-row:last-child { border-bottom: 0; } .workspace-row:hover { background: var(--surface-strong); } @@ -574,7 +585,7 @@ footer { border-top: 1px solid var(--border); color: var(--mute); font-size: .8r transform: translateY(-8px) scale(1.03); z-index: 2; } -.family-jack[data-role="child"] { opacity: .96; transform: scale(.97); } +.family-jack[data-role="child"] { cursor: pointer; opacity: .96; transform: scale(.97); } .family-jack[data-role="operator"] { grid-column: 1; grid-row: 2; diff --git a/src/dyro/console/inspection.py b/src/dyro/console/inspection.py index a19c728..5edcffd 100644 --- a/src/dyro/console/inspection.py +++ b/src/dyro/console/inspection.py @@ -90,8 +90,12 @@ "findings", "snapshot_sha256", "proof_inspection", + "unavailable_reason", } ) +_UNAVAILABLE_REASONS = frozenset( + {"", "missing_root", "read_timeout", "other"} +) _FINDING_KEYS = frozenset({"status", "reason", "line"}) _FINDING_REASONS = frozenset( { @@ -772,6 +776,13 @@ def _validate_summary(cls, value: object) -> None: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") if value.get("freshness") not in {"fresh", "partial"}: raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + unavailable_reason = value.get("unavailable_reason") + if unavailable_reason not in _UNAVAILABLE_REASONS: + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if value.get("availability") == "available" and unavailable_reason != "": + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") + if value.get("availability") == "unavailable" and unavailable_reason == "": + raise ConsoleOverviewError("OVERVIEW_UNAVAILABLE") for key in ( "repository_count", "line_count", diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index e2138fa..35167d2 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -49,6 +49,13 @@ "paused": 3, "waiting": 4, } +WORKSPACE_MISSING_ROOT = "WORKSPACE_MISSING_ROOT" +WORKSPACE_TIMEOUT = "WORKSPACE_TIMEOUT" +WORKSPACE_UNAVAILABLE = "WORKSPACE_UNAVAILABLE" +UNAVAILABLE_REASON_NONE = "" +UNAVAILABLE_REASON_MISSING_ROOT = "missing_root" +UNAVAILABLE_REASON_READ_TIMEOUT = "read_timeout" +UNAVAILABLE_REASON_OTHER = "other" _FINDING_LIMIT = 32 _LINE_FINDING = re.compile( r"^FAIL (?:line|hotfix):([A-Za-z0-9][A-Za-z0-9._-]{0,79})/" @@ -99,6 +106,81 @@ def _empty_inventory() -> dict[str, list[dict[str, object]]]: return {"lines": [], "tasks": [], "objectives": []} +def _empty_attention_counts() -> dict[str, int]: + return {kind: 0 for kind in _ATTENTION_PRIORITY} + + +def unavailable_reason_for(code: object) -> str: + if code == WORKSPACE_MISSING_ROOT: + return UNAVAILABLE_REASON_MISSING_ROOT + if code == WORKSPACE_TIMEOUT: + return UNAVAILABLE_REASON_READ_TIMEOUT + return UNAVAILABLE_REASON_OTHER + + +def unavailable_reason_of(summary: object) -> str: + if not isinstance(summary, dict): + return UNAVAILABLE_REASON_NONE + raw = summary.get("unavailable_reason") + if raw in { + UNAVAILABLE_REASON_MISSING_ROOT, + UNAVAILABLE_REASON_READ_TIMEOUT, + UNAVAILABLE_REASON_OTHER, + }: + return raw + if summary.get("availability") != "unavailable": + return UNAVAILABLE_REASON_NONE + recommendation = summary.get("recommendation") + reason = recommendation.get("reason") if isinstance(recommendation, dict) else "" + return unavailable_reason_for(reason) + + +def workspace_root_missing(root: Path) -> bool: + try: + return not root.is_dir() + except OSError: + return False + + +def unavailable_workspace_summary( + alias: str, + is_default: bool, + *, + reason: str, +) -> dict[str, object]: + """Path-free unread card. Isolated still requires an allowlisted command.""" + safe_alias = _safe_code(alias) + code = ( + reason + if reason + in {WORKSPACE_MISSING_ROOT, WORKSPACE_TIMEOUT, WORKSPACE_UNAVAILABLE} + else WORKSPACE_UNAVAILABLE + ) + return { + "alias": safe_alias, + "display_name": safe_alias, + "is_default": is_default, + "availability": "unavailable", + "health": "unavailable", + "freshness": "partial", + "repository_count": 0, + "line_count": 0, + "objective_count": 0, + "active_objective_count": 0, + "task_count": 0, + "task_status_counts": {}, + "attention_counts": _empty_attention_counts(), + "recommendation": { + "reason": code, + "command": f"dyro --workspace {safe_alias} doctor", + }, + "findings": [], + "snapshot_sha256": "", + "proof_inspection": "not_inspected", + "unavailable_reason": unavailable_reason_for(code), + } + + def _fail_findings(summary: object) -> list[dict[str, str]]: if not isinstance(summary, dict): return [] @@ -661,30 +743,14 @@ def _capture( snapshot = self._snapshot_loader(config) envelope = workspace_envelope(snapshot) except (DyroError, ValidationError, OSError, UnicodeError): + reason = ( + WORKSPACE_MISSING_ROOT + if workspace_root_missing(root) + else WORKSPACE_UNAVAILABLE + ) return ( - { - "alias": safe_alias, - "display_name": safe_alias, - "is_default": is_default, - "availability": "unavailable", - "health": "unavailable", - "freshness": "partial", - "repository_count": 0, - "line_count": 0, - "objective_count": 0, - "active_objective_count": 0, - "task_count": 0, - "task_status_counts": {}, - "attention_counts": self._empty_attention_counts(), - "recommendation": { - "reason": "WORKSPACE_UNAVAILABLE", - "command": f"dyro --workspace {safe_alias} doctor", - }, - "findings": [], - "snapshot_sha256": "", - "proof_inspection": "not_inspected", - }, - {"WORKSPACE_UNAVAILABLE"}, + unavailable_workspace_summary(safe_alias, is_default, reason=reason), + {reason}, _empty_inventory(), ) @@ -734,12 +800,13 @@ def _capture( "findings": findings, "snapshot_sha256": str(envelope.get("snapshot_sha256", "")), "proof_inspection": "not_inspected", + "unavailable_reason": UNAVAILABLE_REASON_NONE, } return summary, warning_codes, _inventory_from_envelope(data) @staticmethod def _empty_attention_counts() -> dict[str, int]: - return {kind: 0 for kind in _ATTENTION_PRIORITY} + return _empty_attention_counts() def _workspace_attention( self, objectives: list[dict[str, object]] @@ -847,6 +914,8 @@ def _task_status_counts(self, summaries: list[dict[str, object]]) -> dict[str, i def _highest_priority(self, summaries: list[dict[str, object]]) -> dict[str, str] | None: candidates: list[tuple[int, str, dict[str, str]]] = [] for summary in summaries: + if summary.get("availability") != "available": + continue recommendation = summary.get("recommendation") counts = _safe_mapping(summary.get("attention_counts")) if not isinstance(recommendation, dict): @@ -889,11 +958,14 @@ def _highest_priority(self, summaries: list[dict[str, object]]) -> dict[str, str @staticmethod def _summary_sort_key(summary: dict[str, object]) -> tuple[int, str]: counts = _safe_mapping(summary.get("attention_counts")) - if ( - summary.get("availability") == "unavailable" - or counts.get("repair_required", 0) - or _fail_findings(summary) - ): + unread = unavailable_reason_of(summary) + if unread == UNAVAILABLE_REASON_MISSING_ROOT: + priority = 8 + elif unread == UNAVAILABLE_REASON_READ_TIMEOUT: + priority = 7 + elif unread == UNAVAILABLE_REASON_OTHER: + priority = 6 + elif counts.get("repair_required", 0) or _fail_findings(summary): priority = 0 elif counts.get("needs_user", 0): priority = 1 diff --git a/tests/support/console_operator.mjs b/tests/support/console_operator.mjs index 85d73d5..4ac96b4 100644 --- a/tests/support/console_operator.mjs +++ b/tests/support/console_operator.mjs @@ -393,6 +393,156 @@ if (action === "fail_overview") { } else if (action === "empty") { const graph = api.renderFamilyGraph("core", [], "core", [], []); result = { text: collectText(graph) }; +} else if (action === "ghost_overview") { + api.renderOverview({ + captured_at: "2026-08-21T07:00:00Z", + freshness: { partial: true, warnings: [] }, + data: { + total_workspaces: 2, + attention_counts: emptyAttention(), + task_status_counts: {}, + workspaces: [ + { + alias: "core", + display_name: "core", + availability: "available", + health: "healthy", + findings: [], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, + attention_counts: emptyAttention(), + unavailable_reason: "", + }, + { + alias: "test-workspace", + display_name: "test-workspace", + availability: "unavailable", + health: "unavailable", + findings: [], + recommendation: { + reason: "WORKSPACE_MISSING_ROOT", + command: "dyro --workspace test-workspace doctor", + }, + attention_counts: emptyAttention(), + unavailable_reason: "missing_root", + }, + ], + }, + }); + result = { + heading: nodesById.get("overview-heading").textContent, + primary: nodesById.get("primary-command").textContent, + command: nodesById.get("primary-copy").dataset.command, + why: nodesById.get("primary-why").textContent, + needsYou: collectText(nodesById.get("needs-you")), + list: collectText(nodesById.get("workspace-list")), + needsYouAliases: api.needsYouWorkspaces([ + { + alias: "core", + availability: "available", + findings: [], + attention_counts: emptyAttention(), + unavailable_reason: "", + }, + { + alias: "test-workspace", + availability: "unavailable", + findings: [], + recommendation: { + reason: "WORKSPACE_MISSING_ROOT", + command: "dyro --workspace test-workspace doctor", + }, + attention_counts: emptyAttention(), + unavailable_reason: "missing_root", + }, + ]).map((item) => item.alias), + }; +} else if (action === "timeout_overview") { + const timeout = { + alias: "slow", + display_name: "slow", + availability: "unavailable", + health: "unavailable", + findings: [], + recommendation: { reason: "WORKSPACE_TIMEOUT", command: "dyro --workspace slow doctor" }, + attention_counts: emptyAttention(), + unavailable_reason: "read_timeout", + }; + const missing = { + alias: "test-workspace", + display_name: "test-workspace", + availability: "unavailable", + health: "unavailable", + findings: [], + recommendation: { + reason: "WORKSPACE_MISSING_ROOT", + command: "dyro --workspace test-workspace doctor", + }, + attention_counts: emptyAttention(), + unavailable_reason: "missing_root", + }; + const core = { + alias: "core", + display_name: "core", + availability: "available", + health: "healthy", + findings: [], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, + attention_counts: emptyAttention(), + unavailable_reason: "", + }; + api.renderOverview({ + captured_at: "2026-08-21T07:00:00Z", + freshness: { partial: true, warnings: [] }, + data: { + total_workspaces: 3, + attention_counts: emptyAttention(), + task_status_counts: {}, + workspaces: [timeout, missing, core], + }, + }); + result = { + heading: nodesById.get("overview-heading").textContent, + command: nodesById.get("primary-copy").dataset.command, + needsYou: collectText(nodesById.get("needs-you")), + list: collectText(nodesById.get("workspace-list")), + timeoutMatter: api.workspaceMatter(timeout), + missingMatter: api.workspaceMatter(missing), + timeoutReason: api.unavailableReason(timeout), + missingReason: api.unavailableReason(missing), + }; +} else if (action === "family_picker") { + const lines = [ + { id: "core", parent: "" }, + { id: "core_pay", parent: "core" }, + { id: "core_pay_fix", parent: "core_pay" }, + { id: "release_a", parent: "" }, + ]; + api.getState().familyParent = ""; + const roots = api.familyParents(lines); + api.getState().familyParent = "core_pay"; + const focused = api.familyParents(lines); + api.getState().familyParent = "core_pay_fix"; + const grandchild = api.familyParents(lines); + result = { roots, focused, grandchild }; +} else if (action === "empty_twin") { + const twinApi = context.__dyroTwinLive; + const twin = twinApi.renderOperatorTwin({ + lines: [ + { id: "core", parent: "" }, + { id: "core_pay", parent: "core" }, + ], + tasks: [], + objectives: [], + operator_twin: { + plan: [], + phases: [], + running: [], + latest_ledger: { present: false, at: "", task_id: "", phase: "", facts: {} }, + projected_seq: 0, + overlay_complete: true, + }, + }); + result = { text: collectText(twin) }; } else { throw new Error(`unknown action ${action}`); } diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py index 6a38b40..1adbef6 100644 --- a/tests/test_console_assets.py +++ b/tests/test_console_assets.py @@ -156,6 +156,12 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: self.assertIn("刚有合入或同步".encode(), script.body) self.assertIn("未检查".encode(), script.body) self.assertIn("先在终端想好子线名".encode(), script.body) + self.assertIn("登记还在,工作区目录已经不在了".encode(), script.body) + self.assertIn("读取超时,项目还在".encode(), script.body) + self.assertIn("读不到 ·".encode(), script.body) + self.assertIn("还没有 Task / Objective".encode(), script.body) + self.assertIn("function familyParents".encode(), script.body) + self.assertIn("unread-workspaces".encode(), script.body) self.assertIn("pane.hidden".encode(), script.body) self.assertIn("force: true".encode(), script.body) self.assertIn("本地会话尚未建立".encode(), script.body) @@ -189,6 +195,7 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: self.assertIn(b"ui-serif", styles.body) self.assertIn(b"ui-monospace", styles.body) self.assertIn(b"workspace-room", styles.body) + self.assertIn(b"unread-workspaces", styles.body) self.assertIn(b"family-jack.is-focus", styles.body) self.assertNotIn(b"fonts.googleapis", styles.body) self.assertNotIn(b"fonts.gstatic", styles.body) diff --git a/tests/test_console_inspection.py b/tests/test_console_inspection.py index e91d2c6..69922e1 100644 --- a/tests/test_console_inspection.py +++ b/tests/test_console_inspection.py @@ -14,7 +14,11 @@ from dyro.canonical import canonical_json_bytes from dyro.console import _inspect_worker from dyro.console.inspection import IsolatedOverviewService -from dyro.console.overview import ConsoleOverviewError +from dyro.console.overview import ( + ConsoleOverviewError, + WORKSPACE_MISSING_ROOT, + WORKSPACE_TIMEOUT, +) from dyro.hub import WorkspaceRecord, WorkspaceRegistry, add_workspace from .support import WorkspaceCase @@ -229,6 +233,118 @@ def test_temporary_root_is_read_without_registering_it_globally(self) -> None: self.assertEqual(overview["data"]["workspaces"][0]["alias"], "test-workspace") self.assertNotIn(str(self.root), repr(overview)) + def test_root_scope_hides_global_ghost_registry_rows(self) -> None: + ghost = Path("/tmp/dyro-test-xyz") + self.assertFalse(ghost.exists()) + home = self.root / "scoped-state" + home.mkdir() + home.joinpath("workspaces.json").write_text( + json.dumps( + { + "schema_version": 1, + "default": "ghost", + "workspaces": [ + { + "name": "ghost", + "root": str(ghost), + "last_kind": "", + "last_target": "", + "last_agent": "", + } + ], + } + ) + + "\n", + encoding="utf-8", + ) + service = IsolatedOverviewService( + registry_state_home=home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + target_root=self.root, + ) + + overview = service.page(limit=20) + aliases = [item["alias"] for item in overview["data"]["workspaces"]] + + self.assertEqual(overview["data"]["total_workspaces"], 1) + self.assertEqual(aliases, ["test-workspace"]) + self.assertEqual(overview["data"]["workspaces"][0]["availability"], "available") + self.assertNotIn("ghost", aliases) + self.assertNotIn("/tmp", repr(overview)) + + def test_vanished_test_workspace_does_not_win_unscoped_overview(self) -> None: + ghost = Path("/tmp/dyro-test-xyz") + self.assertFalse(ghost.exists()) + self.home.joinpath("workspaces.json").write_text( + json.dumps( + { + "schema_version": 1, + "default": "core", + "workspaces": [ + { + "name": "core", + "root": str(self.root), + "last_kind": "", + "last_target": "", + "last_agent": "", + }, + { + "name": "test-workspace", + "root": str(ghost), + "last_kind": "", + "last_target": "", + "last_agent": "", + }, + ], + } + ) + + "\n", + encoding="utf-8", + ) + service = IsolatedOverviewService( + registry_state_home=self.home, + timeout_seconds=5, + cursor_secret=b"q" * 32, + ) + + overview = service.page(limit=20) + cards = overview["data"]["workspaces"] + aliases = [item["alias"] for item in cards] + + self.assertEqual(aliases[0], "core") + self.assertNotEqual(aliases[0], "test-workspace") + ghost_card = next(item for item in cards if item["alias"] == "test-workspace") + self.assertEqual(ghost_card["availability"], "unavailable") + self.assertEqual(ghost_card["unavailable_reason"], "missing_root") + self.assertEqual(ghost_card["recommendation"]["reason"], WORKSPACE_MISSING_ROOT) + highest = overview["data"]["highest_priority"] + if highest is not None: + self.assertNotEqual(highest["alias"], "test-workspace") + self.assertNotEqual( + cards[0]["recommendation"]["command"], + "dyro --workspace test-workspace doctor", + ) + self.assertNotIn("/tmp", repr(overview)) + self.assertNotIn("dyro-test-xyz", repr(overview)) + + def test_timeout_card_is_not_a_missing_root(self) -> None: + timeout = _inspect_worker._unavailable_summary("core", WORKSPACE_TIMEOUT) + missing = _inspect_worker._unavailable_summary( + "test-workspace", WORKSPACE_MISSING_ROOT + ) + + IsolatedOverviewService._validate_summary(timeout) + IsolatedOverviewService._validate_summary(missing) + self.assertEqual(timeout["unavailable_reason"], "read_timeout") + self.assertEqual(missing["unavailable_reason"], "missing_root") + self.assertEqual(timeout["recommendation"]["reason"], WORKSPACE_TIMEOUT) + self.assertEqual(missing["recommendation"]["reason"], WORKSPACE_MISSING_ROOT) + self.assertNotEqual( + timeout["recommendation"]["reason"], + missing["recommendation"]["reason"], + ) + def test_worker_timeout_kills_its_process_group_and_returns_a_stable_code(self) -> None: process = Mock(spec=subprocess.Popen) process.pid = 12345 diff --git a/tests/test_console_operator.py b/tests/test_console_operator.py index e0681fd..6b24567 100644 --- a/tests/test_console_operator.py +++ b/tests/test_console_operator.py @@ -85,6 +85,53 @@ def test_missing_bootstrap_explains_how_to_open_again(self) -> None: self.assertNotIn("正在建立安全本地会话", result["missing"]) self.assertNotIn("正在建立安全本地会话", result["expired"]) + def test_ghost_test_workspace_does_not_win_command_center(self) -> None: + result = _run("ghost_overview") + + self.assertNotIn("test-workspace", result["needsYou"]) + self.assertNotIn("test-workspace", result["needsYouAliases"]) + self.assertNotEqual(result["command"], "dyro --workspace test-workspace doctor") + self.assertNotIn("dyro --workspace test-workspace", result["primary"]) + self.assertIn("core", result["list"]) + self.assertIn("读不到", result["list"]) + self.assertIn("登记还在,工作区目录已经不在了", result["list"]) + self.assertNotEqual(result["heading"], "需要修复") + + def test_timeout_copy_is_not_missing_root(self) -> None: + result = _run("timeout_overview") + + self.assertEqual(result["timeoutReason"], "read_timeout") + self.assertEqual(result["missingReason"], "missing_root") + self.assertIn("读取超时", result["timeoutMatter"]) + self.assertIn("项目还在", result["timeoutMatter"]) + self.assertIn("目录已经不在了", result["missingMatter"]) + self.assertNotEqual(result["timeoutMatter"], result["missingMatter"]) + self.assertNotIn("slow", result["needsYou"]) + self.assertNotIn("test-workspace", result["needsYou"]) + self.assertNotEqual(result["command"], "dyro --workspace slow doctor") + self.assertNotEqual(result["command"], "dyro --workspace test-workspace doctor") + self.assertIn("读取未完成", result["list"]) + self.assertIn("目录不在了", result["list"]) + + def test_family_picker_defaults_to_roots_plus_focused_parent(self) -> None: + result = _run("family_picker") + + self.assertEqual(result["roots"], ["core", "release_a"]) + self.assertNotIn("core_pay", result["roots"]) + self.assertNotIn("core_pay_fix", result["roots"]) + self.assertEqual(result["focused"], ["core", "release_a", "core_pay"]) + self.assertEqual(result["grandchild"], ["core", "release_a", "core_pay_fix"]) + self.assertNotIn("core_pay_fix", result["focused"]) + + def test_empty_twin_explains_lines_without_tasks_or_objectives(self) -> None: + result = _run("empty_twin") + + self.assertIn("2 条线", result["text"]) + self.assertIn("还没有 Task / Objective", result["text"]) + self.assertIn("谁在跑是预期的", result["text"]) + self.assertNotEqual(result["text"].strip(), "没有目标。") + self.assertNotIn("没有目标。", result["text"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index 7a24ff8..522cdf9 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -5,7 +5,13 @@ from types import SimpleNamespace import unittest -from dyro.console.overview import ConsoleOverviewError, ConsoleOverviewService +from dyro.console.overview import ( + ConsoleOverviewError, + ConsoleOverviewService, + WORKSPACE_MISSING_ROOT, + WORKSPACE_TIMEOUT, + unavailable_workspace_summary, +) from dyro.errors import ValidationError from dyro.updates import UpdateState from dyro.hub import WorkspaceRecord, WorkspaceRegistry @@ -162,19 +168,24 @@ def test_paginates_stably_prioritizes_attention_and_never_exposes_roots(self) -> self.assertEqual(first["data"]["attention_counts"]["needs_user"], 1) self.assertEqual(first["data"]["attention_counts"]["repair_required"], 1) self.assertEqual(first["data"]["task_status_counts"], {"backlog": 2}) - self.assertIn("WORKSPACE_UNAVAILABLE", first["freshness"]["warnings"][1]["code"]) + warning_codes = [item["code"] for item in first["freshness"]["warnings"]] + self.assertIn(WORKSPACE_MISSING_ROOT, warning_codes) self.assertNotIn("/private", repr(first)) self.assertNotIn("dyro.toml", repr(first)) second = self.service.page(cursor=first["data"]["next_cursor"], limit=1) - self.assertEqual(second["data"]["workspaces"][0]["alias"], "broken") - self.assertEqual(second["data"]["workspaces"][0]["availability"], "unavailable") + self.assertEqual(second["data"]["workspaces"][0]["alias"], "alpha") + self.assertEqual(second["data"]["workspaces"][0]["availability"], "available") + third = self.service.page(cursor=second["data"]["next_cursor"], limit=1) + self.assertEqual(third["data"]["workspaces"][0]["alias"], "broken") + self.assertEqual(third["data"]["workspaces"][0]["availability"], "unavailable") + self.assertEqual(third["data"]["workspaces"][0]["unavailable_reason"], "missing_root") self.assertEqual( first["data"]["workspaces"][0]["recommendation"]["command"], "dyro --workspace beta objective attention release", ) self.assertEqual( - second["data"]["workspaces"][0]["recommendation"]["command"], + third["data"]["workspaces"][0]["recommendation"]["command"], "dyro --workspace broken doctor", ) self.assertNotEqual(first["snapshot_sha256"], second["snapshot_sha256"]) @@ -381,6 +392,7 @@ def test_unavailable_workspace_keeps_empty_inventory_keys(self) -> None: payload = self.service.workspace("broken") self.assertEqual(payload["data"]["workspace"]["availability"], "unavailable") + self.assertEqual(payload["data"]["workspace"]["unavailable_reason"], "missing_root") self.assertEqual(payload["data"]["workspace"]["findings"], []) self.assertEqual(payload["data"]["workspace"]["proof_inspection"], "not_inspected") self.assertEqual(payload["data"]["lines"], []) @@ -499,6 +511,83 @@ def test_system_sanitizes_unreadable_update_fields(self) -> None: self.assertEqual(payload["data"]["update"]["kind"], "none") self.assertEqual(payload["data"]["tools"], []) + def test_vanished_test_workspace_does_not_outrank_available_core(self) -> None: + ghost_root = Path("/tmp/dyro-test-xyz") + self.assertFalse(ghost_root.exists()) + registry = WorkspaceRegistry( + default="core", + workspaces=( + WorkspaceRecord("core", self.alpha_root), + WorkspaceRecord("test-workspace", ghost_root), + ), + ) + def config_loader(root: Path) -> SimpleNamespace: + try: + return self.service._config_loader(root) + except KeyError: + raise ValidationError("workspace config unavailable") from None + + service = ConsoleOverviewService( + registry_loader=lambda: registry, + config_loader=config_loader, + snapshot_loader=lambda config: self.snapshots[config.name], + clock=self.service._clock, + cursor_secret=b"k" * 32, + doctor_loader=lambda config: [], + ) + + page = service.page() + cards = page["data"]["workspaces"] + + self.assertEqual(cards[0]["alias"], "core") + self.assertNotEqual(cards[0]["alias"], "test-workspace") + self.assertEqual(cards[0]["availability"], "available") + ghost = next(item for item in cards if item["alias"] == "test-workspace") + self.assertEqual(ghost["availability"], "unavailable") + self.assertEqual(ghost["unavailable_reason"], "missing_root") + self.assertEqual(ghost["recommendation"]["reason"], WORKSPACE_MISSING_ROOT) + highest = page["data"]["highest_priority"] + self.assertIsNotNone(highest) + self.assertEqual(highest["alias"], "core") + self.assertNotEqual(highest["alias"], "test-workspace") + self.assertNotIn("dyro --workspace test-workspace", cards[0]["recommendation"]["command"]) + self.assertNotIn("/tmp", repr(page)) + self.assertNotIn("dyro-test-xyz", repr(page)) + + def test_timeout_sorts_below_healthy_default_and_differs_from_missing_root(self) -> None: + healthy = { + "alias": "core", + "availability": "available", + "attention_counts": {}, + "findings": [], + "unavailable_reason": "", + } + timeout = unavailable_workspace_summary("slow", False, reason=WORKSPACE_TIMEOUT) + ghost = unavailable_workspace_summary( + "test-workspace", False, reason=WORKSPACE_MISSING_ROOT + ) + + ordered = sorted( + [timeout, ghost, healthy], + key=ConsoleOverviewService._summary_sort_key, + ) + + self.assertEqual( + [item["alias"] for item in ordered], + ["core", "slow", "test-workspace"], + ) + self.assertEqual(timeout["unavailable_reason"], "read_timeout") + self.assertEqual(ghost["unavailable_reason"], "missing_root") + self.assertNotEqual( + timeout["recommendation"]["reason"], + ghost["recommendation"]["reason"], + ) + service = ConsoleOverviewService( + registry_loader=lambda: WorkspaceRegistry(), + cursor_secret=b"k" * 32, + ) + self.assertIsNone(service._highest_priority([timeout, ghost, healthy])) + if __name__ == "__main__": unittest.main() From e7589c08bfd156ff0b8bcd9d1596ee30f4a896dd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 08:39:13 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(=E6=8E=A7=E5=88=B6=E5=8F=B0):=20close?= =?UTF-8?q?=20the=20command-center=20when=20bootstrap=20is=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missing or expired bootstrap now paints the door on heading and primary, not only session-status. _capture passes allowlisted next commands into the recommendation. Inverted harness locks cover start(), forced refresh, ghost FAIL, FAIL-over-ready, family picker buttons, and hash tabs. Co-authored-by: Dandre Yang --- CHANGELOG.md | 9 +- src/dyro/console/assets.py | 4 +- src/dyro/console/assets/app.js | 45 ++++- src/dyro/console/overview.py | 11 +- tests/support/console_operator.mjs | 296 +++++++++++++++++++++-------- tests/test_console_assets.py | 1 + tests/test_console_operator.py | 51 ++++- tests/test_console_overview.py | 34 ++++ 8 files changed, 358 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c57817..1312dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,11 @@ Refresh re-fetches the overview and any open workspace so captured-at moves. Spawn/merge/sync copy no longer invents `_new`. - `dyro console --no-open` flushes the one-time URL. A missing or - expired bootstrap tells the operator to run `dyro console` again - instead of spinning on session setup. Empty twin/inventory copy is - one honest sentence; line badges do not paint 未检查 when a FAIL - finding exists for that line. + expired bootstrap paints the door on the command-center heading + and primary (not only session-status) and tells the operator to + run `dyro console` again. Empty twin/inventory copy is one honest + sentence; line badges do not paint 未检查 when a FAIL finding + exists for that line. - Unscoped `dyro console` no longer lets vanished registry rows (missing roots, including dead `/tmp/dyro-test-*` aliases) win 现在需要你 or the primary CTA. Those cards fold under 读不到. diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py index fb736f1..dbf8c4b 100644 --- a/src/dyro/console/assets.py +++ b/src/dyro/console/assets.py @@ -27,8 +27,8 @@ class ConsoleAsset: ), "app.js": ( "text/javascript; charset=utf-8", - "3750ebe72935668862ee99a032b3576f97a12724f5d24cf2bb212155d5c6a290", - 96631, + "d3a2787202bd1b42dc924c7b7417dedaf2d3415f1efd5bcfab68b63f010bbe07", + 97664, ), "styles.css": ( "text/css; charset=utf-8", diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js index 0df1bde..06b5feb 100644 --- a/src/dyro/console/assets/app.js +++ b/src/dyro/console/assets/app.js @@ -223,10 +223,41 @@ const $ = (id) => (typeof document !== "undefined" && document.getElementById function setStatus(message, error = false) { const node = $("session-status"); + if (!node) return; node.textContent = message; node.classList.toggle("error", error); } +function paintClosedCommandCenter(message) { + setStatus(message, true); + const heading = $("overview-heading"); + if (heading) heading.textContent = message; + const summary = $("overview-summary"); + if (summary) summary.textContent = ""; + const guidance = $("primary-guidance"); + if (guidance) guidance.textContent = ""; + const why = $("primary-why"); + if (why) why.textContent = ""; + const command = $("primary-command"); + if (command) { + command.textContent = ""; + command.hidden = true; + } + const copy = $("primary-copy"); + if (copy) { + copy.dataset.command = ""; + copy.disabled = true; + copy.hidden = true; + copy.textContent = "复制命令"; + } + const needs = $("needs-you"); + if (needs) needs.replaceChildren(); + const counts = $("attention-counts"); + if (counts) counts.replaceChildren(); + const tasks = $("task-status-counts"); + if (tasks) tasks.replaceChildren(); +} + function text(value) { return typeof value === "string" ? value : ""; } @@ -431,7 +462,7 @@ function expireSession() { state.timer = null; stopEventLive(); sessionStorage.removeItem(TOKEN_KEY); - setStatus("本地会话已过期;请重新运行 dyro console。", true); + paintClosedCommandCenter(sessionExpiredMessage()); } function addBadge(parent, label, level = "") { @@ -530,7 +561,10 @@ function findingLabels(summary) { } function overviewState(attention, workspaces) { - if (Array.isArray(workspaces) && workspaces.some(workspaceHasFail)) return "需要修复"; + const live = Array.isArray(workspaces) + ? workspaces.filter((summary) => !isMissingRootWorkspace(summary)) + : []; + if (live.some(workspaceHasFail)) return "需要修复"; if (count(attention && attention.repair_required)) return "需要修复"; if (count(attention && attention.needs_user)) return "等待你的处理"; if (materialUnavailableCount(workspaces)) return "状态不完整"; @@ -2623,14 +2657,14 @@ async function start() { try { await exchange(bootstrap); } catch (_) { - setStatus(sessionExpiredMessage(), true); + paintClosedCommandCenter(sessionExpiredMessage()); return; } } else { state.bearer = sessionStorage.getItem(TOKEN_KEY) || ""; } if (!state.bearer) { - setStatus(sessionMissingMessage(), true); + paintClosedCommandCenter(sessionMissingMessage()); return; } const meta = await request("/api/v1/meta", "meta"); @@ -2650,7 +2684,7 @@ async function start() { return; } else { sessionStorage.removeItem(TOKEN_KEY); - setStatus("无法建立本地会话;请重新运行 dyro console。", true); + paintClosedCommandCenter("无法建立本地会话;请重新运行 dyro console。"); } showError(error); } @@ -2691,6 +2725,7 @@ globalThis.__dyroConsoleTest = { request, refresh, loadWorkspace, + start, sessionMissingMessage, sessionExpiredMessage, getState: () => state, diff --git a/src/dyro/console/overview.py b/src/dyro/console/overview.py index 35167d2..6f9f759 100644 --- a/src/dyro/console/overview.py +++ b/src/dyro/console/overview.py @@ -327,6 +327,7 @@ def __init__( update_loader: Callable[[], UpdateState] = load_update_state, version_loader: Callable[[], str] = lambda: __version__, doctor_loader: Callable[[Config], list[str]] = load_doctor_findings, + commands_loader: Callable[[Config], object] | None = None, ) -> None: if cursor_secret is not None and ( not isinstance(cursor_secret, bytes) or len(cursor_secret) < 32 @@ -342,6 +343,7 @@ def __init__( self._update_loader = update_loader self._version_loader = version_loader self._doctor_loader = doctor_loader + self._commands_loader = commands_loader if commands_loader is not None else (lambda config: []) def page( self, @@ -775,6 +777,13 @@ def _capture( findings = _project_doctor_findings(self._doctor_loader(config)) except (DyroError, ValidationError, OSError, UnicodeError, TypeError, AttributeError): warning_codes.add("DOCTOR_UNAVAILABLE") + commands: list[object] = [] + try: + loaded = self._commands_loader(config) + if isinstance(loaded, list): + commands = loaded + except (DyroError, ValidationError, OSError, UnicodeError, TypeError, AttributeError): + commands = [] partial = freshness.get("state") != "fresh" or bool(warning_codes) fails = [item for item in findings if item.get("status") == "FAIL"] health = "degraded" if partial or fails else "healthy" @@ -795,7 +804,7 @@ def _capture( "task_status_counts": dict(sorted(task_status_counts.items())), "attention_counts": attention["counts"], "recommendation": self._recommendation( - safe_alias, attention["items"], findings=findings + safe_alias, attention["items"], findings=findings, commands=commands ), "findings": findings, "snapshot_sha256": str(envelope.get("snapshot_sha256", "")), diff --git a/tests/support/console_operator.mjs b/tests/support/console_operator.mjs index 4ac96b4..cdf6a20 100644 --- a/tests/support/console_operator.mjs +++ b/tests/support/console_operator.mjs @@ -103,7 +103,7 @@ function fakeNode(tag) { node.listeners[type].push(fn); }, click() { - for (const fn of node.listeners.click || []) fn({ target: node }); + return Promise.all((node.listeners.click || []).map((fn) => fn({ target: node }))); }, replaceWith() {}, replaceChildren(...items) { @@ -142,6 +142,8 @@ function seed(id) { return node; } +const commandCenter = seed("command-center"); +commandCenter.className = "command-center"; for (const id of [ "overview-heading", "overview-summary", @@ -158,8 +160,48 @@ for (const id of [ "system-panel", "system-note", "system-update", + "refresh", + "detail-close", + "workspace-detail", ]) { - seed(id); + const node = seed(id); + if ( + [ + "overview-heading", + "overview-summary", + "needs-you", + "primary-guidance", + "primary-why", + "primary-command", + "primary-copy", + ].includes(id) + ) { + commandCenter.append(node); + } +} +nodesById.get("overview-heading").textContent = "正在读取工程状态"; +nodesById.get("overview-summary").textContent = "正在读取本地工作区状态。"; +nodesById.get("primary-command").textContent = "正在准备推荐命令…"; +nodesById.get("session-status").textContent = "正在建立安全本地会话…"; + +function resetCommandCenterPlaceholders() { + const heading = nodesById.get("overview-heading"); + const summary = nodesById.get("overview-summary"); + const command = nodesById.get("primary-command"); + const copy = nodesById.get("primary-copy"); + heading.textContent = "正在读取工程状态"; + summary.textContent = "正在读取本地工作区状态。"; + command.textContent = "正在准备推荐命令…"; + command.hidden = false; + copy.hidden = false; + copy.disabled = true; + copy.dataset.command = ""; +} + +function pickerLabels(pane) { + const nav = pane && pane.querySelector ? pane.querySelector(".family-picker") : null; + if (!nav) return []; + return (nav.querySelectorAll("button") || []).map((button) => button.textContent); } const fetchCalls = []; @@ -327,6 +369,26 @@ if (action === "fail_overview") { unknown: collectText(unknown), }; } else if (action === "refresh") { + const coreCard = { + alias: "core", + display_name: "core", + availability: "available", + health: "healthy", + findings: [], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, + attention_counts: emptyAttention(), + unavailable_reason: "", + }; + const freshOverview = { + captured_at: "2026-08-21T08:01:00Z", + freshness: { partial: false, warnings: [] }, + data: { + total_workspaces: 1, + attention_counts: emptyAttention(), + task_status_counts: {}, + workspaces: [coreCard], + }, + }; api.getState().bearer = "token"; api.getState().etags.set("overview", '"old-etag"'); fetchImpl = async () => ({ @@ -342,58 +404,123 @@ if (action === "fail_overview") { const forced = { ...fetchCalls[0] }; api.renderOverview({ captured_at: "2026-08-21T07:00:00Z", + freshness: { partial: false, warnings: [] }, data: { total_workspaces: 1, attention_counts: emptyAttention(), task_status_counts: {}, - workspaces: [ - { - alias: "core", - display_name: "core", - availability: "available", - health: "healthy", - findings: [], - recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, - attention_counts: emptyAttention(), - }, - ], + workspaces: [coreCard], }, }); const before = nodesById.get("captured-at").textContent; + fetchImpl = async (_path, init = {}) => { + if (init.headers && init.headers["If-None-Match"]) { + return { + ok: true, + status: 304, + headers: { get: () => null }, + json: async () => null, + }; + } + return { + ok: true, + status: 200, + headers: { get: (name) => (name === "ETag" ? '"refreshed-etag"' : null) }, + json: async () => freshOverview, + }; + }; + fetchCalls.length = 0; + await api.refresh({ force: true }); + const refreshed = { ...fetchCalls[0] }; + const afterRefresh = nodesById.get("captured-at").textContent; api.renderOverview({ - captured_at: "2026-08-21T08:01:00Z", + captured_at: "2026-08-21T07:00:00Z", + freshness: { partial: false, warnings: [] }, data: { total_workspaces: 1, attention_counts: emptyAttention(), task_status_counts: {}, - workspaces: [ - { - alias: "core", - display_name: "core", - availability: "available", - health: "healthy", - findings: [], - recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, - attention_counts: emptyAttention(), - }, - ], + workspaces: [coreCard], }, }); + fetchCalls.length = 0; + await nodesById.get("refresh").click(); + const clicked = { ...fetchCalls[0] }; result = { cachedHasMatch: Boolean(cached.headers["If-None-Match"]), forcedHasMatch: Boolean(forced.headers["If-None-Match"]), + refreshHasMatch: Boolean(refreshed.headers["If-None-Match"]), + clickHasMatch: Boolean(clicked.headers["If-None-Match"]), before, - after: nodesById.get("captured-at").textContent, + afterRefresh, + afterClick: nodesById.get("captured-at").textContent, + after: afterRefresh, }; } else if (action === "session") { - result = { - missing: api.sessionMissingMessage(), - expired: api.sessionExpiredMessage(), + resetCommandCenterPlaceholders(); + context.window.location.hash = ""; + await api.start(); + const missingCenter = collectText(commandCenter); + const missing = { + status: nodesById.get("session-status").textContent, + heading: nodesById.get("overview-heading").textContent, + primary: nodesById.get("primary-command").textContent, + primaryHidden: Boolean(nodesById.get("primary-command").hidden), + center: missingCenter, + helper: api.sessionMissingMessage(), }; + resetCommandCenterPlaceholders(); + context.window.location.hash = "#bootstrap=dead-token"; + fetchImpl = async () => ({ + ok: false, + status: 401, + headers: { get: () => null }, + json: async () => ({}), + }); + await api.start(); + const expiredCenter = collectText(commandCenter); + const expired = { + status: nodesById.get("session-status").textContent, + heading: nodesById.get("overview-heading").textContent, + primary: nodesById.get("primary-command").textContent, + primaryHidden: Boolean(nodesById.get("primary-command").hidden), + center: expiredCenter, + helper: api.sessionExpiredMessage(), + }; + result = { missing, expired, missingText: missing.helper, expiredText: expired.helper }; } else if (action === "empty") { const graph = api.renderFamilyGraph("core", [], "core", [], []); result = { text: collectText(graph) }; } else if (action === "ghost_overview") { + const ghostRepair = { + alias: "test-workspace", + display_name: "test-workspace", + availability: "unavailable", + health: "unavailable", + findings: [{ status: "FAIL", reason: "MISSING_ORIGIN", line: "core" }], + recommendation: { + reason: "MISSING_ORIGIN", + command: "dyro --workspace test-workspace doctor", + }, + attention_counts: { + repair_required: 1, + needs_user: 0, + ready: 0, + paused: 0, + waiting: 0, + }, + unavailable_reason: "missing_root", + }; + const core = { + alias: "core", + display_name: "core", + availability: "available", + health: "healthy", + findings: [], + recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, + attention_counts: emptyAttention(), + unavailable_reason: "", + }; api.renderOverview({ captured_at: "2026-08-21T07:00:00Z", freshness: { partial: true, warnings: [] }, @@ -401,31 +528,7 @@ if (action === "fail_overview") { total_workspaces: 2, attention_counts: emptyAttention(), task_status_counts: {}, - workspaces: [ - { - alias: "core", - display_name: "core", - availability: "available", - health: "healthy", - findings: [], - recommendation: { reason: "HOME_GUIDANCE", command: "dyro --workspace core doctor" }, - attention_counts: emptyAttention(), - unavailable_reason: "", - }, - { - alias: "test-workspace", - display_name: "test-workspace", - availability: "unavailable", - health: "unavailable", - findings: [], - recommendation: { - reason: "WORKSPACE_MISSING_ROOT", - command: "dyro --workspace test-workspace doctor", - }, - attention_counts: emptyAttention(), - unavailable_reason: "missing_root", - }, - ], + workspaces: [core, ghostRepair], }, }); result = { @@ -435,26 +538,9 @@ if (action === "fail_overview") { why: nodesById.get("primary-why").textContent, needsYou: collectText(nodesById.get("needs-you")), list: collectText(nodesById.get("workspace-list")), - needsYouAliases: api.needsYouWorkspaces([ - { - alias: "core", - availability: "available", - findings: [], - attention_counts: emptyAttention(), - unavailable_reason: "", - }, - { - alias: "test-workspace", - availability: "unavailable", - findings: [], - recommendation: { - reason: "WORKSPACE_MISSING_ROOT", - command: "dyro --workspace test-workspace doctor", - }, - attention_counts: emptyAttention(), - unavailable_reason: "missing_root", - }, - ]).map((item) => item.alias), + needsYouAliases: api.needsYouWorkspaces([core, ghostRepair]).map((item) => item.alias), + ghostCommand: api.recommendedCommand(ghostRepair), + priorityAlias: (api.priorityWorkspace([core, ghostRepair]) || {}).alias || "", }; } else if (action === "timeout_overview") { const timeout = { @@ -517,13 +603,73 @@ if (action === "fail_overview") { { id: "core_pay_fix", parent: "core_pay" }, { id: "release_a", parent: "" }, ]; + api.getState().surfaces = ["events"]; api.getState().familyParent = ""; const roots = api.familyParents(lines); + const rootButtons = pickerLabels(api.renderFamilyTree("core", lines, [], [])); api.getState().familyParent = "core_pay"; const focused = api.familyParents(lines); + const focusedButtons = pickerLabels(api.renderFamilyTree("core", lines, [], [])); api.getState().familyParent = "core_pay_fix"; const grandchild = api.familyParents(lines); - result = { roots, focused, grandchild }; + const grandchildButtons = pickerLabels(api.renderFamilyTree("core", lines, [], [])); + result = { + roots, + focused, + grandchild, + rootButtons, + focusedButtons, + grandchildButtons, + }; +} else if (action === "fail_over_ready") { + const attention = { + repair_required: 0, + needs_user: 0, + ready: 1, + paused: 0, + waiting: 0, + }; + const workspaces = [ + { + alias: "core", + display_name: "core", + availability: "available", + health: "degraded", + findings: [{ status: "FAIL", reason: "MISSING_ORIGIN", line: "core" }], + recommendation: { reason: "MISSING_ORIGIN", command: "dyro --workspace core doctor" }, + attention_counts: attention, + unavailable_reason: "", + }, + ]; + api.renderOverview({ + captured_at: "2026-08-21T07:00:00Z", + freshness: { partial: false, warnings: [] }, + data: { + total_workspaces: 1, + attention_counts: attention, + task_status_counts: {}, + workspaces, + }, + }); + result = { + heading: nodesById.get("overview-heading").textContent, + state: api.overviewState(attention, workspaces), + }; +} else if (action === "hash_tab") { + context.window.location.hash = "#w/core/events"; + api.getState().detailTab = "events"; + const live = api.renderLivePanes("core", { + workspace: { findings: [] }, + lines: [ + { id: "core", parent: "" }, + { id: "core_pay", parent: "core" }, + ], + tasks: [], + }); + result = { + visible: visiblePaneIds(live), + detailTab: api.getState().detailTab, + }; } else if (action === "empty_twin") { const twinApi = context.__dyroTwinLive; const twin = twinApi.renderOperatorTwin({ diff --git a/tests/test_console_assets.py b/tests/test_console_assets.py index 1adbef6..1c60193 100644 --- a/tests/test_console_assets.py +++ b/tests/test_console_assets.py @@ -165,6 +165,7 @@ def test_shell_exposes_a_semantic_command_center(self) -> None: self.assertIn("pane.hidden".encode(), script.body) self.assertIn("force: true".encode(), script.body) self.assertIn("本地会话尚未建立".encode(), script.body) + self.assertIn("function paintClosedCommandCenter".encode(), script.body) self.assertNotIn("${parent}_new".encode(), script.body) self.assertNotIn("干净".encode(), script.body) self.assertNotIn("远端已绑定".encode(), script.body) diff --git a/tests/test_console_operator.py b/tests/test_console_operator.py index 6b24567..24e7373 100644 --- a/tests/test_console_operator.py +++ b/tests/test_console_operator.py @@ -73,23 +73,40 @@ def test_forced_refresh_omits_etag_and_moves_captured_at(self) -> None: self.assertTrue(result["cachedHasMatch"]) self.assertFalse(result["forcedHasMatch"]) - self.assertNotEqual(result["before"], result["after"]) + self.assertFalse(result["refreshHasMatch"]) + self.assertFalse(result["clickHasMatch"]) + self.assertNotEqual(result["before"], result["afterRefresh"]) + self.assertNotEqual(result["before"], result["afterClick"]) + self.assertIn("读取于", result["afterRefresh"]) + self.assertIn("读取于", result["afterClick"]) self.assertIn("读取于", result["after"]) def test_missing_bootstrap_explains_how_to_open_again(self) -> None: result = _run("session") - self.assertIn("尚未建立", result["missing"]) - self.assertIn("dyro console", result["missing"]) - self.assertIn("dyro console", result["expired"]) - self.assertNotIn("正在建立安全本地会话", result["missing"]) - self.assertNotIn("正在建立安全本地会话", result["expired"]) + for door in (result["missing"], result["expired"]): + self.assertNotEqual(door["heading"], "正在读取工程状态") + self.assertNotEqual(door["primary"], "正在准备推荐命令…") + self.assertEqual(door["heading"], door["helper"]) + self.assertEqual(door["primary"], "") + self.assertTrue(door["primaryHidden"]) + self.assertNotIn("正在读取", door["center"]) + self.assertNotIn("正在准备推荐命令", door["center"]) + self.assertIn("dyro console", door["heading"]) + self.assertIn("尚未建立", result["missing"]["heading"]) + self.assertIn("尚未建立", result["missing"]["helper"]) + self.assertIn("dyro console", result["missing"]["helper"]) + self.assertIn("dyro console", result["expired"]["helper"]) + self.assertNotIn("正在建立安全本地会话", result["missing"]["status"]) + self.assertNotIn("正在建立安全本地会话", result["expired"]["status"]) def test_ghost_test_workspace_does_not_win_command_center(self) -> None: result = _run("ghost_overview") self.assertNotIn("test-workspace", result["needsYou"]) self.assertNotIn("test-workspace", result["needsYouAliases"]) + self.assertEqual(result["ghostCommand"], "") + self.assertNotEqual(result["priorityAlias"], "test-workspace") self.assertNotEqual(result["command"], "dyro --workspace test-workspace doctor") self.assertNotIn("dyro --workspace test-workspace", result["primary"]) self.assertIn("core", result["list"]) @@ -122,6 +139,28 @@ def test_family_picker_defaults_to_roots_plus_focused_parent(self) -> None: self.assertEqual(result["focused"], ["core", "release_a", "core_pay"]) self.assertEqual(result["grandchild"], ["core", "release_a", "core_pay_fix"]) self.assertNotIn("core_pay_fix", result["focused"]) + self.assertEqual(result["rootButtons"], ["core", "release_a"]) + self.assertEqual(result["focusedButtons"], ["core", "release_a", "core_pay"]) + self.assertEqual(result["grandchildButtons"], ["core", "release_a", "core_pay_fix"]) + self.assertNotIn("core_pay", result["rootButtons"]) + self.assertNotIn("core_pay_fix", result["rootButtons"]) + self.assertNotIn("core_pay_fix", result["focusedButtons"]) + + def test_fail_outRanks_ready_in_overview_heading(self) -> None: + result = _run("fail_over_ready") + + self.assertEqual(result["heading"], "需要修复") + self.assertEqual(result["state"], "需要修复") + self.assertNotEqual(result["heading"], "有工作可推进") + self.assertNotEqual(result["state"], "有工作可推进") + + def test_hash_tab_shows_only_that_pane_without_a_click(self) -> None: + result = _run("hash_tab") + + self.assertEqual(result["visible"], ["event-pane"]) + self.assertEqual(result["detailTab"], "events") + self.assertNotIn("family-pane", result["visible"]) + self.assertNotIn("channel-pane", result["visible"]) def test_empty_twin_explains_lines_without_tasks_or_objectives(self) -> None: result = _run("empty_twin") diff --git a/tests/test_console_overview.py b/tests/test_console_overview.py index 522cdf9..d16c74b 100644 --- a/tests/test_console_overview.py +++ b/tests/test_console_overview.py @@ -212,6 +212,40 @@ def test_empty_attention_recommends_doctor_not_a_bare_workspace_invocation(self) ) self.assertNotEqual(recommendation["command"], "dyro --workspace core") + def test_fail_findings_prefer_allowlisted_next_command_over_doctor(self) -> None: + self.registry = WorkspaceRegistry( + default="core", + workspaces=(WorkspaceRecord("core", self.alpha_root),), + ) + self.snapshots["Alpha Project"] = _snapshot( + name="Alpha Project", + attention=(), + ) + service = ConsoleOverviewService( + registry_loader=lambda: self.registry, + config_loader=self.service._config_loader, + snapshot_loader=lambda config: self.snapshots[config.name], + clock=self.service._clock, + cursor_secret=b"k" * 32, + doctor_loader=lambda config: [ + "FAIL line:core/api: missing origin/feat/core", + ], + commands_loader=lambda config: [ + "dyro --workspace core objective tick release", + ], + ) + + page = service.page() + card = page["data"]["workspaces"][0] + + self.assertEqual( + card["recommendation"]["command"], + "dyro --workspace core objective tick release", + ) + self.assertNotEqual(card["recommendation"]["command"], "dyro --workspace core doctor") + self.assertEqual(card["recommendation"]["reason"], "MISSING_ORIGIN") + self.assertNotIn("/private", repr(page)) + def test_fail_findings_and_empty_commands_recommend_doctor_not_bare_home(self) -> None: recommendation = self.service._recommendation( "core",