Compare commits

...

3 Commits

Author SHA1 Message Date
suzmii ca53c30d01 共享记忆同步 #504 关闭口径:迁移归属与复核条件
- decision-log 2026-09-24 条目补充:#504 以 Reviewed/Won't Fix 关闭,跟踪交回迁移侧,复核与重新打开条件见 #504 关闭评论
- pitfalls 同条目处理口径补上 #504 关闭状态与重新打开路径
2026-09-24 12:18:34 +08:00
suzmii 771965ea40 记录 AGC 快照换号重传修复未落地:根因与迁移约束写入共享记忆,文档回退到现行状态
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m2s
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / Frontend tests (pull_request) Successful in 2m18s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m22s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m47s
Project CI / Native shell tests (pull_request) Successful in 6m21s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m8s
- decision-log 改写 2026-09-24 条目:只固化根因与「数据与 IO 往后端挪」必须保留的三条约束,注明客户端索引分桶实现保留在分支 fix/api-timeout(3be8e40bc)且 PR #505 已关闭
- decision-log 补充分层结论:本次改动只碰本地持久化格式与 native-only 诊断视图字段,未涉及客户端与服务端的路由、DTO、对象键与清单结构,即不涉及协议层
- pitfalls 同条目把处理口径改为暂缓落地,验证口径改为按分支状态描述
- 主规范索引描述回退为整份只保存一个账号基线、换号后冷启动全量对比,并指向 issue #504 与 decision-log
- 快照里程碑文档回退索引口径,验收项 13 标为随架构迁移重新定义
2026-09-24 12:05:10 +08:00
suzmii 3be8e40bc2 修复 AGC 换号后整项目快照重传导致的日志刷屏与客户端超时
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m20s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / Backend tests (pull_request) Successful in 4m52s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m0s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / Repository checks (pull_request) Successful in 1m50s
- 项目快照索引升级为 schema v2:按账号分桶保存基线,换号不再让其它账号的基线失效,也不会被其它账号覆盖
- 同步只读取并写回当前登录账号的基线,其它账号的基线原样保留
- 读取时按 JSON 版本判定并迁移 v1 单账号索引到它自己记录的 userId 桶,格式升级不额外触发一次重传
- read_local_project_snapshot_state 增补 baselineCount / baselinePresent,fileCount / syncRevision / syncedAtMs 改为当前登录账号的基线口径
- 新增与改写用例:账号切换后基线互不覆盖、切回旧账号差异为空、v1 索引迁移进对应账号桶
- 同步更新快照主规范、快照里程碑文档、shared-memory decision-log 与 pitfalls
2026-09-24 11:20:39 +08:00
7 changed files with 348 additions and 62 deletions
@@ -9,14 +9,13 @@ pub(crate) struct ProjectSnapshotIndexedFile {
pub(crate) checksum: String,
}
/// 上次成功同步的快照。索引只在本机 AppData 中,不进入用户项目目录
/// 某个账号在某个项目上的上次成功同步基线
///
/// `user_id` 是远端前缀的一部分:换号后旧索引不再代表同一个远端命名空间,
/// 因此读取时按用户身份判等,不一致就当作冷启动重新全量对比
/// 基线只对写入它的账号成立:远端对象键里带着 `userId`,换号后旧基线既不能用来
/// 判断"远端已经有什么",也不能被新账号的同步覆盖掉
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectSnapshotIndex {
pub(crate) schema_version: u32,
pub(crate) struct ProjectSnapshotBaseline {
pub(crate) project_id: String,
pub(crate) user_id: String,
pub(crate) sync_revision: u64,
@@ -29,12 +28,59 @@ pub(crate) struct ProjectSnapshotIndex {
pub(crate) files: BTreeMap<String, ProjectSnapshotIndexedFile>,
}
pub(crate) fn empty_project_snapshot_index(
project_id: &str,
user_id: &str,
) -> ProjectSnapshotIndex {
/// 索引文件:一个项目一份,按账号分桶保存各账号的基线。
///
/// 曾经整份索引只保存一个账号的基线,换号即当作"没有基线",于是每次切号后打开项目
/// 都把整个工程重传一遍(issue #504:2311 个文件、服务端全是 HEAD 命中的白跑请求,
/// 同时把同进程的 IPC 压到其它接口 15s 超时)。分桶之后各账号各自持有基线,切回旧
/// 账号只需要传真正的差异。
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProjectSnapshotIndex {
pub(crate) schema_version: u32,
pub(crate) project_id: String,
#[serde(default)]
pub(crate) baselines: BTreeMap<String, ProjectSnapshotBaseline>,
}
/// v1 索引:整份文件只保存一个账号的基线。只读一次,用于迁移。
///
/// `schemaVersion` 不用反序列化:版本已经在选分支时按 JSON 值判定过。
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LegacyProjectSnapshotIndexV1 {
project_id: String,
user_id: String,
sync_revision: u64,
synced_at_ms: u64,
#[serde(default)]
project_name: Option<String>,
#[serde(default)]
pending_files: Option<u32>,
#[serde(default)]
files: BTreeMap<String, ProjectSnapshotIndexedFile>,
}
impl ProjectSnapshotIndex {
pub(crate) fn baseline_for(&self, user_id: &str) -> Option<&ProjectSnapshotBaseline> {
self.baselines.get(user_id)
}
}
pub(crate) fn empty_project_snapshot_index(project_id: &str) -> ProjectSnapshotIndex {
ProjectSnapshotIndex {
schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION,
project_id: project_id.to_string(),
baselines: BTreeMap::new(),
}
}
/// 某账号尚无基线时的空基线:全量对比会重新上传所有文件,不会漏传。
pub(crate) fn empty_project_snapshot_baseline(
project_id: &str,
user_id: &str,
) -> ProjectSnapshotBaseline {
ProjectSnapshotBaseline {
project_id: project_id.to_string(),
user_id: user_id.to_string(),
sync_revision: 0,
@@ -79,8 +125,8 @@ pub(crate) fn project_snapshot_index_path_at(directory: &Path) -> PathBuf {
directory.join(PROJECT_SNAPSHOT_INDEX_FILE_NAME)
}
/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下
/// 全量对比会重新上传所有文件,不会漏传,也不会因为坏索引中断同步。
/// 读取本地索引。索引不存在、版本不符或内容损坏时返回空索引:这种情况下某账号
/// 视为没有基线,会重新上传该账号的全部文件,不会漏传,也不会因为坏索引中断同步。
pub(crate) fn read_project_snapshot_index(
project_id: &str,
) -> Result<ProjectSnapshotIndex, String> {
@@ -93,7 +139,7 @@ pub(crate) fn read_project_snapshot_index_at(
) -> Result<ProjectSnapshotIndex, String> {
let path = project_snapshot_index_path_at(directory);
if !path.exists() {
return Ok(empty_project_snapshot_index(project_id, ""));
return Ok(empty_project_snapshot_index(project_id));
}
let content = match read_game_creator_private_file_to_string(
&path,
@@ -103,29 +149,119 @@ pub(crate) fn read_project_snapshot_index_at(
Ok(content) => content,
Err(error) => {
app_log!("project_snapshot.index.read.failed: {error}");
return Ok(empty_project_snapshot_index(project_id, ""));
return Ok(empty_project_snapshot_index(project_id));
}
};
match serde_json::from_str::<ProjectSnapshotIndex>(&content) {
Ok(index)
if index.schema_version == PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION
&& index.project_id == project_id =>
{
Ok(index)
}
Ok(_) => Ok(empty_project_snapshot_index(project_id, "")),
let value = match serde_json::from_str::<serde_json::Value>(&content) {
Ok(value) => value,
Err(error) => {
app_log!("project_snapshot.index.parse.failed: {error}");
Ok(empty_project_snapshot_index(project_id, ""))
return Ok(empty_project_snapshot_index(project_id));
}
};
match value
.get("schemaVersion")
.and_then(serde_json::Value::as_u64)
{
Some(version) if version == u64::from(PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION) => {
match serde_json::from_value::<ProjectSnapshotIndex>(value) {
Ok(index) if index.project_id == project_id => Ok(index),
Ok(_) => Ok(empty_project_snapshot_index(project_id)),
Err(error) => {
app_log!("project_snapshot.index.parse.failed: {error}");
Ok(empty_project_snapshot_index(project_id))
}
}
}
Some(PROJECT_SNAPSHOT_INDEX_LEGACY_SCHEMA_VERSION) => {
match serde_json::from_value::<LegacyProjectSnapshotIndexV1>(value) {
Ok(legacy) => Ok(migrate_legacy_project_snapshot_index(project_id, legacy)),
Err(error) => {
app_log!("project_snapshot.index.parse.failed: {error}");
Ok(empty_project_snapshot_index(project_id))
}
}
}
_ => Ok(empty_project_snapshot_index(project_id)),
}
}
pub(crate) fn write_project_snapshot_index(index: &ProjectSnapshotIndex) -> Result<(), String> {
write_project_snapshot_index_at(&project_snapshot_index_directory(&index.project_id)?, index)
/// 把 v1 的单账号索引原样搬进新的账号分桶:升级后不需要因为格式变化再做一次全量重传。
///
/// `userId` 缺失的 v1 索引无法归属到任何账号,只能丢弃;`user_id` 保持原样不 trim
/// 因为它是远端前缀的一部分,不做过任何规范化。
fn migrate_legacy_project_snapshot_index(
project_id: &str,
legacy: LegacyProjectSnapshotIndexV1,
) -> ProjectSnapshotIndex {
let mut index = empty_project_snapshot_index(project_id);
if legacy.project_id != project_id || legacy.user_id.is_empty() {
return index;
}
index.baselines.insert(
legacy.user_id.clone(),
ProjectSnapshotBaseline {
project_id: project_id.to_string(),
user_id: legacy.user_id,
sync_revision: legacy.sync_revision,
synced_at_ms: legacy.synced_at_ms,
project_name: legacy.project_name,
pending_files: legacy.pending_files,
files: legacy.files,
},
);
index
}
pub(crate) fn write_project_snapshot_index_at(
/// 读取指定账号的基线;该账号尚无基线时返回空基线,其它账号的基线不受影响。
pub(crate) fn read_project_snapshot_baseline(
project_id: &str,
user_id: &str,
) -> Result<ProjectSnapshotBaseline, String> {
read_project_snapshot_baseline_at(
&project_snapshot_index_directory(project_id)?,
project_id,
user_id,
)
}
pub(crate) fn read_project_snapshot_baseline_at(
directory: &Path,
project_id: &str,
user_id: &str,
) -> Result<ProjectSnapshotBaseline, String> {
let index = read_project_snapshot_index_at(directory, project_id)?;
Ok(index
.baseline_for(user_id)
.cloned()
.unwrap_or_else(|| empty_project_snapshot_baseline(project_id, user_id)))
}
/// 写回某账号的基线,保留同一项目下其它账号的基线。
///
/// 旧索引损坏时按"其它账号没有基线"继续写入:坏文件本身已经不可用,宁可在下一个
/// 账号同步时重算,也不能让这次同步因为索引写失败而整体作废(那会立刻触发重传)。
pub(crate) fn write_project_snapshot_baseline(
baseline: &ProjectSnapshotBaseline,
) -> Result<(), String> {
write_project_snapshot_baseline_at(
&project_snapshot_index_directory(&baseline.project_id)?,
baseline,
)
}
pub(crate) fn write_project_snapshot_baseline_at(
directory: &Path,
baseline: &ProjectSnapshotBaseline,
) -> Result<(), String> {
let mut index = read_project_snapshot_index_at(directory, &baseline.project_id)?;
index
.baselines
.insert(baseline.user_id.clone(), baseline.clone());
write_project_snapshot_index_at(directory, &index)
}
fn write_project_snapshot_index_at(
directory: &Path,
index: &ProjectSnapshotIndex,
) -> Result<(), String> {
@@ -27,7 +27,10 @@ pub(crate) use transport::*;
/// 本机项目快照索引在 AppData 配置目录下的位置。
pub(crate) const PROJECT_SNAPSHOT_DIRECTORY: &str = "project-snapshots";
const PROJECT_SNAPSHOT_INDEX_FILE_NAME: &str = "index.json";
const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 1;
/// 索引文件版本。2 起按账号分桶保存基线(见 `index.rs`),v1 只保存单账号基线。
const PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION: u32 = 2;
/// 只用于迁移读取的历史索引版本。
const PROJECT_SNAPSHOT_INDEX_LEGACY_SCHEMA_VERSION: u64 = 1;
const PROJECT_SNAPSHOT_INDEX_MAX_BYTES: u64 = 32 * 1024 * 1024;
pub(crate) const PROJECT_SNAPSHOT_MAX_FILE_BYTES: u64 =
@@ -293,12 +296,9 @@ async fn sync_project_snapshot_async(
let manifest = read_existing_manifest_for_project(project_root)?;
let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?;
let previous = read_project_snapshot_index(&project_id)?;
let previous = if previous.user_id == session.user_id {
previous
} else {
empty_project_snapshot_index(&project_id, &session.user_id)
};
// 基线按账号读取:换号不再让别的账号的基线失效,也不会把新账号的空白当成
// 需要整项目重传的理由。
let previous = read_project_snapshot_baseline(&project_id, &session.user_id)?;
let scan = scan_project_snapshot_files(project_root, PROJECT_SNAPSHOT_MAX_FILE_BYTES)?;
// 项目总量上限在本地先判:超限时明确失败,不再逐个文件上传后被服务端整体拒绝。
@@ -362,8 +362,7 @@ async fn sync_project_snapshot_async(
upload_project_snapshot_manifest(&session, &payload)
.await
.map_err(|error| error.message())?;
write_project_snapshot_index(&ProjectSnapshotIndex {
schema_version: PROJECT_SNAPSHOT_INDEX_SCHEMA_VERSION,
write_project_snapshot_baseline(&ProjectSnapshotBaseline {
project_id: project_id.clone(),
user_id: session.user_id.clone(),
sync_revision: next_revision,
@@ -447,7 +446,7 @@ fn project_snapshot_pending_file_count(
}
fn project_snapshot_manifest_needs_sync(
previous: &ProjectSnapshotIndex,
previous: &ProjectSnapshotBaseline,
diff: &ProjectSnapshotDiff,
project_name: &Option<String>,
pending_files: u32,
@@ -516,9 +515,14 @@ pub(crate) struct ProjectSnapshotStateView {
project_id: String,
index_path: String,
index_present: bool,
/// 当前登录账号的基线文件数;没有该账号的基线时为 0。
file_count: usize,
sync_revision: u64,
synced_at_ms: u64,
/// 索引文件里已保存基线的账号数;>1 说明本机在这台机器上用过多个账号。
baseline_count: usize,
/// 当前登录账号在本项目上是否已有基线。没有基线的下一次同步必然全量上传。
baseline_present: bool,
enabled: bool,
}
@@ -547,13 +551,19 @@ pub(crate) fn read_local_project_snapshot_state(
let project_id = validate_project_snapshot_project_id(manifest.project_id.trim())?;
let index_path = project_snapshot_index_path(&project_id)?;
let index = read_project_snapshot_index(&project_id)?;
let user_id = current_platform_session()
.map(|session| session.user_id)
.unwrap_or_default();
let baseline = index.baseline_for(&user_id);
Ok(ProjectSnapshotStateView {
project_id,
index_path: index_path.to_string_lossy().into_owned(),
index_present: index_path.exists(),
file_count: index.files.len(),
sync_revision: index.sync_revision,
synced_at_ms: index.synced_at_ms,
file_count: baseline.map_or(0, |baseline| baseline.files.len()),
sync_revision: baseline.map_or(0, |baseline| baseline.sync_revision),
synced_at_ms: baseline.map_or(0, |baseline| baseline.synced_at_ms),
baseline_count: index.baselines.len(),
baseline_present: baseline.is_some(),
enabled: project_snapshot_sync_enabled(),
})
}
@@ -57,15 +57,19 @@ fn project_snapshot_index_round_trips_and_recovers_from_corruption() {
let root = fixture_root();
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
assert!(read_project_snapshot_index_at(&directory, "project-1")
assert!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("missing index reads as empty")
.files
.is_empty());
.is_empty()
);
let mut index = empty_project_snapshot_index("project-1", "user-1");
index.sync_revision = 3;
index.synced_at_ms = 1_700_000_000_000;
index.files.insert(
let mut baseline = empty_project_snapshot_baseline("project-1", "user-1");
baseline.sync_revision = 3;
baseline.synced_at_ms = 1_700_000_000_000;
baseline.project_name = Some("完整工程".to_string());
baseline.pending_files = Some(0);
baseline.files.insert(
"game/index.html".to_string(),
ProjectSnapshotIndexedFile {
size_bytes: 13,
@@ -73,23 +77,107 @@ fn project_snapshot_index_round_trips_and_recovers_from_corruption() {
checksum: "fnv1a64:0000000000000001".to_string(),
},
);
write_project_snapshot_index_at(&directory, &index).expect("write index");
write_project_snapshot_baseline_at(&directory, &baseline).expect("write baseline");
assert_eq!(
read_project_snapshot_index_at(&directory, "project-1").expect("read index"),
index
read_project_snapshot_baseline_at(&directory, "project-1", "user-1").expect("read index"),
baseline
);
let path = project_snapshot_index_path_at(&directory);
fs::write(&path, b"{ not json").expect("corrupt index");
assert!(
read_project_snapshot_index_at(&directory, "project-1")
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("corrupt index reads as empty")
.files
.is_empty(),
"损坏索引必须退化为空索引,让下一次同步全量重算"
"损坏索引必须退化为空基线,让下一次同步全量重算"
);
}
#[test]
fn project_snapshot_index_keeps_each_account_baseline_after_account_switch() {
let root = fixture_root();
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
let mut first = empty_project_snapshot_baseline("project-1", "user-1");
first.sync_revision = 4;
first.files.insert(
"game/index.html".to_string(),
ProjectSnapshotIndexedFile {
size_bytes: 13,
modified_ms: 34,
checksum: "fnv1a64:0000000000000001".to_string(),
},
);
write_project_snapshot_baseline_at(&directory, &first).expect("write first account baseline");
let mut second = empty_project_snapshot_baseline("project-1", "user-2");
second.sync_revision = 1;
second.files.insert(
"game/other.html".to_string(),
ProjectSnapshotIndexedFile {
size_bytes: 21,
modified_ms: 98,
checksum: "fnv1a64:0000000000000002".to_string(),
},
);
write_project_snapshot_baseline_at(&directory, &second).expect("write second account baseline");
assert_eq!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("first account"),
first,
"另一个账号同步过后,本账号基线必须原样保留"
);
assert_eq!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-2")
.expect("second account"),
second
);
let index = read_project_snapshot_index_at(&directory, "project-1").expect("read index");
assert_eq!(index.baselines.len(), 2);
}
#[test]
fn project_snapshot_account_switch_back_does_not_reupload_the_project() {
let root = fixture_root();
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
let scan = scan_fixture(root.path());
let initial =
compute_project_snapshot_diff(&scan, &BTreeMap::new(), PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
.expect("initial diff");
assert_eq!(initial.uploads.len(), 1);
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
let mut first = empty_project_snapshot_baseline("project-1", "user-1");
first.files = initial.current.clone();
write_project_snapshot_baseline_at(&directory, &first).expect("write first account baseline");
// 换到尚未同步过的账号:没有基线,必然全量上传,这是预期行为。
let mut second = read_project_snapshot_baseline_at(&directory, "project-1", "user-2")
.expect("unknown account reads as empty baseline");
let switched =
compute_project_snapshot_diff(&scan, &second.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
.expect("diff under the other account");
assert_eq!(switched.uploads.len(), 1);
second.files = switched.current;
write_project_snapshot_baseline_at(&directory, &second).expect("write second account baseline");
// 切回原账号:基线仍在,不能再传一遍整个工程(issue #504 的回归点)。
let back = read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("first account baseline survives");
let reverted =
compute_project_snapshot_diff(&scan, &back.files, PROJECT_SNAPSHOT_MAX_SYNC_BYTES)
.expect("diff after switching back");
assert!(
reverted.uploads.is_empty(),
"切回旧账号不能重新整项目重传:{reverted:#?}"
);
assert!(!reverted.has_changes());
}
#[test]
fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
let root = fixture_root();
@@ -711,7 +799,7 @@ fn project_snapshot_manifest_metadata_changes_sync_without_content_changes() {
)
.unwrap();
assert_eq!(initial.uploads[0].size_bytes, 0);
let mut previous = empty_project_snapshot_index("project-1", "user-1");
let mut previous = empty_project_snapshot_baseline("project-1", "user-1");
previous.files = initial.current;
let mut unchanged = compute_project_snapshot_diff(
&scan_fixture(root.path()),
@@ -786,14 +874,45 @@ fn project_snapshot_pending_files_include_every_unsynced_path_once() {
}
#[test]
fn project_snapshot_legacy_index_keeps_completeness_unknown() {
let index: ProjectSnapshotIndex = serde_json::from_value(serde_json::json!({
"schemaVersion": 1, "projectId": "project-1", "userId": "user-1",
"syncRevision": 1, "syncedAtMs": 1, "files": {}
}))
.unwrap();
assert_eq!(index.project_name, None);
assert_eq!(index.pending_files, None);
fn project_snapshot_legacy_index_migrates_into_its_account_bucket() {
let root = fixture_root();
let directory =
project_snapshot_index_directory_at(root.path(), "project-1").expect("index directory");
fs::create_dir_all(&directory).expect("create index directory");
fs::write(
project_snapshot_index_path_at(&directory),
serde_json::json!({
"schemaVersion": 1,
"projectId": "project-1",
"userId": "user-1",
"syncRevision": 1,
"syncedAtMs": 1,
"files": {
"game/index.html": {
"sizeBytes": 13,
"modifiedMs": 34,
"checksum": "fnv1a64:0000000000000001"
}
}
})
.to_string(),
)
.expect("write legacy index");
let baseline = read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("legacy index migrates");
assert_eq!(baseline.sync_revision, 1);
assert_eq!(baseline.files.len(), 1);
// v1 没有完整性声明:迁移后仍然是"完整性未知",不能凭空补成已完成。
assert_eq!(baseline.project_name, None);
assert_eq!(baseline.pending_files, None);
assert!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-2")
.expect("other account reads as empty")
.files
.is_empty(),
"迁移只能归属到 v1 记录的账号,不能送给其它账号"
);
}
/// 真实链路冒烟:客户端差异引擎 → 本地 api-server → 真实 OSS。
@@ -16,7 +16,7 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev`
- 服务端 `POST /api/agc/project-snapshots/files``POST /api/agc/project-snapshots/manifest`:登录态鉴权、参数校验、私有前缀 OSS 写入、HEAD 幂等跳过。
- 目标 bucket 配置:`GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_*`,默认 `agc-dev`
- 契约:`shared-contracts::agc_project_snapshots` 新增请求/响应 DTO 与项目 ID、相对路径、摘要校验函数。
- 客户端增量索引:`<AppData>/project-snapshots/<projectId>/index.json`,按用户身份判等,换号后按冷启动全量重算。
- 客户端增量索引:`<AppData>/project-snapshots/<projectId>/index.json`整份只保存一个账号的基线,同步前按用户身份判等,换号后按冷启动全量重算。已知问题:换号后打开项目会把整个工程重传一遍(issue #504),修复随「数据与 IO 往后端挪」的架构迁移一并收口,不在此里程碑内单独落地。
- 排除口径:快照同步使用 `should_skip_project_snapshot_sync_path`2026-09-22 起)。`.agent` 承载项目身份与 Agent 状态,整目录同步;`.git`、构建与依赖目录、凭据目录、敏感后缀、符号链接与重解析点仍然排除。项目索引、checkpoint、Agent 上下文与 git 检查继续沿用 `should_skip_project_snapshot_path` 的整个 `.agent` 排除口径。
## 不做
@@ -41,6 +41,7 @@ AGC 在项目打开期间按周期把用户项目增量上传到 OSS `agc-dev`
10. 清单写入成功后,上一版清单里不再被引用的对象被回收;上一版清单不可读时整轮不删除任何对象。
11. 单项目超过 2 GiB 时客户端明确失败、服务端按 413 拒绝;超过服务端小时配额或 5 秒最小间隔时返回 429 且带 `Retry-After`
12. 同步期间被改写的文件既不上传也不推进索引,沿用上一轮记录,且不会被误判成删除。
13. (暂缓,不在本里程碑内验收)账号 A 同步后切到 B 再切回 A 时 A 的差异集合为空——该口径随「数据与 IO 往后端挪」的架构迁移重新定义;客户端索引分桶修复未落地(issue #504、PR #505 已关闭,实现在分支 `fix/api-timeout`)。
## 依赖
@@ -9329,3 +9329,13 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}``server-rs/crates/shared-contracts/src/game_distribution.rs``packages/shared/src/contracts/gameDistribution.ts``src/components/game-distribution/gameDistributionGuards.ts`(含新增测试)、`deploy/{nginx,container,env}``scripts/check-game-distribution-media-e2e.mjs``package.json`、平台与运维主规范。
- 边界:SpacetimeDB 表结构与公开契约字段不变(`entryUrl` 仍是 string),只是取值从绝对 URL 变为相对路径;历史版本已冻结的绝对值不改写,admin 页与详情页展示口径不变。线上 dev / release 的 nginx 已按同源路径改动并 reload,`/etc/genarrative/api-server.env` 已删除模板变量;api-server 未重启,新写入要等下次重启。
- 验证:`cargo check -p api-server --tests``cargo test -p api-server game_distribution`27 passed)、`cargo fmt --all --check``npx vitest run src/components/game-distribution`57 passed)、`npm run check:nginx-spa-routes``npm run check:encoding`5060 文件)、`npm run check:doc-index``git diff --check` 全部通过;三份 nginx 模板渲染后 `nginx -t` 语法通过;dev 线上实测 `/games/game_2dcd…4955/``./assets/index-2Ws3zHlS.js` 均 200。
## 2026-09-24 AGC 项目快照「换号后整项目重传」的根因与迁移约束(客户端索引修复未落地)
- 背景:AGC 客户端在项目打开期间把本地工程增量上传到 OSS,判定依据是 `<AppData>/project-snapshots/<projectId>/index.json`。该文件整份只保存一个账号的基线,同步前用 `previous.user_id == session.user_id` 判等,不等就换成空基线;而远端对象键第一段正是 `userId`,于是换号被等价成「本机没有基线」。issue #504 的真机复现(CDP attach dev 客户端 + 点「春卷冲刺」):账号 A 留下的基线与当前账号不匹配 → 一次性重传 2311 个文件,约 3 分钟、2311 次 `POST /api/agc/project-snapshots/files`,其中 2306 次服务端 HEAD 命中纯白跑;同窗口内 `/api/runtime/frontend-config``/api/llm/models``/api/profile/recharge-center` 在服务端 200 且 ≤61ms 的情况下被客户端报 15s 超时(同进程 IPC 被重传压垮),并触发 #490 的「检查失败」钉死。本机 6 个索引文件里有 4 个不同 `userId`09-23 19:47、09-23 20:47、09-24 10:57 三次同形态全量重传。
- 结论(本条目只固化根因,不定架构):客户端基线分桶的实现已完成并验证,但**未合入 master**——这条链路正被「数据与 IO 往后端挪」的架构迁移覆盖,落地在客户端本地索引上会与该迁移形成两套实现。实现保留在分支 `fix/api-timeout`commit `3be8e40bc`),对应 PR #505 已按迁移方向关闭;issue #504 随之以 `Reviewed/Won't Fix` 关闭,跟踪交回迁移侧(复核与重新打开条件写在 #504 的关闭评论里)。迁移完成后若该问题仍在,可直接复用该分支或重开 PR。
- 迁移必须保留的约束(不随实现位置改变):① 基线只能按 `(userId, projectId)` 两元组归属,远端对象键第一段就是 `userId`,换号后旧基线对新账号无效,任何新设计都不能跨账号共用或互相覆盖;② 任何一次同步只要清单/清单写入没有成功,就绝不能推进基线,否则下一轮立刻退化为整项目重发(这条已在 #504 上验证);③ 本机扫描与读文件无法上移,服务端拿不到用户磁盘,`扫描 + 读字节 + 发字节` 必须留在客户端,「差异判定」才是可上移到后端的那一层。
- 分层(回应「是否涉及协议层」):本次改动只碰本地持久化格式(`index.json` schema)与一个 native-only 诊断视图的字段(`read_local_project_snapshot_state``baselineCount` / `baselinePresent`),**没有触碰客户端↔服务端的路由 / DTO / 对象键 / 清单结构**,即不涉及协议层。若差异判定上移到后端,新增的 `manifest/diff` 交互才是协议层变更,需要按规范单独定义。
- 边界:不新增路由、不改服务端、不改公开契约;索引是 AppData 私有文件、不进项目目录;分支上的实现只影响 `project_snapshot/index.rs``mod.rs``tests.rs`,删改范围可控。
- 验证(分支 `fix/api-timeout` 上已通过,非 master 状态):`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell project_snapshot` 26 passed / 0 failed / 1 ignored(含「账号切换后基线互不覆盖」「切回旧账号差异为空」「v1 索引迁移进对应账号桶」)、`cargo fmt --check``npm run check:encoding``npm run check:doc-index``git diff --check`
- 关联:issue #504、issue #490、PR #505closed)、`apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs`
@@ -5947,3 +5947,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**`createChannelConfig()` 从基线 `src-tauri/tauri.conf.json` 读完整 client 窗口对象后展开、只覆盖 `title``readBaseClientWindow()`),渠道配置不得再出现"只写 `title`"的窗口对象。新增守卫:`build-release.test.mjs` 用同语义的 merge patch 复现 Tauri 合并并断言 `label=client` / `decorations=false` / 1280x800 / min 1280x720 且承载 `http:default` 的 capability 必须包含该 label`check-config.mjs` 增补基线 `decorations !== false` 失败关闭。
- **验证**`node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs scripts/cargo-features.test.mjs scripts/release-oss.test.mjs scripts/prepare-macos-codex.test.mjs`60/60)、`node apps/ai-game-creator-shell/scripts/check-config.mjs` 通过;`createChannelConfig('dev', …)` 实测输出含 `label: client``decorations: false`。修复后的安装包尚未重新构建与安装,真机观感与登录链未复核。
- **关联**`apps/ai-game-creator-shell/scripts/build-release.mjs``apps/ai-game-creator-shell/scripts/build-release.test.mjs``apps/ai-game-creator-shell/scripts/check-config.mjs``apps/ai-game-creator-shell/src-tauri/capabilities/main.json``docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md`
## 2026-09-24 AGC 换号后整项目快照重传:后端日志刷屏 + 其它接口 15s 超时
- **现象**:打开 AGC 项目后客户端短时间无响应,`/api/runtime/frontend-config``/api/llm/models``/api/profile/recharge-center` 报「请求超时(15000 ms)」;同时本地 api-server 日志被 `POST /api/agc/project-snapshots/files` 刷屏(全是 200、`latency_ms` 58~61ms、每 60ms 一条)。紧接着项目列表还会被钉成「检查失败」(#490)。
- **原因**:本地快照索引整份只保存一个账号的基线,同步前按 `user_id` 判等,不等就换成空基线 → 换号后打开项目即整项目重传(春卷冲刺 2311 个文件、约 3 分钟)。对象键带内容摘要、服务端 HEAD 命中即 `200 + skipped`,这批请求全是白跑却完全静默,只有服务端日志量能看出来。真正超时的那一层在客户端:同一窗口服务端对三个接口都是 200、≤61ms,而客户端 webview 的 Tauri 定制协议 IPC 在重传压力下大面积失败(`IPC custom protocol failed … TypeError: Failed to fetch`,刷屏时 ≈0.7 次/秒,而空闲时段 847 行日志里只有 59 次;每次失败还写一行 ~330 字节日志),响应回不到 renderer,JS 侧 15s 计时器先到。
- **处理(现行口径)**:暂不落地客户端修复——这条链路归入「数据与 IO 往后端挪」的架构迁移,客户端再单独做基线分桶会与之形成两套实现。按账号分桶的实现在分支 `fix/api-timeout`commit `3be8e40bc`)完成并通过验证,PR #505 已按迁移方向关闭、issue #504 已按 `Reviewed/Won't Fix` 关闭,跟踪交回迁移侧;迁移完成后若该问题仍在,可直接复用该分支或重开 PR。
- **排障口径**:遇到「后端日志刷屏 + 客户端报超时」这种组合,先把两侧对齐到同一时间窗:客户端 `diagnostics/application.log``project_snapshot.sync.*`(看 `uploaded=` 是否等于项目文件数、`skippedRemote=` 是否接近它)对 `logs/api-server/api-server-*.log` 里各 route 的 `http.response`(状态码与 `latency_ms`)。客户端报超时但服务端 200 且毫秒级返回,说明堵在客户端侧,别只盯后端。
- **验证**:分支 `fix/api-timeout``cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell project_snapshot` 26 passed / 0 failed(含「账号切换后基线互不覆盖」「切回旧账号差异为空」);master 状态无该修复,运行态排障仍可用 `read_local_project_snapshot_state` 观察当前账号的 `fileCount` / `syncRevision`
- **关联**issue #504、issue #490、PR #505closed)、`apps/ai-game-creator-shell/src-tauri/src/project_snapshot/index.rs``docs/project-memory/shared-memory/decision-log.md`2026-09-24 条目含迁移必须保留的三条约束)。
@@ -1642,7 +1642,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
- 触发入口为项目生命周期登记、已登记项目的周期定时器及窗口关闭事件(`CloseRequested`)。它们共用同一个进程内同步器,同一项目的同步串行执行,周期触发在已有同步进行时直接让位,不排队堆积。
- 应用退出(`RunEvent::Exit`)不重复发起同步;退出路径只负责在有界预算(15 秒)内等待在途同步收尾,让关窗触发的那一次同步有机会写完索引再退出。超过预算不能声明最后状态已经上传。
- 客户端扫描、差异对比、索引持久化与上传编排都在 Tauri Rust 进程(`src-tauri/src/project_snapshot/`);WebView 只读状态,不参与差异计算。
- 本地索引是增量对比的唯一依据:`<AppData>/project-snapshots/<projectId>/index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。
- 本地索引是增量对比的唯一依据:`<AppData>/project-snapshots/<projectId>/index.json` 保存上次成功同步的相对路径、校验和、字节数和修改时间。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。索引整份只保存一个账号的基线,换号后按冷启动全量对比(已知问题与迁移约束见 issue #504`docs/project-memory/shared-memory/decision-log.md` 2026-09-24 条目)。
- 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 AppData 诊断日志(`project_snapshot.sync.*` 前缀),客户端界面不暴露上传状态、时间线或入口按钮。`read_local_project_snapshot_state``sync_local_project_snapshot` 两条命令仅作为 native-only 的排障与联调入口登记,不在渲染层调用。
- 远端写入经 `api-server`,客户端只持平台登录态 Access Token。两条登录态路由:`POST /api/agc/project-snapshots/files`(单文件,正文为原始字节,元数据走查询串)与 `POST /api/agc/project-snapshots/manifest`(本次同步后的完整清单)。
- 对象键与清单由服务端决定:文件键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/files/{sizeBytes}-{checksumDigest}/{relPath}`,清单键为 `agc/project-snapshots/v2/{channel}/{userId}/{projectId}/manifest.json``channel` 是本部署渠道(`GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL`,缺省沿用 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`),同一 bucket 因此天然按渠道分区,开发与正式部署互不可见对方项目。键里带字节数与摘要,因此"对象已存在且长度一致"可以作为内容一致的判据;路径按原始大小写保留,不走 `put_object` 的低位规范化。`agc` 前缀(含历史无渠道的 `agc/project-snapshots/v1/`)继续是服务端专用私有前缀,通用对象键解析与客户端直传票据都不覆盖它。后台“项目工程”按渠道查询与下载,渠道名非法时失败关闭,历史 v1 对象不再列出。
@@ -1671,6 +1671,7 @@ Direct 回合的所有权属于进程内项目身份锁,不属于当前页面
### 验收标准与证据来源
- 定向 Rust 测试:首次同步全量、仅改一个文件时只产生一个修改项、删除文件只体现在清单、`(size,mtime)` 未变时复用旧摘要、排除规则与上限跳过、同步失败不推进索引、同一项目并发触发串行化。
- 换号与切换:账号 A 同步过项目后切到账号 B 并同步,再切回 A 时差异集合为空(不上传任何文件,见 issue #504);B 首次同步该项目仍按全量上传;索引文件同时保留两个账号的基线,v1 单账号索引迁移进它自己记录的账号桶。
- 服务端测试:越界 `projectId`/相对路径/摘要被拒;相同摘要重复提交走跳过分支;超过单项目上限返回 413;超过用户小时配额返回 429;鉴权缺失返回 401;OSS 未配置返回明确的 5xx 而不是写入空对象。
- 运行时 smoke:AGC 开发态打开项目、观察索引写入与同步日志、关闭工作区窗口后确认关闭触发的那次同步执行;报告为"客户端 diff 已验证 / 服务端已配置环境联调"两层,不合并成一句"已通"。
- 边界:新增日志与错误文案不含 Access Token、AccessKey、绝对路径与项目内容。