WIP: AGC 资源画布与替换改造 V3.0 #316

Draft
suzmii wants to merge 131 commits from feat/agc-canvas-resource-workbench-v3 into master
4 changed files with 984 additions and 0 deletions
Showing only changes of commit a571c6e225 - Show all commits
@@ -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;