Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/cli.rs
T
k88936 839051f532
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 1m50s
Project CI / Backend tests (pull_request) Failing after 24s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m47s
Project CI / Repository checks (pull_request) Failing after 21s
Project CI / Frontend tests (pull_request) Successful in 2m38s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m41s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m23s
Project CI / Native shell tests (pull_request) Successful in 6m20s
退役AGC独立Agent Runtime与CLI执行面
- 删除 agent/runtime_driver、runtime_protocol、runtime_tools、runtime_actions、runtime_state、runtime_adapter、context_compaction、collaboration、delegation、goal 等自建 Runtime 执行面及专属测试与 fixture
- CliCommand 收敛为 LlmStatus | EnvironmentCheck | PreviewServe,删除 --agent-run / --agent-task / --agent-goal-* / --agent-resume / --agent-context-compact 等 CLI 与 Tauri 命令白名单条目
- 外部 Runner 收缩为编辑器桥 RPC 与 runner.attach_gui_owner + GUI owner 参与锁 / watchdog,保留 --agent-runner
- 前端删除 AgentRuntime* / AgentGoal* 状态与命令调用链,移除 agent-runtime real-e2e / steer / smoke / llm-transient-fault 脚本与对应 CI job
- 内置工具错误收口为 typed error,抽取 agent/tool/error.rs 共享错误供各工具复用
- 新增 agent/redaction.rs、runtime_models.rs、json_sidecar.rs、task_lock.rs、process_identity.rs、art_manifest.rs、design_session.rs、direct_events.rs
- 同步更新 ADR、decision-log、Runtime V1.1、App 实施计划、本地开发运维、pitfalls 与 CI 脚本
2026-10-02 15:23:12 +08:00

340 lines
12 KiB
Rust

use super::*;
const PREVIEW_SERVE_USAGE: &str = "用法:--preview-serve <本地项目绝对路径>";
#[derive(Debug, Eq, PartialEq)]
pub(crate) enum CliCommand {
LlmStatus,
EnvironmentCheck,
PreviewServe { project_path: PathBuf },
}
impl CliCommand {
fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> {
match self {
Self::PreviewServe { project_path } => Some((project_path, false)),
Self::LlmStatus | Self::EnvironmentCheck => None,
}
}
}
fn canonicalize_cli_path(
path: &Path,
label: &str,
allow_missing_leaf: bool,
) -> Result<PathBuf, String> {
if !path.is_absolute() {
return Err(format!("{label} 必须是绝对路径"));
}
match fs::canonicalize(path) {
Ok(path) => Ok(path),
Err(error) if allow_missing_leaf && error.kind() == std::io::ErrorKind::NotFound => {
let parent = path
.parent()
.ok_or_else(|| format!("{label} 缺少可解析的父目录"))?;
let file_name = path
.file_name()
.ok_or_else(|| format!("{label} 缺少目录名"))?;
let parent = fs::canonicalize(parent).map_err(|parent_error| {
format!(
"解析 {label} 父目录失败:{}: {parent_error}",
parent.display()
)
})?;
Ok(parent.join(file_name))
}
Err(error) => Err(format!("解析 {label} 失败:{}: {error}", path.display())),
}
}
pub(crate) fn prepare_cli_command_paths(
command: &mut CliCommand,
runtime_config_dir: Option<&Path>,
) -> Result<Option<PathBuf>, String> {
let project_path = if let Some((path, allow_missing_leaf)) = command.project_path_mut() {
let canonical = canonicalize_cli_path(path, "本地项目路径", allow_missing_leaf)?;
*path = canonical.clone();
Some(canonical)
} else {
None
};
let config_dir = runtime_config_dir
.map(|path| canonicalize_cli_path(path, "--config-dir", true))
.transpose()?;
if let (Some(config_dir), Some(project_path)) = (&config_dir, &project_path) {
validate_game_creator_runtime_config_dir_outside_project(config_dir, project_path)?;
}
Ok(config_dir)
}
pub(crate) fn take_cli_runtime_config_dir(
args: &mut Vec<String>,
) -> Result<Option<PathBuf>, String> {
let positions = args
.iter()
.enumerate()
.filter_map(|(index, arg)| (arg == "--config-dir").then_some(index))
.collect::<Vec<_>>();
if positions.len() > 1 {
return Err("--config-dir 只能指定一次".to_string());
}
let Some(index) = positions.first().copied() else {
return Ok(None);
};
let value = args
.get(index + 1)
.map(String::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| "--config-dir 缺少目录路径".to_string())?;
let path = PathBuf::from(value);
if !path.is_absolute() {
return Err("--config-dir 必须是绝对路径".to_string());
}
args.drain(index..=index + 1);
Ok(Some(path))
}
pub(crate) fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec<String> {
let mut lines = vec![
format!("agent.mode={}", status.agent_mode),
format!("llm.configured={}", status.configured),
format!(
"llm.accountCredentialState={}",
status.account_credential_state
),
format!("llm.officialRouteLocked={}", status.official_route_locked),
format!("llm.reasoningEffort={}", status.reasoning_effort),
format!("llm.stream={}", status.stream),
format!("llm.contextWindowTokens={}", status.context_window_tokens),
format!(
"llm.autoCompactTokenLimit={}",
status.auto_compact_token_limit
),
format!("llm.requestTimeoutMs={}", status.request_timeout_ms),
format!("llm.maxRetries={}", status.max_retries),
format!("llm.retryBackoffMs={}", status.retry_backoff_ms),
];
if status.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
lines.push(format!(
"llm.controlledWebSearchEnabled={}",
status.web_search_enabled
));
lines.push("llm.codexNativeWebSearch=disabled".to_string());
} else {
lines.push(format!(
"llm.webSearchEnabled={}",
status.web_search_enabled
));
}
for agent in &status.agents {
lines.push(format!(
"llm.agent.{}.configured={}",
agent.agent_id, agent.configured
));
lines.push(format!(
"llm.agent.{}.accountCredentialState={}",
agent.agent_id, agent.account_credential_state
));
lines.push(format!(
"llm.agent.{}.officialRouteLocked={}",
agent.agent_id, agent.official_route_locked
));
lines.push(format!(
"llm.agent.{}.reasoningEffort={}",
agent.agent_id, agent.reasoning_effort
));
lines.push(format!(
"llm.agent.{}.stream={}",
agent.agent_id, agent.stream
));
if agent.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
lines.push(format!(
"llm.agent.{}.controlledWebSearchEnabled={}",
agent.agent_id, agent.web_search_enabled
));
lines.push(format!(
"llm.agent.{}.codexNativeWebSearch=disabled",
agent.agent_id
));
} else {
lines.push(format!(
"llm.agent.{}.webSearchEnabled={}",
agent.agent_id, agent.web_search_enabled
));
}
lines.push(format!(
"llm.agent.{}.contextWindowTokens={}",
agent.agent_id, agent.context_window_tokens
));
lines.push(format!(
"llm.agent.{}.autoCompactTokenLimit={}",
agent.agent_id, agent.auto_compact_token_limit
));
lines.push(format!(
"llm.agent.{}.requestTimeoutMs={}",
agent.agent_id, agent.request_timeout_ms
));
lines.push(format!(
"llm.agent.{}.maxRetries={}",
agent.agent_id, agent.max_retries
));
lines.push(format!(
"llm.agent.{}.retryBackoffMs={}",
agent.agent_id, agent.retry_backoff_ms
));
if let Some(error) = agent.error.as_deref() {
lines.push(format!("llm.agent.{}.error={error}", agent.agent_id));
}
}
if let Some(error) = status.error.as_deref() {
lines.push(format!("llm.error={error}"));
}
lines
}
pub(crate) fn parse_cli_command(args: &[String]) -> Result<Option<CliCommand>, String> {
if args.first().map(String::as_str) == Some("--environment-check") {
if args.len() != 1 {
return Err("用法:--environment-check".into());
}
return Ok(Some(CliCommand::EnvironmentCheck));
}
if args.first().map(String::as_str) == Some("--preview-serve") {
if args.len() != 2 || args[1].trim().is_empty() {
return Err(PREVIEW_SERVE_USAGE.to_string());
}
return Ok(Some(CliCommand::PreviewServe {
project_path: PathBuf::from(&args[1]),
}));
}
if args.first().map(String::as_str) == Some("--llm-status") {
return Ok(Some(CliCommand::LlmStatus));
}
Ok(None)
}
pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> {
match command {
CliCommand::EnvironmentCheck => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|_| "无法启动环境预检".to_string())?;
let report = runtime.block_on(crate::environment_check::host_web_creation_preflight());
println!(
"{}",
serde_json::to_string(&report).map_err(|_| "无法序列化环境预检".to_string())?
);
if report["status"] == "ready" {
Ok(())
} else {
Err("Web 游戏环境预检未通过;未启动生成".into())
}
}
CliCommand::LlmStatus => {
let status = check_game_creator_llm_config_from_config();
for line in game_creator_llm_status_lines(&status) {
println!("{line}");
}
if status.configured {
Ok(())
} else {
Err("LLM 配置未就绪".to_string())
}
}
CliCommand::PreviewServe { project_path } => {
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CLI runtime 失败:{error}"))?;
let registry = game_creator_preview_registry();
let preview = start_local_game_preview_at(&project_path, &registry)?;
println!("preview.running");
println!("projectPath={}", project_path.display());
println!("previewUrl={}", preview.url);
let wait_result = std::io::stdout()
.flush()
.map_err(|error| format!("刷新本地预览 CLI 输出失败:{error}"))
.and_then(|()| {
runtime
.block_on(tokio::signal::ctrl_c())
.map_err(|error| format!("等待 Ctrl+C 失败:{error}"))
});
let stop_result = stop_local_game_preview_for_root(Some(&project_path), &registry)
.map(|_| ())
.map_err(|error| format!("停止本地预览失败:{error}"));
match (wait_result, stop_result) {
(Ok(()), Ok(())) => Ok(()),
(Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
(Err(wait_error), Err(stop_error)) => {
Err(format!("{wait_error};同时{stop_error}"))
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_check_cli_accepts_no_project_script_or_credentials() {
assert_eq!(
parse_cli_command(&["--environment-check".into()]).unwrap(),
Some(CliCommand::EnvironmentCheck)
);
assert!(
parse_cli_command(&["--environment-check".into(), "user-script.js".into()]).is_err()
);
assert!(CliCommand::EnvironmentCheck.project_path_mut().is_none());
}
#[test]
fn parses_preview_serve_without_config() {
let project_path = std::env::current_dir().expect("current directory");
let mut command = parse_cli_command(&[
"--preview-serve".to_string(),
project_path.display().to_string(),
])
.expect("parse preview serve")
.expect("preview serve command");
assert_eq!(
command,
CliCommand::PreviewServe {
project_path: project_path.clone(),
}
);
assert_eq!(
prepare_cli_command_paths(&mut command, None)
.expect("prepare preview serve without config dir"),
None
);
assert_eq!(
command,
CliCommand::PreviewServe {
project_path: fs::canonicalize(project_path).expect("canonical project path"),
}
);
}
#[test]
fn preview_serve_requires_exactly_one_non_empty_project_path() {
for args in [
vec!["--preview-serve"],
vec!["--preview-serve", " "],
vec!["--preview-serve", "/tmp/game-project", "/tmp/extra"],
] {
let args = args.into_iter().map(str::to_string).collect::<Vec<_>>();
assert_eq!(
parse_cli_command(&args),
Err(PREVIEW_SERVE_USAGE.to_string()),
"unexpected parse result for {args:?}"
);
}
}
}