项目总览
+优先处理需要关注的工程
+正在读取本地工作区状态。
+diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4c6fac..f1d885b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,12 @@ jobs: "$smoke_root/wheel-venv/bin/pip" install /tmp/dyro-dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" "$smoke_root/wheel-venv/bin/python" -I -c "import dyro.continuation" + "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/dispatch-doctor.json" + uv run python -m venv "$smoke_root/sdist-venv" + "$smoke_root/sdist-venv/bin/pip" install /tmp/dyro-dist/dyro-*.tar.gz + "$smoke_root/sdist-venv/bin/python" -I -c "import dyro.continuation" + "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" windows-dispatch-import: name: Windows dispatch import / fail-closed smoke diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index eb08775..7e15a0b 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -81,11 +81,13 @@ jobs: uv run python -m venv "$smoke_root/wheel-venv" "$smoke_root/wheel-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.whl "$smoke_root/wheel-venv/bin/python" -c "import experiments.local_agent_dispatch" + "$smoke_root/wheel-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/wheel-dispatch-home" "$smoke_root/wheel-venv/bin/dyro" dispatch doctor >"$smoke_root/wheel-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/wheel-doctor.json'))" uv run python -m venv "$smoke_root/sdist-venv" "$smoke_root/sdist-venv/bin/pip" install "$GITHUB_WORKSPACE"/dist/dyro-*.tar.gz "$smoke_root/sdist-venv/bin/python" -c "import experiments.local_agent_dispatch" + "$smoke_root/sdist-venv/bin/python" -I -c "from dyro.console.assets import validate_assets; validate_assets()" DYRO_LOCAL_AGENT_DISPATCH_HOME="$smoke_root/sdist-dispatch-home" "$smoke_root/sdist-venv/bin/dyro" dispatch doctor >"$smoke_root/sdist-doctor.json" uv run python -c "import json; json.load(open('$smoke_root/sdist-doctor.json'))" diff --git a/MANIFEST.in b/MANIFEST.in index db8f72f..410163f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -5,3 +5,4 @@ include docs/tool-catalog.md include docs/updates.md include docs/workspace-blueprints.md include examples/blueprints/acme-platform.toml +recursive-include src/dyro/console/assets * diff --git a/README.md b/README.md index 15b43ef..a4d54a4 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,18 @@ DyroEngineeringFlow is not coupled to Codex, Claude, or any business domain. Each team supplies a `dyro.toml` Profile for repositories, layouts, agent adapters, and delivery policy; business rules, model cost, and release practices stay in that Profile. +## Local web Console + +Run `dyro console` from any directory to open a read-only local dashboard for registered workspaces. It binds only to `127.0.0.1` on a random port, hands the browser a one-time fragment secret, and never exposes a network listener or browser write actions. + +```bash +dyro console +dyro --workspace platform console --no-open +dyro --root /path/to/profile console +``` + +The dashboard surfaces workspace health, attention, task execution counts, active objectives, and a safe next CLI command. It never runs agents, changes task state, marks a workspace as recently used, merges, pushes, or contacts external services. See the [Console design](docs/designs/local-web-console.md) for the read-only boundary and recovery behavior. + ## What it enforces - A task belongs to exactly one development line—never a mixed feature or hotfix workspace. diff --git a/README.zh-CN.md b/README.zh-CN.md index 9449d24..b735e72 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,6 +8,18 @@ DyroEngineeringFlow 不绑定 Codex、Claude 或任何业务领域。每个团队通过 `dyro.toml` Profile 定义仓库、目录布局、Agent adapter 与交付策略;业务规则、模型成本和发布实践始终留在各自的 Profile 中。 +## 本地 Web 控制台 + +可在任意目录运行 `dyro console`,打开已登记工作区的只读本地总览。它只绑定随机端口的 `127.0.0.1`,通过一次性 fragment secret 与浏览器建立会话,不暴露网络监听,也不提供浏览器写操作。 + +```bash +dyro console +dyro --workspace platform console --no-open +dyro --root /path/to/profile console +``` + +控制台展示工作区健康、需要关注的事项、Task 执行计数、活跃 Objective 和一条安全的下一步 CLI 命令。它不会启动 Agent、改变任务状态、标记最近使用、合并、push 或访问外部服务。只读边界与恢复方式见 [Console 设计](docs/designs/local-web-console.md)。 + ## 核心约束 - 一个任务只能属于一条开发线,不能混用功能版本与 Hotfix 工作区。 diff --git a/docs/designs/local-web-console.md b/docs/designs/local-web-console.md index b8e998a..9f721be 100644 --- a/docs/designs/local-web-console.md +++ b/docs/designs/local-web-console.md @@ -254,6 +254,23 @@ C05 提供前台入口 `dyro console [--no-open] [--port PORT]`,但不增加 - bare `dyro` Home 新增“查看全部项目控制台”。它复用当前已登记工作区作为初始焦点,且 Home 的 dry-run 会保留零副作用;需要不登记某个 Profile 时,应显式使用 `dyro --root PATH console`。 +### 4.6 C06 已落地的离线总览界面 + +C06 将已认证的总览接口接入无框架浏览器界面,优先让使用者在一页内识别需要处理的工程: + +- `index.html`、`app.js` 和 `styles.css` 是 wheel 内的固定资源;每个启动都通过 SHA-256 与大小 + manifest 验证,缺失、漂移或未知 asset 使 listener fail closed,绝不从 cwd 或 source tree 回退; +- 页面先将 fragment 中的一次性 bootstrap secret 读入局部变量并立即清除 URL,再交换为只存在 + 当前 tab `sessionStorage` 的 bearer。它不用 cookie、`localStorage`、外部资源、inline script、 + `innerHTML` 或服务端 mutation; +- 全局页显示 attention、Task 状态分布、active Objective、健康与 freshness、推荐命令。所有 + workspace 文本通过 DOM `textContent` 写入;复制只在浏览器内进行; +- 可点击卡片查看当前 C04 summary,支持条件 ETag 刷新。页面在后台时暂停轮询,恢复可见时只恢复 + 单一轮询定时器;会话过期或本地读取失败显示一条恢复说明,不猜测数据或修改项目。 + +C06 仅消费已发布的 overview/workspace 只读 DTO;Objective、Task 证据链、图和活动的深度 API +仍须在对应 Core 投影准备完成后通过单独的只读扩展交付,不能由浏览器自行推导。 + ## 5. 模块设计 建议模块边界: diff --git a/pyproject.toml b/pyproject.toml index 0c88ad5..beb2358 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,12 +48,16 @@ dev = ["build==1.3.0", "ruff==0.15.2", "twine==6.2.0"] packages = [ "dyro", "dyro.console", + "dyro.console.assets", "dyro.continuation", "experiments", "experiments.local_agent_dispatch", "experiments.local_agent_dispatch.adapters", ] +[tool.setuptools.package-data] +"dyro.console" = ["assets/*"] + [tool.unittest] start-directory = "tests" diff --git a/src/dyro/console/assets.py b/src/dyro/console/assets.py new file mode 100644 index 0000000..7909f83 --- /dev/null +++ b/src/dyro/console/assets.py @@ -0,0 +1,70 @@ +"""Verified package resources for the offline local Console shell.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +from importlib.resources import files + + +@dataclass(frozen=True) +class ConsoleAsset: + name: str + content_type: str + sha256: str + size: int + body: bytes + + +# The values are generated from the checked-in resources. The server validates +# every byte before listening so it never silently falls back to cwd or source +# tree files when a packaged asset is missing or has drifted. +ASSET_MANIFEST = { + "index.html": ( + "text/html; charset=utf-8", + "8980f489da7e7fbd6c6023fc3338c7ff768e14dfade2c13d6b53b69f6304821a", + 1949, + ), + "app.js": ( + "text/javascript; charset=utf-8", + "034405c85e20d7fbcde3d07b0bf0a29abf2c20c9ce5be16b579b48561d69965f", + 10484, + ), + "styles.css": ( + "text/css; charset=utf-8", + "4759e04327b0a29d0bcc039d09d64f633670014ecf7fe111244ec78f60d091af", + 4842, + ), +} + + +class ConsoleAssetError(RuntimeError): + """A packaged static asset is missing, malformed, or has unexpected bytes.""" + + +def load_asset(name: str) -> ConsoleAsset: + if name not in ASSET_MANIFEST: + raise ConsoleAssetError("unknown Console asset") + content_type, expected_sha256, expected_size = ASSET_MANIFEST[name] + resource = files("dyro.console").joinpath("assets", name) + if not resource.is_file(): + raise ConsoleAssetError("missing Console asset") + try: + body = resource.read_bytes() + except OSError as exc: + raise ConsoleAssetError("unreadable Console asset") from exc + digest = hashlib.sha256(body).hexdigest() + if ( + not expected_sha256 + or expected_size <= 0 + or len(body) != expected_size + or digest != expected_sha256 + ): + raise ConsoleAssetError("invalid Console asset manifest") + return ConsoleAsset(name, content_type, digest, len(body), body) + + +def validate_assets() -> None: + """Fail closed unless every public resource matches the fixed manifest.""" + for name in ASSET_MANIFEST: + load_asset(name) diff --git a/src/dyro/console/assets/app.js b/src/dyro/console/assets/app.js new file mode 100644 index 0000000..24fcdcc --- /dev/null +++ b/src/dyro/console/assets/app.js @@ -0,0 +1,294 @@ +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/; +const TOKEN_KEY = "dyro.console.bearer"; +const state = { bearer: "", etags: new Map(), timer: null, focus: "" }; + +const $ = (id) => document.getElementById(id); + +function setStatus(message, error = false) { + const node = $("session-status"); + node.textContent = message; + node.classList.toggle("error", error); +} + +function text(value) { + return typeof value === "string" ? value : ""; +} + +function count(value) { + return Number.isInteger(value) && value >= 0 ? value : 0; +} + +function element(name, value = "") { + const node = document.createElement(name); + if (value) node.textContent = value; + return node; +} + +function consumeFragment() { + const raw = window.location.hash.startsWith("#") ? window.location.hash.slice(1) : ""; + const values = new URLSearchParams(raw); + const bootstrap = values.get("bootstrap"); + const workspace = values.get("workspace"); + state.focus = workspace && SAFE_ID.test(workspace) ? workspace : ""; + const safeRoute = state.focus ? `#workspace=${state.focus}` : ""; + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}${safeRoute}`); + return bootstrap && bootstrap.length <= 256 ? bootstrap : ""; +} + +async function exchange(bootstrap) { + const response = await fetch("/api/v1/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bootstrap }), + cache: "no-store", + credentials: "omit", + }); + const body = await response.json().catch(() => null); + if (!response.ok || !body || typeof body.bearer !== "string") { + throw new Error("SESSION_REJECTED"); + } + state.bearer = body.bearer; + sessionStorage.setItem(TOKEN_KEY, state.bearer); +} + +async function request(path, key) { + const headers = { Authorization: `Bearer ${state.bearer}` }; + const etag = 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; + const body = await response.json().catch(() => null); + if (response.status === 401) throw new Error("SESSION_EXPIRED"); + if (!response.ok || !body) { + const code = text(body && body.error && body.error.code) || "LOCAL_READ_UNAVAILABLE"; + throw new Error(code); + } + const received = response.headers.get("ETag"); + if (received) state.etags.set(key, received); + return body; +} + +function expireSession() { + state.bearer = ""; + state.etags.clear(); + window.clearTimeout(state.timer); + state.timer = null; + sessionStorage.removeItem(TOKEN_KEY); + setStatus("本地会话已过期;请重新运行 dyro console。", true); +} + +function addBadge(parent, label, level = "") { + const badge = element("span", label); + badge.className = "badge"; + if (level) badge.dataset.level = level; + parent.append(badge); +} + +async function copyCommand(command, button) { + try { + await navigator.clipboard.writeText(command); + button.textContent = "已复制"; + } catch (_) { + button.textContent = "请手动复制"; + } +} + +function commandRow(command) { + const row = element("div"); + row.className = "command"; + const code = element("code", command); + const button = element("button", "复制命令"); + button.type = "button"; + button.addEventListener("click", () => copyCommand(command, button)); + row.append(code, button); + return row; +} + +function attentionLevel(summary) { + const attention = summary.attention_counts || {}; + if (count(attention.repair_required)) return "danger"; + if (count(attention.needs_user)) return "warning"; + if (summary.health === "healthy") return "success"; + return ""; +} + +function renderCounts(attention) { + const root = $("attention-counts"); + root.replaceChildren(); + for (const [key, label] of [ + ["repair_required", "需要修复"], + ["needs_user", "需要你处理"], + ["ready", "可推进"], + ["paused", "已暂停"], + ["waiting", "等待中"], + ]) { + const card = element("div"); + card.className = "count"; + card.append(element("strong", String(count(attention && attention[key]))), element("span", label)); + root.append(card); + } +} + +function renderWorkspaceCard(summary) { + const card = element("article"); + card.className = "workspace-card"; + const header = element("header"); + const title = element("h3", text(summary.display_name) || text(summary.alias) || "未命名工作区"); + const badges = element("div"); + badges.className = "badges"; + addBadge(badges, text(summary.health) || "unavailable", attentionLevel(summary)); + addBadge(badges, text(summary.freshness) || "partial"); + header.append(title, badges); + card.append(header); + + const meta = element("p", `别名:${text(summary.alias)} · Task:${count(summary.task_count)} · Active Objective:${count(summary.active_objective_count)}`); + meta.className = "workspace-meta"; + card.append(meta); + const statuses = summary.task_status_counts && typeof summary.task_status_counts === "object" + ? Object.entries(summary.task_status_counts).filter(([, value]) => Number.isInteger(value) && value > 0) + : []; + if (statuses.length) { + const execution = element("p", `任务状态:${statuses.map(([key, value]) => `${key} ${value}`).join(" · ")}`); + execution.className = "workspace-meta"; + card.append(execution); + } + + const recommendation = summary.recommendation || {}; + const reason = text(recommendation.reason) || "LOCAL_READ_UNAVAILABLE"; + const priority = element("p", `当前关注:${reason}`); + priority.className = "priority"; + card.append(priority); + const command = text(recommendation.command); + if (command) card.append(commandRow(command)); + + const footer = element("footer"); + const view = element("button", "查看摘要"); + view.type = "button"; + view.addEventListener("click", () => loadWorkspace(text(summary.alias))); + footer.append(view); + card.append(footer); + return card; +} + +function renderOverview(payload) { + const data = payload && payload.data; + if (!data || !Array.isArray(data.workspaces)) throw new Error("OVERVIEW_UNAVAILABLE"); + const total = count(data.total_workspaces); + $("overview-summary").textContent = total ? `已加载 ${total} 个本地工作区;先处理有明确恢复路径的事项。` : "尚未登记工作区。可运行 dyro setup、dyro join 或 dyro workspace add。"; + $("captured-at").textContent = text(payload.captured_at) ? `采样时间:${text(payload.captured_at)}` : ""; + renderCounts(data.attention_counts || {}); + const list = $("workspace-list"); + list.replaceChildren(); + if (!data.workspaces.length) { + const empty = element("p", "没有可展示的工作区。页面不会自动创建或登记项目。"); + empty.className = "empty"; + list.append(empty); + return; + } + for (const summary of data.workspaces) list.append(renderWorkspaceCard(summary)); + if (state.focus) loadWorkspace(state.focus, true); +} + +function definition(label, value) { + const wrapper = element("div"); + wrapper.append(element("dt", label), element("dd", value)); + return wrapper; +} + +async function loadWorkspace(alias, silent = false) { + if (!SAFE_ID.test(alias)) return; + try { + const payload = await request(`/api/v1/workspaces/${encodeURIComponent(alias)}`, `workspace:${alias}`); + if (!payload) return; + const summary = payload.data && payload.data.workspace; + if (!summary) throw new Error("WORKSPACE_UNAVAILABLE"); + const detail = $("workspace-detail"); + const content = $("detail-content"); + const grid = element("dl"); + grid.className = "detail-grid"; + grid.append( + definition("别名", text(summary.alias)), + definition("健康", text(summary.health)), + definition("可用性", text(summary.availability)), + definition("Task 总数", String(count(summary.task_count))), + definition("开发线", String(count(summary.line_count))), + definition("Objective", String(count(summary.objective_count))), + ); + content.replaceChildren(grid); + const command = text(summary.recommendation && summary.recommendation.command); + if (command) content.append(commandRow(command)); + detail.hidden = false; + $("detail-heading").focus(); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") { + expireSession(); + return; + } + if (!silent) showError(error); + } +} + +function showError(error) { + const code = text(error && error.message) || "LOCAL_READ_UNAVAILABLE"; + setStatus(`无法读取本地状态:${code}。可重新运行 dyro console。`, true); + const list = $("workspace-list"); + list.replaceChildren(); + const notice = element("p", `读取失败:${code}。Console 未修改任何项目文件。`); + notice.className = "error"; + list.append(notice); +} + +async function refresh() { + try { + const payload = await request("/api/v1/overview?limit=100", "overview"); + if (payload) renderOverview(payload); + setStatus("本地会话已就绪;页面只读。", false); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") { + expireSession(); + return; + } + showError(error); + } +} + +function scheduleRefresh() { + window.clearTimeout(state.timer); + if (state.bearer && !document.hidden) state.timer = window.setTimeout(async () => { + await refresh(); + scheduleRefresh(); + }, 5000); +} + +async function start() { + $("refresh").addEventListener("click", async () => { await refresh(); scheduleRefresh(); }); + $("detail-close").addEventListener("click", () => { $("workspace-detail").hidden = true; }); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + scheduleRefresh(); + return; + } + refresh().finally(scheduleRefresh); + }); + const bootstrap = consumeFragment(); + try { + if (bootstrap) await exchange(bootstrap); + else state.bearer = sessionStorage.getItem(TOKEN_KEY) || ""; + if (!state.bearer) throw new Error("SESSION_EXPIRED"); + const meta = await request("/api/v1/meta", "meta"); + if (meta && !state.focus) state.focus = text(meta.data && meta.data.initial_workspace); + await refresh(); + scheduleRefresh(); + } catch (error) { + if (error && error.message === "SESSION_EXPIRED") { + expireSession(); + return; + } else { + sessionStorage.removeItem(TOKEN_KEY); + setStatus("无法建立本地会话;请重新运行 dyro console。", true); + } + showError(error); + } +} + +start(); diff --git a/src/dyro/console/assets/index.html b/src/dyro/console/assets/index.html new file mode 100644 index 0000000..552a968 --- /dev/null +++ b/src/dyro/console/assets/index.html @@ -0,0 +1,46 @@ + + +
+ + + +LOCAL · READ ONLY
+正在建立安全本地会话…
+项目总览
+正在读取本地工作区状态。
+Secure local session is starting.