//! 使者回传:子项目 agent(Claude / Codex / DSH)把体系级洞察回传炼境,炼境处置后留回执。 //! //! 回传仍存 `project_notes`,内容以 [`ENVOY_PREFIX`] 开头(B4 沉默项目统计按前缀匹配,格式保持兼容); //! 回执存 `envoy_dispositions`(note_id 主键,允许 upsert 更正)。 //! 「回传只进不出」是本模块要修的病灶:列表只列未处置,处置必须写明方式与理由。 //! 所有函数由调用方注入连接(规则:业务函数不内部取池),便于 in-memory 测试。 use rusqlite::{params, Connection, OptionalExtension}; pub const ENVOY_PREFIX: &str = "【炼境使者回传】"; pub const KINDS: &[&str] = &["template_bug", "validated_practice", "workflow_hypothesis"]; pub const CONFIDENCES: &[&str] = &["confirmed", "under_validation"]; pub const SUGGESTIONS: &[&str] = &["fix_template", "update_convention", "log_for_review"]; pub const CLIENTS: &[&str] = &["claude", "codex", "dsh", "other"]; /// landed=有落点证据;carded=已转任务卡;watching=待观察/仅归档;obsolete=已过时;rejected=不由炼境处理 pub const DISPOSITIONS: &[&str] = &["landed", "carded", "watching", "obsolete", "rejected"]; const MAX_CONTENT_CHARS: usize = 1000; /// 列表单次最多展示条数;超出时明确报告未展示条数,绝不静默截断 const LIST_DISPLAY_LIMIT: usize = 50; /// 一条待写入的回传(字段语义与 /envoy 模板既有格式一致,新增 client) pub struct EnvoyReport<'a> { pub kind: &'a str, pub confidence: &'a str, pub scope: &'a str, pub content: &'a str, pub suggestion: &'a str, pub client: &'a str, } #[derive(Debug, PartialEq, Eq)] pub struct ReportOutcome { pub note_id: i64, /// 同项目已有内容完全相同且未处置的回传(多为断连重试),未重复写入 pub duplicate: bool, } fn ensure_one_of(field: &str, value: &str, allowed: &[&str]) -> Result<(), String> { if allowed.contains(&value) { Ok(()) } else { Err(format!( "{field} 取值无效:{value:?},只允许 {}", allowed.join(" | ") )) } } /// 回传格式是逐行「键: 值」,字段内换行会破坏格式——压成单行 fn single_line(text: &str) -> String { text.lines() .map(str::trim) .filter(|line| !line.is_empty()) .collect::>() .join(" ") } fn validate_scope(scope: &str) -> Result<(), String> { let valid = matches!(scope, "this_project" | "all_projects") || scope .strip_prefix("project_type:") .is_some_and(|kind| !kind.trim().is_empty()); if valid { Ok(()) } else { Err(format!( "scope 取值无效:{scope:?},只允许 this_project | all_projects | project_type:<类型>" )) } } /// 兜底拦截明显的凭据(私钥头、常见令牌前缀后接长串)。高精度、低召回: /// 回传会双写进子项目 `.blueprint/notes-archive.jsonl` 随项目进 Git,其余情形靠工具描述约束。 fn looks_like_secret(text: &str) -> bool { const TOKEN_PREFIXES: &[&str] = &[ "sk-", "ghp_", "gho_", "ghs_", "github_pat_", "glpat-", "xoxb-", "xoxp-", "AKIA", ]; if text.contains("PRIVATE KEY-----") { return true; } let token_char = |c: char| c.is_ascii_alphanumeric() || c == '-' || c == '_'; text.split(|c: char| !(token_char(c) || c == '.')) .any(|word| { let prefixed = TOKEN_PREFIXES.iter().any(|prefix| { word.strip_prefix(prefix) .is_some_and(|rest| rest.len() >= 16 && rest.chars().all(token_char)) }); let jwt = word.starts_with("eyJ") && word.len() >= 30 && word.matches('.').count() == 2; prefixed || jwt }) } fn project_display_name(conn: &Connection, project_id: &str) -> Result { conn.query_row( "SELECT COALESCE(pp.name, pw.id) FROM project_workspaces pw LEFT JOIN project_profiles pp ON pw.profile_id = pp.id WHERE pw.id = ?1", [project_id], |row| row.get::<_, String>(0), ) .optional() .map_err(|e| e.to_string())? .ok_or_else(|| format!("项目 {project_id} 不存在")) } /// 写入一条回传,返回笔记编号。调用方负责双写 notes 档案(与 append_project_note 同一路径)。 pub fn report( conn: &Connection, project_id: &str, input: &EnvoyReport, ) -> Result { ensure_one_of("kind", input.kind, KINDS)?; ensure_one_of("confidence", input.confidence, CONFIDENCES)?; ensure_one_of("suggestion", input.suggestion, SUGGESTIONS)?; ensure_one_of("client", input.client, CLIENTS)?; let scope = single_line(input.scope); validate_scope(&scope)?; let content = single_line(input.content); if content.is_empty() { return Err("content 不能为空:用一句话写清摩擦或实践及其证据".to_string()); } if content.chars().count() > MAX_CONTENT_CHARS { return Err(format!( "content 超过 {MAX_CONTENT_CHARS} 字:回传只写一句话结论与证据,细节留在本项目" )); } if looks_like_secret(&content) { return Err( "content 疑似包含凭据(私钥或访问令牌),已拒绝写入:回传只描述现象,不附带任何凭据" .to_string(), ); } if project_id.trim().is_empty() { return Err("project_id 必填:项目地址会自动绑定;连到全局地址时传 .lian-jing/context.json 中的 project_id".to_string()); } let name = project_display_name(conn, project_id)?; let note = format!( "{ENVOY_PREFIX}来源: {name}\n客户端: {client}\n类型: {kind}\n置信度: {confidence}\n影响范围: {scope}\n内容: {content}\n建议炼境: {suggestion}", client = input.client, kind = input.kind, confidence = input.confidence, suggestion = input.suggestion, ); // 幂等只针对断连重试:同项目、1 天内、未处置、首行之后逐字相同(首行含项目名,改名不影响)。 // 先查后插放进 IMMEDIATE 事务,并发重试不会各写一条。 let body = ¬e[note.find('\n').unwrap_or(note.len())..]; let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) .map_err(|e| e.to_string())?; let existing: Option = tx .query_row( "SELECT pn.id FROM project_notes pn WHERE pn.project_id = ?1 AND substr(pn.content, instr(pn.content, char(10))) = ?2 AND pn.created_at >= datetime('now', 'localtime', '-1 day') AND NOT EXISTS (SELECT 1 FROM envoy_dispositions ed WHERE ed.note_id = pn.id) ORDER BY pn.id LIMIT 1", params![project_id, body], |row| row.get(0), ) .optional() .map_err(|e| e.to_string())?; let outcome = match existing { Some(note_id) => ReportOutcome { note_id, duplicate: true, }, None => { tx.execute( "INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)", params![project_id, note], ) .map_err(|e| e.to_string())?; ReportOutcome { note_id: tx.last_insert_rowid(), duplicate: false, } } }; tx.commit().map_err(|e| e.to_string())?; Ok(outcome) } /// 处置一条回传(upsert,允许更正),返回此前的处置方式(首次处置为 None)。 pub fn resolve( conn: &Connection, note_id: i64, disposition: &str, reference: &str, reason: &str, ) -> Result, String> { ensure_one_of("disposition", disposition, DISPOSITIONS)?; let reference = single_line(reference); let reason = single_line(reason); if reason.is_empty() { return Err("reason 不能为空:写明处置依据".to_string()); } if matches!(disposition, "landed" | "carded") && reference.is_empty() { return Err(format!( "disposition={disposition} 必须填写 reference(提交 SHA、文件路径或任务卡标题)" )); } let content: Option = conn .query_row( "SELECT content FROM project_notes WHERE id = ?1", [note_id], |row| row.get(0), ) .optional() .map_err(|e| e.to_string())?; match content { None => return Err(format!("笔记 #{note_id} 不存在")), Some(content) if !content.starts_with(ENVOY_PREFIX) => { return Err(format!("笔记 #{note_id} 不是使者回传,不能处置")) } Some(_) => {} } let previous: Option = conn .query_row( "SELECT disposition FROM envoy_dispositions WHERE note_id = ?1", [note_id], |row| row.get(0), ) .optional() .map_err(|e| e.to_string())?; conn.execute( "INSERT INTO envoy_dispositions (note_id, disposition, reference, reason) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(note_id) DO UPDATE SET disposition = excluded.disposition, reference = excluded.reference, reason = excluded.reason, resolved_at = datetime('now', 'localtime')", params![note_id, disposition, reference, reason], ) .map_err(|e| e.to_string())?; Ok(previous) } #[derive(Debug, Clone)] pub struct EnvoyReturn { pub id: i64, pub project: String, pub created_at: String, pub content: String, /// (disposition, reference, reason, resolved_at);未处置为 None pub resolution: Option<(String, String, String, String)>, } /// 全部使者回传(新→旧)。LEFT JOIN:项目登记已删的回传也要列出,不得静默丢失。 pub fn all_returns(conn: &Connection) -> Result, String> { let mut stmt = conn .prepare( "SELECT pn.id, COALESCE(pp.name, pn.project_id), pn.content, COALESCE(pn.created_at, ''), ed.disposition, ed.reference, ed.reason, ed.resolved_at FROM project_notes pn LEFT JOIN project_workspaces pw ON pn.project_id = pw.id LEFT JOIN project_profiles pp ON pw.profile_id = pp.id LEFT JOIN envoy_dispositions ed ON ed.note_id = pn.id WHERE pn.content LIKE ?1 ORDER BY pn.created_at DESC, pn.id DESC", ) .map_err(|e| e.to_string())?; let rows = stmt .query_map([format!("{ENVOY_PREFIX}%")], |row| { let disposition: Option = row.get(4)?; Ok(EnvoyReturn { id: row.get(0)?, project: row.get(1)?, content: row.get(2)?, created_at: row.get(3)?, resolution: match disposition { Some(disposition) => Some((disposition, row.get(5)?, row.get(6)?, row.get(7)?)), None => None, }, }) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(rows) } fn date_of(timestamp: &str) -> &str { ×tamp[..timestamp.len().min(10)] } /// 回传正文里的「内容:」一行(列已处置时用作摘要) fn content_summary(content: &str) -> String { let line = content .lines() .find_map(|line| line.strip_prefix("内容:")) .map(str::trim) .unwrap_or_else(|| content.lines().next().unwrap_or_default()); let mut summary: String = line.chars().take(80).collect(); if line.chars().count() > 80 { summary.push('…'); } summary } /// list_envoy_returns 的文本输出:默认只列未处置(带编号),附已处置计数;超出展示上限时明说 pub fn format_returns(returns: &[EnvoyReturn], include_resolved: bool) -> String { let (resolved, pending): (Vec<&EnvoyReturn>, Vec<&EnvoyReturn>) = returns.iter().partition(|item| item.resolution.is_some()); let mut out = if pending.is_empty() { format!( "📭 暂无待处置的炼境使者回传(已处置 {} 条)\n", resolved.len() ) } else { format!( "📬 待处置使者回传 {} 条(已处置 {} 条)\n\n", pending.len(), resolved.len() ) }; for item in pending.iter().take(LIST_DISPLAY_LIMIT) { out.push_str(&format!( "---\n**#{}** | **来源**: {} | **时间**: {}\n{}\n\n", item.id, item.project, date_of(&item.created_at), item.content )); } if pending.len() > LIST_DISPLAY_LIMIT { out.push_str(&format!( "---\n⚠️ 另有 {} 条更早的待处置回传未展示——处置上面的条目后再次调用即可看到\n\n", pending.len() - LIST_DISPLAY_LIMIT )); } if include_resolved && !resolved.is_empty() { out.push_str(&format!("---\n### 已处置({} 条)\n\n", resolved.len())); for item in &resolved { if let Some((disposition, reference, reason, resolved_at)) = &item.resolution { let reference = if reference.is_empty() { String::new() } else { format!("|落点: {reference}") }; out.push_str(&format!( "- **#{}** {} {} → **{}**{}|理由: {}({})\n {}\n", item.id, item.project, date_of(&item.created_at), disposition, reference, reason, date_of(resolved_at), content_summary(&item.content) )); } } out.push('\n'); } if !pending.is_empty() { out.push_str( "---\n💡 处置:resolve_envoy_return(note_id, disposition, reference, reason)——\ landed=有落点证据、carded=已转任务卡(两者 reference 必填:提交 SHA / 文件 / 卡片标题)、\ watching=待观察或仅归档、obsolete=已过时、rejected=不由炼境处理;可重复调用更正", ); } out } #[cfg(test)] mod tests { use super::*; fn seed_project(conn: &Connection, workspace_id: &str, name: &str) { conn.execute( "INSERT INTO project_profiles (id, name) VALUES (?1, ?2)", params![format!("profile-{workspace_id}"), name], ) .unwrap(); conn.execute( "INSERT INTO project_workspaces (id, profile_id) VALUES (?1, ?2)", params![workspace_id, format!("profile-{workspace_id}")], ) .unwrap(); } fn report_one( conn: &Connection, project_id: &str, content: &str, client: &str, ) -> ReportOutcome { report( conn, project_id, &EnvoyReport { kind: "template_bug", confidence: "confirmed", scope: "all_projects", content, suggestion: "fix_template", client, }, ) .unwrap() } fn pending_ids(conn: &Connection) -> Vec { all_returns(conn) .unwrap() .into_iter() .filter(|item| item.resolution.is_none()) .map(|item| item.id) .collect() } #[test] fn report_writes_prefixed_note_with_client_line() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let outcome = report_one( &conn, "ent", "模板 X 在 Y 场景\n漏了 Z(实测 2 次返工)", "codex", ); assert!(!outcome.duplicate); let stored: String = conn .query_row( "SELECT content FROM project_notes WHERE id = ?1", [outcome.note_id], |row| row.get(0), ) .unwrap(); assert!(stored.starts_with("【炼境使者回传】来源: enterprise-system\n")); assert!(stored.contains("\n客户端: codex\n")); assert!( stored.contains("\n内容: 模板 X 在 Y 场景 漏了 Z(实测 2 次返工)\n"), "字段内换行须压成单行: {stored}" ); assert!(stored.ends_with("\n建议炼境: fix_template")); // B4 沉默项目统计按前缀 LIKE 匹配,新格式必须仍命中 let matched: i64 = conn .query_row( "SELECT COUNT(*) FROM project_notes WHERE content LIKE '【炼境使者回传】%'", [], |row| row.get(0), ) .unwrap(); assert_eq!(matched, 1); } #[test] fn report_rejects_invalid_fields_and_unknown_project() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let base = EnvoyReport { kind: "template_bug", confidence: "confirmed", scope: "all_projects", content: "一句话", suggestion: "fix_template", client: "dsh", }; for bad in [ EnvoyReport { kind: "bug", ..base }, EnvoyReport { confidence: "sure", ..base }, EnvoyReport { scope: "everywhere", ..base }, EnvoyReport { scope: "project_type: ", ..base }, EnvoyReport { suggestion: "fix", ..base }, EnvoyReport { client: "cursor", ..base }, EnvoyReport { content: " \n ", ..base }, ] { assert!(report(&conn, "ent", &bad).is_err()); } let long = "长".repeat(MAX_CONTENT_CHARS + 1); assert!(report( &conn, "ent", &EnvoyReport { content: &long, ..base } ) .is_err()); assert!(report(&conn, "ghost", &base) .unwrap_err() .contains("不存在")); assert!(report( &conn, "ent", &EnvoyReport { scope: "project_type:capacitor_android", ..base } ) .is_ok()); let count: i64 = conn .query_row("SELECT COUNT(*) FROM project_notes", [], |row| row.get(0)) .unwrap(); assert_eq!(count, 1, "非法输入不得落库"); } #[test] fn identical_retry_is_idempotent_until_resolved() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let first = report_one(&conn, "ent", "断连重试场景", "claude"); let retry = report_one(&conn, "ent", "断连重试场景", "claude"); assert_eq!( retry, ReportOutcome { note_id: first.note_id, duplicate: true } ); // 已处置后再次出现同样摩擦 = 复发信号,必须新开一条 resolve(&conn, first.note_id, "watching", "", "待观察").unwrap(); let recurrence = report_one(&conn, "ent", "断连重试场景", "claude"); assert!(!recurrence.duplicate); assert_ne!(recurrence.note_id, first.note_id); } #[test] fn retry_dedup_survives_rename_but_not_the_one_day_window() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let first = report_one(&conn, "ent", "改名后重试", "codex"); conn.execute("UPDATE project_profiles SET name = '企业系统'", []) .unwrap(); let retry = report_one(&conn, "ent", "改名后重试", "codex"); assert_eq!(retry.note_id, first.note_id, "首行项目名不参与去重"); // 超过 1 天的同内容未处置回传 = 摩擦复发,不能被幂等吞掉(否则沉默项目统计也看不到) conn.execute( "UPDATE project_notes SET created_at = datetime('now', 'localtime', '-3 days')", [], ) .unwrap(); let later = report_one(&conn, "ent", "改名后重试", "codex"); assert!(!later.duplicate); } #[test] fn report_refuses_obvious_credentials() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let input = |content| EnvoyReport { kind: "template_bug", confidence: "confirmed", scope: "all_projects", content, suggestion: "fix_template", client: "codex", }; for leaked in [ "部署失败,token=ghp_abcdefghijklmnopqrstuvwxyz0123", "环境变量 OPENAI_API_KEY=sk-proj-AbCdEfGhIjKlMnOpQrSt 不生效", "粘了 -----BEGIN OPENSSH PRIVATE KEY----- 到 Secret", "header Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJlLXZhbHVl", ] { let error = report(&conn, "ent", &input(leaked)).unwrap_err(); assert!(error.contains("疑似包含凭据"), "{leaked} → {error}"); } // 只描述现象、提到令牌前缀本身不算凭据 for clean in [ "Windows 复制 SSH 私钥须用 cat | clip 保 LF,否则 ssh.ParsePrivateKey 报 no key found", "OpenAI key 以 sk- 开头,task-runner 与 desk-check 不受影响", ] { assert!(report(&conn, "ent", &input(clean)).is_ok(), "{clean}"); } assert!(report(&conn, " ", &input("缺项目")) .unwrap_err() .contains("project_id 必填")); } #[test] fn resolved_returns_leave_pending_list_and_count_up() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let a = report_one(&conn, "ent", "摩擦 A", "claude").note_id; let b = report_one(&conn, "ent", "摩擦 B", "codex").note_id; let c = report_one(&conn, "ent", "摩擦 C", "dsh").note_id; assert_eq!(pending_ids(&conn).len(), 3); assert_eq!( resolve(&conn, a, "landed", "commit abc1234", "已修模板").unwrap(), None ); let text = format_returns(&all_returns(&conn).unwrap(), false); assert!( text.contains("待处置使者回传 2 条(已处置 1 条)"), "{text}" ); assert!( !text.contains(&format!("**#{a}**")), "已处置不得再列出: {text}" ); assert!(text.contains(&format!("**#{b}**")) && text.contains(&format!("**#{c}**"))); resolve( &conn, b, "carded", "governance-feedback-loop 杂项挂车批", "转卡", ) .unwrap(); resolve(&conn, c, "rejected", "", "探针测试").unwrap(); assert!(pending_ids(&conn).is_empty(), "全部处置后待处置应为 0"); let text = format_returns(&all_returns(&conn).unwrap(), false); assert!( text.starts_with("📭 暂无待处置的炼境使者回传(已处置 3 条)"), "{text}" ); let audit = format_returns(&all_returns(&conn).unwrap(), true); assert!(audit.contains(&format!("**#{a}** enterprise-system"))); assert!(audit.contains("→ **landed**|落点: commit abc1234|理由: 已修模板")); } #[test] fn resolve_validates_and_allows_correction() { let conn = crate::db::conn_with_schema(); seed_project(&conn, "ent", "enterprise-system"); let id = report_one(&conn, "ent", "摩擦", "claude").note_id; conn.execute( "INSERT INTO project_notes (project_id, content) VALUES ('ent', '【复盘】普通笔记')", [], ) .unwrap(); let plain = conn.last_insert_rowid(); assert!( resolve(&conn, id, "done", "x", "y").is_err(), "非法 disposition" ); assert!( resolve(&conn, id, "landed", " ", "有理由").is_err(), "landed 缺 reference" ); assert!( resolve(&conn, id, "carded", "", "有理由").is_err(), "carded 缺 reference" ); assert!( resolve(&conn, id, "watching", "", " ").is_err(), "缺 reason" ); assert!(resolve(&conn, plain, "watching", "", "理由") .unwrap_err() .contains("不是使者回传")); assert!(resolve(&conn, 9_999, "watching", "", "理由") .unwrap_err() .contains("不存在")); assert_eq!(resolve(&conn, id, "watching", "", "先观察").unwrap(), None); assert_eq!( resolve(&conn, id, "landed", "commit def5678", "已落地").unwrap(), Some("watching".to_string()) ); let (disposition, reference): (String, String) = conn .query_row( "SELECT disposition, reference FROM envoy_dispositions WHERE note_id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?)), ) .unwrap(); assert_eq!( (disposition.as_str(), reference.as_str()), ("landed", "commit def5678") ); } #[test] fn list_keeps_orphaned_returns_and_reports_overflow() { let conn = crate::db::conn_with_schema(); // 项目登记已删:旧实现 JOIN project_profiles 会静默丢掉这条 conn.execute( "INSERT INTO project_notes (project_id, content, created_at) VALUES ('gone-project', '【炼境使者回传】来源: 旧项目\n内容: 孤儿回传', '2026-07-01 10:00:00')", [], ) .unwrap(); for i in 0..LIST_DISPLAY_LIMIT { conn.execute( "INSERT INTO project_notes (project_id, content, created_at) VALUES ('gone-project', ?1, '2026-08-01 10:00:00')", [format!("【炼境使者回传】来源: 旧项目\n内容: 第 {i} 条")], ) .unwrap(); } let returns = all_returns(&conn).unwrap(); assert_eq!(returns.len(), LIST_DISPLAY_LIMIT + 1); assert_eq!( returns.last().unwrap().project, "gone-project", "无登记时回退显示 project_id" ); let text = format_returns(&returns, false); assert!(text.contains(&format!("待处置使者回传 {} 条", LIST_DISPLAY_LIMIT + 1))); assert!( text.contains("另有 1 条更早的待处置回传未展示"), "超限必须明说: {text}" ); assert!(!text.contains("孤儿回传"), "最旧的一条超出展示上限"); } }