回传只进不出:list_envoy_returns 无处置状态、LIMIT 50 把更早的回传静默挤掉; 入口只有 Claude /envoy,08-23 后 enterprise 主力转 DSH/Codex 即零回传。 - envoy.rs 业务本体(连接注入)+ envoy_dispositions 回执表(migrate 补表) - report_envoy_return(项目级,Claude/Codex/DSH 在项目地址均可用):字段枚举校验、 内容压单行限长、兜底拒绝明显凭据;同项目 1 天内同内容未处置幂等返回原编号 (IMMEDIATE 事务防并发重复);复用 append_project_note 的档案双写 - resolve_envoy_return(控制面):landed/carded 必填落点、reason 必填、允许更正 - list_envoy_returns 默认只列未处置(带编号),附已处置计数,超出展示上限明说; LEFT JOIN 不再丢掉登记已删项目的回传;include_resolved 可审计回执 - lian-jing skill 新增「遇到摩擦时回传炼境」;/envoy 改为指向该入口的薄指引 (保留扫描与分类硬门槛,炼境分发物出错免门槛,身份改读 .lian-jing/context.json); AGENTS 托管块加一行指路(1.9.0→1.10.0),能力包 base-onboarding 1.6.0→1.7.0 - 删除炼境仓库内 Codex 迁移遗留的 source-command-envoy 技能(读旧身份文件、走旧写入路径) - 契约与架构文档同步:project-context.yaml、project-brief.md、AGENTS.md、agents-meta-rules.md Rules-Applied: R05,R06,R12 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
741 lines
27 KiB
Rust
741 lines
27 KiB
Rust
//! 使者回传:子项目 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::<Vec<_>>()
|
||
.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<String, String> {
|
||
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<ReportOutcome, String> {
|
||
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<i64> = 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<Option<String>, 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<String> = 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<String> = 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<Vec<EnvoyReturn>, 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<String> = 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::<Result<Vec<_>, _>>()
|
||
.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<i64> {
|
||
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("孤儿回传"), "最旧的一条超出展示上限");
|
||
}
|
||
}
|