Commit 59e96d3eda76bc6821454b2ea097d65e10349579

Authored by bboonnee
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>
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 = &quot;/api/ws/work-nodes/&quot; @@ -127,6 +127,7 @@ _WS_WORK_NODES_PREFIX = &quot;/api/ws/work-nodes/&quot;
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()