④ 资源分类写入补一条 agent.db 审计记录

- update_manifest_asset_classification_at 在 manifest 写成功后追加 recordType=asset.classification.update 的审计,复用既有 append_agent_db_record 惯例(照 assets.rs 的 asset.register / asset.update:manifest 写成功后追加)
- 字段:assetId(沿用 assets.rs 的 assetId)、expectedProjectRevision(本次写入实际校验的 CAS 基准 revision,沿用分类输入契约里的同名参数)、previousCategory / previousTags(变更前值)、category / tags(变更后值)
- 不记 localPath / kind / mediaType / source:本记录针对的是已有 assetId 的分类变更,这些字段并未改变,且能从 manifest 或既有 asset.register 记录追到,多记一份会在改名后产生互相矛盾的审计
- 不用 projectRevision 这个名字:既有 agent.runtime.action_receipt 里 projectRevision 的语义是「动作完成后的 revision」,本条审计追加在 revision 推进之前,沿用同名会指代不一致;审计是持久化数据,故在新记录类型里另起不冲突的名字,不动既有记录格式约定
- 位置放在推进 revision 之前是刻意的:分类已经真实落盘,审计不能因为紧随其后的 revision 推进失败而缺失,否则「改过但查不到」正是这条缺陷;推进失败仍然照旧报错
- 审计追加失败按既有惯例映射为可见错误「资源分类已写入,但审计记录失败:{error}」,不静默吞掉
- project/manifest/classification_tests.rs 补 3 条断言:成功写入恰好一条且前后值正确(含第二次写入的 previous 必须取第一次的落盘值)、四类被拒写入不产生任何审计、审计写失败时错误可见且不留假审计(同时确认分类本身已落盘、不回滚)
This commit is contained in:
2026-09-11 21:18:55 +08:00
parent 4161888219
commit 5fff7a134c
2 changed files with 203 additions and 2 deletions
@@ -1146,7 +1146,7 @@ pub(crate) fn update_manifest_asset_classification_at(
return Err("project-revision-conflict".to_string());
}
let asset = mutate_manifest_at(root, |manifest| {
let (asset, previous_category, previous_tags) = mutate_manifest_at(root, |manifest| {
let Some(asset) = manifest
.assets
.iter_mut()
@@ -1154,10 +1154,29 @@ pub(crate) fn update_manifest_asset_classification_at(
else {
return Err(format!("项目资源不存在:{asset_id}"));
};
let previous_category = asset.category;
let previous_tags = asset.tags.clone();
asset.category = category;
asset.tags = tags;
Ok(asset.clone())
Ok((asset.clone(), previous_category, previous_tags))
})?;
// 审计紧跟在「manifest 已落盘」之后、`advance_agent_runtime_project_revision_locked` 之前:
// 与 `assets.rs` 的 `asset.register` / `asset.update` 同一位置约定(manifest 写成功后追加)。
// 放在推进 revision 之前是刻意的——分类已经真实落盘,审计不能因为紧随其后的 revision 推进
// 失败而缺失,否则「改过但查不到」正是这条缺陷要修的东西;推进失败仍然照旧报错(见下方 map_err)。
append_agent_db_record(
root,
serde_json::json!({
"recordType": "asset.classification.update",
"assetId": asset.id,
"expectedProjectRevision": expected_project_revision,
"previousCategory": previous_category,
"previousTags": previous_tags,
"category": asset.category,
"tags": asset.tags,
}),
)
.map_err(|error| format!("资源分类已写入,但审计记录失败:{error}"))?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?;
Ok(UpdateLocalProjectResourceClassificationResult {
@@ -277,3 +277,185 @@ fn asset_classification_input_rejects_unknown_fields() {
);
assert!(input.is_err());
}
/// 分类写入的 `recordType`。取 `asset.*` 命名族,与 `assets.rs` 的 `asset.register` /
/// `asset.update` 同族;`.update` 与「只改分类与标签、不新增资源登记」的语义一致。
const ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE: &str = "asset.classification.update";
fn classification_audit_records(root: &Path) -> Vec<serde_json::Value> {
let (records, truncated) =
read_agent_db_records_bounded(root, 4 * 1024 * 1024).expect("read agent db records");
assert!(!truncated, "分类测试的 agent.db 不应触达尾窗上限");
records
.into_iter()
.filter(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some(ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE)
})
.collect()
}
/// 分类写入必须留下一条 agent.db 审计,且字段能回答「把哪个资源从哪个分类改成了什么」。
#[test]
fn asset_classification_update_appends_one_agent_db_audit_record() {
let (root, asset_id) = project_with_two_assets("audit-success");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision before")
.revision;
assert!(
classification_audit_records(&root).is_empty(),
"初始化阶段不应存在分类审计"
);
update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
vec!["主舞台".to_string()],
)
.expect("update classification");
let records = classification_audit_records(&root);
assert_eq!(records.len(), 1, "{records:?}");
let record = &records[0];
assert_eq!(record["assetId"], serde_json::json!(asset_id));
assert_eq!(record["previousCategory"], serde_json::json!("character"));
assert_eq!(record["previousTags"], serde_json::json!([]));
assert_eq!(record["category"], serde_json::json!("scene"));
assert_eq!(record["tags"], serde_json::json!(["主舞台"]));
assert_eq!(
record["expectedProjectRevision"],
serde_json::json!(revision_before)
);
// 信封字段由 `append_agent_db_record` 统一追加:新增记录类型不得改动既有信封约定。
assert_eq!(
record["schemaVersion"],
serde_json::json!(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION)
);
assert!(record["updatedAt"].as_u64().is_some_and(|value| value > 0));
// 第二次写入的「变更前值」必须是第一次写入后的落盘值,证明前后值取自 manifest 而不是硬编码。
let revision_after_first = revision_before + 1;
update_manifest_asset_classification_at(
&root,
&project_id,
revision_after_first,
&asset_id,
"document",
vec!["说明".to_string()],
)
.expect("update classification twice");
let records = classification_audit_records(&root);
assert_eq!(records.len(), 2, "{records:?}");
assert_eq!(records[1]["assetId"], serde_json::json!(asset_id));
assert_eq!(records[1]["previousCategory"], serde_json::json!("scene"));
assert_eq!(records[1]["previousTags"], serde_json::json!(["主舞台"]));
assert_eq!(records[1]["category"], serde_json::json!("document"));
assert_eq!(records[1]["tags"], serde_json::json!(["说明"]));
assert_eq!(
records[1]["expectedProjectRevision"],
serde_json::json!(revision_after_first)
);
fs::remove_dir_all(root).ok();
}
/// 被拒的分类写入不得留下审计:审计是「真的改了什么」的记录,不是「尝试过什么」的记录。
#[test]
fn asset_classification_rejections_do_not_append_agent_db_audit_records() {
let (root, asset_id) = project_with_two_assets("audit-rejections");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision")
.revision;
update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"future-category",
Vec::new(),
)
.expect_err("invalid category must fail");
update_manifest_asset_classification_at(
&root,
&project_id,
revision_before + 1,
&asset_id,
"scene",
Vec::new(),
)
.expect_err("stale revision must fail");
update_manifest_asset_classification_at(
&root,
"other-project",
revision_before,
&asset_id,
"scene",
Vec::new(),
)
.expect_err("foreign project identity must fail");
update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
"missing-asset",
"scene",
Vec::new(),
)
.expect_err("missing asset must fail");
assert!(
classification_audit_records(&root).is_empty(),
"被拒的分类写入不得产生审计"
);
fs::remove_dir_all(root).ok();
}
/// 审计写失败必须可见:分类已经落盘的既定行为不变(不静默回滚),但命令要报错,
/// 而且不能留下一条假的审计。
#[test]
fn asset_classification_audit_failure_is_reported_and_never_faked() {
let (root, asset_id) = project_with_two_assets("audit-failure");
let project_id = read_existing_manifest_for_project(&root)
.expect("read manifest")
.project_id;
let revision_before = read_game_creator_agent_runtime_project_revision(&root)
.expect("read revision")
.revision;
fs::write(
root.join(".agent/runtime/test-fail-next-agent-db-record"),
ASSET_CLASSIFICATION_AUDIT_RECORD_TYPE,
)
.expect("write audit failure injection marker");
let error = update_manifest_asset_classification_at(
&root,
&project_id,
revision_before,
&asset_id,
"scene",
vec!["主舞台".to_string()],
)
.expect_err("an audit append failure must surface to the caller");
assert!(error.contains("审计记录失败"), "unexpected error: {error}");
assert!(classification_audit_records(&root).is_empty());
let persisted = read_existing_manifest_for_project(&root).expect("read manifest after failure");
let asset = persisted
.assets
.iter()
.find(|asset| asset.id == asset_id)
.expect("persisted asset");
assert_eq!(asset.category, GameCreationAppAssetCategory::Scene);
assert_eq!(asset.tags, vec!["主舞台"]);
fs::remove_dir_all(root).ok();
}