feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45
@@ -95,3 +95,12 @@ Core 通过绑定 Vault 的 workspace.persona.get/write RPC 读写;Host 先校
|
||||
|
||||
|
||||
逻辑记录的“另存副本并采用远端”可将原始记录内容导出为 `attachments/*.txt`;文本副本作为普通附件同步,拥有独立 file_id,不作为活动设置自动应用。副本目标必须属于同步白名单;若目标本身位于记录命名空间,还必须与原始记录 kind/id/schema 一致。这些检查在持久化冲突决定之前完成,因此无效路径不占用该冲突的解决选择。
|
||||
|
||||
|
||||
## 可选记录范围(Workspace schema 11)
|
||||
|
||||
人设与布局默认仅保存在本机。用户在同步设置中分别选择 persona/layout 后才参与传输;选择保存在本机 Vault 数据库,不随逻辑记录上传。已有活动绑定时不得直接改变范围,必须解除绑定、调整选择、重新预览合并;预览摘要包含范围,旧范围的预览不能用于绑定新范围。
|
||||
|
||||
本地扫描、外部删除发现、首次对账和 outbox 捕获均排除未选择的类别。旧版本残留的可选上传作业会被归档,提交前再次核验范围。关闭选项不删除本地文件或远端历史,也不产生远端 delete。接收未选择的类别时只保存 revision 元数据并推进已处理游标,既不获取对象正文,也不应用记录或制造冲突。已有未完成的排除类别冲突决定保留,但不继续应用;需重新对账处理。
|
||||
|
||||
重新开启经新绑定获取固定远端快照,因此能够获取先前已跳过游标的记录,匹配的现有文件沿用初始对账身份与 CAS 规则。普通 Markdown、允许附件、任务及默认设置类别保持原范围;手动导出的 attachments/*.txt 是普通附件,不是自动生效的人设。此版只定义已实现的人设和布局选择,不把尚无适配器的对话、Agent 历史或 Provider 参数视为已支持。
|
||||
|
||||
@@ -730,3 +730,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
|
||||
- **当前仅完成持久层,尚未接入传输过滤与 UI,因此不能将 default=false 当作已经阻止人设/布局传输的证据。** 下一步必须先完成 sync_paths/发现删除、capture/已排队 job、初始预览、pull 下载/stage/apply 的一致范围检查;排除项只推进元数据游标,不下载对象、不写本地文件、不删除远端数据。不得在这些边界未接齐时宣称“可选同步完成”。
|
||||
- 持久选择、重开、另一 Vault 隔离、绑定后拒绝修改、解绑后修改及未知字段拒绝的最初定向测试通过(.build/sync-optional-scope.log);补充升级用例后的完整结果另列。
|
||||
- 最终 desktop 全目标 142 通过、12 ignored(库 127、Host 8、其余集成 7),包含 schema 10 升级新用例;全目标 Clippy -D warnings 通过。日志 .build/sync-scope-foundation-full.log、.build/sync-scope-foundation-clippy.log。此结果仅证明持久层与现有回归,不能证明范围过滤已生效,下一轮优先完成剩余接线。
|
||||
|
||||
|
||||
## 增量:可选范围接入实际传输与设置
|
||||
|
||||
- 将 schema 11 的 scope 接入 sync_paths、外部删除发现、outbox 捕获、旧作业选择/提交前检查,以及初始预览(摘要包含选择)。旧可选作业无授权时归档,不让其阻塞普通笔记队列。关闭类别不转换为删除本地文件/远端版本。
|
||||
- pull 的增量和初始快照两条路径均在对象下载前判断范围;未选记录可在没有 spool 正文时 stage 为元数据,apply 只将状态标为 excluded 并推进相应游标。不能通过已缓存 inbox 或旧 conflict resolution 自动改写未选类别;手工解决关闭类别时明确返回 SYNC_SCOPE_DISABLED。
|
||||
- 新增主窗口 sync_set_scope 命令,核对 Vault 身份,绑定期间拒绝修改。SyncSettings 显示默认关闭的人设/布局选择,改变范围立即失效旧预览,保存前不乐观显示选中状态;已绑定时提示解绑后重新合并。人设表单文案改为可选择同步。
|
||||
- 新增范围测试覆盖无正文 spool 时跳过远端人设而游标推进、本地内容不变/无冲突、旧预览因开启范围失效,以及 schema 前残留队列不得越过缺失授权。真实 HTTP 增加默认关闭的第三客户端,要求没有活动人设/布局文件及对应 head spool;开启后新快照绑定补齐两类记录且无重复上传。既有人设/布局传输测试改为显式选择后运行。
|
||||
- 前端全套 530 项与类型检查通过(.build/scope-wired-frontend.log、.build/scope-wired-types.log);调整最终文案及选择控件后定向 12 项和类型检查通过(.build/scope-ui-final.log、.build/scope-ui-final-types.log)。
|
||||
- Rust 首次含新增用例的并行全套出现既有 AppContainer 测试失败:启动参数/环境辅助进程返回 1(不是网络越权判定),日志 .build/scope-wired-final-rust.log。单独复核同一实际探测通过,16.94 秒(.build/scope-native-recheck.log),原因尚未定位;保留此失败,不据此宣称全套稳定。随后对最终代码串行全量验证,结果另列。
|
||||
- 最终串行 desktop 全目标 143 通过、12 ignored(库 128、Host 8、其余集成 7),含第三客户端关闭/重开范围的实际 HTTP 验证,日志 .build/scope-wired-final-serial.log;补充旧队列测试单独 1 项通过(.build/scope-old-queue-final.log),累计覆盖 144 项通过用例。最终全目标 Clippy -D warnings 通过(.build/scope-wired-clippy.log)。
|
||||
- 本轮修正了先前人设/布局默认进入同步的规划偏差;仅这两类可选记录已经接通选择与传输。尚未实现的可选数据类、完整发布/兼容、独立设备 UI 和上述偶发 native 失败原因仍需处理,完整生产化目标未完成。
|
||||
|
||||
@@ -891,6 +891,7 @@ fn main() {
|
||||
sync_unbind,
|
||||
sync_pause,
|
||||
sync_status,
|
||||
sync_set_scope,
|
||||
sync_resolve,
|
||||
sync_logout,
|
||||
sync_run,
|
||||
|
||||
@@ -374,7 +374,9 @@ impl SyncClient {
|
||||
if let Some(initial) = workspace.access(|ws| ws.sync_initial_pending(&binding.id))? {
|
||||
let count = initial.len().min(100);
|
||||
for revision in initial.iter().take(count) {
|
||||
if revision.operation == "put" {
|
||||
if revision.operation == "put"
|
||||
&& workspace.access(|ws| ws.sync_path_enabled(&revision.path))?
|
||||
{
|
||||
self.download(workspace, binding, revision).await?;
|
||||
}
|
||||
workspace.access(|ws| {
|
||||
@@ -423,7 +425,9 @@ impl SyncClient {
|
||||
if revision.sequence != cursor + index as i64 + 1 || revision.sequence > end {
|
||||
return Err(SyncError::new("SYNC_RESPONSE_INVALID"));
|
||||
}
|
||||
if revision.operation == "put" {
|
||||
if revision.operation == "put"
|
||||
&& workspace.access(|ws| ws.sync_path_enabled(&revision.path))?
|
||||
{
|
||||
self.download(workspace, binding, &revision).await?;
|
||||
}
|
||||
workspace.access(|ws| {
|
||||
|
||||
@@ -249,6 +249,21 @@ pub fn sync_pause(host: State<'_, Host>, binding_id: String, paused: bool) -> Re
|
||||
Ok(())
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn sync_set_scope(
|
||||
host: State<'_, Host>,
|
||||
vault_id: String,
|
||||
scope: notesagent_host::sync_scope::OptionalScope,
|
||||
) -> Result<(), String> {
|
||||
with_workspace(&host, |ws| {
|
||||
if ws.vault_id != vault_id {
|
||||
return Err(notesagent_host::workspace::HostError::new(
|
||||
"VAULT_PERMISSION_CHANGED",
|
||||
));
|
||||
}
|
||||
ws.sync_set_optional_scope(scope)
|
||||
})
|
||||
}
|
||||
#[tauri::command]
|
||||
pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
|
||||
let snapshot = with_workspace(&host, |ws| {
|
||||
let binding = ws.sync_binding()?;
|
||||
@@ -263,6 +278,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
Ok((
|
||||
ws.sync_optional_scope()?,
|
||||
ws.vault_id.clone(),
|
||||
binding.clone(),
|
||||
paused,
|
||||
@@ -280,7 +296,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
})?;
|
||||
let (vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot;
|
||||
let (optional_scope, vault_id, binding, paused, conflicts, pending, attempts, retry) = snapshot;
|
||||
let credential_state = if let Some(b) = &binding {
|
||||
sync_auth::available(&host.credentials, &b.endpoint, &b.account)
|
||||
.map(|exists| {
|
||||
@@ -298,7 +314,7 @@ pub fn sync_status(host: State<'_, Host>) -> Result<Value, String> {
|
||||
let statuses = host.sync.status.lock().map_err(|_| "HOST_BUSY")?;
|
||||
let status = binding.as_ref().and_then(|b| statuses.get(&b.id));
|
||||
Ok(
|
||||
json!({"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}),
|
||||
json!({"optional_scope":optional_scope,"vault_id":vault_id,"binding":binding,"paused":paused,"pending":pending,"conflicts":conflicts,"credential_state":credential_state,"running":status.is_some_and(|s|s.running),"error":retry.error,"retry_in":retry.retry_at.map(|_| retry.remaining(now())),"failures":retry.failures,"halted":retry.halted,"attempts":attempts}),
|
||||
)
|
||||
}
|
||||
#[tauri::command]
|
||||
|
||||
@@ -75,7 +75,7 @@ impl Workspace {
|
||||
let resolved = ws.resolve(&path)?;
|
||||
if item.file_type()?.is_dir() {
|
||||
walk(ws, &resolved, paths)?;
|
||||
} else if allowed(&path) && item.file_type()?.is_file() {
|
||||
} else if ws.sync_path_enabled(&path)? && item.file_type()?.is_file() {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,10 @@ impl Workspace {
|
||||
rows
|
||||
};
|
||||
for (file_id, path) in previous {
|
||||
if seen.contains(&file_id) || !allowed(&path) || self.resolve(&path)?.exists() {
|
||||
if seen.contains(&file_id)
|
||||
|| !self.sync_path_enabled(&path)?
|
||||
|| self.resolve(&path)?.exists()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let operation = Uuid::new_v4().to_string();
|
||||
|
||||
@@ -145,8 +145,10 @@ impl Workspace {
|
||||
{
|
||||
return Err(HostError::new("SYNC_CURSOR_INVALID"));
|
||||
}
|
||||
if let Some(digest) = &revision.hash {
|
||||
crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?;
|
||||
if self.sync_path_enabled(&revision.path)? {
|
||||
if let Some(digest) = &revision.hash {
|
||||
crate::payloads::verify(&self.sync_spool(digest)?, digest, revision.size as u64)?;
|
||||
}
|
||||
}
|
||||
let encoded =
|
||||
serde_json::to_string(revision).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
@@ -254,6 +256,10 @@ impl Workspace {
|
||||
};
|
||||
let revision: RemoteRevision =
|
||||
serde_json::from_str(&encoded).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
if !self.sync_path_enabled(&revision.path)? {
|
||||
self.sync_finish(binding, &revision, "excluded")?;
|
||||
return Ok(true);
|
||||
}
|
||||
let own: Option<Job> = self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND operation_id=?2", params![binding,revision.operation_id], |r| {
|
||||
Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? })
|
||||
}).optional()?;
|
||||
|
||||
@@ -72,7 +72,7 @@ impl Workspace {
|
||||
if item.sequence > snapshot.boundary {
|
||||
return Err(HostError::new("SYNC_RESPONSE_INVALID"));
|
||||
}
|
||||
if item.operation == "put" {
|
||||
if item.operation == "put" && self.sync_path_enabled(&item.path)? {
|
||||
paths.insert(item.path.clone(), "download");
|
||||
}
|
||||
}
|
||||
@@ -93,8 +93,16 @@ impl Workspace {
|
||||
);
|
||||
}
|
||||
let fingerprint = hash(
|
||||
&serde_json::to_vec(&(&self.vault_id, endpoint, remote, account, &local, snapshot))
|
||||
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?,
|
||||
&serde_json::to_vec(&(
|
||||
&self.vault_id,
|
||||
endpoint,
|
||||
remote,
|
||||
account,
|
||||
&local,
|
||||
snapshot,
|
||||
self.sync_optional_scope()?,
|
||||
))
|
||||
.map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?,
|
||||
);
|
||||
Ok(Preview {
|
||||
fingerprint,
|
||||
|
||||
@@ -18,6 +18,19 @@ impl Workspace {
|
||||
expected: &str,
|
||||
) -> Result<()> {
|
||||
self.check_binding(binding)?;
|
||||
let scope_path: Option<String> = self
|
||||
.db
|
||||
.query_row(
|
||||
"SELECT local_path FROM sync_conflicts WHERE binding=?1 AND sequence=?2",
|
||||
params![binding, sequence],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(path) = scope_path {
|
||||
if !self.sync_path_enabled(&path)? {
|
||||
return Err(HostError::new("SYNC_SCOPE_DISABLED"));
|
||||
}
|
||||
}
|
||||
if !matches!(choice, "local" | "remote" | "copy") {
|
||||
return Err(HostError::new("SYNC_RESOLUTION_INVALID"));
|
||||
}
|
||||
@@ -107,6 +120,9 @@ impl Workspace {
|
||||
)?;
|
||||
let revision: RemoteRevision =
|
||||
serde_json::from_str(&remote).map_err(|_| HostError::new("SYNC_RESPONSE_INVALID"))?;
|
||||
if !self.sync_path_enabled(&revision.path)? {
|
||||
return Ok(());
|
||||
}
|
||||
let head: i64 = self.db.query_row(
|
||||
"SELECT revision FROM sync_heads WHERE binding=?1 AND file_id=?2",
|
||||
params![binding, revision.file_id],
|
||||
@@ -279,6 +295,11 @@ mod tests {
|
||||
fn invalid_record_copy_destination_does_not_freeze_conflict_decision() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
ws.sync_set_optional_scope(crate::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
})
|
||||
.unwrap();
|
||||
let binding = ws
|
||||
.sync_bind_download("https://sync.example", "remote-vault", "account")
|
||||
.unwrap();
|
||||
|
||||
@@ -21,6 +21,10 @@ impl OptionalScope {
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
pub fn sync_path_enabled(&self, path: &str) -> Result<bool> {
|
||||
Ok(crate::sync_discovery::allowed(path) && self.sync_optional_scope()?.includes(path))
|
||||
}
|
||||
|
||||
pub fn sync_optional_scope(&self) -> Result<OptionalScope> {
|
||||
Ok(self
|
||||
.db
|
||||
@@ -54,6 +58,104 @@ impl Workspace {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn old_queued_optional_jobs_cannot_bypass_missing_consent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
ws.sync_set_optional_scope(OptionalScope {
|
||||
persona: true,
|
||||
layout: false,
|
||||
})
|
||||
.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();
|
||||
let path = "opennexus-records/v1/persona/default.json";
|
||||
ws.write(path, "", &data, "local").unwrap();
|
||||
let binding = ws
|
||||
.sync_bind_empty("https://sync.example", "remote", "account")
|
||||
.unwrap();
|
||||
let job = ws.sync_next(&binding.id).unwrap().unwrap();
|
||||
// Models a pre-scope database's pending job after schema migration.
|
||||
ws.db
|
||||
.execute("DELETE FROM sync_optional_scope", [])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ws.sync_commit_payload(&job).unwrap_err().code,
|
||||
"SYNC_SCOPE_DISABLED"
|
||||
);
|
||||
assert!(ws.sync_next(&binding.id).unwrap().is_none());
|
||||
ws.write("note.md", "", b"still synchronized", "local")
|
||||
.unwrap();
|
||||
ws.sync_capture(&binding.id).unwrap();
|
||||
assert_eq!(ws.sync_next(&binding.id).unwrap().unwrap().path, "note.md");
|
||||
assert_eq!(std::fs::read(root.path().join(path)).unwrap(), data);
|
||||
}
|
||||
#[test]
|
||||
fn excluded_records_neither_upload_nor_require_object_bytes_to_advance_cursor() {
|
||||
use crate::sync_inbox::RemoteRevision;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut ws = Workspace::open(root.path()).unwrap();
|
||||
let path = "opennexus-records/v1/persona/default.json";
|
||||
let local = serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":0,"name":"Local only","system_prompt":"private","dialogue_pairs":[]}})).unwrap();
|
||||
ws.write(path, "", &local, "local").unwrap();
|
||||
let binding = ws
|
||||
.sync_bind_download("https://sync.example", "remote", "account")
|
||||
.unwrap();
|
||||
ws.sync_capture(&binding.id).unwrap();
|
||||
assert!(ws.sync_next(&binding.id).unwrap().is_none());
|
||||
let revision = RemoteRevision {
|
||||
vault_id: "remote".into(),
|
||||
sequence: 1,
|
||||
file_id: uuid::Uuid::new_v4().to_string(),
|
||||
base_revision: 0,
|
||||
path: path.into(),
|
||||
operation: "put".into(),
|
||||
hash: Some("a".repeat(64)),
|
||||
size: 123,
|
||||
operation_id: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
ws.sync_set_boundary(&binding.id, 1).unwrap();
|
||||
ws.sync_stage(&binding.id, &revision).unwrap();
|
||||
assert!(ws.sync_apply_pending(&binding.id).unwrap());
|
||||
assert_eq!(ws.sync_binding().unwrap().unwrap().cursor, 1);
|
||||
assert_eq!(std::fs::read(root.path().join(path)).unwrap(), local);
|
||||
assert!(!ws
|
||||
.sync_spool(revision.hash.as_ref().unwrap())
|
||||
.unwrap()
|
||||
.exists());
|
||||
assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty());
|
||||
ws.sync_unbind(&binding.id).unwrap();
|
||||
let snapshot = crate::sync_initial::Snapshot {
|
||||
boundary: 1,
|
||||
items: vec![revision],
|
||||
};
|
||||
let before = ws
|
||||
.sync_preview("https://sync.example", "remote", "account", &snapshot)
|
||||
.unwrap();
|
||||
assert!(before.items.is_empty());
|
||||
ws.sync_set_optional_scope(OptionalScope {
|
||||
persona: true,
|
||||
layout: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ws.sync_bind_initial(
|
||||
"https://sync.example",
|
||||
"remote",
|
||||
"account",
|
||||
&snapshot,
|
||||
&before.fingerprint
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.code,
|
||||
"SYNC_PREVIEW_CHANGED"
|
||||
);
|
||||
let after = ws
|
||||
.sync_preview("https://sync.example", "remote", "account", &snapshot)
|
||||
.unwrap();
|
||||
assert_eq!(after.items.len(), 1);
|
||||
assert_eq!(after.items[0].action, "conflict");
|
||||
}
|
||||
#[test]
|
||||
fn schema_ten_upgrade_preserves_vault_and_does_not_infer_consent() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -163,7 +163,7 @@ impl Workspace {
|
||||
let Some((operation_id, file_id, path, digest, operation, content)) = pending else {
|
||||
break;
|
||||
};
|
||||
if !crate::sync_discovery::allowed(&path) {
|
||||
if !self.sync_path_enabled(&path)? {
|
||||
self.db.execute(
|
||||
"UPDATE outbox SET state='excluded' WHERE operation_id=?1",
|
||||
[&operation_id],
|
||||
@@ -201,19 +201,34 @@ impl Workspace {
|
||||
}
|
||||
pub fn sync_next(&self, binding: &str) -> Result<Option<Job>> {
|
||||
self.check_binding(binding)?;
|
||||
let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| {
|
||||
loop {
|
||||
let job=self.db.query_row("SELECT binding,operation_id,file_id,path,hash,size,operation,state,base_revision,upload_id FROM sync_jobs WHERE binding=?1 AND state NOT IN ('acked','archived','conflict') AND NOT EXISTS (SELECT 1 FROM sync_conflicts c WHERE c.binding=sync_jobs.binding AND (c.file_id=sync_jobs.file_id OR c.local_path=sync_jobs.path) AND c.state='open') ORDER BY rowid LIMIT 1", [binding], |r| {
|
||||
Ok(Job { binding:r.get(0)?,operation_id:r.get(1)?,file_id:r.get(2)?,path:r.get(3)?,hash:r.get(4)?,size:r.get(5)?,operation:r.get(6)?,state:r.get(7)?,base_revision:r.get(8)?,upload_id:r.get(9)? })
|
||||
}).optional()?;
|
||||
if job
|
||||
.as_ref()
|
||||
.is_some_and(|job| !crate::sync_discovery::allowed(&job.path))
|
||||
{
|
||||
return Err(HostError::new("SYNC_CLASS_UNSUPPORTED"));
|
||||
if job
|
||||
.as_ref()
|
||||
.is_some_and(|job| !crate::sync_discovery::allowed(&job.path))
|
||||
{
|
||||
return Err(HostError::new("SYNC_CLASS_UNSUPPORTED"));
|
||||
}
|
||||
if let Some(ref excluded) = job {
|
||||
if !self.sync_path_enabled(&excluded.path)? {
|
||||
self.db.execute("UPDATE sync_jobs SET state='archived' WHERE binding=?1 AND operation_id=?2",params![binding,excluded.operation_id])?;
|
||||
self.db.execute(
|
||||
"UPDATE outbox SET state='excluded' WHERE operation_id=?1",
|
||||
[&excluded.operation_id],
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return Ok(job);
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
pub(crate) fn check_job(&self, job: &Job) -> Result<()> {
|
||||
self.check_binding(&job.binding)?;
|
||||
if !self.sync_path_enabled(&job.path)? {
|
||||
return Err(HostError::new("SYNC_SCOPE_DISABLED"));
|
||||
}
|
||||
let state: String = self.db.query_row(
|
||||
"SELECT state FROM sync_jobs WHERE binding=?1 AND operation_id=?2",
|
||||
params![job.binding, job.operation_id],
|
||||
|
||||
@@ -75,6 +75,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
client.verify_empty(remote).await.unwrap();
|
||||
let local = tempfile::tempdir().unwrap();
|
||||
let workspace = Arc::new(Mutex::new(Workspace::open(local.path()).unwrap()));
|
||||
workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
})
|
||||
.unwrap();
|
||||
let binding = {
|
||||
let mut ws = workspace.lock().unwrap();
|
||||
let mut digest = String::new();
|
||||
@@ -152,6 +160,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
.unwrap();
|
||||
let root_b = tempfile::tempdir().unwrap();
|
||||
let workspace_b = Arc::new(Mutex::new(Workspace::open(root_b.path()).unwrap()));
|
||||
workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
})
|
||||
.unwrap();
|
||||
let binding_b = workspace_b
|
||||
.lock()
|
||||
.unwrap()
|
||||
@@ -660,6 +676,86 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
|
||||
);
|
||||
}
|
||||
|
||||
// A default-off device consumes history metadata without downloading either
|
||||
// optional record. Rebinding after opt-in must fetch the already-seen heads.
|
||||
let excluded_root = tempfile::tempdir().unwrap();
|
||||
let excluded_ws = Arc::new(Mutex::new(Workspace::open(excluded_root.path()).unwrap()));
|
||||
let excluded_binding = excluded_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.sync_bind_download(&endpoint, remote, "rust-fixture")
|
||||
.unwrap();
|
||||
while client_b
|
||||
.pull_page(&excluded_ws, &excluded_binding)
|
||||
.await
|
||||
.unwrap()
|
||||
> 0
|
||||
{}
|
||||
for (kind, id) in [("persona", "default"), ("layout", "sidebars")] {
|
||||
let original = workspace
|
||||
.lock()
|
||||
.unwrap()
|
||||
.record_get_kind(kind, id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let mut excluded = excluded_ws.lock().unwrap();
|
||||
assert!(excluded.record_get_kind(kind, id).unwrap().is_none());
|
||||
assert!(!excluded
|
||||
.sync_spool(original["hash"].as_str().unwrap())
|
||||
.unwrap()
|
||||
.exists());
|
||||
}
|
||||
assert!(!client_b
|
||||
.push_one(&excluded_ws, &excluded_binding)
|
||||
.await
|
||||
.unwrap());
|
||||
{
|
||||
let mut excluded = excluded_ws.lock().unwrap();
|
||||
excluded.sync_unbind(&excluded_binding.id).unwrap();
|
||||
excluded
|
||||
.sync_set_optional_scope(notesagent_host::sync_scope::OptionalScope {
|
||||
persona: true,
|
||||
layout: true,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let snapshot = client_b.snapshot(remote).await.unwrap();
|
||||
let opted_binding = {
|
||||
let mut excluded = excluded_ws.lock().unwrap();
|
||||
let preview = excluded
|
||||
.sync_preview(&endpoint, remote, "rust-fixture", &snapshot)
|
||||
.unwrap();
|
||||
excluded
|
||||
.sync_bind_initial(
|
||||
&endpoint,
|
||||
remote,
|
||||
"rust-fixture",
|
||||
&snapshot,
|
||||
&preview.fingerprint,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
while client_b
|
||||
.pull_page(&excluded_ws, &opted_binding)
|
||||
.await
|
||||
.unwrap()
|
||||
> 0
|
||||
{}
|
||||
for (kind, id) in [("persona", "default"), ("layout", "sidebars")] {
|
||||
assert_eq!(
|
||||
excluded_ws
|
||||
.lock()
|
||||
.unwrap()
|
||||
.record_get_kind(kind, id)
|
||||
.unwrap(),
|
||||
workspace.lock().unwrap().record_get_kind(kind, id).unwrap()
|
||||
);
|
||||
}
|
||||
assert!(!client_b
|
||||
.push_one(&excluded_ws, &opted_binding)
|
||||
.await
|
||||
.unwrap());
|
||||
|
||||
// Kill the actual client process after each durable 10 MiB server offset,
|
||||
// before its response reaches the client. The next process must query offset.
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -95,7 +95,7 @@ async function save() {
|
||||
<dialog ref="dialog" class="modal persona-dialog" aria-labelledby="persona-title" @cancel.prevent="emit('close')" @click="($event.target === dialog) && emit('close')">
|
||||
<form @submit.prevent="save">
|
||||
<div class="persona-heading"><h2 id="persona-title">{{ t('人设与头像', 'Persona and avatars') }}</h2><button type="button" class="button-secondary" @click="emit('close')">{{ t('关闭', 'Close') }}</button></div>
|
||||
<p class="notice-banner">{{ isDesktop() ? t('工作区人设 · 随当前 Vault 同步,应用于此工作区的对话与智能体。旧全局人设不会自动导入。', 'Workspace persona · Syncs with this Vault and applies to its chats and agents. Legacy global personas are not imported automatically.') : t('全局人设 · 应用于连接此 AI Core 的所有对话与智能体。留空的提示词和对话示例不会拼入请求。', 'Global persona · Applies to all chats and agents connected to this AI Core. Empty prompts and examples are omitted.') }}</p>
|
||||
<p class="notice-banner">{{ isDesktop() ? t('工作区人设 · 可在同步设置中选择随当前 Vault 同步,应用于此工作区的对话与智能体。旧全局人设不会自动导入。', 'Workspace persona · Optionally syncs with this Vault and applies to its chats and agents. Legacy global personas are not imported automatically.') : t('全局人设 · 应用于连接此 AI Core 的所有对话与智能体。留空的提示词和对话示例不会拼入请求。', 'Global persona · Applies to all chats and agents connected to this AI Core. Empty prompts and examples are omitted.') }}</p>
|
||||
<p v-if="!ready" role="status">{{ t('正在加载全局设置', 'Loading global settings') }} <button type="button" class="button-secondary" @click="loadGlobal">{{ t('重试', 'Retry') }}</button></p>
|
||||
<section v-if="isDesktop()" class="legacy-persona">
|
||||
<button type="button" class="button-secondary" :disabled="!ready || saving || legacyLoading" @click="previewLegacy">{{ t('查看旧全局人设', 'Preview legacy global persona') }}</button>
|
||||
|
||||
@@ -81,3 +81,15 @@ it('shows persisted per-job interruption counts without starting work from the v
|
||||
expect(vi.mocked(hostInvoke).mock.calls.every(([command]) => command === 'sync_status')).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
|
||||
it('keeps optional records off until an explicit unbound scope change', async () => {
|
||||
vi.mocked(hostInvoke).mockResolvedValue({ ...empty(), optional_scope: { persona: false, layout: false } })
|
||||
const wrapper = mount(SyncSettings); await flushPromises()
|
||||
const options = wrapper.findAll('.sync-scope input[type=checkbox]')
|
||||
expect(options).toHaveLength(2)
|
||||
expect((options[0]!.element as HTMLInputElement).checked).toBe(false)
|
||||
await options[0]!.setValue(true); await flushPromises()
|
||||
expect(hostInvoke).toHaveBeenCalledWith('sync_set_scope', { vaultId: empty().vault_id, scope: { persona: true, layout: false } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ import { t } from '@/i18n'
|
||||
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
|
||||
interface Binding { id: string; endpoint: string; account: string; remote_vault: string; cursor: number }
|
||||
interface Conflict { sequence: number; local_path: string; local_hash: string; current_hash?: string; current_path?: string; remote: { path: string; operation: string } }
|
||||
interface Status { vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> }
|
||||
interface Status { optional_scope?: { persona: boolean; layout: boolean }; vault_id: string; binding: Binding | null; paused: boolean; pending: number; conflicts: Conflict[]; credential_state: string; running: boolean; error: string | null; retry_in: number | null; failures: number; halted: boolean; attempts?: Array<{ operation_id: string; path: string; attempts: number; outcome: string; error: string | null }> }
|
||||
interface RemoteVault { id: string; name: string; sequence: number; used: number; quota: number }
|
||||
const status = ref<Status | null>(null)
|
||||
const endpoint = ref('https://'), account = ref(''), password = ref(''), device = ref('OpenNexus Desktop'), testHttp = ref(false)
|
||||
@@ -42,6 +42,15 @@ async function act(action: () => Promise<void>) {
|
||||
catch (error) { message.value = error instanceof Error ? error.message : 'SYNC_FAILED' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
function setScope(kind: 'persona' | 'layout', event: Event) {
|
||||
const current = status.value
|
||||
if (!current || current.binding) return
|
||||
const input = event.target as HTMLInputElement
|
||||
const scope = { persona: false, layout: false, ...current.optional_scope, [kind]: input.checked }
|
||||
input.checked = current.optional_scope?.[kind] ?? false
|
||||
preview.value = null
|
||||
return act(async () => { await hostInvoke('sync_set_scope', { vaultId: current.vault_id, scope }) })
|
||||
}
|
||||
async function listVaults() {
|
||||
const result = await hostInvoke<{ items: RemoteVault[] }>('sync_vaults', { endpoint: endpoint.value, account: account.value })
|
||||
remoteVaults.value = result.items
|
||||
@@ -118,6 +127,12 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
|
||||
<div class="inline-actions"><button :disabled="previewPage === 0" @click="previewPage--">{{ t('上一页', 'Previous') }}</button><button :disabled="(previewPage + 1) * 100 >= preview.items.length" @click="previewPage++">{{ t('下一页', 'Next') }}</button><button :disabled="busy" @click="merge">{{ t('确认合并并绑定', 'Confirm merge and bind') }}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<fieldset v-if="status" :disabled="busy || !!status.binding" class="sync-scope">
|
||||
<legend>{{ t('可选同步内容', 'Optional sync content') }}</legend>
|
||||
<label><input type="checkbox" :checked="status.optional_scope?.persona ?? false" @change="setScope('persona', $event)" />{{ t('工作区人设', 'Workspace persona') }}</label>
|
||||
<label><input type="checkbox" :checked="status.optional_scope?.layout ?? false" @change="setScope('layout', $event)" />{{ t('侧栏布局', 'Sidebar layout') }}</label>
|
||||
<p>{{ t('默认仅保存在本机。修改已绑定范围时,请先解除绑定,再重新预览合并;关闭选项不会删除远端内容。', 'Kept locally by default. Unbind before changing scope, then preview a new merge. Disabling an option does not delete remote content.') }}</p>
|
||||
</fieldset>
|
||||
<div v-if="status?.binding" class="sync-bound">
|
||||
<p>{{ status.binding.endpoint }} · {{ status.binding.account }} · {{ status.binding.remote_vault }}</p>
|
||||
<p aria-live="polite">{{ status.paused ? t('已暂停', 'Paused') : status.running ? t('同步中', 'Syncing') : status.halted ? t('自动同步已停止,请处理错误后重试', 'Automatic sync stopped; resolve the error and retry') : t('等待下一轮同步', 'Waiting for next sync') }} · {{ t('待上传', 'Pending') }} {{ status.pending }} · cursor {{ status.binding.cursor }}</p>
|
||||
|
||||
Reference in New Issue
Block a user