修复游戏创作工作流与UI图集事务

将固定图片规则降为默认合同并允许Supervisor持久委派扩展UI图集
修复条件Graph路由、终态投影、steer窗口和manifest只读刷新
将UI主图、切片、manifest与Agent DB审计收口为可恢复事务
加固跨平台句柄、journal重放、残留隔离和Agent DB容量门禁
补充工作流、事务恢复、Provider与项目夹具回归
同步技术方案与团队共享决策
This commit is contained in:
2026-08-07 19:27:14 +08:00
parent f113326973
commit 00053cf1d3
22 changed files with 10362 additions and 499 deletions
File diff suppressed because it is too large Load Diff
@@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt(
);
}
format!(
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
"{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。当且仅当当前 Project Supervisor 的静态委派 expectedArtifacts 同时保留 assets/art-spritesheet.png 并声明另一个 UI 图集 PNG 时,可按委派语义额外生成该路径,固定使用 1:1、1K、assetKind=ui-spritesheet 和贴合任务的 assetLabel;初次生成 replaceExisting=false,只有完整继承同一 expectedArtifacts 的唯一返工委派可设 replaceExisting=true。不得自行发明额外路径。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。"
)
}
@@ -1234,6 +1234,9 @@ mod tests {
assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved"));
assert!(with_canvas.contains("不得登记、验收或自动重试"));
assert!(with_canvas.contains("仅 sliceWarning"));
assert!(with_canvas.contains("Project Supervisor 的静态委派 expectedArtifacts"));
assert!(with_canvas.contains("assetKind=ui-spritesheet"));
assert!(with_canvas.contains("不得自行发明额外路径"));
}
#[test]
@@ -629,6 +629,11 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
.actions
.iter()
.any(|action| action.tool.trim() == "agent.delegate");
let has_code_asset_route = agent_id == "code-director"
&& plan
.actions
.iter()
.any(|action| action.tool.trim() == "agent.route_manifest");
if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID {
if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision {
if project_revision < failed_playtest_revision {
@@ -765,6 +770,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness(
|| mutation_revision.is_some()
|| !plan.response.trim().is_empty()
|| has_mutation
|| has_code_asset_route
{
return Ok(());
}
@@ -813,6 +819,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla
| "command.output_read"
| "command.poll"
| "image.inspect" => false,
"agent.route_manifest" => agent_id != "code-director",
"preview.validate" => agent_id != "preview-playtest",
"command.run_limited" => {
agent_id != "preview-readiness"
@@ -1771,6 +1778,13 @@ mod tests {
serde_json::json!({"commandId": "game.static_smoke"}),
);
let preview = plan_for("preview.validate", serde_json::json!({}));
let route_manifest = plan_for(
"agent.route_manifest",
serde_json::json!({
"strategy": "use-existing-art",
"missingAssetSlots": [],
}),
);
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
"preview-readiness",
@@ -1784,9 +1798,41 @@ mod tests {
&preview,
)
.is_ok());
assert!(validate_agent_runtime_autonomous_read_only_delivery_plan(
"code-director",
true,
&route_manifest,
)
.is_ok());
let verification_gate = AgentRuntimeVerificationGate {
schema_version: "test".to_string(),
project_id: "test".to_string(),
agent_id: "code-director".to_string(),
run_id: "test".to_string(),
requires_verification: false,
mutation_revision: None,
verified_revision: None,
last_mutation_tool: None,
last_verification_tool: None,
last_verification_status: None,
failed_playtest_revision: None,
updated_at: 0,
};
assert!(validate_agent_runtime_autonomous_plan_liveness(
"code-director",
AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1,
0,
&verification_gate,
&[],
&route_manifest,
false,
false,
)
.is_ok());
for (agent_id, plan) in [
("quality-review", &smoke),
("quality-review", &preview),
("quality-review", &route_manifest),
("preview-readiness", &preview),
("preview-playtest", &smoke),
] {
@@ -19,12 +19,22 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_content(
"taskContext": task,
}))
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
validate_agent_runtime_pending_serialized_content(root, &content)
validate_agent_runtime_pending_sensitive_serialized_content(&content)?;
let action_content = serde_json::to_string(action)
.map_err(|error| format!("序列化待确认工具动作失败:{error}"))?;
validate_agent_runtime_pending_project_path_content(root, &action_content)
}
pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
root: &Path,
content: &str,
) -> Result<(), String> {
validate_agent_runtime_pending_sensitive_serialized_content(content)?;
validate_agent_runtime_pending_project_path_content(root, content)
}
fn validate_agent_runtime_pending_sensitive_serialized_content(
content: &str,
) -> Result<(), String> {
let lower = content.to_ascii_lowercase();
let sensitive_rule = [
@@ -46,6 +56,13 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content(
"待确认工具输入命中敏感规则 #{rule},Runtime 已拒绝持久化"
));
}
Ok(())
}
fn validate_agent_runtime_pending_project_path_content(
root: &Path,
content: &str,
) -> Result<(), String> {
let root_display = root.to_string_lossy();
if !root_display.is_empty() && content.contains(root_display.as_ref()) {
return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string());
@@ -374,7 +391,12 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record(
}
let serialized = serde_json::to_string(pending)
.map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?;
validate_agent_runtime_pending_serialized_content(root, &serialized)?;
validate_agent_runtime_pending_sensitive_serialized_content(&serialized)?;
let mut project_path_record = pending.clone();
project_path_record.task.clear();
let project_path_serialized = serde_json::to_string(&project_path_record)
.map_err(|error| format!("序列化 Agent Runtime 待确认路径校验记录失败:{error}"))?;
validate_agent_runtime_pending_project_path_content(root, &project_path_serialized)?;
let action_fingerprint = agent_runtime_pending_tool_action_fingerprint(
&pending.action,
&pending.task,
@@ -762,6 +784,115 @@ mod tests {
}
}
#[test]
fn pending_content_allows_project_root_in_task_context_when_action_is_relative() {
let root = Path::new("/data/dsk/games/game01");
let action = AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("核对当前资产".to_string()),
input: serde_json::json!({ "path": "assets" }),
};
validate_agent_runtime_pending_tool_action_content(
root,
&action,
"继续修复 /data/dsk/games/game01 中的现有项目",
)
.expect("task context may identify the current project while tool input stays relative");
}
#[test]
fn pending_content_rejects_project_root_in_tool_action() {
let root = Path::new("/data/dsk/games/game01");
let action = AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("读取当前入口".to_string()),
input: serde_json::json!({
"path": "/data/dsk/games/game01/game/index.html"
}),
};
let error =
validate_agent_runtime_pending_tool_action_content(root, &action, "继续修复当前项目")
.expect_err("tool action must keep using a project-relative path");
assert!(error.contains("项目绝对路径"), "{error}");
}
#[tokio::test]
async fn provider_batch_round_trips_when_only_task_identifies_project_root() {
let temporary = crate::tests::canonical_test_tempdir("pending-task-project-root-");
let root = temporary.path();
init_local_game_project_at(root, "pending-task-project-root", "待确认路径批次测试")
.expect("init project");
let task = format!("继续修复 {} 中的现有项目", root.display());
let mut runtime = start_game_creator_agent_runtime_task_at(
root,
"art-director",
&task,
"pending-task-project-root-run",
"agent-ready-task-scheduler",
"核对当前项目",
vec!["核对当前项目".to_string()],
)
.expect("start runtime");
runtime.loop_iteration = 1;
let plan = AgentRuntimeToolPlan {
thinking_summary: "核对相对路径项目上下文".to_string(),
plan_update: None,
plan: vec!["核对资产".to_string(), "核对入口".to_string()],
actions: vec![
AgentRuntimeToolAction {
tool: "file.list".to_string(),
reason: Some("核对当前资产".to_string()),
input: serde_json::json!({ "path": "assets" }),
},
AgentRuntimeToolAction {
tool: "file.read".to_string(),
reason: Some("核对当前入口".to_string()),
input: serde_json::json!({
"path": "game/index.html",
"startLine": 1,
"maxLines": 20
}),
},
],
response: String::new(),
};
let project_revision =
read_game_creator_agent_runtime_project_revision(root).expect("read project revision");
let repository_fingerprint = build_repository_startup_context_at(root)
.expect("build repository context")
.fingerprint;
let prepared = prepare_game_creator_agent_runtime_provider_action_batch(
root,
&runtime,
&task,
&plan,
&[],
&project_revision,
&repository_fingerprint,
)
.await
.expect("prepare provider batch");
let prepared = match prepared {
AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch,
other => panic!("expected ready provider batch, got {other:?}"),
};
let persisted = read_game_creator_agent_runtime_provider_action_batch(
root,
&runtime.agent_id,
&runtime.run_id,
)
.expect("read persisted provider batch");
assert_eq!(persisted.batch_id, prepared.batch_id);
assert_eq!(persisted.actions.len(), 2);
assert!(persisted.actions.iter().all(|pending| pending.task == task));
assert_eq!(persisted.actions[0].action, plan.actions[0]);
assert_eq!(persisted.actions[1].action, plan.actions[1]);
}
#[test]
fn pending_content_still_rejects_api_key_fields_and_secret_tokens() {
let root = Path::new("C:\\workspace");
@@ -1,7 +1,12 @@
use super::*;
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] =
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"];
pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &[
"game-art",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
];
#[cfg(test)]
mod canvas_asset_kind_contract_tests {
@@ -11,7 +16,13 @@ mod canvas_asset_kind_contract_tests {
fn canvas_asset_kind_catalog_preserves_authoritative_contract() {
assert_eq!(
AGENT_RUNTIME_CANVAS_ASSET_KINDS,
&["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]
&[
"game-art",
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
]
);
}
}
@@ -7495,6 +7495,47 @@ fn autonomous_ready_terminal_failures_are_projected_without_retry() {
}
}
#[test]
fn autonomous_scheduler_reprojects_a_recovered_terminal_child_before_returning() {
let (_temporary, root, parent_state, _contract) =
autonomous_fixture("做一个完整小游戏", "autonomous-recovered-terminal-parent");
update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running)
.expect("mark recovered autonomous child running");
let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director");
append_game_creator_agent_runtime_task_record(
&root,
&AgentRuntimeTaskRecord {
status: "completed".to_string(),
phase: "completed".to_string(),
current_action: "recovered child already completed".to_string(),
terminal_detail: Some("completed before scheduler recovery".to_string()),
error: None,
updated_at: unix_timestamp(),
..record
},
)
.expect("append recovered autonomous child terminal");
let scheduled = schedule_autonomous_game_build_ready_tasks_at(
&root,
&parent_state.agent_id,
&parent_state.run_id,
3,
)
.expect("recover terminal autonomous child");
assert_eq!(scheduled.len(), 1);
assert_eq!(
read_manifest_for_project(&root)
.expect("read reprojected manifest")
.tasks
.iter()
.find(|task| task.id == "design-director")
.map(|task| &task.status),
Some(&GameCreationAppTaskStatus::Completed),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_autonomous_child_terminal_projection_preserves_all_manifest_updates() {
let (_temporary, root, parent_state, _contract) =
@@ -705,6 +705,19 @@ pub(in crate::agent) fn continuation_for_game_creator_agent_runtime_steer(
..AgentRuntimeContinuationContext::default()
};
context_tracker.apply_to_continuation(&mut continuation);
if next_loop_index > 0
&& next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT == 0
&& continuation.window_completed_loops > 0
{
continuation.window_completed_loops = 0;
continuation.window_observation_fingerprints.clear();
continuation.last_window_fingerprint =
super::context_window::agent_runtime_context_window_fingerprint(
&context_tracker.observation_signatures,
)
.or_else(|| context_tracker.last_window_fingerprint.clone());
continuation.context_stalled = false;
}
continuation
}
@@ -876,4 +889,41 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn steer_at_a_skipped_checkpoint_boundary_starts_a_fresh_window() {
let temporary = crate::tests::canonical_test_tempdir("steer-window-boundary-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-steer-window", "追加指令窗口边界")
.expect("project init");
let runtime = start_game_creator_agent_runtime_task_at(
&root,
"code-prototype",
"验证追加指令窗口边界",
"steer-context-window-boundary-run",
"agent-background-task",
"追加指令窗口边界测试",
vec!["恢复时保持 context bundle 有效".to_string()],
)
.expect("start steer window boundary runtime state");
let mut tracker = AgentRuntimeContextWindowTracker {
completed_loops: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT - 1,
..AgentRuntimeContextWindowTracker::default()
};
tracker
.observation_signatures
.insert("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string());
let continuation = continuation_for_game_creator_agent_runtime_steer(
&runtime,
&AgentRuntimeToolPlan::default(),
&[],
AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT,
&tracker,
);
assert_eq!(continuation.window_completed_loops, 0);
assert!(continuation.window_observation_fingerprints.is_empty());
assert!(!continuation.context_stalled);
}
}
@@ -33,7 +33,11 @@ pub(in crate::agent) use run_status::*;
pub(in crate::agent) use task_ops::*;
#[cfg(test)]
pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at;
pub(crate) use media::{
resolve_agent_runtime_platform_art_generation_options_at,
validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at,
validate_agent_runtime_canvas_replacement_authorization_at, AGENT_RUNTIME_ART_SPRITESHEET_PATH,
};
pub(crate) use action_history::{
is_valid_agent_runtime_action_id, observe_agent_runtime_action_history,
File diff suppressed because it is too large Load Diff
@@ -1257,7 +1257,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value {
"missingAssetSlots": {
"type": "array",
"maxItems": 2,
"uniqueItems": true,
"items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] }
}
}
@@ -1381,7 +1380,7 @@ mod tests {
let Some(object) = schema.as_object() else {
return;
};
for keyword in ["oneOf", "anyOf", "allOf", "not"] {
for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] {
if object.contains_key(keyword) {
issues.push(format!(
"strict schema contains unsupported {keyword} at {path}"
@@ -1026,9 +1026,131 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
register_local_asset_entry_internal(root, local_path, kind, media_type, id_prefix, source, None)
.map(|(registered, _)| registered)
.map_err(LocalAssetRegistrationError::into_message)
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct CanvasAssetRegistrationAuditIdentity {
pub(crate) asset_id: String,
pub(crate) local_path: String,
pub(crate) record_type: String,
pub(crate) transaction_id: String,
}
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum CanvasAssetRegistrationError {
BeforeMutation(String),
AuditAppendOutcomeUnknown {
registered: UploadLocalAssetResult,
audit: CanvasAssetRegistrationAuditIdentity,
message: String,
},
}
impl CanvasAssetRegistrationError {
pub(crate) fn audit_identity(&self) -> Option<&CanvasAssetRegistrationAuditIdentity> {
match self {
Self::BeforeMutation(_) => None,
Self::AuditAppendOutcomeUnknown { audit, .. } => Some(audit),
}
}
}
impl std::fmt::Display for CanvasAssetRegistrationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BeforeMutation(message) => formatter.write_str(message),
Self::AuditAppendOutcomeUnknown {
audit, message, ..
} => write!(
formatter,
"{message};素材登记审计追加结果未知:assetId={}, localPath={}, recordType={}, transactionId={}",
audit.asset_id, audit.local_path, audit.record_type, audit.transaction_id
),
}
}
}
pub(crate) fn register_local_asset_entry_for_canvas_transaction(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
transaction_id: &str,
) -> Result<(UploadLocalAssetResult, String), CanvasAssetRegistrationError> {
let transaction_id = transaction_id.trim();
validate_agent_db_canvas_asset_transaction_id(transaction_id)
.map_err(CanvasAssetRegistrationError::BeforeMutation)?;
register_local_asset_entry_internal(
root,
local_path,
kind,
media_type,
id_prefix,
source,
Some(transaction_id),
)
.map_err(|error| error.into_canvas_error(transaction_id))
}
enum LocalAssetRegistrationError {
BeforeMutation(String),
AuditAppendOutcomeUnknown {
registered: UploadLocalAssetResult,
record_type: String,
message: String,
},
}
impl LocalAssetRegistrationError {
fn into_message(self) -> String {
match self {
Self::BeforeMutation(message) | Self::AuditAppendOutcomeUnknown { message, .. } => {
message
}
}
}
fn into_canvas_error(self, transaction_id: &str) -> CanvasAssetRegistrationError {
match self {
Self::BeforeMutation(message) => CanvasAssetRegistrationError::BeforeMutation(message),
Self::AuditAppendOutcomeUnknown {
registered,
record_type,
message,
} => CanvasAssetRegistrationError::AuditAppendOutcomeUnknown {
audit: CanvasAssetRegistrationAuditIdentity {
asset_id: registered.id.clone(),
local_path: registered.local_path.clone(),
record_type,
transaction_id: transaction_id.to_string(),
},
registered,
message,
},
}
}
}
fn register_local_asset_entry_internal(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
transaction_id: Option<&str>,
) -> Result<(UploadLocalAssetResult, String), LocalAssetRegistrationError> {
let normalized_path =
normalize_relative_path(local_path).map_err(LocalAssetRegistrationError::BeforeMutation)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
let (manifest_path, mut manifest) =
read_or_create_manifest(root).map_err(LocalAssetRegistrationError::BeforeMutation)?;
let kind = if kind.is_empty() { "asset" } else { kind };
let media_type = if media_type.is_empty() {
"application/octet-stream"
@@ -1062,25 +1184,49 @@ pub(crate) fn register_local_asset_entry(
});
(id, "asset.register")
};
write_manifest(&manifest_path, &manifest)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
}),
)?;
Ok(UploadLocalAssetResult {
if let Some(transaction_id) = transaction_id {
validate_agent_db_canvas_asset_audit_identity(transaction_id, &id, &normalized_path)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
}
write_manifest(&manifest_path, &manifest)
.map_err(LocalAssetRegistrationError::BeforeMutation)?;
let mut audit = serde_json::json!({
"recordType": record_type,
"assetId": id.clone(),
"localPath": normalized_path.clone(),
"kind": kind,
"mediaType": media_type,
"source": source_for_record,
});
if let Some(transaction_id) = transaction_id {
audit
.as_object_mut()
.expect("asset registration audit is an object")
.insert(
"transactionId".to_string(),
serde_json::Value::String(transaction_id.to_string()),
);
}
let registered = UploadLocalAssetResult {
id,
local_path: normalized_path.clone(),
absolute_path: absolute_path.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
})
};
let append_result = if transaction_id.is_some() {
append_agent_db_canvas_asset_transaction_audit_idempotent(root, audit).map(|_| ())
} else {
append_agent_db_record(root, audit)
};
if let Err(message) = append_result {
return Err(LocalAssetRegistrationError::AuditAppendOutcomeUnknown {
registered,
record_type: record_type.to_string(),
message,
});
}
Ok((registered, record_type.to_string()))
}
#[cfg(test)]
@@ -1088,6 +1234,152 @@ mod tests {
use super::*;
use std::io::{Read, Write};
fn unique_asset_registration_test_root(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-asset-registration-{label}-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn canvas_registration_source() -> GameCreationAppAssetSource {
GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),
resource_id: Some("resource-1".to_string()),
asset_object_id: Some("asset-object-1".to_string()),
task_id: Some("task-1".to_string()),
prompt: Some("test prompt".to_string()),
model: Some("test-model".to_string()),
generation_route: Some("test-route".to_string()),
generation_kind: Some("test-kind".to_string()),
reference_resource_ids: Vec::new(),
}
}
#[test]
fn canvas_registration_sync_unknown_error_retains_complete_audit_identity() {
let root = unique_asset_registration_test_root("post-sync-identity");
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
.expect("initialize asset registration fixture");
let local_path = "assets/ui-spritesheet.png";
fs::write(root.join(local_path), b"test-image").expect("write asset registration fixture");
fs::write(
root.join(".agent/runtime/test-fail-after-agent-db-record-sync"),
"asset.register",
)
.expect("inject post-sync Agent DB failure");
let error = register_local_asset_entry_for_canvas_transaction(
&root,
local_path,
"ui-spritesheet",
"image/png",
"platform-art",
canvas_registration_source(),
"canvas-transaction-1",
)
.expect_err("post-sync append outcome must be surfaced as unknown");
let audit = error
.audit_identity()
.expect("unknown append error carries audit identity");
let registered = match &error {
CanvasAssetRegistrationError::AuditAppendOutcomeUnknown { registered, .. } => {
registered
}
CanvasAssetRegistrationError::BeforeMutation(_) => {
panic!("post-sync error must carry registered asset")
}
};
assert_eq!(audit.asset_id, registered.id);
assert_eq!(audit.local_path, local_path);
assert_eq!(audit.record_type, "asset.register");
assert_eq!(audit.transaction_id, "canvas-transaction-1");
assert!(error.to_string().contains("追加结果未知"));
let records = fs::read_to_string(root.join(".agent/agent.db"))
.expect("read durably appended asset audit");
let matching = records
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|record| {
record.get("recordType").and_then(serde_json::Value::as_str)
== Some("asset.register")
&& record
.get("transactionId")
.and_then(serde_json::Value::as_str)
== Some("canvas-transaction-1")
})
.expect("post-sync failure occurs after the audit is durable");
assert_eq!(matching["assetId"], audit.asset_id);
assert_eq!(matching["localPath"], audit.local_path);
fs::remove_dir_all(root).ok();
}
#[test]
fn canvas_registration_rejects_non_compensatable_identity_before_manifest_mutation() {
let cases = [
(
"unsafe-transaction",
"assets/ui-spritesheet.png",
"platform-art",
"../unsafe",
),
(
"outside-assets",
"ui/ui-spritesheet.png",
"platform-art",
"canvas-transaction-2",
),
(
"unsafe-asset-id",
"assets/ui-spritesheet.png",
"../unsafe",
"canvas-transaction-3",
),
];
for (label, local_path, id_prefix, transaction_id) in cases {
let root = unique_asset_registration_test_root(label);
init_local_game_project_at(&root, "asset-registration-test", "素材登记测试")
.expect("initialize asset registration fixture");
let absolute_path = root.join(local_path);
fs::create_dir_all(
absolute_path
.parent()
.expect("asset identity fixture has parent"),
)
.expect("create asset identity fixture parent");
fs::write(&absolute_path, b"test-image").expect("write asset identity fixture");
let (manifest_path, manifest_before) =
read_or_create_manifest(&root).expect("read manifest before rejected registration");
let error = register_local_asset_entry_for_canvas_transaction(
&root,
local_path,
"ui-spritesheet",
"image/png",
id_prefix,
canvas_registration_source(),
transaction_id,
)
.expect_err("non-compensatable Canvas identity must be rejected");
assert!(
matches!(error, CanvasAssetRegistrationError::BeforeMutation(_)),
"{error}"
);
assert_eq!(
read_manifest(&manifest_path).expect("read manifest after rejected registration"),
manifest_before
);
fs::remove_dir_all(root).ok();
}
}
fn read_asset_test_request(stream: &mut std::net::TcpStream) {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
@@ -193,7 +193,7 @@ pub(crate) fn get_local_game_manifest(
return Err(format!("不支持通过 manifest 执行命令:{command_id}"));
}
enforce_project_permission_policy(root, command_id)?;
read_manifest_for_project(root)
read_existing_manifest_for_project(root)
}
#[tauri::command]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -282,8 +282,11 @@ pub(crate) fn record_command_run(
pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
let persisted = manifest.clone();
ensure_manifest_seed_tasks(root, &mut manifest);
write_manifest(&manifest_path, &manifest)?;
if manifest != persisted {
write_manifest(&manifest_path, &manifest)?;
}
Ok(manifest)
}
@@ -35,7 +35,8 @@ fn manifest_read_and_project_write_recover_previous_file() {
assert_eq!(recovered.project_id, "project-recovered");
assert_eq!(recovered.goal.as_deref(), Some("保留恢复副本内容"));
let recovered = read_manifest_for_project(&root).expect("rewrite recovered manifest");
write_manifest(&manifest_path, &recovered).expect("rewrite recovered manifest");
let recovered = read_manifest(&manifest_path).expect("read installed manifest");
assert_eq!(recovered.project_id, "project-recovered");
assert!(manifest_path.is_file());
assert!(!backup_path.exists());
@@ -43,6 +44,20 @@ fn manifest_read_and_project_write_recover_previous_file() {
fs::remove_dir_all(root).ok();
}
#[test]
fn current_manifest_read_does_not_acquire_the_write_lock() {
let root = unique_manifest_test_root("current-read-is-pure");
let manifest_path = root.join(".agent/manifest.json");
let manifest = new_game_creation_app_manifest("project-current", "当前项目");
write_manifest(&manifest_path, &manifest).expect("write current manifest");
let _write_lock = acquire_manifest_write_lock(&manifest_path).expect("hold manifest lock");
let read = read_manifest_for_project(&root).expect("read current manifest without writing");
assert_eq!(read, manifest);
fs::remove_dir_all(root).ok();
}
fn version_fixture(
version_id: &str,
parent_version_id: Option<&str>,
@@ -1215,6 +1215,172 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() {
fs::remove_dir_all(root).ok();
}
#[test]
fn supervisor_delegation_can_extend_art_asset_plan_with_a_ui_spritesheet_contract() {
let root = unique_project_path();
init_local_game_project_at(
&root,
"project-ui-spritesheet-contract",
"UI 图集委派合同测试",
)
.expect("project init");
let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID;
let parent_session_id = "ui-spritesheet-parent-session";
let parent_run_id = "ui-spritesheet-parent-run";
let child_agent_id = "art-asset-plan";
let child_session_id = "ui-spritesheet-child-session";
let parent_action_id = "ui-spritesheet-delegate-action";
let delegation_id = agent_runtime_delegation_id(
parent_agent_id,
parent_run_id,
child_agent_id,
parent_action_id,
);
let child_run_id = format!("delegated-{delegation_id}");
let expected_artifacts = vec![
AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string(),
"assets/ui-spritesheet.png".to_string(),
];
let delivery = new_static_delegate_delivery_with_contract(
parent_agent_id,
parent_session_id,
parent_run_id,
parent_action_id,
&delegation_id,
child_agent_id,
child_session_id,
&child_run_id,
&["保留核心图集并补齐运行时 UI 透明图集".to_string()],
&expected_artifacts,
None,
);
create_or_read_static_delegate_delivery_at(&root, &delivery)
.expect("persist UI spritesheet delivery");
let parent_task = AgentRuntimeTaskRecord {
goal_id: None,
goal_revision: 0,
goal_status: None,
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: parent_agent_id.to_string(),
task_id: parent_agent_id.to_string(),
session_id: parent_session_id.to_string(),
run_id: parent_run_id.to_string(),
source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE.to_string(),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
parent_agent_id: None,
parent_run_id: None,
delegation_id: None,
task: "安排补充 UI 图集".to_string(),
status: "running".to_string(),
phase: "waiting-for-delegate-receipts".to_string(),
current_action: "等待 UI 图集".to_string(),
terminal_detail: None,
error: None,
updated_at: unix_timestamp(),
};
write_agent_runtime_task_record_for_test(&root, &parent_task);
write_agent_runtime_task_record_for_test(
&root,
&AgentRuntimeTaskRecord {
goal_id: None,
goal_revision: 0,
goal_status: None,
schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(),
agent_id: child_agent_id.to_string(),
task_id: child_agent_id.to_string(),
session_id: child_session_id.to_string(),
run_id: child_run_id.clone(),
source: "agent-delegate".to_string(),
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
parent_agent_id: Some(parent_agent_id.to_string()),
parent_run_id: Some(parent_run_id.to_string()),
delegation_id: Some(delegation_id),
task: "生成运行时 UI 透明图集".to_string(),
status: "running".to_string(),
phase: "planning".to_string(),
current_action: "准备生成 UI 图集".to_string(),
terminal_detail: None,
error: None,
updated_at: unix_timestamp(),
},
);
let canonical = PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "art-spritesheet".to_string(),
asset_label: "游戏首版核心美术素材".to_string(),
replace_existing: false,
};
let requested = PlatformArtAssetGenerationOptions {
output_path: Some("assets/ui-spritesheet.png".to_string()),
aspect_ratio: "1:1".to_string(),
image_size: "1K".to_string(),
asset_kind: "ui-spritesheet".to_string(),
asset_label: "晶穹叠阵运行时 UI 透明图集".to_string(),
replace_existing: false,
};
let resolved = resolve_agent_runtime_platform_art_generation_options_at(
&root,
child_agent_id,
&child_run_id,
canonical.clone(),
requested.clone(),
)
.expect("durable Supervisor delivery authorizes the declared UI spritesheet");
assert_eq!(resolved, requested);
assert!(resolve_agent_runtime_platform_art_generation_options_at(
&root,
child_agent_id,
"ordinary-art-run",
canonical.clone(),
requested,
)
.expect_err("ordinary direct run cannot extend the fixed image contract")
.contains("静态专业委派"));
assert!(resolve_agent_runtime_platform_art_generation_options_at(
&root,
child_agent_id,
&child_run_id,
canonical.clone(),
PlatformArtAssetGenerationOptions {
output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()),
asset_kind: "ui-spritesheet".to_string(),
..canonical.clone()
},
)
.expect_err("delegation cannot change the canonical core path semantics")
.contains("不能覆盖固定输出合同"));
write_agent_runtime_task_record_for_test(
&root,
&AgentRuntimeTaskRecord {
status: "failed".to_string(),
phase: "failed".to_string(),
current_action: "父 run 已失败".to_string(),
terminal_detail: Some("父 run 已失败".to_string()),
error: Some("parent-failed".to_string()),
updated_at: unix_timestamp(),
..parent_task
},
);
assert!(
validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at(
&root,
child_agent_id,
&child_run_id,
"assets/ui-spritesheet.png",
)
.expect_err("terminal parent revokes delegated UI generation")
.contains("未授权")
);
fs::remove_dir_all(root).ok();
}
#[test]
fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() {
let _config_guard = crate::tests::write_test_local_config(
@@ -1849,6 +1849,8 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() {
let root = unique_project_path();
let config_dir = unique_project_path();
let base_url = spawn_mock_external_canvas_generation_failure_server();
init_local_game_project_at(&root, "project-art-fallback", "未命名游戏原型")
.expect("initialize platform art fallback project");
fs::create_dir_all(&config_dir).expect("runtime config dir");
fs::write(
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
@@ -1888,7 +1890,7 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() {
assert_eq!(step.status, "failed");
assert!(step.output_paths.is_empty());
assert!(step.summary.contains("HTTP 500"));
assert!(step.summary.contains("HTTP 500"), "{}", step.summary);
assert!(!step.summary.contains("editor-fallback-secret"));
assert!(read_manifest_for_project(&root).unwrap().assets.is_empty());
@@ -7691,6 +7691,7 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() {
"icon-spec",
"ui-prototype",
"art-spritesheet",
"ui-spritesheet",
null
])
);