实现游戏工作台 P0/P1 可运行版本能力 #132
@@ -161,9 +161,6 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- name: Prepare server-rs Rust dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -181,6 +178,9 @@ jobs:
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- name: Run server-rs workspace tests
|
||||
run: cargo test --locked --workspace --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||
|
||||
|
||||
@@ -885,6 +885,10 @@ function readBrowserDom(url) {
|
||||
|
||||
function resolveChromeBin() {
|
||||
for (const candidate of [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
'/opt/google/chrome/chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
'/usr/bin/google-chrome-stable',
|
||||
'/usr/bin/chromium',
|
||||
|
||||
@@ -2057,7 +2057,7 @@ mod canvas_generation_tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() {
|
||||
let temporary = tempfile::tempdir().expect("create accepted scan project");
|
||||
let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "accepted-scan", "恢复扫描测试")
|
||||
.expect("init accepted scan project");
|
||||
|
||||
+2
-2
@@ -736,7 +736,7 @@ mod external_generation_state_tests {
|
||||
|
||||
#[test]
|
||||
fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() {
|
||||
let temporary = tempfile::tempdir().expect("create generation ledger project");
|
||||
let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "generation-ledger", "生成账本测试")
|
||||
.expect("init project");
|
||||
@@ -846,7 +846,7 @@ mod external_generation_state_tests {
|
||||
|
||||
#[test]
|
||||
fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() {
|
||||
let temporary = tempfile::tempdir().expect("create legacy generation ledger project");
|
||||
let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-");
|
||||
let root = temporary.path();
|
||||
init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试")
|
||||
.expect("init project");
|
||||
|
||||
+1
-1
@@ -790,7 +790,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn generation_cleanup_failure_preserves_pending_identity_anchor() {
|
||||
let temporary = tempfile::tempdir().expect("create pending cleanup project");
|
||||
let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-");
|
||||
let root = temporary.path();
|
||||
let run_id = "generation-cleanup-order-run";
|
||||
init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试")
|
||||
|
||||
+1
-1
@@ -425,7 +425,7 @@ mod tests {
|
||||
const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载";
|
||||
const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker";
|
||||
|
||||
let directory = tempfile::tempdir().expect("temp project directory");
|
||||
let directory = crate::tests::canonical_test_tempdir("provider-request-project-");
|
||||
let root = directory.path().join("project");
|
||||
init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试")
|
||||
.expect("project init");
|
||||
|
||||
+4
-16
@@ -64,14 +64,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
|
||||
|
||||
#[tokio::test]
|
||||
async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"genarrative-game-chat-deadline-reconciliation-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock")
|
||||
.as_nanos()
|
||||
));
|
||||
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
|
||||
.expect("project init");
|
||||
bind_game_creator_agent_runtime_run_profile_at(
|
||||
@@ -271,14 +265,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_reconciliatio
|
||||
|
||||
#[test]
|
||||
fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"genarrative-game-chat-deadline-local-cleanup-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock")
|
||||
.as_nanos()
|
||||
));
|
||||
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
|
||||
.expect("project init");
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
|
||||
@@ -767,7 +767,7 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() {
|
||||
fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() {
|
||||
const RUN_ID: &str = "autonomous-manifest-waiting-parent";
|
||||
const TASK: &str = "生成完整小游戏并完成项目任务图";
|
||||
let temporary = tempfile::tempdir().expect("create manifest waiting root");
|
||||
let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "manifest-waiting-project", TASK)
|
||||
.expect("init manifest waiting project");
|
||||
@@ -981,7 +981,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
|
||||
const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏";
|
||||
const TEST_KEY: &str = "autonomous-final-reply-fallback-key";
|
||||
|
||||
let temporary = tempfile::tempdir().expect("create autonomous fallback root");
|
||||
let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-");
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "autonomous-fallback-project", TASK)
|
||||
.expect("init autonomous fallback project");
|
||||
|
||||
@@ -13,7 +13,6 @@ where
|
||||
.await
|
||||
.expect("pending continuation task must exist")
|
||||
}
|
||||
|
||||
async fn run_after_pending_stack_boundary<T>(
|
||||
future: std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'static>>,
|
||||
) -> T
|
||||
@@ -150,7 +149,6 @@ fn persist_game_creator_agent_runtime_continuation_reconciliation_emergency_at(
|
||||
);
|
||||
emit_game_creator_agent_runtime_update(root, &runtime.agent_id);
|
||||
}
|
||||
|
||||
pub(crate) async fn continue_game_creator_agent_pending_tool_action(
|
||||
root: PathBuf,
|
||||
agent_id: String,
|
||||
|
||||
@@ -1221,7 +1221,7 @@ mod orphaned_external_generation_recovery_tests {
|
||||
|
||||
#[test]
|
||||
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
|
||||
let temporary = tempfile::tempdir().expect("create orphan generation recovery project");
|
||||
let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-");
|
||||
let root = temporary.path();
|
||||
let run_id = "orphan-generation-recovery-run";
|
||||
init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试")
|
||||
|
||||
@@ -1249,6 +1249,88 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
if status == GameCreationAppTaskStatus::Completed && state.agent_id == "preview-playtest" {
|
||||
let manifest_before_completion = read_manifest_for_project(root)?;
|
||||
let required_tasks = autonomous_manifest_seed_tasks_for_source(&root_parent_binding.source);
|
||||
let required_by_id = required_tasks
|
||||
.iter()
|
||||
.map(|task| (task.id.as_str(), task))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let mut prerequisite_ids = std::collections::BTreeSet::new();
|
||||
let mut pending_ids = vec![state.agent_id.as_str()];
|
||||
while let Some(task_id) = pending_ids.pop() {
|
||||
if !prerequisite_ids.insert(task_id) {
|
||||
continue;
|
||||
}
|
||||
let task = required_by_id
|
||||
.get(task_id)
|
||||
.ok_or_else(|| format!("可运行版本项目完整性合同缺少任务:{task_id}"))?;
|
||||
pending_ids.extend(task.dependencies.iter().map(String::as_str));
|
||||
}
|
||||
let incomplete = required_tasks
|
||||
.iter()
|
||||
.filter(|required| prerequisite_ids.contains(required.id.as_str()))
|
||||
.filter(|required| required.id != state.agent_id)
|
||||
.filter(|required| {
|
||||
manifest_before_completion
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == required.id)
|
||||
.is_none_or(|task| task.status != GameCreationAppTaskStatus::Completed)
|
||||
})
|
||||
.map(|task| task.id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
if !incomplete.is_empty() {
|
||||
return Err(format!(
|
||||
"可运行版本项目完整性检查未通过:{}",
|
||||
incomplete.join("、")
|
||||
));
|
||||
}
|
||||
let readiness_records =
|
||||
latest_game_creator_agent_runtime_tasks(read_all_game_creator_agent_runtime_tasks(
|
||||
&game_creator_agent_runtime_task_path(root, "preview-readiness"),
|
||||
)?);
|
||||
let readiness = readiness_records
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|record| {
|
||||
record.parent_agent_id.as_deref() == Some(parent_agent_id.as_str())
|
||||
&& record.parent_run_id.as_deref() == Some(parent_run_id.as_str())
|
||||
&& record.status == "completed"
|
||||
})
|
||||
.ok_or_else(|| "可运行版本缺少 preview-readiness 完成回执".to_string())?;
|
||||
let current_revision = read_game_creator_agent_runtime_project_revision(root)?;
|
||||
let readiness_gate = read_game_creator_agent_runtime_verification_gate(
|
||||
root,
|
||||
&readiness.agent_id,
|
||||
&readiness.run_id,
|
||||
)?;
|
||||
if readiness_gate.last_verification_tool.as_deref() != Some("game.static_smoke")
|
||||
|| readiness_gate.last_verification_status.as_deref()
|
||||
!= Some(AGENT_RUNTIME_VERIFICATION_STATUS_PASSED)
|
||||
|| readiness_gate.verified_revision != Some(current_revision.revision)
|
||||
{
|
||||
return Err("可运行版本缺少当前 revision 的 game.static_smoke 通过凭证".to_string());
|
||||
}
|
||||
let contract = autonomous_playtest_completion_contract_for_state_at(root, state)?
|
||||
.ok_or_else(|| "可运行版本缺少 preview.validate 完成合同".to_string())?;
|
||||
let receipt = read_autonomous_playtest_receipt(root, &contract)?
|
||||
.ok_or_else(|| "可运行版本缺少 preview.validate 成功回执".to_string())?;
|
||||
verify_autonomous_playtest_evidence_files_at(root, &receipt)?;
|
||||
if receipt.revision != current_revision.revision {
|
||||
return Err(format!(
|
||||
"可运行版本 revision 不一致:receipt={} current={}",
|
||||
receipt.revision, current_revision.revision
|
||||
));
|
||||
}
|
||||
register_current_runnable_game_version_at(
|
||||
root,
|
||||
current_revision.revision,
|
||||
&receipt.agent_id,
|
||||
&receipt.run_id,
|
||||
&receipt.report.path,
|
||||
)?;
|
||||
}
|
||||
update_manifest_task_status_at(root, &state.agent_id, status.clone())?;
|
||||
append_agent_db_record(
|
||||
root,
|
||||
|
||||
+132
@@ -698,6 +698,138 @@ fn autonomous_preview_manifest_tasks_accept_bound_current_revision_receipts() {
|
||||
assert!(autonomous_game_build_completion_blocker_at_locked(&root, &playtest_state).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_playtest_terminal_registers_a_runnable_snapshot_before_downstream_publish_tasks_complete(
|
||||
) {
|
||||
let (_temporary, root, parent_state, contract) =
|
||||
autonomous_fixture("做一个完整小游戏", "autonomous-runnable-version-parent");
|
||||
for task_id in ["publish-strategy", "publish-package"] {
|
||||
update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Pending)
|
||||
.unwrap_or_else(|error| panic!("leave downstream {task_id} pending: {error}"));
|
||||
}
|
||||
let task_ids = read_manifest_for_project(&root)
|
||||
.expect("read autonomous manifest")
|
||||
.tasks
|
||||
.into_iter()
|
||||
.map(|task| task.id)
|
||||
.collect::<Vec<_>>();
|
||||
for task_id in task_ids {
|
||||
if !matches!(
|
||||
task_id.as_str(),
|
||||
"preview-readiness" | "preview-playtest" | "publish-strategy" | "publish-package"
|
||||
) {
|
||||
update_manifest_task_status_at(&root, &task_id, GameCreationAppTaskStatus::Completed)
|
||||
.unwrap_or_else(|error| panic!("complete prerequisite {task_id}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"preview-readiness",
|
||||
GameCreationAppTaskStatus::Running,
|
||||
)
|
||||
.expect("mark preview readiness running");
|
||||
let readiness_child =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-readiness");
|
||||
let revision = advance_game_index_revision(
|
||||
&root,
|
||||
&parent_state,
|
||||
"<!doctype html><title>可运行版本</title><canvas></canvas>",
|
||||
);
|
||||
let readiness_state = agent_runtime_state_from_task_record(&readiness_child);
|
||||
mark_verification_passed(&root, &readiness_state, "game.static_smoke");
|
||||
let readiness_terminal = AgentRuntimeTaskRecord {
|
||||
status: "completed".to_string(),
|
||||
phase: "completed".to_string(),
|
||||
updated_at: unix_timestamp(),
|
||||
..readiness_child
|
||||
};
|
||||
append_game_creator_agent_runtime_task_record(&root, &readiness_terminal)
|
||||
.expect("persist completed preview readiness record");
|
||||
project_autonomous_manifest_ready_task_terminal_at(
|
||||
&root,
|
||||
&agent_runtime_state_from_task_record(&readiness_terminal),
|
||||
)
|
||||
.expect("project preview readiness completion");
|
||||
|
||||
update_manifest_task_status_at(
|
||||
&root,
|
||||
"preview-playtest",
|
||||
GameCreationAppTaskStatus::Running,
|
||||
)
|
||||
.expect("mark preview playtest running");
|
||||
let playtest_child =
|
||||
queue_autonomous_manifest_child_fixture(&root, &parent_state, "preview-playtest");
|
||||
let playtest_state = agent_runtime_state_from_task_record(&playtest_child);
|
||||
let result = browser_result_fixture(
|
||||
&root,
|
||||
&parent_state,
|
||||
revision,
|
||||
BrowserPlaytestScenario::GenericV1,
|
||||
);
|
||||
let action = AgentRuntimeToolAction {
|
||||
tool: "preview.validate".to_string(),
|
||||
reason: Some("验证可运行版本".to_string()),
|
||||
input: serde_json::json!({}),
|
||||
};
|
||||
let action_fingerprint =
|
||||
agent_runtime_tool_action_fingerprint(&action, &playtest_state.current_task);
|
||||
let action_id =
|
||||
agent_runtime_tool_action_id(&playtest_state.run_id, 1, 0, 1, &action_fingerprint);
|
||||
write_autonomous_playtest_receipt_at(
|
||||
&root,
|
||||
&contract,
|
||||
&action_id,
|
||||
&action_fingerprint,
|
||||
revision,
|
||||
&result,
|
||||
)
|
||||
.expect("persist runnable playtest receipt");
|
||||
let playtest_terminal = AgentRuntimeTaskRecord {
|
||||
status: "completed".to_string(),
|
||||
phase: "completed".to_string(),
|
||||
updated_at: unix_timestamp(),
|
||||
..playtest_child
|
||||
};
|
||||
append_game_creator_agent_runtime_task_record(&root, &playtest_terminal)
|
||||
.expect("persist completed preview playtest record");
|
||||
project_autonomous_manifest_ready_task_terminal_at(
|
||||
&root,
|
||||
&agent_runtime_state_from_task_record(&playtest_terminal),
|
||||
)
|
||||
.expect("project preview playtest and register runnable version");
|
||||
|
||||
let manifest = read_manifest_for_project(&root).expect("read runnable manifest");
|
||||
assert_eq!(manifest.runnable_versions.len(), 1);
|
||||
let version = &manifest.runnable_versions[0];
|
||||
assert_eq!(version.project_revision, revision);
|
||||
assert_eq!(
|
||||
version.created_reason,
|
||||
RunnableGameVersionCreatedReason::Initial
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.current_runnable_version_id.as_deref(),
|
||||
Some(version.version_id.as_str())
|
||||
);
|
||||
assert!(root
|
||||
.join(&version.artifact_path)
|
||||
.join("game/index.html")
|
||||
.is_file());
|
||||
for task_id in ["publish-strategy", "publish-package"] {
|
||||
let status = manifest
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
.map(|task| &task.status)
|
||||
.unwrap_or_else(|| panic!("missing downstream task {task_id}"));
|
||||
assert_ne!(
|
||||
status,
|
||||
&GameCreationAppTaskStatus::Completed,
|
||||
"runnable registration must not wait for downstream {task_id} completion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autonomous_completion_rejects_formal_artifact_unchanged_from_run_baseline() {
|
||||
let baseline_bytes =
|
||||
|
||||
@@ -827,10 +827,11 @@ mod tests {
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("system clock should be after epoch")
|
||||
.as_nanos();
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"genarrative-context-window-boundary-{}-{unique}",
|
||||
let temporary = crate::tests::canonical_test_tempdir(&format!(
|
||||
"context-window-boundary-{}-{unique}-",
|
||||
std::process::id()
|
||||
));
|
||||
let root = temporary.path().join("project");
|
||||
init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目")
|
||||
.expect("project init");
|
||||
let mut runtime = start_game_creator_agent_runtime_task_at(
|
||||
|
||||
@@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout(
|
||||
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_resource_graph(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
resources: Vec<ProjectResourceGraphNodeInput>,
|
||||
) -> Result<ProjectResourceGraphReadModel, String> {
|
||||
let root = validated_local_project_directory_path(project_path.trim())?;
|
||||
enforce_project_auto_permission_policy(&root, "asset.list")?;
|
||||
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_resource_canvas_layout(
|
||||
project_path: String,
|
||||
@@ -1166,6 +1177,66 @@ pub(crate) fn read_local_project_image_preview(
|
||||
load_local_project_image_preview(root, &normalized_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_text_preview(
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
) -> Result<LocalProjectTextPreview, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||||
let is_registered_document = manifest.assets.iter().any(|asset| {
|
||||
asset.local_path == normalized_path
|
||||
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
|
||||
}) || manifest.tasks.iter().any(|task| {
|
||||
task.status == GameCreationAppTaskStatus::Completed
|
||||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||||
&& is_supported_project_text_resource(&normalized_path, "")
|
||||
});
|
||||
if !is_registered_document {
|
||||
return Err("只能读取当前项目已登记的文档资源".to_string());
|
||||
}
|
||||
load_local_project_text_preview(root, &normalized_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_project_media_preview(
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
category: String,
|
||||
) -> Result<LocalProjectMediaPreview, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_auto_permission_policy(root, "file.read")?;
|
||||
let normalized_path = normalize_relative_path(relative_path.trim())?;
|
||||
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
|
||||
let kind = match category.trim() {
|
||||
"art" => ProjectMediaPreviewKind::Art,
|
||||
"audio" => ProjectMediaPreviewKind::Audio,
|
||||
_ => return Err("媒体预览类别只支持 art 或 audio".to_string()),
|
||||
};
|
||||
let is_registered_media = manifest.assets.iter().any(|asset| {
|
||||
asset.local_path == normalized_path
|
||||
&& match kind {
|
||||
ProjectMediaPreviewKind::Art => {
|
||||
is_supported_project_art_media_resource(&asset.local_path, &asset.media_type)
|
||||
}
|
||||
ProjectMediaPreviewKind::Audio => {
|
||||
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
|
||||
}
|
||||
}
|
||||
}) || (kind == ProjectMediaPreviewKind::Art
|
||||
&& manifest.tasks.iter().any(|task| {
|
||||
task.status == GameCreationAppTaskStatus::Completed
|
||||
&& task.artifacts.iter().any(|path| path == &normalized_path)
|
||||
&& is_supported_project_art_media_resource(&normalized_path, "")
|
||||
}));
|
||||
if !is_registered_media {
|
||||
return Err("只能预览当前项目已登记的媒体资源".to_string());
|
||||
}
|
||||
load_local_project_media_preview(root, &normalized_path, kind)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn write_local_project_file(
|
||||
project_path: String,
|
||||
|
||||
@@ -204,7 +204,10 @@ fn validate_agent_runtime_inspection_path(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_agent_runtime_inspection_ancestors(root: &Path, path: &Path) -> Result<(), String> {
|
||||
pub(crate) fn validate_agent_runtime_inspection_ancestors(
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
) -> Result<(), String> {
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| "image.inspect 图片路径超出项目目录".to_string())?;
|
||||
@@ -450,7 +453,7 @@ fn metadata_is_windows_reparse_point(_metadata: &fs::Metadata) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
left.dev() == right.dev()
|
||||
&& left.ino() == right.ino()
|
||||
@@ -463,12 +466,12 @@ fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||
pub(crate) fn same_open_file_snapshot(left: &fs::Metadata, right: &fs::Metadata) -> bool {
|
||||
left.len() == right.len() && left.modified().ok() == right.modified().ok()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn same_open_file_identity(
|
||||
pub(crate) fn same_open_file_identity(
|
||||
_left_file: &fs::File,
|
||||
left: &fs::Metadata,
|
||||
_right_file: &fs::File,
|
||||
@@ -479,7 +482,7 @@ fn same_open_file_identity(
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn same_open_file_identity(
|
||||
pub(crate) fn same_open_file_identity(
|
||||
left_file: &fs::File,
|
||||
_left: &fs::Metadata,
|
||||
right_file: &fs::File,
|
||||
@@ -489,7 +492,7 @@ fn same_open_file_identity(
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn same_open_file_identity(
|
||||
pub(crate) fn same_open_file_identity(
|
||||
_left_file: &fs::File,
|
||||
left: &fs::Metadata,
|
||||
_right_file: &fs::File,
|
||||
|
||||
@@ -23,6 +23,7 @@ use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared_contracts::game_creation_app::{
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
validate_game_iteration_versions, validate_runnable_game_versions,
|
||||
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
|
||||
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
|
||||
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
|
||||
@@ -31,11 +32,14 @@ use shared_contracts::game_creation_app::{
|
||||
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
|
||||
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
|
||||
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
|
||||
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
|
||||
GameIterationVersionResourceBinding, ProjectResourceCanvasLayout,
|
||||
ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, RunnableGameVersion,
|
||||
RunnableGameVersionCreatedReason, RunnableGameVersionValidation,
|
||||
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
|
||||
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
|
||||
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
|
||||
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
RUNNABLE_GAME_VERSION_SCHEMA_VERSION,
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
@@ -72,6 +76,7 @@ mod project;
|
||||
mod provider_handoff;
|
||||
mod provider_retry;
|
||||
mod repository_context;
|
||||
mod resource_inspect;
|
||||
mod runner;
|
||||
mod swarm_cli;
|
||||
mod tool_plan_handoff;
|
||||
@@ -101,6 +106,7 @@ use preview::*;
|
||||
use process_session::*;
|
||||
use project::*;
|
||||
use repository_context::*;
|
||||
use resource_inspect::*;
|
||||
use runner::*;
|
||||
use swarm_cli::*;
|
||||
use user_input::*;
|
||||
@@ -144,6 +150,14 @@ struct LocalPreviewStatus {
|
||||
root: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RunnableGameVersionLaunchResult {
|
||||
manifest: GameCreationAppManifest,
|
||||
version: RunnableGameVersion,
|
||||
preview: LocalPreviewResult,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalGameProjectRevisionStatus {
|
||||
@@ -2026,6 +2040,7 @@ mod game_chat_release_client_exit_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn main() {
|
||||
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -2348,6 +2363,8 @@ fn main() {
|
||||
list_local_project_files,
|
||||
read_local_project_file,
|
||||
read_local_project_image_preview,
|
||||
read_local_project_text_preview,
|
||||
read_local_project_media_preview,
|
||||
write_local_project_file,
|
||||
delete_local_project_file,
|
||||
read_local_game_memory,
|
||||
@@ -2374,11 +2391,13 @@ fn main() {
|
||||
open_game_creator_launcher_window,
|
||||
open_project_supervisor_chat_window,
|
||||
start_local_game_preview,
|
||||
launch_local_game_runnable_version,
|
||||
activate_local_game_preview,
|
||||
stop_local_game_preview,
|
||||
stop_local_game_preview_if_matches,
|
||||
get_local_game_preview_status,
|
||||
read_local_project_resource_canvas_layout,
|
||||
read_local_project_resource_graph,
|
||||
update_local_project_resource_canvas_layout,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest
|
||||
|
||||
@@ -1931,7 +1931,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn mcp_test_project(label: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
let temp_root = std::env::temp_dir()
|
||||
.canonicalize()
|
||||
.expect("canonicalize MCP test temp root");
|
||||
let root = temp_root.join(format!(
|
||||
"game-creator-mcp-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
|
||||
@@ -382,6 +382,14 @@ pub(crate) fn activate_local_game_preview(
|
||||
pub(crate) fn start_local_game_preview_for_project(
|
||||
root: &Path,
|
||||
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
|
||||
start_local_game_preview_for_served_root(root, root)
|
||||
}
|
||||
|
||||
pub(crate) fn start_local_game_preview_for_served_root(
|
||||
project_root: &Path,
|
||||
served_root: &Path,
|
||||
) -> Result<(LocalPreviewResult, mpsc::Sender<()>), String> {
|
||||
let root = project_root;
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("项目目录不能为空".to_string());
|
||||
}
|
||||
@@ -389,7 +397,10 @@ pub(crate) fn start_local_game_preview_for_project(
|
||||
return Err("项目目录必须是绝对路径".to_string());
|
||||
}
|
||||
|
||||
let game_root = root.join("game");
|
||||
if served_root.as_os_str().is_empty() || !served_root.is_absolute() {
|
||||
return Err("预览产物目录无效".to_string());
|
||||
}
|
||||
let game_root = served_root.join("game");
|
||||
if !game_root.is_dir() {
|
||||
return Err(format!("游戏目录不存在:{}", game_root.display()));
|
||||
}
|
||||
@@ -409,7 +420,7 @@ pub(crate) fn start_local_game_preview_for_project(
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.map_err(|error| format!("设置预览监听失败:{error}"))?;
|
||||
let served_root = root.to_path_buf();
|
||||
let served_root = served_root.to_path_buf();
|
||||
let (stop_sender, stop_receiver) = mpsc::channel();
|
||||
|
||||
thread::spawn(move || loop {
|
||||
@@ -443,6 +454,80 @@ pub(crate) fn start_local_game_preview_for_project(
|
||||
))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn launch_local_game_runnable_version(
|
||||
project_path: String,
|
||||
expected_project_id: String,
|
||||
version_id: Option<String>,
|
||||
registry: tauri::State<'_, PreviewRegistry>,
|
||||
) -> Result<RunnableGameVersionLaunchResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "preview.start")?;
|
||||
let _lock = acquire_project_write_lock(root, "preview.start")?;
|
||||
let current_manifest = read_existing_manifest_for_project(root)?;
|
||||
if current_manifest.project_id != expected_project_id.trim() {
|
||||
return Err("可运行版本项目身份不一致".to_string());
|
||||
}
|
||||
let version_id = version_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.or(current_manifest.current_runnable_version_id.clone())
|
||||
.ok_or_else(|| "当前无可运行版本".to_string())?;
|
||||
|
||||
let _ = registry.stop_for_project(Some(root));
|
||||
let (_, version, artifact_root) =
|
||||
resolve_runnable_game_version_at(root, expected_project_id.trim(), &version_id)?;
|
||||
let (preview, stop) = match start_local_game_preview_for_served_root(root, &artifact_root) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
|
||||
return Err(format!("可运行版本预览启动失败:{error}"));
|
||||
}
|
||||
};
|
||||
if let Err(error) = record_preview_state(
|
||||
root,
|
||||
GameCreationAppPreviewStatus::Running,
|
||||
Some(preview.url.clone()),
|
||||
Some(preview.port),
|
||||
) {
|
||||
let _ = stop.send(());
|
||||
return Err(error);
|
||||
}
|
||||
if let Err(error) = append_preview_log(root, "running", Some(&preview.url)) {
|
||||
let _ = stop.send(());
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
|
||||
return Err(error);
|
||||
}
|
||||
let (preview, previous_preview) = registry.set_running(preview, stop);
|
||||
if let Some(previous_preview) = previous_preview.as_ref() {
|
||||
record_replaced_preview_stop(previous_preview);
|
||||
}
|
||||
if let Err(error) = append_preview_start_trace_step(root, &preview) {
|
||||
let _ = registry.stop();
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
|
||||
return Err(error);
|
||||
}
|
||||
let (manifest, selected_version, _) = match select_current_runnable_game_version_at(
|
||||
root,
|
||||
expected_project_id.trim(),
|
||||
&version.version_id,
|
||||
) {
|
||||
Ok(selected) => selected,
|
||||
Err(error) => {
|
||||
let _ = registry.stop();
|
||||
let _ = record_preview_state(root, GameCreationAppPreviewStatus::Failed, None, None);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
Ok(RunnableGameVersionLaunchResult {
|
||||
manifest,
|
||||
version: selected_version,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_preview_stream(mut stream: TcpStream, root: &Path) {
|
||||
// The listener is nonblocking so its accept loop can observe the stop channel. Windows may
|
||||
// inherit that mode on accepted sockets; switch each connection back to blocking mode before
|
||||
|
||||
@@ -10,7 +10,9 @@ mod export;
|
||||
mod filesystem;
|
||||
mod manifest;
|
||||
mod memory;
|
||||
mod resource_dependency_graph;
|
||||
mod resource_layout;
|
||||
mod runnable_versions;
|
||||
mod verification;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
@@ -20,5 +22,7 @@ pub(crate) use export::*;
|
||||
pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
pub(crate) use runnable_versions::*;
|
||||
pub(crate) use verification::*;
|
||||
|
||||
@@ -631,8 +631,17 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.open(source_path)
|
||||
.and_then(|mut file| file.read_to_string(&mut payload))
|
||||
.map_err(|error| format!("读取 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
serde_json::from_str(&payload)
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))
|
||||
let manifest: GameCreationAppManifest = serde_json::from_str(&payload)
|
||||
.map_err(|error| format!("解析 {label} 失败:{}: {error}", source_path.display()))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
|
||||
validate_runnable_game_versions(
|
||||
&manifest.project_id,
|
||||
&manifest.runnable_versions,
|
||||
manifest.current_runnable_version_id.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("校验 {label} 可运行版本失败:{error}"))?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn install_manifest_temp_with<F>(
|
||||
@@ -709,6 +718,35 @@ pub(crate) fn write_manifest(
|
||||
path: &Path,
|
||||
manifest: &GameCreationAppManifest,
|
||||
) -> Result<(), String> {
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 manifest 项目版本失败:{error}"))?;
|
||||
validate_runnable_game_versions(
|
||||
&manifest.project_id,
|
||||
&manifest.runnable_versions,
|
||||
manifest.current_runnable_version_id.as_deref(),
|
||||
)
|
||||
.map_err(|error| format!("校验 manifest 可运行版本失败:{error}"))?;
|
||||
if manifest_storage_exists(path)? {
|
||||
let existing = read_manifest(path)?;
|
||||
if existing.versions.len() > manifest.versions.len()
|
||||
|| existing
|
||||
.versions
|
||||
.iter()
|
||||
.zip(&manifest.versions)
|
||||
.any(|(existing, candidate)| existing != candidate)
|
||||
{
|
||||
return Err("项目版本记录写入后不可修改、删除或重排".to_string());
|
||||
}
|
||||
if existing.runnable_versions.len() > manifest.runnable_versions.len()
|
||||
|| existing
|
||||
.runnable_versions
|
||||
.iter()
|
||||
.zip(&manifest.runnable_versions)
|
||||
.any(|(existing, candidate)| existing != candidate)
|
||||
{
|
||||
return Err("可运行版本记录写入后不可修改、删除或重排".to_string());
|
||||
}
|
||||
}
|
||||
let payload = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
|
||||
};
|
||||
|
||||
fn unique_manifest_test_root(test_name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!(
|
||||
@@ -40,6 +43,59 @@ fn manifest_read_and_project_write_recover_previous_file() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
fn version_fixture(
|
||||
version_id: &str,
|
||||
parent_version_id: Option<&str>,
|
||||
project_revision: u64,
|
||||
created_reason: GameIterationVersionCreatedReason,
|
||||
) -> GameIterationVersion {
|
||||
GameIterationVersion {
|
||||
version_id: version_id.to_string(),
|
||||
parent_version_id: parent_version_id.map(str::to_string),
|
||||
project_revision,
|
||||
resource_bindings: vec![GameIterationVersionResourceBinding {
|
||||
slot_id: "player".to_string(),
|
||||
resource_id: "asset-player".to_string(),
|
||||
}],
|
||||
created_reason,
|
||||
created_at: project_revision,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_versions_are_append_only_at_the_storage_boundary() {
|
||||
let root = unique_manifest_test_root("versions-append-only");
|
||||
let manifest_path = root.join(".agent/manifest.json");
|
||||
let mut manifest = new_game_creation_app_manifest("project-versioned", "版本项目");
|
||||
manifest.versions.push(version_fixture(
|
||||
"version-root",
|
||||
None,
|
||||
1,
|
||||
GameIterationVersionCreatedReason::Initial,
|
||||
));
|
||||
write_manifest(&manifest_path, &manifest).expect("write initial version");
|
||||
|
||||
manifest.versions.push(version_fixture(
|
||||
"version-child",
|
||||
Some("version-root"),
|
||||
2,
|
||||
GameIterationVersionCreatedReason::AgentRevision,
|
||||
));
|
||||
write_manifest(&manifest_path, &manifest).expect("append child version");
|
||||
|
||||
let stable_payload = fs::read(&manifest_path).expect("read stable manifest bytes");
|
||||
manifest.versions[0].resource_bindings[0].resource_id = "asset-mutated".to_string();
|
||||
let error =
|
||||
write_manifest(&manifest_path, &manifest).expect_err("reject mutation of existing version");
|
||||
assert!(error.contains("不可修改、删除或重排"), "{error}");
|
||||
assert_eq!(
|
||||
fs::read(&manifest_path).expect("read untouched manifest bytes"),
|
||||
stable_payload
|
||||
);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_install_uses_previous_when_direct_replace_fails() {
|
||||
let root = unique_manifest_test_root("replace-fallback");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,441 @@
|
||||
use crate::image_inspect::{
|
||||
same_open_file_identity, same_open_file_snapshot, validate_agent_runtime_inspection_ancestors,
|
||||
};
|
||||
use crate::project::{
|
||||
normalize_relative_path, open_project_snapshot_regular_file,
|
||||
reject_sensitive_project_file_read, resolve_local_project_path,
|
||||
};
|
||||
use base64::Engine as _;
|
||||
use serde::Serialize;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
|
||||
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectTextPreview {
|
||||
pub(crate) path: String,
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct LocalProjectMediaPreview {
|
||||
pub(crate) path: String,
|
||||
pub(crate) media_type: String,
|
||||
pub(crate) byte_len: u64,
|
||||
pub(crate) data_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum ProjectMediaPreviewKind {
|
||||
Art,
|
||||
Audio,
|
||||
}
|
||||
|
||||
pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -> bool {
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
path_extension(path).as_deref(),
|
||||
Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml")
|
||||
) && (media_type.is_empty()
|
||||
|| media_type.starts_with("text/")
|
||||
|| media_type.contains("json")
|
||||
|| media_type.contains("yaml")
|
||||
|| matches!(
|
||||
media_type.as_str(),
|
||||
"项目文档" | "application/toml" | "application/mdx"
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn is_supported_project_art_media_resource(path: &str, media_type: &str) -> bool {
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
path_extension(path).as_deref(),
|
||||
Some("gif" | "svg" | "avif" | "bmp" | "mp4" | "webm" | "mov")
|
||||
) || media_type.starts_with("video/")
|
||||
|| media_type == "image/svg+xml"
|
||||
}
|
||||
|
||||
pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str) -> bool {
|
||||
let media_type = media_type.trim().to_ascii_lowercase();
|
||||
matches!(
|
||||
path_extension(path).as_deref(),
|
||||
Some("mp3" | "wav" | "ogg" | "m4a" | "aac" | "flac" | "opus")
|
||||
) || media_type.starts_with("audio/")
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_project_text_preview(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
) -> Result<LocalProjectTextPreview, String> {
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let media_type = project_text_media_type(&normalized)
|
||||
.ok_or_else(|| "文档预览只支持 Markdown、文本、JSON、YAML 和 TOML".to_string())?;
|
||||
let bytes = read_stable_project_resource(
|
||||
root,
|
||||
&normalized,
|
||||
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
|
||||
"项目文档",
|
||||
)?;
|
||||
let content =
|
||||
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
|
||||
Ok(LocalProjectTextPreview {
|
||||
path: normalized,
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: content.len() as u64,
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_local_project_media_preview(
|
||||
root: &Path,
|
||||
relative_path: &str,
|
||||
kind: ProjectMediaPreviewKind,
|
||||
) -> Result<LocalProjectMediaPreview, String> {
|
||||
let normalized = normalize_relative_path(relative_path.trim())?;
|
||||
reject_sensitive_project_file_read(&normalized)?;
|
||||
let bytes = read_stable_project_resource(
|
||||
root,
|
||||
&normalized,
|
||||
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
|
||||
"项目媒体资源",
|
||||
)?;
|
||||
if bytes.is_empty() {
|
||||
return Err("媒体文件为空,无法预览".to_string());
|
||||
}
|
||||
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
|
||||
Ok(LocalProjectMediaPreview {
|
||||
path: normalized,
|
||||
media_type: media_type.to_string(),
|
||||
byte_len: bytes.len() as u64,
|
||||
data_url: format!(
|
||||
"data:{media_type};base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_stable_project_resource(
|
||||
root: &Path,
|
||||
normalized: &str,
|
||||
max_bytes: u64,
|
||||
label: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let absolute = resolve_local_project_path(root, normalized)?;
|
||||
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
|
||||
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
|
||||
if initial_metadata.len() > max_bytes {
|
||||
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
|
||||
file.by_ref()
|
||||
.take(max_bytes + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
|
||||
if bytes.len() as u64 > max_bytes {
|
||||
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
|
||||
}
|
||||
let final_metadata = file
|
||||
.metadata()
|
||||
.map_err(|error| format!("复核{label}失败:{normalized}: {error}"))?;
|
||||
if initial_metadata.len() != bytes.len() as u64
|
||||
|| final_metadata.len() != bytes.len() as u64
|
||||
|| !same_open_file_snapshot(&initial_metadata, &final_metadata)
|
||||
{
|
||||
return Err(format!("{label}读取期间发生漂移:{normalized}"));
|
||||
}
|
||||
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
|
||||
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
|
||||
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn project_text_media_type(path: &str) -> Option<&'static str> {
|
||||
match path_extension(path).as_deref()? {
|
||||
"md" | "markdown" | "mdx" => Some("text/markdown"),
|
||||
"txt" => Some("text/plain"),
|
||||
"json" => Some("application/json"),
|
||||
"yaml" | "yml" => Some("application/yaml"),
|
||||
"toml" => Some("application/toml"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_project_media_type(
|
||||
path: &str,
|
||||
bytes: &[u8],
|
||||
kind: ProjectMediaPreviewKind,
|
||||
) -> Result<&'static str, String> {
|
||||
if kind == ProjectMediaPreviewKind::Art && path_extension(path).as_deref() == Some("svg") {
|
||||
validate_safe_svg(bytes)?;
|
||||
return Ok("image/svg+xml");
|
||||
}
|
||||
if kind == ProjectMediaPreviewKind::Art {
|
||||
if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
|
||||
return Ok("image/gif");
|
||||
}
|
||||
if bytes.starts_with(b"BM") {
|
||||
return Ok("image/bmp");
|
||||
}
|
||||
if is_avif(bytes) {
|
||||
return Ok("image/avif");
|
||||
}
|
||||
if is_iso_base_media(bytes) {
|
||||
return Ok(if path_extension(path).as_deref() == Some("mov") {
|
||||
"video/quicktime"
|
||||
} else {
|
||||
"video/mp4"
|
||||
});
|
||||
}
|
||||
if bytes.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]) {
|
||||
return Ok("video/webm");
|
||||
}
|
||||
return Err("美术媒体预览只支持 GIF、安全 SVG、AVIF、BMP、MP4、WebM 或 MOV".to_string());
|
||||
}
|
||||
|
||||
if looks_like_id3(bytes) || looks_like_mp3_frame(bytes) {
|
||||
Ok("audio/mpeg")
|
||||
} else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE" {
|
||||
Ok("audio/wav")
|
||||
} else if bytes.starts_with(b"OggS") {
|
||||
Ok("audio/ogg")
|
||||
} else if bytes.starts_with(b"fLaC") {
|
||||
Ok("audio/flac")
|
||||
} else if is_avif(bytes) {
|
||||
Err("音乐音效文件签名与登记类型不一致".to_string())
|
||||
} else if is_iso_base_media(bytes) {
|
||||
Ok("audio/mp4")
|
||||
} else if looks_like_aac_adts(bytes) {
|
||||
Ok("audio/aac")
|
||||
} else {
|
||||
Err("音乐音效预览只支持 MP3、WAV、OGG、M4A、AAC、FLAC 或 Opus".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
|
||||
let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?;
|
||||
let lower = text.to_ascii_lowercase();
|
||||
if !lower.contains("<svg") {
|
||||
return Err("SVG 内容无效,无法预览".to_string());
|
||||
}
|
||||
let forbidden = [
|
||||
"<script",
|
||||
"<style",
|
||||
"<foreignobject",
|
||||
"<image",
|
||||
"<!doctype",
|
||||
"<!entity",
|
||||
"&#",
|
||||
"javascript:",
|
||||
"file:",
|
||||
"@import",
|
||||
];
|
||||
let external_url_probe = lower
|
||||
.replace("http://www.w3.org/2000/svg", "")
|
||||
.replace("http://www.w3.org/1999/xlink", "");
|
||||
if forbidden.iter().any(|value| lower.contains(value))
|
||||
|| external_url_probe.contains("http://")
|
||||
|| external_url_probe.contains("https://")
|
||||
|| contains_svg_event_handler(&lower)
|
||||
|| contains_unsafe_svg_href(&lower)
|
||||
|| contains_unsafe_svg_url(&lower)
|
||||
{
|
||||
return Err("SVG 包含脚本或外部资源引用,无法安全预览".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn contains_unsafe_svg_href(text: &str) -> bool {
|
||||
let mut remaining = text;
|
||||
while let Some(index) = remaining.find("href") {
|
||||
let after_name = &remaining[index + 4..];
|
||||
let Some(after_equals) = after_name.trim_start().strip_prefix('=') else {
|
||||
remaining = after_name;
|
||||
continue;
|
||||
};
|
||||
let value = after_equals.trim_start();
|
||||
let value = value
|
||||
.strip_prefix('\'')
|
||||
.or_else(|| value.strip_prefix('"'))
|
||||
.unwrap_or(value)
|
||||
.trim_start();
|
||||
if !value.starts_with('#') {
|
||||
return true;
|
||||
}
|
||||
remaining = after_name;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn contains_unsafe_svg_url(text: &str) -> bool {
|
||||
let mut remaining = text;
|
||||
while let Some(index) = remaining.find("url(") {
|
||||
let value = remaining[index + 4..].trim_start();
|
||||
let value = value
|
||||
.strip_prefix('\'')
|
||||
.or_else(|| value.strip_prefix('"'))
|
||||
.unwrap_or(value)
|
||||
.trim_start();
|
||||
if !value.starts_with('#') {
|
||||
return true;
|
||||
}
|
||||
remaining = &remaining[index + 4..];
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn contains_svg_event_handler(text: &str) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
let mut index = 0usize;
|
||||
while index + 3 < bytes.len() {
|
||||
if bytes[index].is_ascii_whitespace() && bytes[index + 1..].starts_with(b"on") {
|
||||
let mut cursor = index + 3;
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_alphabetic() {
|
||||
cursor += 1;
|
||||
}
|
||||
while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() {
|
||||
cursor += 1;
|
||||
}
|
||||
if cursor < bytes.len() && bytes[cursor] == b'=' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn is_iso_base_media(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 12 && &bytes[4..8] == b"ftyp"
|
||||
}
|
||||
|
||||
fn is_avif(bytes: &[u8]) -> bool {
|
||||
is_iso_base_media(bytes)
|
||||
&& (&bytes[8..12] == b"avif"
|
||||
|| &bytes[8..12] == b"avis"
|
||||
|| bytes[8..].windows(4).any(|brand| brand == b"avif"))
|
||||
}
|
||||
|
||||
fn looks_like_mp3_frame(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 4
|
||||
&& bytes[0] == 0xff
|
||||
&& bytes[1] & 0xe0 == 0xe0
|
||||
&& bytes[1] & 0x06 != 0
|
||||
&& bytes[2] & 0xf0 != 0xf0
|
||||
&& bytes[2] & 0x0c != 0x0c
|
||||
}
|
||||
|
||||
fn looks_like_id3(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 10 || !bytes.starts_with(b"ID3") || bytes[3] == 0xff || bytes[4] == 0xff {
|
||||
return false;
|
||||
}
|
||||
let size_bytes = &bytes[6..10];
|
||||
if size_bytes.iter().any(|byte| byte & 0x80 != 0) {
|
||||
return false;
|
||||
}
|
||||
let tag_size = size_bytes
|
||||
.iter()
|
||||
.fold(0usize, |size, byte| (size << 7) | usize::from(*byte));
|
||||
10usize
|
||||
.checked_add(tag_size)
|
||||
.is_some_and(|required| required <= bytes.len())
|
||||
}
|
||||
|
||||
fn looks_like_aac_adts(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[0] == 0xff && bytes[1] & 0xf6 == 0xf0
|
||||
}
|
||||
|
||||
fn path_extension(path: &str) -> Option<String> {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn text_preview_requires_utf8_and_a_supported_extension() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
|
||||
fs::write(root.path().join("docs/design.md"), "# 设计\n\n正文").expect("markdown");
|
||||
fs::write(root.path().join("docs/legacy.txt"), [0xff, 0xfe]).expect("legacy text");
|
||||
fs::write(root.path().join("docs/page.html"), "<h1>unsafe</h1>").expect("html");
|
||||
|
||||
let preview =
|
||||
load_local_project_text_preview(root.path(), "docs/design.md").expect("load markdown");
|
||||
assert_eq!(preview.media_type, "text/markdown");
|
||||
assert!(preview.content.contains("正文"));
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/legacy.txt").is_err());
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/page.html").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_preview_accepts_safe_svg_and_rejects_active_svg() {
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
fs::create_dir_all(root.path().join("assets")).expect("assets dir");
|
||||
fs::write(
|
||||
root.path().join("assets/icon.svg"),
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>",
|
||||
)
|
||||
.expect("svg");
|
||||
fs::write(
|
||||
root.path().join("assets/active.svg"),
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" onload=\"alert(1)\"/>",
|
||||
)
|
||||
.expect("active svg");
|
||||
fs::write(
|
||||
root.path().join("assets/external.svg"),
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><use href = \"https://example.com/icon.svg#x\"/></svg>",
|
||||
)
|
||||
.expect("external svg");
|
||||
|
||||
let preview = load_local_project_media_preview(
|
||||
root.path(),
|
||||
"assets/icon.svg",
|
||||
ProjectMediaPreviewKind::Art,
|
||||
)
|
||||
.expect("safe svg");
|
||||
assert_eq!(preview.media_type, "image/svg+xml");
|
||||
assert!(preview.data_url.starts_with("data:image/svg+xml;base64,"));
|
||||
assert!(load_local_project_media_preview(
|
||||
root.path(),
|
||||
"assets/active.svg",
|
||||
ProjectMediaPreviewKind::Art,
|
||||
)
|
||||
.is_err());
|
||||
assert!(load_local_project_media_preview(
|
||||
root.path(),
|
||||
"assets/external.svg",
|
||||
ProjectMediaPreviewKind::Art,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn resource_preview_rejects_symlink_and_hardlink_files() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = tempfile::tempdir().expect("temp root");
|
||||
let outside = tempfile::tempdir().expect("outside");
|
||||
fs::create_dir_all(root.path().join("docs")).expect("docs dir");
|
||||
let source = outside.path().join("source.md");
|
||||
fs::write(&source, "secret").expect("source");
|
||||
symlink(&source, root.path().join("docs/link.md")).expect("symlink");
|
||||
fs::hard_link(&source, root.path().join("docs/hard.md")).expect("hardlink");
|
||||
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
|
||||
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1606,5 +1606,12 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser
|
||||
.expect("read barrier after rejecting reserved delivery")
|
||||
.is_clear());
|
||||
|
||||
let released = wait_for_agent_runtime_lane_release_async(
|
||||
&root,
|
||||
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(released.state.run_id, parent_run_id);
|
||||
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user