WIP: AGC 资源画布与替换改造 V3.0 #316
@@ -4,6 +4,7 @@ use similar::TextDiff;
|
||||
use std::io::{Seek, SeekFrom};
|
||||
|
||||
mod agent_db;
|
||||
mod asset_usage;
|
||||
mod checkpoint;
|
||||
mod conversation;
|
||||
mod export;
|
||||
@@ -17,6 +18,7 @@ mod resource_layout;
|
||||
mod verification;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
pub(crate) use asset_usage::*;
|
||||
pub(crate) use checkpoint::*;
|
||||
pub(crate) use conversation::*;
|
||||
pub(crate) use export::*;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,400 @@
|
||||
use super::*;
|
||||
|
||||
/// 建一个最小项目目录,并在 `assets/` 下写入一个真实素材文件。
|
||||
fn asset_usage_project_fixture() -> PathBuf {
|
||||
let root = unique_project_path();
|
||||
fs::create_dir_all(root.join("assets")).expect("create asset usage fixture assets dir");
|
||||
root
|
||||
}
|
||||
|
||||
fn write_asset_usage_asset(root: &Path, relative_path: &str, contents: &[u8]) {
|
||||
let path = root.join(relative_path);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create asset usage fixture asset parent");
|
||||
}
|
||||
fs::write(&path, contents).expect("write asset usage fixture asset");
|
||||
}
|
||||
|
||||
fn asset_usage_reference(
|
||||
slot_id: Option<&str>,
|
||||
resource_id: Option<&str>,
|
||||
source_path: Option<&str>,
|
||||
) -> SubmitLocalProjectAssetUsageReference {
|
||||
SubmitLocalProjectAssetUsageReference {
|
||||
slot_id: slot_id.map(str::to_string),
|
||||
slot_alias: None,
|
||||
resource_id: resource_id.map(str::to_string),
|
||||
source_path: source_path.map(str::to_string),
|
||||
load_site: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn asset_usage_submit(
|
||||
project_revision: u64,
|
||||
references: Vec<SubmitLocalProjectAssetUsageReference>,
|
||||
) -> SubmitLocalProjectAssetUsageInput {
|
||||
SubmitLocalProjectAssetUsageInput {
|
||||
project_revision,
|
||||
references,
|
||||
}
|
||||
}
|
||||
|
||||
fn asset_usage_diagnostic_codes(usage: &LocalProjectAssetUsage) -> Vec<&str> {
|
||||
usage
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|diagnostic| diagnostic.code.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 已登记资源:只保留身份引用,不复制路径,且能给出槽位绑定。
|
||||
#[test]
|
||||
fn asset_usage_registers_registered_slot_as_sparse_reference() {
|
||||
let root = asset_usage_project_fixture();
|
||||
write_asset_usage_asset(&root, "assets/hero.png", b"hero-bytes");
|
||||
let assets = vec![LocalProjectAssetFacts {
|
||||
asset_id: "hero-image".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
}];
|
||||
|
||||
let mut reference = asset_usage_reference(Some("hero"), Some("hero-image"), None);
|
||||
reference.load_site = Some("game/game.js:preload".to_string());
|
||||
let usage = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&assets,
|
||||
&asset_usage_submit(7, vec![reference]),
|
||||
)
|
||||
.expect("register asset usage");
|
||||
|
||||
assert_eq!(usage.schema_version, ASSET_USAGE_SCHEMA_VERSION);
|
||||
assert_eq!(usage.project_id, "project-1");
|
||||
assert_eq!(usage.project_revision, 7);
|
||||
assert_eq!(usage.references.len(), 1);
|
||||
let entry = &usage.references[0];
|
||||
assert_eq!(entry.slot_id.as_deref(), Some("hero"));
|
||||
assert_eq!(entry.resource_id.as_deref(), Some("hero-image"));
|
||||
assert_eq!(entry.attribution, ASSET_USAGE_ATTRIBUTION_SLOT);
|
||||
assert_eq!(entry.load_site, "game/game.js:preload");
|
||||
// 关键:已登记资源不复制 manifest 已有的路径/哈希,避免第二份资源清单。
|
||||
assert_eq!(entry.source_path, None);
|
||||
assert!(usage.diagnostics.is_empty());
|
||||
assert_eq!(
|
||||
usage.slot_bindings(),
|
||||
vec![("hero".to_string(), "hero-image".to_string())]
|
||||
);
|
||||
assert_eq!(
|
||||
usage.slots_by_resource_id(),
|
||||
vec![(
|
||||
"hero-image".to_string(),
|
||||
vec!["hero".to_string()]
|
||||
)]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 引用了 manifest 里不存在的资源 ID:产出诊断、保留身份、降级为非槽位绑定。
|
||||
#[test]
|
||||
fn asset_usage_reports_unknown_resource_id_without_blocking() {
|
||||
let root = asset_usage_project_fixture();
|
||||
let usage = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&[],
|
||||
&asset_usage_submit(
|
||||
1,
|
||||
vec![asset_usage_reference(Some("hero"), Some("ghost"), None)],
|
||||
),
|
||||
)
|
||||
.expect("register unknown resource reference");
|
||||
|
||||
assert_eq!(
|
||||
asset_usage_diagnostic_codes(&usage),
|
||||
vec![asset_usage_unknown_resource_id_code()]
|
||||
);
|
||||
assert_eq!(
|
||||
usage.references[0].attribution,
|
||||
ASSET_USAGE_ATTRIBUTION_UNREGISTERED
|
||||
);
|
||||
assert_eq!(usage.references[0].resource_id.as_deref(), Some("ghost"));
|
||||
// 未登记引用不产生替换绑定。
|
||||
assert!(usage.slot_bindings().is_empty());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 未登记素材:带路径、产出诊断、路径落进 sourcePath 作为待登记线索。
|
||||
#[test]
|
||||
fn asset_usage_reports_unregistered_asset_with_source_path() {
|
||||
let root = asset_usage_project_fixture();
|
||||
write_asset_usage_asset(&root, "assets/unregistered.png", b"raw-bytes");
|
||||
let usage = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&[],
|
||||
&asset_usage_submit(
|
||||
2,
|
||||
vec![asset_usage_reference(
|
||||
Some("banner"),
|
||||
None,
|
||||
Some("assets/unregistered.png"),
|
||||
)],
|
||||
),
|
||||
)
|
||||
.expect("register unregistered reference");
|
||||
|
||||
assert_eq!(
|
||||
asset_usage_diagnostic_codes(&usage),
|
||||
vec![asset_usage_unregistered_asset_code()]
|
||||
);
|
||||
let entry = &usage.references[0];
|
||||
assert_eq!(entry.resource_id, None);
|
||||
assert_eq!(entry.source_path.as_deref(), Some("assets/unregistered.png"));
|
||||
assert_eq!(entry.attribution, ASSET_USAGE_ATTRIBUTION_UNREGISTERED);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 同一个槽位注册多次:保留首次,产出 duplicate-slot 诊断。
|
||||
#[test]
|
||||
fn asset_usage_keeps_first_registration_for_duplicate_slot() {
|
||||
let root = asset_usage_project_fixture();
|
||||
let assets = vec![
|
||||
LocalProjectAssetFacts {
|
||||
asset_id: "first".to_string(),
|
||||
local_path: "assets/first.png".to_string(),
|
||||
},
|
||||
LocalProjectAssetFacts {
|
||||
asset_id: "second".to_string(),
|
||||
local_path: "assets/second.png".to_string(),
|
||||
},
|
||||
];
|
||||
write_asset_usage_asset(&root, "assets/first.png", b"first");
|
||||
write_asset_usage_asset(&root, "assets/second.png", b"second");
|
||||
|
||||
let usage = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&assets,
|
||||
&asset_usage_submit(
|
||||
1,
|
||||
vec![
|
||||
asset_usage_reference(Some("hero"), Some("first"), None),
|
||||
asset_usage_reference(Some("hero"), Some("second"), None),
|
||||
],
|
||||
),
|
||||
)
|
||||
.expect("register duplicate slot");
|
||||
|
||||
assert_eq!(usage.references.len(), 1);
|
||||
assert_eq!(usage.references[0].resource_id.as_deref(), Some("first"));
|
||||
assert_eq!(
|
||||
asset_usage_diagnostic_codes(&usage),
|
||||
vec![asset_usage_duplicate_slot_code()]
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 引用的文件不存在:产出 missing-file 诊断,但仍然登记,不阻断。
|
||||
#[test]
|
||||
fn asset_usage_reports_missing_file_reference() {
|
||||
let root = asset_usage_project_fixture();
|
||||
let usage = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&[],
|
||||
&asset_usage_submit(
|
||||
3,
|
||||
vec![asset_usage_reference(
|
||||
Some("ghost-slot"),
|
||||
None,
|
||||
Some("assets/missing.png"),
|
||||
)],
|
||||
),
|
||||
)
|
||||
.expect("register missing file reference");
|
||||
|
||||
// 未登记素材 + 文件不存在是两条互补诊断,按 code 排序输出。
|
||||
assert_eq!(
|
||||
asset_usage_diagnostic_codes(&usage),
|
||||
vec![
|
||||
asset_usage_missing_file_code(),
|
||||
asset_usage_unregistered_asset_code()
|
||||
]
|
||||
);
|
||||
assert_eq!(usage.references.len(), 1);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 没有槽位的引用:记 unattributed,不产生替换绑定,不产出诊断。
|
||||
#[test]
|
||||
fn asset_usage_records_unattributed_reference_without_diagnostic() {
|
||||
let root = asset_usage_project_fixture();
|
||||
let assets = vec![LocalProjectAssetFacts {
|
||||
asset_id: "style-doc".to_string(),
|
||||
local_path: "memory/style.md".to_string(),
|
||||
}];
|
||||
write_asset_usage_asset(&root, "memory/style.md", b"# style");
|
||||
|
||||
let usage = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&assets,
|
||||
&asset_usage_submit(
|
||||
4,
|
||||
vec![asset_usage_reference(None, Some("style-doc"), None)],
|
||||
),
|
||||
)
|
||||
.expect("register unattributed reference");
|
||||
|
||||
assert!(usage.diagnostics.is_empty());
|
||||
assert_eq!(
|
||||
usage.references[0].attribution,
|
||||
ASSET_USAGE_ATTRIBUTION_UNATTRIBUTED
|
||||
);
|
||||
assert!(usage.slot_bindings().is_empty());
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 幂等重放:同一 revision 重复登记结果一致。
|
||||
#[test]
|
||||
fn asset_usage_replay_at_same_revision_is_idempotent() {
|
||||
let root = asset_usage_project_fixture();
|
||||
let assets = vec![LocalProjectAssetFacts {
|
||||
asset_id: "hero-image".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
}];
|
||||
write_asset_usage_asset(&root, "assets/hero.png", b"hero");
|
||||
|
||||
let input = asset_usage_submit(
|
||||
5,
|
||||
vec![asset_usage_reference(Some("hero"), Some("hero-image"), None)],
|
||||
);
|
||||
let first = submit_local_project_asset_usage_at(&root, "project-1", &assets, &input)
|
||||
.expect("first asset usage submission");
|
||||
let second = submit_local_project_asset_usage_at(&root, "project-1", &assets, &input)
|
||||
.expect("replayed asset usage submission");
|
||||
|
||||
assert_eq!(first.references, second.references);
|
||||
assert_eq!(first.diagnostics, second.diagnostics);
|
||||
assert_eq!(first.project_revision, second.project_revision);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// revision 回退被拒绝:清单只允许随项目 revision 前进。
|
||||
#[test]
|
||||
fn asset_usage_rejects_revision_regression() {
|
||||
let root = asset_usage_project_fixture();
|
||||
submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&[],
|
||||
&asset_usage_submit(
|
||||
9,
|
||||
vec![asset_usage_reference(
|
||||
Some("hero"),
|
||||
None,
|
||||
Some("assets/missing.png"),
|
||||
)],
|
||||
),
|
||||
)
|
||||
.expect("register forward revision");
|
||||
|
||||
let error = submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&[],
|
||||
&asset_usage_submit(8, vec![]),
|
||||
)
|
||||
.expect_err("regressed revision must be rejected");
|
||||
assert!(error.contains("小于已登记 revision"), "unexpected: {error}");
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// projectId 不匹配的清单按"尚未登记"处理,不串项目。
|
||||
#[test]
|
||||
fn asset_usage_read_ignores_other_project_identity() {
|
||||
let root = asset_usage_project_fixture();
|
||||
submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&[],
|
||||
&asset_usage_submit(
|
||||
1,
|
||||
vec![asset_usage_reference(
|
||||
Some("hero"),
|
||||
None,
|
||||
Some("assets/missing.png"),
|
||||
)],
|
||||
),
|
||||
)
|
||||
.expect("register project scoped usage");
|
||||
|
||||
assert!(
|
||||
read_local_project_asset_usage(&root, Some("project-2"))
|
||||
.expect("read other project usage")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
read_local_project_asset_usage(&root, Some("project-1"))
|
||||
.expect("read own project usage")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 清单缺失时读取返回 None,而不是报错。
|
||||
#[test]
|
||||
fn asset_usage_read_returns_none_when_absent() {
|
||||
let root = asset_usage_project_fixture();
|
||||
assert!(
|
||||
read_local_project_asset_usage(&root, Some("project-1"))
|
||||
.expect("read absent asset usage")
|
||||
.is_none()
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
/// 落盘正文只包含引用与诊断,不含 manifest 已有的资源元数据。
|
||||
#[test]
|
||||
fn asset_usage_persisted_payload_omits_manifest_owned_metadata() {
|
||||
let root = asset_usage_project_fixture();
|
||||
let assets = vec![LocalProjectAssetFacts {
|
||||
asset_id: "hero-image".to_string(),
|
||||
local_path: "assets/hero.png".to_string(),
|
||||
}];
|
||||
write_asset_usage_asset(&root, "assets/hero.png", b"hero");
|
||||
let mut reference = asset_usage_reference(Some("hero"), Some("hero-image"), None);
|
||||
reference.load_site = Some("game/game.js:preload".to_string());
|
||||
submit_local_project_asset_usage_at(
|
||||
&root,
|
||||
"project-1",
|
||||
&assets,
|
||||
&asset_usage_submit(1, vec![reference]),
|
||||
)
|
||||
.expect("register usage for payload inspection");
|
||||
|
||||
let payload = fs::read_to_string(root.join(asset_usage_relative_path()))
|
||||
.expect("read persisted asset usage payload");
|
||||
for forbidden in ["sha256", "mediaType", "localPath", "kind"] {
|
||||
assert!(
|
||||
!payload.contains(forbidden),
|
||||
"asset usage payload must not duplicate manifest field {forbidden}: {payload}"
|
||||
);
|
||||
}
|
||||
for required in ["slotId", "resourceId", "loadSite", "attribution"] {
|
||||
assert!(
|
||||
payload.contains(required),
|
||||
"asset usage payload missing {required}: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
@@ -6065,6 +6065,7 @@ async fn background_agent_runtime_marks_unconverged_loop_budget_exhausted() {
|
||||
}
|
||||
|
||||
mod collaboration;
|
||||
mod asset_usage;
|
||||
mod command_runtime;
|
||||
pub(crate) mod configuration;
|
||||
mod goal;
|
||||
|
||||
Reference in New Issue
Block a user