ac4448a2ef
后台 Agent 工具箱新增 task.update,可由 Agent 更新 manifest 任务状态。 任务状态更新复用 task.update 权限策略、项目写锁和 manifest 任务真相源。 补充任务状态更新成功与策略拦截测试,同步 Runtime V1 文档。
1715 lines
60 KiB
Rust
1715 lines
60 KiB
Rust
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(¤t_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 ¤t_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 ¤t_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)
|
||
}
|