新增: 知识图谱Phase2事件流(父⑥) — project_events统一事件流+埋点+timeline
父⑥ Phase 2(对标设计 §2.4): - V30 迁移:project_events 追加型审计表(id/project_id/event_type/entity_type/entity_id/from_state/to_state/context_json/source/conversation_id/created_at)+ 双索引(project,time / entity) - ProjectEventRecord + ProjectEventRepo(insert 追加型/get_by_project/get_by_entity/list_recent) - 事件埋点(best-effort,hook/after,失败 warn 不阻断主操作): - task.rs emit_event 辅助 + create_task/advance_task/move_task_queue 埋点 - idea.rs promote_idea 埋点(idea_promoted,project_id=新项目) - project.rs create/delete 埋点 - 注:idea_created 暂不埋点(idea 无 project_id,待 Inbox 项目落地,NOT NULL+FK 约束) - get_project_timeline IPC(events 模块)+ lib.rs 注册 + AI 工具(基线 +N) - source 语义:IPC 标 human,AI 工具路径标 ai 注:workflow 脚本中文变量名 const 致验证 agent 未跑,主控独立 cargo check EXIT 0 + grep 落地齐全核验。
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
|
||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
||||
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
@@ -19,6 +20,7 @@ mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod project_event_repo;
|
||||
mod project_repo;
|
||||
mod settings;
|
||||
mod task_link_repo;
|
||||
@@ -28,6 +30,7 @@ pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_repo::*;
|
||||
pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
@@ -297,6 +300,7 @@ mod baseline_tests {
|
||||
let _ = ProjectRepo::new(&db);
|
||||
let _ = TaskRepo::new(&db);
|
||||
let _ = TaskLinkRepo::new(&db);
|
||||
let _ = ProjectEventRepo::new(&db);
|
||||
let _ = BranchRepo::new(&db);
|
||||
let _ = ReleaseRepo::new(&db);
|
||||
let _ = WorkflowRepo::new(&db);
|
||||
|
||||
391
crates/df-storage/src/crud/project_event_repo.rs
Normal file
391
crates/df-storage/src/crud/project_event_repo.rs
Normal file
@@ -0,0 +1,391 @@
|
||||
//! 项目事件流 Repo:ProjectEventRepo(project_events 表,知识图谱 Phase 2 V30)
|
||||
//!
|
||||
//! 统一事件流——一张表承载跨实体(idea/task/workflow/knowledge/module/service/project)的
|
||||
//! 全部事件,AI 精准检索的基础(回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出")。
|
||||
//! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4。
|
||||
//!
|
||||
//! 关键设计(对标 TaskLinkRepo 全专用路径):
|
||||
//! - **追加型审计表**:只 INSERT,无 UPDATE/DELETE(历史不改可追溯,对标 knowledge_events V10 /
|
||||
//! idea_evaluations V22)。故不走 `impl_repo!` 宏(宏生成的 update/delete/update_field 对审计
|
||||
//! 语义是错的),手写专用 insert + 三个查询方法。
|
||||
//! - 不登记通用列白名单(`allowed_columns_for("project_events")` 返回 None):同 task_links,
|
||||
//! 无通用 update_field 路径故无需白名单。`baseline_tests::all_known_tables_have_column_whitelist`
|
||||
//! 的已知表数组不含本表(与 task_links 同款豁免)。
|
||||
//! - `event_type` / `entity_type` 白名单**应用层校验**(类比 task_links.link_type),不进 DB 约束
|
||||
//! (保留扩展性,新增事件/实体类型无需迁移)。本 Repo insert **不做白名单校验**——埋点层
|
||||
//! (commands/IPC hook)是事件唯一生产者,字符串字面量自带约束;开放校验留给未来若开放外部写入。
|
||||
//! - 埋点策略(设计 §2.4 hook/after)与 knowledge_events 双写均在调用方(commands 层)实现,
|
||||
//! 非 Repo 职责。事件写入失败 best-effort 不阻断主操作(设计 §10.1)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::ProjectEventRecord;
|
||||
|
||||
use super::{now_millis_str, storage_err};
|
||||
|
||||
// ============================================================
|
||||
// from_row 辅助函数
|
||||
// ============================================================
|
||||
|
||||
fn project_event_from_row(row: &Row<'_>) -> std::result::Result<ProjectEventRecord, rusqlite::Error> {
|
||||
Ok(ProjectEventRecord {
|
||||
id: row.get("id")?,
|
||||
project_id: row.get("project_id")?,
|
||||
event_type: row.get("event_type")?,
|
||||
entity_type: row.get("entity_type")?,
|
||||
entity_id: row.get("entity_id")?,
|
||||
from_state: row.get("from_state")?,
|
||||
to_state: row.get("to_state")?,
|
||||
context_json: row.get("context_json")?,
|
||||
source: row.get("source")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
created_at: row.get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// project_events SELECT 列清单(11 字段,防 COLS 漂移,对标 KNOWLEDGE_COLS)。
|
||||
const PROJECT_EVENT_COLS: &str = "id,project_id,event_type,entity_type,entity_id,from_state,\
|
||||
to_state,context_json,source,conversation_id,created_at";
|
||||
|
||||
// ============================================================
|
||||
// ProjectEventRepo
|
||||
// ============================================================
|
||||
|
||||
/// 项目统一事件流表 Repo(project_events,V30,追加型审计表)。
|
||||
///
|
||||
/// 不走 `impl_repo!` 宏:① 追加型审计表无 UPDATE/DELETE(宏生成的 update/update_field/delete
|
||||
/// 对审计语义是错的);② 表无 `updated_at`(宏的 update_field 硬编码 `SET ... updated_at = ?`
|
||||
/// 会触发 "no such column: updated_at")。故手写专用方法,对标 TaskLinkRepo 全专用路径。
|
||||
pub struct ProjectEventRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ProjectEventRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 追加一条事件(只 INSERT,无 UPDATE,审计语义不可篡改)。
|
||||
///
|
||||
/// 返回插入的事件 id。`created_at` 由本方法内部取当前毫秒时间戳填充(调用方无需传),
|
||||
/// 与 IdeaRepo::insert(now_millis_str)同款收口——事件生产时机即落库时机,时间由存储层定。
|
||||
///
|
||||
/// 不做 event_type / entity_type 白名单校验(埋点层是唯一生产者,字符串字面量自带约束)。
|
||||
pub async fn insert(&self, record: ProjectEventRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
// created_at 由存储层覆盖(调用方传入的不可信),保证「事件生产时机即落库时机」。
|
||||
let now = now_millis_str();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO project_events \
|
||||
(id, project_id, event_type, entity_type, entity_id, from_state, to_state, \
|
||||
context_json, source, conversation_id, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
record.id,
|
||||
record.project_id,
|
||||
record.event_type,
|
||||
record.entity_type,
|
||||
record.entity_id,
|
||||
record.from_state,
|
||||
record.to_state,
|
||||
record.context_json,
|
||||
record.source,
|
||||
record.conversation_id,
|
||||
now,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按项目查询事件流(time DESC,id DESC 兜底同毫秒并列,limit 钳制上限 200,
|
||||
/// 命中 idx_project_events_project)。
|
||||
///
|
||||
/// AI / Dashboard 项目时间线视图用:取某项目最近 N 条事件(设计 §2.4 / §10.3.4)。
|
||||
/// 同毫秒插入的事件(高频埋点常见)以 id DESC 兜底——生产 id 是 ULID(字典序单调递增),
|
||||
/// 字典序 DESC 等价插入逆序,保证「最后发生的事件在最前」语义稳定(纯 created_at DESC
|
||||
/// 同毫秒并列时顺序不确定,审计表必须可复现)。
|
||||
pub async fn get_by_project(
|
||||
&self,
|
||||
project_id: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<ProjectEventRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
// 钳制 limit 防滥用(最大 200,对标 KnowledgeEventsRepo::list_recent)
|
||||
let safe_limit = limit.min(200) as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
|
||||
WHERE project_id = ?1 ORDER BY created_at DESC, id DESC LIMIT ?2"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![project_id, safe_limit], project_event_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按实体反查事件流(命中 idx_project_events_entity)。
|
||||
///
|
||||
/// AI「这个任务/灵感发生过什么」反查:取某实体的全部事件(时间倒序)。跨项目——
|
||||
/// 同一实体(如被移动过项目的任务)的全部历史事件都返回。同毫秒并列以 id DESC 兜底
|
||||
/// (语义稳定,见 get_by_project 说明)。
|
||||
pub async fn get_by_entity(
|
||||
&self,
|
||||
entity_type: &str,
|
||||
entity_id: &str,
|
||||
) -> Result<Vec<ProjectEventRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let entity_type = entity_type.to_owned();
|
||||
let entity_id = entity_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
|
||||
WHERE entity_type = ?1 AND entity_id = ?2 ORDER BY created_at DESC, id DESC"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![entity_type, entity_id], project_event_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 跨项目列最近 N 条事件(全表 created_at DESC,id DESC 兜底,top-N,命中
|
||||
/// idx_project_events_project 的 created_at 维度)。
|
||||
///
|
||||
/// 对标 KnowledgeEventsRepo::list_recent:供「全局事件流 / 跨项目时间线」视图使用
|
||||
/// (设计 §10.3.4 模式 B 事件流)。limit 上限钳制 200,防恶意/失误传超大值。
|
||||
/// 同毫秒并列以 id DESC 兜底(语义稳定,见 get_by_project 说明)。
|
||||
pub async fn list_recent(&self, limit: u32) -> Result<Vec<ProjectEventRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let safe_limit = limit.min(200) as i64;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = format!(
|
||||
"SELECT {PROJECT_EVENT_COLS} FROM project_events \
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?1"
|
||||
);
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![safe_limit], project_event_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — ProjectEventRepo insert + get_by_project + get_by_entity(内存 DB,
|
||||
// 对标 task_link_repo 测试:先建占位 project 满足 FK,再插事件)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::models::ProjectRecord;
|
||||
|
||||
/// 构造内存 DB + 占位 project(project_events.project_id FK 要求 projects 存在)。
|
||||
async fn setup() -> (crate::db::Database, ProjectEventRepo) {
|
||||
let db = crate::db::Database::open_in_memory()
|
||||
.await
|
||||
.expect("open_in_memory");
|
||||
let repo = ProjectEventRepo::new(&db);
|
||||
let project_repo = ProjectRepo::new(&db);
|
||||
project_repo
|
||||
.insert(ProjectRecord {
|
||||
id: "proj-1".to_string(),
|
||||
name: "proj-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
(db, repo)
|
||||
}
|
||||
|
||||
/// 构造一条 ProjectEventRecord fixture(可指定 created_at 便于排序断言)。
|
||||
fn erec(id: &str, project_id: &str, created_at: &str) -> ProjectEventRecord {
|
||||
ProjectEventRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
event_type: "task_advanced".to_string(),
|
||||
entity_type: Some("task".to_string()),
|
||||
entity_id: Some("task-A".to_string()),
|
||||
from_state: Some("todo".to_string()),
|
||||
to_state: Some("in_progress".to_string()),
|
||||
context_json: None,
|
||||
source: Some("ai".to_string()),
|
||||
conversation_id: Some("conv-1".to_string()),
|
||||
created_at: created_at.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn insert_and_read_back_full_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let id = repo.insert(erec("e1", "proj-1", "1700000000001")).await.unwrap();
|
||||
assert_eq!(id, "e1");
|
||||
|
||||
let got = repo
|
||||
.get_by_project("proj-1", 10)
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("应有 1 条事件");
|
||||
// 全字段回读校验
|
||||
assert_eq!(got.id, "e1");
|
||||
assert_eq!(got.project_id, "proj-1");
|
||||
assert_eq!(got.event_type, "task_advanced");
|
||||
assert_eq!(got.entity_type.as_deref(), Some("task"));
|
||||
assert_eq!(got.entity_id.as_deref(), Some("task-A"));
|
||||
assert_eq!(got.from_state.as_deref(), Some("todo"));
|
||||
assert_eq!(got.to_state.as_deref(), Some("in_progress"));
|
||||
assert!(got.context_json.is_none());
|
||||
assert_eq!(got.source.as_deref(), Some("ai"));
|
||||
assert_eq!(got.conversation_id.as_deref(), Some("conv-1"));
|
||||
// created_at 由存储层覆盖为当前毫秒(非 fixture 传入的旧值)
|
||||
assert_ne!(got.created_at, "1700000000001", "created_at 应由存储层覆盖");
|
||||
assert!(!got.created_at.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_project_orders_desc_and_filters() {
|
||||
let (_db, repo) = setup().await;
|
||||
// 三条事件不同时间(create_at 由存储层覆盖,但插入顺序决定落库 created_at 单调递增,
|
||||
// 故 DESC 应按插入逆序返回——最后插的最前)
|
||||
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e3", "proj-1", "1")).await.unwrap();
|
||||
|
||||
let got = repo.get_by_project("proj-1", 10).await.unwrap();
|
||||
assert_eq!(got.len(), 3, "应返回全部 3 条事件");
|
||||
// DESC:最后插入的 e3 在前
|
||||
assert_eq!(got[0].id, "e3");
|
||||
assert_eq!(got[1].id, "e2");
|
||||
assert_eq!(got[2].id, "e1");
|
||||
|
||||
// 过滤:不存在的项目返回空
|
||||
let empty = repo.get_by_project("no-such-proj", 10).await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_project_limit_clamped() {
|
||||
let (_db, repo) = setup().await;
|
||||
for i in 0..5 {
|
||||
repo.insert(erec(&format!("e{i}"), "proj-1", "1"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// limit=2 只返回 2 条(最新两条)
|
||||
let got = repo.get_by_project("proj-1", 2).await.unwrap();
|
||||
assert_eq!(got.len(), 2);
|
||||
// limit=99999 钳制为 200,但只有 5 条,返回 5 条
|
||||
let got_all = repo.get_by_project("proj-1", 99999).await.unwrap();
|
||||
assert_eq!(got_all.len(), 5, "钳制后仍返回全部存在的事件");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_by_entity_cross_project_and_filters() {
|
||||
let (_db, repo) = setup().await;
|
||||
// 同一实体 task-A 在 proj-1 下产生 2 条事件
|
||||
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
|
||||
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
|
||||
// 另一实体 task-B 1 条(改 entity_id)
|
||||
let mut other = erec("e3", "proj-1", "1");
|
||||
other.entity_id = Some("task-B".to_string());
|
||||
repo.insert(other).await.unwrap();
|
||||
|
||||
// task-A 反查:返回 2 条(DESC)
|
||||
let got_a = repo.get_by_entity("task", "task-A").await.unwrap();
|
||||
assert_eq!(got_a.len(), 2);
|
||||
assert_eq!(got_a[0].id, "e2");
|
||||
assert_eq!(got_a[1].id, "e1");
|
||||
|
||||
// task-B 反查:返回 1 条
|
||||
let got_b = repo.get_by_entity("task", "task-B").await.unwrap();
|
||||
assert_eq!(got_b.len(), 1);
|
||||
assert_eq!(got_b[0].id, "e3");
|
||||
|
||||
// 不存在的实体返回空
|
||||
let empty = repo.get_by_entity("task", "no-such").await.unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_recent_cross_project() {
|
||||
let (_db, repo) = setup().await;
|
||||
for i in 0..3 {
|
||||
repo.insert(erec(&format!("e{i}"), "proj-1", "1"))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// list_recent 跨项目取最近 N 条(此处仅 proj-1)
|
||||
let got = repo.list_recent(10).await.unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
// DESC:最后插入的最前
|
||||
assert_eq!(got[0].id, "e2");
|
||||
}
|
||||
|
||||
/// entity_type/entity_id 为 None(纯决策日志无明确实体)也能正常插入与查询。
|
||||
#[tokio::test]
|
||||
async fn insert_with_null_entity_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
let mut rec = erec("e1", "proj-1", "1");
|
||||
rec.event_type = "decision_made".to_string();
|
||||
rec.entity_type = None;
|
||||
rec.entity_id = None;
|
||||
rec.from_state = None;
|
||||
rec.to_state = None;
|
||||
repo.insert(rec).await.unwrap();
|
||||
|
||||
let got = repo.get_by_project("proj-1", 10).await.unwrap();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert!(got[0].entity_type.is_none());
|
||||
assert!(got[0].entity_id.is_none());
|
||||
assert!(got[0].from_state.is_none());
|
||||
assert!(got[0].to_state.is_none());
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,10 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/
|
||||
// parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表
|
||||
// (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 29] = [
|
||||
// V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表,
|
||||
// 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索
|
||||
// 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 30] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -67,6 +70,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(27, migrate_v27),
|
||||
(28, migrate_v28),
|
||||
(29, migrate_v29),
|
||||
(30, migrate_v30),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -773,6 +777,74 @@ fn migrate_v29(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V30:知识图谱 Phase 2 统一事件流 — project_events 追加型审计表
|
||||
///
|
||||
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4,
|
||||
/// 为「AI 精准检索项目事件流」打数据层地基。一张表承载跨实体(idea/task/workflow/
|
||||
/// knowledge/module/service/project)的全部事件,回答"上周做了什么 / 这个任务为何 blocked /
|
||||
/// 这个决策何时做出"——此前需跨 6 张表 JOIN。
|
||||
///
|
||||
/// **追加型审计表**(对标 knowledge_events V10 / idea_evaluations V22):只 INSERT 不 UPDATE/
|
||||
/// DELETE,历史不改可追溯。故表无 updated_at,无通用 update/delete 路径,无列白名单登记
|
||||
/// (ProjectEventRepo 走专用方法,与 TaskLinkRepo 同款全专用路径)。
|
||||
///
|
||||
/// **与 knowledge_events 的关系**(对标设计 D9 / §2.4):knowledge_events 保持不变(知识库专属,
|
||||
/// 向后兼容),project_events 是全局事件流;knowledge 相关事件**同时写入两者**——本表只管写入,
|
||||
/// 双写策略在埋点层(commands/IPC hook)实现,非本表职责。
|
||||
///
|
||||
/// 列语义(对标设计 §2.4):
|
||||
/// - `event_type`:事件类型白名单(idea_created/idea_promoted/...task_created/task_advanced/
|
||||
/// .../decision_made 等,见设计 §2.4 注释枚举)。白名单**应用层校验**(类比 task_links
|
||||
/// link_type),不进 DB 约束(保留扩展性,新增事件类型无需迁移)。
|
||||
/// - `entity_type` / `entity_id`:事件指向的实体(可空——部分事件无明确实体,如纯决策日志)。
|
||||
/// entity_type 白名单同样应用层校验(idea/project/task/workflow/knowledge/module/service)。
|
||||
/// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值,如 task_advanced;
|
||||
/// created/referenced 类为 NULL)。
|
||||
/// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。
|
||||
/// - `source`:ai / human / system(AI Working 溯源——区分是 AI 自主操作还是人操作)。
|
||||
/// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。
|
||||
///
|
||||
/// **埋点策略**(设计 §2.4 hook/after):在现有 IPC 命令(create_task/advance_task/create_project
|
||||
/// /idea_promote 等)执行后追加事件写入,不侵入业务逻辑。事件写入失败 best-effort 不阻断主操作
|
||||
/// (设计 §10.1 风险已识别),由埋点层实现,非本表职责。
|
||||
///
|
||||
/// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V30 之前的库)已有则跳过,均安全。
|
||||
/// 索引覆盖最高频查询:
|
||||
/// - `idx_project_events_project(project_id, created_at)`:按项目查事件流(时间倒序,Dashboard 时间线)
|
||||
/// - `idx_project_events_entity(entity_type, entity_id)`:按实体反查(AI「这个任务发生过什么」)
|
||||
fn migrate_v30(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
event_type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id TEXT,
|
||||
from_state TEXT,
|
||||
to_state TEXT,
|
||||
context_json TEXT,
|
||||
source TEXT,
|
||||
conversation_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_events_project \
|
||||
ON project_events(project_id, created_at)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_events_entity \
|
||||
ON project_events(entity_type, entity_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v30: 建 project_events 表 + 索引(统一事件流,知识图谱 Phase 2)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [30])?;
|
||||
tracing::info!("迁移 v30 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -146,6 +146,41 @@ pub struct TaskLinkRecord {
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 项目事件流记录(project_events 表,知识图谱 Phase 2 V30,追加型审计表)。
|
||||
///
|
||||
/// 一张表承载跨实体(idea/task/workflow/knowledge/module/service/project)的全部事件,
|
||||
/// 是 AI 精准检索项目事件流的基础(回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出")。
|
||||
/// 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4。
|
||||
///
|
||||
/// **追加型审计**(对标 KnowledgeEventRecord / IdeaEvaluationRecord):只 INSERT 不 UPDATE/
|
||||
/// DELETE,历史不改可追溯。无 updated_at 字段,无通用 update/delete 路径。
|
||||
///
|
||||
/// - `project_id`:所属项目(FK projects.id)。
|
||||
/// - `event_type`:事件类型白名单(idea_created/task_advanced/decision_made 等,应用层校验)。
|
||||
/// - `entity_type` / `entity_id`:事件指向的实体(可空——纯决策日志等无明确实体)。entity_type
|
||||
/// 白名单应用层校验(idea/project/task/workflow/knowledge/module/service)。
|
||||
/// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值;created/referenced 类为 None)。
|
||||
/// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。
|
||||
/// - `source`:ai / human / system(AI Working 溯源——区分 AI 自主操作还是人操作)。
|
||||
/// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。
|
||||
///
|
||||
/// **与 knowledge_events 关系**(设计 D9):knowledge_events 保持不变(知识库专属),knowledge
|
||||
/// 相关事件同时写入两者,双写在埋点层(commands/IPC hook)实现。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectEventRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub event_type: String,
|
||||
pub entity_type: Option<String>,
|
||||
pub entity_id: Option<String>,
|
||||
pub from_state: Option<String>,
|
||||
pub to_state: Option<String>,
|
||||
pub context_json: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub conversation_id: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 分支记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BranchRecord {
|
||||
|
||||
Reference in New Issue
Block a user