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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user