feat(sync): adopt DatasetPolicy-scoped TT-Sync v2 manifests

This commit is contained in:
Darkatse 2026-06-10 22:01:15 +08:00
parent da57b39fcc
commit f296a9b335
11 changed files with 623 additions and 43 deletions

View file

@ -10,14 +10,15 @@
TauriTavern 当前存在两套同步链路:
- **LAN Sync(v1)**:局域网内设备间同步(协议与事件语义对齐上游 SillyTavern)。
- **LAN Sync(v1)**:局域网内设备间同步(遗留 HTTP 协议,后续不再扩展新同步能力)。
- **TT-Sync(v2)**:公网远端同步(TauriTavern ⇄ TT-Sync 服务端),语义与 LAN Sync 对齐,但协议独立演进。
关键结论(后续改动优先守住这些):
1. **同步语义以“用户数据一致性”为中心**:scope/exclude、`(size_bytes, modified_ms)` 增量判定、Mirror delete 的时序、原子写入与 mtime 保留。
2. **同步作业全局串行**:LAN Sync 与 TT-Sync 共用同一个 `Semaphore(1)`(即同一时刻只能跑一个同步作业),避免两条链路并发写入相同数据目录导致破坏性竞态(见 `src-tauri/src/app/bootstrap.rs`)。
3. **TT-Sync 已落地 Bundle 传输形态**:在公网高 RTT 场景下把 N 个 per-file 请求收敛为 1 个 bundle 请求,并可选 zstd 压缩;旧的 per-file 端点仍保留作为 fallback。
3. **长期同步 scope 由 TT-Sync `DatasetPolicy` 定义**:TT-Sync v2 与后续 HTTPS LAN v2 消费同一份策略;LAN Sync v1 冻结旧 allowlist,不再扩展 Agent 等新范围。
4. **TT-Sync 已落地 Bundle 传输形态**:在公网高 RTT 场景下把 N 个 per-file 请求收敛为 1 个 bundle 请求,并可选 zstd 压缩;旧的 per-file 端点仍保留作为 fallback。
---
@ -32,7 +33,22 @@ TauriTavern 当前存在两套同步链路:
- TT-Sync v2 状态:`default-user/user/lan-sync/tt-sync-v2/`
- `identity.json` / `paired-servers.json`(见 `src-tauri/src/infrastructure/tt_sync/store.rs`)
TT-Sync 的 manifest 扫描严格遵循 `ttsync_core::scope`,并且会排除 LAN/TT 同步状态目录(见 `src-tauri/src/infrastructure/tt_sync/fs.rs`)。
TT-Sync v2 manifest 扫描严格遵循 `ttsync_core::dataset::ResolvedDatasetPolicy`,并且会排除 LAN/TT 同步状态目录(见 `src-tauri/src/infrastructure/tt_sync/fs.rs`)。当前 TauriTavern 默认数据集还会排除 `_tauritavern/prompt-cache/`、`_tauritavern/.ios-policy.json`、`_cache/`、`.staging` 与同步临时文件。
默认 TauriTavern 数据集已经覆盖 Agent 连续性数据:
- `_tauritavern/agent-profiles/profiles/**`
- `_tauritavern/llm-connections/**`
- `_tauritavern/skills/{installed,index}/**`
- `_tauritavern/agent-workspaces/chats/**/persistent-states/**`
- `_tauritavern/agent-workspaces/index/runs/**`
- `_tauritavern/agent-workspaces/chats/**/runs/*/{run.json,events.jsonl}`
`default-user/secrets.json`、Agent `model-responses/`、`checkpoints/`、`backups/`、`vectors/`、`thumbnails/` 被保留为独立数据集,不在当前无 UI 选择的默认同步中。
Agent run history 只同步终态运行。扫描器会读取 `run.json` / run index JSON 的 `status`,仅纳入 `completed`、`partial_success`、`cancelled`、`failed`;运行中的 `calling_model` 等状态不会进入 manifest。
LAN Sync v1 仍使用原有固定 allowlist 和裸 `LanSyncManifest` plan 请求。它不会同步 Agent 数据,也不会接入 DatasetPolicy;新范围应进入 TT-Sync v2/未来 HTTPS LAN v2。
---
@ -77,14 +93,15 @@ TT-Sync 的 manifest 扫描严格遵循 `ttsync_core::scope`,并且会排除 L
1. **全局 permit**:尝试获取同步许可;失败则发 error 事件并直接返回(见 `src-tauri/src/application/services/tt_sync_service.rs`)。
2. `POST /v2/session/open`:用 Ed25519 对 canonical request 签名,获得 `session_token` 与 `granted_permissions`。
3. Scanning:扫描本地 manifest(`src-tauri/src/infrastructure/tt_sync/fs.rs`)。
4. Diffing:请求 plan:
3. Status:读取 `GET /v2/status`,必须支持 `dataset_scope_v1` 且 `dataset_policy_version` 匹配;旧 server 会 fail-fast,避免静默漏同步 Agent 数据。
4. Scanning:按 TauriTavern default `DatasetPolicy` 扫描本地 manifest(`src-tauri/src/infrastructure/tt_sync/fs.rs`)。
5. Diffing:携带同一份 `DatasetSelection` 请求 plan:
- pull:`POST /v2/sync/pull-plan`
- push:`POST /v2/sync/push-plan`
5. Transfer:
6. Transfer:
- **优先 bundle**(需服务端 `features` 声明支持;见 5.x)
- 否则 fallback 到 per-file 并发传输
6. Deleting(仅 Mirror):
7. Deleting(仅 Mirror):
- pull:本地按 plan.delete 删除
- push:在 commit 后由服务端应用删除(Mirror 语义)
@ -102,13 +119,15 @@ push 的额外步骤:
### 5.1 能力协商(features)
客户端会先调用 `GET /v2/status` 获取 `features`(失败则当作不支持,走 fallback):
客户端会先调用 `GET /v2/status` 获取 `features` 与 DatasetPolicy 版本;状态请求失败、`dataset_scope_v1` 缺失或策略版本不匹配都会 fail-fast:
- `bundle_v1`:支持 bundle 端点
- `zstd_v1`:支持 bundle 的 zstd 编解码
- `dataset_scope_v1`:支持携带 `DatasetSelection` 的 scope-aware plan/delete
客户端策略(见 `src-tauri/src/infrastructure/tt_sync/push.rs`、`src-tauri/src/infrastructure/tt_sync/pull.rs`):
- `dataset_scope_v1` 缺失或策略版本不匹配时直接报错。
- 仅当存在 `bundle_v1` 才启用 bundle。
- 仅当同时存在 `bundle_v1` + `zstd_v1` 才启用 zstd(push 不能“试一试”,必须先确认)。
@ -179,3 +198,5 @@ wire framing(见 `src-tauri/src/infrastructure/tt_sync/bundle.rs`):
3. **不要破坏 mtime 语义**:增量 diff 依赖 `(size_bytes, modified_ms)`,写入必须保留 `modified_ms`。
4. **不要改动事件语义**:阶段划分与完成/错误时序对前端是契约。
5. **不要把 iOS policy 本地缓存纳入 scope**:`_tauritavern/.ios-policy.json` 属于 iOS-only 宿主本地状态,用于避免同步覆盖 `tauritavern-settings.json` 时丢失已解锁的 policy。
6. **不要在 v2 链路重新引入手写 scope 数组**:新增同步目录必须先进入 TT-Sync `DatasetPolicy`,再由 TT-Sync v2/未来 HTTPS LAN v2 消费。
7. **不要把敏感/重型 Agent 数据默认并入无选择同步**:`model-responses/`、`checkpoints/` 与密钥文件需要保持独立数据集。

8
src-tauri/Cargo.lock generated
View file

@ -5930,9 +5930,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttsync-contract"
version = "1.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c676651619c641d33124f4bea2719913480527eb07b76a4ae82f6128627a6599"
checksum = "1dba659dd93ae604c03ee7f74235db3dd178b6591e3ae604053ea6a05dfc4983"
dependencies = [
"base64 0.22.1",
"serde",
@ -5942,9 +5942,9 @@ dependencies = [
[[package]]
name = "ttsync-core"
version = "1.0.0"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7935aae284eee368f06a7ac1d468fad23b627114f242aede210fe9210ee939f6"
checksum = "bedf213d7db5eeef959aa6918d6587c7c57fca9a5fe550fd2be730bc4d5458cd"
dependencies = [
"base64 0.22.1",
"ed25519-dalek",

View file

@ -70,8 +70,8 @@ webpki-roots = "1"
dunce = "1"
# TT-Sync v2 shared contract/scope (crates.io)
ttsync-contract = "~1.0.0"
ttsync-core = "~1.0.0"
ttsync-contract = "2"
ttsync-core = "2"
[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies]
tauri-plugin-dialog = "2.7.0"

View file

@ -1,5 +1,4 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum LanSyncSyncMode {
Incremental,

View file

@ -239,6 +239,41 @@ mod tests {
.expect("create global source state directory");
std::fs::create_dir_all(root.join("default-user").join("user").join("lan-sync"))
.expect("create lan sync state directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-profiles")
.join("profiles"),
)
.expect("create agent profile directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("persistent-states")
.join("run-1"),
)
.expect("create agent persistent state directory");
std::fs::create_dir_all(root.join("_tauritavern").join("prompt-cache"))
.expect("create prompt cache directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done"),
)
.expect("create terminal run directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-active"),
)
.expect("create active run directory");
std::fs::write(
root.join("default-user")
@ -280,6 +315,76 @@ mod tests {
b"{}",
)
.expect("write lan sync config");
std::fs::write(
root.join("_tauritavern")
.join("agent-profiles")
.join("profiles")
.join("writer.json"),
b"{}",
)
.expect("write agent profile");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("persistent-states")
.join("run-1")
.join("manifest.json"),
b"{}",
)
.expect("write agent persistent state");
std::fs::write(
root.join("_tauritavern")
.join("prompt-cache")
.join("cache.json"),
b"{}",
)
.expect("write prompt cache");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("run.json"),
br#"{"status":"completed"}"#,
)
.expect("write terminal run");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("events.jsonl"),
b"{}\n",
)
.expect("write terminal event");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-active")
.join("run.json"),
br#"{"status":"calling_model"}"#,
)
.expect("write active run");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-active")
.join("events.jsonl"),
b"{}\n",
)
.expect("write active event");
let manifest = scan_manifest_sync(&root).expect("scan sync manifest");
let relative_paths = manifest
@ -302,6 +407,26 @@ mod tests {
!relative_paths.contains(&"default-user/user/lan-sync/config.json".to_string()),
"lan sync state must never be part of the sync manifest"
);
assert!(
!relative_paths
.contains(&"_tauritavern/agent-profiles/profiles/writer.json".to_string()),
"LAN v1 is legacy scope and must not grow to include Agent data"
);
assert!(!relative_paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/persistent-states/run-1/manifest.json"
.to_string()
));
assert!(
!relative_paths.contains(&"_tauritavern/prompt-cache/cache.json".to_string()),
"prompt cache must stay local"
);
assert!(
!relative_paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/runs/run-done/events.jsonl"
.to_string()
),
"LAN v1 must not include Agent run history"
);
std::fs::remove_dir_all(&root).expect("remove temp lan sync test root");
}

View file

@ -156,7 +156,10 @@ mod tests {
"extensions/index.js",
"_tauritavern/extension-sources/index.json",
"default-user/user/lan-sync/config.json",
"default-user/secrets.json",
"_tauritavern/settings.json",
"_tauritavern/prompt-cache/cache.json",
"_tauritavern/agent-profiles/profiles/writer.json",
];
for path in rejected_paths {

View file

@ -80,7 +80,14 @@ pub async fn spawn_lan_sync_server(
}
async fn handle_status() -> impl IntoResponse {
(StatusCode::OK, Json(json!({ "ok": true })))
(
StatusCode::OK,
Json(json!({
"ok": true,
"protocol": "lan-v1",
"deprecated": true,
})),
)
}
fn sync_plan_body_limit() -> DefaultBodyLimit {
@ -182,8 +189,6 @@ async fn handle_sync_file_inner(
headers: HeaderMap,
path: String,
) -> Result<(HeaderMap, Body), (StatusCode, String)> {
validate_relative_path(&path).map_err(map_domain_error)?;
let (device_id, signature) = require_auth_headers(&headers)?;
let paired_device =
@ -447,6 +452,8 @@ mod tests {
use axum::http::Request;
use tower::ServiceExt;
use crate::domain::models::lan_sync::LanSyncManifest;
async fn accept_manifest(Json(_): Json<LanSyncManifest>) -> StatusCode {
StatusCode::OK
}

View file

@ -3,19 +3,58 @@ use std::time::UNIX_EPOCH;
use ttsync_contract::manifest::{ManifestEntryV2, ManifestV2};
use ttsync_contract::path::SyncPath;
use ttsync_contract::plan::SyncPlan;
use ttsync_core::dataset::ResolvedDatasetPolicy;
use crate::domain::errors::DomainError;
pub async fn scan_manifest(sync_root: PathBuf) -> Result<ManifestV2, DomainError> {
tokio::task::spawn_blocking(move || scan_manifest_sync(&sync_root))
pub async fn scan_manifest_with_policy(
sync_root: PathBuf,
policy: ResolvedDatasetPolicy,
) -> Result<ManifestV2, DomainError> {
tokio::task::spawn_blocking(move || scan_manifest_sync(&sync_root, &policy))
.await
.map_err(|error| DomainError::InternalError(error.to_string()))?
}
fn scan_manifest_sync(sync_root: &Path) -> Result<ManifestV2, DomainError> {
pub fn validate_plan_scope(
plan: &SyncPlan,
policy: &ResolvedDatasetPolicy,
) -> Result<(), DomainError> {
if plan.selection.as_ref() != Some(policy.selection()) {
return Err(DomainError::InvalidData(
"TT-Sync plan dataset selection does not match the requested policy".to_string(),
));
}
for entry in &plan.transfer {
if !policy.contains_path(entry.path.as_str()) {
return Err(DomainError::InvalidData(format!(
"TT-Sync plan contains transfer outside selected dataset scope: {}",
entry.path
)));
}
}
for path in &plan.delete {
if !policy.allows_delete(path.as_str()) {
return Err(DomainError::InvalidData(format!(
"TT-Sync plan contains delete outside selected dataset scope: {}",
path
)));
}
}
Ok(())
}
fn scan_manifest_sync(
sync_root: &Path,
policy: &ResolvedDatasetPolicy,
) -> Result<ManifestV2, DomainError> {
let mut entries = Vec::new();
for directory in ttsync_core::scope::included_directories() {
for directory in policy.scan_roots() {
let root = sync_root.join(directory);
if !root.exists() {
continue;
@ -27,10 +66,14 @@ fn scan_manifest_sync(sync_root: &Path) -> Result<ManifestV2, DomainError> {
)));
}
scan_dir_recursive(sync_root, &root, &mut entries)?;
scan_dir_recursive(sync_root, &root, policy, &mut entries)?;
}
for file in ttsync_core::scope::included_files() {
for file in policy.files() {
if !policy.contains_path(file) {
continue;
}
let path = sync_root.join(file);
if !path.exists() {
continue;
@ -52,6 +95,7 @@ fn scan_manifest_sync(sync_root: &Path) -> Result<ManifestV2, DomainError> {
fn scan_dir_recursive(
sync_root: &Path,
dir: &Path,
policy: &ResolvedDatasetPolicy,
entries: &mut Vec<ManifestEntryV2>,
) -> Result<(), DomainError> {
for entry in std::fs::read_dir(dir).map_err(|error| {
@ -91,16 +135,32 @@ fn scan_dir_recursive(
.map_err(|error| DomainError::InternalError(error.to_string()))?;
let relative = normalize_relative_path(relative)?;
if ttsync_core::scope::is_excluded(&relative) {
if ttsync_core::dataset::is_excluded(&relative) {
continue;
}
if file_type.is_dir() {
scan_dir_recursive(sync_root, &entry_path, entries)?;
if ttsync_core::dataset::is_agent_run_root_dir(&relative)
&& !agent_run_file_is_terminal(&entry_path.join("run.json"))?
{
continue;
}
if !policy.should_descend_dir(&relative) {
continue;
}
scan_dir_recursive(sync_root, &entry_path, policy, entries)?;
continue;
}
if file_type.is_file() {
if file_type.is_file() && policy.contains_path(&relative) {
if ttsync_core::dataset::is_agent_run_index_file(&relative)
&& !agent_run_file_is_terminal(&entry_path)?
{
continue;
}
entries.push(make_entry(sync_root, &entry_path)?);
}
}
@ -108,6 +168,22 @@ fn scan_dir_recursive(
Ok(())
}
fn agent_run_file_is_terminal(path: &Path) -> Result<bool, DomainError> {
if !path.exists() {
return Ok(false);
}
let text = std::fs::read_to_string(path).map_err(|error| {
DomainError::InternalError(format!(
"Failed to read agent run {}: {}",
path.display(),
error
))
})?;
ttsync_core::dataset::agent_run_json_is_terminal(&text)
.map_err(|error| DomainError::InvalidData(format!("{}: {}", path.display(), error)))
}
fn make_entry(sync_root: &Path, file_path: &Path) -> Result<ManifestEntryV2, DomainError> {
let relative = file_path
.strip_prefix(sync_root)
@ -164,8 +240,13 @@ mod tests {
use std::path::PathBuf;
use rand::random;
use ttsync_contract::dataset::{DATASET_POLICY_VERSION, DatasetSelection};
use ttsync_contract::manifest::ManifestEntryV2;
use ttsync_contract::path::SyncPath;
use ttsync_contract::plan::{PlanId, SyncPlan};
use ttsync_core::dataset::ResolvedDatasetPolicy;
use super::scan_manifest_sync;
use super::{scan_manifest_sync, validate_plan_scope};
fn unique_temp_root() -> PathBuf {
std::env::temp_dir().join(format!("tauritavern-tt-sync-{}", random::<u64>()))
@ -201,7 +282,184 @@ mod tests {
)
.expect("write excluded state file");
let manifest = scan_manifest_sync(&root).expect("scan manifest");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-profiles")
.join("profiles"),
)
.expect("create agent profile directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("persistent-states")
.join("run-1"),
)
.expect("create agent persistent state directory");
std::fs::create_dir_all(root.join("_tauritavern").join("prompt-cache"))
.expect("create prompt cache directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done"),
)
.expect("create terminal run directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("input"),
)
.expect("create terminal run input directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("model-responses"),
)
.expect("create terminal run model responses directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-active"),
)
.expect("create active run directory");
std::fs::create_dir_all(
root.join("_tauritavern")
.join("agent-workspaces")
.join("index")
.join("runs"),
)
.expect("create run index directory");
std::fs::write(
root.join("_tauritavern")
.join("agent-profiles")
.join("profiles")
.join("writer.json"),
b"{}",
)
.expect("write agent profile");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("persistent-states")
.join("run-1")
.join("manifest.json"),
b"{}",
)
.expect("write agent persistent state");
std::fs::write(
root.join("_tauritavern")
.join("prompt-cache")
.join("cache.json"),
b"{}",
)
.expect("write prompt cache");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("run.json"),
br#"{"status":"completed"}"#,
)
.expect("write terminal run");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("events.jsonl"),
b"{}\n",
)
.expect("write terminal event");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("input")
.join("prompt_snapshot.json"),
b"{}",
)
.expect("write terminal run input");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-done")
.join("model-responses")
.join("round-001.json"),
b"{}",
)
.expect("write terminal run model response");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-active")
.join("run.json"),
br#"{"status":"calling_model"}"#,
)
.expect("write active run");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("chats")
.join("workspace")
.join("runs")
.join("run-active")
.join("events.jsonl"),
b"{}\n",
)
.expect("write active event");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("index")
.join("runs")
.join("run-done.json"),
br#"{"status":"completed"}"#,
)
.expect("write terminal run index");
std::fs::write(
root.join("_tauritavern")
.join("agent-workspaces")
.join("index")
.join("runs")
.join("run-active.json"),
br#"{"status":"calling_model"}"#,
)
.expect("write active run index");
let policy = ResolvedDatasetPolicy::tauri_tavern_default();
let manifest = scan_manifest_sync(&root, &policy).expect("scan manifest");
let paths = manifest
.entries
.into_iter()
@ -216,7 +474,91 @@ mod tests {
!paths.contains(&"default-user/user/lan-sync/tt-sync-v2/identity.json".to_string()),
"lan sync state must never be part of the manifest"
);
assert!(
paths.contains(&"_tauritavern/agent-profiles/profiles/writer.json".to_string()),
"agent profiles are part of the TauriTavern default dataset"
);
assert!(
paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/persistent-states/run-1/manifest.json"
.to_string()
),
"agent persistent state is part of the TauriTavern default dataset"
);
assert!(
!paths.contains(&"_tauritavern/prompt-cache/cache.json".to_string()),
"prompt cache is local runtime state"
);
assert!(paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/runs/run-done/events.jsonl".to_string()
));
assert!(
!paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/runs/run-done/input/prompt_snapshot.json"
.to_string()
),
"default Agent continuity sync must not include run input context"
);
assert!(
!paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/runs/run-done/model-responses/round-001.json"
.to_string()
),
"default Agent continuity sync must not include model responses"
);
assert!(
paths.contains(&"_tauritavern/agent-workspaces/index/runs/run-done.json".to_string())
);
assert!(
!paths.contains(
&"_tauritavern/agent-workspaces/chats/workspace/runs/run-active/events.jsonl"
.to_string()
),
"active agent runs must not be included"
);
assert!(
!paths
.contains(&"_tauritavern/agent-workspaces/index/runs/run-active.json".to_string()),
"active agent run index entries must not be included"
);
std::fs::remove_dir_all(&root).expect("remove temp root");
}
#[test]
fn validate_plan_scope_requires_matching_dataset_selection() {
let policy = ResolvedDatasetPolicy::tauri_tavern_default();
let entry = ManifestEntryV2 {
path: SyncPath::new("default-user/chats/chat.jsonl").unwrap(),
size_bytes: 1,
modified_ms: 1,
content_hash: None,
};
let valid_plan = SyncPlan {
plan_id: PlanId("plan".to_string()),
selection: Some(policy.selection().clone()),
transfer: vec![entry.clone()],
delete: Vec::new(),
files_total: 1,
bytes_total: 1,
};
validate_plan_scope(&valid_plan, &policy).expect("matching selection should validate");
let missing_selection = SyncPlan {
selection: None,
..valid_plan.clone()
};
assert!(validate_plan_scope(&missing_selection, &policy).is_err());
let other_selection = DatasetSelection::new(
DATASET_POLICY_VERSION,
vec!["chat.character.history".to_string()],
);
let mismatched_selection = SyncPlan {
selection: Some(other_selection),
..valid_plan
};
assert!(validate_plan_scope(&mismatched_selection, &policy).is_err());
}
}

View file

@ -14,6 +14,7 @@ use ttsync_contract::plan::{PlanId, SyncPlan};
use ttsync_contract::session::SessionToken;
use ttsync_contract::sync::SyncMode;
use ttsync_contract::sync::SyncPhase;
use ttsync_core::dataset::{ResolvedDatasetPolicy, tauri_tavern_default_selection};
use crate::domain::errors::DomainError;
use crate::domain::models::tt_sync::{TtSyncCompletedEvent, TtSyncDirection, TtSyncProgressEvent};
@ -21,7 +22,7 @@ use crate::infrastructure::sync_fs;
use crate::infrastructure::tt_sync::bundle::{
ExactSizeReader, FEATURE_BUNDLE_V1, FEATURE_ZSTD_V1, MAX_BUNDLE_PATH_LEN, read_u32_be,
};
use crate::infrastructure::tt_sync::fs::scan_manifest;
use crate::infrastructure::tt_sync::fs::{scan_manifest_with_policy, validate_plan_scope};
use crate::infrastructure::tt_sync::runtime::TtSyncRuntime;
use crate::infrastructure::tt_sync::transfer;
use crate::infrastructure::tt_sync::v2_api::TtSyncV2Api;
@ -35,7 +36,9 @@ pub async fn pull_from_server(
let identity = runtime.store.load_or_create_identity().await?;
let api = TtSyncV2Api::new(server.base_url.clone(), server.spki_sha256.clone())?;
let features = api.status_features().await.unwrap_or_default();
let status = api.status().await?;
status.ensure_dataset_scope_v1()?;
let features = status.features;
let prefer_bundle = features.iter().any(|f| f == FEATURE_BUNDLE_V1);
let accept_zstd = prefer_bundle && features.iter().any(|f| f == FEATURE_ZSTD_V1);
@ -67,7 +70,11 @@ pub async fn pull_from_server(
current_path: None,
})?;
let target_manifest = scan_manifest(runtime.sync_root.clone()).await?;
let selection = tauri_tavern_default_selection();
let policy = ResolvedDatasetPolicy::from_selection(&selection)
.map_err(|error| DomainError::InvalidData(error.to_string()))?;
let target_manifest =
scan_manifest_with_policy(runtime.sync_root.clone(), policy.clone()).await?;
runtime.emit_progress(TtSyncProgressEvent {
direction: TtSyncDirection::Pull,
@ -80,8 +87,9 @@ pub async fn pull_from_server(
})?;
let plan = api
.pull_plan(&session.session_token, mode, target_manifest)
.pull_plan(&session.session_token, mode, selection, target_manifest)
.await?;
validate_plan_scope(&plan, &policy)?;
let plan_files_total = plan.files_total;
let plan_bytes_total = plan.bytes_total;

View file

@ -11,13 +11,14 @@ use ttsync_contract::peer::DeviceId;
use ttsync_contract::plan::{PlanId, SyncPlan};
use ttsync_contract::session::SessionToken;
use ttsync_contract::sync::{SyncMode, SyncPhase};
use ttsync_core::dataset::{ResolvedDatasetPolicy, tauri_tavern_default_selection};
use crate::domain::errors::DomainError;
use crate::domain::models::tt_sync::{TtSyncCompletedEvent, TtSyncDirection, TtSyncProgressEvent};
use crate::infrastructure::tt_sync::bundle::{
FEATURE_BUNDLE_V1, FEATURE_ZSTD_V1, copy_exact, write_u32_be,
};
use crate::infrastructure::tt_sync::fs::scan_manifest;
use crate::infrastructure::tt_sync::fs::{scan_manifest_with_policy, validate_plan_scope};
use crate::infrastructure::tt_sync::runtime::TtSyncRuntime;
use crate::infrastructure::tt_sync::transfer;
use crate::infrastructure::tt_sync::v2_api::TtSyncV2Api;
@ -31,7 +32,9 @@ pub async fn push_to_server(
let identity = runtime.store.load_or_create_identity().await?;
let api = TtSyncV2Api::new(server.base_url.clone(), server.spki_sha256.clone())?;
let features = api.status_features().await.unwrap_or_default();
let status = api.status().await?;
status.ensure_dataset_scope_v1()?;
let features = status.features;
let prefer_bundle = features.iter().any(|f| f == FEATURE_BUNDLE_V1);
let allow_zstd = prefer_bundle && features.iter().any(|f| f == FEATURE_ZSTD_V1);
@ -63,7 +66,11 @@ pub async fn push_to_server(
current_path: None,
})?;
let source_manifest = scan_manifest(runtime.sync_root.clone()).await?;
let selection = tauri_tavern_default_selection();
let policy = ResolvedDatasetPolicy::from_selection(&selection)
.map_err(|error| DomainError::InvalidData(error.to_string()))?;
let source_manifest =
scan_manifest_with_policy(runtime.sync_root.clone(), policy.clone()).await?;
runtime.emit_progress(TtSyncProgressEvent {
direction: TtSyncDirection::Push,
@ -76,8 +83,9 @@ pub async fn push_to_server(
})?;
let plan = api
.push_plan(&session.session_token, mode, source_manifest)
.push_plan(&session.session_token, mode, selection, source_manifest)
.await?;
validate_plan_scope(&plan, &policy)?;
let plan_files_total = plan.files_total;
let plan_bytes_total = plan.bytes_total;

View file

@ -5,6 +5,9 @@ use serde::Deserialize;
use url::Url;
use ttsync_contract::canonical::CanonicalRequest;
use ttsync_contract::dataset::{
DATASET_POLICY_VERSION, DATASET_SCOPE_FEATURE_V1, DatasetSelection,
};
use ttsync_contract::manifest::ManifestV2;
use ttsync_contract::pair::{PairCompleteRequest, PairCompleteResponse};
use ttsync_contract::path::SyncPath;
@ -73,7 +76,7 @@ impl TtSyncV2Api {
.map_err(|error| DomainError::InternalError(error.to_string()))
}
pub async fn status_features(&self) -> Result<Vec<String>, DomainError> {
pub async fn status(&self) -> Result<StatusResponse, DomainError> {
let url = status_url(&self.base_url)?;
let response = self
@ -95,7 +98,7 @@ impl TtSyncV2Api {
));
}
Ok(status.features)
Ok(status)
}
pub async fn open_session(
@ -150,12 +153,14 @@ impl TtSyncV2Api {
&self,
session_token: &SessionToken,
mode: SyncMode,
selection: DatasetSelection,
target_manifest: ManifestV2,
) -> Result<SyncPlan, DomainError> {
let url = pull_plan_url(&self.base_url)?;
let request = PullPlanRequest {
mode,
selection,
target_manifest,
};
let body = serde_json::to_vec(&request)
@ -182,12 +187,14 @@ impl TtSyncV2Api {
&self,
session_token: &SessionToken,
mode: SyncMode,
selection: DatasetSelection,
source_manifest: ManifestV2,
) -> Result<SyncPlan, DomainError> {
let url = push_plan_url(&self.base_url)?;
let request = PushPlanRequest {
mode,
selection,
source_manifest,
};
let body = serde_json::to_vec(&request)
@ -343,10 +350,41 @@ impl TtSyncV2Api {
}
#[derive(Debug, Deserialize)]
struct StatusResponse {
ok: bool,
pub(crate) struct StatusResponse {
pub(crate) ok: bool,
#[serde(default)]
features: Vec<String>,
pub(crate) features: Vec<String>,
#[serde(default)]
pub(crate) dataset_policy_version: Option<u32>,
}
impl StatusResponse {
pub(crate) fn ensure_dataset_scope_v1(&self) -> Result<(), DomainError> {
if !self
.features
.iter()
.any(|feature| feature == DATASET_SCOPE_FEATURE_V1)
{
return Err(DomainError::InvalidData(
"TT-Sync server does not support DatasetPolicy; upgrade TT-Sync server".to_string(),
));
}
let Some(version) = self.dataset_policy_version else {
return Err(DomainError::InvalidData(
"TT-Sync server did not report dataset policy version".to_string(),
));
};
if version != DATASET_POLICY_VERSION {
return Err(DomainError::InvalidData(format!(
"Unsupported TT-Sync dataset policy version: {}",
version
)));
}
Ok(())
}
}
fn pair_complete_url(base_url: &str, token: &str) -> Result<Url, DomainError> {
@ -451,7 +489,9 @@ fn response_error(status: reqwest::StatusCode, message: String) -> DomainError {
#[cfg(test)]
mod tests {
use super::response_error;
use ttsync_contract::dataset::{DATASET_POLICY_VERSION, DATASET_SCOPE_FEATURE_V1};
use super::{StatusResponse, response_error};
use crate::domain::errors::DomainError;
#[test]
@ -473,6 +513,33 @@ mod tests {
assert!(matches!(error, DomainError::AuthenticationError(_)));
}
#[test]
fn status_requires_dataset_scope_feature() {
let status = StatusResponse {
ok: true,
features: vec![],
dataset_policy_version: Some(DATASET_POLICY_VERSION),
};
assert!(matches!(
status.ensure_dataset_scope_v1(),
Err(DomainError::InvalidData(_))
));
}
#[test]
fn status_accepts_matching_dataset_policy_version() {
let status = StatusResponse {
ok: true,
features: vec![DATASET_SCOPE_FEATURE_V1.to_string()],
dataset_policy_version: Some(DATASET_POLICY_VERSION),
};
status
.ensure_dataset_scope_v1()
.expect("dataset scope feature should be accepted");
}
}
fn now_ms() -> u64 {