推进本地缓冲区与资源对象壳验证

This commit is contained in:
lix-2026
2026-05-19 12:20:47 +08:00
parent 6fcdf78603
commit 4b863086ff
3 changed files with 333 additions and 40 deletions
+254
View File
@@ -181,6 +181,91 @@ pub struct KernelObjectIdentity {
pub asset_id: Option<String>,
}
/// DocumentBuffer 打开状态 — 表达当前编辑器缓冲区对文件的打开态、dirty 状态和文件版本仲裁。
///
/// 五种状态:Clean(未修改)、Dirty(已修改未保存)、Stale(版本过时)、
/// ExternalModified(外部已修改)、Deleted(外部已删除)。
///
/// Phase A2 收口后,tiptap autosave、AI 写入、外部 watcher 均围绕此状态仲裁。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DocBufferDirtyState {
Clean,
Dirty,
Stale,
ExternalModified,
Deleted,
}
impl Default for DocBufferDirtyState {
fn default() -> Self {
Self::Clean
}
}
/// DocumentBuffer 最小字段模型 — 表示一个已打开文档的缓冲区状态。
///
/// 不是持久化结构,而是运行时模型,用于仲裁写入冲突和外部修改检测。
/// 每个打开的编辑器(tiptap / AI session / external editor)对应一个实例。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DocumentBuffer {
/// 文档在 workspace 中的统一路径身份
pub workspace_path: ObjectWorkspacePath,
/// 最后已知文件版本(来自 aggregate.fileVersion 或 disk mtime content-hash
pub file_version: Option<String>,
/// 最后加载/保存时的内容 hash
pub base_content_hash: Option<String>,
/// 当前编辑器内容 hashdirty 时与 base 不同)
pub current_content_hash: Option<String>,
/// 缓冲区 dirty 状态
pub dirty_state: DocBufferDirtyState,
/// 最后加载时间戳 (ms since epoch)
pub last_loaded_at: Option<i64>,
/// 最后保存时间戳 (ms since epoch)
pub last_saved_at: Option<i64>,
/// 外部修改者
pub external_actor: Option<String>,
}
impl DocumentBuffer {
pub fn is_dirty(&self) -> bool {
self.dirty_state == DocBufferDirtyState::Dirty
|| self.dirty_state == DocBufferDirtyState::Stale
}
pub fn mark_dirty(&mut self, content_hash: String) {
self.current_content_hash = Some(content_hash);
self.dirty_state = DocBufferDirtyState::Dirty;
}
pub fn mark_saved(&mut self, file_version: String, content_hash: String) {
self.file_version = Some(file_version);
self.base_content_hash = Some(content_hash);
self.current_content_hash = None;
self.dirty_state = DocBufferDirtyState::Clean;
self.last_saved_at = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64,
);
}
pub fn mark_external_modified(&mut self, actor: Option<String>) {
self.external_actor = actor;
if self.is_dirty() {
self.dirty_state = DocBufferDirtyState::Stale;
} else {
self.dirty_state = DocBufferDirtyState::ExternalModified;
}
}
pub fn mark_deleted(&mut self) {
self.dirty_state = DocBufferDirtyState::Deleted;
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KernelBlockAssetRelation {
@@ -190,6 +275,28 @@ pub struct KernelBlockAssetRelation {
pub asset_kind: KernelProjectionAssetKind,
}
/// 统一 workspace 路径身份 — 表达"哪个 workspace root 下的哪个对象/文件/资源"。
///
/// 这是 Phase A1 收口后的主身份类型,用于:
/// - File Tree / Page Tree / Resource Tree 的 resourceMeta
/// - 本地文件写入时的身份验证
/// - 前端 sidebar/filetree 的统一 identity 消费
///
/// 相比旧的 `KernelObjectIdentity`,增加了 workspace 上下文:
/// `workspace_id`、`source_kind`、`root_uri`、`relative_path`。
///
/// 所有新代码应优先使用此类型;`KernelObjectIdentity` 保留为内核投影的兼容子集。
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ObjectWorkspacePath {
pub workspace_id: String,
pub source_kind: WorkspaceSourceKind,
pub root_uri: String,
pub relative_path: String,
pub object_identity: KernelObjectIdentity,
pub resource_kind: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum KernelGraphDirection {
@@ -817,4 +924,151 @@ mod tests {
assert_eq!(request.base_content_hash, None);
assert_eq!(request.editor_source, None);
}
#[test]
fn object_workspace_path_round_trip() {
let path = ObjectWorkspacePath {
workspace_id: "local-ws:user:my-space".into(),
source_kind: WorkspaceSourceKind::LocalFolder,
root_uri: "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space".into(),
relative_path: "docs/README.md".into(),
object_identity: KernelObjectIdentity {
object_kind: KernelObjectKind::Page,
document_id: Some("local-md:docs~2FREADME.md".into()),
block_id: None,
asset_id: None,
},
resource_kind: Some("document".into()),
};
let value = serde_json::to_value(&path).expect("ObjectWorkspacePath 应可序列化");
assert_eq!(value["workspaceId"], json!("local-ws:user:my-space"));
assert_eq!(value["sourceKind"], json!("local_folder"));
assert_eq!(
value["rootUri"],
json!("file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space")
);
assert_eq!(value["relativePath"], json!("docs/README.md"));
assert_eq!(value["objectIdentity"]["objectKind"], json!("page"));
assert_eq!(
value["objectIdentity"]["documentId"],
json!("local-md:docs~2FREADME.md")
);
assert_eq!(value["resourceKind"], json!("document"));
let decoded: ObjectWorkspacePath =
serde_json::from_value(value).expect("ObjectWorkspacePath 应可反序列化");
assert_eq!(decoded, path);
}
#[test]
fn object_workspace_path_accepts_resource_kind_null() {
#[derive(Deserialize)]
struct Minimal {
resource_kind: Option<String>,
}
let value = serde_json::json!({
"workspaceId": "ws_1",
"sourceKind": "convex_workspace",
"rootUri": "convex://ws",
"relativePath": "doc.md",
"objectIdentity": {
"objectKind": "page",
"documentId": "doc_1",
"blockId": null,
"assetId": null
}
});
let path: ObjectWorkspacePath =
serde_json::from_value(value).expect("resource_kind null 应接受");
assert_eq!(path.resource_kind, None);
}
#[test]
fn document_buffer_round_trip() {
let identity = KernelObjectIdentity {
object_kind: KernelObjectKind::Page,
document_id: Some("local-md:doc.md".into()),
block_id: None,
asset_id: None,
};
let path = ObjectWorkspacePath {
workspace_id: "ws_1".into(),
source_kind: WorkspaceSourceKind::LocalFolder,
root_uri: "file:///tmp/root".into(),
relative_path: "doc.md".into(),
object_identity: identity,
resource_kind: Some("document".into()),
};
let buf = DocumentBuffer {
workspace_path: path,
file_version: Some("v1".into()),
base_content_hash: Some("sha256:abc".into()),
current_content_hash: None,
dirty_state: DocBufferDirtyState::Clean,
last_loaded_at: Some(1_700_000_000_000i64),
last_saved_at: Some(1_700_000_000_001i64),
external_actor: None,
};
let value = serde_json::to_value(&buf).expect("DocumentBuffer 应可序列化");
assert_eq!(value["dirtyState"], json!("clean"));
assert_eq!(value["fileVersion"], json!("v1"));
assert_eq!(value["workspacePath"]["relativePath"], json!("doc.md"));
let decoded: DocumentBuffer =
serde_json::from_value(value).expect("DocumentBuffer 应可反序列化");
assert_eq!(decoded, buf);
}
#[test]
fn document_buffer_state_transitions() {
let identity = KernelObjectIdentity {
object_kind: KernelObjectKind::Page,
document_id: Some("local-md:doc.md".into()),
block_id: None,
asset_id: None,
};
let path = ObjectWorkspacePath {
workspace_id: "ws_1".into(),
source_kind: WorkspaceSourceKind::LocalFolder,
root_uri: "file:///tmp/root".into(),
relative_path: "doc.md".into(),
object_identity: identity,
resource_kind: Some("document".into()),
};
let mut buf = DocumentBuffer {
workspace_path: path,
file_version: Some("v1".into()),
base_content_hash: Some("sha256:abc".into()),
current_content_hash: None,
dirty_state: DocBufferDirtyState::Clean,
last_loaded_at: None,
last_saved_at: None,
external_actor: None,
};
assert!(!buf.is_dirty());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
buf.mark_dirty("sha256:dirty".into());
assert!(buf.is_dirty());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Dirty);
buf.mark_external_modified(Some("external-editor".into()));
assert!(buf.is_dirty());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Stale);
assert_eq!(buf.external_actor.as_deref(), Some("external-editor"));
buf.mark_saved("v2".into(), "sha256:saved".into());
assert!(!buf.is_dirty());
assert_eq!(buf.dirty_state, DocBufferDirtyState::Clean);
assert_eq!(buf.file_version.as_deref(), Some("v2"));
buf.mark_external_modified(None);
assert_eq!(buf.dirty_state, DocBufferDirtyState::ExternalModified);
buf.mark_deleted();
assert_eq!(buf.dirty_state, DocBufferDirtyState::Deleted);
}
}
+12 -11
View File
@@ -37,19 +37,20 @@ pub use editor::{
ReferenceToken, ReferenceTokenKind, ReferenceTokenStrategy, TextMark,
};
pub use kernel::{
DocumentContentResult, DocumentReadEvidenceItem, DocumentReadEvidenceKind, DocumentReadNode,
DocumentReadNodeMeta, DocumentReadNodeType, DocumentReadOutlineEntry, DocumentReadPageSubtree,
DocumentReadStats, DocumentReadSubtree, KernelAttachEdge, KernelAuditStamp,
KernelBlockAssetRelation, KernelContentPayload, KernelCreateNode, KernelDetachEdge, KernelEdge,
KernelEdgeListResult, KernelEdgeType, KernelGetNode, KernelGetSubtree, KernelGraphDirection,
KernelGraphTraversalResult, KernelGraphVisit, KernelListChildren, KernelListEdges,
KernelMoveSubtree, KernelNode, KernelNodeMetadata, KernelNodeType, KernelObjectIdentity,
KernelObjectKind, KernelProjectionAssetKind, KernelProjectionCapability,
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
DocBufferDirtyState, DocumentBuffer, DocumentContentResult, DocumentReadEvidenceItem,
DocumentReadEvidenceKind, DocumentReadNode, DocumentReadNodeMeta, DocumentReadNodeType,
DocumentReadOutlineEntry, DocumentReadPageSubtree, DocumentReadStats, DocumentReadSubtree,
KernelAttachEdge, KernelAuditStamp, KernelBlockAssetRelation, KernelContentPayload,
KernelCreateNode, KernelDetachEdge, KernelEdge, KernelEdgeListResult, KernelEdgeType,
KernelGetNode, KernelGetSubtree, KernelGraphDirection, KernelGraphTraversalResult,
KernelGraphVisit, KernelListChildren, KernelListEdges, KernelMoveSubtree, KernelNode,
KernelNodeMetadata, KernelNodeType, KernelObjectIdentity, KernelObjectKind,
KernelProjectionAssetKind, KernelProjectionCapability, KernelProjectionFilter,
KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult,
KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult,
KernelTraverseGraph, KernelUpdateNode, PageBodyWriteRequest, WorkspaceSource,
WorkspaceSourceCapability, WorkspaceSourceKind,
KernelTraverseGraph, KernelUpdateNode, ObjectWorkspacePath, PageBodyWriteRequest,
WorkspaceSource, WorkspaceSourceCapability, WorkspaceSourceKind,
};
pub use mindmap::{
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,