新增: 无人值守P0任务边界全链

- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
This commit is contained in:
lxy
2026-10-04 16:27:30 +08:00
parent 8396849d04
commit b2d44f85e9
29 changed files with 2773 additions and 142 deletions
@@ -108,6 +108,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
// origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式):
// 迁移未跑/极端情况防御,NULL=主会话。
origin: row.get("origin").unwrap_or(None),
// boundary_json 列老库经 v45 迁移补建。unwrap_or(None) 兜底(NULL=未声明任务边界)。
boundary_json: row.get("boundary_json").unwrap_or(None),
})
}
@@ -183,24 +185,25 @@ impl_repo!(
from_row => |row| ai_conversation_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
rec.boundary_json
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12 WHERE id = ?13",
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
params![
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
if rec.pinned { 1i32 } else { 0i32 },
rec.prompt_tokens, rec.completion_tokens,
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.id
rec.pinned_goals, rec.pending_approvals, rec.updated_at, rec.boundary_json, rec.id
],
)
}
@@ -783,6 +786,29 @@ impl AiConversationRepo {
.map_err(storage_err)?
}
/// 无人值守任务边界快照写入(V45 boundary_json 列)。None=清除(回退逐次审批)。
///
/// 仅改 boundary_json,不动 updated_at(纯元数据标记,对齐 set_archived/set_pinned)。
/// 返回 affected>0:对话尚未落库(懒创建,首条消息前)时影响 0 行——调用方以内存
/// per_conv.boundary 为真相源,首插 save 会随 INSERT 带上,无需报错。
pub async fn set_boundary_json(&self, id: &str, boundary_json: Option<&str>) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let value = boundary_json.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ai_conversations SET boundary_json = ?1 WHERE id = ?2",
params![value, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// G1.3: 删除对话 + 其全部 ai_messages 子行(单事务原子)。
///
/// 背景:原宏生成 `delete` 只删 ai_conversations 主行,而 ai_messages 表无外键级联
+59 -6
View File
@@ -51,7 +51,9 @@ pub fn run(conn: &Connection) -> Result<()> {
// 纯数据 UPDATE 不经过 promote 函数,不触发嵌入生成)。
// V44 = 子代理轨迹来源标记(ai_conversations.origin TEXT,spawn_agent 出口整轨迹落库,
// 桌面侧栏/miniapp 列表按 origin 过滤子会话)。
let steps: [(i32, fn(&Connection) -> Result<()>); 44] = [
// V45 = 无人值守任务边界(ai_conversations.boundary_json TEXT,TaskBoundary JSON 快照,
// 发起时一次声明存续期生效,随会话同亡)。
let steps: [(i32, fn(&Connection) -> Result<()>); 45] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -96,6 +98,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(42, migrate_v42),
(43, migrate_v43),
(44, migrate_v44),
(45, migrate_v45),
];
for (version, migrate_fn) in steps {
@@ -1334,6 +1337,23 @@ fn migrate_v44(conn: &Connection) -> Result<()> {
Ok(())
}
/// V45: 无人值守任务边界 — ai_conversations 加 boundary_json TEXT 列。
///
/// TaskBoundary JSON 快照(风险上限/目录白名单/禁破坏性/预算),发起时一次声明、
/// 任务存续期生效、随会话同亡(无独立表,设计拍板点 2: 任务与会话 1:1,独立表过度设计)。
/// NULL=未声明(现状逐次审批,渐进增强零倒退)。
/// - **幂等**:column_exists 守卫(同 V42/V44 模式),崩溃重跑/全链两遍不炸;
/// - **可逆**:`ALTER TABLE ai_conversations DROP COLUMN boundary_json`(SQLite 3.35+)。
fn migrate_v45(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ai_conversations", "boundary_json") {
conn.execute("ALTER TABLE ai_conversations ADD COLUMN boundary_json TEXT", [])?;
tracing::info!("v45: ai_conversations 加 boundary_json 列(无人值守任务边界)");
}
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [45])?;
tracing::info!("迁移 v45 完成: ai_conversations 加 boundary_json 列");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -2338,7 +2358,7 @@ mod tests {
"tasks.module_id 列缺失(V41 加)"
);
// 3. schema_version 应推进到 43(全量迁移成功落版本号)
// 3. schema_version 应推进到 45(全量迁移成功落版本号)
let max_version: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -2347,8 +2367,8 @@ mod tests {
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 44,
"全量迁移后 schema_version 应为 44(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version, 45,
"全量迁移后 schema_version 应为 45(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
);
@@ -2425,7 +2445,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v, 44, "首轮应推进到 44");
assert_eq!(max_v, 45, "首轮应推进到 45");
// 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2437,7 +2457,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v2, 44, "重跑后应重新推进到 44");
assert_eq!(max_v2, 45, "重跑后应重新推进到 45");
}
// ============================================================
@@ -2510,4 +2530,37 @@ mod tests {
.unwrap();
assert_eq!(v, 41);
}
/// V45 幂等:最小库补建 boundary_json 列;重跑(含手动回退版本号模拟崩溃断点)不炸、
/// 不报 duplicate column、版本号 INSERT OR IGNORE 防 PK 冲突。
#[test]
fn v45_boundary_json_idempotent() {
let conn = setup_legacy_db();
migrate_v45(&conn).expect("v45 首次迁移应成功");
assert!(column_exists(&conn, "ai_conversations", "boundary_json"), "应有 boundary_json 列");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 45, "应写入版本号 45");
// 模拟崩溃断点:版本号回退(45 未落)但列已建 → 重跑走 column_exists 短路
conn.execute("DELETE FROM schema_version WHERE version = 45", []).unwrap();
migrate_v45(&conn).expect("列已存在时重跑应幂等跳过 ALTER 不报错");
let v2: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v2, 45, "重跑后版本号恢复 45");
// 列可写可读(NULL 语义=未声明边界)
conn.execute(
"INSERT INTO ai_conversations (id, messages, created_at, updated_at, boundary_json) \
VALUES ('c1', '[]', '0', '0', '{\"risk_ceiling\":\"medium\"}')",
[],
)
.unwrap();
let got: Option<String> = conn
.query_row("SELECT boundary_json FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
.unwrap();
assert!(got.is_some(), "boundary_json 应可写可读");
}
}
+3
View File
@@ -374,6 +374,9 @@ pub struct AiConversationRecord {
/// 会话来源标记(V44):None=主会话;Some("sub_agent")=子代理轨迹(spawn_agent 出口落库),
/// 桌面侧栏/miniapp 列表按此过滤。不可变列(UPDATE 不写)。
pub origin: Option<String>,
/// 无人值守任务边界快照(V45):TaskBoundary JSON;None=未声明(现状逐次审批)。
/// 生命周期=会话存续(发起时一次声明,随会话同亡),save 随行写/switch 读还原。
pub boundary_json: Option<String>,
}
/// AI 工具执行审计记录