feat(sync): 完成 S-08 数据分类
This commit is contained in:
@@ -101,6 +101,60 @@ struct Preferences {
|
||||
markdown: Markdown,
|
||||
presets: Vec<Preset>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ConversationMessage {
|
||||
message_id: String,
|
||||
parent_message_id: Option<String>,
|
||||
role: String,
|
||||
content: String,
|
||||
thinking: Option<String>,
|
||||
attachments: Vec<String>,
|
||||
created_at_ms: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Conversation {
|
||||
title: String,
|
||||
active_leaf: Option<String>,
|
||||
created_at_ms: i64,
|
||||
updated_at_ms: i64,
|
||||
messages: Vec<ConversationMessage>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AgentHistory {
|
||||
status: String,
|
||||
input: String,
|
||||
output: Option<String>,
|
||||
model: String,
|
||||
skill_id: Option<String>,
|
||||
error_code: Option<String>,
|
||||
error_message: Option<String>,
|
||||
token_usage: u64,
|
||||
created_at_ms: i64,
|
||||
updated_at_ms: i64,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProviderSettings {
|
||||
version: u64,
|
||||
provider_type: String,
|
||||
name: String,
|
||||
base_url: Option<String>,
|
||||
default_model: Option<String>,
|
||||
enabled: bool,
|
||||
capabilities: Vec<String>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ExtensionInstallation {
|
||||
package_kind: String,
|
||||
package_id: String,
|
||||
source: String,
|
||||
version: String,
|
||||
sha256: String,
|
||||
}
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: &Value) -> Result<T> {
|
||||
serde_json::from_value(value.clone()).map_err(|_| HostError::new("RECORD_SCHEMA_INVALID"))
|
||||
}
|
||||
@@ -138,6 +192,44 @@ fn unique_bounded(values: &[String], max_items: usize, max_chars: usize) -> bool
|
||||
.enumerate()
|
||||
.all(|(index, value)| !values[..index].contains(value))
|
||||
}
|
||||
fn bounded_identifier(value: &str, max_chars: usize) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b"._:-".contains(&b))
|
||||
}
|
||||
fn portable_reference(value: &str, max_chars: usize) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= max_chars
|
||||
&& !value.starts_with('/')
|
||||
&& !value.contains(['\\', '\0'])
|
||||
&& value
|
||||
.split('/')
|
||||
.all(|part| !part.is_empty() && part != "." && part != "..")
|
||||
}
|
||||
fn model_identifier(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().count() <= 256
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/+-".contains(&byte))
|
||||
}
|
||||
fn timestamp(value: i64) -> bool {
|
||||
(0..=253402300799999).contains(&value)
|
||||
}
|
||||
fn safe_base_url(value: &str) -> bool {
|
||||
let Some(rest) = value
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| value.strip_prefix("http://"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
!rest.is_empty()
|
||||
&& !rest.bytes().any(|byte| byte.is_ascii_control())
|
||||
&& !rest.contains(['@', '?', '#', '\\'])
|
||||
&& rest.split('/').next().is_some_and(|host| !host.is_empty())
|
||||
}
|
||||
pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
let valid = match kind {
|
||||
"persona" => {
|
||||
@@ -252,6 +344,138 @@ pub fn validate(kind: &str, value: &Value) -> Result<()> {
|
||||
&& markdown(&p.preferences)
|
||||
})
|
||||
}
|
||||
"conversation" => {
|
||||
let value: Conversation = decode(value)?;
|
||||
let message_id = |id: &str| bounded_identifier(id, 128);
|
||||
!value.title.trim().is_empty()
|
||||
&& value.title.chars().count() <= 120
|
||||
&& timestamp(value.created_at_ms)
|
||||
&& timestamp(value.updated_at_ms)
|
||||
&& value.updated_at_ms >= value.created_at_ms
|
||||
&& value.messages.len() <= 2_000
|
||||
&& value.active_leaf.as_deref().is_none_or(|id| {
|
||||
message_id(id) && value.messages.iter().any(|v| v.message_id == id)
|
||||
})
|
||||
&& value.messages.iter().enumerate().all(|(index, message)| {
|
||||
message_id(&message.message_id)
|
||||
&& !value.messages[..index]
|
||||
.iter()
|
||||
.any(|previous| previous.message_id == message.message_id)
|
||||
&& message.parent_message_id.as_deref().is_none_or(|id| {
|
||||
message_id(id)
|
||||
&& id != message.message_id
|
||||
&& value
|
||||
.messages
|
||||
.iter()
|
||||
.any(|candidate| candidate.message_id == id)
|
||||
})
|
||||
&& matches!(message.role.as_str(), "user" | "assistant" | "system")
|
||||
&& message.content.chars().count() <= 262_144
|
||||
&& message
|
||||
.thinking
|
||||
.as_ref()
|
||||
.is_none_or(|text| text.chars().count() <= 262_144)
|
||||
&& message.attachments.len() <= 64
|
||||
&& message
|
||||
.attachments
|
||||
.iter()
|
||||
.all(|path| portable_reference(path, 512))
|
||||
&& message
|
||||
.attachments
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(index, path)| !message.attachments[..index].contains(path))
|
||||
&& timestamp(message.created_at_ms)
|
||||
})
|
||||
}
|
||||
"agent_history" => {
|
||||
let value: AgentHistory = decode(value)?;
|
||||
matches!(value.status.as_str(), "completed" | "failed" | "cancelled")
|
||||
&& value.input.chars().count() <= 262_144
|
||||
&& value
|
||||
.output
|
||||
.as_ref()
|
||||
.is_none_or(|text| text.chars().count() <= 524_288)
|
||||
&& model_identifier(&value.model)
|
||||
&& value
|
||||
.skill_id
|
||||
.as_deref()
|
||||
.is_none_or(|id| bounded_identifier(id, 128))
|
||||
&& value
|
||||
.error_code
|
||||
.as_deref()
|
||||
.is_none_or(|code| bounded_identifier(code, 128))
|
||||
&& value
|
||||
.error_message
|
||||
.as_ref()
|
||||
.is_none_or(|message| message.chars().count() <= 4_096)
|
||||
&& value.token_usage <= 9_007_199_254_740_991
|
||||
&& timestamp(value.created_at_ms)
|
||||
&& timestamp(value.updated_at_ms)
|
||||
&& value.updated_at_ms >= value.created_at_ms
|
||||
}
|
||||
"provider_settings" => {
|
||||
let value: ProviderSettings = decode(value)?;
|
||||
let _ = value.enabled;
|
||||
let known_capabilities = [
|
||||
"chat",
|
||||
"vision",
|
||||
"tool_calling",
|
||||
"reasoning",
|
||||
"streaming",
|
||||
"structured_output",
|
||||
"embedding",
|
||||
"transcription",
|
||||
"speaker_matching",
|
||||
];
|
||||
value.version <= 9_007_199_254_740_991
|
||||
&& matches!(
|
||||
value.provider_type.as_str(),
|
||||
"mock"
|
||||
| "openai_responses"
|
||||
| "openai_chat"
|
||||
| "openai_compatible"
|
||||
| "anthropic_messages"
|
||||
| "ollama"
|
||||
)
|
||||
&& !value.name.trim().is_empty()
|
||||
&& value.name.chars().count() <= 128
|
||||
&& value
|
||||
.base_url
|
||||
.as_deref()
|
||||
.is_none_or(|url| url.len() <= 2_048 && safe_base_url(url))
|
||||
&& value.default_model.as_deref().is_none_or(model_identifier)
|
||||
&& unique_bounded(&value.capabilities, 16, 64)
|
||||
&& value
|
||||
.capabilities
|
||||
.iter()
|
||||
.all(|capability| known_capabilities.contains(&capability.as_str()))
|
||||
}
|
||||
"extension_installation" => {
|
||||
let value: ExtensionInstallation = decode(value)?;
|
||||
matches!(value.package_kind.as_str(), "skill" | "plugin" | "theme")
|
||||
&& bounded_identifier(&value.package_id, 128)
|
||||
&& !value.source.is_empty()
|
||||
&& value.source.len() <= 512
|
||||
&& value
|
||||
.source
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b"._:/-".contains(&byte))
|
||||
&& !value.source.contains("..")
|
||||
&& !value.source.contains('@')
|
||||
&& !value.source.contains(['?', '#', '\\'])
|
||||
&& !value.version.is_empty()
|
||||
&& value.version.len() <= 128
|
||||
&& value
|
||||
.version
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || b".+-_".contains(&byte))
|
||||
&& value.sha256.len() == 64
|
||||
&& value
|
||||
.sha256
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
|
||||
};
|
||||
if valid {
|
||||
@@ -347,4 +571,71 @@ mod tests {
|
||||
"RECORD_DATA_INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s08_optional_schemas_are_strict_and_installations_cannot_authorize() {
|
||||
let fixtures = [
|
||||
(
|
||||
"conversation",
|
||||
"conversation_00000000000000000000000000000001",
|
||||
json!({"title":"Review","active_leaf":"message_1","created_at_ms":1,"updated_at_ms":2,"messages":[{"message_id":"message_1","parent_message_id":null,"role":"user","content":"Hello","thinking":null,"attachments":[],"created_at_ms":1}]}),
|
||||
),
|
||||
(
|
||||
"agent_history",
|
||||
"agent_run_00000000000000000000000000000001",
|
||||
json!({"status":"completed","input":"Review","output":"Done","model":"gpt-5.6","skill_id":null,"error_code":null,"error_message":null,"token_usage":42,"created_at_ms":1,"updated_at_ms":2}),
|
||||
),
|
||||
(
|
||||
"provider_settings",
|
||||
"provider_00000000000000000000000000000001",
|
||||
json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat","tool_calling"]}),
|
||||
),
|
||||
(
|
||||
"extension_installation",
|
||||
"extension_00000000000000000000000000000001",
|
||||
json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)}),
|
||||
),
|
||||
];
|
||||
for (kind, id, data) in fixtures {
|
||||
let path = crate::records::path_for(kind, id).unwrap();
|
||||
let record = json!({"schema":1,"kind":kind,"id":id,"data":data});
|
||||
crate::records::validate(&path, &serde_json::to_vec(&record).unwrap()).unwrap();
|
||||
assert!(crate::records::allowed(&path));
|
||||
assert!(!crate::sync_scope::OptionalScope::default().includes(&path));
|
||||
let mut unknown = record.clone();
|
||||
unknown["data"]["api_key"] = json!("must-never-sync");
|
||||
assert_eq!(
|
||||
crate::records::validate(&path, &serde_json::to_vec(&unknown).unwrap())
|
||||
.err()
|
||||
.unwrap()
|
||||
.code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
|
||||
let installation = json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)});
|
||||
for forbidden in [
|
||||
"permissions",
|
||||
"granted_permissions",
|
||||
"enabled",
|
||||
"trusted",
|
||||
"device_grants",
|
||||
"package_path",
|
||||
] {
|
||||
let mut invalid = installation.clone();
|
||||
invalid[forbidden] = json!(true);
|
||||
assert_eq!(
|
||||
validate("extension_installation", &invalid)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
let mut provider = json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat"]});
|
||||
provider["credential_id"] = json!("device-secret-reference");
|
||||
assert_eq!(
|
||||
validate("provider_settings", &provider).unwrap_err().code,
|
||||
"RECORD_SCHEMA_INVALID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,17 @@ pub fn user_skill_path(id: &str) -> Result<String> {
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/user-skills/{id}.json"))
|
||||
}
|
||||
fn portable_id_path(id: &str, prefix: &str, directory: &str) -> Result<String> {
|
||||
if !id.starts_with(prefix)
|
||||
|| id.len() != prefix.len() + 32
|
||||
|| !id[prefix.len()..]
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v))
|
||||
{
|
||||
return Err(HostError::new("RECORD_ID_INVALID"));
|
||||
}
|
||||
Ok(format!("opennexus-records/v1/{directory}/{id}.json"))
|
||||
}
|
||||
pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
match (kind, id) {
|
||||
("task", id) => path(id),
|
||||
@@ -55,6 +66,12 @@ pub fn path_for(kind: &str, id: &str) -> Result<String> {
|
||||
("layout", "sidebars") => Ok("opennexus-records/v1/layout/sidebars.json".into()),
|
||||
("preferences", "editor") => Ok("opennexus-records/v1/preferences/editor.json".into()),
|
||||
("user_skill", id) => user_skill_path(id),
|
||||
("conversation", id) => portable_id_path(id, "conversation_", "conversations"),
|
||||
("agent_history", id) => portable_id_path(id, "agent_run_", "agent-history"),
|
||||
("provider_settings", id) => portable_id_path(id, "provider_", "provider-settings"),
|
||||
("extension_installation", id) => {
|
||||
portable_id_path(id, "extension_", "extension-installations")
|
||||
}
|
||||
_ => Err(HostError::new("RECORD_ID_INVALID")),
|
||||
}
|
||||
}
|
||||
@@ -78,10 +95,32 @@ pub fn allowed(path_value: &str) -> bool {
|
||||
{
|
||||
return true;
|
||||
}
|
||||
path_value
|
||||
if path_value
|
||||
.strip_prefix("opennexus-records/v1/user-skills/")
|
||||
.and_then(|v| v.strip_suffix(".json"))
|
||||
.is_some_and(|id| user_skill_path(id).is_ok())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
[
|
||||
("conversation", "opennexus-records/v1/conversations/"),
|
||||
("agent_history", "opennexus-records/v1/agent-history/"),
|
||||
(
|
||||
"provider_settings",
|
||||
"opennexus-records/v1/provider-settings/",
|
||||
),
|
||||
(
|
||||
"extension_installation",
|
||||
"opennexus-records/v1/extension-installations/",
|
||||
),
|
||||
]
|
||||
.iter()
|
||||
.any(|(kind, prefix)| {
|
||||
path_value
|
||||
.strip_prefix(prefix)
|
||||
.and_then(|value| value.strip_suffix(".json"))
|
||||
.is_some_and(|id| path_for(kind, id).is_ok())
|
||||
})
|
||||
}
|
||||
pub fn validate(path_value: &str, content: &[u8]) -> Result<Record> {
|
||||
if content.len() > 1024 * 1024 {
|
||||
@@ -184,6 +223,10 @@ impl Workspace {
|
||||
let prefix = match kind {
|
||||
"task" => "opennexus-records/v1/tasks/",
|
||||
"user_skill" => "opennexus-records/v1/user-skills/",
|
||||
"conversation" => "opennexus-records/v1/conversations/",
|
||||
"agent_history" => "opennexus-records/v1/agent-history/",
|
||||
"provider_settings" => "opennexus-records/v1/provider-settings/",
|
||||
"extension_installation" => "opennexus-records/v1/extension-installations/",
|
||||
_ => return Err(HostError::new("RECORD_SCHEMA_UNSUPPORTED")),
|
||||
};
|
||||
let paths = self
|
||||
@@ -295,4 +338,38 @@ mod tests {
|
||||
value
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s08_incompatible_remote_schema_preserves_local_file_and_upload_queue() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let current = fixture();
|
||||
let path = path(current["id"].as_str().unwrap()).unwrap();
|
||||
let entry = ws
|
||||
.write(&path, "", &serde_json::to_vec(¤t).unwrap(), "local")
|
||||
.unwrap();
|
||||
assert_eq!(ws.pending_count().unwrap(), 1);
|
||||
|
||||
let mut future = current.clone();
|
||||
future["schema"] = json!(2);
|
||||
future["data"]["title"] = json!("future remote value");
|
||||
assert_eq!(
|
||||
ws.write(
|
||||
&path,
|
||||
&entry.hash,
|
||||
&serde_json::to_vec(&future).unwrap(),
|
||||
"remote",
|
||||
)
|
||||
.unwrap_err()
|
||||
.code,
|
||||
"RECORD_SCHEMA_UNSUPPORTED"
|
||||
);
|
||||
assert_eq!(ws.pending_count().unwrap(), 1);
|
||||
assert_eq!(
|
||||
ws.record_get(current["id"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.unwrap()["record"],
|
||||
current
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +206,11 @@ mod tests {
|
||||
"attachments/tool.exe",
|
||||
"attachments/plugin.zip",
|
||||
"settings.json",
|
||||
"index.sqlite3",
|
||||
"vectors.bin",
|
||||
"cache/index.md",
|
||||
"logs/agent.md",
|
||||
"models/embedding.md",
|
||||
] {
|
||||
assert!(!allowed(path), "{path}");
|
||||
}
|
||||
@@ -214,6 +219,8 @@ mod tests {
|
||||
"attachments/movie.mp4",
|
||||
"attachments/image.png",
|
||||
"attachments/fixture.bin",
|
||||
"opennexus-records/v1/conversations/conversation_00000000000000000000000000000001.json",
|
||||
"opennexus-records/v1/extension-installations/extension_00000000000000000000000000000001.json",
|
||||
] {
|
||||
assert!(allowed(path), "{path}");
|
||||
}
|
||||
|
||||
@@ -419,6 +419,7 @@ mod tests {
|
||||
ws.sync_set_optional_scope(crate::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let binding = ws
|
||||
|
||||
@@ -8,6 +8,10 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct OptionalScope {
|
||||
pub persona: bool,
|
||||
pub layout: bool,
|
||||
pub conversations: bool,
|
||||
pub agent_history: bool,
|
||||
pub provider_settings: bool,
|
||||
pub extension_installations: bool,
|
||||
}
|
||||
|
||||
impl OptionalScope {
|
||||
@@ -15,6 +19,14 @@ impl OptionalScope {
|
||||
match path {
|
||||
"opennexus-records/v1/persona/default.json" => self.persona,
|
||||
"opennexus-records/v1/layout/sidebars.json" => self.layout,
|
||||
path if path.starts_with("opennexus-records/v1/conversations/") => self.conversations,
|
||||
path if path.starts_with("opennexus-records/v1/agent-history/") => self.agent_history,
|
||||
path if path.starts_with("opennexus-records/v1/provider-settings/") => {
|
||||
self.provider_settings
|
||||
}
|
||||
path if path.starts_with("opennexus-records/v1/extension-installations/") => {
|
||||
self.extension_installations
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -29,12 +41,16 @@ impl Workspace {
|
||||
Ok(self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT persona,layout FROM sync_optional_scope WHERE id=1",
|
||||
"SELECT persona,layout,conversations,agent_history,provider_settings,extension_installations FROM sync_optional_scope WHERE id=1",
|
||||
[],
|
||||
|row| {
|
||||
Ok(OptionalScope {
|
||||
persona: row.get(0)?,
|
||||
layout: row.get(1)?,
|
||||
conversations: row.get(2)?,
|
||||
agent_history: row.get(3)?,
|
||||
provider_settings: row.get(4)?,
|
||||
extension_installations: row.get(5)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -47,8 +63,8 @@ impl Workspace {
|
||||
return Err(HostError::new("SYNC_SCOPE_REBIND_REQUIRED"));
|
||||
}
|
||||
self.db.execute(
|
||||
"INSERT INTO sync_optional_scope VALUES (1,?1,?2) ON CONFLICT(id) DO UPDATE SET persona=excluded.persona,layout=excluded.layout",
|
||||
rusqlite::params![scope.persona, scope.layout],
|
||||
"INSERT INTO sync_optional_scope (id,persona,layout,conversations,agent_history,provider_settings,extension_installations) VALUES (1,?1,?2,?3,?4,?5,?6) ON CONFLICT(id) DO UPDATE SET persona=excluded.persona,layout=excluded.layout,conversations=excluded.conversations,agent_history=excluded.agent_history,provider_settings=excluded.provider_settings,extension_installations=excluded.extension_installations",
|
||||
rusqlite::params![scope.persona, scope.layout, scope.conversations, scope.agent_history, scope.provider_settings, scope.extension_installations],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -65,6 +81,7 @@ mod tests {
|
||||
ws.sync_set_optional_scope(OptionalScope {
|
||||
persona: true,
|
||||
layout: false,
|
||||
..OptionalScope::default()
|
||||
})
|
||||
.unwrap();
|
||||
let data = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"local","system_prompt":"private","dialogue_pairs":[]}})).unwrap();
|
||||
@@ -135,6 +152,7 @@ mod tests {
|
||||
ws.sync_set_optional_scope(OptionalScope {
|
||||
persona: true,
|
||||
layout: false,
|
||||
..OptionalScope::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -176,7 +194,7 @@ mod tests {
|
||||
ws.db
|
||||
.query_row("PRAGMA user_version", [], |r| r.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
12
|
||||
13
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
@@ -187,10 +205,24 @@ mod tests {
|
||||
let off = ws.sync_optional_scope().unwrap();
|
||||
assert!(!off.includes("opennexus-records/v1/persona/default.json"));
|
||||
assert!(!off.includes("opennexus-records/v1/layout/sidebars.json"));
|
||||
assert!(!off.includes(
|
||||
"opennexus-records/v1/conversations/conversation_00000000000000000000000000000001.json"
|
||||
));
|
||||
assert!(!off.includes(
|
||||
"opennexus-records/v1/agent-history/agent_run_00000000000000000000000000000001.json"
|
||||
));
|
||||
assert!(!off.includes(
|
||||
"opennexus-records/v1/provider-settings/provider_00000000000000000000000000000001.json"
|
||||
));
|
||||
assert!(!off.includes("opennexus-records/v1/extension-installations/extension_00000000000000000000000000000001.json"));
|
||||
assert!(off.includes("notes/example.md"));
|
||||
let chosen = OptionalScope {
|
||||
persona: true,
|
||||
layout: false,
|
||||
conversations: true,
|
||||
agent_history: true,
|
||||
provider_settings: true,
|
||||
extension_installations: true,
|
||||
};
|
||||
ws.sync_set_optional_scope(chosen).unwrap();
|
||||
assert_eq!(ws.pending_count().unwrap(), 0);
|
||||
@@ -220,7 +252,7 @@ mod tests {
|
||||
#[test]
|
||||
fn scope_rejects_unknown_fields_instead_of_authorizing_future_categories() {
|
||||
assert!(serde_json::from_str::<OptionalScope>(
|
||||
r#"{"persona":true,"layout":false,"credentials":true}"#
|
||||
r#"{"persona":true,"layout":false,"conversations":false,"agent_history":false,"provider_settings":false,"extension_installations":false,"credentials":true}"#
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
@@ -136,10 +136,10 @@ impl Workspace {
|
||||
let db = Connection::open(db_path)?;
|
||||
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
|
||||
let version: i64 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?;
|
||||
if version > 12 {
|
||||
if version > 13 {
|
||||
return Err(HostError::new("SCHEMA_INCOMPATIBLE"));
|
||||
}
|
||||
if (1..12).contains(&version) {
|
||||
if (1..13).contains(&version) {
|
||||
// Independent, complete SQLite backup before the schema ownership change.
|
||||
let backup = managed.join(format!("host-schema{version}-{}.sqlite3", Uuid::new_v4()));
|
||||
db.execute("VACUUM INTO ?1", [backup.to_string_lossy().as_ref()])?;
|
||||
@@ -166,7 +166,7 @@ impl Workspace {
|
||||
CREATE TABLE IF NOT EXISTS sync_attempts (binding TEXT NOT NULL,operation_id TEXT NOT NULL,attempts INTEGER NOT NULL,outcome TEXT NOT NULL,error TEXT,PRIMARY KEY(binding,operation_id));
|
||||
CREATE TABLE IF NOT EXISTS sync_retry (binding TEXT PRIMARY KEY,error TEXT,failures INTEGER NOT NULL,retry_at INTEGER,halted INTEGER NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS sync_preferences (binding TEXT PRIMARY KEY,paused INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE IF NOT EXISTS sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)));
|
||||
CREATE TABLE IF NOT EXISTS sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)),conversations INTEGER NOT NULL DEFAULT 0 CHECK(conversations IN (0,1)),agent_history INTEGER NOT NULL DEFAULT 0 CHECK(agent_history IN (0,1)),provider_settings INTEGER NOT NULL DEFAULT 0 CHECK(provider_settings IN (0,1)),extension_installations INTEGER NOT NULL DEFAULT 0 CHECK(extension_installations IN (0,1)));
|
||||
CREATE TABLE IF NOT EXISTS sync_resolutions (binding TEXT NOT NULL,sequence INTEGER NOT NULL,choice TEXT NOT NULL,destination TEXT NOT NULL,expected TEXT NOT NULL,operation_id TEXT NOT NULL,rename_id TEXT NOT NULL,copy_id TEXT NOT NULL,state TEXT NOT NULL,retire_id TEXT NOT NULL,restore_id TEXT NOT NULL,PRIMARY KEY(binding,sequence));")?;
|
||||
let has_origin: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('file_ops') WHERE name='origin')",
|
||||
@@ -201,10 +201,30 @@ impl Workspace {
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
for column in [
|
||||
"conversations",
|
||||
"agent_history",
|
||||
"provider_settings",
|
||||
"extension_installations",
|
||||
] {
|
||||
let exists: bool = db.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_optional_scope') WHERE name=?1)",
|
||||
[column],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
db.execute(
|
||||
&format!(
|
||||
"ALTER TABLE sync_optional_scope ADD COLUMN {column} INTEGER NOT NULL DEFAULT 0 CHECK({column} IN (0,1))"
|
||||
),
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if version < 7 {
|
||||
db.execute_batch("INSERT OR IGNORE INTO sync_observed SELECT f.id,COALESCE((SELECT o.path FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.path FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.path),COALESCE((SELECT o.hash FROM outbox o WHERE o.file_id=f.id AND o.state IN ('pending','queued') ORDER BY rowid DESC LIMIT 1),(SELECT h.hash FROM sync_heads h JOIN sync_bindings b ON h.binding=b.id WHERE h.file_id=f.id AND b.state='active'),f.hash),f.deleted FROM files f;")?;
|
||||
}
|
||||
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=12; COMMIT;")?;
|
||||
db.execute_batch("UPDATE sync_attempts SET outcome=CASE WHEN EXISTS(SELECT 1 FROM sync_jobs j WHERE j.binding=sync_attempts.binding AND j.operation_id=sync_attempts.operation_id AND j.state='acked') THEN 'succeeded' ELSE 'interrupted' END WHERE outcome='running'; PRAGMA user_version=13; COMMIT;")?;
|
||||
let vault_id: String = db
|
||||
.query_row("SELECT id FROM identity", [], |r| r.get(0))
|
||||
.optional()?
|
||||
@@ -1398,7 +1418,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_eleven_upgrade_adds_durable_conflict_operation_ids() {
|
||||
fn schema_eleven_upgrade_adds_durable_conflict_and_scope_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ws = Workspace::open(dir.path()).unwrap();
|
||||
ws.db
|
||||
@@ -1415,7 +1435,7 @@ mod tests {
|
||||
ws.db
|
||||
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
12
|
||||
13
|
||||
);
|
||||
for column in ["retire_id", "restore_id"] {
|
||||
assert!(ws
|
||||
@@ -1447,6 +1467,54 @@ mod tests {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_twelve_upgrade_adds_optional_categories_without_consent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ws = Workspace::open(dir.path()).unwrap();
|
||||
ws.db
|
||||
.execute_batch(
|
||||
"DROP TABLE sync_optional_scope;
|
||||
CREATE TABLE sync_optional_scope (id INTEGER PRIMARY KEY CHECK(id=1),persona INTEGER NOT NULL CHECK(persona IN (0,1)),layout INTEGER NOT NULL CHECK(layout IN (0,1)));
|
||||
INSERT INTO sync_optional_scope VALUES (1,1,0);
|
||||
PRAGMA user_version=12;",
|
||||
)
|
||||
.unwrap();
|
||||
drop(ws);
|
||||
|
||||
let ws = Workspace::open(dir.path()).unwrap();
|
||||
let scope = ws.sync_optional_scope().unwrap();
|
||||
assert!(scope.persona);
|
||||
assert!(!scope.layout);
|
||||
assert!(!scope.conversations);
|
||||
assert!(!scope.agent_history);
|
||||
assert!(!scope.provider_settings);
|
||||
assert!(!scope.extension_installations);
|
||||
assert_eq!(
|
||||
ws.db
|
||||
.query_row("PRAGMA user_version", [], |row| row.get::<_, i64>(0))
|
||||
.unwrap(),
|
||||
13
|
||||
);
|
||||
let backup = fs::read_dir(dir.path().join(".ainote"))
|
||||
.unwrap()
|
||||
.filter_map(|entry| entry.ok())
|
||||
.find(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("host-schema12-")
|
||||
})
|
||||
.unwrap();
|
||||
let old = Connection::open(backup.path()).unwrap();
|
||||
assert!(!old
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('sync_optional_scope') WHERE name='conversations')",
|
||||
[],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_paths_external_change_and_remote_origin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
#![cfg(feature = "desktop")]
|
||||
|
||||
use notesagent_host::{
|
||||
records, sync_client::SyncClient, sync_scope::OptionalScope, workspace::Workspace,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
io::{BufRead, BufReader},
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
struct Server(Child);
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.0.stdin.take();
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn record(kind: &str, id: &str, data: Value) -> Value {
|
||||
json!({"schema":1,"kind":kind,"id":id,"data":data})
|
||||
}
|
||||
|
||||
fn records_fixture() -> Vec<(&'static str, &'static str, Value, bool)> {
|
||||
let task = "task_00000000000000000000000000000001";
|
||||
let skill = "user_skill_00000000000000000000000000000001";
|
||||
let conversation = "conversation_00000000000000000000000000000001";
|
||||
let run = "agent_run_00000000000000000000000000000001";
|
||||
let provider = "provider_00000000000000000000000000000001";
|
||||
let extension = "extension_00000000000000000000000000000001";
|
||||
vec![
|
||||
(
|
||||
"task",
|
||||
task,
|
||||
record(
|
||||
"task",
|
||||
task,
|
||||
json!({"title":"Ship","description":"Production checklist","status":"todo","note_id":null,"due_at_ms":null,"created_at_ms":1,"updated_at_ms":2}),
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"user_skill",
|
||||
skill,
|
||||
record(
|
||||
"user_skill",
|
||||
skill,
|
||||
json!({"version":1,"name":"Review","description":"Review a note","prompt":"Check carefully","tools":["notes.read"],"permissions":["notes.read"],"retrieval":{"top_k":10,"rerank":true,"citation":true},"required_capabilities":["chat"],"created_at_ms":1,"updated_at_ms":2}),
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"theme_settings",
|
||||
"appearance",
|
||||
record(
|
||||
"theme_settings",
|
||||
"appearance",
|
||||
json!({"themeId":"dark","fontEditorSize":18,"fontEditorFamily":"system-ui","lineHeight":1.7,"codeBlockTheme":"auto","headings":{"custom":false,"family":"inherit","levels":([32,28,24,21,18,16].map(|size|json!({"size":size,"weight":700})))}}),
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"preferences",
|
||||
"editor",
|
||||
record(
|
||||
"preferences",
|
||||
"editor",
|
||||
json!({"restoreLastVault":true,"autoSaveInterval":1500,"language":"zh-CN","defaultEditorMode":"wysiwyg","editorLineWidth":80,"spellCheck":false,"markdown":{"heading":"atx","bullet":"-","incrementList":true,"fence":"`","math":true,"callouts":true,"diagrams":true,"autoLinks":true,"lineNumbers":true,"wrapCode":false,"indent":4,"defaultLanguage":""},"presets":[]}),
|
||||
),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"persona",
|
||||
"default",
|
||||
record(
|
||||
"persona",
|
||||
"default",
|
||||
json!({"version":1,"name":"Writer","system_prompt":"Be concise","dialogue_pairs":[]}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"layout",
|
||||
"sidebars",
|
||||
record(
|
||||
"layout",
|
||||
"sidebars",
|
||||
json!({"primaryExpanded":true,"workspaceWidth":400,"chatWidth":320}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"conversation",
|
||||
conversation,
|
||||
record(
|
||||
"conversation",
|
||||
conversation,
|
||||
json!({"title":"Release review","active_leaf":"message_1","created_at_ms":1,"updated_at_ms":2,"messages":[{"message_id":"message_1","parent_message_id":null,"role":"user","content":"Review the release","thinking":null,"attachments":[],"created_at_ms":1}]}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"agent_history",
|
||||
run,
|
||||
record(
|
||||
"agent_history",
|
||||
run,
|
||||
json!({"status":"completed","input":"Review release","output":"Ready","model":"gpt-5.6","skill_id":null,"error_code":null,"error_message":null,"token_usage":42,"created_at_ms":1,"updated_at_ms":2}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"provider_settings",
|
||||
provider,
|
||||
record(
|
||||
"provider_settings",
|
||||
provider,
|
||||
json!({"version":1,"provider_type":"openai_responses","name":"OpenAI","base_url":"https://api.openai.com/v1","default_model":"gpt-5.6","enabled":true,"capabilities":["chat","tool_calling"]}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"extension_installation",
|
||||
extension,
|
||||
record(
|
||||
"extension_installation",
|
||||
extension,
|
||||
json!({"package_kind":"plugin","package_id":"opennexus.review","source":"community/opennexus.review","version":"1.2.3","sha256":"a".repeat(64)}),
|
||||
),
|
||||
true,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn seed(workspace: &mut Workspace) -> Vec<(String, Value, bool)> {
|
||||
workspace
|
||||
.write("notes/default.md", "", b"# Default markdown", "local")
|
||||
.unwrap();
|
||||
workspace
|
||||
.write(
|
||||
"attachments/manual.pdf",
|
||||
"",
|
||||
b"portable attachment",
|
||||
"local",
|
||||
)
|
||||
.unwrap();
|
||||
let mut written = Vec::new();
|
||||
for (kind, id, value, optional) in records_fixture() {
|
||||
let path = records::path_for(kind, id).unwrap();
|
||||
workspace
|
||||
.write(&path, "", &serde_json::to_vec(&value).unwrap(), "local")
|
||||
.unwrap();
|
||||
written.push((path, value, optional));
|
||||
}
|
||||
for (path, contents) in [
|
||||
("logs/trace.md", "forbidden log"),
|
||||
("cache/search.md", "forbidden cache"),
|
||||
("models/local.md", "forbidden model"),
|
||||
("index.sqlite3", "forbidden index"),
|
||||
("vectors.bin", "forbidden vectors"),
|
||||
("provider-settings.json", "fixture-api-key"),
|
||||
] {
|
||||
workspace
|
||||
.write(path, "", contents.as_bytes(), "local")
|
||||
.unwrap();
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
async fn push_all(
|
||||
client: &SyncClient,
|
||||
workspace: &Arc<Mutex<Workspace>>,
|
||||
binding: ¬esagent_host::sync_state::Binding,
|
||||
) {
|
||||
while client.push_one(workspace, binding).await.unwrap() {}
|
||||
}
|
||||
|
||||
async fn pull_all(
|
||||
client: &SyncClient,
|
||||
workspace: &Arc<Mutex<Workspace>>,
|
||||
binding: ¬esagent_host::sync_state::Binding,
|
||||
) {
|
||||
while client.pull_page(workspace, binding).await.unwrap() > 0 {}
|
||||
}
|
||||
|
||||
async fn create_remote(client: &SyncClient, name: &str) -> String {
|
||||
client
|
||||
.json(
|
||||
reqwest::Method::POST,
|
||||
"sync/v1/vaults",
|
||||
Some(json!({"name":name})),
|
||||
)
|
||||
.await
|
||||
.unwrap()["vault_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn s08_actual_service_enforces_classification_and_roundtrips_selected_records() {
|
||||
let service_root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(service_root.path().join(".opennexus-test"), b"fixture").unwrap();
|
||||
let service = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../server sync")
|
||||
.canonicalize()
|
||||
.unwrap();
|
||||
let python = service.join(if cfg!(windows) {
|
||||
".venv/Scripts/python.exe"
|
||||
} else {
|
||||
".venv/bin/python"
|
||||
});
|
||||
let mut server = Server(
|
||||
Command::new(python)
|
||||
.args(["-m", "tests.host_fixture"])
|
||||
.arg(service_root.path())
|
||||
.current_dir(service)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut line = String::new();
|
||||
BufReader::new(server.0.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
let ready: Value = serde_json::from_str(&line).unwrap();
|
||||
let endpoint = format!("http://127.0.0.1:{}", ready["port"]);
|
||||
let public = SyncClient::new(&endpoint, Zeroizing::new(String::new()), true).unwrap();
|
||||
public.handshake().await.unwrap();
|
||||
let session = public
|
||||
.login(
|
||||
"rust-fixture",
|
||||
Zeroizing::new("controlled-fixture-password".into()),
|
||||
"S-08 classification",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let client = SyncClient::new(
|
||||
&endpoint,
|
||||
Zeroizing::new(session.access_token.clone()),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let default_remote = create_remote(&client, "S-08 default").await;
|
||||
let source_root = tempfile::tempdir().unwrap();
|
||||
let mut source = Workspace::open(source_root.path()).unwrap();
|
||||
let written = seed(&mut source);
|
||||
let source = Arc::new(Mutex::new(source));
|
||||
let source_binding = source
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_empty(&endpoint, &default_remote, "rust-fixture")
|
||||
.unwrap();
|
||||
push_all(&client, &source, &source_binding).await;
|
||||
let default_snapshot = client.snapshot(&default_remote).await.unwrap();
|
||||
let default_paths = default_snapshot
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| item.path.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(default_paths.len(), 6);
|
||||
assert!(default_paths.contains(&"notes/default.md"));
|
||||
assert!(default_paths.contains(&"attachments/manual.pdf"));
|
||||
assert!(written
|
||||
.iter()
|
||||
.filter(|(_, _, optional)| !optional)
|
||||
.all(|(path, _, _)| default_paths.contains(&path.as_str())));
|
||||
assert!(written
|
||||
.iter()
|
||||
.filter(|(_, _, optional)| *optional)
|
||||
.all(|(path, _, _)| !default_paths.contains(&path.as_str())));
|
||||
assert!(default_paths.iter().all(|path| {
|
||||
!path.starts_with("logs/")
|
||||
&& !path.starts_with("cache/")
|
||||
&& !path.starts_with("models/")
|
||||
&& !path.contains("index")
|
||||
&& !path.contains("vectors")
|
||||
&& !path.contains("provider-settings.json")
|
||||
}));
|
||||
|
||||
let target_root = tempfile::tempdir().unwrap();
|
||||
let target = Arc::new(Mutex::new(Workspace::open(target_root.path()).unwrap()));
|
||||
let target_binding = target
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_download(&endpoint, &default_remote, "rust-fixture")
|
||||
.unwrap();
|
||||
pull_all(&client, &target, &target_binding).await;
|
||||
assert_eq!(
|
||||
target
|
||||
.lock()
|
||||
.unwrap()
|
||||
.read("notes/default.md")
|
||||
.unwrap()
|
||||
.content,
|
||||
"# Default markdown"
|
||||
);
|
||||
assert_eq!(
|
||||
target
|
||||
.lock()
|
||||
.unwrap()
|
||||
.read("attachments/manual.pdf")
|
||||
.unwrap()
|
||||
.content,
|
||||
"portable attachment"
|
||||
);
|
||||
for (path, value, optional) in &written {
|
||||
if *optional {
|
||||
assert!(!target_root.path().join(path).exists());
|
||||
} else {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(
|
||||
&std::fs::read(target_root.path().join(path)).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
*value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let optional_remote = create_remote(&client, "S-08 optional").await;
|
||||
let optional_source_root = tempfile::tempdir().unwrap();
|
||||
let mut optional_source = Workspace::open(optional_source_root.path()).unwrap();
|
||||
optional_source
|
||||
.sync_set_optional_scope(OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
conversations: true,
|
||||
agent_history: true,
|
||||
provider_settings: true,
|
||||
extension_installations: true,
|
||||
})
|
||||
.unwrap();
|
||||
let optional_written = seed(&mut optional_source);
|
||||
let optional_source = Arc::new(Mutex::new(optional_source));
|
||||
let optional_source_binding = optional_source
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_empty(&endpoint, &optional_remote, "rust-fixture")
|
||||
.unwrap();
|
||||
push_all(&client, &optional_source, &optional_source_binding).await;
|
||||
assert_eq!(
|
||||
client.snapshot(&optional_remote).await.unwrap().items.len(),
|
||||
12
|
||||
);
|
||||
|
||||
let optional_target_root = tempfile::tempdir().unwrap();
|
||||
let mut optional_target = Workspace::open(optional_target_root.path()).unwrap();
|
||||
optional_target
|
||||
.sync_set_optional_scope(OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
conversations: true,
|
||||
agent_history: true,
|
||||
provider_settings: true,
|
||||
extension_installations: true,
|
||||
})
|
||||
.unwrap();
|
||||
let optional_target = Arc::new(Mutex::new(optional_target));
|
||||
let optional_target_binding = optional_target
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_download(&endpoint, &optional_remote, "rust-fixture")
|
||||
.unwrap();
|
||||
pull_all(&client, &optional_target, &optional_target_binding).await;
|
||||
for (path, value, _) in &optional_written {
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(
|
||||
&std::fs::read(optional_target_root.path().join(path)).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
*value
|
||||
);
|
||||
}
|
||||
let installation = &optional_written
|
||||
.iter()
|
||||
.find(|(_, value, _)| value["kind"] == "extension_installation")
|
||||
.unwrap()
|
||||
.1["data"];
|
||||
let keys = installation
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(keys.len(), 5);
|
||||
assert!(keys.iter().all(|key| {
|
||||
matches!(
|
||||
key.as_str(),
|
||||
"package_kind" | "package_id" | "source" | "version" | "sha256"
|
||||
)
|
||||
}));
|
||||
}
|
||||
@@ -81,6 +81,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let binding = {
|
||||
@@ -211,6 +212,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let binding_b = workspace_b
|
||||
@@ -810,6 +812,7 @@ async fn s01_actual_service_preserves_offline_chains_and_response_loss_idempoten
|
||||
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user