项目快照整目录上传 .agent,保留项目身份与 Agent 历史
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m16s
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Successful in 1m17s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m47s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m3s
Project CI / Backend tests (push) Successful in 5m59s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m6s
Project CI / Native shell tests (push) Successful in 7m23s
Project CI / Frontend tests (push) Successful in 3m48s
Project CI / Repository checks (push) Successful in 3m37s
Project CI / AI game creator shell web tests (push) Successful in 3m40s

- 新增 should_skip_project_snapshot_sync_path:快照同步放行 .agent 组件,并放行其中 .db / .db-wal / .db-shm 的 Agent 状态数据库
- 组件与后缀排除表提取为常量;should_skip_project_snapshot_path 行为不变,项目索引、checkpoint、Agent 上下文与 git 检查继续排除整个 .agent
- project_snapshot 扫描入口切到新口径,.agent 的 manifest、agent.db、会话、日志、checkpoint、workbench 与 project.lock 全部参与同步
- 新增整目录扫描用例与策略用例,覆盖 .agent 内凭据、.env、版本库与构建目录仍被排除
- 主规范更新上传内容边界与后台归档范围:工程 ZIP 可用于还原项目身份与 AGC 对话历史
- 里程碑、实施计划与共享记忆决策记录同步 .agent 全量口径、真实上传与后台 ZIP 的运行时验证证据,以及 v1/v2 键布局边界
This commit is contained in:
kdletters
2026-09-22 16:38:39 +08:00
parent fdc14404b2
commit bba4f0eb24
8 changed files with 232 additions and 75 deletions
@@ -348,79 +348,113 @@ pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool {
|| should_skip_project_snapshot_path(relative_path)
}
/// 项目索引、checkpoint、Agent 上下文与 git 检查共用的排除口径:`.agent` 是这些结果的
/// 本机控制面,不参与其中。
pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
project_snapshot_path_is_excluded(relative_path, false)
}
/**
* 项目快照同步(上传)的排除口径。
*
* 与 `should_skip_project_snapshot_path` 是同一份组件与后缀规则,唯一区别是 `.agent`:
* 它承载项目身份与 Agent 状态(`manifest.json`、`agent.db`、会话、运行日志、checkpoint、
* workbench、`project.lock`),必须整目录随快照同步,因此不再把 `.agent` 组件本身当作
* 排除项,并放行其中的 Agent 状态数据库(`.db` / `.db-wal` / `.db-shm`)。
*
* 其余排除项在 `.agent` 内同样生效:版本库、依赖与构建目录、凭据目录、敏感后缀、
* `.env*` 与凭据类文件名一律不参与同步;符号链接与重解析点在扫描阶段单独跳过。
*/
pub(crate) fn should_skip_project_snapshot_sync_path(relative_path: &str) -> bool {
project_snapshot_path_is_excluded(relative_path, true)
}
/// 任意层级出现即排除的目录组件。`.agent` 只有项目快照同步会放行。
const PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS: &[&str] = &[
".agent",
".git",
".hg",
".svn",
".ssh",
".aws",
".azure",
".gnupg",
".kube",
".docker",
".gcloud",
".terraform",
".password-store",
".secrets",
"secrets",
"credentials",
"node_modules",
"target",
"dist",
"build",
".next",
"coverage",
".cache",
];
/// 凭据、密钥与数据库转储类文件名后缀。
const PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES: &[&str] = &[
".pem",
".key",
".p12",
".pfx",
".ppk",
".jks",
".keystore",
".kdbx",
".db",
".db-wal",
".db-shm",
".sqlite",
".sqlite-wal",
".sqlite-shm",
".sqlite3",
".sqlite3-wal",
".sqlite3-shm",
".sql",
".sql.gz",
".sql.bz2",
".sql.xz",
".dump",
".dump.gz",
".dmp",
".bak",
".mdb",
".accdb",
".rdb",
".bson",
".pgdump",
".tfstate",
".tfstate.backup",
];
/// `.agent` 内的 Agent 状态数据库(`agent.db` 及其 WAL / SHM 旁文件)属于项目状态,
/// 随快照同步;其它数据库与转储后缀仍然排除。
const PROJECT_AGENT_STATE_DATABASE_SUFFIXES: &[&str] = &[".db", ".db-wal", ".db-shm"];
fn project_snapshot_path_is_excluded(relative_path: &str, include_agent_state: bool) -> bool {
let components = relative_path
.split('/')
.filter(|component| !component.is_empty())
.map(str::to_ascii_lowercase)
.collect::<Vec<_>>();
if components.iter().any(|component| {
matches!(
component.as_str(),
".agent"
| ".git"
| ".hg"
| ".svn"
| ".ssh"
| ".aws"
| ".azure"
| ".gnupg"
| ".kube"
| ".docker"
| ".gcloud"
| ".terraform"
| ".password-store"
| ".secrets"
| "secrets"
| "credentials"
| "node_modules"
| "target"
| "dist"
| "build"
| ".next"
| "coverage"
| ".cache"
)
PROJECT_SNAPSHOT_EXCLUDED_COMPONENTS.contains(&component.as_str())
&& !(include_agent_state && component == ".agent")
}) {
return true;
}
let Some(file_name) = components.last() else {
return true;
};
let sensitive_suffixes = [
".pem",
".key",
".p12",
".pfx",
".ppk",
".jks",
".keystore",
".kdbx",
".db",
".db-wal",
".db-shm",
".sqlite",
".sqlite-wal",
".sqlite-shm",
".sqlite3",
".sqlite3-wal",
".sqlite3-shm",
".sql",
".sql.gz",
".sql.bz2",
".sql.xz",
".dump",
".dump.gz",
".dmp",
".bak",
".mdb",
".accdb",
".rdb",
".bson",
".pgdump",
".tfstate",
".tfstate.backup",
];
let agent_state_database = include_agent_state
&& components
.first()
.is_some_and(|first| first.as_str() == ".agent");
let structured_secret_suffixes = [".json", ".txt", ".toml", ".yaml", ".yml"];
file_name == ".env"
|| file_name.starts_with(".env.")
@@ -471,9 +505,10 @@ pub(crate) fn should_skip_project_snapshot_path(relative_path: &str) -> bool {
|| file_name.starts_with("id_ecdsa")
|| file_name.starts_with("id_ed25519")
|| file_name.starts_with("id_xmss")
|| sensitive_suffixes
.iter()
.any(|suffix| file_name.ends_with(suffix))
|| PROJECT_SNAPSHOT_EXCLUDED_SUFFIXES.iter().any(|suffix| {
file_name.ends_with(suffix)
&& !(agent_state_database && PROJECT_AGENT_STATE_DATABASE_SUFFIXES.contains(suffix))
})
|| ((file_name.contains("cookie") || file_name.contains("credential"))
&& structured_secret_suffixes
.iter()
@@ -23,9 +23,10 @@ pub(crate) struct ProjectSnapshotScanResult {
pub(crate) skipped: Vec<ProjectSnapshotSkippedPath>,
}
/// 扫描项目目录,复用 checkpoint / 项目索引同一份排除口径
/// `.agent`、版本控制目录、依赖与构建产物目录、凭据目录、符号链接与重解析点
/// 都不参与同步,超出单文件上限的文件进入跳过清单而不是静默丢弃。
/// 扫描项目目录,排除口径见 `should_skip_project_snapshot_sync_path`
/// `.agent` 是项目身份与 Agent 状态的权威位置,整目录参与同步;版本控制目录、
/// 依赖与构建产物目录、凭据目录、符号链接与重解析点都不参与同步,超出单文件
/// 上限的文件进入跳过清单而不是静默丢弃。
pub(crate) fn scan_project_snapshot_files(
root: &Path,
max_file_bytes: u64,
@@ -52,7 +53,7 @@ pub(crate) fn scan_project_snapshot_files(
let Ok(relative_path) = relative_project_path(root, &path) else {
continue;
};
if should_skip_project_snapshot_path(&relative_path) {
if should_skip_project_snapshot_sync_path(&relative_path) {
continue;
}
let metadata = match fs::symlink_metadata(&path) {
@@ -95,8 +95,6 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
let root = fixture_root();
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
write_fixture_file(root.path(), "assets/manifest.json", b"{}");
write_fixture_file(root.path(), ".agent/runtime/state.json", b"{}");
write_fixture_file(root.path(), ".agent/manifest.json", b"{}");
write_fixture_file(root.path(), "node_modules/pkg/index.js", b"export {};");
write_fixture_file(root.path(), "game/dist/bundle.js", b"bundle");
write_fixture_file(root.path(), "secrets/key.pem", b"private-key");
@@ -111,7 +109,7 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
assert_eq!(
scanned,
vec!["assets/manifest.json".to_string()],
".agent、node_modules、dist 与凭据目录里的文件不能进入候选集合"
"node_modules、dist 与凭据目录里的文件不能进入候选集合"
);
let skipped = scan
.skipped
@@ -125,6 +123,111 @@ fn project_snapshot_scan_skips_excluded_paths_and_oversized_files() {
);
}
#[test]
fn project_snapshot_scan_uploads_whole_agent_directory() {
let root = fixture_root();
write_fixture_file(root.path(), "game/index.html", b"<html></html>");
write_fixture_file(root.path(), ".agent/manifest.json", b"{}");
write_fixture_file(root.path(), ".agent/agent.db", b"sqlite");
write_fixture_file(root.path(), ".agent/agent.db-wal", b"wal");
write_fixture_file(root.path(), ".agent/project.lock", b"{}");
write_fixture_file(root.path(), ".agent/.manifest.json.lock", b"");
write_fixture_file(root.path(), ".agent/conversations/project.jsonl", b"{}\n");
write_fixture_file(root.path(), ".agent/runtime/events/art.jsonl", b"{}\n");
write_fixture_file(
root.path(),
".agent/runtime/command-env/home/.config.json",
b"{}",
);
write_fixture_file(root.path(), ".agent/logs/command.log", b"log");
write_fixture_file(
root.path(),
".agent/checkpoints/0001/manifest.json",
b"{\"files\":[]}",
);
write_fixture_file(
root.path(),
".agent/workbench/resource-layouts/art.json",
b"{}",
);
let scan = scan_fixture(root.path());
let scanned = scan
.files
.iter()
.map(|file| file.relative_path.clone())
.collect::<Vec<_>>();
assert_eq!(
scanned,
vec![
".agent/.manifest.json.lock".to_string(),
".agent/agent.db".to_string(),
".agent/agent.db-wal".to_string(),
".agent/checkpoints/0001/manifest.json".to_string(),
".agent/conversations/project.jsonl".to_string(),
".agent/logs/command.log".to_string(),
".agent/manifest.json".to_string(),
".agent/project.lock".to_string(),
".agent/runtime/command-env/home/.config.json".to_string(),
".agent/runtime/events/art.jsonl".to_string(),
".agent/workbench/resource-layouts/art.json".to_string(),
"game/index.html".to_string(),
],
"`.agent` 是项目身份与 Agent 状态的权威位置,必须整目录参与同步"
);
assert!(
scan.skipped.is_empty(),
"`.agent` 内的普通文件既不跳过也不延后"
);
}
#[test]
fn project_snapshot_sync_policy_keeps_agent_state_and_still_blocks_credentials() {
for relative_path in [
".agent/manifest.json",
".agent/agent.db",
".agent/agent.db-wal",
".agent/agent.db-shm",
".agent/project.lock",
".agent/runtime/events/art.jsonl",
".agent/runtime/locks/append/01.lock",
".agent/checkpoints/0001/manifest.json",
".agent/conversations/project.jsonl",
".agent/workbench/resource-layouts/art.json",
".AGENT/manifest.json",
] {
assert!(
!should_skip_project_snapshot_sync_path(relative_path),
"`.agent` 状态必须参与同步:{relative_path}"
);
}
for relative_path in [
".agent/credentials/platform.json",
".agent/.ssh/id_rsa",
".agent/certs/server.pem",
".agent/node_modules/pkg/index.js",
".agent/runtime/command-env/home/.env",
".agent/runtime/command-env/home/.npmrc",
".agent/backup/game.sql",
".git/config",
"node_modules/pkg/index.js",
"game/dist/bundle.js",
"secrets/key.pem",
"",
] {
assert!(
should_skip_project_snapshot_sync_path(relative_path),
"凭据、版本库与构建产物仍然排除:{relative_path}"
);
}
// 项目索引、checkpoint 与 Agent 上下文继续排除整个 `.agent`,本变更只放开快照同步。
assert!(should_skip_project_snapshot_path(".agent/manifest.json"));
assert!(should_skip_project_index_path(".agent/manifest.json"));
assert!(should_skip_project_index_path(".agent/agent.db"));
}
#[test]
fn project_snapshot_diff_reuses_metadata_and_reports_a_single_modification() {
let root = fixture_root();