④ 资源分类写入补一条 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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user