feat(delivery): observe production revision with dual evidence #19
@ -194,7 +194,8 @@ GET /health/version
|
|||||||
- `not_configured`:项目没有 production delivery profile。
|
- `not_configured`:项目没有 production delivery profile。
|
||||||
- `pending`:部署 context 尚未到达最终状态。
|
- `pending`:部署 context 尚未到达最终状态。
|
||||||
- `deployed`:部署成功、探针健康、运行 SHA 与目标 SHA 一致。
|
- `deployed`:部署成功、探针健康、运行 SHA 与目标 SHA 一致。
|
||||||
- `drifted`:探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。
|
- `drifted`:本次部署 context 成功、探针可达,但运行 SHA 与目标 SHA 不一致(含回滚后的旧版本)。
|
||||||
|
- `skipped`:本次默认分支提交未触发部署;保留实际运行 SHA,不把正常跳过误报为漂移或部署成功。
|
||||||
- `failed`:Gitea 明确报告部署失败;若探针仍健康,必须同时展示实际运行 SHA,不能把线上误报为不可用。
|
- `failed`:Gitea 明确报告部署失败;若探针仍健康,必须同时展示实际运行 SHA,不能把线上误报为不可用。
|
||||||
- `unhealthy`:运行 SHA 可读但技术健康失败。
|
- `unhealthy`:运行 SHA 可读但技术健康失败。
|
||||||
- `unknown`:缺少足以判断的证据。
|
- `unknown`:缺少足以判断的证据。
|
||||||
@ -343,7 +344,8 @@ v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gi
|
|||||||
- complexity: M
|
- complexity: M
|
||||||
- files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_server.rs
|
- 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 自检
|
- 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 端到端验收与观测基线
|
### 📋 P3-E. Enterprise production 端到端验收与观测基线
|
||||||
- status: todo
|
- status: todo
|
||||||
|
|||||||
@ -143,7 +143,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
|||||||
|
|
||||||
项目可自主管理 `.lian-jing/capabilities.yaml` 的 `delivery.targets.production`,声明 Gitea Actions 的**精确 status context** 与公开 HTTP JSON 版本探针。炼境只读该声明;接入包不生成或覆盖项目的 production 配置。探针不接受 URL 凭证、认证头、查询参数、非 HTTP(S) 协议或本机/内网地址;连接时过滤域名解析出的私网地址,不走环境代理、不跟随重定向,并限制请求时间与响应大小。
|
项目可自主管理 `.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 或数据库写权限。
|
||||||
|
|
||||||
## 三条设计原则
|
## 三条设计原则
|
||||||
|
|
||||||
|
|||||||
@ -108,7 +108,7 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否
|
|||||||
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
- `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。
|
||||||
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
- `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。
|
||||||
- `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。
|
- `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 和字段名,不放凭证或命令。
|
- production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。
|
||||||
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
- 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。
|
||||||
|
|
||||||
|
|||||||
@ -342,15 +342,19 @@ fn aggregate_deployment(
|
|||||||
let any_pending = components
|
let any_pending = components
|
||||||
.values()
|
.values()
|
||||||
.any(|(_, deploy, _)| deploy.state == "pending");
|
.any(|(_, deploy, _)| deploy.state == "pending");
|
||||||
|
let any_skipped = components
|
||||||
|
.values()
|
||||||
|
.any(|(_, deploy, _)| deploy.state == "skipped");
|
||||||
let any_unhealthy = components
|
let any_unhealthy = components
|
||||||
.values()
|
.values()
|
||||||
.any(|(_, _, probe)| probe.state == "unhealthy");
|
.any(|(_, _, probe)| probe.state == "unhealthy");
|
||||||
let any_drifted = target_sha.is_some_and(|target| {
|
let any_drifted = target_sha.is_some_and(|target| {
|
||||||
components.values().any(|(_, _, probe)| {
|
components.values().any(|(_, deploy, probe)| {
|
||||||
probe
|
deploy.state == "success"
|
||||||
.revision
|
&& probe
|
||||||
.as_deref()
|
.revision
|
||||||
.is_some_and(|running| running != target)
|
.as_deref()
|
||||||
|
.is_some_and(|running| running != target)
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let all_deployed = target_sha.is_some()
|
let all_deployed = target_sha.is_some()
|
||||||
@ -384,6 +388,11 @@ fn aggregate_deployment(
|
|||||||
)
|
)
|
||||||
} else if any_unavailable {
|
} else if any_unavailable {
|
||||||
("unavailable", "Gitea 部署状态或运行探针不可达")
|
("unavailable", "Gitea 部署状态或运行探针不可达")
|
||||||
|
} else if any_skipped {
|
||||||
|
(
|
||||||
|
"skipped",
|
||||||
|
"本次默认分支提交未触发部署;保留当前运行版本,不将正常跳过误报为漂移",
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
("unknown", "缺少足以判断 production 部署的双证据")
|
("unknown", "缺少足以判断 production 部署的双证据")
|
||||||
};
|
};
|
||||||
@ -862,6 +871,17 @@ delivery:
|
|||||||
aggregate_fixture("success", "unavailable", None).state,
|
aggregate_fixture("success", "unavailable", None).state,
|
||||||
"unavailable"
|
"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!(
|
assert_eq!(
|
||||||
aggregate_fixture("unknown", "healthy", Some(&target)).state,
|
aggregate_fixture("unknown", "healthy", Some(&target)).state,
|
||||||
"unknown"
|
"unknown"
|
||||||
|
|||||||
@ -2007,7 +2007,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn production_context_uses_remote_default_branch_and_exact_job_name() {
|
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();
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
listener.set_nonblocking(true).unwrap();
|
listener.set_nonblocking(true).unwrap();
|
||||||
let address = listener.local_addr().unwrap();
|
let address = listener.local_addr().unwrap();
|
||||||
@ -2040,9 +2040,13 @@ mod tests {
|
|||||||
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
assert!(started.elapsed() < std::time::Duration::from_secs(5));
|
||||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||||
};
|
};
|
||||||
let mut request = [0u8; 4096];
|
// Windows 上从 nonblocking listener 接受的 socket 也可能保持 nonblocking。
|
||||||
let len = stream.read(&mut request).unwrap();
|
stream.set_nonblocking(false).unwrap();
|
||||||
let request = String::from_utf8_lossy(&request[..len]);
|
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!(
|
assert!(
|
||||||
request.starts_with(&format!("GET {expected_path}")),
|
request.starts_with(&format!("GET {expected_path}")),
|
||||||
"{request}"
|
"{request}"
|
||||||
|
|||||||
@ -187,7 +187,7 @@ pub fn tools_list_result() -> Value {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "get_delivery_status",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user