修复 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
This commit is contained in:
2026-09-24 11:20:39 +08:00
parent 87e52860a7
commit 3be8e40bc2
7 changed files with 349 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")
.expect("missing index reads as empty")
.files
.is_empty());
assert!(
read_project_snapshot_baseline_at(&directory, "project-1", "user-1")
.expect("missing index reads as empty")
.files
.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`2026-09-24 起为 schema v2,按账号分桶保存各账号基线;换号只让该账号自己没有基线(首次同步该项目才全量),切回旧账号不再重传。v1 单账号索引按它记录的 `userId` 迁移
- 排除口径:快照同步使用 `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 的差异集合为空(不重传);B 首次同步该项目仍全量上传;索引在换号后同时保留两个账号的基线。
## 依赖
@@ -9329,3 +9329,14 @@ 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 三次同形态全量重传。
- 决策:索引文件升级为 schema v2,结构改为 `baselines: { userId → 该账号基线 }`;同步只读当前登录账号的桶,写回也只替换自己那一个桶,其它账号的基线原样保留。基线字段与 v1 一致(`syncRevision` / `syncedAtMs` / `projectName` / `pendingFiles` / `files`)。某账号在本机首次同步该项目仍然全量上传,这是必要行为而不是漏洞。
- 决策(迁移):读取时按 JSON 值判定版本,v1 文件按其记录的 `userId` 迁移进对应桶(`userId` 为空或 `projectId` 不符时丢弃),格式升级本身不产生额外重传;v1 缺少的 `projectName` / `pendingFiles` 迁移后仍表示「完整性未知」,不伪造已完成。文件保持 v1 形态直到下一次成功同步写回 v2。
- 决策(诊断):`read_local_project_snapshot_state` 增补 `baselineCount``baselinePresent``fileCount` / `syncRevision` / `syncedAtMs` 改为当前登录账号的基线口径——此前这三项会把另一个账号的基线报成本账号的。
- 影响面:`apps/ai-game-creator-shell/src-tauri/src/project_snapshot/{index.rs,mod.rs,tests.rs}``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/project-memory/plans/【里程碑】AGC项目定时快照上传-2026-09-17.md`、本文件与 pitfalls。
- 边界:不新增路由、不改服务端、不改公开契约;远端对象键与清单结构不变;索引仍是 AppData 私有文件、不进项目目录;旧客户端读到 v2 文件会判「版本不符」→ 该账号一次全量,属可接受回退代价。
- 未覆盖:一次「项目 + 新账号」的新组合仍会把项目全量发给服务端(服务端逐个 HEAD 跳过);要彻底消掉这批白跑请求,需要客户端可读的远端清单或批量存在性探测,另开事项。
- 验证:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot`(含「账号切换后基线互不覆盖」「切回旧账号差异为空」「v1 索引迁移进对应账号桶」三条新增/改写用例)、`cargo fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check``npm run check:encoding``git diff --check`
@@ -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 计时器先到。
- **处理(现行口径)**:索引升级为按账号分桶(schema v2,见 decision-log 2026-09-24「AGC 项目快照索引按账号分桶」),换号只让该账号自己没有基线,切回旧账号不再重传。
- **排障口径**:遇到「后端日志刷屏 + 客户端报超时」这种组合,先把两侧对齐到同一时间窗:客户端 `diagnostics/application.log``project_snapshot.sync.*`(看 `uploaded=` 是否等于项目文件数、`skippedRemote=` 是否接近它)对 `logs/api-server/api-server-*.log` 里各 route 的 `http.response`(状态码与 `latency_ms`)。客户端报超时但服务端 200 且毫秒级返回,说明堵在客户端侧,别只盯后端。
- **验证**`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml project_snapshot` 里「账号切换后基线互不覆盖」「切回旧账号差异为空」两条用例;运行态可用 `read_local_project_snapshot_state``baselineCount` / `baselinePresent`(换号前后各持自己的基线)。
- **关联**issue #504、issue #490`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`schema v2)**按账号分桶**保存各账号上次成功同步的相对路径、校验和、字节数和修改时间。远端对象键第一段就是 `userId`,所以基线只对写入它的账号成立——换号不会再让别的账号的基线失效,也不会被别的账号覆盖;只有该账号在本机确实没有基线(首次同步该项目)时才全量上传。v1 的单账号索引在读取时按它记录的 `userId` 迁移进对应桶,格式升级本身不额外触发一次重传。项目根使用现有 manifest 的稳定 `project_id` 作为远端身份,路径不再作为身份。
- 可观测性按产品口径收敛到本机日志:同步结果、失败分类、延后与跳过计数只写入 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、绝对路径与项目内容。