fix(agc): fork 建项把成品包铺进预览根并登记可运行原型

This commit is contained in:
2026-10-07 01:08:22 +08:00
parent 2d5c79d0e0
commit 32714298be
2 changed files with 175 additions and 72 deletions
@@ -1,16 +1,21 @@
//! 从平台作品 Fork:受鉴权取件 → 摘要校验 → 建成一份可在本项目里继续改造的工程。
//! 从平台作品 Fork:受鉴权取件 → 摘要校验 → 建成一份可在本项目里继续改造、并且**立刻可运行**
//! 的工程。
//!
//! 两种取件形态(服务端 `source`)落法不同,实现事实如下:
//! - `Project`(工程源包):包本身就是作者的工程,解到项目根即可继续改造;
//! - `Package`(已构建成品包):平台只有成品时,先按标准初始化生成脚手架,再把成品放进
//! `<project>/reference/<gameId>/`,用户在新工程里继续改造。
//! - `Project`(工程源包):包本身就是作者的工程,解到项目根即可继续改造。包内**不含**构建产物
//! (`project_bundle.rs` 排除 `game/dist`),所以本机还没有可运行的产物:建项只登记一条初始
//! 工程版本,`code-prototype` 保持 `pending`,由运行视图如实提示「需要先构建」。
//! - `Package`(已构建成品包):平台只有成品时,先按标准初始化生成脚手架,再把成品铺进**预览根**
//! (`fork_playable_root`,即 `game/dist`)——运行视图只服务预览根,铺在别处的副本用户既看不到
//! 也播不了。成品包自带可玩入口,因此建项同时把 `code-prototype` 登记为已完成。
//!
//! 关键顺序(顺序本身就是合同):
//! 1. 先取件并按元数据校验字节(失败关闭,不落盘);
//! 2. 再 `init_local_game_project_at` 生成脚手架——它的 `create_npm_scaffold` 判据是
//! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以 `Package`
//! 形态的成品必须等脚手架生成之后再铺,且只能铺进子目录;
//! 3. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明 Fork 来源。
//! 形态的成品必须等脚手架生成之后再铺,且不能铺进项目根或 `game/` 根;
//! 3. 然后 `register_forked_project_state_at` 登记「可运行原型」与初始工程版本;
//! 4. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明 Fork 来源。
#[cfg(not(test))]
mod desktop;
@@ -19,9 +24,6 @@ pub(crate) use desktop::*;
use super::*;
/// 参考副本的子目录名。绝不能落在项目根或 `game/` 根:那会让合规脚手架一个文件都不生成。
const FORK_REFERENCE_DIRECTORY_NAME: &str = "reference";
/// 取件字节校验:字节数与 SHA-256 都必须与取件元数据一致,任一不符即失败关闭。
///
/// 抽成纯函数是为了让「失败就绝不落盘」这条规则可被单测钉住:调用点在解压之前。
@@ -43,39 +45,75 @@ pub(crate) fn verify_fork_source_bytes(
Ok(())
}
/// 参考副本相对项目的路径:`reference/<gameId>/`。
/// 可玩参考的落点:项目的**预览根**。
///
/// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与
/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。这是参考目录的**唯一**
/// 推导处:调用方把它 join 到项目根,不再另起第二份拼接规则。
pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, String> {
let game_id = game_id.trim();
// 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被
// 归一成 `reference`,那不是某个作品的参考目录。
if game_id.is_empty() {
return Err("Fork 来源作品 ID 无效,无法确定参考副本目录".to_string());
}
let relative = format!("{FORK_REFERENCE_DIRECTORY_NAME}/{game_id}");
let relative = crate::template_library::safe_archive_relative_path(&relative)
.map_err(|_| "Fork 来源作品 ID 无效,无法确定参考副本目录".to_string())?;
// 只接受「`reference` + 单一作品目录」两段:带 `/` 的标识会变成嵌套目录,既可能撞上
// 另一个作品的目录,也不是平台作品 ID 的形状。
if relative.components().count() != 2 {
return Err("Fork 来源作品 ID 无效,无法确定参考副本目录".to_string());
}
Ok(relative)
/// 成品包 fork 把取到的发行产物铺在这里,而不是另找一个"参考目录":运行视图只服务
/// `preview::project_game_root()` 指向的那一个根(`preview.rs` 的
/// `start_local_game_preview_for_project` / `resolve_preview_path`),铺在根之外的副本用户
/// 既看不到也播不了——那正是「fork 之后没法直接运行」的成因之一。
///
/// 预览根由 `project_game_root` 单点推导,这里不另写第二份判据:本形态必然是刚生成的 npm
/// 脚手架(`game/package.json` 存在),预览根就是 `game/dist`(AGC 网页脚手架的 vite
/// `outDir`)。它**不**在项目根或 `game/` 根,因此不会打断 `create_npm_scaffold` 判据;它也
/// 被工程源包的排除清单覆盖(`project_bundle.rs` 的 `game/dist`),fork 来的成品**不会**被当成
/// 作者自己的源码重新上传。
pub(crate) fn fork_playable_root(project_root: &Path) -> PathBuf {
crate::preview::project_game_root(project_root)
}
/// Fork 建项收口:把「本项目已有可运行原型」与初始工程版本**如实**登记进 manifest。
///
/// 为什么必须有这一条:运行视图与发布导出都以 `manifest.tasks[code-prototype] == completed`
/// (或存在运行中的预览)作为「项目里已有可运行原型」的**唯一**事实——前端
/// `view/project-development/index.tsx` 的 `runAvailable`、后端 `project/export.rs` 的
/// `project_has_runnable_prototype` 都是这一条。Fork 建项过去只生成脚手架与全新 manifest
/// (seed 任务全 `pending`、`versions` 为空),于是 fork 出来的项目一进来就被判成「首个可运行
/// 原型尚未完成」:运行页签点了没反应、发布也被拦——而它其实已经带了一份可玩的成品。
///
/// 写入顺序照既有 AI 直连回合收口(`agent/direct_runtime/mod.rs` 的同一套模式,见
/// `sync_direct_codex_project_file_projection_at`):任务状态 → 推进 project revision →
/// 追加 `initial-<revision>` 版本 → 宣告清单失效。这里不新造状态写入路径,也不动 manifest
/// 契约(改编来源仍只写在 `.agent/fork-source.json`)。
///
/// **只在项目里确实存在可玩入口时才标完成**(判据与预览同源:预览根下的 `index.html`):
/// - 成品包形态:发行产物已铺进预览根,`<预览根>/index.html` 存在 → 登记为已完成,运行与
/// 发布都立刻可用;
/// - 工程源包形态:按产品口径**不自动构建**(与「不自动安装外部工程依赖」的既有策略一致),
/// 本机还没有可运行产物 → 只追加初始工程版本、`code-prototype` 保持 `pending`,由运行视图
/// 如实提示「需要先构建」,绝不谎报原型已完成。
///
/// `emit_game_creator_manifest_invalidated` 不是可选项:资源画布「项目版本」卡与发布面板的
/// 版本标签只读清单快照,不宣告失效就看不到刚写进去的初始版本。
fn register_forked_project_state_at(root: &Path) -> Result<GameCreationAppManifest, String> {
if fork_playable_root(root).join("index.html").is_file() {
update_manifest_task_status_at(
root,
"code-prototype",
GameCreationAppTaskStatus::Completed,
)?;
}
// 顺序是合同:先推进 durable revision,再把该 revision 绑成正式版本
// (`append_agent_game_iteration_version_at` 明确要求 revision > 0 且由调用方先推进)。
let revision = advance_agent_runtime_project_revision_locked(root)?;
append_agent_game_iteration_version_at(root, revision)?;
emit_game_creator_manifest_invalidated(root, FORK_MANIFEST_INVALIDATED_SOURCE);
read_manifest_for_project(root)
}
/// 清单失效事件的来源标识(与 `direct-codex.version` 等既有来源并列,便于排障定位写入者)。
const FORK_MANIFEST_INVALIDATED_SOURCE: &str = "game-fork.project";
/// 用平台作品的取件内容建一个新项目。
///
/// 两种取件形态(`source`)落法不同:
/// - `Package`(已构建成品包):先按标准初始化生成脚手架,再把成品铺进
/// `<project>/reference/<gameId>/`——平台只有成品时只有这一份内容可用。
/// - `Package`(已构建成品包):先按标准初始化生成脚手架,再把成品铺进**预览根**
/// (`fork_playable_root`)——平台只有成品时只有这一份内容可用,而且它必须落在预览根才播得了。
/// - `Project`(工程源包):包本身就是作者的工程(自带 `package.json` / `vite.config.*`),
/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/package.json` 已存在),
/// 因此**先把包解到项目根**再走标准初始化:初始化会跳过 npm 脚手架(`game/index.html` 已存在),
/// 只补 `.agent` 身份、目录与 agent.db。
///
/// 任一步失败都删掉半成品目录,不留无法解释的项目。
/// 任一形态建项成功后都走 `register_forked_project_state_at` 收口(登记「可运行原型」与初始
/// 工程版本),任一步失败都删掉半成品目录,不留无法解释的项目。
#[allow(clippy::too_many_arguments)]
pub(crate) fn create_project_from_platform_fork_at(
projects_root: &Path,
@@ -91,8 +129,10 @@ pub(crate) fn create_project_from_platform_fork_at(
let requested_name = requested_name
.map(normalize_game_creation_project_name)
.transpose()?;
// 目录名先算出来:标识非法时要在创建任何目录之前失败,不能留下半成品。
let reference_relative = fork_reference_relative_path(game_id)?;
// 作品 ID 来自平台响应:先把形状钉住再创建任何目录,非法标识不得留下半成品。
if !crate::game_distribution_publish::is_safe_fork_game_id(game_id.trim()) {
return Err("Fork 来源作品 ID 无效,无法建立来源记录".to_string());
}
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
return Err("自动工作区根目录必须是绝对路径".to_string());
}
@@ -126,7 +166,7 @@ pub(crate) fn create_project_from_platform_fork_at(
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
enforce_project_permission_policy(&project_root, "project.create")?;
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
match source {
let mut project = match source {
// 工程源包:包本身就是可编辑工程,先解到项目根再初始化。
ProjectForkSourceKind::Project => {
on_stage(ForkSyncStage::Extract);
@@ -151,10 +191,11 @@ pub(crate) fn create_project_from_platform_fork_at(
ProjectForkSourceKind::Project,
),
)?;
Ok(project)
project
}
// 成品包:脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被
// 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。
// 成品包:脚手架必须先生成,否则 `create_npm_scaffold` 判据被成品包里的
// `index.html` / `package.json` 打断,项目从此无法发布;铺到哪里则由
// `fork_playable_root` 决定(预览根),不能铺进项目根或 `game/` 根。
ProjectForkSourceKind::Package => {
on_stage(ForkSyncStage::Project);
let project = init_local_game_project_at(
@@ -163,14 +204,17 @@ pub(crate) fn create_project_from_platform_fork_at(
&project_name,
)?;
on_stage(ForkSyncStage::Extract);
let reference_root = project_root.join(&reference_relative);
let playable_root = fork_playable_root(&project_root);
ensure_game_creator_private_directory_tree(
&reference_root,
"改编参考目录",
&playable_root,
"可玩参考目录",
)?;
// 发行包契约:入口 `index.html` 固定在包根(服务端 `validate_release_zip`
// 与 api-server 的 `package_entry_path` 都强制),所以整包解到预览根后
// 就是 `<预览根>/index.html`。
crate::template_library::extract_template_archive(
package_bytes,
&reference_root,
&playable_root,
)
.map_err(|error| format!("Fork 内容解压失败:{error}"))?;
write_project_fork_source(
@@ -181,9 +225,13 @@ pub(crate) fn create_project_from_platform_fork_at(
ProjectForkSourceKind::Package,
),
)?;
Ok(project)
project
}
}
};
// 建项收口:登记「可运行原型」与初始工程版本。返回收口**之后**重读的清单,
// 免得把过期快照交给进项目通道(它会据这份清单渲染首帧)。
project.manifest = register_forked_project_state_at(&project_root)?;
Ok(project)
})();
if result.is_err() {
let _ = fs::remove_dir_all(&project_root);
@@ -443,23 +491,23 @@ mod tests {
}
#[test]
fn fork_reference_relative_path_stays_inside_the_project() {
// 生产路径就是「把这唯一的相对路径 join 到项目根」,因此这里直接钉相对路径本身。
assert_eq!(
fork_reference_relative_path("game_1").expect("safe id"),
Path::new("reference").join("game_1")
);
assert_eq!(
Path::new("C:/work/project").join(fork_reference_relative_path("game_1").unwrap()),
Path::new("C:/work/project/reference/game_1")
);
// 路径不安全的标识一律失败关闭,不拼出逃出项目根的路径。
for unsafe_id in ["", "..", "../escape", "a/b", "a\\b", "C:game", " "] {
assert!(
fork_reference_relative_path(unsafe_id).is_err(),
"should reject {unsafe_id:?}"
);
}
fn playable_reference_root_is_the_project_preview_root() {
// 生产路径把成品包铺到「预览根」。它必须与预览服务读取的根(`project_game_root`)
// 是同一个答案,否则「铺进项目」与「预览服务哪里」会分叉成两份判据——fork 之后能跑
// 不能跑就取决于这份判据是否一致。
let root = test_root("playable-root");
fs::create_dir_all(root.join("game")).expect("create game dir");
fs::write(
root.join("game/package.json"),
r#"{"scripts":{"build":"vite build"}}"#,
)
.expect("write package.json");
let playable_root = fork_playable_root(&root);
assert_eq!(playable_root, root.join("game/dist"));
// 可玩参考永远落在项目内,不会拼出项目外的路径。
assert!(playable_root.starts_with(&root));
fs::remove_dir_all(&root).ok();
}
/// 最小工程源包:自带 `game/package.json` 与 `game/index.html`(这正是「解压即工程」的条件)。
@@ -482,7 +530,7 @@ mod tests {
}
#[test]
fn platform_project_bundle_source_yields_an_editable_project_without_reference_copy() {
fn platform_project_bundle_source_stays_source_only_with_an_initial_version() {
let projects_root = test_root("create-project-source");
let bytes = project_bundle_bytes();
let project = create_project_from_platform_fork_at(
@@ -516,10 +564,37 @@ mod tests {
assert!(root.join(".agent/manifest.json").is_file());
assert!(root.join(".agent/agent.db").is_file());
// 3) 工程源包不做参考副本:包里就是工程本身。
// 3) 工程源包不做参考副本,也不在 fork 时构建:包内不含构建产物(工程源包排除
// `game/dist`),因此本机还没有可运行的产物。
assert!(!root.join("reference").exists());
assert!(!root.join("game/dist").exists());
// 4) 来源记录带上取件形态(v2)。
// 4) 收口**如实**:登记一条初始工程版本,但 `code-prototype` 保持未完成——运行视图会
// 提示「需要先构建」,绝不谎报原型已完成。
let code_prototype = project
.manifest
.tasks
.iter()
.find(|task| task.id == "code-prototype")
.map(|task| task.status.clone())
.expect("code-prototype 必须存在于 seed 任务里");
assert_eq!(code_prototype, GameCreationAppTaskStatus::Pending);
assert!(!project_has_runnable_prototype(&project.manifest));
assert_eq!(
project.manifest.versions.len(),
1,
"fork 建项必须有初始版本"
);
let initial = &project.manifest.versions[0];
assert_eq!(initial.version_id, "initial-1");
assert_eq!(initial.project_revision, 1);
assert_eq!(
initial.created_reason,
GameIterationVersionCreatedReason::Initial
);
assert_eq!(initial.parent_version_id, None);
// 5) 来源记录带上取件形态(v2)。
let record = read_project_fork_source(root).expect("fork source record");
assert_eq!(record.schema_version, FORK_SOURCE_SCHEMA_VERSION);
assert_eq!(record.source, ProjectForkSourceKind::Project);
@@ -528,7 +603,7 @@ mod tests {
}
#[test]
fn platform_fork_project_gets_scaffold_reference_copy_and_source_record() {
fn platform_fork_project_gets_playable_reference_at_the_preview_root() {
let projects_root = test_root("create");
let bytes = release_package_bytes();
let project = create_project_from_platform_fork_at(
@@ -550,15 +625,43 @@ mod tests {
assert!(root.join("game/index.html").is_file());
assert!(root.join(".agent/manifest.json").is_file());
// 2) 参考副本落在子目录里,且与脚手架互不覆盖。
assert!(root.join("reference/game_parent/index.html").is_file());
assert!(root.join("reference/game_parent/game/main.js").is_file());
// 2) 成品铺在**预览根**(`game/dist`),与脚手架互不覆盖:预览根下必须有可玩入口,
// 否则运行视图即使放行也会报「游戏目录不存在」。不再另写一份 `reference/` 副本
// (第二份副本用户既看不到也播不了,还会翻倍占磁盘)。
assert!(!root.join("reference").exists());
assert_eq!(
fs::read_to_string(root.join("game/dist/index.html")).unwrap(),
"<html></html>"
);
assert_eq!(
fs::read_to_string(root.join("game/dist/game/main.js")).unwrap(),
"console.log('playable')"
);
assert_ne!(
fs::read_to_string(root.join("reference/game_parent/index.html")).unwrap(),
fs::read_to_string(root.join("game/dist/index.html")).unwrap(),
fs::read_to_string(root.join("game/index.html")).unwrap()
);
// 3) 来源记录与项目身份一致,并标明这条是成品包取件(v2 字段)。
// 3) 收口把「可运行原型」登记上:成品包自带可玩入口,所以任务置完成、版本为初始版本,
// 运行与发布门禁同时放行(这就是「fork 之后能直接运行」的判据本身)。
let code_prototype = project
.manifest
.tasks
.iter()
.find(|task| task.id == "code-prototype")
.map(|task| task.status.clone())
.expect("code-prototype 必须存在于 seed 任务里");
assert_eq!(code_prototype, GameCreationAppTaskStatus::Completed);
assert!(project_has_runnable_prototype(&project.manifest));
assert_eq!(
project.manifest.versions.len(),
1,
"fork 建项必须有初始版本"
);
assert_eq!(project.manifest.versions[0].version_id, "initial-1");
assert_eq!(project.manifest.versions[0].project_revision, 1);
// 4) 来源记录与项目身份一致,并标明这条是成品包取件(v2 字段)。
let record = read_project_fork_source(root).expect("fork source record");
assert_eq!(record.source, ProjectForkSourceKind::Package);
assert_eq!(record.game_id, "game_parent");
@@ -132,7 +132,7 @@ pub(crate) fn export_local_project_package_at(
/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。
pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300;
fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
pub(crate) fn project_has_runnable_prototype(manifest: &GameCreationAppManifest) -> bool {
let has_completed_prototype_task = manifest.tasks.iter().any(|task| {
task.id == "code-prototype" && task.status == GameCreationAppTaskStatus::Completed
});