Commit 59e96d3eda76bc6821454b2ea097d65e10349579
1 parent
05292668
U-043: console deployment status view (v_deployment_status)
Adds src/deployment_query.py (list_deployments/get_current_deployment/ get_deployment -- no project_id filter needed, deployment has no project_id column since there's exactly one graphlemon self-target) + three new backend_server.py routes (GET /api/deployments, /current, /:id, the last with recent deploy_health_signal history) + a new v_deployment_status page in frontend/app.js polling every 5s (deployment.status changes don't go through c_ws_subscribe -- Deploy Supervisor writes deployment/ deploy_health_signal directly, no execution_event of its own). No 02-V spec exists for this view (PH5 was added after 02-V was written) -- DOM/class conventions follow the existing pages' own patterns. Verified against a real running server via urllib (browser extension unavailable this session), plus 9 deployment_query unit tests and 5 real HTTP route tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
8 changed files
with
408 additions
and
2 deletions
docs/development-plan.md
| @@ -406,6 +406,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -406,6 +406,9 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 406 | - **U-043 [Value Path]** 控制台展示 `deployment` 状态与健康信号(02-V 新增视图) | 406 | - **U-043 [Value Path]** 控制台展示 `deployment` 状态与健康信号(02-V 新增视图) |
| 407 | - 依赖:U-024, U-040 | 407 | - 依赖:U-024, U-040 |
| 408 | - Done:能在控制台看到当前是否有 deployment 在进行中、处于状态机的哪个状态、最近一次健康信号采样结果 | 408 | - Done:能在控制台看到当前是否有 deployment 在进行中、处于状态机的哪个状态、最近一次健康信号采样结果 |
| 409 | + - **实现补充(2026-08-02)**:02-V 从没写过这个视图(PH5 是后加的 Phase,02-V 原文只覆盖 PH0-PH4),DOM/class 命名沿用现有页面(`v_skill_library` 等)的既有约定,没有 wireframe 可以照抄。新增 `src/deployment_query.py`(`list_deployments`/`get_current_deployment`/`get_deployment`,`deployment` 表没有 `project_id`——graphlemon 自己只有一份 self-target,不是每项目一份,所以读接口不需要 project_id 过滤,和 `v_skill_library` 的"平台级资产"是同一类情况)+ `backend_server.py` 三个新路由(`GET /api/deployments`、`GET /api/deployments/current`、`GET /api/deployments/:id`,后者带最近 50 条 `deploy_health_signal`)。前端 `v_deployment_status`:`deployment.status` 变化不走 `c_ws_subscribe`(Deploy Supervisor 直接写 `deployment`/`deploy_health_signal` 表,不产生自己的 `execution_event`),页面改用定时轮询(5s 间隔)而不是复用 WS 连接,路由切换时清理 interval 的方式和其余页面清理 WS 连接是同一个 `registerRouteCleanup` 约定。验证方式:9 个 `deployment_query` 单测 + 5 个真实 HTTP 路由测试(`test_backend_server.py` 新增 `DeploymentRouteTest`)+ 对着真实跑起来的服务器用 `urllib` 走了一遍全部三条路由确认真实响应结构正确(浏览器扩展本次不可用,`node --check` 过语法)。 | ||
| 410 | + - **提交时机的一个插曲,记录一下**:新增 `deployment_query.py` 后,`supervisor/test_deploy_cutover.py`/`test_deploy_rollback.py`/`test_deploy_stage.py` 里那些用 `git stash create` 模拟"对着当前未提交工作区做真实 candidate 拉起"的测试集体失败——根因是 `git stash create` 不会捕获**未跟踪的新文件**(这条限制 U-037 当时已经在自己的测试注释里写明过),`deployment_query.py` 是全新文件,没被 stash 快照捕捉到,检出出来的候选进程 `import` 它时直接失败,"候选进程起不来"。这不是这次改动本身有 bug(真正提交之后,`HEAD` 天然包含这份新文件,同一批测试会恢复通过),是这批测试自己的固有前提("只覆盖已跟踪文件的改动")第一次被一次"新增文件"的改动踩到;提交后立即重跑过一次完整套件确认确实恢复全绿,不是只凭猜测判断"这样应该没事"。 | ||
| 411 | + - 覆盖:`src/deployment_query.py`;`backend_server.py` 新增 `GET /api/deployments`/`GET /api/deployments/current`/`GET /api/deployments/:id`;`frontend/app.js`/`index.html` 新增 `v_deployment_status` | ||
| 409 | 412 | ||
| 410 | ### PH5 已知交叉缺口(不阻塞设计定稿,实现前需要留意) | 413 | ### PH5 已知交叉缺口(不阻塞设计定稿,实现前需要留意) |
| 411 | 414 |
docs/sdd/unit_matrix.json
| @@ -60,7 +60,7 @@ | @@ -60,7 +60,7 @@ | ||
| 60 | {"id": "U-040", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_monitor(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-039"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_monitor"], "process": [], "requirement": []}, "done": "四类信号采样正常(supervisor/deploy_monitor.py);validator_approval_rate 偏离基线阈值时触发 anomaly_detected;approve 占比算法基于对 scheduler.finish_task_lifecycle 真实代码逻辑的核实(PASS 从不产生 bug_record,FAIL 必然产生),非字面 SDD 定义"}, | 60 | {"id": "U-040", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_monitor(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-039"], "coverage": {"model": ["deploy_health_signal"], "view": [], "controller": ["c_deploy_supervisor_monitor"], "process": [], "requirement": []}, "done": "四类信号采样正常(supervisor/deploy_monitor.py);validator_approval_rate 偏离基线阈值时触发 anomaly_detected;approve 占比算法基于对 scheduler.finish_task_lifecycle 真实代码逻辑的核实(PASS 从不产生 bug_record,FAIL 必然产生),非字面 SDD 定义"}, |
| 61 | {"id": "U-041", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_rollback(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次(supervisor/deploy_rollback.py,真实进程+真实 git 仓库验证);旧版本也起不来时正确终态到 blocked_pending_human(真实场景验证,非假设);git revert 冲突有明确处理(真实构造冲突历史验证过,含 --abort 清理)"}, | 61 | {"id": "U-041", "phase": "PH5", "sub_phase": "PH5-S3", "name": "c_deploy_supervisor_rollback(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-038", "U-040"], "coverage": {"model": ["deployment"], "view": [], "controller": ["c_deploy_supervisor_rollback"], "process": [], "requirement": []}, "done": "回滚路径 A/B 各跑通一次(supervisor/deploy_rollback.py,真实进程+真实 git 仓库验证);旧版本也起不来时正确终态到 blocked_pending_human(真实场景验证,非假设);git revert 冲突有明确处理(真实构造冲突历史验证过,含 --abort 清理)"}, |
| 62 | {"id": "U-042", "phase": "PH5", "sub_phase": "PH5-S3", "name": "自举闭环端到端验收(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-035", "U-041"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "真实复现 self-development-feasibility.md 9 步场景,全程无需人工评审代码内容(supervisor/self_hosting_e2e.py 编排层已建好);真实双实例 E2E 用例(RUN_REAL_SELF_HOSTING_E2E=1,独立于常规 RUN_REAL_AGENT_E2E 的单独环境变量)默认跳过,需显式环境变量开启且需额外用户确认才真正执行;日常回归只跑 mock 版本(6 个编排逻辑测试,已跑通)"}, | 62 | {"id": "U-042", "phase": "PH5", "sub_phase": "PH5-S3", "name": "自举闭环端到端验收(见 development-plan.md 2026-08-02 实现补充)", "classification": "Risk Gate", "depends_on": ["U-035", "U-041"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "真实复现 self-development-feasibility.md 9 步场景,全程无需人工评审代码内容(supervisor/self_hosting_e2e.py 编排层已建好);真实双实例 E2E 用例(RUN_REAL_SELF_HOSTING_E2E=1,独立于常规 RUN_REAL_AGENT_E2E 的单独环境变量)默认跳过,需显式环境变量开启且需额外用户确认才真正执行;日常回归只跑 mock 版本(6 个编排逻辑测试,已跑通)"}, |
| 63 | - {"id": "U-043", "phase": "PH5", "sub_phase": "PH5-S4", "name": "控制台展示 deployment 状态与健康信号", "classification": "Value Path", "depends_on": ["U-024", "U-040"], "coverage": {"model": [], "view": ["v_deployment_status"], "controller": [], "process": [], "requirement": []}, "done": "能看到当前 deployment 状态机所处状态与最近一次健康信号采样结果"} | 63 | + {"id": "U-043", "phase": "PH5", "sub_phase": "PH5-S4", "name": "控制台展示 deployment 状态与健康信号(见 development-plan.md 2026-08-02 实现补充)", "classification": "Value Path", "depends_on": ["U-024", "U-040"], "coverage": {"model": [], "view": ["v_deployment_status"], "controller": ["c_deployment_list", "c_deployment_current", "c_deployment_get"], "process": [], "requirement": []}, "done": "能看到当前 deployment 状态机所处状态与最近一次健康信号采样结果;02-V 从未定义过这个视图(PH5 后加),DOM/class 约定沿用现有页面;真实服务器 + urllib 验证过全部三条新路由"} |
| 64 | ], | 64 | ], |
| 65 | "classification_summary": { | 65 | "classification_summary": { |
| 66 | "Value Path": 17, | 66 | "Value Path": 17, |
frontend/app.js
| @@ -79,6 +79,7 @@ const routes = { | @@ -79,6 +79,7 @@ const routes = { | ||
| 79 | "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, | 79 | "#/tasks": { render: renderTaskBoard, mount: mountTaskBoard }, |
| 80 | "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, | 80 | "#/artifacts": { render: renderArtifactBrowser, mount: mountArtifactBrowser }, |
| 81 | "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, | 81 | "#/skills": { render: renderSkillLibrary, mount: mountSkillLibrary }, |
| 82 | + "#/deployment": { render: renderDeploymentStatus, mount: mountDeploymentStatus }, | ||
| 82 | "#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings }, | 83 | "#/settings/concurrency": { render: renderConcurrencySettings, mount: mountConcurrencySettings }, |
| 83 | "#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings }, | 84 | "#/settings/budget": { render: renderBudgetSettings, mount: mountBudgetSettings }, |
| 84 | "#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings }, | 85 | "#/settings/log-retention": { render: renderLogRetentionSettings, mount: mountLogRetentionSettings }, |
| @@ -1248,6 +1249,117 @@ function mountSkillAuditLog(skillId) { | @@ -1248,6 +1249,117 @@ function mountSkillAuditLog(skillId) { | ||
| 1248 | refresh(); | 1249 | refresh(); |
| 1249 | } | 1250 | } |
| 1250 | 1251 | ||
| 1252 | +// ---- v_deployment_status (U-043, PH5 -- no 02-V spec exists for this view, | ||
| 1253 | +// it's a PH5-only addition; DOM/class conventions below follow the existing | ||
| 1254 | +// pages' own patterns rather than a wireframe that was never written) ---- | ||
| 1255 | +// Not project-scoped (there's exactly one graphlemon self-target, not one | ||
| 1256 | +// per project, same reasoning v_skill_library's pages already apply). | ||
| 1257 | +// deployment.status changes aren't pushed over c_ws_subscribe (Deploy | ||
| 1258 | +// Supervisor writes deployment/deploy_health_signal rows directly, no | ||
| 1259 | +// execution_event of its own) -- this page polls on an interval instead of | ||
| 1260 | +// reusing connectEventSocket, and cleans the interval up via | ||
| 1261 | +// registerRouteCleanup like every other page cleans up its own sockets. | ||
| 1262 | + | ||
| 1263 | +const DEPLOYMENT_POLL_INTERVAL_MS = 5000; | ||
| 1264 | + | ||
| 1265 | +const DEPLOYMENT_STATUS_LABELS = { | ||
| 1266 | + staged: "已暂存", candidate_starting: "候选启动中", health_check_running: "健康检查中", | ||
| 1267 | + health_check_passed: "健康检查通过", health_check_failed: "健康检查失败", | ||
| 1268 | + cutover_in_progress: "切换中", live: "已上线", monitoring: "监控中", | ||
| 1269 | + anomaly_detected: "检测到异常", rollback_in_progress: "回滚中", rolled_back: "已回滚", | ||
| 1270 | + rollback_failed: "回滚失败", blocked_pending_human: "需要人工介入", | ||
| 1271 | + monitoring_window_clean: "监控窗口正常", finalized: "已完成", | ||
| 1272 | +}; | ||
| 1273 | + | ||
| 1274 | +function renderDeploymentStatus() { | ||
| 1275 | + return ` | ||
| 1276 | + <div class="page page-wide"> | ||
| 1277 | + <h1>部署状态</h1> | ||
| 1278 | + <div id="deployment_current"><div class="hint">加载中…</div></div> | ||
| 1279 | + <h2 style="margin-top:24px;">历史部署</h2> | ||
| 1280 | + <div id="deployment_history"><div class="hint">加载中…</div></div> | ||
| 1281 | + </div> | ||
| 1282 | + `; | ||
| 1283 | +} | ||
| 1284 | + | ||
| 1285 | +function mountDeploymentStatus() { | ||
| 1286 | + const currentEl = document.getElementById("deployment_current"); | ||
| 1287 | + const historyEl = document.getElementById("deployment_history"); | ||
| 1288 | + | ||
| 1289 | + function renderSignals(signals) { | ||
| 1290 | + if (!signals || signals.length === 0) return '<div class="hint">尚无健康信号采样</div>'; | ||
| 1291 | + return '<div class="signal-list">' + signals.map((s) => | ||
| 1292 | + '<div class="signal-row status-' + escapeHtml(s.status) + '">' | ||
| 1293 | + + '<span class="type">' + escapeHtml(s.signal_type) + '</span>' | ||
| 1294 | + + '<span class="tag">' + escapeHtml(s.status) + '</span>' | ||
| 1295 | + + '<span class="t">' + escapeHtml(s.sampled_at) + '</span>' | ||
| 1296 | + + (s.detail ? '<span class="detail">' + escapeHtml(s.detail) + '</span>' : '') | ||
| 1297 | + + '</div>' | ||
| 1298 | + ).join('') + '</div>'; | ||
| 1299 | + } | ||
| 1300 | + | ||
| 1301 | + function renderCurrent(current) { | ||
| 1302 | + if (!current) { | ||
| 1303 | + currentEl.innerHTML = '<div class="panel"><div class="hint">当前没有进行中的 deployment</div></div>'; | ||
| 1304 | + return; | ||
| 1305 | + } | ||
| 1306 | + const label = DEPLOYMENT_STATUS_LABELS[current.status] || current.status; | ||
| 1307 | + currentEl.innerHTML = ( | ||
| 1308 | + '<div class="panel">' | ||
| 1309 | + + '<div class="top"><b>deployment_id: ' + escapeHtml(current.deployment_id) + '</b>' | ||
| 1310 | + + '<span class="tag status-' + escapeHtml(current.status) + '">' + escapeHtml(label) + '</span></div>' | ||
| 1311 | + + '<div class="summary">commit ' + escapeHtml((current.git_commit_hash || '').slice(0, 12)) | ||
| 1312 | + + ' · started_at ' + escapeHtml(current.started_at || '-') + '</div>' | ||
| 1313 | + + '</div>' | ||
| 1314 | + ); | ||
| 1315 | + } | ||
| 1316 | + | ||
| 1317 | + function renderHistory(deployments) { | ||
| 1318 | + if (deployments.length === 0) { | ||
| 1319 | + historyEl.innerHTML = '<div class="hint">暂无历史部署记录</div>'; | ||
| 1320 | + return; | ||
| 1321 | + } | ||
| 1322 | + historyEl.innerHTML = deployments.map((d) => { | ||
| 1323 | + const label = DEPLOYMENT_STATUS_LABELS[d.status] || d.status; | ||
| 1324 | + return ( | ||
| 1325 | + '<div class="deployment-row" data-deployment-id="' + d.deployment_id + '">' | ||
| 1326 | + + '<span class="name">' + escapeHtml(d.deployment_id.slice(0, 8)) + '</span>' | ||
| 1327 | + + '<span class="tag status-' + escapeHtml(d.status) + '">' + escapeHtml(label) + '</span>' | ||
| 1328 | + + '<span class="t">' + escapeHtml(d.cutover_at || d.started_at || '-') + '</span>' | ||
| 1329 | + + '</div>' | ||
| 1330 | + ); | ||
| 1331 | + }).join(''); | ||
| 1332 | + | ||
| 1333 | + for (const row of historyEl.querySelectorAll('.deployment-row')) { | ||
| 1334 | + row.addEventListener('click', async () => { | ||
| 1335 | + const detail = await fetch('/api/deployments/' + encodeURIComponent(row.dataset.deploymentId)).then((r) => r.json()); | ||
| 1336 | + currentEl.innerHTML = ( | ||
| 1337 | + '<div class="panel">' | ||
| 1338 | + + '<div class="top"><b>deployment_id: ' + escapeHtml(detail.deployment_id) + '</b>' | ||
| 1339 | + + '<span class="tag status-' + escapeHtml(detail.status) + '">' | ||
| 1340 | + + escapeHtml(DEPLOYMENT_STATUS_LABELS[detail.status] || detail.status) + '</span></div>' | ||
| 1341 | + + '<div class="summary">commit ' + escapeHtml((detail.git_commit_hash || '').slice(0, 12)) + '</div>' | ||
| 1342 | + + '<h3>最近健康信号</h3>' + renderSignals(detail.health_signals) | ||
| 1343 | + + '</div>' | ||
| 1344 | + ); | ||
| 1345 | + }); | ||
| 1346 | + } | ||
| 1347 | + } | ||
| 1348 | + | ||
| 1349 | + async function refresh() { | ||
| 1350 | + const [current, history] = await Promise.all([ | ||
| 1351 | + fetch('/api/deployments/current').then((r) => r.json()), | ||
| 1352 | + fetch('/api/deployments').then((r) => r.json()), | ||
| 1353 | + ]); | ||
| 1354 | + renderCurrent(current); | ||
| 1355 | + renderHistory(history); | ||
| 1356 | + } | ||
| 1357 | + | ||
| 1358 | + refresh(); | ||
| 1359 | + const intervalId = setInterval(refresh, DEPLOYMENT_POLL_INTERVAL_MS); | ||
| 1360 | + registerRouteCleanup(() => clearInterval(intervalId)); | ||
| 1361 | +} | ||
| 1362 | + | ||
| 1251 | // ---- v_concurrency_settings / v_budget_settings / v_log_retention_settings | 1363 | // ---- v_concurrency_settings / v_budget_settings / v_log_retention_settings |
| 1252 | // (02-V §2.11/§2.12/§2.13) -- three small config pages, none of them | 1364 | // (02-V §2.11/§2.12/§2.13) -- three small config pages, none of them |
| 1253 | // project-scoped (all three tables are platform-wide singletons, config_id | 1365 | // project-scoped (all three tables are platform-wide singletons, config_id |
frontend/index.html
| @@ -11,6 +11,7 @@ | @@ -11,6 +11,7 @@ | ||
| 11 | <a href="#/tasks">Tasks</a> | 11 | <a href="#/tasks">Tasks</a> |
| 12 | <a href="#/artifacts">Artifacts</a> | 12 | <a href="#/artifacts">Artifacts</a> |
| 13 | <a href="#/skills">Skills</a> | 13 | <a href="#/skills">Skills</a> |
| 14 | + <a href="#/deployment">Deployment</a> | ||
| 14 | <a href="#/settings/concurrency">Settings</a> | 15 | <a href="#/settings/concurrency">Settings</a> |
| 15 | </nav> | 16 | </nav> |
| 16 | <main id="app"></main> | 17 | <main id="app"></main> |
src/backend_server.py
| @@ -37,7 +37,7 @@ import urllib.parse | @@ -37,7 +37,7 @@ import urllib.parse | ||
| 37 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer | 37 | from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer |
| 38 | 38 | ||
| 39 | from src import ( | 39 | from src import ( |
| 40 | - artifact_query, budget_guardrail, bug_record, execution_log, | 40 | + artifact_query, budget_guardrail, bug_record, deployment_query, execution_log, |
| 41 | org_loop_route, paths, project_scaffold, schema, settings_config, | 41 | org_loop_route, paths, project_scaffold, schema, settings_config, |
| 42 | skill_query, skill_update, task_create, task_query, ws_subscribe, | 42 | skill_query, skill_update, task_create, task_query, ws_subscribe, |
| 43 | ) | 43 | ) |
| @@ -127,6 +127,7 @@ _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" | @@ -127,6 +127,7 @@ _WS_WORK_NODES_PREFIX = "/api/ws/work-nodes/" | ||
| 127 | _LOG_SUFFIX = "/log" | 127 | _LOG_SUFFIX = "/log" |
| 128 | _ARTIFACTS_PREFIX = "/api/artifacts/" | 128 | _ARTIFACTS_PREFIX = "/api/artifacts/" |
| 129 | _SKILLS_PREFIX = "/api/skills/" | 129 | _SKILLS_PREFIX = "/api/skills/" |
| 130 | +_DEPLOYMENTS_PREFIX = "/api/deployments/" | ||
| 130 | 131 | ||
| 131 | 132 | ||
| 132 | class _Handler(BaseHTTPRequestHandler): | 133 | class _Handler(BaseHTTPRequestHandler): |
| @@ -181,6 +182,17 @@ class _Handler(BaseHTTPRequestHandler): | @@ -181,6 +182,17 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 181 | skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] | 182 | skill_id = parsed.path[len(_SKILLS_PREFIX):-len("/audit")] |
| 182 | self._handle_skill_audit_log(skill_id) | 183 | self._handle_skill_audit_log(skill_id) |
| 183 | return | 184 | return |
| 185 | + if parsed.path == "/api/deployments": | ||
| 186 | + self._handle_deployment_list() | ||
| 187 | + return | ||
| 188 | + if parsed.path == "/api/deployments/current": | ||
| 189 | + self._handle_deployment_current() | ||
| 190 | + return | ||
| 191 | + if parsed.path.startswith(_DEPLOYMENTS_PREFIX): | ||
| 192 | + deployment_id = parsed.path[len(_DEPLOYMENTS_PREFIX):] | ||
| 193 | + if deployment_id and "/" not in deployment_id: | ||
| 194 | + self._handle_deployment_get(deployment_id) | ||
| 195 | + return | ||
| 184 | 196 | ||
| 185 | entry = _STATIC_FILES.get(self.path) | 197 | entry = _STATIC_FILES.get(self.path) |
| 186 | if entry is None: | 198 | if entry is None: |
| @@ -448,6 +460,25 @@ class _Handler(BaseHTTPRequestHandler): | @@ -448,6 +460,25 @@ class _Handler(BaseHTTPRequestHandler): | ||
| 448 | else: | 460 | else: |
| 449 | self._send_json(200, result) | 461 | self._send_json(200, result) |
| 450 | 462 | ||
| 463 | + def _handle_deployment_list(self): | ||
| 464 | + with self.server.conn_lock: | ||
| 465 | + result = deployment_query.list_deployments(self.server.conn) | ||
| 466 | + self._send_json(200, result) | ||
| 467 | + | ||
| 468 | + def _handle_deployment_current(self): | ||
| 469 | + with self.server.conn_lock: | ||
| 470 | + result = deployment_query.get_current_deployment(self.server.conn) | ||
| 471 | + self._send_json(200, result) | ||
| 472 | + | ||
| 473 | + def _handle_deployment_get(self, deployment_id): | ||
| 474 | + try: | ||
| 475 | + with self.server.conn_lock: | ||
| 476 | + result = deployment_query.get_deployment(self.server.conn, deployment_id) | ||
| 477 | + except deployment_query.DeploymentNotFoundError: | ||
| 478 | + self._send_json(404, {"error": "NOT_FOUND"}) | ||
| 479 | + else: | ||
| 480 | + self._send_json(200, result) | ||
| 481 | + | ||
| 451 | def _handle_skill_list(self): | 482 | def _handle_skill_list(self): |
| 452 | with self.server.conn_lock: | 483 | with self.server.conn_lock: |
| 453 | result = skill_query.list_skills(self.server.conn) | 484 | result = skill_query.list_skills(self.server.conn) |
src/deployment_query.py
0 → 100644
| 1 | +"""U-043: read-side controllers for `v_deployment_status` -- no operation_id | ||
| 2 | +for these exists anywhere in 03-C/the MVCP JSON (this whole view is a PH5 | ||
| 3 | +addition with no 02-V spec to begin with, see development-plan.md's own | ||
| 4 | +U-043 note), same "view's own filter/display needs directly justify a new | ||
| 5 | +read endpoint" precedent as `c_task_list`/`c_artifact_list` before it. | ||
| 6 | +`deployment` has no `project_id` column (01-M 1.15) -- there's exactly one | ||
| 7 | +graphlemon self-target, not one per project, so these never take a | ||
| 8 | +project_id filter the way task/artifact listing does. | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +_STATUSES = ( | ||
| 12 | + "staged", "candidate_starting", "health_check_running", "health_check_passed", | ||
| 13 | + "health_check_failed", "cutover_in_progress", "live", "monitoring", | ||
| 14 | + "anomaly_detected", "rollback_in_progress", "rolled_back", "rollback_failed", | ||
| 15 | + "blocked_pending_human", "monitoring_window_clean", "finalized", | ||
| 16 | +) | ||
| 17 | +_TERMINAL_STATUSES = {"rolled_back", "rollback_failed", "finalized", "blocked_pending_human"} | ||
| 18 | + | ||
| 19 | + | ||
| 20 | +class DeploymentNotFoundError(Exception): | ||
| 21 | + """NOT_FOUND -- c_deployment_get on a nonexistent deployment_id.""" | ||
| 22 | + | ||
| 23 | + | ||
| 24 | +def _deployment_row_to_dict(r): | ||
| 25 | + return { | ||
| 26 | + "deployment_id": r[0], "git_commit_hash": r[1], "previous_commit_hash": r[2], "status": r[3], | ||
| 27 | + "candidate_port": r[4], "candidate_instance_dir": r[5], | ||
| 28 | + "started_at": r[6], "cutover_at": r[7], "monitoring_closed_at": r[8], | ||
| 29 | + "rollback_at": r[9], "rollback_reason": r[10], "previous_instance_dir": r[11], | ||
| 30 | + "is_active": r[3] not in _TERMINAL_STATUSES, | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +_DEPLOYMENT_COLUMNS = ( | ||
| 35 | + "deployment_id, git_commit_hash, previous_commit_hash, status, candidate_port, candidate_instance_dir, " | ||
| 36 | + "started_at, cutover_at, monitoring_closed_at, rollback_at, rollback_reason, previous_instance_dir" | ||
| 37 | +) | ||
| 38 | + | ||
| 39 | + | ||
| 40 | +def list_deployments(conn, limit=20): | ||
| 41 | + """Newest-first (`rowid DESC` -- `deployment` has no created_at column | ||
| 42 | + of its own, `started_at` is nullable until c_deploy_supervisor_stage | ||
| 43 | + actually sets it, same ordering convention U-016/U-037 already use | ||
| 44 | + elsewhere for "most recent" when a table lacks a dedicated timestamp).""" | ||
| 45 | + rows = conn.execute( | ||
| 46 | + "SELECT {} FROM deployment ORDER BY rowid DESC LIMIT ?".format(_DEPLOYMENT_COLUMNS), (limit,) | ||
| 47 | + ).fetchall() | ||
| 48 | + return [_deployment_row_to_dict(r) for r in rows] | ||
| 49 | + | ||
| 50 | + | ||
| 51 | +def get_current_deployment(conn): | ||
| 52 | + """The single non-terminal deployment, if any (01-M 1.15's own | ||
| 53 | + `ux_deployment_active` constraint guarantees at most one) -- what | ||
| 54 | + `v_deployment_status` actually wants front and center: "当前是否有 | ||
| 55 | + deployment 在进行中". Returns None when nothing is active (the common | ||
| 56 | + case -- most of the time nothing is deploying).""" | ||
| 57 | + row = conn.execute( | ||
| 58 | + "SELECT {} FROM deployment WHERE status NOT IN ({}) ORDER BY rowid DESC LIMIT 1".format( | ||
| 59 | + _DEPLOYMENT_COLUMNS, ", ".join("'{}'".format(s) for s in _TERMINAL_STATUSES) | ||
| 60 | + ) | ||
| 61 | + ).fetchone() | ||
| 62 | + return _deployment_row_to_dict(row) if row else None | ||
| 63 | + | ||
| 64 | + | ||
| 65 | +def get_deployment(conn, deployment_id): | ||
| 66 | + """Includes recent `deploy_health_signal` rows (newest first) -- "最近 | ||
| 67 | + 一次健康信号采样结果" per this Unit's own Done line, but the full recent | ||
| 68 | + history (not just the latest) is what actually lets a human tell "one | ||
| 69 | + blip" from "a real pattern" at a glance, same reasoning U-026's | ||
| 70 | + `v_graph_visualizer` tooltip already applies to per-node detail.""" | ||
| 71 | + row = conn.execute( | ||
| 72 | + "SELECT {} FROM deployment WHERE deployment_id = ?".format(_DEPLOYMENT_COLUMNS), (deployment_id,) | ||
| 73 | + ).fetchone() | ||
| 74 | + if row is None: | ||
| 75 | + raise DeploymentNotFoundError(deployment_id) | ||
| 76 | + result = _deployment_row_to_dict(row) | ||
| 77 | + result["health_signals"] = _recent_signals(conn, deployment_id) | ||
| 78 | + return result | ||
| 79 | + | ||
| 80 | + | ||
| 81 | +def _recent_signals(conn, deployment_id, limit=50): | ||
| 82 | + rows = conn.execute( | ||
| 83 | + "SELECT signal_id, sampled_at, signal_type, status, detail FROM deploy_health_signal " | ||
| 84 | + "WHERE deployment_id = ? ORDER BY rowid DESC LIMIT ?", | ||
| 85 | + (deployment_id, limit), | ||
| 86 | + ).fetchall() | ||
| 87 | + return [ | ||
| 88 | + {"signal_id": r[0], "sampled_at": r[1], "signal_type": r[2], "status": r[3], "detail": r[4]} | ||
| 89 | + for r in rows | ||
| 90 | + ] |
src/test_backend_server.py
| @@ -948,6 +948,73 @@ class ArtifactRouteTest(unittest.TestCase): | @@ -948,6 +948,73 @@ class ArtifactRouteTest(unittest.TestCase): | ||
| 948 | self.assertEqual(body["error"], "NOT_FOUND") | 948 | self.assertEqual(body["error"], "NOT_FOUND") |
| 949 | 949 | ||
| 950 | 950 | ||
| 951 | +class DeploymentRouteTest(unittest.TestCase): | ||
| 952 | + """U-043: c_deployment_list/c_deployment_current/c_deployment_get for | ||
| 953 | + v_deployment_status.""" | ||
| 954 | + | ||
| 955 | + def setUp(self): | ||
| 956 | + self.server = backend_server.create_server(db_path=":memory:") | ||
| 957 | + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) | ||
| 958 | + self.thread.start() | ||
| 959 | + conn = self.server.conn | ||
| 960 | + conn.execute( | ||
| 961 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | ||
| 962 | + "previous_instance_dir) VALUES ('dep-1', 'c1', 'c0', 'finalized', '/prev')" | ||
| 963 | + ) | ||
| 964 | + conn.execute( | ||
| 965 | + "INSERT INTO deployment (deployment_id, git_commit_hash, previous_commit_hash, status, " | ||
| 966 | + "previous_instance_dir) VALUES ('dep-2', 'c2', 'c1', 'monitoring', '/prev2')" | ||
| 967 | + ) | ||
| 968 | + conn.execute( | ||
| 969 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | ||
| 970 | + "VALUES ('s1', 'dep-2', CURRENT_TIMESTAMP, 'process_alive', 'ok')" | ||
| 971 | + ) | ||
| 972 | + conn.commit() | ||
| 973 | + | ||
| 974 | + def tearDown(self): | ||
| 975 | + self.server.shutdown() | ||
| 976 | + self.server.server_close() | ||
| 977 | + self.thread.join(timeout=5) | ||
| 978 | + | ||
| 979 | + def _get(self, path): | ||
| 980 | + host, port = self.server.server_address | ||
| 981 | + conn = http.client.HTTPConnection(host, port, timeout=5) | ||
| 982 | + conn.request("GET", path) | ||
| 983 | + resp = conn.getresponse() | ||
| 984 | + body = json.loads(resp.read()) | ||
| 985 | + conn.close() | ||
| 986 | + return resp.status, body | ||
| 987 | + | ||
| 988 | + def test_list_returns_all_newest_first(self): | ||
| 989 | + status, body = self._get("/api/deployments") | ||
| 990 | + self.assertEqual(status, 200) | ||
| 991 | + self.assertEqual([d["deployment_id"] for d in body], ["dep-2", "dep-1"]) | ||
| 992 | + | ||
| 993 | + def test_current_returns_the_active_one(self): | ||
| 994 | + status, body = self._get("/api/deployments/current") | ||
| 995 | + self.assertEqual(status, 200) | ||
| 996 | + self.assertEqual(body["deployment_id"], "dep-2") | ||
| 997 | + | ||
| 998 | + def test_current_is_null_when_nothing_active(self): | ||
| 999 | + with self.server.conn_lock: | ||
| 1000 | + self.server.conn.execute("UPDATE deployment SET status = 'finalized' WHERE deployment_id = 'dep-2'") | ||
| 1001 | + self.server.conn.commit() | ||
| 1002 | + status, body = self._get("/api/deployments/current") | ||
| 1003 | + self.assertEqual(status, 200) | ||
| 1004 | + self.assertIsNone(body) | ||
| 1005 | + | ||
| 1006 | + def test_get_includes_health_signals(self): | ||
| 1007 | + status, body = self._get("/api/deployments/dep-2") | ||
| 1008 | + self.assertEqual(status, 200) | ||
| 1009 | + self.assertEqual(len(body["health_signals"]), 1) | ||
| 1010 | + self.assertEqual(body["health_signals"][0]["signal_id"], "s1") | ||
| 1011 | + | ||
| 1012 | + def test_get_nonexistent_returns_404(self): | ||
| 1013 | + status, body = self._get("/api/deployments/no-such-deployment") | ||
| 1014 | + self.assertEqual(status, 404) | ||
| 1015 | + self.assertEqual(body["error"], "NOT_FOUND") | ||
| 1016 | + | ||
| 1017 | + | ||
| 951 | class SkillRouteTest(unittest.TestCase): | 1018 | class SkillRouteTest(unittest.TestCase): |
| 952 | """U-029: c_skill_diff (GET /api/skills/diff) + c_skill_rollback | 1019 | """U-029: c_skill_diff (GET /api/skills/diff) + c_skill_rollback |
| 953 | (POST /api/skills/:skill_id/rollback) + list/get for v_skill_library / | 1020 | (POST /api/skills/:skill_id/rollback) + list/get for v_skill_library / |
src/test_deployment_query.py
0 → 100644
| 1 | +"""U-043 tests: deployment_query.py. All real (no mocking needed -- sqlite | ||
| 2 | +is fast/free/local). | ||
| 3 | + | ||
| 4 | +Run: python3 -m unittest src.test_deployment_query -v | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +import unittest | ||
| 8 | + | ||
| 9 | +from src import deployment_query, schema | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +class DeploymentQueryTestBase(unittest.TestCase): | ||
| 13 | + def setUp(self): | ||
| 14 | + self.conn = schema.connect(":memory:") | ||
| 15 | + schema.create_schema(self.conn) | ||
| 16 | + | ||
| 17 | + def tearDown(self): | ||
| 18 | + self.conn.close() | ||
| 19 | + | ||
| 20 | + def _insert(self, deployment_id, status, **kwargs): | ||
| 21 | + cols = ["deployment_id", "git_commit_hash", "previous_commit_hash", "status", "previous_instance_dir"] | ||
| 22 | + vals = [deployment_id, kwargs.get("git_commit_hash", "c1"), kwargs.get("previous_commit_hash", "c0"), | ||
| 23 | + status, kwargs.get("previous_instance_dir", "/prev")] | ||
| 24 | + for extra_col in ("candidate_port", "candidate_instance_dir", "started_at", "cutover_at", | ||
| 25 | + "monitoring_closed_at", "rollback_at", "rollback_reason"): | ||
| 26 | + if extra_col in kwargs: | ||
| 27 | + cols.append(extra_col) | ||
| 28 | + vals.append(kwargs[extra_col]) | ||
| 29 | + self.conn.execute( | ||
| 30 | + "INSERT INTO deployment ({}) VALUES ({})".format(", ".join(cols), ", ".join("?" * len(cols))), vals | ||
| 31 | + ) | ||
| 32 | + self.conn.commit() | ||
| 33 | + | ||
| 34 | + | ||
| 35 | +class ListDeploymentsTest(DeploymentQueryTestBase): | ||
| 36 | + def test_newest_first(self): | ||
| 37 | + self._insert("d1", "finalized") | ||
| 38 | + self._insert("d2", "finalized") | ||
| 39 | + result = deployment_query.list_deployments(self.conn) | ||
| 40 | + self.assertEqual([d["deployment_id"] for d in result], ["d2", "d1"]) | ||
| 41 | + | ||
| 42 | + def test_respects_limit(self): | ||
| 43 | + for i in range(5): | ||
| 44 | + self._insert("d{}".format(i), "finalized") | ||
| 45 | + result = deployment_query.list_deployments(self.conn, limit=2) | ||
| 46 | + self.assertEqual(len(result), 2) | ||
| 47 | + | ||
| 48 | + def test_is_active_flag(self): | ||
| 49 | + self._insert("d1", "monitoring") | ||
| 50 | + self._insert("d2", "finalized") | ||
| 51 | + by_id = {d["deployment_id"]: d for d in deployment_query.list_deployments(self.conn)} | ||
| 52 | + self.assertTrue(by_id["d1"]["is_active"]) | ||
| 53 | + self.assertFalse(by_id["d2"]["is_active"]) | ||
| 54 | + | ||
| 55 | + | ||
| 56 | +class GetCurrentDeploymentTest(DeploymentQueryTestBase): | ||
| 57 | + def test_none_when_nothing_active(self): | ||
| 58 | + self._insert("d1", "finalized") | ||
| 59 | + self.assertIsNone(deployment_query.get_current_deployment(self.conn)) | ||
| 60 | + | ||
| 61 | + def test_returns_the_one_active_deployment(self): | ||
| 62 | + self._insert("d1", "finalized") | ||
| 63 | + self._insert("d2", "health_check_running") | ||
| 64 | + result = deployment_query.get_current_deployment(self.conn) | ||
| 65 | + self.assertEqual(result["deployment_id"], "d2") | ||
| 66 | + self.assertTrue(result["is_active"]) | ||
| 67 | + | ||
| 68 | + def test_blocked_pending_human_is_not_active(self): | ||
| 69 | + # 01-M 1.15's ux_deployment_active constraint treats blocked_pending_human | ||
| 70 | + # as terminal too -- it's the one status that needs a human, not the | ||
| 71 | + # scheduler, to move it forward again. | ||
| 72 | + self._insert("d1", "blocked_pending_human") | ||
| 73 | + self.assertIsNone(deployment_query.get_current_deployment(self.conn)) | ||
| 74 | + | ||
| 75 | + | ||
| 76 | +class GetDeploymentTest(DeploymentQueryTestBase): | ||
| 77 | + def test_not_found_raises(self): | ||
| 78 | + with self.assertRaises(deployment_query.DeploymentNotFoundError): | ||
| 79 | + deployment_query.get_deployment(self.conn, "nonexistent") | ||
| 80 | + | ||
| 81 | + def test_includes_recent_health_signals_newest_first(self): | ||
| 82 | + self._insert("d1", "monitoring") | ||
| 83 | + self.conn.execute( | ||
| 84 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | ||
| 85 | + "VALUES ('s1', 'd1', '2020-01-01 00:00:00', 'process_alive', 'ok')" | ||
| 86 | + ) | ||
| 87 | + self.conn.execute( | ||
| 88 | + "INSERT INTO deploy_health_signal (signal_id, deployment_id, sampled_at, signal_type, status) " | ||
| 89 | + "VALUES ('s2', 'd1', '2020-01-01 00:05:00', 'http_health', 'ok')" | ||
| 90 | + ) | ||
| 91 | + self.conn.commit() | ||
| 92 | + result = deployment_query.get_deployment(self.conn, "d1") | ||
| 93 | + self.assertEqual([s["signal_id"] for s in result["health_signals"]], ["s2", "s1"]) | ||
| 94 | + | ||
| 95 | + def test_no_signals_yet_gives_empty_list(self): | ||
| 96 | + self._insert("d1", "staged") | ||
| 97 | + result = deployment_query.get_deployment(self.conn, "d1") | ||
| 98 | + self.assertEqual(result["health_signals"], []) | ||
| 99 | + | ||
| 100 | + | ||
| 101 | +if __name__ == "__main__": | ||
| 102 | + unittest.main() |