Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/project.rs
T
AIGameCreator App ac4448a2ef 补齐Agent Runtime任务状态工具
后台 Agent 工具箱新增 task.update,可由 Agent 更新 manifest 任务状态。

任务状态更新复用 task.update 权限策略、项目写锁和 manifest 任务真相源。

补充任务状态更新成功与策略拦截测试,同步 Runtime V1 文档。
2026-07-10 01:32:00 +08:00

1715 lines
60 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub(crate) fn init_local_game_project_at(
root: &Path,
project_id: &str,
name: &str,
) -> Result<InitLocalProjectResult, String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
if project_id.is_empty() {
return Err("项目 ID 不能为空".to_string());
}
if name.is_empty() {
return Err("项目名称不能为空".to_string());
}
for relative in [
"game",
"assets",
"memory",
"memory/agents",
"exports",
".agent/logs",
] {
fs::create_dir_all(root.join(relative)).map_err(|error| {
format!(
"创建本地项目目录失败:{}: {error}",
root.join(relative).display()
)
})?;
}
let index_path = root.join("game/index.html");
if !index_path.exists() {
fs::write(&index_path, DEFAULT_GAME_INDEX_HTML)
.map_err(|error| format!("写入默认游戏入口失败:{}: {error}", index_path.display()))?;
}
let agent_db_path = root.join(".agent/agent.db");
if !agent_db_path.exists() {
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.init",
"projectId": project_id,
"name": name,
}),
)?;
}
let manifest_path = root.join(".agent/manifest.json");
if !manifest_path.exists() {
let manifest = new_game_creation_app_manifest(project_id, name);
write_manifest(&manifest_path, &manifest)?;
}
let manifest = ensure_manifest_has_seed_tasks(root, None)?;
Ok(InitLocalProjectResult {
project_path: root.to_string_lossy().into_owned(),
manifest_path: manifest_path.to_string_lossy().into_owned(),
manifest,
})
}
pub(crate) fn append_agent_db_record(
root: &Path,
mut record: serde_json::Value,
) -> Result<(), String> {
let path = root.join(".agent/agent.db");
let object = record
.as_object_mut()
.ok_or_else(|| "Agent DB record 必须是 JSON object".to_string())?;
object.insert(
"schemaVersion".to_string(),
serde_json::Value::String(GAME_CREATOR_AGENT_DB_SCHEMA_VERSION.to_string()),
);
object.insert(
"updatedAt".to_string(),
serde_json::Value::Number(serde_json::Number::from(unix_timestamp())),
);
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("打开 Agent 本地索引失败:{}: {error}", path.display()))?;
let line = serde_json::to_string(&record)
.map_err(|error| format!("序列化 Agent 本地索引失败:{error}"))?;
file.write_all(format!("{line}\n").as_bytes())
.map_err(|error| format!("写入 Agent 本地索引失败:{}: {error}", path.display()))
}
#[derive(Debug)]
pub(crate) struct ProjectWriteLock {
path: PathBuf,
}
impl Drop for ProjectWriteLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub(crate) fn acquire_project_write_lock(
root: &Path,
command_id: &str,
) -> Result<ProjectWriteLock, String> {
validate_project_root(root)?;
let path = root.join(PROJECT_WRITE_LOCK_PATH);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?;
}
let payload = serde_json::json!({
"commandId": command_id,
"pid": std::process::id(),
"createdAt": unix_timestamp(),
});
let content = serde_json::to_string_pretty(&payload)
.map_err(|error| format!("生成项目写锁失败:{error}"))?;
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&path)
{
Ok(mut file) => {
file.write_all(content.as_bytes())
.map_err(|error| format!("写入项目写锁失败:{}: {error}", path.display()))?;
Ok(ProjectWriteLock { path })
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
Err(format!("项目正在被其他写操作占用:{}", path.display()))
}
Err(error) => Err(format!("创建项目写锁失败:{}: {error}", path.display())),
}
}
pub(crate) fn read_local_game_memory_at(
root: &Path,
scope: &str,
) -> Result<LocalGameMemoryResult, String> {
let (scope, path) = memory_file_path(root, scope)?;
match fs::read_to_string(&path) {
Ok(content) => Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
content,
exists: true,
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
content: String::new(),
exists: false,
}),
Err(error) => Err(format!("读取记忆失败:{}: {error}", path.display())),
}
}
pub(crate) fn read_local_agent_memory_at(
root: &Path,
task_id: &str,
) -> Result<LocalAgentMemoryResult, String> {
let relative_path = agent_role_memory_relative_path_for_task(task_id)?;
let path = resolve_local_project_path(root, &relative_path)?;
match fs::read_to_string(&path) {
Ok(content) => Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
path: path.to_string_lossy().into_owned(),
content,
exists: true,
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
path: path.to_string_lossy().into_owned(),
content: String::new(),
exists: false,
}),
Err(error) => Err(format!("读取 Agent 记忆失败:{}: {error}", path.display())),
}
}
pub(crate) fn write_local_agent_memory_at(
root: &Path,
task_id: &str,
content: &str,
) -> Result<LocalAgentMemoryResult, String> {
let relative_path = agent_role_memory_relative_path_for_task(task_id)?;
let path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 Agent 记忆目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入 Agent 记忆失败:{}: {error}", path.display()))?;
Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
path: path.to_string_lossy().into_owned(),
content: content.to_string(),
exists: true,
})
}
pub(crate) fn write_local_game_memory_at(
root: &Path,
scope: &str,
content: &str,
) -> Result<LocalGameMemoryResult, String> {
let (scope, path) = memory_file_path(root, scope)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建记忆目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入记忆失败:{}: {error}", path.display()))?;
Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
content: content.to_string(),
exists: true,
})
}
pub(crate) fn delete_local_game_memory_at(
root: &Path,
scope: &str,
) -> Result<LocalGameMemoryResult, String> {
let (scope, path) = memory_file_path(root, scope)?;
match fs::remove_file(&path) {
Ok(()) => Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
content: String::new(),
exists: false,
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(LocalGameMemoryResult {
scope: scope.to_string(),
path: path.to_string_lossy().into_owned(),
content: String::new(),
exists: false,
}),
Err(error) => Err(format!("删除记忆失败:{}: {error}", path.display())),
}
}
pub(crate) fn read_local_conversation_at(
root: &Path,
agent_id: Option<&str>,
) -> Result<LocalConversationResult, String> {
let (path, normalized_agent_id) = conversation_file_path(root, agent_id)?;
let mut messages = Vec::new();
match File::open(&path) {
Ok(file) => {
for line in BufReader::new(file).lines() {
let line =
line.map_err(|error| format!("读取对话记录失败:{}: {error}", path.display()))?;
let line = line.trim();
if line.is_empty() {
continue;
}
let record = serde_json::from_str::<LocalConversationMessageRecord>(line)
.map_err(|error| format!("解析对话记录失败:{}: {error}", path.display()))?;
messages.push(record);
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("读取对话记录失败:{}: {error}", path.display())),
}
Ok(LocalConversationResult {
path: path.to_string_lossy().into_owned(),
agent_id: normalized_agent_id,
messages,
})
}
pub(crate) fn append_local_conversation_message_at(
root: &Path,
agent_id: Option<&str>,
message: LocalConversationMessage,
) -> Result<LocalConversationResult, String> {
let LocalConversationMessage {
role,
content,
agent_id: _client_agent_id,
} = message;
let (path, normalized_agent_id) = conversation_file_path(root, agent_id)?;
let role = role.trim();
if !matches!(role, "user" | "assistant" | "tool") {
return Err("对话角色必须是 user、assistant 或 tool".to_string());
}
let content = content.trim();
if content.is_empty() {
return read_local_conversation_at(root, agent_id);
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?;
}
let record = LocalConversationMessageRecord {
schema_version: LOCAL_CONVERSATION_SCHEMA_VERSION.to_string(),
role: role.to_string(),
content: content.to_string(),
agent_id: normalized_agent_id.clone(),
updated_at: unix_timestamp(),
};
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|error| format!("打开对话记录失败:{}: {error}", path.display()))?;
serde_json::to_writer(&mut file, &record)
.map_err(|error| format!("序列化对话记录失败:{error}"))?;
file.write_all(b"\n")
.map_err(|error| format!("写入对话记录失败:{}: {error}", path.display()))?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "conversation.message",
"agentId": normalized_agent_id,
"role": role,
"path": relative_project_path(root, &path)?,
}),
)?;
read_local_conversation_at(root, agent_id)
}
pub(crate) fn conversation_file_path(
root: &Path,
agent_id: Option<&str>,
) -> Result<(PathBuf, Option<String>), String> {
validate_project_root(root)?;
let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok((root.join(".agent/conversations/project.jsonl"), None));
};
let normalized = normalize_conversation_agent_id(agent_id)?;
Ok((
root.join(".agent/conversations/agents")
.join(format!("{normalized}.jsonl")),
Some(normalized),
))
}
pub(crate) fn normalize_conversation_agent_id(agent_id: &str) -> Result<String, String> {
if agent_id.is_empty()
|| agent_id.contains("..")
|| agent_id
.chars()
.any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'))
{
return Err("agent id 只能包含 ASCII 字母、数字、短横线和下划线".to_string());
}
Ok(agent_id.to_string())
}
pub(crate) fn append_markdown_entry(
path: &Path,
header: &str,
entry: &str,
error_label: &str,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("{error_label}:{}: {error}", parent.display()))?;
}
let needs_header = fs::metadata(path)
.map(|metadata| metadata.len() == 0)
.unwrap_or(true);
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.map_err(|error| format!("{error_label}:{}: {error}", path.display()))?;
if needs_header {
file.write_all(header.as_bytes())
.map_err(|error| format!("{error_label}:{}: {error}", path.display()))?;
}
file.write_all(entry.as_bytes())
.map_err(|error| format!("{error_label}:{}: {error}", path.display()))
}
pub(crate) fn append_local_permission_log_at(
root: &Path,
event: &str,
command_id: &str,
) -> Result<(), String> {
validate_project_root(root)?;
if !matches!(
event,
"permission.pending" | "permission.confirm" | "permission.cancel" | "command.auto"
) {
return Err("不支持的命令日志事件".to_string());
}
let Some(command) = GAME_CREATION_APP_COMMANDS
.iter()
.find(|command| command.id == command_id)
else {
return Err("不支持的内置命令".to_string());
};
if event == "command.auto" && command.permission != GameCreationAppPermission::Auto {
return Err("自动命令日志只能记录 auto 权限命令".to_string());
}
let log_path = root.join(".agent/logs/command.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
}
let line = format!("{} {event} {command_id}\n", unix_timestamp());
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))
}
pub(crate) fn run_limited_local_command_at(
root: &Path,
command_id: &str,
) -> Result<LimitedLocalCommandResult, String> {
if command_id != "game.static_smoke" {
return Err("不支持的受限命令".to_string());
}
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let game_index_path = root.join("game/index.html");
let html = fs::read_to_string(&game_index_path)
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
if !html.contains("<html") && !html.contains("<!doctype html") {
return Err("游戏入口不是 HTML 文档".to_string());
}
validate_game_html_smoke(&html)?;
let output = format!("通过:{},{} 字节", game_index_path.display(), html.len());
let log_path = root.join(".agent/logs/command.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
}
let updated_at = unix_timestamp();
let line = format!("{updated_at} command.run_limited {command_id}: {output}\n");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: command_id.to_string(),
status: GameCreationAppCommandRunStatus::Completed,
output: output.clone(),
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
},
)?;
Ok(LimitedLocalCommandResult {
command_id: command_id.to_string(),
status: "completed".to_string(),
output,
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
})
}
impl Default for ProjectPermissionPolicy {
fn default() -> Self {
Self {
denied_commands: Vec::new(),
confirm_commands: GAME_CREATION_APP_COMMANDS
.iter()
.filter(|command| command.permission == GameCreationAppPermission::Confirm)
.map(|command| command.id.to_string())
.collect(),
}
}
}
pub(crate) fn read_project_permission_policy_at(
root: &Path,
) -> Result<ProjectPermissionPolicyView, String> {
validate_project_root(root)?;
let path = root.join(PROJECT_PERMISSION_POLICY_PATH);
if !path.exists() {
return Ok(ProjectPermissionPolicyView {
path: path.to_string_lossy().into_owned(),
policy: ProjectPermissionPolicy::default(),
});
}
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取项目权限策略失败:{}: {error}", path.display()))?;
let policy = serde_json::from_str::<ProjectPermissionPolicy>(&content)
.map_err(|error| format!("解析项目权限策略失败:{}: {error}", path.display()))?;
Ok(ProjectPermissionPolicyView {
path: path.to_string_lossy().into_owned(),
policy: normalize_project_permission_policy(policy)?,
})
}
pub(crate) fn write_project_permission_policy_at(
root: &Path,
policy: ProjectPermissionPolicy,
) -> Result<ProjectPermissionPolicyView, String> {
validate_project_root(root)?;
let policy = normalize_project_permission_policy(policy)?;
let path = root.join(PROJECT_PERMISSION_POLICY_PATH);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目权限策略目录失败:{}: {error}", parent.display()))?;
}
let content = serde_json::to_string_pretty(&policy)
.map_err(|error| format!("序列化项目权限策略失败:{error}"))?;
fs::write(&path, format!("{content}\n"))
.map_err(|error| format!("写入项目权限策略失败:{}: {error}", path.display()))?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.policy_write",
"deniedCommands": policy.denied_commands,
"confirmCommands": policy.confirm_commands,
}),
)?;
read_project_permission_policy_at(root)
}
pub(crate) fn normalize_project_permission_policy(
mut policy: ProjectPermissionPolicy,
) -> Result<ProjectPermissionPolicy, String> {
policy.denied_commands = normalize_policy_command_ids(policy.denied_commands)?;
policy.confirm_commands = normalize_policy_command_ids(policy.confirm_commands)?;
policy
.confirm_commands
.retain(|command| !policy.denied_commands.contains(command));
Ok(policy)
}
pub(crate) fn normalize_policy_command_ids(values: Vec<String>) -> Result<Vec<String>, String> {
let mut output = Vec::new();
for value in values {
let command_id = value.trim();
if command_id.is_empty() {
continue;
}
if !GAME_CREATION_APP_COMMANDS
.iter()
.any(|command| command.id == command_id)
{
return Err(format!("不支持的内置命令:{command_id}"));
}
if !output.iter().any(|existing| existing == command_id) {
output.push(command_id.to_string());
}
}
Ok(output)
}
pub(crate) fn enforce_project_permission_policy(
root: &Path,
command_id: &str,
) -> Result<(), String> {
let view = read_project_permission_policy_at(root)?;
if view
.policy
.denied_commands
.iter()
.any(|command| command == command_id)
{
return Err(format!("项目权限策略拒绝执行:{command_id}"));
}
Ok(())
}
pub(crate) fn list_local_project_files_at(
root: &Path,
) -> Result<ListLocalProjectFilesResult, String> {
validate_project_root(root)?;
if !root.exists() {
return Ok(ListLocalProjectFilesResult {
project_path: root.to_string_lossy().into_owned(),
files: Vec::new(),
});
}
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir)
.map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))?
{
let entry =
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!("读取文件类型失败:{}: {error}", entry.path().display())
})?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let relative_path = relative_project_path(root, &path)?;
let metadata = entry.metadata().map_err(|error| {
format!("读取文件元数据失败:{}: {error}", entry.path().display())
})?;
let modified_at = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0);
if file_type.is_dir() {
files.push(LocalProjectFileEntry {
path: relative_path,
kind: "directory".to_string(),
size: 0,
modified_at,
});
dirs.push(path);
} else if file_type.is_file() {
let size = metadata.len();
files.push(LocalProjectFileEntry {
path: relative_path,
kind: "file".to_string(),
size,
modified_at,
});
}
}
}
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(ListLocalProjectFilesResult {
project_path: root.to_string_lossy().into_owned(),
files,
})
}
pub(crate) fn read_local_project_file_at(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectFileResult, String> {
let normalized_path = normalize_relative_path(relative_path)?;
reject_sensitive_project_file_read(&normalized_path)?;
let path = resolve_local_project_path(root, &normalized_path)?;
let metadata = fs::metadata(&path)
.map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?;
if !metadata.is_file() {
return Err("只能读取文件".to_string());
}
let content = fs::read_to_string(&path)
.map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?;
Ok(LocalProjectFileResult {
path: normalized_path,
absolute_path: path.to_string_lossy().into_owned(),
content,
})
}
pub(crate) fn reject_sensitive_project_file_read(normalized_path: &str) -> Result<(), String> {
for part in normalized_path.split('/') {
let lower = part.to_ascii_lowercase();
if lower == ".env"
|| lower.starts_with(".env.")
|| lower == GAME_CREATOR_CONFIG_FILE_NAME
|| lower == GAME_CREATOR_LOCAL_CONFIG_FILE_NAME
{
return Err("拒绝读取敏感配置文件".to_string());
}
}
Ok(())
}
pub(crate) fn is_agent_trace_read_path(normalized_path: &str) -> bool {
normalized_path == ".agent/run.latest.json"
|| (normalized_path.starts_with(".agent/runs/") && normalized_path.ends_with(".json"))
}
pub(crate) fn write_local_project_file_at(
root: &Path,
relative_path: &str,
content: &str,
) -> Result<LocalProjectFileMutationResult, String> {
let path = resolve_local_project_path(root, relative_path)?;
if path.exists() && !path.is_file() {
return Err("只能写入文件".to_string());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入项目文件失败:{}: {error}", path.display()))?;
Ok(LocalProjectFileMutationResult {
path: normalize_relative_path(relative_path)?,
absolute_path: path.to_string_lossy().into_owned(),
deleted: false,
})
}
pub(crate) fn delete_local_project_file_at(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectFileMutationResult, String> {
let path = resolve_local_project_path(root, relative_path)?;
if !path.exists() {
return Ok(LocalProjectFileMutationResult {
path: normalize_relative_path(relative_path)?,
absolute_path: path.to_string_lossy().into_owned(),
deleted: false,
});
}
if !path.is_file() {
return Err("只能删除文件".to_string());
}
fs::remove_file(&path)
.map_err(|error| format!("删除项目文件失败:{}: {error}", path.display()))?;
Ok(LocalProjectFileMutationResult {
path: normalize_relative_path(relative_path)?,
absolute_path: path.to_string_lossy().into_owned(),
deleted: true,
})
}
pub(crate) fn build_local_project_index_at(root: &Path) -> Result<LocalProjectIndexResult, String> {
validate_project_root(root)?;
let files = collect_project_index_files(root)?;
let total_bytes = files.iter().map(|file| file.size).sum();
let result = LocalProjectIndexResult {
project_path: root.to_string_lossy().into_owned(),
index_path: root.join(PROJECT_INDEX_PATH).to_string_lossy().into_owned(),
file_count: files.len(),
total_bytes,
files,
};
if let Some(parent) = root.join(PROJECT_INDEX_PATH).parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建项目索引目录失败:{}: {error}", parent.display()))?;
}
fs::write(
root.join(PROJECT_INDEX_PATH),
format!(
"{}\n",
serde_json::to_string_pretty(&result)
.map_err(|error| format!("序列化项目索引失败:{error}"))?
),
)
.map_err(|error| {
format!(
"写入项目索引失败:{}: {error}",
root.join(PROJECT_INDEX_PATH).display()
)
})?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.index",
"fileCount": result.file_count,
"totalBytes": result.total_bytes,
"indexPath": PROJECT_INDEX_PATH,
}),
)?;
Ok(result)
}
pub(crate) fn create_local_project_checkpoint_at(
root: &Path,
) -> Result<LocalProjectCheckpointResult, String> {
validate_project_root(root)?;
let checkpoint_id = format!("checkpoint-{}", unix_millis());
let checkpoint_root = root.join(".agent/checkpoints").join(&checkpoint_id);
let files = collect_project_index_files(root)?;
for file in &files {
let source = root.join(&file.path);
let target = checkpoint_root.join("files").join(&file.path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| {
format!("创建 checkpoint 目录失败:{}: {error}", parent.display())
})?;
}
fs::copy(&source, &target).map_err(|error| {
format!(
"写入 checkpoint 文件失败:{} -> {}: {error}",
source.display(),
target.display()
)
})?;
}
let total_bytes = files.iter().map(|file| file.size).sum::<u64>();
let manifest = serde_json::json!({
"checkpointId": checkpoint_id,
"createdAt": unix_timestamp(),
"files": files,
});
fs::write(
checkpoint_root.join("manifest.json"),
format!(
"{}\n",
serde_json::to_string_pretty(&manifest)
.map_err(|error| format!("序列化 checkpoint 失败:{error}"))?
),
)
.map_err(|error| {
format!(
"写入 checkpoint manifest 失败:{}: {error}",
checkpoint_root.join("manifest.json").display()
)
})?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.checkpoint",
"checkpointId": checkpoint_id,
"fileCount": files.len(),
"totalBytes": total_bytes,
}),
)?;
Ok(LocalProjectCheckpointResult {
checkpoint_id,
checkpoint_path: checkpoint_root.to_string_lossy().into_owned(),
file_count: files.len(),
total_bytes,
})
}
pub(crate) fn export_local_project_package_at(
root: &Path,
) -> Result<LocalProjectExportPackageResult, String> {
validate_project_root(root)?;
ensure_project_export_package_dir(root, "game")?;
let game_index_path = resolve_local_project_path(root, "game/index.html")?;
if !game_index_path.is_file() {
return Err("导出试玩包前需要先生成 game/index.html".to_string());
}
let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?;
if !game_index_metadata.is_file() {
return Err("导出试玩包前需要先生成 game/index.html".to_string());
}
let game_index = fs::read_to_string(&game_index_path)
.map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?;
if game_index.trim().is_empty() {
return Err("导出试玩包前 game/index.html 不能为空".to_string());
}
let lower_game_index = game_index.to_ascii_lowercase();
if !lower_game_index.contains("<html") && !lower_game_index.contains("<!doctype html") {
return Err("导出试玩包前 game/index.html 必须是 HTML 文档".to_string());
}
validate_game_html_smoke(&game_index)?;
ensure_project_export_package_dir(root, "exports")?;
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
if !readme_path.is_file() {
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
}
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
if !readme_metadata.is_file() {
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
}
let files = collect_project_export_package_files(root)?;
let total_bytes = files.iter().map(|(_, _, size)| *size).sum::<u64>();
if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES {
return Err(format!(
"试玩包文件数量超过上限:{} > {}",
files.len(),
MAX_PROJECT_EXPORT_PACKAGE_FILES
));
}
if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES {
return Err(format!(
"试玩包文件总大小超过上限:{}B > {}B",
total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES
));
}
let package_relative_path = next_project_export_package_relative_path(root)?;
let package_path = resolve_local_project_path(root, &package_relative_path)?;
if let Some(parent) = package_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建导出目录失败:{}: {error}", parent.display()))?;
}
let file = File::create(&package_path)
.map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?;
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (relative_path, absolute_path, _) in &files {
writer
.start_file(relative_path, options)
.map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?;
let bytes = fs::read(absolute_path)
.map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?;
writer
.write_all(&bytes)
.map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?;
}
writer
.finish()
.map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?;
let updated_at = unix_timestamp();
let log_path = root.join(".agent/logs/command.log");
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?;
}
let output = format!(
"导出试玩包:{},{} 个文件,{}B",
package_relative_path,
files.len(),
total_bytes
);
let line = format!("{updated_at} project.export_package: {output}\n");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.and_then(|mut file| file.write_all(line.as_bytes()))
.map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?;
record_command_run(
root,
GameCreationAppCommandRunState {
command_id: "project.export_package".to_string(),
status: GameCreationAppCommandRunStatus::Completed,
output,
log_path: log_path.to_string_lossy().into_owned(),
updated_at,
},
)?;
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.export_package",
"packagePath": package_relative_path,
"fileCount": files.len(),
"totalBytes": total_bytes,
}),
)?;
Ok(LocalProjectExportPackageResult {
package_path: package_path.to_string_lossy().into_owned(),
package_relative_path,
file_count: files.len(),
total_bytes,
})
}
pub(crate) fn next_project_export_package_relative_path(root: &Path) -> Result<String, String> {
let seed = unix_millis();
for suffix in 0..1000 {
let file_name = if suffix == 0 {
format!("playtest-package-{seed}.zip")
} else {
format!("playtest-package-{seed}-{suffix}.zip")
};
let relative_path = format!("exports/{file_name}");
let package_path = resolve_local_project_path(root, &relative_path)?;
if !package_path.exists() {
return Ok(relative_path);
}
}
Err("无法生成唯一试玩包文件名".to_string())
}
pub(crate) fn list_local_project_export_packages_at(
root: &Path,
) -> Result<LocalProjectExportPackagesResult, String> {
validate_project_root(root)?;
let export_dir = resolve_local_project_path(root, "exports")?;
if !export_dir.exists() {
return Ok(LocalProjectExportPackagesResult {
project_path: root.to_string_lossy().into_owned(),
packages: Vec::new(),
});
}
let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?;
if !export_dir_metadata.is_dir() {
return Err("exports 必须是目录".to_string());
}
let mut packages = Vec::new();
for entry in fs::read_dir(&export_dir)
.map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))?
{
let entry = entry
.map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!(
"读取试玩包文件类型失败:{}: {error}",
entry.path().display()
)
})?;
if file_type.is_symlink() || !file_type.is_file() {
continue;
}
let file_name = match entry.file_name().to_str() {
Some(value) => value.to_string(),
None => continue,
};
if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") {
continue;
}
let package_relative_path =
normalize_export_package_entry_path(&format!("exports/{file_name}"))?;
let metadata = entry.metadata().map_err(|error| {
format!("读取试玩包元数据失败:{}: {error}", entry.path().display())
})?;
let modified_at = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(0);
packages.push(LocalProjectExportPackageSummary {
package_path: entry.path().to_string_lossy().into_owned(),
package_relative_path,
total_bytes: metadata.len(),
modified_at,
});
}
packages.sort_by(|left, right| {
(right.modified_at, &right.package_relative_path)
.cmp(&(left.modified_at, &left.package_relative_path))
});
Ok(LocalProjectExportPackagesResult {
project_path: root.to_string_lossy().into_owned(),
packages,
})
}
pub(crate) fn collect_project_export_package_files(
root: &Path,
) -> Result<Vec<(String, PathBuf, u64)>, String> {
let mut files = Vec::new();
collect_project_export_package_dir_files(root, "game", &mut files)?;
if resolve_local_project_path(root, "assets")?.exists() {
collect_project_export_package_dir_files(root, "assets", &mut files)?;
}
let readme_path = resolve_local_project_path(root, "exports/README.md")?;
let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?;
if !readme_metadata.is_file() {
return Err("导出试玩包前需要先生成 exports/README.md".to_string());
}
let readme_size = readme_metadata.len();
files.push(("exports/README.md".to_string(), readme_path, readme_size));
files.sort_by(|left, right| left.0.cmp(&right.0));
Ok(files)
}
pub(crate) fn ensure_project_export_package_dir(
root: &Path,
relative_dir: &str,
) -> Result<(), String> {
let dir = resolve_local_project_path(root, relative_dir)?;
let metadata = checked_export_package_metadata(&dir, relative_dir)?;
if !metadata.is_dir() {
return Err(format!("{relative_dir} 必须是目录"));
}
Ok(())
}
pub(crate) fn collect_project_export_package_dir_files(
root: &Path,
relative_dir: &str,
files: &mut Vec<(String, PathBuf, u64)>,
) -> Result<(), String> {
let dir = resolve_local_project_path(root, relative_dir)?;
if !dir.exists() {
return Ok(());
}
let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?;
if !dir_metadata.is_dir() {
return Err(format!("{relative_dir} 必须是目录"));
}
let mut dirs = vec![dir];
while let Some(current_dir) = dirs.pop() {
for entry in fs::read_dir(&current_dir)
.map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))?
{
let entry = entry
.map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!("读取导出文件类型失败:{}: {error}", entry.path().display())
})?;
let relative_path =
normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?;
if file_type.is_symlink() {
return Err(format!("试玩包不能包含符号链接:{relative_path}"));
}
if file_type.is_dir() {
dirs.push(entry.path());
} else if file_type.is_file() {
let size = entry
.metadata()
.map_err(|error| {
format!(
"读取导出文件元数据失败:{}: {error}",
entry.path().display()
)
})?
.len();
files.push((relative_path, entry.path(), size));
}
}
}
Ok(())
}
pub(crate) fn checked_export_package_metadata(
path: &Path,
relative_path: &str,
) -> Result<fs::Metadata, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
return Err(format!(
"试玩包不能包含符号链接:{}",
normalize_export_package_entry_path(relative_path)?
));
}
Ok(metadata)
}
pub(crate) fn normalize_export_package_entry_path(relative_path: &str) -> Result<String, String> {
if relative_path.chars().any(char::is_control) {
return Err("试玩包条目路径不能包含控制字符".to_string());
}
normalize_relative_path(relative_path)
}
pub(crate) fn diff_local_project_checkpoint_at(
root: &Path,
checkpoint_id: &str,
) -> Result<LocalProjectDiffResult, String> {
validate_project_root(root)?;
let checkpoint_id = normalize_checkpoint_id(checkpoint_id)?;
let checkpoint_files = read_checkpoint_files(root, &checkpoint_id)?;
let current_files = collect_project_index_files(root)?;
let mut added = Vec::new();
let mut changed = Vec::new();
let mut deleted = Vec::new();
for file in &current_files {
match checkpoint_files
.iter()
.find(|candidate| candidate.path == file.path)
{
None => added.push(LocalProjectDiffEntry {
path: file.path.clone(),
status: "added".to_string(),
}),
Some(previous) if previous.checksum != file.checksum || previous.size != file.size => {
changed.push(LocalProjectDiffEntry {
path: file.path.clone(),
status: "changed".to_string(),
});
}
Some(_) => {}
}
}
for file in &checkpoint_files {
if !current_files
.iter()
.any(|candidate| candidate.path == file.path)
{
deleted.push(LocalProjectDiffEntry {
path: file.path.clone(),
status: "deleted".to_string(),
});
}
}
Ok(LocalProjectDiffResult {
checkpoint_id,
added,
changed,
deleted,
})
}
pub(crate) fn restore_local_project_checkpoint_at(
root: &Path,
checkpoint_id: &str,
) -> Result<LocalProjectRestoreResult, String> {
validate_project_root(root)?;
let checkpoint_id = normalize_checkpoint_id(checkpoint_id)?;
let files = read_checkpoint_files(root, &checkpoint_id)?;
let current_files = collect_project_index_files(root)?;
let checkpoint_files_root = root
.join(".agent/checkpoints")
.join(&checkpoint_id)
.join("files");
let mut restored_count = 0usize;
for file in &files {
if should_skip_project_restore_path(&file.path) {
continue;
}
let source = checkpoint_files_root.join(&file.path);
let target = root.join(&file.path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建恢复目录失败:{}: {error}", parent.display()))?;
}
fs::copy(&source, &target).map_err(|error| {
format!(
"恢复 checkpoint 文件失败:{} -> {}: {error}",
source.display(),
target.display()
)
})?;
restored_count += 1;
}
let mut deleted_count = 0usize;
for file in &current_files {
if should_skip_project_restore_path(&file.path) {
continue;
}
if files.iter().any(|candidate| candidate.path == file.path) {
continue;
}
let target = root.join(&file.path);
if target.is_file() {
fs::remove_file(&target).map_err(|error| {
format!(
"删除 checkpoint 外新增文件失败:{}: {error}",
target.display()
)
})?;
deleted_count += 1;
}
}
let project_index_path = root.join(PROJECT_INDEX_PATH);
if project_index_path.is_file() {
fs::remove_file(&project_index_path).map_err(|error| {
format!(
"删除恢复后的旧项目索引失败:{}: {error}",
project_index_path.display()
)
})?;
}
append_agent_db_record(
root,
serde_json::json!({
"recordType": "project.restore",
"checkpointId": checkpoint_id,
"restoredCount": restored_count,
"deletedCount": deleted_count,
}),
)?;
Ok(LocalProjectRestoreResult {
checkpoint_id,
restored_count,
deleted_count,
})
}
pub(crate) fn collect_project_index_files(
root: &Path,
) -> Result<Vec<LocalProjectIndexedFile>, String> {
validate_project_root(root)?;
if !root.exists() {
return Ok(Vec::new());
}
let mut files = Vec::new();
let mut dirs = vec![root.to_path_buf()];
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir)
.map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))?
{
let entry =
entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?;
let file_type = entry.file_type().map_err(|error| {
format!("读取文件类型失败:{}: {error}", entry.path().display())
})?;
if file_type.is_symlink() {
continue;
}
let path = entry.path();
let relative_path = relative_project_path(root, &path)?;
if should_skip_project_index_path(&relative_path) {
continue;
}
if file_type.is_dir() {
dirs.push(path);
} else if file_type.is_file() {
let bytes = fs::read(&path)
.map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?;
files.push(LocalProjectIndexedFile {
path: relative_path,
size: bytes.len() as u64,
checksum: format!("fnv1a64:{:016x}", fnv1a64(&bytes)),
});
}
}
}
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
pub(crate) fn should_skip_project_index_path(relative_path: &str) -> bool {
relative_path == PROJECT_WRITE_LOCK_PATH
|| relative_path == PROJECT_INDEX_PATH
|| relative_path.starts_with(".agent/checkpoints/")
|| relative_path.starts_with(".agent/runtime/")
}
pub(crate) fn should_skip_project_restore_path(relative_path: &str) -> bool {
relative_path == ".agent/agent.db"
|| relative_path == PROJECT_PERMISSION_POLICY_PATH
|| relative_path == PROJECT_WRITE_LOCK_PATH
|| relative_path == PROJECT_INDEX_PATH
|| relative_path.starts_with(".agent/logs/")
|| relative_path.starts_with(".agent/conversations/")
|| relative_path.starts_with(".agent/runtime/")
}
pub(crate) fn normalize_checkpoint_id(checkpoint_id: &str) -> Result<String, String> {
let checkpoint_id = checkpoint_id.trim();
if checkpoint_id.is_empty()
|| checkpoint_id.contains('/')
|| checkpoint_id.contains('\\')
|| checkpoint_id.contains("..")
{
return Err("checkpoint id 非法".to_string());
}
Ok(checkpoint_id.to_string())
}
pub(crate) fn read_checkpoint_files(
root: &Path,
checkpoint_id: &str,
) -> Result<Vec<LocalProjectIndexedFile>, String> {
let manifest_path = root
.join(".agent/checkpoints")
.join(checkpoint_id)
.join("manifest.json");
let content = fs::read_to_string(&manifest_path)
.map_err(|error| format!("读取 checkpoint 失败:{}: {error}", manifest_path.display()))?;
let payload = serde_json::from_str::<serde_json::Value>(&content)
.map_err(|error| format!("解析 checkpoint 失败:{}: {error}", manifest_path.display()))?;
serde_json::from_value::<Vec<LocalProjectIndexedFile>>(
payload
.get("files")
.cloned()
.ok_or_else(|| "checkpoint 缺少 files".to_string())?,
)
.map_err(|error| format!("解析 checkpoint 文件清单失败:{error}"))
}
pub(crate) fn resolve_local_project_path(
root: &Path,
relative_path: &str,
) -> Result<PathBuf, String> {
validate_project_root(root)?;
let normalized = normalize_relative_path(relative_path)?;
let mut path = root.to_path_buf();
for part in normalized.split('/') {
path.push(part);
if path.exists() {
let metadata = fs::symlink_metadata(&path)
.map_err(|error| format!("读取路径失败:{}: {error}", path.display()))?;
if metadata.file_type().is_symlink() {
return Err("项目文件路径不能包含符号链接".to_string());
}
}
}
Ok(path)
}
pub(crate) fn validate_project_root(root: &Path) -> Result<(), String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
if project_path_has_control_chars(root) {
return Err("项目目录不能包含控制字符".to_string());
}
if root.exists() {
let metadata = fs::symlink_metadata(root)
.map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?;
if metadata.file_type().is_symlink() {
return Err("项目目录不能是符号链接".to_string());
}
}
Ok(())
}
pub(crate) fn project_path_has_control_chars(root: &Path) -> bool {
root.to_string_lossy().chars().any(char::is_control)
}
pub(crate) fn normalize_relative_path(relative_path: &str) -> Result<String, String> {
let relative_path = relative_path.trim();
if relative_path.is_empty() {
return Err("项目文件路径不能为空".to_string());
}
if Path::new(relative_path).is_absolute() {
return Err("项目文件路径不能是绝对路径".to_string());
}
if relative_path.contains('\\') {
return Err("项目文件路径不能包含反斜杠".to_string());
}
if relative_path.contains(':') {
return Err("项目文件路径不能包含冒号".to_string());
}
let mut parts = Vec::new();
for part in relative_path.split('/') {
if part.is_empty() || part == "." || part == ".." {
return Err("项目文件路径非法".to_string());
}
parts.push(part);
}
Ok(parts.join("/"))
}
pub(crate) fn relative_project_path(root: &Path, path: &Path) -> Result<String, String> {
let relative = path
.strip_prefix(root)
.map_err(|_| "项目文件路径不在项目目录内".to_string())?;
let parts = relative
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
Ok(parts.join("/"))
}
pub(crate) fn record_preview_state(
root: &Path,
status: GameCreationAppPreviewStatus,
url: Option<String>,
port: Option<u16>,
) -> Result<(), String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
let is_running = status == GameCreationAppPreviewStatus::Running;
manifest.preview = Some(GameCreationAppPreviewState { status, url, port });
if is_running {
set_task_status(
&mut manifest,
"preview-playtest",
GameCreationAppTaskStatus::WaitingForConfirmation,
);
}
write_manifest(&manifest_path, &manifest)
}
pub(crate) fn record_command_run(
root: &Path,
run: GameCreationAppCommandRunState,
) -> Result<(), String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
if run.command_id == "game.static_smoke"
&& run.status == GameCreationAppCommandRunStatus::Completed
{
set_task_status(
&mut manifest,
"preview-readiness",
GameCreationAppTaskStatus::Completed,
);
set_task_status(
&mut manifest,
"preview-playtest",
GameCreationAppTaskStatus::WaitingForConfirmation,
);
}
manifest.command_runs.push(run);
write_manifest(&manifest_path, &manifest)
}
pub(crate) fn read_manifest_for_project(root: &Path) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
write_manifest(&manifest_path, &manifest)?;
Ok(manifest)
}
pub(crate) fn ensure_manifest_has_seed_tasks(
root: &Path,
goal: Option<&str>,
) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
if let Some(goal) = goal.map(str::trim).filter(|goal| !goal.is_empty()) {
manifest.goal = Some(goal.to_string());
}
write_manifest(&manifest_path, &manifest)?;
Ok(manifest)
}
pub(crate) fn record_draft_task_progress(
root: &Path,
goal: &str,
updated_at: u64,
agent_log_path: &Path,
) -> Result<GameCreationAppManifest, String> {
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
manifest.goal = Some(goal.to_string());
for completed_task_id in [
"design-director",
"design-foundation",
"balance-director",
"balance-seed",
"art-director",
"art-asset-plan",
"art-polish",
"audio-director",
"audio-asset-plan",
"code-director",
"code-prototype",
"quality-review",
] {
set_task_status(
&mut manifest,
completed_task_id,
GameCreationAppTaskStatus::Completed,
);
}
set_task_status(
&mut manifest,
"preview-readiness",
GameCreationAppTaskStatus::WaitingForConfirmation,
);
manifest.command_runs.push(GameCreationAppCommandRunState {
command_id: "game.generate_draft".to_string(),
status: GameCreationAppCommandRunStatus::Completed,
output: "已完成 Planner/Generator/Evaluator loop、记忆写入和本地原型生成".to_string(),
log_path: agent_log_path.to_string_lossy().into_owned(),
updated_at,
});
write_manifest(&manifest_path, &manifest)?;
Ok(manifest)
}
pub(crate) fn ensure_manifest_seed_tasks(manifest: &mut GameCreationAppManifest) {
let seed_tasks = new_game_creation_app_seed_tasks();
if manifest.tasks.is_empty() {
manifest.tasks = seed_tasks;
return;
}
for seed_task in seed_tasks {
if let Some(existing_task) = manifest
.tasks
.iter_mut()
.find(|task| task.id == seed_task.id)
{
let status = existing_task.status.clone();
*existing_task = seed_task;
existing_task.status = status;
} else {
manifest.tasks.push(seed_task);
}
}
}
pub(crate) fn set_task_status(
manifest: &mut GameCreationAppManifest,
task_id: &str,
status: GameCreationAppTaskStatus,
) {
if let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) {
task.status = status;
}
}
pub(crate) fn update_manifest_task_status_at(
root: &Path,
task_id: &str,
status: GameCreationAppTaskStatus,
) -> Result<GameCreationAppTaskState, String> {
let task_id = task_id.trim();
if task_id.is_empty() {
return Err("任务 ID 不能为空".to_string());
}
let (manifest_path, mut manifest) = read_or_create_manifest(root)?;
ensure_manifest_seed_tasks(&mut manifest);
let Some(task) = manifest.tasks.iter_mut().find(|task| task.id == task_id) else {
return Err(format!("项目任务不存在:{task_id}"));
};
task.status = status;
let updated = task.clone();
write_manifest(&manifest_path, &manifest)?;
Ok(updated)
}
pub(crate) fn read_or_create_manifest(
root: &Path,
) -> Result<(PathBuf, GameCreationAppManifest), String> {
if root.as_os_str().is_empty() {
return Err("项目目录不能为空".to_string());
}
if !root.is_absolute() {
return Err("项目目录必须是绝对路径".to_string());
}
let manifest_path = root.join(".agent/manifest.json");
let manifest = if manifest_path.exists() {
read_manifest(&manifest_path)?
} else {
new_game_creation_app_manifest("local-project-draft", "未命名游戏原型")
};
Ok((manifest_path, manifest))
}
pub(crate) fn memory_file_path<'a>(
root: &Path,
scope: &'a str,
) -> Result<(&'a str, PathBuf), String> {
match scope {
"short" | "session" => Ok((
"short",
resolve_local_project_path(root, "memory/session.md")?,
)),
"long" | "project" => Ok((
"long",
resolve_local_project_path(root, "memory/project.md")?,
)),
"blackboard" => Ok((
"blackboard",
resolve_local_project_path(root, PROJECT_BLACKBOARD_MEMORY_PATH)?,
)),
_ => Err("记忆范围只能是 short、long 或 blackboard".to_string()),
}
}
pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, String> {
let payload = fs::read_to_string(path)
.map_err(|error| format!("读取 manifest 失败:{}: {error}", path.display()))?;
serde_json::from_str(&payload)
.map_err(|error| format!("解析 manifest 失败:{}: {error}", path.display()))
}
pub(crate) fn write_manifest(
path: &Path,
manifest: &GameCreationAppManifest,
) -> Result<(), String> {
let payload = serde_json::to_string_pretty(manifest)
.map_err(|error| format!("序列化 manifest 失败:{error}"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 manifest 目录失败:{}: {error}", parent.display()))?;
}
fs::write(path, format!("{payload}\n"))
.map_err(|error| format!("写入 manifest 失败:{}: {error}", path.display()))
}
pub(crate) fn sanitize_file_name(file_name: &str) -> String {
let sanitized = file_name
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
character if character.is_control() => '_',
character => character,
})
.collect::<String>()
.trim_matches('.')
.trim()
.to_string();
if sanitized.is_empty() {
"upload.bin".to_string()
} else {
sanitized
}
}
pub(crate) fn parse_asset_source_kind(
value: &str,
) -> Result<GameCreationAppAssetSourceKind, String> {
match value {
"" | "generated" => Ok(GameCreationAppAssetSourceKind::Generated),
"uploaded" => Ok(GameCreationAppAssetSourceKind::Uploaded),
"canvas" => Ok(GameCreationAppAssetSourceKind::Canvas),
_ => Err("资产来源只能是 uploaded、generated 或 canvas".to_string()),
}
}
pub(crate) fn trim_optional_string(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub(crate) fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
pub(crate) fn unix_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0)
}