修复策划切换游戏时清单版本错配

策划产物登记仅在实际变更时推进项目 revision

切换到 GameAgent 时保持 manifest 与 revision 配对

补充幂等切换回归测试与技术文档
This commit is contained in:
2026-09-14 19:49:39 +08:00
parent de10b99ddb
commit 8b25e144b9
4 changed files with 91 additions and 17 deletions
@@ -1142,7 +1142,9 @@ pub(crate) fn set_design_agent_runtime_mode(
"design.runtime-mode",
)?;
if active_runtime.trim() == "game" {
crate::assets::register_design_artifacts_at(root)?;
if crate::assets::register_design_artifacts_at(root)? {
advance_agent_runtime_project_revision_locked(root)?;
}
}
write_design_runtime_mode(root, active_runtime.trim())
}
@@ -1378,6 +1380,38 @@ mod tests {
let error = ensure_design_runtime_active(root).expect_err("game mode must reject design");
assert!(error.contains("游戏运行态"));
}
#[test]
fn switching_to_game_pairs_design_artifact_registration_with_revision() {
let temporary = tempfile::tempdir().expect("create runtime mode root");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "design-switch-test", "策划切换")
.expect("init project");
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
.expect("write artifact");
let before = read_game_creator_agent_runtime_project_revision(root)
.expect("read initial revision")
.revision;
assert_eq!(
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string(),)
.expect("switch to game")
.active_runtime,
"game"
);
let after = read_game_creator_agent_runtime_project_revision(root)
.expect("read committed revision")
.revision;
assert_eq!(after, before + 1);
set_design_agent_runtime_mode(root.to_string_lossy().into_owned(), "game".to_string())
.expect("repeat switch to game");
let repeated = read_game_creator_agent_runtime_project_revision(root)
.expect("read repeated revision")
.revision;
assert_eq!(repeated, after);
}
use serde_json::json;
use std::fs;
@@ -604,10 +604,10 @@ pub(crate) fn register_local_asset_at(
register_local_asset_entry(root, local_path, kind, media_type, id_prefix, source)
}
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String> {
pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<bool, String> {
let design_root = root.join("design_artifacts");
if !design_root.exists() {
return Ok(0);
return Ok(false);
}
let mut files = Vec::new();
let mut directories = vec![design_root];
@@ -630,7 +630,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
}
}
files.sort();
let mut registered = 0;
let mut changed = false;
for path in files {
let relative = path
.strip_prefix(root)
@@ -644,7 +644,7 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
Some("yaml" | "yml") => "text/yaml",
_ => "application/octet-stream",
};
register_local_asset_at(
let (_, asset_changed) = register_local_asset_entry_with_change(
root,
&relative,
"document",
@@ -663,9 +663,9 @@ pub(crate) fn register_design_artifacts_at(root: &Path) -> Result<usize, String>
reference_resource_ids: Vec::new(),
},
)?;
registered += 1;
changed |= asset_changed;
}
Ok(registered)
Ok(changed)
}
pub(crate) fn import_canvas_asset_at(
@@ -1876,6 +1876,18 @@ pub(crate) fn register_local_asset_entry(
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<UploadLocalAssetResult, String> {
register_local_asset_entry_with_change(root, local_path, kind, media_type, id_prefix, source)
.map(|(result, _)| result)
}
fn register_local_asset_entry_with_change(
root: &Path,
local_path: &str,
kind: &str,
media_type: &str,
id_prefix: &str,
source: GameCreationAppAssetSource,
) -> Result<(UploadLocalAssetResult, bool), String> {
let normalized_path = normalize_relative_path(local_path)?;
let absolute_path = resolve_local_project_path(root, &normalized_path)?;
let manifest_path = root.join(".agent/manifest.json");
@@ -1888,7 +1900,7 @@ pub(crate) fn register_local_asset_entry(
let mut source_for_record = source.clone();
source_for_record.prompt = None;
let (id, record_type) = mutate_manifest_at(root, |manifest| {
let (id, record_type, changed) = mutate_manifest_at(root, |manifest| {
if let Some(existing) = manifest
.assets
.iter_mut()
@@ -1898,13 +1910,16 @@ pub(crate) fn register_local_asset_entry(
// 而陈旧的非 unclassified 值会被读侧无条件信任(自愈只在落盘值是 unclassified
// 时才触发),于是这个资产永远停在错误栏目。
// kind 没变时刻意不动 category——落盘分类是权威值,同 kind 重登记不得抹掉它。
let changed = existing.kind != kind
|| existing.media_type != media_type
|| existing.source != source;
if existing.kind != kind {
existing.kind = kind.to_string();
existing.category = game_creation_app_asset_category_for_kind(kind);
}
existing.media_type = media_type.to_string();
existing.source = source;
Ok((existing.id.clone(), "asset.update"))
Ok((existing.id.clone(), "asset.update", changed))
} else {
let id = format!(
"{id_prefix}-{}-{}",
@@ -1922,7 +1937,7 @@ pub(crate) fn register_local_asset_entry(
tags: Vec::new(),
source,
});
Ok((id, "asset.register"))
Ok((id, "asset.register", true))
}
})?;
append_agent_db_record(
@@ -1937,12 +1952,15 @@ pub(crate) fn register_local_asset_entry(
}),
)?;
Ok(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(),
})
Ok((
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(),
},
changed,
))
}
#[derive(Clone, Debug, Deserialize)]
@@ -2137,6 +2155,27 @@ mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn design_artifact_registration_reports_only_real_manifest_changes() {
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "design-artifact-test", "策划产物登记")
.expect("init project");
fs::create_dir_all(root.join("design_artifacts/project")).expect("create artifacts");
fs::write(root.join("design_artifacts/project/design.md"), "设计内容")
.expect("write artifact");
assert!(register_design_artifacts_at(root).expect("register first time"));
assert_eq!(
read_existing_manifest_for_project(root)
.unwrap()
.assets
.len(),
1
);
assert!(!register_design_artifacts_at(root).expect("register idempotently"));
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
///
/// 这个值会被原样写进 manifest 并据以派生落盘 `category`;一旦写出非 canonical 值
@@ -308,6 +308,7 @@
- 背景:立项策划 GDD 批准后需要给用户一个进入做游戏的自然出口,产品决策改为点击按钮后直接开始建造。
- 决策:批准态 GDD 交付行提供“做成游戏”按钮。点击后读取当前项目的权威 `game/fast_gdd.md`,直接创建自动游戏工作区、导入 `text/markdown` 参考附件,并以固定建造指令自动启动 Direct Codex;不再回首页等待用户二次提交。该动作不复制原项目的 `approvedGddRef`、planning sidecar 或 approval receipt。
- 补充:策划项目切换到 GameAgent 时,`design_artifacts` 的新增或登记信息实际变化必须与一次项目 revision 推进配对;重复切换不重复推进,避免 manifest 已变化而 revision 仍停留在旧值,触发前端同 revision 清单冲突提示。
- 影响范围:AGC 前端 GDD 交付行与现有自动建项/附件导入/Direct Codex 链路;移除首页 RichInputArea 的 GDD 一次性预填链路;不新增 HTTP API、SpacetimeDB schema、迁移、OpenAPI 或正式构建绑定。
- 验证方式:批准态按钮直接创建工作区、导入附件、携带固定首条指令进入项目工作台且重复点击不重复创建的 appSurface 回归;类型检查、编码检查和 `git diff --check` 通过。
- 关联文档:`docs/technical/【技术方案】立项策划AgentFast GDD-2026-08-10.md`
@@ -342,7 +342,7 @@ UI 使用“批准”和“继续修改”两个文字按钮,分别配 Lucide
## 14. 开发调试入口
项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”写入 `game`。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。
项目运行模式通过 `.agent/runtime-mode.json` 持久化。新建策划项目在进入工作台前写入 `design`;“做成游戏”先登记 `design_artifacts` 中尚未登记或登记信息已变化的策划产物,若 manifest 实际发生变化则在同一项目写锁范围内推进一次项目 revision,再写入 `game`。重复切换不重复登记或推进 revision。重新打开项目时通过 `get_design_agent_runtime_mode` 读取模式,完成后一次性挂载对应工作台,不能先挂载 GameAgent 再切回策划。旧项目缺少模式文件但存在策划会话时按 `design` 恢复;明确的 `game` 标记优先于残留策划会话。无模式也无策划会话的项目仍使用游戏工作台。
开发构建的策划工作区页头在“刷新”旁提供“快速准备做成游戏测试”按钮。该入口与策划 Debug 日志共用 `GENARRATIVE_AGC_DESIGN_DEBUG=1` 开关:开关未启用时按钮不显示,命令也不可执行。入口仅进行本地 fixture 和会话状态写入,不调用 Provider;完成后自动刷新文件树与阶段,通过 `design-agent-update` 状态事件同步右侧审批/阶段操作区。随后仍需点击正常的“做成游戏”按钮执行资产登记与运行时切换。