// U-008: hash-based client-side router (no History API / server catch-all // needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V // §2.1); U-023 adds #/goals/new and #/bugs/new; U-025 adds the real #/ // (v_dashboard), #/tasks (v_task_board), #/tasks/:task_id (v_task_detail). // 02-V's console has no multi-project switcher UI (decision 2), but // c_task_create/c_bug_report_create both require project_id (03-C §3.1; // §3.1.1 doesn't list it, but needs it too -- see bug_record.py's // create_bug_report docstring). Until v_dashboard carries "current project" // state through some richer UI, the id picked/created in v_project_setup is // stashed here and read back by the pages that need it. const CURRENT_PROJECT_KEY = "graphlemon_project_id"; function getCurrentProjectId() { return localStorage.getItem(CURRENT_PROJECT_KEY); } function setCurrentProjectId(projectId) { localStorage.setItem(CURRENT_PROJECT_KEY, projectId); } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text == null ? "" : String(text); return div.innerHTML; } // c_ws_subscribe (03-C §3.14, U-024): opens a live event feed for a project // (optionally scoped to one task_id) and auto-reconnects on drop -- 02-V // §2.2's "实时连接已断开,正在重连" exception state. REQ-12's deeper // reconnect-catch-up semantics are U-024.5's job (03-C §3.14 itself routes // that through REST snapshots, not WS replay); this only re-opens the // socket and reports connection status back to the caller. function connectEventSocket(projectId, taskId, { onEvent, onStatusChange }) { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; let url = wsProtocol + "//" + window.location.host + "/api/ws/projects/" + encodeURIComponent(projectId); if (taskId) url += "?task_id=" + encodeURIComponent(taskId); let closedByCaller = false; let socket = null; function open() { socket = new WebSocket(url); socket.onopen = () => onStatusChange("connected"); socket.onmessage = (event) => onEvent(JSON.parse(event.data)); socket.onclose = () => { if (closedByCaller) return; onStatusChange("disconnected"); setTimeout(open, 2000); }; socket.onerror = () => socket.close(); } open(); return { close: () => { closedByCaller = true; if (socket) socket.close(); }, }; } // c_ws_log_tail (03-C §3.6.2, U-027): live tail for one Work Node's raw log. // No auto-reconnect here, unlike connectEventSocket -- 03-C is explicit that // the server closes this connection on purpose once the node reaches a // terminal status, and the caller (mountLogViewer) is expected to switch to // c_execution_log_get's static mode on close, not treat it as a dropped // connection to retry. function connectLogTailSocket(nodeExecId, { onChunk, onClose }) { const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const url = wsProtocol + "//" + window.location.host + "/api/ws/work-nodes/" + encodeURIComponent(nodeExecId) + "/log"; const socket = new WebSocket(url); socket.onmessage = (event) => onChunk(JSON.parse(event.data).appended_content); socket.onclose = () => onClose(); socket.onerror = () => socket.close(); return { close: () => socket.close() }; } const routes = { "#/": { render: renderDashboard, mount: mountDashboard }, "#/projects": { render: renderProjectList, mount: mountProjectList }, "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, "#/graph": { render: renderGraphConsole, mount: mountGraphConsole }, "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, "#/deployment": { render: renderDeploymentStatus, mount: mountDeploymentStatus }, "#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings }, "#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings }, "#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings }, "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, }; // #/tasks/:task_id (v_task_detail), #/skills/:skill_id/audit // (v_skill_audit_log), and #/artifacts/:artifact_id (v_artifact_browser, // pre-selecting one artifact) are the only dynamic routes -- not worth a // full path-matching router for three patterns. const TASK_DETAIL_RE = /^#\/tasks\/([^/]+)$/; const SKILL_AUDIT_RE = /^#\/skills\/([^/]+)\/audit$/; const ARTIFACT_DETAIL_RE = /^#\/artifacts\/([^/]+)$/; // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): both v_dashboard's and // v_task_detail's org-strip nodes route here. `:org_node` is restricted to // the three real values on purpose -- anything else (typo, stray path // segment) falls through to the "Not found" fallback in render() instead of // reaching renderTaskDetail/mountTaskDetail with a bogus org_node that would // otherwise need its own defensive checks further down. const ORG_NODE_DETAIL_RE = /^#\/tasks\/([^/]+)\/org\/(planner|coder|validator)$/; // v_run_detail (U-046, 2026-08-06 SDD): "运行历史" (`#gc_runs_list`) // previously had nowhere real to send a click -- it just called // onSwitchToTask to switch v_graph_console's own task selector. This is the // actual run-detail page/route that closes that gap. const RUN_DETAIL_RE = /^#\/runs\/([^/]+)$/; let currentRouteCleanup = null; function registerRouteCleanup(fn) { currentRouteCleanup = fn; } function render() { if (currentRouteCleanup) { currentRouteCleanup(); currentRouteCleanup = null; } const hash = window.location.hash || "#/"; let route = routes[hash]; let routeArgs = []; if (!route) { const taskMatch = hash.match(TASK_DETAIL_RE); const orgNodeMatch = hash.match(ORG_NODE_DETAIL_RE); const skillMatch = hash.match(SKILL_AUDIT_RE); const artifactMatch = hash.match(ARTIFACT_DETAIL_RE); const runMatch = hash.match(RUN_DETAIL_RE); if (orgNodeMatch) { // Reuses v_task_detail itself (same render/mount) with the org_node // carried through as a second arg -- no separate view/route handler. route = { render: renderTaskDetail, mount: mountTaskDetail }; routeArgs = [orgNodeMatch[1], orgNodeMatch[2]]; } else if (taskMatch) { route = { render: renderTaskDetail, mount: mountTaskDetail }; routeArgs = [taskMatch[1]]; } else if (skillMatch) { route = { render: renderSkillAuditLog, mount: mountSkillAuditLog }; routeArgs = [skillMatch[1]]; } else if (artifactMatch) { // Reuses v_artifact_browser itself (same render/mount as "#/artifacts") // with the artifact_id pre-selected -- no separate detail view. route = { render: renderArtifactBrowser, mount: mountArtifactBrowser }; routeArgs = [artifactMatch[1]]; } else if (runMatch) { route = { render: renderRunDetail, mount: mountRunDetail }; routeArgs = [runMatch[1]]; } } if (!route) route = { render: () => "

Not found

" + hash + "

" }; document.getElementById("app").innerHTML = route.render(...routeArgs); if (route.mount) route.mount(...routeArgs); for (const a of document.querySelectorAll("#nav a")) { a.classList.toggle("active", a.getAttribute("href") === hash); } } window.addEventListener("hashchange", render); window.addEventListener("DOMContentLoaded", render); // ---- v_project_list (2026-08-03): project list + CRUD ---- // 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an // MVP non-goal -- this page is a deliberate, explicit scope expansion // beyond that, requested directly by the user. Creation still goes through // v_project_setup's existing flow (pick a folder, optionally git-init it, // project_scaffold.create_project() already handles a folder that already // has real code in it correctly -- it only ever adds .graphlemon/ inside, // nothing about "importing" needs to be built separately). This page adds // the missing read/update/archive half: list every project, rename, // archive/reactivate, and select one as "current" to work in. function renderProjectList() { return `

项目

+ 新建 / 导入项目
名称目标目录状态Goal 数创建时间
`; } function mountProjectList() { const rowsEl = document.getElementById("pl_rows"); async function refresh() { const resp = await fetch("/api/projects"); const projects = await resp.json(); if (projects.length === 0) { rowsEl.innerHTML = '还没有项目,创建一个'; return; } rowsEl.innerHTML = projects.map((p) => ( '' + '' + escapeHtml(p.name) + "" + '" + "" + escapeHtml(p.target_folder_path) + "" + '' + p.status + "" + "" + p.goal_count + "" + "" + escapeHtml(p.created_at) + "" + "" + ' ' + ' ' + '" + "" + "" )).join(""); for (const btn of rowsEl.querySelectorAll('button[data-action="select"]')) { btn.addEventListener("click", () => { setCurrentProjectId(btn.dataset.projectId); window.location.hash = "#/tasks"; }); } for (const btn of rowsEl.querySelectorAll('button[data-action="rename"]')) { btn.addEventListener("click", () => { const id = btn.dataset.projectId; rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = "none"; rowsEl.querySelector('.pl-name-edit[data-project-id="' + id + '"]').style.display = "inline-block"; }); } for (const span of rowsEl.querySelectorAll(".pl-name-edit")) { const id = span.dataset.projectId; span.querySelector('[data-action="cancel-rename"]').addEventListener("click", () => { span.style.display = "none"; rowsEl.querySelector('.pl-name-view[data-project-id="' + id + '"]').style.display = ""; }); span.querySelector('[data-action="save-rename"]').addEventListener("click", () => { const newName = span.querySelector(".pl-name-input").value.trim(); if (!newName) return; fetch("/api/projects/" + encodeURIComponent(id), { method: "PUT", body: JSON.stringify({ name: newName }), }).then(refresh); }); } for (const btn of rowsEl.querySelectorAll('button[data-action="toggle-status"]')) { btn.addEventListener("click", () => { const nextStatus = btn.dataset.currentStatus === "active" ? "archived" : "active"; fetch("/api/projects/" + encodeURIComponent(btn.dataset.projectId), { method: "PUT", body: JSON.stringify({ status: nextStatus }), }).then(refresh); }); } } refresh(); } // ---- v_project_setup (02-V §2.1): create/switch project ---- function renderProjectSetup() { return `
← 返回仪表盘

创建 / 切换项目

选择一个本地文件夹作为目标项目的代码空间
点击"选择文件夹"会由后端代为弹出系统原生对话框,不支持手动输入路径(除非返回 DIALOG_UNAVAILABLE)
`; } function mountProjectSetup() { const folderInput = document.getElementById("folder_path"); const folderHint = document.getElementById("folder_path_hint"); const nameInput = document.getElementById("project_name"); const errorBox = document.getElementById("setup_error"); const confirmBanner = document.getElementById("confirm_banner"); function showError(message) { errorBox.textContent = message; errorBox.style.display = "block"; } function clearError() { errorBox.style.display = "none"; } document.getElementById("btn_pick_folder").addEventListener("click", async () => { clearError(); const resp = await fetch("/api/system/pick-folder", { method: "POST", body: "{}" }); const body = await resp.json(); if (resp.status === 422 && body.error === "DIALOG_UNAVAILABLE") { folderInput.removeAttribute("readonly"); folderHint.textContent = "当前环境不支持弹窗选择,请手动输入路径"; return; } if (body.selected_path) { folderInput.value = body.selected_path; } // user canceled the native dialog -> folder_path stays as it was (02-V §2.1) }); async function createProject(initGitIfMissing) { clearError(); confirmBanner.style.display = "none"; const folderPath = folderInput.value.trim(); if (!folderPath) { showError("请先选择目标文件夹"); return; } const resp = await fetch("/api/projects", { method: "POST", body: JSON.stringify({ target_folder_path: folderPath, name: nameInput.value.trim() || undefined, init_git_if_missing: initGitIfMissing, }), }); const body = await resp.json(); if (resp.status === 200) { setCurrentProjectId(body.project_id); window.location.hash = "#/"; return; } if (body.error === "GIT_INIT_DECLINED") { confirmBanner.style.display = "block"; return; } showError(body.error + (body.detail ? ":" + body.detail : "")); } document.getElementById("btn_create").addEventListener("click", () => createProject(false)); document.getElementById("btn_confirm_init").addEventListener("click", () => createProject(true)); document.getElementById("btn_cancel_init").addEventListener("click", () => { confirmBanner.style.display = "none"; }); } // ---- shared: pages that need a "current project" to already exist ---- function renderNoProjectSelected() { return `
还没有选择项目。请先创建或切换项目。
`; } // ---- v_goal_composer (02-V §2.3): propose a new goal ---- const GOAL_TEXT_MAX_LEN = 4000; function renderGoalComposer() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `
← 返回仪表盘

提出新目标

用自然语言描述你想让 graphlemon 完成的目标
`; } function mountGoalComposer() { if (!getCurrentProjectId()) return; const textArea = document.getElementById("goal_text"); const errorBox = document.getElementById("goal_error"); const form = document.getElementById("goal_form"); const confirmBanner = document.getElementById("goal_confirm_banner"); const confirmSummary = document.getElementById("goal_confirm_summary"); document.getElementById("btn_submit_goal").addEventListener("click", () => { errorBox.style.display = "none"; const goalText = textArea.value.trim(); if (!goalText) { errorBox.textContent = "目标描述不能为空"; errorBox.style.display = "block"; return; } confirmSummary.textContent = "即将创建任务:" + goalText; form.style.display = "none"; confirmBanner.style.display = "block"; }); document.getElementById("btn_edit_goal").addEventListener("click", () => { confirmBanner.style.display = "none"; form.style.display = "block"; }); document.getElementById("btn_confirm_goal").addEventListener("click", async () => { const resp = await fetch("/api/tasks", { method: "POST", body: JSON.stringify({ project_id: getCurrentProjectId(), goal_text: textArea.value.trim(), task_type: "goal", created_via: "human_console", }), }); const body = await resp.json(); if (resp.status !== 200) { confirmBanner.style.display = "none"; form.style.display = "block"; errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorBox.style.display = "block"; return; } // 02-V §2.3: 成功跳转 v_task_detail window.location.hash = "#/tasks/" + body.task_id; }); } // ---- v_bug_feedback (02-V §2.4): report a bug ---- function renderBugFeedback() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `
← 返回仪表盘

反馈 Bug

人工反馈,与 Validator 自动生成的 bug_record 复用同一张表(source=human)
`; } function mountBugFeedback() { if (!getCurrentProjectId()) return; const descriptionArea = document.getElementById("bug_description"); const reproStepsArea = document.getElementById("bug_repro_steps"); const relatedTaskIdSelect = document.getElementById("bug_related_task_id"); const errorBox = document.getElementById("bug_error"); const successBox = document.getElementById("bug_success"); const form = document.getElementById("bug_form"); fetch("/api/tasks?project_id=" + encodeURIComponent(getCurrentProjectId())) .then((resp) => resp.json()) .then((tasks) => { for (const task of tasks) { const option = document.createElement("option"); option.value = task.task_id; option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")"; relatedTaskIdSelect.appendChild(option); } }); document.getElementById("btn_submit_bug").addEventListener("click", async () => { errorBox.style.display = "none"; const description = descriptionArea.value.trim(); if (!description) { errorBox.textContent = "问题描述不能为空"; errorBox.style.display = "block"; return; } const relatedTaskId = relatedTaskIdSelect.value || undefined; const resp = await fetch("/api/bug-reports", { method: "POST", body: JSON.stringify({ project_id: getCurrentProjectId(), description: description, repro_steps: reproStepsArea.value.trim() || undefined, related_task_id: relatedTaskId, }), }); const body = await resp.json(); if (resp.status !== 200) { errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorBox.style.display = "block"; return; } // 02-V §2.4: 跳转到该 bug 的详情视图,MVP 复用 v_task_detail 的 bug 展示区 // -- only possible when the report was tied to a task; otherwise there's // no task detail page to land on, so show the confirmation inline. if (relatedTaskId) { window.location.hash = "#/tasks/" + relatedTaskId; return; } form.style.display = "none"; successBox.innerHTML = "已提交,Validator 将进行归因确认(bug " + body.bug_id + ")。" + '返回仪表盘'; successBox.style.display = "block"; }); } // ---- v_dashboard (02-V §2.2): real-time landing page ---- const ORG_NODE_ORDER = ["planner", "coder", "validator"]; // ---- Org Graph 三节点条点击可达详情 (2026-08-06 SDD): shared by // v_dashboard's `#org_strip` and v_task_detail's own inline org-strip below // -- both mark each `.org-node` with `data-org-node`, both route through // `#/tasks/:task_id/org/:org_node`, both pick "最新一次 work_graph_run" for // that org_node the same way (C6: plain array filtering, no new backend // query params). DAG/log/artifact display itself stays entirely inside // renderGraphVisualizerPanel/renderTodoTree/mountLogViewer/v_artifact_browser // (C3-C5) -- this block only does the "接线": which run is "latest", and // where on the already-rendered page to scroll/highlight. function getLatestRunForOrgNode(task, orgNode) { const runs = task.work_graph_runs.filter((r) => r.org_node === orgNode); return runs.length ? runs[runs.length - 1] : null; } // v_dashboard's org-strip: role + hint text, only ever links out to // v_task_detail (a genuine page change, so plain hash assignment is fine). function renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode) { return ORG_NODE_ORDER.map((node, i) => { const isActive = activeOrgNode === node; const runStatus = runStatusByOrgNode[node]; const hint = isActive ? "当前节点" : runStatus === "done" ? "已完成" : runStatus || "未开始"; const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; return '
' + node + '
' + escapeHtml(hint) + "
" + arrow; }).join(""); } // v_task_detail's inline "Org Graph" panel: plainer markup (no role/hint // split), `activeSet` already carries different semantics than the // dashboard's single activeOrgNode -- kept as its own function rather than // forcing the two call sites to share one shape. function renderTaskDetailOrgStripHtml(activeSet) { return ORG_NODE_ORDER.map((node, i) => { const arrow = i < ORG_NODE_ORDER.length - 1 ? '
→
' : ""; return '
' + node + "
" + arrow; }).join(""); } function mountOrgStripClicks(container, taskId) { if (!taskId) return; for (const el of container.querySelectorAll(".org-node[data-org-node]")) { el.addEventListener("click", () => { window.location.hash = "#/tasks/" + taskId + "/org/" + el.dataset.orgNode; }); } } // "组合视图" content for v_task_detail's "Org 节点详情" panel: reuses the // artifact list's own markup pattern (li[data-artifact-id], same as // renderArtifactSummaryList) filtered down to this run's own artifacts -- // linking to #/artifacts/:id (v_artifact_browser), not re-rendering artifact // content itself (C5). The DAG/to-do-tree/log panels are NOT duplicated here // -- they're already rendered once, further down this same page, by the // unmodified renderGraphVisualizerPanel/renderTodoTree calls; this function // only describes which run that is and lists its artifacts. function renderOrgComboPanelContent(task, orgNode) { const run = getLatestRunForOrgNode(task, orgNode); if (!run) { return '
' + escapeHtml(orgNode) + " 尚未开始(暂无 Work Graph run)
"; } const hasDag = !!run.graph_manifest && run.graph_manifest.nodes.length > 0; const artifacts = task.artifacts.filter((a) => a.produced_by_run_id === run.run_id); const artifactsHtml = artifacts.length ? ( '" ) : '
暂无 Artifact
'; return ( '
最新一次 run_id=' + escapeHtml(run.run_id) + "(status=" + escapeHtml(run.status) + ")" + (hasDag ? " · 已定位到下方 Work Graph / to-do 树,点击节点可展开日志" : " · 该 run 尚未生成 Work Graph") + "
" + '

该 run 产出的 Artifact

' + artifactsHtml ); } function wireOrgComboArtifactClicks(scopeEl) { const combo = scopeEl.querySelector("#org_combo_content"); if (!combo) return; for (const li of combo.querySelectorAll("li[data-artifact-id]")) { li.addEventListener("click", () => { window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId); }); } } // Called both on initial mount (deep link / dashboard click landed here with // an org_node already known) and from the inline org-strip's own click // handler (already on this page -- 02-V happy path 2.2 explicitly wants an // in-page scroll, not another full page render). Scrolls to the DAG panel // first (renders whenever graph_manifest exists), falls back to the to-do // tree panel, then to the combo panel itself for the "尚无 run"/"尚未生成 // Work Graph" empty states -- never throws if a target is missing. function focusOrgNodePanel(content, task, orgNode) { for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { el.classList.toggle("org-node-focused", el.dataset.orgNode === orgNode); } const comboContent = content.querySelector("#org_combo_content"); if (comboContent) { comboContent.innerHTML = renderOrgComboPanelContent(task, orgNode); wireOrgComboArtifactClicks(content); } const run = getLatestRunForOrgNode(task, orgNode); let target = run ? document.getElementById("graph-panel-" + run.run_id) : null; if (!target && run) target = document.getElementById("todo-tree-" + run.run_id); if (!target) target = content.querySelector("#org_combo_panel"); if (target && target.scrollIntoView) { target.scrollIntoView({ behavior: "smooth", block: "start" }); target.classList.add("org-highlight-flash"); setTimeout(() => target.classList.remove("org-highlight-flash"), 1600); } } function renderDashboard() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `

仪表盘

Org Graph 三节点状态

当前任务概要

预算用量

最近事件

`; } function mountDashboard() { const projectId = getCurrentProjectId(); if (!projectId) return; const orgStrip = document.getElementById("org_strip"); const taskSummary = document.getElementById("task_summary"); const budgetSummary = document.getElementById("budget_summary"); const eventTimeline = document.getElementById("event_timeline"); const quotaBanner = document.getElementById("quota_info_banner"); const wsBanner = document.getElementById("ws_disconnected_banner"); const recentEvents = []; // Set right before every renderOrgStrip() call below -- org-strip clicks // need to know which task_id to route to (#/tasks/:task_id/org/:org_node). let orgStripTaskId = null; function renderOrgStrip(activeOrgNode, runStatusByOrgNode) { orgStrip.innerHTML = renderDashboardOrgStripHtml(activeOrgNode, runStatusByOrgNode); mountOrgStripClicks(orgStrip, orgStripTaskId); } async function refreshTasksAndOrgGraph() { const resp = await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)); const tasks = await resp.json(); const active = tasks.find((t) => t.status !== "done") || tasks[0]; if (!active) { orgStripTaskId = null; orgStrip.innerHTML = '
暂无运行中任务
'; taskSummary.innerHTML = '
暂无运行中任务 · 提出第一个目标 →
'; quotaBanner.style.display = "none"; return; } orgStripTaskId = active.task_id; // v_dashboard Start affordance (2026-08-03): the full Pause/Stop set // only makes sense on v_task_detail (they need the task's own status // context to pick which one to show) -- the dashboard only ever needs // "queued and nothing has started yet", so it gets just Start. const startRowHtml = active.status !== "queued" ? "" : ( '
' + '' + "
" ); taskSummary.innerHTML = ( '
Goal' + escapeHtml(active.goal_text) + "
" + '
Loop Round' + active.loop_round + "
" + '
Status' + escapeHtml(active.status) + "
" + startRowHtml ); const dbStartBtn = document.getElementById("db_start_task"); if (dbStartBtn) { dbStartBtn.addEventListener("click", async () => { dbStartBtn.disabled = true; await fetch("/api/tasks/" + active.task_id + "/execute", { method: "POST" }); refreshTasksAndOrgGraph(); }); } const detailResp = await fetch("/api/tasks/" + active.task_id); const detail = await detailResp.json(); const runStatusByOrgNode = {}; let waitingNode = null; for (const run of detail.work_graph_runs) { runStatusByOrgNode[run.org_node] = run.status; if (!waitingNode) waitingNode = run.nodes.find((n) => n.status === "waiting_for_quota"); } renderOrgStrip(active.assigned_org_node, runStatusByOrgNode); if (waitingNode) { quotaBanner.innerHTML = "🕐 有任务正在等待 Claude Code 额度恢复 · " + '查看详情'; quotaBanner.style.display = "flex"; } else { quotaBanner.style.display = "none"; } } async function refreshBudget() { const resp = await fetch("/api/settings/budget"); const configs = await resp.json(); if (configs.length === 0) { budgetSummary.innerHTML = '
预算护栏未配置
'; return; } const c = configs[0]; const pct = c.limit_value > 0 ? Math.min(100, Math.round((c.current_usage / c.limit_value) * 100)) : 0; budgetSummary.innerHTML = ( '
' + c.current_usage.toLocaleString() + " / " + c.limit_value.toLocaleString() + " " + escapeHtml(c.limit_type) + "
" + '
' ); } function prependEvent(event) { recentEvents.unshift(event); recentEvents.length = Math.min(recentEvents.length, 20); eventTimeline.innerHTML = recentEvents.map((e) => '
' + escapeHtml(e.event_type) + '
' + escapeHtml(e.created_at || "") + "
" ).join("") || '
暂无事件
'; } refreshTasksAndOrgGraph(); refreshBudget(); eventTimeline.innerHTML = '
暂无事件
'; let wasDisconnected = false; const socket = connectEventSocket(projectId, null, { onEvent: (event) => { prependEvent(event); refreshTasksAndOrgGraph(); if (event.event_type === "budget_updated") refreshBudget(); }, onStatusChange: (status) => { wsBanner.style.display = status === "disconnected" ? "block" : "none"; if (status === "disconnected") { wasDisconnected = true; } else if (status === "connected" && wasDisconnected) { // REQ-12 boundary: reconnect doesn't replay every missed event, but // must at least catch up on current state via a fresh snapshot // fetch (03-C §3.14's own documented reconnect story). wasDisconnected = false; refreshTasksAndOrgGraph(); refreshBudget(); } }, }); registerRouteCleanup(() => socket.close()); } // ---- v_task_board (02-V §2.5): task list with filters ---- const TASK_STATUSES = [ "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed", "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done", ]; function renderTaskBoard() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `

任务看板

+ 提出新目标
task_idgoal_textstatusassigned_org_nodeloop_roundupdated_at
`; } function mountTaskBoard() { const projectId = getCurrentProjectId(); if (!projectId) return; const rowsEl = document.getElementById("tb_rows"); const keywordEl = document.getElementById("tb_keyword"); const statusEl = document.getElementById("tb_status"); const orgNodeEl = document.getElementById("tb_org_node"); const sortEl = document.getElementById("tb_sort"); async function refresh() { const params = new URLSearchParams({ project_id: projectId }); if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); if (statusEl.value) params.set("status", statusEl.value); if (orgNodeEl.value) params.set("org_node", orgNodeEl.value); const [sortField, sortOrder] = sortEl.value.split(":"); params.set("sort", sortField); params.set("order", sortOrder); const resp = await fetch("/api/tasks?" + params.toString()); const tasks = await resp.json(); if (tasks.length === 0) { rowsEl.innerHTML = '暂无任务'; return; } rowsEl.innerHTML = tasks.map((t) => ( '' + "" + t.task_id + "" + "" + escapeHtml(t.goal_text) + "" + '' + t.status + "" + "" + t.assigned_org_node + "" + "" + t.loop_round + "" + "" + escapeHtml(t.updated_at) + "" + "" )).join(""); for (const row of rowsEl.querySelectorAll("tr[data-task-id]")) { row.addEventListener("click", () => { window.location.hash = "#/tasks/" + row.dataset.taskId; }); } } keywordEl.addEventListener("input", refresh); statusEl.addEventListener("change", refresh); orgNodeEl.addEventListener("change", refresh); sortEl.addEventListener("change", refresh); refresh(); let wasDisconnected = false; const socket = connectEventSocket(projectId, null, { onEvent: (event) => { if (event.event_type === "task_status_changed") refresh(); }, onStatusChange: (status) => { if (status === "disconnected") { wasDisconnected = true; } else if (status === "connected" && wasDisconnected) { // REQ-12 boundary: catch up via a fresh snapshot on reconnect. wasDisconnected = false; refresh(); } }, }); registerRouteCleanup(() => socket.close()); } // ---- v_graph_visualizer (02-V §2.7): embedded Work Graph DAG, layered by // execution depth (computed from graph_manifest's depends_on edges) and // colored by work_node_execution.status. Not an independent route -- // embedded into v_task_detail below. The Org Graph layer from the same // spec section is skipped here since v_task_detail already renders an // equivalent org-strip panel just above (same classes/data); duplicating // it a second time immediately below would just be redundant scroll. function computeGraphLayers(manifestNodes) { const nodesById = {}; for (const n of manifestNodes) nodesById[n.node_id] = n; const layerById = {}; function layerOf(nodeId, seen) { if (nodeId in layerById) return layerById[nodeId]; if (seen.has(nodeId)) return 0; // dependency cycle -- shouldn't happen, don't loop forever seen.add(nodeId); const deps = (nodesById[nodeId] && nodesById[nodeId].depends_on) || []; const layer = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((d) => layerOf(d, seen))); layerById[nodeId] = layer; return layer; } for (const n of manifestNodes) layerOf(n.node_id, new Set()); const maxLayer = manifestNodes.length ? Math.max(...manifestNodes.map((n) => layerById[n.node_id])) : -1; const rows = Array.from({ length: maxLayer + 1 }, () => []); for (const n of manifestNodes) rows[layerById[n.node_id]].push(n); return rows; } const GRAPH_LEGEND_HTML = [ ["pending", "status-pending"], ["running", "status-running"], ["reflecting / retrying", "status-reflecting"], ["waiting_for_quota(信息态,非告警)", "status-waiting_for_quota"], ["blocked / failed", "status-blocked"], ["done", "status-done"], ].map(([label, cls]) => '
' + label + "
" ).join(""); function renderGraphVisualizerPanel(run, options) { if (!run.graph_manifest || run.graph_manifest.nodes.length === 0) return ""; // FR-4 (v_graph_console, 2026-08-03): `options` is new and optional -- // every existing call site (v_task_detail's `task.work_graph_runs.map( // renderGraphVisualizerPanel)`) keeps calling this with a single arg, so // `enableNodeClick` stays falsy there and the emitted DOM is byte-for-byte // what it always was (no clickable class, no data-clickable-node-exec-id, // no log-viewer-slot) -- v_task_detail's existing hover-tooltip-only // behavior is unaffected. Only v_graph_console opts in. const enableNodeClick = options && options.enableNodeClick; const nodesById = {}; for (const n of run.nodes) nodesById[n.node_id] = n; const rowsHtml = computeGraphLayers(run.graph_manifest.nodes).map((row) => { const cellsHtml = row.map((gn) => { const wn = nodesById[gn.node_id]; const status = wn ? wn.status : "pending"; const quotaAttrs = wn && wn.status === "waiting_for_quota" ? ' data-quota-limit-type="' + escapeHtml(wn.quota_limit_type || "") + '" data-quota-wait-since="' + escapeHtml(wn.quota_wait_started_at || "") + '"' : ""; const clickable = enableNodeClick && wn; const clickableAttrs = clickable ? ' data-clickable-node-exec-id="' + wn.node_exec_id + '"' : ""; const logSlotHtml = clickable ? '
' : ""; return ( '
" + '
' + escapeHtml(gn.node_id) + "
" + '
' + escapeHtml(wn ? wn.subagent_type : "尚未开始") + "
" + logSlotHtml + "
" ); }).join(""); return '
' + cellsHtml + "
"; }).join(""); return ( // id="graph-panel-": Org Graph 点击可达详情's scroll/highlight // target (focusOrgNodePanel above) -- additive, doesn't change any // existing class/data-* this function already emitted. '

' + run.org_node + " Work Graph DAG 视图(v_graph_visualizer,run_id=" + run.run_id + ")

" + '
' + rowsHtml + "
" + '
' + GRAPH_LEGEND_HTML + "
" + "
" ); } function mountGraphVisualizerTooltips(container) { const tooltip = document.getElementById("graph-tooltip"); if (!tooltip) return; for (const node of container.querySelectorAll(".work-node")) { node.addEventListener("mousemove", (evt) => { const lines = ["skill: " + (node.querySelector(".node-skill").textContent || "")]; if (node.dataset.quotaLimitType) { lines.push("quota_limit_type: " + node.dataset.quotaLimitType); lines.push("已等待:" + formatWaitDuration(node.dataset.quotaWaitSince)); } tooltip.innerHTML = "" + escapeHtml(node.dataset.nodeExecId || node.querySelector(".node-id").textContent) + "
" + lines.map(escapeHtml).join("
"); tooltip.style.left = evt.pageX + 12 + "px"; tooltip.style.top = evt.pageY + 12 + "px"; tooltip.classList.add("show"); }); node.addEventListener("mouseout", () => tooltip.classList.remove("show")); } } // FR-4 (v_graph_console): click-to-expand-logs, opt-in via // renderGraphVisualizerPanel(run, { enableNodeClick: true }) above. This is // a `click` listener, deliberately not `mousemove`/`mouseout` like // mountGraphVisualizerTooltips -- the two can be mounted on the same // container at once without conflicting, so v_graph_console keeps the // hover tooltip *and* gains click-to-expand as an additive capability. function mountWorkNodeClickExpand(container, { onNodeClick }) { for (const node of container.querySelectorAll(".work-node.clickable[data-clickable-node-exec-id]")) { node.addEventListener("click", () => { const nodeExecId = node.dataset.clickableNodeExecId; const statusClass = Array.from(node.classList).find((c) => c.startsWith("status-")); const status = statusClass ? statusClass.slice("status-".length) : "pending"; onNodeClick(nodeExecId, status, node); }); } } function formatWaitDuration(sqliteTimestamp) { if (!sqliteTimestamp) return "未知时长"; const startedAt = new Date(sqliteTimestamp.replace(" ", "T") + "Z").getTime(); const minutes = Math.max(0, Math.round((Date.now() - startedAt) / 60000)); return minutes < 60 ? minutes + " 分钟" : Math.round(minutes / 60) + " 小时"; } // ---- v_log_viewer (02-V §2.7.1): embedded raw execution log ---- // Non-terminal node status -> realtime scroll mode via c_ws_log_tail; // terminal status -> one-shot static fetch via c_execution_log_get. // Embedded per node_exec_id inside v_task_detail's to-do tree, mounted // lazily the first time a to-do row is expanded (mountTaskDetail wires the // click handler and tracks cleanup for whichever ones got opened). function renderLogViewerHtml(nodeExecId) { return ( '
' + '' + '下载完整日志' + "
" + '
加载中…
' ); } function mountLogViewer(nodeExecId, initialStatus) { const viewerEl = document.getElementById("log-viewer-" + nodeExecId); const autoScrollEl = document.querySelector('#todo-detail-' + nodeExecId + " .log-autoscroll"); if (!viewerEl) return () => {}; function appendLine(text) { const line = document.createElement("div"); line.className = "line"; line.textContent = text; viewerEl.appendChild(line); if (autoScrollEl && autoScrollEl.checked) viewerEl.scrollTop = viewerEl.scrollHeight; } async function loadStatic() { try { const resp = await fetch("/api/work-nodes/" + encodeURIComponent(nodeExecId) + "/log"); if (resp.status === 404) { viewerEl.innerHTML = '
日志暂不可用
'; return; } if (resp.status === 410) { viewerEl.innerHTML = '
该日志已超过保留期限被清理,保留天数可在设置中调整
'; return; } const body = await resp.json(); viewerEl.innerHTML = ""; if (!body.content) { viewerEl.innerHTML = '
暂无日志内容
'; return; } for (const line of body.content.split("\n")) appendLine(line); } catch (e) { viewerEl.innerHTML = '
日志暂不可用
'; } } if (isTerminalNodeStatus(initialStatus)) { loadStatic(); return () => {}; } // live mode still needs to show everything written so far, not just // chunks appended after this connection opens -- backfill via the same // static read before tailing new content (mounting is re-triggered on // every task-level refresh, e.g. from v_task_detail's own WS feed, so // this also re-populates content a reconnect would otherwise have wiped) loadStatic(); const socket = connectLogTailSocket(nodeExecId, { onChunk: (chunk) => { for (const line of chunk.split("\n")) if (line) appendLine(line); }, onClose: () => loadStatic(), // server closes on terminal status (03-C §3.6.2) -- switch to static mode }); return () => socket.close(); } const TERMINAL_NODE_STATUSES = new Set(["done", "blocked", "failed"]); function isTerminalNodeStatus(status) { return TERMINAL_NODE_STATUSES.has(status); } // ---- v_task_detail (02-V §2.6): task detail / process inspection ---- // Embeds a to-do tree built from graph_manifest (01-M 1.3's "补充说明") // and the v_graph_visualizer DAG view above. The raw-log v_log_viewer is a // separate Unit (U-027) and shows as a clearly-labeled placeholder here // until it lands, rather than faking its content. function renderTaskDetail(taskId) { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `
加载中…
`; } function mountTaskDetail(taskId, initialOrgNode) { const projectId = getCurrentProjectId(); if (!projectId) return; const content = document.getElementById("td_content"); // Org Graph 三节点条点击可达详情 (2026-08-06 SDD): which org_node's // "组合视图" (DAG/日志/Artifact) is currently focused -- seeded from the // #/tasks/:task_id/org/:org_node route (deep link / dashboard click) and // updated in place when the inline org-strip below is clicked while // already on this page, so a WS-triggered refresh() re-render keeps // pointing at the same node instead of losing focus. let focusedOrgNode = initialOrgNode || null; // v_log_viewer instances (02-V §2.7.1) mounted lazily on first to-do // expand -- tracked here so a re-render (WS-triggered refresh) or leaving // the page closes any live c_ws_log_tail connections instead of leaking them. const activeLogViewers = new Map(); function closeAllLogViewers() { for (const close of activeLogViewers.values()) close(); activeLogViewers.clear(); } function renderTodoTree(run) { if (!run.graph_manifest) return ""; const nodesById = {}; for (const n of run.nodes) nodesById[n.node_id] = n; const items = run.graph_manifest.nodes.map((gn) => { const wn = nodesById[gn.node_id]; const status = wn ? wn.status : "pending"; const detail = wn ? ( '
' + "retry_count: " + wn.retry_count + (wn.reflection_notes ? "
reflection_notes: " + escapeHtml(wn.reflection_notes) : "") + '
' + "
" ) : ""; return ( '
  • ' + '
    ' + '' + 'to-do: ' + escapeHtml(gn.node_id) + "" + '' + status + (wn ? " · node_exec_id=" + wn.node_exec_id : "") + "" + "
    " + detail + "
  • " ); }).join(""); return ( // id="todo-tree-": secondary scroll/highlight target when the // DAG panel above didn't render (graph_manifest missing). '

    ' + run.org_node + " Work Graph 的 to-do 树(run_id=" + run.run_id + ")

    " + '
      ' + items + "
    " + '
    DAG 视图(同一份 graph_manifest 的另一种呈现)见下方 v_graph_visualizer
    ' + "
    " ); } function renderDetail(task) { closeAllLogViewers(); const activeSet = new Set([task.assigned_org_node]); for (const run of task.work_graph_runs) { if (run.status !== "done" && run.status !== "failed") activeSet.add(run.org_node); } const orgStripHtml = renderTaskDetailOrgStripHtml(activeSet); // v_task_detail Start/Pause/Stop (2026-08-03): a task only ever has one // real thing "running" against it at a time -- exactly one of these // three blocks is shown, keyed off task.status, matching the states // task_execution.py itself understands (see its module docstring for // why Pause/Stop are a soft stop reusing blocked_pending_human rather // than a genuine subprocess kill / new status enum value). const isUserPauseStop = task.status === "blocked_pending_human" && (task.blocked_reason === "user_paused" || task.blocked_reason === "user_stopped"); const execControlsHtml = task.status === "queued" ? ( '
    ' + '
    ' ) : (task.status === "planning_brd_prd_sdd" || task.status === "coding") ? ( '
    ' + '' + '
    ' ) : ""; const alertHtml = task.status !== "blocked_pending_human" ? "" : isUserPauseStop ? ( '
    ' + (task.blocked_reason === "user_paused" ? "⏸ 已暂停" : "⏹ 已停止") + '
    ' ) : ( '
    ⚠ 已阻塞(' + escapeHtml(task.blocked_reason || "") + '),需要人工干预' + '
    ' ); const todoTreesHtml = task.work_graph_runs.map(renderTodoTree).join(""); const graphVisualizerHtml = task.work_graph_runs.map(renderGraphVisualizerPanel).join(""); const artifactsHtml = task.artifacts.length ? ( '" ) : '
    暂无 Artifact
    '; const bugsHtml = task.bug_records.length ? ( '" ) : '
    暂无 Bug 记录
    '; // v_goal_list CRUD (2026-08-03): editing/deleting a goal is only safe // while it's still 'queued' -- once real work has started, artifacts/ // bug_records/work_graph_runs all reference this task_id, and the // backend (task_query.TaskNotEditableError) rejects both operations // past that point anyway; hiding the controls here just avoids // presenting an action that would visibly fail. const goalCrudHtml = task.status !== "queued" ? "" : ( '
    ' + '' + '' + "
    " + '" + '" ); content.innerHTML = ( "

    " + escapeHtml(task.goal_text) + "

    " + goalCrudHtml + execControlsHtml + alertHtml + '
    ' + "
    STATUS" + escapeHtml(task.status) + "
    " + "
    LOOP_ROUND" + task.loop_round + "
    " + "
    ASSIGNED_ORG_NODE" + task.assigned_org_node + "
    " + "
    CREATED_VIA" + escapeHtml(task.created_via) + "
    " + "
    " + '

    Org Graph

    ' + orgStripHtml + "
    " // "Org 节点详情" (2026-08-06 SDD): click-through target -- filled in by // focusOrgNodePanel() below, either right after this render (deep // link/dashboard click already carries an org_node) or on a later // inline org-strip click. Left as the default hint until then. + '

    Org 节点详情

    ' + '
    点击上方 Org Graph 节点,查看该节点最新一次 Work Graph / 日志 / Artifact
    ' + "
    " + todoTreesHtml + graphVisualizerHtml + '
    ' + '

    关联 Artifact

    ' + artifactsHtml + "
    " + '

    关联 Bug 记录

    ' + bugsHtml + "
    " ); for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) { row.addEventListener("click", () => { const nodeExecId = row.dataset.nodeExecId; if (!nodeExecId) return; const detail = document.getElementById("todo-detail-" + nodeExecId); if (!detail) return; detail.classList.toggle("open"); if (detail.classList.contains("open") && !activeLogViewers.has(nodeExecId)) { const slot = document.getElementById("log-slot-" + nodeExecId); slot.innerHTML = renderLogViewerHtml(nodeExecId); activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, detail.dataset.status)); } }); } mountGraphVisualizerTooltips(content); // Org Graph 三节点条点击可达详情: clicking a node here (already on // v_task_detail) stays on this page -- update focusedOrgNode + the URL // (history.replaceState, no hashchange -> no full route re-render) and // scroll/highlight in place, per 02-V happy path 2.2. for (const el of content.querySelectorAll(".org-strip .org-node[data-org-node]")) { el.addEventListener("click", () => { focusedOrgNode = el.dataset.orgNode; const newHash = "#/tasks/" + taskId + "/org/" + focusedOrgNode; if (window.location.hash !== newHash) history.replaceState(null, "", newHash); focusOrgNodePanel(content, task, focusedOrgNode); }); } // Deep link / dashboard click landed here with an org_node already // known -- populate + scroll to it once, right after this render. if (focusedOrgNode) focusOrgNodePanel(content, task, focusedOrgNode); const resumeBtn = document.getElementById("btn_manual_resume"); if (resumeBtn) { resumeBtn.addEventListener("click", async () => { resumeBtn.disabled = true; await fetch("/api/tasks/" + taskId + "/route", { method: "POST", body: JSON.stringify({ decided_route: "manual_resume" }), }); refresh(); }); } const startBtn = document.getElementById("btn_start_task"); if (startBtn) { startBtn.addEventListener("click", async () => { startBtn.disabled = true; await fetch("/api/tasks/" + taskId + "/execute", { method: "POST" }); refresh(); }); } const pauseBtn = document.getElementById("btn_pause_task"); if (pauseBtn) { pauseBtn.addEventListener("click", async () => { pauseBtn.disabled = true; await fetch("/api/tasks/" + taskId + "/pause", { method: "POST" }); refresh(); }); } const stopBtn = document.getElementById("btn_stop_task"); if (stopBtn) { stopBtn.addEventListener("click", async () => { stopBtn.disabled = true; await fetch("/api/tasks/" + taskId + "/stop", { method: "POST" }); refresh(); }); } const editBtn = document.getElementById("btn_edit_goal_text"); const deleteBtn = document.getElementById("btn_delete_goal"); if (editBtn) { const editPanel = document.getElementById("edit_goal_panel"); const deleteConfirm = document.getElementById("delete_goal_confirm"); const textarea = document.getElementById("edit_goal_textarea"); const errorBox = document.getElementById("edit_goal_error"); editBtn.addEventListener("click", () => { deleteConfirm.style.display = "none"; editPanel.style.display = "block"; }); document.getElementById("btn_cancel_edit_goal").addEventListener("click", () => { editPanel.style.display = "none"; }); document.getElementById("btn_save_goal_text").addEventListener("click", async () => { errorBox.style.display = "none"; const goalText = textarea.value.trim(); if (!goalText) { errorBox.textContent = "目标描述不能为空"; errorBox.style.display = "block"; return; } const resp = await fetch("/api/tasks/" + taskId, { method: "PUT", body: JSON.stringify({ goal_text: goalText }), }); const body = await resp.json(); if (resp.status !== 200) { errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorBox.style.display = "block"; return; } editPanel.style.display = "none"; refresh(); }); deleteBtn.addEventListener("click", () => { editPanel.style.display = "none"; deleteConfirm.style.display = "block"; }); document.getElementById("btn_cancel_delete_goal").addEventListener("click", () => { deleteConfirm.style.display = "none"; }); document.getElementById("btn_confirm_delete_goal").addEventListener("click", async () => { await fetch("/api/tasks/" + taskId, { method: "DELETE" }); window.location.hash = "#/tasks"; }); } } async function refresh() { const resp = await fetch("/api/tasks/" + taskId); if (resp.status === 404) { content.innerHTML = '
    任务不存在
    '; return; } renderDetail(await resp.json()); } refresh(); let wasDisconnected = false; const socket = connectEventSocket(projectId, taskId, { onEvent: () => refresh(), onStatusChange: (status) => { if (status === "disconnected") { wasDisconnected = true; } else if (status === "connected" && wasDisconnected) { // REQ-12 boundary: catch up via a fresh snapshot on reconnect. wasDisconnected = false; refresh(); } }, }); registerRouteCleanup(() => { socket.close(); closeAllLogViewers(); }); } // ---- v_run_detail (`#/runs/:run_id`, U-046 2026-08-06 SDD): single Work // Graph Run detail -- every work_node_execution the run contains, each with // its own log (live c_ws_log_tail while running, static c_execution_log_get // once terminal). Reuses renderGraphVisualizerPanel/mountLogViewer // unmodified, same as v_task_detail's todo tree above; the one DOM-id // contract mountLogViewer relies on (`#todo-detail- // .log-autoscroll`) is reproduced here via renderRunNodeListHtml so the // existing auto-scroll checkbox keeps working without touching // mountLogViewer itself. function renderRunNodeListHtml(run) { if (!run.nodes.length) return '
    该运行尚无节点
    '; const items = run.nodes.map((wn) => { const status = wn.status; return ( '
  • ' + '
    ' + '' + '' + escapeHtml(wn.node_id) + "" + '' + escapeHtml(status) + " · " + escapeHtml(wn.subagent_type) + " · retry_count=" + wn.retry_count + " · node_exec_id=" + wn.node_exec_id + "" + "
    " + '
    ' + (wn.reflection_notes ? "reflection_notes: " + escapeHtml(wn.reflection_notes) + "
    " : "") + '
    ' + "
    " + "
  • " ); }).join(""); return '
      ' + items + "
    "; } function renderRunDetail(runId) { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `
    加载中…
    `; } function mountRunDetail(runId) { const projectId = getCurrentProjectId(); if (!projectId) return; const content = document.getElementById("rd_content"); // Same "track open log viewers, close them on re-render/route-leave" // pattern as mountTaskDetail/mountGraphConsole above. const activeLogViewers = new Map(); function closeAllLogViewers() { for (const close of activeLogViewers.values()) close(); activeLogViewers.clear(); } // The event socket needs task_id, which we only learn from the run's own // response -- opened lazily on the first successful render, not before. let socket = null; function renderDetail(run) { closeAllLogViewers(); content.innerHTML = ( "

    " + escapeHtml(run.org_node) + " Work Graph Run

    " + '
    ' + "
    RUN_ID" + escapeHtml(run.run_id) + "
    " + "
    STATUS" + escapeHtml(run.status) + "
    " + "
    STARTED_AT" + escapeHtml(run.started_at || "") + "
    " + "
    COMPLETED_AT" + escapeHtml(run.completed_at || "") + "
    " + '
    TASK' + escapeHtml(run.task_id) + "
    " + "
    " + renderGraphVisualizerPanel(run) + '
    ' + '

    节点列表

    ' + renderRunNodeListHtml(run) + "
    " ); for (const row of content.querySelectorAll(".todo-row[data-node-exec-id]")) { row.addEventListener("click", () => { const nodeExecId = row.dataset.nodeExecId; if (!nodeExecId) return; const detail = document.getElementById("todo-detail-" + nodeExecId); if (!detail) return; detail.classList.toggle("open"); if (detail.classList.contains("open") && !activeLogViewers.has(nodeExecId)) { const slot = document.getElementById("log-slot-" + nodeExecId); slot.innerHTML = renderLogViewerHtml(nodeExecId); activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, detail.dataset.status)); } }); } mountGraphVisualizerTooltips(content); if (!socket) { socket = connectEventSocket(projectId, run.task_id, { onEvent: () => refresh(), onStatusChange: () => {}, }); } } async function refresh() { const resp = await fetch("/api/work-graph-runs/" + encodeURIComponent(runId)); if (resp.status === 404) { content.innerHTML = '
    运行不存在
    '; return; } renderDetail(await resp.json()); } refresh(); registerRouteCleanup(() => { if (socket) socket.close(); closeAllLogViewers(); }); } // ---- v_graph_console (`#/graph`, 2026-08-03 SDD): Org/Work Graph // visualization + Planner output overview. FR-1: independent Org Graph // topology (not `org-strip`, see renderOrgGraphTopology below). FR-2/3: // reuses computeGraphLayers/renderGraphVisualizerPanel via a task switcher. // FR-4: click-to-expand logs, wired through the `enableNodeClick` param // added to renderGraphVisualizerPanel above. FR-5: a read-only "most // recent N + 查看全部" overview of documents/code/graph runs/tasks below // the graphs -- no local filtering/pagination reimplemented here, matching // the SDD's explicit non-goal. function renderOrgGraphTopology(activeOrgNode, runStatusByOrgNode, route) { const nodesHtml = ORG_NODE_ORDER.map((node, i) => { const status = runStatusByOrgNode[node] || "not_started"; const isActive = activeOrgNode === node; const nodeHtml = ( '
    ' + '
    ' + node + "
    " + '
    ' + escapeHtml(status) + "
    " + "
    " ); const flowEdgeHtml = i < ORG_NODE_ORDER.length - 1 ? '
    →
    ' : ""; return nodeHtml + flowEdgeHtml; }).join(""); // 04-P p_org_loop_route semantics: fast_lane loops back validator->coder, // full_replan loops back validator->planner. task.route is null until the // first loop actually happens -- no retry edge drawn in that case. let retryEdgeHtml = ""; if (route === "fast_lane") { retryEdgeHtml = '
    ↩ validator → coder(fast_lane)
    '; } else if (route === "full_replan") { retryEdgeHtml = '
    ↩ validator → planner(full_replan)
    '; } return '
    ' + nodesHtml + "
    " + retryEdgeHtml; } const GC_DOC_ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark"]; const GC_CODE_ARTIFACT_TYPES = ["code_diff", "test_report"]; function renderArtifactSummaryList(artifacts, types, emptyText) { const filtered = artifacts.filter((a) => types.includes(a.artifact_type)).slice(0, 5); if (filtered.length === 0) return '
    ' + emptyText + "
    "; // Each item links to v_artifact_browser (#/artifacts/:artifact_id) -- // click handlers are wired in mountArtifactsOverviewPanel after this HTML // is inserted into the DOM. This deliberately reuses the existing // browser/preview view rather than building a second content display. return '
      ' + filtered.map((a) => '
    • ' + escapeHtml(a.artifact_type) + " · " + escapeHtml(a.file_path.split("/").pop()) + "" + escapeHtml(a.validation_status) + "
    • " ).join("") + "
    "; } // FR-5: "查看全部" for documents/code/tasks intentionally lands on // #/artifacts / #/tasks with no filter pre-applied -- v_artifact_browser // and v_task_board don't parse URL query params today, and adding that is // out of scope for this page (SDD §2.5's explicit, recorded trade-off). // Graph run rows: U-046 fixed the previous shortcut (switching this same // page's own task switcher via onSwitchToTask, because there was no // separate "run detail" route) to navigate to the real v_run_detail page // (#/runs/:run_id) instead. async function mountArtifactsOverviewPanel(container, projectId) { container.innerHTML = '

    Planner 产出总览

    加载中…
    '; const [artifacts, runs, tasks] = await Promise.all([ fetch("/api/artifacts?project_id=" + encodeURIComponent(projectId)).then((r) => r.json()), fetch("/api/work-graph-runs?project_id=" + encodeURIComponent(projectId) + "&limit=10").then((r) => (r.ok ? r.json() : [])), fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)).then((r) => r.json()), ]); const docsHtml = renderArtifactSummaryList(artifacts, GC_DOC_ARTIFACT_TYPES, "暂无文档类 Artifact"); const codeHtml = renderArtifactSummaryList(artifacts, GC_CODE_ARTIFACT_TYPES, "暂无代码类 Artifact"); const runsHtml = runs.length === 0 ? '
    暂无 Graph 运行记录
    ' : ( '
      ' + runs.slice(0, 10).map((r) => '
    • ' + escapeHtml(r.org_node) + " · run " + r.run_id.slice(0, 8) + "" + escapeHtml(r.status) + " · " + r.node_count + " 节点
    • " ).join("") + "
    " ); const recentTasks = tasks.slice(0, 10); const tasksHtml = recentTasks.length === 0 ? '
    暂无任务
    ' : ( '
      ' + recentTasks.map((t) => "
    • " + escapeHtml(t.goal_text.slice(0, 40)) + "" + escapeHtml(t.status) + "
    • " ).join("") + "
    " ); container.innerHTML = ( '

    Planner 产出总览

    ' + '
    ' + '

    文档列表(brd/prd/sdd/benchmark)

    ' + docsHtml + '
    ' + '

    代码列表(code_diff/test_report)

    ' + codeHtml + '
    ' + '

    Graph 运行历史

    ' + runsHtml + "
    " + '

    任务列表

    ' + tasksHtml + '
    ' + "
    " ); const runsListEl = document.getElementById("gc_runs_list"); if (runsListEl) { for (const li of runsListEl.querySelectorAll("li[data-run-id]")) { li.addEventListener("click", () => { window.location.hash = "#/runs/" + encodeURIComponent(li.dataset.runId); }); } } // Doc/code list items: jump to the existing v_artifact_browser detail // view (#/artifacts/:artifact_id) pre-selecting this artifact. "查看全部" // links above stay pointed at unfiltered #/artifacts per the FR-5 note. for (const li of container.querySelectorAll("li[data-artifact-id]")) { li.addEventListener("click", () => { window.location.hash = "#/artifacts/" + encodeURIComponent(li.dataset.artifactId); }); } } function renderGraphConsole() { if (!getCurrentProjectId()) return renderNoProjectSelected(); return `

    Graph 控制台

    Org Graph / Work Graph 拓扑与执行状态 · 点击 Work Graph 节点查看 Agent 日志

    Org Graph

    加载中…
    加载中…
    加载中…
    `; } function mountGraphConsole() { const projectId = getCurrentProjectId(); if (!projectId) return; const taskSelect = document.getElementById("gc_task_select"); const orgTopologyEl = document.getElementById("gc_org_topology"); const workGraphPanelsEl = document.getElementById("gc_work_graph_panels"); const artifactsOverviewEl = document.getElementById("gc_artifacts_overview"); let currentTaskId = null; // Same "track open log viewers, close them all on re-render/route-leave" // pattern as mountTaskDetail's activeLogViewers/closeAllLogViewers, so a // task switch or WS-triggered refresh doesn't leak c_ws_log_tail sockets. const activeLogViewers = new Map(); function closeAllLogViewers() { for (const close of activeLogViewers.values()) close(); activeLogViewers.clear(); } function onWorkNodeClick(nodeExecId, status, nodeEl) { const slot = nodeEl.querySelector(".log-viewer-slot"); if (!slot) return; if (activeLogViewers.has(nodeExecId)) { activeLogViewers.get(nodeExecId)(); activeLogViewers.delete(nodeExecId); slot.innerHTML = ""; nodeEl.classList.remove("expanded"); return; } // Wrapping element id `todo-detail-` reuses the same // selector mountLogViewer already looks for (`#todo-detail- // .log-autoscroll`) -- lets the existing auto-scroll checkbox work here // too without touching mountLogViewer's own implementation at all. slot.innerHTML = '
    ' + renderLogViewerHtml(nodeExecId) + "
    "; activeLogViewers.set(nodeExecId, mountLogViewer(nodeExecId, status)); nodeEl.classList.add("expanded"); } function refreshArtifactsOverview() { mountArtifactsOverviewPanel(artifactsOverviewEl, projectId); } async function loadTaskAndRender(taskId) { closeAllLogViewers(); currentTaskId = taskId; workGraphPanelsEl.innerHTML = '
    加载中…
    '; const resp = await fetch("/api/tasks/" + encodeURIComponent(taskId)); if (resp.status !== 200) { workGraphPanelsEl.innerHTML = '
    任务不存在
    '; return; } const task = await resp.json(); const runStatusByOrgNode = {}; for (const run of task.work_graph_runs) runStatusByOrgNode[run.org_node] = run.status; orgTopologyEl.innerHTML = renderOrgGraphTopology(task.assigned_org_node, runStatusByOrgNode, task.route); const panelsHtml = task.work_graph_runs .map((run) => renderGraphVisualizerPanel(run, { enableNodeClick: true })) .join(""); if (!panelsHtml) { workGraphPanelsEl.innerHTML = '
    尚未开始规划
    '; return; } workGraphPanelsEl.innerHTML = panelsHtml; mountGraphVisualizerTooltips(workGraphPanelsEl); mountWorkNodeClickExpand(workGraphPanelsEl, { onNodeClick: onWorkNodeClick }); } async function populateTaskSelect() { const tasks = await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)).then((r) => r.json()); if (tasks.length === 0) { taskSelect.innerHTML = ''; orgTopologyEl.innerHTML = ''; workGraphPanelsEl.innerHTML = ""; return; } taskSelect.innerHTML = tasks.map((t) => '" ).join(""); // 默认选中当前项目下第一个非 done 的任务,与 v_dashboard. // refreshTasksAndOrgGraph 的既有约定一致(app.js: `tasks.find((t) => // t.status !== "done") || tasks[0]`)。 const active = tasks.find((t) => t.status !== "done") || tasks[0]; const preselect = (currentTaskId && tasks.some((t) => t.task_id === currentTaskId)) ? currentTaskId : active.task_id; taskSelect.value = preselect; await loadTaskAndRender(preselect); } taskSelect.addEventListener("change", () => loadTaskAndRender(taskSelect.value)); populateTaskSelect(); refreshArtifactsOverview(); let wasDisconnected = false; const socket = connectEventSocket(projectId, null, { onEvent: (event) => { if ( (event.event_type === "task_status_changed" || event.event_type === "node_status_changed") && currentTaskId && event.task_id === currentTaskId ) { loadTaskAndRender(currentTaskId); } if (event.event_type === "artifact_created") refreshArtifactsOverview(); }, onStatusChange: (status) => { if (status === "disconnected") { wasDisconnected = true; } else if (status === "connected" && wasDisconnected) { // REQ-12 boundary: catch up via a fresh snapshot on reconnect. wasDisconnected = false; if (currentTaskId) loadTaskAndRender(currentTaskId); refreshArtifactsOverview(); } }, }); registerRouteCleanup(() => { socket.close(); closeAllLogViewers(); }); } // ---- v_artifact_browser (02-V §2.8): artifact list + read-only preview ---- // Preview renders content in a plain
     block for every artifact_type --
    // 02-V asks for Markdown rendering / syntax highlighting, but pulling in a
    // markdown/highlighting library would be this frontend's first external
    // dependency (05 §5.7's "no build step" ethos extends to keeping the
    // console vanilla-JS-only); a monospace raw-text preview is a deliberate,
    // documented simplification, not a missed requirement.
    
    const ARTIFACT_TYPES = ["brd", "prd", "sdd", "benchmark", "bug_record", "code_diff", "test_report"];
    
    function renderArtifactBrowser() {
      if (!getCurrentProjectId()) return renderNoProjectSelected();
      return `
        

    Artifact 浏览器

      选择左侧一个 Artifact 查看预览
      `; } function mountArtifactBrowser(initialArtifactId) { const projectId = getCurrentProjectId(); if (!projectId) return; const listEl = document.getElementById("ab_list"); const previewEl = document.getElementById("ab_preview"); const typeFilterEl = document.getElementById("ab_type_filter"); const taskFilterEl = document.getElementById("ab_task_filter"); // Seeded from the #/artifacts/:artifact_id route (v_graph_console's // Planner 产出总览 list links here). refreshList()'s existing fallback // below already handles an id that turns out invalid/filtered-out -- // falls back to the first artifact in the (possibly filtered) list. let selectedArtifactId = initialArtifactId || null; fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)) .then((resp) => resp.json()) .then((tasks) => { for (const task of tasks) { const option = document.createElement("option"); option.value = task.task_id; option.textContent = task.goal_text.slice(0, 40) + " (" + task.task_id.slice(0, 8) + ")"; taskFilterEl.appendChild(option); } }); async function showPreview(artifactId) { selectedArtifactId = artifactId; for (const li of listEl.querySelectorAll("li")) { li.classList.toggle("active", li.dataset.artifactId === artifactId); } previewEl.innerHTML = '
      加载中…
      '; const artifact = await fetch("/api/artifacts/" + encodeURIComponent(artifactId)).then((r) => r.json()); const lineageHtml = artifact.lineage.map((a, i) => { const isCurrent = a.artifact_id === artifactId; const label = escapeHtml(a.artifact_type) + (isCurrent ? "(当前)" : ""); const arrow = i < artifact.lineage.length - 1 ? " → " : ""; return '' + label + "" + arrow; }).join(""); previewEl.innerHTML = ( '
      血缘链:' + lineageHtml + "
      " + '
      ' + (artifact.content == null ? "(文件不可用)" : escapeHtml(artifact.content)) + "
      " ); } async function refreshList() { const params = new URLSearchParams({ project_id: projectId }); if (typeFilterEl.value) params.set("artifact_type", typeFilterEl.value); if (taskFilterEl.value) params.set("task_id", taskFilterEl.value); const artifacts = await fetch("/api/artifacts?" + params.toString()).then((r) => r.json()); if (artifacts.length === 0) { listEl.innerHTML = '
    • 暂无产物
    • '; previewEl.innerHTML = '
      暂无产物
      '; return; } listEl.innerHTML = artifacts.map((a) => '
    • ' + escapeHtml(a.artifact_type) + "
      " + escapeHtml(a.file_path.split("/").pop()) + "
    • " ).join(""); for (const li of listEl.querySelectorAll("li[data-artifact-id]")) { li.addEventListener("click", () => showPreview(li.dataset.artifactId)); } if (selectedArtifactId && artifacts.some((a) => a.artifact_id === selectedArtifactId)) { showPreview(selectedArtifactId); } else { showPreview(artifacts[0].artifact_id); } } typeFilterEl.addEventListener("change", refreshList); taskFilterEl.addEventListener("change", refreshList); refreshList(); const socket = connectEventSocket(projectId, null, { onEvent: (event) => { if (event.event_type === "artifact_created") refreshList(); }, onStatusChange: () => {}, }); registerRouteCleanup(() => socket.close()); } // ---- v_skill_library (02-V §2.9) + v_skill_audit_log (02-V §2.10) ---- // Skills are a platform-wide asset living in graphlemon's own repo (see // skill_query.py), not scoped to any target project -- these two pages // deliberately don't check getCurrentProjectId(), unlike every other real // page in this console. const SOURCE_TAG_LABELS = { migrated_from_hermes: "migrated_from_hermes", new: "new", auto_iterated: "auto_iterated", }; function renderSkillLibrary() { return `

      技能库

      加载中…
      `; } function mountSkillLibrary() { const groupsEl = document.getElementById("skill_groups"); fetch("/api/skills").then((r) => r.json()).then((skills) => { if (skills.length === 0) { groupsEl.innerHTML = '
      暂无 Skill
      '; return; } const byOrgNode = { planner: [], coder: [], validator: [] }; for (const s of skills) (byOrgNode[s.owner_org_node] || []).push(s); groupsEl.innerHTML = ORG_NODE_ORDER.map((node) => { const rows = byOrgNode[node]; if (!rows || rows.length === 0) return ""; const rowsHtml = rows.map((s) => '
      ' + '' + escapeHtml(s.skill_id) + "" + '' + escapeHtml(SOURCE_TAG_LABELS[s.source] || s.source) + "" + "
      " ).join(""); return ( '

      ' + node.charAt(0).toUpperCase() + node.slice(1) + "

      " + '
      ' + rowsHtml + "
      " ); }).join(""); for (const row of groupsEl.querySelectorAll(".skill-row")) { row.addEventListener("click", () => { window.location.hash = "#/skills/" + row.dataset.skillId + "/audit"; }); } }); } function renderSkillAuditLog(skillId) { return `

      ${escapeHtml(skillId)} 变更审计

      Skill 内容

      加载中…

      迭代记录

      加载中…
      `; } function mountSkillAuditLog(skillId) { const listEl = document.getElementById("skill_audit_list"); const errorEl = document.getElementById("skill_audit_error"); const contentEl = document.getElementById("skill_content"); // Independent of the iteration-history panel below (a skill can have real // content with zero skill_iteration rows -- that's the normal post- // migration state) -- fetched and rendered separately, side by side, not // as an either/or with refresh()'s own list. fetch("/api/skills/" + encodeURIComponent(skillId) + "/content").then(async (resp) => { if (resp.status !== 200) { const body = await resp.json(); contentEl.innerHTML = '
      ' + (body.error === "CONTENT_MISSING" ? "该 Skill 的真实文件尚未迁移" : "该 Skill 不存在") + "
      "; return; } const skill = await resp.json(); const supportHtml = skill.support_files.length ? '
      支持文件:' + skill.support_files.map(escapeHtml).join("、") + "
      " : ""; contentEl.innerHTML = supportHtml + '
      ' + escapeHtml(skill.content) + "
      "; }); async function refresh() { const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/audit"); if (resp.status === 404) { listEl.innerHTML = ""; errorEl.textContent = "该 Skill 不存在"; errorEl.style.display = "block"; return; } const iterations = await resp.json(); if (iterations.length === 0) { listEl.innerHTML = '
      该 Skill 尚无自主迭代记录
      '; return; } // newest first for display, oldest-first order from the API gives each its #N listEl.innerHTML = iterations.slice().reverse().map((it, i) => { const number = iterations.length - i; return ( '
      ' + '
      迭代 #' + number + "applied_at: " + escapeHtml(it.applied_at) + "
      " + '
      triggered_by_bug_id: ' + escapeHtml(it.triggered_by_bug_id) + " · " + escapeHtml(it.change_summary) + "
      " + '
      ' + '' + '' + "
      " + '' + "
      " ); }).join(""); for (const el of listEl.querySelectorAll(".iteration")) { const diffOutput = el.querySelector(".diff-output"); el.querySelector(".btn-diff").addEventListener("click", async () => { if (diffOutput.style.display === "block") { diffOutput.style.display = "none"; return; } diffOutput.textContent = "加载中…"; diffOutput.style.display = "block"; const resp = await fetch( "/api/skills/diff?before=" + encodeURIComponent(el.dataset.before) + "&after=" + encodeURIComponent(el.dataset.after) ); const body = await resp.json(); diffOutput.textContent = body.diff_text || "(无差异)"; }); el.querySelector(".btn-rollback").addEventListener("click", async () => { const resp = await fetch("/api/skills/" + encodeURIComponent(skillId) + "/rollback", { method: "POST", body: JSON.stringify({ iteration_id: el.dataset.iterationId }), }); if (resp.status !== 200) { const body = await resp.json(); errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorEl.style.display = "block"; return; } errorEl.style.display = "none"; refresh(); }); } } refresh(); } // ---- v_deployment_status (U-043, PH5 -- no 02-V spec exists for this view, // it's a PH5-only addition; DOM/class conventions below follow the existing // pages' own patterns rather than a wireframe that was never written) ---- // Not project-scoped (there's exactly one graphlemon self-target, not one // per project, same reasoning v_skill_library's pages already apply). // deployment.status changes aren't pushed over c_ws_subscribe (Deploy // Supervisor writes deployment/deploy_health_signal rows directly, no // execution_event of its own) -- this page polls on an interval instead of // reusing connectEventSocket, and cleans the interval up via // registerRouteCleanup like every other page cleans up its own sockets. const DEPLOYMENT_POLL_INTERVAL_MS = 5000; const DEPLOYMENT_STATUS_LABELS = { staged: "已暂存", candidate_starting: "候选启动中", health_check_running: "健康检查中", health_check_passed: "健康检查通过", health_check_failed: "健康检查失败", cutover_in_progress: "切换中", live: "已上线", monitoring: "监控中", anomaly_detected: "检测到异常", rollback_in_progress: "回滚中", rolled_back: "已回滚", rollback_failed: "回滚失败", blocked_pending_human: "需要人工介入", monitoring_window_clean: "监控窗口正常", finalized: "已完成", }; function renderDeploymentStatus() { return `

      部署状态

      加载中…

      历史部署

      加载中…
      `; } function mountDeploymentStatus() { const currentEl = document.getElementById("deployment_current"); const historyEl = document.getElementById("deployment_history"); function renderSignals(signals) { if (!signals || signals.length === 0) return '
      尚无健康信号采样
      '; return '
      ' + signals.map((s) => '
      ' + '' + escapeHtml(s.signal_type) + '' + '' + escapeHtml(s.status) + '' + '' + escapeHtml(s.sampled_at) + '' + (s.detail ? '' + escapeHtml(s.detail) + '' : '') + '
      ' ).join('') + '
      '; } function renderCurrent(current) { if (!current) { currentEl.innerHTML = '
      当前没有进行中的 deployment
      '; return; } const label = DEPLOYMENT_STATUS_LABELS[current.status] || current.status; currentEl.innerHTML = ( '
      ' + '
      deployment_id: ' + escapeHtml(current.deployment_id) + '' + '' + escapeHtml(label) + '
      ' + '
      commit ' + escapeHtml((current.git_commit_hash || '').slice(0, 12)) + ' · started_at ' + escapeHtml(current.started_at || '-') + '
      ' + '
      ' ); } function renderHistory(deployments) { if (deployments.length === 0) { historyEl.innerHTML = '
      暂无历史部署记录
      '; return; } historyEl.innerHTML = deployments.map((d) => { const label = DEPLOYMENT_STATUS_LABELS[d.status] || d.status; return ( '
      ' + '' + escapeHtml(d.deployment_id.slice(0, 8)) + '' + '' + escapeHtml(label) + '' + '' + escapeHtml(d.cutover_at || d.started_at || '-') + '' + '
      ' ); }).join(''); for (const row of historyEl.querySelectorAll('.deployment-row')) { row.addEventListener('click', async () => { const detail = await fetch('/api/deployments/' + encodeURIComponent(row.dataset.deploymentId)).then((r) => r.json()); currentEl.innerHTML = ( '
      ' + '
      deployment_id: ' + escapeHtml(detail.deployment_id) + '' + '' + escapeHtml(DEPLOYMENT_STATUS_LABELS[detail.status] || detail.status) + '
      ' + '
      commit ' + escapeHtml((detail.git_commit_hash || '').slice(0, 12)) + '
      ' + '

      最近健康信号

      ' + renderSignals(detail.health_signals) + '
      ' ); }); } } async function refresh() { const [current, history] = await Promise.all([ fetch('/api/deployments/current').then((r) => r.json()), fetch('/api/deployments').then((r) => r.json()), ]); renderCurrent(current); renderHistory(history); } refresh(); const intervalId = setInterval(refresh, DEPLOYMENT_POLL_INTERVAL_MS); registerRouteCleanup(() => clearInterval(intervalId)); } // ---- v_concurrency_settings / v_budget_settings / v_log_retention_settings // (02-V §2.11/§2.12/§2.13) -- three small config pages, none of them // project-scoped (all three tables are platform-wide singletons, config_id // ='default'), so like the Skill pages they skip the getCurrentProjectId() // guard. renderSettingsSubnav() stands in for 02-V's nav-dropdown, which // this shell's single-level #nav bar doesn't have room for. function renderSettingsSubnav(active) { const items = [["concurrency", "并发数"], ["budget", "预算护栏"], ["log-retention", "日志保留"]]; return '
      ' + items.map(([key, label]) => '' + label + "" ).join("") + "
      "; } function renderConcurrencySettings() { return `

      并发数配置

      ${renderSettingsSubnav("concurrency")}
      调大后下一个新 Goal 起生效
      `; } function mountConcurrencySettings() { const input = document.getElementById("cs_max_concurrent_goals"); const errorEl = document.getElementById("cs_error"); const successEl = document.getElementById("cs_success"); fetch("/api/settings/concurrency").then((r) => r.json()).then((config) => { if (config) input.value = config.max_concurrent_goals; }); document.getElementById("btn_save_concurrency").addEventListener("click", async () => { errorEl.style.display = "none"; successEl.style.display = "none"; const resp = await fetch("/api/settings/concurrency", { method: "PUT", body: JSON.stringify({ max_concurrent_goals: Number(input.value) }), }); const body = await resp.json(); if (resp.status !== 200) { errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorEl.style.display = "block"; return; } successEl.textContent = "已更新,下一个新 Goal 起生效"; successEl.style.display = "block"; }); } function renderBudgetSettings() { return `

      预算护栏配置

      ${renderSettingsSubnav("budget")}
      人工配置层,与 Claude Code plan 额度耗尽是两套独立机制
      `; } function mountBudgetSettings() { const scopeEl = document.getElementById("bs_scope"); const limitTypeEl = document.getElementById("bs_limit_type"); const limitValueEl = document.getElementById("bs_limit_value"); const usageEl = document.getElementById("bs_usage"); const usageFillEl = document.getElementById("bs_usage_fill"); const alertEl = document.getElementById("bs_alert"); const errorEl = document.getElementById("bs_error"); const successEl = document.getElementById("bs_success"); async function refresh() { const configs = await fetch("/api/settings/budget").then((r) => r.json()); const config = configs[0]; if (config) { scopeEl.value = config.scope; limitTypeEl.value = config.limit_type; limitValueEl.value = config.limit_value; usageEl.textContent = "当前用量:" + config.current_usage.toLocaleString() + " / " + config.limit_value.toLocaleString(); const pct = config.limit_value > 0 ? Math.min(100, Math.round((config.current_usage / config.limit_value) * 100)) : 0; usageFillEl.style.width = pct + "%"; } else { usageEl.textContent = "尚未配置"; } const projectId = getCurrentProjectId(); const blocked = projectId ? await fetch("/api/tasks?project_id=" + encodeURIComponent(projectId) + "&status=blocked_pending_human") .then((r) => (r.ok ? r.json() : [])) : []; if (blocked.length > 0) { alertEl.innerHTML = "已有 " + blocked.length + " 个任务处于 blocked_pending_human · " + '查看已暂停任务'; alertEl.style.display = "flex"; } else { alertEl.style.display = "none"; } } document.getElementById("btn_save_budget").addEventListener("click", async () => { errorEl.style.display = "none"; successEl.style.display = "none"; const resp = await fetch("/api/settings/budget", { method: "PUT", body: JSON.stringify({ scope: scopeEl.value, limit_type: limitTypeEl.value, limit_value: Number(limitValueEl.value) }), }); const body = await resp.json(); if (resp.status !== 200) { errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorEl.style.display = "block"; return; } successEl.textContent = "已更新" + (body.resumed_task_ids.length ? ",已恢复 " + body.resumed_task_ids.length + " 个因超限阻塞的任务" : ""); successEl.style.display = "block"; refresh(); }); refresh(); } function renderLogRetentionSettings() { return `

      日志保留策略配置

      ${renderSettingsSubnav("log-retention")}
      超过该天数的 closed 状态日志会被清理任务标记 purged 并删除物理文件;清理任务触发频率本身不可配
      `; } function mountLogRetentionSettings() { const daysEl = document.getElementById("lrs_retention_days"); const sizeEl = document.getElementById("lrs_max_total_size_mb"); const errorEl = document.getElementById("lrs_error"); const successEl = document.getElementById("lrs_success"); fetch("/api/settings/log-retention").then((r) => r.json()).then((config) => { if (config) { daysEl.value = config.retention_days; if (config.max_total_size_mb != null) sizeEl.value = config.max_total_size_mb; } }); document.getElementById("btn_save_log_retention").addEventListener("click", async () => { errorEl.style.display = "none"; successEl.style.display = "none"; const resp = await fetch("/api/settings/log-retention", { method: "PUT", body: JSON.stringify({ retention_days: Number(daysEl.value), max_total_size_mb: sizeEl.value ? Number(sizeEl.value) : undefined, }), }); const body = await resp.json(); if (resp.status !== 200) { errorEl.textContent = body.error + (body.detail ? ":" + body.detail : ""); errorEl.style.display = "block"; return; } successEl.textContent = "已更新"; successEl.style.display = "block"; }); }