dev-manager-tauri/src-tauri/src/envoy.rs
lanrtop 3095df3d2a feat(mcp/envoy): 使者回传闭环——回执 + 三端回传入口
回传只进不出: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>
2026-10-03 01:15:40 +09:00

741 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 使者回传:子项目 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 = &note[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 {
&timestamp[..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("孤儿回传"), "最旧的一条超出展示上限");
}
}