diff --git a/.blueprint/modules/project-capability-control-plane.md b/.blueprint/modules/project-capability-control-plane.md index 81e8415..03b6226 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`:缺少足以判断的证据。 @@ -202,6 +203,8 @@ GET /health/version v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gitea status 的时间字段,当前运行事实来自实时探针;连续历史、受控部署和审计记录等真实需求出现后,再评估 SQLite record。 +`deployed_at` 仅在对应部署 context 成功时取其状态更新时间;失败、等待和跳过的状态不能用其更新时间冒充部署完成时间。 + ### 系统边界变更 - **Enterprise System HTTP 契约**:新增 `/health/version`;Docker 镜像携带不可变 revision,回滚镜像自然恢复对应旧 revision。 @@ -324,25 +327,36 @@ v1 不新增 deployment history 表。最近部署时间和过程状态来自 Gi - acceptance: "agent_verify: [cargo test] `merge_pull_request` 在 confirmed=false、expected_head_sha 与远端不一致、PR 非 open、CI pending/failure 或 review changes_requested 时拒绝且不执行 merge;仅在 PR open、SHA 一致、CI passed、无阻塞 review 且 confirmed=true 时允许执行;[MCP schema] confirmed 与 expected_head_sha 均为必填;合并后重新调用 get_delivery_status 可读取真实结果。" - done_note: "2026-09-22:cargo test --lib 151/151、pnpm typecheck 与 PR CI 全部通过;MCP 实机先以错误 SHA 验证 fail-closed,再由用户确认 PR #14 与完整 head SHA b6d749025458928b86ab607a5a7f31479f8db85f 后,通过带 head_commit_id 的能力本体成功合并;回读 get_delivery_status 为 PR none、CI passed。" -### 📋 P3-B. Enterprise System 不可变 revision 与版本健康端点 -- status: todo +### 🔵 P3-B. Enterprise System 不可变 revision 与版本健康端点 +- status: in_progress - complexity: M - files: E:/code-project/enterprise-system/apps/api/src/index.ts, E:/code-project/enterprise-system/apps/api/Dockerfile, E:/code-project/enterprise-system/deploy/deploy-api.sh - acceptance: "[HTTP API] 以 BUILD_REVISION= 构建并启动 API 镜像后调用 `GET /health/version` → 200,body 仅含 ok/component/revision/started_at 且 revision=;切回以 构建的旧镜像 → revision=,证明回滚不会伪报新 SHA。" +- progress_note: "Enterprise 隔离分支 feat/production-revision-evidence 本地提交 d915399;API typecheck、bash 静态检查与隔离端口 HTTP 四字段/鉴权冒烟通过。Docker daemon 未运行,新旧镜像回滚和 production 仍待验,故保持 in_progress。" -### 📋 P3-C. production delivery profile、安全探针与 onboarding 自检 -- status: todo +### 🔵 P3-C. production delivery profile、安全探针与 onboarding 自检 +- status: in_progress - complexity: M - files: src-tauri/src/commands/delivery_status.rs, src-tauri/src/commands/onboarding.rs, src-tauri/resources/onboarding/ - depends: A. Canonical 项目上下文与 capability schema - acceptance: "[cargo test] 读取声明了 production/http-json probe 的 capability profile → 得到固定 environment/component/context/url 字段;`file:`、URL 内凭证、动态认证头、超时响应和超限响应体分别被拒绝或降级为 unavailable;未配置项目返回 not_configured,onboarding 自检不覆盖已有声明。" -### 📋 P3-D. deployment 双证据聚合与 MCP 契约 -- status: todo +### 🔵 P3-D. deployment 双证据聚合与 MCP 契约 +- status: in_progress - 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-D1. 修正 deployment 时间证据与接入说明 +- status: done +- complexity: M +- files: src-tauri/src/commands/delivery_status.rs, src-tauri/resources/onboarding/git-workflow.md.tmpl, docs/ai-context/git-workflow.md +- depends: P3-D. deployment 双证据聚合与 MCP 契约 +- acceptance: "agent_verify: [cargo test] aggregate_deployment 的 success context 保留 deployed_at,skipped/failed/pending context 的 deployed_at 为 null 且仍保留运行 SHA;[文档对照] 接入模板与 docs/ai-context/git-workflow.md 均说明 drifted 仅限成功部署后 SHA 不一致、skipped 不误报漂移;[git diff] diff --check 通过。" +- done_note: "2026-09-23:聚合回归测试覆盖 success/skipped/failed/pending 的 deployed_at 与运行 SHA;cargo test --lib 187/187、scoped rustfmt、模板与说明文件对照、git diff --check 均通过。" +- retrospective: "【复盘】部署时间证据与接入说明;任务类型: MCP工具;复杂度: M;实际轮数: 1;是否返工: 是;返工原因: skipped context 的更新时间被误写为 deployed_at,模板未同步 skipped 边界;有效策略: 对同一 fixture 同时断言状态、运行 SHA 和时间字段,并逐字对照分发模板;遗留风险: 正式版未加载本 PR,跨项目 MCP 实机仍待合并后终验;auto_applied_rules: R07,R13;rule_effectiveness: R07=有效,R13=未验证。" ### 📋 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 abd4dd1..c2f354c 100644 --- a/docs/ai-context/agent-button-system.md +++ b/docs/ai-context/agent-button-system.md @@ -139,6 +139,12 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言: 这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。 +## production 交付观测边界 + +项目可自主管理 `.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`;本次提交的部署 job 被跳过时报告 `skipped` 并保留运行 SHA,不把文档类提交误报为漂移。缺失/不可达证据明确降级。该状态仅是技术交付观测,不替代业务测试、上线决策,也不赋予炼境部署、回滚、SSH 或数据库写权限。 + ## 三条设计原则 辩证结论,缺一会让体系在现实中碎裂。 diff --git a/docs/ai-context/git-workflow.md b/docs/ai-context/git-workflow.md index c651d5d..c747620 100644 --- a/docs/ai-context/git-workflow.md +++ b/docs/ai-context/git-workflow.md @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 ## 分支策略(复杂度感知) @@ -107,6 +107,10 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 - `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。 - `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 +- `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。 +- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;成功部署后 SHA 不一致才标 `drifted`。本次提交部署 job 被跳过时标 `skipped` 并保留实际运行 SHA,不误报漂移。技术健康不替代业务验收。 +- `deployment.data.deployed_at` 仅在部署 context 成功时取其状态更新时间;`skipped`、`failed`、`pending` 不提供部署完成时间,实际运行 SHA 仍单独保留。 +- production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。 - 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 ## PR 合并(炼境托管项目) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index ff512d2..50e15ec 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -15,7 +15,7 @@ contracts: - "src-tauri/src/commands/toolchain.rs" - "src-tauri/src/commands/cicd.rs" - "src-tauri/src/commands/gitea.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" + doc: "工具 schema、dispatch 与调用说明必须同步;production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/resources/onboarding/git-workflow.md.tmpl b/src-tauri/resources/onboarding/git-workflow.md.tmpl index c651d5d..c747620 100644 --- a/src-tauri/resources/onboarding/git-workflow.md.tmpl +++ b/src-tauri/resources/onboarding/git-workflow.md.tmpl @@ -3,7 +3,7 @@ > 把此文件粘贴给任何 AI Agent,让它了解本项目的 git 工作方式。由炼境接入包分发。 > `ONBOARDING_MANAGED` 标记块由炼境版本化管理(重跑接入包可原位升级),块外可自由补充项目特有约定。 - + > ⚠️ 本块由炼境版本化托管,升级时**整块原位覆盖**——项目定制请写到块外,块内手改必丢。 ## 分支策略(复杂度感知) @@ -107,6 +107,10 @@ PR 别攒太大(建议每 3~5 个 commit 或几百行内就开一次),否 - `unknown`:炼境尚未获得该项证据,**不是成功**;例如 CI 成功不能代替 production 健康。 - `unavailable`:对应外部来源暂时不可达;保留本地验证与 Gitea 网页作为降级路径。 +- `not_configured`:项目尚未声明 `.lian-jing/capabilities.yaml` 的 production delivery profile;这是正常的可选接入状态,不代表部署失败。 +- `deployed` 仅表示 Gitea 部署 context 成功、公开版本探针健康且运行 SHA 与默认分支目标 SHA 完全一致;成功部署后 SHA 不一致才标 `drifted`。本次提交部署 job 被跳过时标 `skipped` 并保留实际运行 SHA,不误报漂移。技术健康不替代业务验收。 +- `deployment.data.deployed_at` 仅在部署 context 成功时取其状态更新时间;`skipped`、`failed`、`pending` 不提供部署完成时间,实际运行 SHA 仍单独保留。 +- production profile 由项目维护,炼境接入包不会覆盖;只声明公开 HTTP(S) JSON URL、精确 Gitea context 和字段名,不放凭证或命令。 - 结果是交付上下文,不替代项目自己的测试、业务验收或发布决定。 ## PR 合并(炼境托管项目) diff --git a/src-tauri/src/commands/delivery_status.rs b/src-tauri/src/commands/delivery_status.rs index ffc568b..e919859 100644 --- a/src-tauri/src/commands/delivery_status.rs +++ b/src-tauri/src/commands/delivery_status.rs @@ -3,9 +3,492 @@ //! 本模块只采集和呈现信号,不替项目判断功能是否应交付。Git 本地状态、Gitea //! 信号与部署证据各自独立降级:任一来源不可用时必须明确标出,而不能猜测成功。 -use std::{path::Path, process::Command}; +use std::{ + collections::BTreeMap, + io::{self, Read}, + net::{IpAddr, SocketAddr, ToSocketAddrs}, + path::Path, + process::Command, + time::Duration, +}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; + +const CAPABILITY_PROFILE_PATH: &str = ".lian-jing/capabilities.yaml"; +const MAX_PROFILE_BYTES: u64 = 16 * 1024; +const MAX_PROBE_BYTES: u64 = 8 * 1024; + +#[derive(Debug, Clone, Deserialize)] +struct CapabilityProfile { + version: u64, + delivery: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeliveryCapabilities { + targets: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct DeliveryTargets { + production: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProductionDeliveryProfile { + pub provider: String, + pub components: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct DeliveryComponentProfile { + pub deploy_context: String, + pub probe: HttpJsonProbe, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct HttpJsonProbe { + pub kind: String, + pub url: String, + pub revision_field: String, + pub healthy_field: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ProbeEvidence { + pub state: String, + pub revision: Option, + pub healthy: Option, + pub detail: String, +} + +impl ProbeEvidence { + fn unavailable(detail: impl Into) -> Self { + Self { + state: "unavailable".into(), + revision: None, + healthy: None, + detail: detail.into(), + } + } +} + +/// 项目拥有此声明;炼境只读取和校验,不分发或修改项目配置。 +pub fn read_production_profile(root: &Path) -> Result, String> { + let path = root.join(CAPABILITY_PROFILE_PATH); + let metadata = match std::fs::metadata(&path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("无法读取 delivery profile:{error}")), + }; + if !metadata.is_file() || metadata.len() > MAX_PROFILE_BYTES { + return Err("delivery profile 不是普通文件或超过 16 KiB".into()); + } + let content = std::fs::read_to_string(&path) + .map_err(|error| format!("无法读取 delivery profile:{error}"))?; + parse_production_profile(&content) +} + +pub fn parse_production_profile( + content: &str, +) -> Result, String> { + if content.len() as u64 > MAX_PROFILE_BYTES { + return Err("delivery profile 超过 16 KiB".into()); + } + let profile: CapabilityProfile = serde_yaml::from_str(content) + .map_err(|error| format!("delivery profile YAML 无效:{error}"))?; + if profile.version != 1 { + return Err(format!( + "不支持 delivery profile version={}", + profile.version + )); + } + let Some(production) = profile + .delivery + .and_then(|delivery| delivery.targets) + .and_then(|targets| targets.production) + else { + return Ok(None); + }; + if production.provider != "gitea-actions" + || production.components.is_empty() + || production.components.len() > 4 + { + return Err("production 仅支持 gitea-actions 且须声明 1–4 个组件".into()); + } + for (component, config) in &production.components { + if !valid_identifier(component) || !valid_context(&config.deploy_context) { + return Err(format!("组件 {component:?} 的名称或 deploy_context 无效")); + } + if config.probe.kind != "http-json" + || !valid_identifier(&config.probe.revision_field) + || !valid_identifier(&config.probe.healthy_field) + { + return Err(format!("组件 {component:?} 的 probe 字段无效")); + } + validate_probe_url(&config.probe.url)?; + } + Ok(Some(production)) +} + +fn valid_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|ch| ch.is_ascii_alphanumeric() || ch == b'_' || ch == b'-') +} + +fn valid_context(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.trim() == value + && !value.chars().any(char::is_control) +} + +fn validate_probe_url(raw: &str) -> Result<(), String> { + if raw.len() > 2048 { + return Err("探针 URL 超过长度上限".into()); + } + let url = tauri::Url::parse(raw).map_err(|_| "探针 URL 无效".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("探针仅允许无凭证、无查询参数的 http/https URL".into()); + } + let host = url.host_str().ok_or("探针 URL 缺少主机名")?; + let host_lower = host.to_ascii_lowercase(); + if host_lower == "localhost" + || host_lower.ends_with(".localhost") + || host_lower.ends_with(".local") + || host_lower.ends_with(".internal") + { + return Err("探针不允许本机或内网域名".into()); + } + let ip_host = host.trim_start_matches('[').trim_end_matches(']'); + if let Ok(ip) = ip_host.parse::() { + if !is_public_probe_ip(ip) { + return Err("探针不允许本机或内网 IP".into()); + } + } + Ok(()) +} + +fn is_public_probe_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_multicast() + && !ip.is_unspecified() + && !ip.is_broadcast() + && octets[0] != 0 + && !(octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(ip) => { + if let Some(ipv4) = ip.to_ipv4() { + return is_public_probe_ip(IpAddr::V4(ipv4)); + } + !ip.is_loopback() + && !ip.is_unique_local() + && !ip.is_unicast_link_local() + && !ip.is_multicast() + && !ip.is_unspecified() + } + } +} + +fn retain_public_probe_addresses( + addresses: impl IntoIterator, +) -> io::Result> { + let safe: Vec<_> = addresses + .into_iter() + .filter(|address| is_public_probe_ip(address.ip())) + .collect(); + if safe.is_empty() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "探针域名未解析到允许的公网地址", + )); + } + Ok(safe) +} + +/// 探针只用项目声明的 URL 发起无认证 GET,不接受 Agent 动态 URL 或请求头。 +pub fn probe_http_json(probe: &HttpJsonProbe) -> ProbeEvidence { + if let Err(detail) = validate_probe_url(&probe.url) { + return ProbeEvidence::unavailable(detail); + } + let agent = ureq::AgentBuilder::new() + .try_proxy_from_env(false) + .resolver(|addr: &str| retain_public_probe_addresses(addr.to_socket_addrs()?)) + .redirects(0) + .timeout(Duration::from_secs(4)) + .timeout_connect(Duration::from_secs(3)) + .timeout_read(Duration::from_secs(3)) + .timeout_write(Duration::from_secs(3)) + .build(); + let response = match agent.get(&probe.url).call() { + Ok(response) => response, + Err(error) => return ProbeEvidence::unavailable(format!("探针请求失败:{error}")), + }; + let body = match read_bounded_probe(response.into_reader()) { + Ok(body) => body, + Err(detail) => return ProbeEvidence::unavailable(detail), + }; + parse_probe_response(&body, probe) +} + +fn read_bounded_probe(reader: impl Read) -> Result, String> { + let mut body = Vec::new(); + reader + .take(MAX_PROBE_BYTES + 1) + .read_to_end(&mut body) + .map_err(|error| format!("探针响应不可读:{error}"))?; + if body.len() as u64 > MAX_PROBE_BYTES { + return Err("探针响应超过 8 KiB".into()); + } + Ok(body) +} + +fn inspect_declared_deployment( + conn: &rusqlite::Connection, + project_id: &str, + root: &Path, +) -> DeliverySignal { + let profile = match read_production_profile(root) { + Ok(None) => { + return DeliverySignal::new( + "not_configured", + "project_capabilities", + "项目未声明 production delivery profile", + serde_json::json!({}), + ); + } + Err(detail) => { + return DeliverySignal::new( + "unavailable", + "project_capabilities", + detail, + serde_json::json!({}), + ); + } + Ok(Some(profile)) => profile, + }; + let contexts: Vec = profile + .components + .values() + .map(|component| component.deploy_context.clone()) + .collect(); + let remote = + crate::commands::gitea::get_production_context_signals(conn, project_id, &contexts); + let (target_sha, deploy_signals) = match remote { + Ok((sha, signals)) => (Some(sha), signals), + Err(signal) => ( + None, + contexts + .iter() + .map(|context| (context.clone(), signal.clone())) + .collect(), + ), + }; + let components = profile + .components + .iter() + .map(|(name, component)| { + ( + name.clone(), + ( + component.deploy_context.clone(), + deploy_signals + .get(&component.deploy_context) + .cloned() + .unwrap_or_else(|| { + DeliverySignal::new( + "unknown", + "gitea", + "部署 context 未返回结果", + serde_json::json!({}), + ) + }), + probe_http_json(&component.probe), + ), + ) + }) + .collect(); + aggregate_deployment(target_sha.as_deref(), components) +} + +type ComponentEvidence = (String, DeliverySignal, ProbeEvidence); + +/// 纯函数:部署过程与当前运行事实必须同时成立;不把 CI 或 HTTP 单独升格为成功。 +fn aggregate_deployment( + target_sha: Option<&str>, + components: BTreeMap, +) -> DeliverySignal { + let any_failed = components + .values() + .any(|(_, deploy, _)| deploy.state == "failure"); + 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(|(_, deploy, probe)| { + deploy.state == "success" + && probe + .revision + .as_deref() + .is_some_and(|running| running != target) + }) + }); + let all_deployed = target_sha.is_some() + && !components.is_empty() + && components.values().all(|(_, deploy, probe)| { + deploy.state == "success" + && probe.state == "healthy" + && probe.revision.as_deref() == target_sha + }); + let any_unavailable = components + .values() + .any(|(_, deploy, probe)| deploy.state == "unavailable" || probe.state == "unavailable"); + let (state, detail) = if any_failed { + ( + "failed", + "Gitea 明确报告部署失败;同时保留探针所见的运行版本", + ) + } else if any_pending { + ( + "pending", + "部署 context 尚未结束,当前运行版本可能仍是旧版本", + ) + } else if any_unhealthy { + ("unhealthy", "运行版本可读,但项目技术健康字段为 false") + } else if any_drifted { + ("drifted", "当前运行 SHA 与 Gitea 默认分支目标 SHA 不一致") + } else if all_deployed { + ( + "deployed", + "Gitea 部署 context 成功且当前运行 SHA 与目标一致", + ) + } else if any_unavailable { + ("unavailable", "Gitea 部署状态或运行探针不可达") + } else if any_skipped { + ( + "skipped", + "本次默认分支提交未触发部署;保留当前运行版本,不将正常跳过误报为漂移", + ) + } else { + ("unknown", "缺少足以判断 production 部署的双证据") + }; + let running_revision = (components.len() == 1) + .then(|| { + components + .values() + .next() + .and_then(|(_, _, probe)| probe.revision.clone()) + }) + .flatten(); + let deployed_at = (components.len() == 1) + .then(|| { + components + .values() + .next() + .and_then(|(_, deploy, _)| { + (deploy.state == "success") + .then(|| deploy.data["status"]["updated_at"].as_str()) + .flatten() + }) + .map(str::to_owned) + }) + .flatten(); + let mut evidence = Vec::new(); + let component_data: BTreeMap = components + .iter() + .map(|(name, (context, deploy, probe))| { + evidence.push(serde_json::json!({ + "component": name, + "source": "gitea", + "state": deploy.state, + })); + evidence.push(serde_json::json!({ + "component": name, + "source": "http_probe", + "state": probe.state, + })); + let revision_match = target_sha + .and_then(|target| probe.revision.as_deref().map(|running| running == target)); + ( + name.clone(), + serde_json::json!({ + "deploy_context": context, + "deploy": deploy, + "health": probe, + "revision_match": revision_match, + }), + ) + }) + .collect(); + DeliverySignal::new( + state, + "gitea+http_probe", + detail, + serde_json::json!({ + "environment": "production", + "target_revision": target_sha, + "running_revision": running_revision, + "deployed_at": deployed_at, + "components": component_data, + "evidence": evidence, + }), + ) +} + +fn parse_probe_response(body: &[u8], probe: &HttpJsonProbe) -> ProbeEvidence { + let Ok(value) = serde_json::from_slice::(body) else { + return ProbeEvidence::unavailable("探针未返回有效 JSON"); + }; + let revision = value + .get(&probe.revision_field) + .and_then(|value| value.as_str()) + .map(str::to_owned); + let healthy = value + .get(&probe.healthy_field) + .and_then(|value| value.as_bool()); + if revision + .as_deref() + .is_none_or(|value| value.len() != 40 || !value.bytes().all(|ch| ch.is_ascii_hexdigit())) + || healthy.is_none() + { + return ProbeEvidence::unavailable("探针缺少完整 Git SHA 或布尔健康字段"); + } + ProbeEvidence { + state: if healthy == Some(true) { + "healthy" + } else { + "unhealthy" + } + .into(), + revision, + healthy, + detail: "已读取项目声明的只读 HTTP JSON 探针".into(), + } +} #[derive(Debug, Serialize, Clone, PartialEq)] pub struct DeliverySignal { @@ -81,13 +564,7 @@ pub fn get_delivery_status( pull_request: gitea.pull_request, ci: gitea.ci, review: gitea.review, - // P1 尚未要求项目向炼境回传部署结果。保留独立信号位,防止把 CI 成功误报为线上健康。 - deployment: DeliverySignal::new( - "unknown", - "deployment_report", - "项目尚未向炼境回传部署完成或健康证据;CI 成功不等于 production 健康", - serde_json::json!({}), - ), + deployment: inspect_declared_deployment(conn, project_id, project_root), } } @@ -224,6 +701,197 @@ mod tests { get_delivery_status(&conn, "unbound", Path::new("E:/definitely-not-a-project")); assert_eq!(status.pull_request.state, "not_configured"); assert_eq!(status.ci.state, "not_configured"); - assert_eq!(status.deployment.state, "unknown"); + assert_eq!(status.deployment.state, "not_configured"); + } + + const PROFILE: &str = r#" +version: 1 +delivery: + targets: + production: + provider: gitea-actions + components: + api: + deploy_context: "CI — DeepSeek Agent / deploy-api (push)" + probe: + kind: http-json + url: https://api3.yljt.info/health/version + revision_field: revision + healthy_field: ok +"#; + + #[test] + fn production_profile_has_fixed_read_only_shape() { + let profile = parse_production_profile(PROFILE).unwrap().unwrap(); + assert_eq!(profile.provider, "gitea-actions"); + let api = &profile.components["api"]; + assert_eq!( + api.deploy_context, + "CI — DeepSeek Agent / deploy-api (push)" + ); + assert_eq!(api.probe.url, "https://api3.yljt.info/health/version"); + assert!(parse_production_profile( + &PROFILE.replace("provider: gitea-actions", "provider: ssh") + ) + .is_err()); + assert!(parse_production_profile(&PROFILE.replace( + "healthy_field: ok", + "healthy_field: ok\n headers: {Authorization: secret}" + )) + .is_err()); + assert!(parse_production_profile("version: 1\nplanning: {}\n") + .unwrap() + .is_none()); + } + + #[test] + fn probe_url_rejects_credentials_and_non_http_sources() { + for url in [ + "file:///etc/passwd", + "https://user:secret@api3.yljt.info/health/version", + "https://api3.yljt.info/health/version?token=secret", + "http://127.0.0.1:27190/mcp", + "http://[::1]/version", + "http://[::ffff:127.0.0.1]/version", + "http://192.168.1.10/version", + "http://localhost/version", + ] { + assert!(validate_probe_url(url).is_err(), "{url} 应被拒绝"); + } + assert!(validate_probe_url("https://api3.yljt.info/health/version").is_ok()); + } + + #[test] + fn probe_dns_resolver_never_returns_private_addresses() { + let loopback: SocketAddr = "127.0.0.1:443".parse().unwrap(); + let private: SocketAddr = "192.168.1.1:443".parse().unwrap(); + let carrier_nat: SocketAddr = "100.64.0.1:443".parse().unwrap(); + let public: SocketAddr = "8.8.8.8:443".parse().unwrap(); + assert!(retain_public_probe_addresses([loopback, private, carrier_nat]).is_err()); + assert_eq!( + retain_public_probe_addresses([loopback, public, private]).unwrap(), + vec![public] + ); + assert!(validate_probe_url("http://100.64.0.1/version").is_err()); + } + + #[test] + fn missing_profile_is_not_configured_and_probe_body_is_strict() { + let empty = std::env::temp_dir().join(format!( + "lianjing_delivery_profile_absent_{}", + std::process::id() + )); + assert!(read_production_profile(&empty).unwrap().is_none()); + let probe = parse_production_profile(PROFILE) + .unwrap() + .unwrap() + .components + .remove("api") + .unwrap() + .probe; + let sha = "a".repeat(40); + let healthy = parse_probe_response( + format!(r#"{{"ok":true,"revision":"{sha}"}}"#).as_bytes(), + &probe, + ); + assert_eq!(healthy.state, "healthy"); + assert_eq!(healthy.revision.as_deref(), Some(sha.as_str())); + let unhealthy = parse_probe_response( + format!(r#"{{"ok":false,"revision":"{sha}"}}"#).as_bytes(), + &probe, + ); + assert_eq!(unhealthy.state, "unhealthy"); + assert_eq!(parse_probe_response(b"{}", &probe).state, "unavailable"); + } + + #[test] + fn oversized_and_timed_out_probe_bodies_are_unavailable() { + let oversized = vec![b'x'; MAX_PROBE_BYTES as usize + 1]; + assert!(read_bounded_probe(std::io::Cursor::new(oversized)).is_err()); + + struct TimedOut; + impl Read for TimedOut { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout")) + } + } + assert!(read_bounded_probe(TimedOut) + .unwrap_err() + .contains("timeout")); + } + + fn aggregate_fixture( + deploy_state: &str, + probe_state: &str, + running: Option<&str>, + ) -> DeliverySignal { + let mut components = BTreeMap::new(); + components.insert( + "api".to_string(), + ( + "CI — DeepSeek Agent / deploy-api (push)".to_string(), + DeliverySignal::new( + deploy_state, + "gitea", + "fixture", + serde_json::json!({"status": {"updated_at": "2026-09-22T03:38:55Z"}}), + ), + ProbeEvidence { + state: probe_state.to_string(), + revision: running.map(str::to_owned), + healthy: Some(probe_state == "healthy"), + detail: "fixture".into(), + }, + ), + ); + aggregate_deployment(Some(&"a".repeat(40)), components) + } + + #[test] + fn deployment_requires_both_evidence_sources_and_exact_sha() { + let target = "a".repeat(40); + let old = "b".repeat(40); + let deployed = aggregate_fixture("success", "healthy", Some(&target)); + assert_eq!(deployed.state, "deployed"); + assert_eq!(deployed.data["target_revision"], target); + assert_eq!(deployed.data["running_revision"], target); + assert_eq!(deployed.data["deployed_at"], "2026-09-22T03:38:55Z"); + assert_eq!(deployed.data["evidence"].as_array().unwrap().len(), 2); + + let drifted = aggregate_fixture("success", "healthy", Some(&old)); + assert_eq!(drifted.state, "drifted"); + assert_eq!(drifted.data["running_revision"], old); + let failed = aggregate_fixture("failure", "healthy", Some(&old)); + assert_eq!(failed.state, "failed"); + assert_eq!(failed.data["running_revision"], old); + assert!(failed.data["deployed_at"].is_null()); + let pending = aggregate_fixture("pending", "healthy", Some(&old)); + assert_eq!(pending.state, "pending"); + assert_eq!(pending.data["running_revision"], old); + assert!(pending.data["deployed_at"].is_null()); + assert_eq!( + aggregate_fixture("success", "unhealthy", Some(&target)).state, + "unhealthy" + ); + assert_eq!( + 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!(skipped.data["deployed_at"].is_null()); + 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 324dfe7..21ade35 100644 --- a/src-tauri/src/commands/gitea.rs +++ b/src-tauri/src/commands/gitea.rs @@ -607,6 +607,100 @@ pub fn summarize_status_context( ) } +/// production 目标始终来自 Gitea 默认分支,不取本地工作分支的 HEAD。 +/// 这样 Agent 在 feature branch 上开发时也不会把未合并 SHA 当成线上目标。 +pub fn get_production_context_signals( + conn: &rusqlite::Connection, + project_id: &str, + contexts: &[String], +) -> Result<(String, std::collections::BTreeMap), DeliverySignal> { + let binding: rusqlite::Result<(String, String, String)> = conn.query_row( + "SELECT instance_id, gitea_owner, gitea_repo_name FROM gitea_repos WHERE project_id = ?1", + params![project_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ); + let (instance_id, owner, repo) = binding.map_err(|error| { + DeliverySignal::new( + if matches!(error, rusqlite::Error::QueryReturnedNoRows) { + "not_configured" + } else { + "unavailable" + }, + "gitea", + format!("读取项目 Gitea 绑定失败:{error}"), + serde_json::json!({}), + ) + })?; + let (base_url, token) = instance_auth(conn, &instance_id).map_err(|error| { + DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) + })?; + let agent = ureq::builder() + .timeout(std::time::Duration::from_secs(5)) + .build(); + let read = |path: &str, label: &str| { + agent + .get(&format!("{base_url}{path}")) + .set("Authorization", &format!("token {token}")) + .call() + .map_err(|error| api_err(label, error)) + .and_then(|response| { + response + .into_json::() + .map_err(|error| format!("{label}: 解析响应失败: {error}")) + }) + .map_err(|error| { + DeliverySignal::new("unavailable", "gitea", error, serde_json::json!({})) + }) + }; + let repository = read( + &format!("/api/v1/repos/{owner}/{repo}"), + "读取 Gitea 仓库失败", + )?; + let branch = repository["default_branch"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + DeliverySignal::new( + "unknown", + "gitea", + "仓库未返回默认分支", + serde_json::json!({}), + ) + })?; + let branch_path = format!( + "/api/v1/repos/{owner}/{repo}/branches/{}", + urlencoding::encode(branch) + ); + let branch_response = read(&branch_path, "读取 Gitea 默认分支失败")?; + let target_sha = branch_response["commit"]["id"] + .as_str() + .or_else(|| branch_response["commit"]["sha"].as_str()) + .filter(|value| value.len() == 40 && value.bytes().all(|ch| ch.is_ascii_hexdigit())) + .ok_or_else(|| { + DeliverySignal::new( + "unknown", + "gitea", + "默认分支未返回完整 commit SHA", + serde_json::json!({}), + ) + })? + .to_string(); + let statuses = read( + &format!("/api/v1/repos/{owner}/{repo}/statuses/{target_sha}?sort=recentupdate&limit=50"), + "读取 Gitea 部署 context 失败", + )?; + let signals = contexts + .iter() + .map(|context| { + ( + context.clone(), + summarize_status_context(&statuses, &target_sha, context), + ) + }) + .collect(); + Ok((target_sha, signals)) +} + /// 对每位 reviewer 仅保留提交时间最新的状态,避免旧的 approve 掩盖后续 request changes。 pub fn summarize_reviews(reviews: &serde_json::Value) -> DeliverySignal { let Some(items) = reviews.as_array() else { @@ -1911,6 +2005,78 @@ mod tests { ); } + #[test] + fn production_context_uses_remote_default_branch_and_exact_job_name() { + 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(); + let sha = "a".repeat(40); + let status_body = serde_json::json!([{ + "context": "CI — DeepSeek Agent / deploy-api (push)", + "status": "success", + "description": "Success", + "updated_at": "2026-09-22T03:38:55Z" + }]) + .to_string(); + let responses = [ + ( + "/api/v1/repos/o/r", + r#"{"default_branch":"main"}"#.to_string(), + ), + ( + "/api/v1/repos/o/r/branches/main", + format!(r#"{{"commit":{{"id":"{sha}"}}}}"#), + ), + ("/api/v1/repos/o/r/statuses/", status_body), + ]; + let server = std::thread::spawn(move || { + let started = std::time::Instant::now(); + for (expected_path, body) in responses { + let mut stream = loop { + if let Ok((stream, _)) = listener.accept() { + break stream; + } + assert!(started.elapsed() < std::time::Duration::from_secs(5)); + std::thread::sleep(std::time::Duration::from_millis(10)); + }; + // 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}" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO gitea_instances (id, name, url, access_token) VALUES ('i1','test',?1,'token')", + params![format!("http://{address}")], + ) + .unwrap(); + conn.execute( + "INSERT INTO gitea_repos (id, instance_id, project_id, gitea_owner, gitea_repo_name) VALUES ('r1','i1','p1','o','r')", + [], + ) + .unwrap(); + let context = "CI — DeepSeek Agent / deploy-api (push)".to_string(); + let (target, signals) = + get_production_context_signals(&conn, "p1", &[context.clone()]).unwrap(); + assert_eq!(target, sha); + assert_eq!(signals[&context].state, "success"); + server.join().unwrap(); + } + #[test] fn gitea_1_22_skipped_job_is_not_deployment_success() { // 脱敏自 enterprise-system 真实 run #529:Gitea 1.22.6 把 skipped job diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 76a4f8d..a2a7356 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -546,6 +546,29 @@ fn check_capability_packs(content: Option<&str>) -> VerifyCheck { } } +fn check_delivery_profile(content: &str) -> VerifyCheck { + match crate::commands::delivery_status::parse_production_profile(content) { + Ok(Some(profile)) => VerifyCheck::with_state( + "production delivery profile", + VerifyState::Ready, + format!( + ".lian-jing/capabilities.yaml 已声明 {} 个只读组件探针;项目文件由项目自行维护,接入包不会覆盖", + profile.components.len() + ), + ), + Ok(None) => VerifyCheck::with_state( + "production delivery profile", + VerifyState::Ready, + ".lian-jing/capabilities.yaml 格式有效;未配置 production delivery(可选)", + ), + Err(error) => VerifyCheck::with_state( + "production delivery profile", + VerifyState::Unavailable, + format!(".lian-jing/capabilities.yaml 无效:{error};请在项目内修复,接入包不会覆盖"), + ), + } +} + fn check_toolchain_resolver(root: &Path) -> VerifyCheck { match crate::commands::toolchain::parse_toolchain_contract(root) { Err(error) => { @@ -844,6 +867,18 @@ pub fn verify_onboarding( checks.push(check_capability_packs( read(".lian-jing/capability-packs.json").as_deref(), )); + // delivery profile 是可选的项目所有声明;缺席不降低基础 onboarding 的通过率。 + let delivery_path = root.join(".lian-jing/capabilities.yaml"); + if delivery_path.exists() { + checks.push(match std::fs::read_to_string(&delivery_path) { + Ok(content) => check_delivery_profile(&content), + Err(error) => VerifyCheck::with_state( + "production delivery profile", + VerifyState::Unavailable, + format!("无法读取项目声明:{error}"), + ), + }); + } if node_pnpm_contract_present(root) || root.join("mise.toml").exists() { checks.push(check_toolchain_resolver(root)); } @@ -1325,6 +1360,29 @@ mod tests { let _ = std::fs::remove_dir_all(&base); } + #[test] + fn project_owned_delivery_profile_is_checked_but_never_overwritten() { + let base = std::env::temp_dir().join(format!( + "lianjing_delivery_onboarding_test_{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(base.join(".lian-jing")).unwrap(); + let profile_path = base.join(".lian-jing/capabilities.yaml"); + let profile = "version: 1\ndelivery:\n targets:\n production:\n provider: gitea-actions\n components:\n api:\n deploy_context: deploy-api\n probe:\n kind: http-json\n url: https://api3.yljt.info/health/version\n revision_field: revision\n healthy_field: ok\n"; + std::fs::write(&profile_path, profile).unwrap(); + dispatch_templates(base.to_str().unwrap()); + assert_eq!(std::fs::read_to_string(&profile_path).unwrap(), profile); + let check = check_delivery_profile(profile); + assert_eq!(check.state, VerifyState::Ready); + let invalid = check_delivery_profile(&profile.replace( + "url: https://api3.yljt.info/health/version", + "url: file:///etc/passwd", + )); + assert_eq!(invalid.state, VerifyState::Unavailable); + let _ = std::fs::remove_dir_all(&base); + } + #[test] fn managed_version_check_states() { let expected = extract_managed_block(TMPL_AGENTS) diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 0468ef5..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 工作区与最近 SHA、当前分支的 Gitea PR、该 SHA 的 CI、PR review,以及已知 deployment/health 证据。该工具只呈现来源信号,不替项目判断是否应该发布;来源不可用或未接入时会明确返回 unknown/unavailable。", + "description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、跳过部署、部署成功后的漂移、未配置或来源不可达分别保留 failed/skipped/drifted/not_configured/unavailable,不替项目作发布决定。", "inputSchema": { "type": "object", "properties": {