feat: advance local-first workspace checklist

- add admin access-policy UI and local access control surfaces

- add local markdown conflict resolution UI and smoke coverage

- add ACP local agent changed-files audit scaffold and read-only write guard

- document current P0-P2 checklist progress and verification evidence
This commit is contained in:
lix-2026
2026-05-19 08:07:17 +08:00
parent a2cb1338c8
commit 68d321e297
36 changed files with 8643 additions and 235 deletions
+193 -1
View File
@@ -1,4 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::kernel::WorkspaceSourceKind;
use serde::de;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -66,3 +68,193 @@ pub enum AiStructuredWriteKind {
ReferenceEdge,
PageBody,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AiAccessPermissionLevel {
Admin,
ReadOnly,
ReadWrite,
SharedRead,
SharedWrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiShareContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub share_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct AiAccessScope {
pub user_id: String,
pub workspace_id: String,
pub session_id: String,
pub source_kind: WorkspaceSourceKind,
pub permission_level: AiAccessPermissionLevel,
#[serde(default)]
pub allowed_roots: Vec<String>,
#[serde(default)]
pub allowed_file_paths: Vec<String>,
#[serde(default)]
pub allowed_resource_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub share_context: Option<AiShareContext>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AiAccessScopeRaw {
user_id: String,
workspace_id: String,
session_id: String,
source_kind: WorkspaceSourceKind,
permission_level: AiAccessPermissionLevel,
#[serde(default)]
allowed_roots: Vec<String>,
#[serde(default)]
allowed_file_paths: Vec<String>,
#[serde(default)]
allowed_resource_ids: Vec<String>,
#[serde(default)]
share_context: Option<AiShareContext>,
}
impl<'de> Deserialize<'de> for AiAccessScope {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = AiAccessScopeRaw::deserialize(deserializer)?;
let scope = Self {
user_id: raw.user_id,
workspace_id: raw.workspace_id,
session_id: raw.session_id,
source_kind: raw.source_kind,
permission_level: raw.permission_level,
allowed_roots: raw.allowed_roots,
allowed_file_paths: raw.allowed_file_paths,
allowed_resource_ids: raw.allowed_resource_ids,
share_context: raw.share_context,
};
scope.validate().map_err(de::Error::custom)?;
Ok(scope)
}
}
impl AiAccessScope {
pub fn validate(&self) -> Result<(), String> {
reject_blank("userId", &self.user_id)?;
reject_blank("workspaceId", &self.workspace_id)?;
reject_blank("sessionId", &self.session_id)?;
reject_blank_vec("allowedRoots", &self.allowed_roots)?;
reject_blank_vec("allowedFilePaths", &self.allowed_file_paths)?;
reject_blank_vec("allowedResourceIds", &self.allowed_resource_ids)?;
if let Some(share_context) = &self.share_context {
if let Some(share_id) = &share_context.share_id {
reject_blank("shareContext.shareId", share_id)?;
}
if let Some(mode) = &share_context.mode {
reject_blank("shareContext.mode", mode)?;
}
}
Ok(())
}
}
fn reject_blank(field: &str, value: &str) -> Result<(), String> {
if value.trim().is_empty() {
return Err(format!("{field} must not be blank"));
}
Ok(())
}
fn reject_blank_vec(field: &str, values: &[String]) -> Result<(), String> {
for value in values {
reject_blank(field, value)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::WorkspaceSourceKind;
use serde_json::json;
#[test]
fn ai_access_scope_uses_camel_case_payload() {
let scope: AiAccessScope = serde_json::from_value(json!({
"userId": "user_1",
"workspaceId": "workspace_1",
"sessionId": "session_1",
"sourceKind": "local_folder",
"permissionLevel": "read_write",
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
"allowedFilePaths": ["file:///mnt/Data1T/Mnote_data/users/user_1/我的空间/README.md"],
"allowedResourceIds": ["local-md:README.md"],
"shareContext": {
"shareId": "share_1",
"mode": "shared_write"
}
}))
.expect("scope");
assert_eq!(scope.user_id, "user_1");
assert_eq!(scope.workspace_id, "workspace_1");
assert_eq!(scope.session_id, "session_1");
assert_eq!(scope.source_kind, WorkspaceSourceKind::LocalFolder);
assert_eq!(scope.permission_level, AiAccessPermissionLevel::ReadWrite);
assert_eq!(scope.allowed_roots.len(), 1);
assert_eq!(scope.allowed_file_paths.len(), 1);
assert_eq!(scope.allowed_resource_ids, vec!["local-md:README.md"]);
assert_eq!(
scope
.share_context
.as_ref()
.and_then(|value| value.share_id.as_deref()),
Some("share_1")
);
let serialized = serde_json::to_value(&scope).expect("serialized");
assert_eq!(serialized["userId"], "user_1");
assert_eq!(serialized["permissionLevel"], "read_write");
assert_eq!(
serialized["allowedRoots"][0],
"file:///mnt/Data1T/Mnote_data/users/user_1"
);
}
#[test]
fn ai_access_scope_rejects_blank_required_fields_and_paths() {
let error = serde_json::from_value::<AiAccessScope>(json!({
"userId": " ",
"workspaceId": "workspace_1",
"sessionId": "session_1",
"sourceKind": "local_folder",
"permissionLevel": "read_only",
"allowedRoots": ["file:///mnt/Data1T/Mnote_data/users/user_1"],
"allowedFilePaths": [],
"allowedResourceIds": []
}))
.expect_err("blank user id should be rejected");
assert!(error.to_string().contains("userId"));
let error = serde_json::from_value::<AiAccessScope>(json!({
"userId": "user_1",
"workspaceId": "workspace_1",
"sessionId": "session_1",
"sourceKind": "local_folder",
"permissionLevel": "read_only",
"allowedRoots": [" "],
"allowedFilePaths": [],
"allowedResourceIds": []
}))
.expect_err("blank allowed root should be rejected");
assert!(error.to_string().contains("allowedRoots"));
}
}
+87
View File
@@ -76,6 +76,33 @@ pub struct WorkspaceSource {
pub capabilities: Vec<WorkspaceSourceCapability>,
}
fn default_page_body_content_format() -> String {
"editorBlocks".into()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PageBodyWriteRequest {
pub document_id: String,
pub workspace_id: String,
pub source_kind: WorkspaceSourceKind,
pub root_uri: String,
#[serde(
default,
alias = "expected_file_version",
alias = "conflictDetectionKey",
alias = "conflict_detection_key"
)]
pub expected_file_version: Option<String>,
#[serde(default)]
pub base_content_hash: Option<String>,
#[serde(default = "default_page_body_content_format")]
pub content_format: String,
pub content: Value,
#[serde(default)]
pub editor_source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum KernelProjectionCapability {
@@ -528,6 +555,14 @@ pub struct DocumentReadPageSubtree {
#[serde(rename_all = "camelCase")]
pub struct DocumentContentResult {
pub content: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub editor_document: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tiptap_document: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_document: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_projection_version: Option<u32>,
pub revision: u64,
pub conflict_detection_key: String,
pub title: Option<String>,
@@ -730,4 +765,56 @@ mod tests {
serde_json::from_value(value).expect("workspace source 应可反序列化");
assert_eq!(decoded, source);
}
#[test]
fn page_body_write_request_uses_file_version_contract() {
let request: PageBodyWriteRequest = serde_json::from_value(json!({
"documentId": "local-md:README.md",
"workspaceId": "local-ws:user:my-space",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/user/workspaces/my-space",
"expectedFileVersion": "local-md:local-md:README.md:1:2:hash",
"baseContentHash": "sha256:base",
"contentFormat": "editorBlocks",
"content": [{"type": "paragraph", "content": [{"type": "text", "text": "正文"}]}],
"editorSource": "tiptap"
}))
.expect("page.body.write request 应可反序列化");
assert_eq!(request.document_id, "local-md:README.md");
assert_eq!(request.source_kind, WorkspaceSourceKind::LocalFolder);
assert_eq!(
request.expected_file_version.as_deref(),
Some("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(request.content_format, "editorBlocks");
assert_eq!(request.editor_source.as_deref(), Some("tiptap"));
let value = serde_json::to_value(&request).expect("request 应可序列化");
assert_eq!(
value["expectedFileVersion"],
json!("local-md:local-md:README.md:1:2:hash")
);
assert_eq!(value["baseContentHash"], json!("sha256:base"));
assert_eq!(value["contentFormat"], json!("editorBlocks"));
assert_eq!(value["editorSource"], json!("tiptap"));
}
#[test]
fn page_body_write_request_accepts_legacy_conflict_key_alias() {
let request: PageBodyWriteRequest = serde_json::from_value(json!({
"documentId": "local-md:README.md",
"workspaceId": "local-ws:user:my-space",
"sourceKind": "local_folder",
"rootUri": "file:///tmp/workspace",
"conflictDetectionKey": "legacy-key",
"content": []
}))
.expect("legacy compat request 应可反序列化");
assert_eq!(request.expected_file_version.as_deref(), Some("legacy-key"));
assert_eq!(request.content_format, "editorBlocks");
assert_eq!(request.base_content_hash, None);
assert_eq!(request.editor_source, None);
}
}
+5 -4
View File
@@ -11,8 +11,8 @@ pub mod search;
pub mod tool;
pub use ai::{
AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind,
AiStructuredWriteResult, AiToolCall,
AiAccessPermissionLevel, AiAccessScope, AiEvent, AiEventKind, AiRuntimeOwner, AiSession,
AiShareContext, AiStructuredWriteKind, AiStructuredWriteResult, AiToolCall,
};
pub use command::{
CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace,
@@ -48,8 +48,8 @@ pub use kernel::{
KernelProjectionFilter, KernelProjectionItem, KernelProjectionKind, KernelProjectionRequest,
KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult,
KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult,
KernelTraverseGraph, KernelUpdateNode, WorkspaceSource, WorkspaceSourceCapability,
WorkspaceSourceKind,
KernelTraverseGraph, KernelUpdateNode, PageBodyWriteRequest, WorkspaceSource,
WorkspaceSourceCapability, WorkspaceSourceKind,
};
pub use mindmap::{
MindmapAdapterProjection, MindmapAssociativeLine, MindmapCommand, MindmapKernelCapabilities,
@@ -289,6 +289,7 @@ mod tests {
content: serde_json::json!([]),
revision: serde_json::json!(7),
conflict_detection_key: serde_json::json!("page_1:7"),
file_version: serde_json::json!(null),
block_document: serde_json::json!({
"documentId": "page_1",
"rootBlockIds": [],
@@ -139,6 +139,7 @@ mod tests {
assert_eq!(body.block_projection_version, 0);
assert_eq!(body.block_document, json!(null));
assert_eq!(body.projection_source, "");
assert_eq!(body.file_version, json!(null));
}
}
@@ -149,6 +150,8 @@ pub struct PageBody {
pub revision: Value,
pub conflict_detection_key: Value,
#[serde(default)]
pub file_version: Value,
#[serde(default)]
pub block_document: Value,
#[serde(default)]
pub block_projection_version: u32,