diff --git a/.blueprint/modules/project-capability-control-plane.md b/.blueprint/modules/project-capability-control-plane.md index dc0c2fc..d391b5b 100644 --- a/.blueprint/modules/project-capability-control-plane.md +++ b/.blueprint/modules/project-capability-control-plane.md @@ -194,7 +194,8 @@ GET /health/version - `not_configured`:项目没有 production delivery profile。 - `pending`:部署 context 尚未到达最终状态。 - `deployed`:部署成功、探针健康、运行 SHA 与目标 SHA 一致。 -- `drifted`:探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。 +- `drifted`:本次部署 context 成功、探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。 +- `skipped`:本次默认分支提交未触发部署;保留实际运行 SHA,不把正常跳过误报为漂移或部署成功。 - `failed`:Gitea 明确报告部署失败;若探针仍健康,必须同时展示实际运行 SHA,不能把线上误报为不可用。 - `unhealthy`:运行 SHA 可读但技术健康失败。 - `unknown`:缺少足以判断的证据。 @@ -343,7 +344,8 @@ v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gi - complexity: M - files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs - depends: P3-A. Gitea 1.22 真实 deployment signal 实证与归一化, P3-C. production delivery profile、安全探针与 onboarding 自检 -- acceptance: "[MCP] `get_delivery_status(project_id)` 对固定 fixture 调用 → Gitea success + probe healthy + SHA 相同返回 deployed;SHA 不同返回 drifted;deploy failure 同时保留实际运行 SHA;来源不可达返回 unavailable;证据不足返回 unknown,且现有 Git/PR/CI/review 顶层字段保持兼容。" +- acceptance: "[MCP] `get_delivery_status(project_id)` 对固定 fixture 调用 → Gitea success + probe healthy + SHA 相同返回 deployed;成功部署但 SHA 不同返回 drifted;deploy skipped 且运行旧 SHA 返回 skipped;deploy failure 同时保留实际运行 SHA;来源不可达返回 unavailable;证据不足返回 unknown,且现有 Git/PR/CI/review 顶层字段保持兼容。" +- progress_note: "2026-09-23:真实 Enterprise run #529 的 deploy-api 为 skipped;聚合层新增 skipped 与旧运行 SHA 回归测试,避免普通文档提交误报 drifted。隔离分支 cargo test --lib 187/187;正式版尚未加载新代码,production 探针当前为 401,待 PR/发布后做 MCP 端到端验收。" ### 📋 P3-E. Enterprise production 端到端验收与观测基线 - status: todo diff --git a/docs/ai-context/agent-button-system.md b/docs/ai-context/agent-button-system.md index 225d1ce..c2f354c 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -143,7 +143,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言: 项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网地址;连接时过滤域名解析出的私网地址,不走环境代理、不跟随重定向,并限制请求时间与响应大小。 -`get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本,`drifted` 表示版本不一致;缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。 +`get_delivery_status` 分别采集 Gitea 默认分支目标 SHA 的部署过程证据和当前服务报告的运行 SHA。只有部署 context 成功、探针健康、两个完整 SHA 相等时才报告 `deployed`。`failed` 保留实际运行版本;部署成功但版本不一致才报告 `drifted`;本次提交的部署 job 被跳过时报告 `skipped` 并保留运行 SHA,不把文档类提交误报为漂移。缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。 ## 三条设计原则 diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index a51b2d2..a7da1e7 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -108,7 +108,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 - `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。 - `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 - `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。 -- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;`drifted` 表示运行 SHA 不一致。技术健康不替代业务验收。 +- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;成功部署后 SHA 不一致才标 `drifted`。本次提交部署 job 被跳过时标 `skipped` 并保留实际运行 SHA,不误报漂移。技术健康不替代业务验收。 - production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。 - 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 diff --git a/src-tauri/src/commands/delivery_status.rs b/src-tauri/src/commands/delivery_status.rs index c48a37f..9528c77 100644 --- a/src-tauri/src/commands/delivery_status.rs +++ b/src-tauri/src/commands/delivery_status.rs @@ -342,15 +342,19 @@ fn aggregate_deployment( let any_pending = components .values() .any(|(_, deploy, _)| deploy.state == "pending"); + let any_skipped = components + .values() + .any(|(_, deploy, _)| deploy.state == "skipped"); let any_unhealthy = components .values() .any(|(_, _, probe)| probe.state == "unhealthy"); let any_drifted = target_sha.is_some_and(|target| { - components.values().any(|(_, _, probe)| { - probe - .revision - .as_deref() - .is_some_and(|running| running != target) + components.values().any(|(_, deploy, probe)| { + deploy.state == "success" + && probe + .revision + .as_deref() + .is_some_and(|running| running != target) }) }); let all_deployed = target_sha.is_some() @@ -384,6 +388,11 @@ fn aggregate_deployment( ) } else if any_unavailable { ("unavailable", "Gitea 部署状态或运行探针不可达") + } else if any_skipped { + ( + "skipped", + "本次默认分支提交未触发部署;保留当前运行版本,不将正常跳过误报为漂移", + ) } else { ("unknown", "缺少足以判断 production 部署的双证据") }; @@ -862,6 +871,17 @@ delivery: aggregate_fixture("success", "unavailable", None).state, "unavailable" ); + let skipped = aggregate_fixture("skipped", "healthy", Some(&old)); + assert_eq!(skipped.state, "skipped"); + assert_eq!(skipped.data["running_revision"], old); + assert_eq!( + aggregate_fixture("skipped", "unavailable", None).state, + "unavailable" + ); + assert_eq!( + aggregate_fixture("unknown", "healthy", Some(&old)).state, + "unknown" + ); assert_eq!( aggregate_fixture("unknown", "healthy", Some(&target)).state, "unknown" diff --git a/src-tauri/src/commands/gitea.rs b/src-tauri/src/commands/gitea.rs index fc12d61..21ade35 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -2007,7 +2007,7 @@ mod tests { #[test] fn production_context_uses_remote_default_branch_and_exact_job_name() { - use std::io::{Read, Write}; + use std::io::{BufRead, BufReader, Write}; let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); listener.set_nonblocking(true).unwrap(); let address = listener.local_addr().unwrap(); @@ -2040,9 +2040,13 @@ mod tests { assert!(started.elapsed() < std::time::Duration::from_secs(5)); std::thread::sleep(std::time::Duration::from_millis(10)); }; - let mut request = [0u8; 4096]; - let len = stream.read(&mut request).unwrap(); - let request = String::from_utf8_lossy(&request[..len]); + // Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。 + stream.set_nonblocking(false).unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(3))) + .unwrap(); + let mut request = String::new(); + BufReader::new(&mut stream).read_line(&mut request).unwrap(); assert!( request.starts_with(&format!("GET {expected_path}")), "{request}" diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 9974c04..63ee029 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -187,7 +187,7 @@ pub fn tools_list_result() -> Value { }, { "name": "get_delivery_status", - "description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、漂移、未配置或来源不可达分别保留 failed/drifted/not_configured/unavailable,不替项目作发布决定。", + "description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、跳过部署、部署成功后的漂移、未配置或来源不可达分别保留 failed/skipped/drifted/not_configured/unavailable,不替项目作发布决定。", "inputSchema": { "type": "object", "properties": {