feat: 完成 OpenNexus 第三阶段核心功能与生产化基础 #45

Merged
Kronecker merged 151 commits from feat/phase3-completion into main 2026-09-10 01:16:31 +08:00
5 changed files with 107 additions and 6 deletions
Showing only changes of commit 195ee9de84 - Show all commits
+3
View File
@@ -92,3 +92,6 @@ Core 通过绑定 Vault 的 workspace.persona.get/write RPC 读写;Host 先校
旧全局人设导入使用只读 GET `/api/settings/persona/legacy`,仅桌面模式提供,并先向 Host 验证当前 Vault。返回 available 与旧人设内容,不返回旧记录 revision,也不写入任何 Workspace 记录。UI 展示预览后,用户可以点击“填入当前表单”;这只替换可编辑内容,保留当前目标的 version/revision。最终保存沿用正常人设 PUT、Host CAS 与 journal。取消预览/关闭表单不会导入,切换 Vault 后迟到响应被丢弃,旧 SQLite 来源始终保留。没有自动清除或把读取行为作为迁移完成标记。 旧全局人设导入使用只读 GET `/api/settings/persona/legacy`,仅桌面模式提供,并先向 Host 验证当前 Vault。返回 available 与旧人设内容,不返回旧记录 revision,也不写入任何 Workspace 记录。UI 展示预览后,用户可以点击“填入当前表单”;这只替换可编辑内容,保留当前目标的 version/revision。最终保存沿用正常人设 PUT、Host CAS 与 journal。取消预览/关闭表单不会导入,切换 Vault 后迟到响应被丢弃,旧 SQLite 来源始终保留。没有自动清除或把读取行为作为迁移完成标记。
逻辑记录的“另存副本并采用远端”可将原始记录内容导出为 `attachments/*.txt`;文本副本作为普通附件同步,拥有独立 file_id,不作为活动设置自动应用。副本目标必须属于同步白名单;若目标本身位于记录命名空间,还必须与原始记录 kind/id/schema 一致。这些检查在持久化冲突决定之前完成,因此无效路径不占用该冲突的解决选择。
@@ -710,3 +710,13 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 前端人设定向 6 项、Python 人设定向 7 项通过;完整前端 101 文件/529 项通过,日志 .build/persona-import-frontend.log。完整后端与类型检查结果另列。 - 前端人设定向 6 项、Python 人设定向 7 项通过;完整前端 101 文件/529 项通过,日志 .build/persona-import-frontend.log。完整后端与类型检查结果另列。
- 此流程是用户明确选择的复制导入,不是自动所有权推断;没有实现旧来源清除。完整导入进程中断矩阵、独立设备 UI 与发布兼容验收仍需继续,完整生产化目标保持未完成。 - 此流程是用户明确选择的复制导入,不是自动所有权推断;没有实现旧来源清除。完整导入进程中断矩阵、独立设备 UI 与发布兼容验收仍需继续,完整生产化目标保持未完成。
- 最终后端全套 904 通过、1 项依赖弃用警告,81.49 秒;前端 type-check 通过。日志 .build/persona-import-python.log、.build/persona-import-types.log。本轮无 Rust 生产代码改动,未重复 Rust 全套;用户 Vault 修改保持原状。 - 最终后端全套 904 通过、1 项依赖弃用警告,81.49 秒;前端 type-check 通过。日志 .build/persona-import-python.log、.build/persona-import-types.log。本轮无 Rust 生产代码改动,未重复 Rust 全套;用户 Vault 修改保持原状。
## 增量:副本路径预检与逻辑记录文本副本
- 发现 sync_resolve 会先持久化 copy 决定,再由写入路径校验逻辑记录;无效目标可使 pending 决定无法应用且不允许改选。现于 INSERT 前检查目标同步白名单,并针对记录目标流式验证原始快照及目标记录 schema,不在验证失败时冻结选择。
- 新增真实 Workspace 故障测试:人设同改后,copy.json(不在同步白名单)与 layout/sidebars(错误记录身份)均被拒绝,sync_resolutions 行数仍为 0,本地内容保留;随后改选 attachments/persona-copy.txt 成功,重开后文本副本是完整本地记录、活动人设是远端记录,冲突已关闭。定向测试通过,日志 .build/record-copy-preflight.log。
- SyncSettings 对逻辑记录提供 attachments/settings-copy.txt 路径提示,并说明文本副本用于查看恢复而不自动应用。
- 真实 HTTP 双客户端测试扩充为人设、布局各 60 轮同改冲突,三种选择各 20 轮。副本分支额外要求两端副本正文/摘要一致,副本 file_id 在两端一致且不同于活动记录。最终执行结果另列。
- 生产代码完整 Rust 回归 139 通过、12 ignored,日志 .build/record-copy-full-rust.log;随后单独运行扩充后的 60 轮/类别 HTTP 用例通过,38.97 秒,日志 .build/record-copy-http-final.log。Clippy 首次发现新测试重开变量多余 mut,已移除,最终全目标 -D warnings 通过(.build/record-copy-clippy-final.log)。前端 529 项及 type-check 通过,日志 .build/record-copy-frontend.log、.build/record-copy-types.log。
- 上述结果证明本机双客户端同改的三种选择与文本副本,不代替完整 S-03 改对删/rename/历史恢复、独立硬件 UI 或发布验收。本轮未更改用户 Vault 数据,完整目标仍未完成。
+77
View File
@@ -50,6 +50,17 @@ impl Workspace {
if current.is_empty() || self.resolve(destination)?.exists() { if current.is_empty() || self.resolve(destination)?.exists() {
return Err(HostError::new("PATH_CONFLICT")); return Err(HostError::new("PATH_CONFLICT"));
} }
if !crate::sync_discovery::allowed(destination) {
return Err(HostError::new("SYNC_PATH_DENIED"));
}
// Validate the intended record path before freezing the decision.
// A typo must not leave an unchangeable, unappliable resolution.
if crate::records::is_record(destination) {
let spool = self.sync_spool(&current)?;
let size = fs::metadata(&spool)?.len();
let mut file = crate::payloads::open_verified(&spool, &current, size)?;
crate::payloads::validate_record(&mut file, destination, size)?;
}
} else if !destination.is_empty() { } else if !destination.is_empty() {
return Err(HostError::new("SYNC_RESOLUTION_INVALID")); return Err(HostError::new("SYNC_RESOLUTION_INVALID"));
} }
@@ -265,6 +276,72 @@ mod tests {
ws.sync_apply_pending(binding).unwrap(); ws.sync_apply_pending(binding).unwrap();
} }
#[test] #[test]
fn invalid_record_copy_destination_does_not_freeze_conflict_decision() {
let root = tempfile::tempdir().unwrap();
let mut ws = Workspace::open(root.path()).unwrap();
let binding = ws
.sync_bind_download("https://sync.example", "remote-vault", "account")
.unwrap();
let record = |name: &str| {
serde_json::to_vec(&serde_json::json!({"schema":1,"kind":"persona","id":"default","data":{"version":1,"name":name,"system_prompt":"","dialogue_pairs":[]}})).unwrap()
};
let base = record("base");
let local = record("local");
let remote = record("remote");
let path = crate::records::path_for("persona", "default").unwrap();
let mut revision = RemoteRevision {
vault_id: "remote-vault".into(),
sequence: 1,
file_id: Uuid::new_v4().to_string(),
base_revision: 0,
path: path.clone(),
operation: "put".into(),
hash: Some(ws.sync_store_bytes(&base).unwrap()),
size: base.len() as i64,
operation_id: Uuid::new_v4().to_string(),
};
receive(&mut ws, &binding.id, &revision);
let local_hash = ws
.write(&path, revision.hash.as_deref().unwrap(), &local, "local")
.unwrap()
.hash;
revision.sequence = 2;
revision.base_revision = 1;
revision.hash = Some(ws.sync_store_bytes(&remote).unwrap());
revision.size = remote.len() as i64;
revision.operation_id = Uuid::new_v4().to_string();
receive(&mut ws, &binding.id, &revision);
for destination in ["copy.json", "opennexus-records/v1/layout/sidebars.json"] {
assert!(ws
.sync_resolve(&binding.id, 2, "copy", destination, &local_hash)
.is_err());
assert_eq!(
ws.db
.query_row("SELECT COUNT(*) FROM sync_resolutions", [], |r| r
.get::<_, i64>(0))
.unwrap(),
0
);
assert_eq!(fs::read(root.path().join(&path)).unwrap(), local);
}
ws.sync_resolve(
&binding.id,
2,
"copy",
"attachments/persona-copy.txt",
&local_hash,
)
.unwrap();
drop(ws);
let ws = Workspace::open(root.path()).unwrap();
assert_eq!(
fs::read(root.path().join("attachments/persona-copy.txt")).unwrap(),
local
);
assert_eq!(fs::read(root.path().join(path)).unwrap(), remote);
assert!(ws.sync_conflicts(&binding.id).unwrap().is_empty());
}
#[test]
fn hundred_mib_conflicts_resolve_all_choices_and_reopen_without_duplicate_jobs() { fn hundred_mib_conflicts_resolve_all_choices_and_reopen_without_duplicate_jobs() {
use std::io::Write; use std::io::Write;
let fixtures = tempfile::tempdir().unwrap(); let fixtures = tempfile::tempdir().unwrap();
+15 -5
View File
@@ -560,7 +560,7 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
.unwrap(); .unwrap();
while client.push_one(&workspace, &binding).await.unwrap() {} while client.push_one(&workspace, &binding).await.unwrap() {}
client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); client_b.pull_page(&workspace_b, &binding_b).await.unwrap();
for round in 0..20 { for round in 0..60 {
let mut hashes = Vec::new(); let mut hashes = Vec::new();
for (side, target) in [(0, &workspace), (1, &workspace_b)] { for (side, target) in [(0, &workspace), (1, &workspace_b)] {
let mut ws = target.lock().unwrap(); let mut ws = target.lock().unwrap();
@@ -583,7 +583,9 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
} }
while client.push_one(&workspace, &binding).await.unwrap() {} while client.push_one(&workspace, &binding).await.unwrap() {}
client_b.pull_page(&workspace_b, &binding_b).await.unwrap(); client_b.pull_page(&workspace_b, &binding_b).await.unwrap();
let choice = if round % 2 == 0 { "local" } else { "remote" }; let choice = ["local", "remote", "copy"][round % 3];
let copy = format!("attachments/{kind}-copy-{round}.txt");
let destination = if choice == "copy" { copy.as_str() } else { "" };
{ {
let mut ws = workspace_b.lock().unwrap(); let mut ws = workspace_b.lock().unwrap();
let conflicts = ws.sync_conflicts(&binding_b.id).unwrap(); let conflicts = ws.sync_conflicts(&binding_b.id).unwrap();
@@ -591,14 +593,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
let sequence = conflicts[0]["sequence"].as_i64().unwrap(); let sequence = conflicts[0]["sequence"].as_i64().unwrap();
assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]); assert_eq!(ws.read(&path).unwrap().entry.hash, hashes[1]);
assert_eq!( assert_eq!(
ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[0]) ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[0])
.unwrap_err() .unwrap_err()
.code, .code,
"REVISION_CONFLICT" "REVISION_CONFLICT"
); );
ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[1])
.unwrap(); .unwrap();
ws.sync_resolve(&binding_b.id, sequence, choice, "", &hashes[1]) ws.sync_resolve(&binding_b.id, sequence, choice, destination, &hashes[1])
.unwrap(); .unwrap();
} }
while client_b.push_one(&workspace_b, &binding_b).await.unwrap() {} while client_b.push_one(&workspace_b, &binding_b).await.unwrap() {}
@@ -618,6 +620,14 @@ async fn actual_service_accepts_ordered_push_and_repeat_commit_without_duplicate
.unwrap(); .unwrap();
assert_eq!(a, b); assert_eq!(a, b);
assert_eq!(a["hash"], hashes[usize::from(choice == "local")]); assert_eq!(a["hash"], hashes[usize::from(choice == "local")]);
if choice == "copy" {
let copy_a = workspace.lock().unwrap().read(&copy).unwrap();
let copy_b = workspace_b.lock().unwrap().read(&copy).unwrap();
assert_eq!(copy_a.content, copy_b.content);
assert_eq!(copy_a.entry.hash, hashes[1]);
assert_eq!(copy_a.entry.file_id, copy_b.entry.file_id);
assert_ne!(copy_a.entry.file_id, a["file_id"].as_str().unwrap());
}
assert!(workspace assert!(workspace
.lock() .lock()
.unwrap() .unwrap()
@@ -136,7 +136,8 @@ onUnmounted(() => { mounted = false; clearInterval(timer); password.value = '' }
<article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict"> <article v-for="conflict in status.conflicts" :key="conflict.sequence" class="sync-conflict">
<h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p> <h3>{{ conflict.local_path }}</h3><p>{{ t('远端版本', 'Remote revision') }} {{ conflict.sequence }} · {{ conflict.remote.operation }} · {{ conflict.remote.path }}</p>
<div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div> <div class="inline-actions"><button :disabled="busy" @click="resolve(conflict, 'local')">{{ t('保留本地', 'Keep local') }}</button><button :disabled="busy" @click="resolve(conflict, 'remote')">{{ t('采用远端', 'Use remote') }}</button></div>
<label>{{ t('副本相对路径', 'Relative copy path') }}<input v-model="copies[conflict.sequence]" placeholder="conflicts/note-copy.md" /></label><button :disabled="busy || !copies[conflict.sequence]" @click="resolve(conflict, 'copy')">{{ t('另存本地副本并采用远端', 'Save local copy and use remote') }}</button> <p v-if="conflict.local_path.startsWith('opennexus-records/')">{{ t('可将本地设置保留为 attachments 目录下的文本副本副本供查看和恢复不会自动应用', 'Keep local settings as a text copy under attachments for inspection and recovery; the copy is not applied automatically.') }}</p>
<label>{{ t('副本相对路径', 'Relative copy path') }}<input v-model="copies[conflict.sequence]" :placeholder="conflict.local_path.startsWith('opennexus-records/') ? 'attachments/settings-copy.txt' : 'conflicts/note-copy.md'" /></label><button :disabled="busy || !copies[conflict.sequence]" @click="resolve(conflict, 'copy')">{{ t('另存本地副本并采用远端', 'Save local copy and use remote') }}</button>
</article> </article>
</div> </div>
</section> </section>