diff --git a/README.md b/README.md index 51b0286..7767cb5 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标 - `/api/v1/events` 使用 SSE 推送运行状态和日志增量,前端断线时自动回退到轮询。 - 运行历史支持按状态和触发方式筛选,并支持下载完整日志。 - 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。 +- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。 +- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。 - 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。 - React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。 - 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。 @@ -345,12 +347,14 @@ task_pilot/ - [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md) - [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md) +- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md) ## 当前限制 - 重试策略字段已持久化,但自动重试尚未实现。 - HTTP 任务执行器尚未提供响应 Header 转换等高级断言,只校验状态码。 - 敏感值脱敏基于变量名关键词和值替换,不是系统凭据保险箱。 +- Conda 环境不会按 venv 自动激活,目前需要显式填写其 Python 解释器路径。 - 当前版本是本地单用户工具,不支持多用户账户和远程部署。 - 尚未安装为 Windows 系统服务,关闭启动脚本后调度器也会停止。 - 旧 `task_pilot.html` 暂时保留在根目录作为设计参考,正式入口为 React 应用。 diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 7bc9489..e95f569 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -3,6 +3,7 @@ pub mod cron; pub mod database; pub mod error; pub mod process_job; +pub mod python_env; pub mod runs; pub mod scheduler; pub mod security; diff --git a/backend/src/python_env.rs b/backend/src/python_env.rs new file mode 100644 index 0000000..4c9b721 --- /dev/null +++ b/backend/src/python_env.rs @@ -0,0 +1,386 @@ +use std::{ + collections::BTreeMap, + env, + ffi::OsString, + io, + path::{Path, PathBuf}, +}; + +use tokio::process::Command; + +const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"]; + +#[derive(Debug, Clone)] +pub struct PreparedPython { + pub interpreter: PathBuf, + pub virtual_env: Option, + environment: BTreeMap, + remove_environment: Vec<&'static str>, +} + +impl PreparedPython { + pub fn apply(&self, command: &mut Command) { + for key in &self.remove_environment { + command.env_remove(key); + } + command.envs(&self.environment); + } +} + +pub fn prepare_script_environment( + working_dir: &str, + interpreter: &str, + configured_venv: Option<&str>, + user_env: &BTreeMap, +) -> io::Result { + if !is_python_interpreter(interpreter) { + return Ok(PreparedPython { + interpreter: resolve_interpreter(working_dir, interpreter), + virtual_env: None, + environment: user_env.clone(), + remove_environment: Vec::new(), + }); + } + + let working_directory = absolute_path(Path::new(working_dir))?; + let interpreter_path = resolve_interpreter(working_dir, interpreter); + let explicit_venv = configured_venv + .filter(|value| !value.trim().is_empty()) + .map(|value| resolve_against(&working_directory, value)) + .transpose()?; + let detected_venv = explicit_venv + .as_deref() + .map(validate_venv) + .transpose()? + .flatten() + .or_else(|| detect_venv_from_interpreter(&interpreter_path)) + .or_else(|| { + is_generic_python(interpreter) + .then(|| detect_venv_from_ancestors(&working_directory)) + .flatten() + }); + + let mut environment = BTreeMap::new(); + for (key, value) in user_env { + if key.eq_ignore_ascii_case("PATH") + || key.eq_ignore_ascii_case("VIRTUAL_ENV") + || key.eq_ignore_ascii_case("VIRTUAL_ENV_PROMPT") + || (detected_venv.is_some() && key.eq_ignore_ascii_case("PYTHONHOME")) + { + continue; + } + environment.insert(key.clone(), value.clone()); + } + + if let Some(venv) = &detected_venv { + let scripts_dir = venv.join("Scripts"); + let python = scripts_dir.join("python.exe"); + environment.insert( + "VIRTUAL_ENV".to_owned(), + venv.to_string_lossy().into_owned(), + ); + environment.insert( + "VIRTUAL_ENV_PROMPT".to_owned(), + venv.file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + ); + environment.insert( + "PATH".to_owned(), + build_venv_path(&scripts_dir, user_env)? + .to_string_lossy() + .into_owned(), + ); + environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned()); + return Ok(PreparedPython { + interpreter: python, + virtual_env: Some(venv.clone()), + environment, + remove_environment: vec![ + "PYTHONHOME", + "PYTHONPATH", + "VIRTUAL_ENV", + "VIRTUAL_ENV_PROMPT", + ], + }); + } + + environment.insert("PYTHONUNBUFFERED".to_owned(), "1".to_owned()); + Ok(PreparedPython { + interpreter: interpreter_path, + virtual_env: None, + environment, + remove_environment: vec!["VIRTUAL_ENV", "VIRTUAL_ENV_PROMPT"], + }) +} + +pub fn validate_configured_venv(working_dir: &str, configured_venv: &str) -> io::Result { + let working_directory = absolute_path(Path::new(working_dir))?; + let venv = resolve_against(&working_directory, configured_venv)?; + validate_venv(&venv)?.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("无效的 Python 虚拟环境: {}", venv.display()), + ) + }) +} + +fn validate_venv(path: &Path) -> io::Result> { + let root = absolute_path(path)?; + let python = root.join("Scripts").join("python.exe"); + if !root.join("pyvenv.cfg").is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("虚拟环境缺少 pyvenv.cfg: {}", root.display()), + )); + } + if !python.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("虚拟环境缺少 Scripts\\python.exe: {}", root.display()), + )); + } + Ok(Some(root)) +} + +fn detect_venv_from_ancestors(start: &Path) -> Option { + for directory in start.ancestors() { + for name in VENV_DIRECTORY_NAMES { + let candidate = directory.join(name); + if candidate.join("pyvenv.cfg").is_file() + && candidate.join("Scripts").join("python.exe").is_file() + { + return Some(candidate); + } + } + } + None +} + +fn detect_venv_from_interpreter(interpreter: &Path) -> Option { + let scripts = interpreter.parent()?; + if !scripts + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("Scripts")) + { + return None; + } + let root = scripts.parent()?; + (root.join("pyvenv.cfg").is_file() && scripts.join("python.exe").is_file()) + .then(|| root.to_path_buf()) +} + +fn build_venv_path( + scripts_dir: &Path, + user_env: &BTreeMap, +) -> io::Result { + let base = user_env + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("PATH")) + .map(|(_, value)| OsString::from(value)) + .or_else(|| env::var_os("PATH")) + .unwrap_or_default(); + let mut seen = std::collections::HashSet::new(); + let mut paths = Vec::new(); + for path in std::iter::once(scripts_dir.to_path_buf()).chain(env::split_paths(&base)) { + let key = path.to_string_lossy().to_ascii_lowercase(); + if seen.insert(key) { + paths.push(path); + } + } + env::join_paths(paths).map_err(io::Error::other) +} + +fn resolve_interpreter(working_dir: &str, interpreter: &str) -> PathBuf { + let path = Path::new(interpreter); + if path.is_absolute() || path.components().count() == 1 { + path.to_path_buf() + } else { + Path::new(working_dir).join(path) + } +} + +fn resolve_against(base: &Path, value: &str) -> io::Result { + let path = Path::new(value); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + }; + absolute_path(&resolved) +} + +fn absolute_path(path: &Path) -> io::Result { + std::path::absolute(path) +} + +fn is_python_interpreter(interpreter: &str) -> bool { + let name = Path::new(interpreter) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(interpreter) + .to_ascii_lowercase(); + name == "py.exe" || name == "py" || name.starts_with("python") +} + +fn is_generic_python(interpreter: &str) -> bool { + Path::new(interpreter).components().count() == 1 && is_python_interpreter(interpreter) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn create_fake_venv(root: &Path) -> PathBuf { + let venv = root.join(".venv"); + fs::create_dir_all(venv.join("Scripts")).unwrap(); + fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python").unwrap(); + fs::write(venv.join("Scripts").join("python.exe"), "").unwrap(); + venv + } + + #[test] + fn detects_venv_from_working_directory_and_builds_path() { + let temp = TempDir::new().unwrap(); + let venv = create_fake_venv(temp.path()); + let working_dir = temp.path().join("project").join("src"); + fs::create_dir_all(&working_dir).unwrap(); + let mut user_env = BTreeMap::new(); + user_env.insert("CUSTOM_VALUE".to_owned(), "yes".to_owned()); + user_env.insert("PYTHONHOME".to_owned(), "C:\\wrong".to_owned()); + + let prepared = prepare_script_environment( + working_dir.to_str().unwrap(), + "python.exe", + None, + &user_env, + ) + .unwrap(); + + assert_eq!(prepared.virtual_env, Some(venv.clone())); + assert_eq!( + prepared.interpreter, + venv.join("Scripts").join("python.exe") + ); + assert_eq!( + prepared.environment.get("VIRTUAL_ENV"), + Some(&venv.to_string_lossy().into_owned()) + ); + assert_eq!( + prepared.environment.get("PYTHONUNBUFFERED"), + Some(&"1".to_owned()) + ); + } + + #[test] + fn explicit_venv_is_validated() { + let temp = TempDir::new().unwrap(); + let venv = create_fake_venv(temp.path()); + let prepared = prepare_script_environment( + temp.path().to_str().unwrap(), + "python.exe", + Some(venv.to_str().unwrap()), + &BTreeMap::new(), + ) + .unwrap(); + + assert_eq!(prepared.virtual_env, Some(venv)); + } + + #[test] + fn invalid_explicit_venv_returns_error() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("missing-venv"); + let error = prepare_script_environment( + temp.path().to_str().unwrap(), + "python.exe", + Some(missing.to_str().unwrap()), + &BTreeMap::new(), + ) + .unwrap_err(); + + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn non_python_interpreter_is_not_modified() { + let temp = TempDir::new().unwrap(); + create_fake_venv(temp.path()); + let prepared = prepare_script_environment( + temp.path().to_str().unwrap(), + "node.exe", + None, + &BTreeMap::new(), + ) + .unwrap(); + + assert_eq!(prepared.virtual_env, None); + assert!(prepared.environment.is_empty()); + } + + #[test] + fn venv_environment_overrides_conflicting_python_variables() { + let temp = TempDir::new().unwrap(); + let venv = create_fake_venv(temp.path()); + let mut user_env = BTreeMap::new(); + user_env.insert( + "Path".to_owned(), + temp.path() + .join("custom-bin") + .to_string_lossy() + .into_owned(), + ); + user_env.insert("PYTHONHOME".to_owned(), "C:\\wrong".to_owned()); + user_env.insert("PYTHONUNBUFFERED".to_owned(), "0".to_owned()); + user_env.insert( + "PYTHONPATH".to_owned(), + "C:\\explicit-python-path".to_owned(), + ); + + let prepared = prepare_script_environment( + temp.path().to_str().unwrap(), + "python.exe", + None, + &user_env, + ) + .unwrap(); + + assert!(!prepared.environment.contains_key("PYTHONHOME")); + assert_eq!( + prepared.environment.get("PYTHONUNBUFFERED"), + Some(&"1".to_owned()) + ); + assert_eq!( + prepared.environment.get("PYTHONPATH"), + Some(&"C:\\explicit-python-path".to_owned()) + ); + let paths = env::split_paths( + prepared + .environment + .get("PATH") + .expect("venv PATH should be present"), + ) + .collect::>(); + assert_eq!(paths.first(), Some(&venv.join("Scripts"))); + assert!(paths.contains(&temp.path().join("custom-bin"))); + } + + #[test] + fn global_python_removes_stale_virtual_env_markers() { + let temp = TempDir::new().unwrap(); + let prepared = prepare_script_environment( + temp.path().to_str().unwrap(), + "python.exe", + None, + &BTreeMap::new(), + ) + .unwrap(); + + assert_eq!(prepared.virtual_env, None); + assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV")); + assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT")); + } +} diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs index c1dc57a..c6262c4 100644 --- a/backend/src/runs/executor.rs +++ b/backend/src/runs/executor.rs @@ -25,6 +25,7 @@ use tokio_util::sync::CancellationToken; use crate::{ error::ApiResult, process_job::ProcessJob, + python_env::prepare_script_environment, runs::{ model::{RunStatus, RunTrigger, TaskRunResponse}, repository, @@ -291,18 +292,32 @@ impl TaskExecutor { match &task.action { TaskAction::Script { working_dir, + venv, interpreter, script, args, env, } => { - let mut command = Command::new(interpreter); - command - .arg(script) - .args(args) - .envs(env) - .current_dir(working_dir); - self.run_command(task, run_id, cancellation, command).await + let prepared = match prepare_script_environment( + working_dir, + interpreter, + venv.as_deref(), + env, + ) { + Ok(prepared) => prepared, + Err(error) => { + return failed_outcome(format!("准备 Python 运行环境失败: {error}")); + } + }; + let mut command = Command::new(&prepared.interpreter); + prepared.apply(&mut command); + command.arg(script).args(args).current_dir(working_dir); + let startup_note = prepared + .virtual_env + .as_ref() + .map(|venv| format!("使用 Python 虚拟环境: {}", venv.display())); + self.run_command(task, run_id, cancellation, command, startup_note) + .await } TaskAction::Process { executable, @@ -315,7 +330,8 @@ impl TaskExecutor { if let Some(working_dir) = working_dir { command.current_dir(working_dir); } - self.run_command(task, run_id, cancellation, command).await + self.run_command(task, run_id, cancellation, command, None) + .await } TaskAction::Http { .. } => self.run_http(task, run_id, cancellation).await, } @@ -327,6 +343,7 @@ impl TaskExecutor { run_id: i64, cancellation: &CancellationToken, mut command: Command, + startup_note: Option, ) -> ScriptOutcome { let log_path = self.log_path(&task.id, run_id); let secrets = sensitive_values(&task.action); @@ -342,6 +359,9 @@ impl TaskExecutor { Ok(sink) => sink, Err(error) => return failed_outcome(format!("无法创建运行日志: {error}")), }; + if let Some(note) = startup_note { + sink.system(¬e).await; + } let job = match ProcessJob::new() { Ok(job) => job, diff --git a/backend/src/scheduler.rs b/backend/src/scheduler.rs index 4fddc24..aac870a 100644 --- a/backend/src/scheduler.rs +++ b/backend/src/scheduler.rs @@ -176,6 +176,7 @@ mod tests { retry_policy: RetryPolicy::default(), action: TaskAction::Script { working_dir: ".".to_owned(), + venv: None, interpreter: "cmd.exe".to_owned(), script: "/C".to_owned(), args: vec!["exit".to_owned(), "0".to_owned()], diff --git a/backend/src/tasks/model.rs b/backend/src/tasks/model.rs index b66f08b..5726b63 100644 --- a/backend/src/tasks/model.rs +++ b/backend/src/tasks/model.rs @@ -55,6 +55,8 @@ pub struct RetryPolicy { pub enum TaskAction { Script { working_dir: String, + #[serde(default)] + venv: Option, interpreter: String, script: String, #[serde(default)] diff --git a/backend/src/tasks/service.rs b/backend/src/tasks/service.rs index 4b8a0b7..1346b3a 100644 --- a/backend/src/tasks/service.rs +++ b/backend/src/tasks/service.rs @@ -7,6 +7,7 @@ use croner::Cron; use sqlx::SqlitePool; use url::Url; +use crate::python_env::validate_configured_venv; use crate::{error::ApiResult, tasks::model::*}; use super::repository::{self, ListFilters}; @@ -182,12 +183,22 @@ fn validate_action(action: &TaskAction) -> ApiResult<()> { match action { TaskAction::Script { working_dir, + venv, interpreter, script, args, env, } => { validate_required("action.working_dir", working_dir)?; + if let Some(venv) = venv { + validate_required("action.venv", venv)?; + validate_configured_venv(working_dir, venv).map_err(|error| { + crate::error::ApiError::validation( + "action.venv", + format!("虚拟环境无效: {error}"), + ) + })?; + } validate_required("action.interpreter", interpreter)?; validate_required("action.script", script)?; validate_args(args)?; @@ -319,6 +330,7 @@ mod tests { fn script_action() -> TaskAction { TaskAction::Script { working_dir: "D:\\Projects\\sync".to_owned(), + venv: None, interpreter: "python.exe".to_owned(), script: "main.py".to_owned(), args: vec!["--mode".to_owned(), "full".to_owned()], @@ -358,6 +370,7 @@ mod tests { fn rejects_empty_script_path() { let action = TaskAction::Script { working_dir: "D:\\Projects\\sync".to_owned(), + venv: None, interpreter: "python.exe".to_owned(), script: " ".to_owned(), args: Vec::new(), diff --git a/backend/tests/runs.rs b/backend/tests/runs.rs index 56dc7a7..1455146 100644 --- a/backend/tests/runs.rs +++ b/backend/tests/runs.rs @@ -275,6 +275,77 @@ async fn manual_script_run_captures_exit_code_and_logs() { app.state.shutdown(); } +#[tokio::test] +async fn auto_detected_venv_injects_activation_environment() { + let app = test_app().await; + let project = app._temp_dir.path().join("project"); + let working_dir = project.join("src"); + let venv = project.join(".venv"); + std::fs::create_dir_all(&working_dir).unwrap(); + std::fs::create_dir_all(venv.join("Scripts")).unwrap(); + std::fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python").unwrap(); + let command_shell = std::env::var_os("COMSPEC") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from("C:\\Windows\\System32\\cmd.exe")); + std::fs::copy(command_shell, venv.join("Scripts").join("python.exe")).unwrap(); + + let task = json!({ + "id": "venv_task", + "name": "Venv task", + "cron": "0 0 2 * * *", + "timeout_secs": 5, + "action": { + "type": "script", + "working_dir": working_dir, + "interpreter": "python.exe", + "script": "/C", + "args": [ + "echo", + "VIRTUAL=%VIRTUAL_ENV%;UNBUFFERED=%PYTHONUNBUFFERED%;HOME=%PYTHONHOME%;CUSTOM=%CUSTOM%" + ], + "env": { + "PYTHONHOME": "C:\\wrong-python-home", + "PYTHONUNBUFFERED": "0", + "CUSTOM": "yes" + } + } + }); + let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await; + assert_eq!(create_response.status(), StatusCode::CREATED); + let run = send_json( + &app.router, + Method::POST, + "/api/v1/tasks/venv_task/run", + None, + ) + .await; + let run_id = response_json(run).await["id"].as_i64().unwrap(); + assert_eq!( + wait_for_run(&app.router, run_id).await["status"], + "succeeded" + ); + let log = response_json( + send_json( + &app.router, + Method::GET, + &format!("/api/v1/runs/{run_id}/logs"), + None, + ) + .await, + ) + .await; + let content = log["content"].as_str().unwrap(); + assert!( + content.contains(&format!( + "VIRTUAL={};UNBUFFERED=1;HOME=%PYTHONHOME%;CUSTOM=yes", + venv.display() + )), + "unexpected venv log output: {content}" + ); + assert!(!content.contains("wrong-python-home")); + app.state.shutdown(); +} + #[tokio::test] async fn http_and_process_executors_run_through_the_api() { let app = test_app().await; diff --git a/contracts/openapi.json b/contracts/openapi.json index b62c429..5be4157 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -999,6 +999,12 @@ "script" ] }, + "venv": { + "type": [ + "string", + "null" + ] + }, "working_dir": { "type": "string" } diff --git a/docs/decisions/0003-python-environment.md b/docs/decisions/0003-python-environment.md new file mode 100644 index 0000000..32ccb29 --- /dev/null +++ b/docs/decisions/0003-python-environment.md @@ -0,0 +1,39 @@ +# ADR-0003:Python 虚拟环境启动环境 + +- 状态:已接受 +- 日期:2026-10-19 + +## 背景 + +直接执行虚拟环境中的 `python.exe` 只解决了当前进程使用哪个解释器,并不会自动完成命令行激活脚本所做的环境准备。脚本派生的子进程可能因此找不到虚拟环境内的工具,或受到宿主机全局 Python 环境变量污染。 + +## 决策 + +所有脚本任务在启动前统一经过后端 `python_env` 模块: + +1. 只有解释器确认为 Python 时才应用 Python 专属环境。 +2. 支持显式配置虚拟环境目录;未配置时从工作目录向上搜索 `.venv`、`venv`、`env`。 +3. 虚拟环境必须同时存在 `pyvenv.cfg` 和 `Scripts/python.exe`,否则显式配置在任务保存时直接拒绝,自动探测则跳过无效目录。 +4. 优先使用虚拟环境自己的 `Scripts/python.exe`。 +5. 将 `Scripts` 目录置于 PATH 最前方,同时保留用户提供的 PATH 或宿主机 PATH,并按 Windows 大小写不敏感规则去重。 +6. 注入 `VIRTUAL_ENV` 和 `VIRTUAL_ENV_PROMPT`。 +7. 清理继承的 `PYTHONHOME`、`PYTHONPATH` 和旧的 `VIRTUAL_ENV` 标记。 +8. 虚拟环境模式禁止用户环境重新注入 `PYTHONHOME`;显式配置的 `PYTHONPATH` 仍可在清理继承污染后重新加入。 +9. 强制设置 `PYTHONUNBUFFERED=1`。 +10. 未能检测到虚拟环境时,平滑回退到配置的 Python 解释器。 + +## 原因 + +- PATH 是隔离子进程工具解析的关键,不只是当前 Python 解释器路径。 +- `VIRTUAL_ENV` 是第三方框架判断隔离环境的重要标识。 +- 继承的 Python 全局变量可能破坏 venv 的标准库解析。 +- 实时日志依赖 Python 标准流不被块缓冲。 +- 仅按目录名判断不可靠,必须验证 venv 结构。 +- Windows 环境变量大小写不敏感,必须主动处理 `PATH`/`Path` 重复。 + +## 边界 + +- Conda 环境不会被识别为标准 venv;当前可通过显式解释器路径运行,但不会自动设置 `CONDA_PREFIX`。 +- `PYTHONUNBUFFERED` 只影响 Python 标准流。第三方日志 Handler 或非 Python 子进程仍可能自行缓冲。 +- 虚拟环境路径必须是后端运行时可以访问的有效 Windows 目录。 +- 使用系统 Python 时不会伪造虚拟环境变量。 diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index da15bf9..3814248 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -67,11 +67,17 @@ test('manages a task and reads a real run log', async ({ page, request }) => { await newTaskDialog .getByPlaceholder('参数值 (可选,无值可留空)') .fill('full') + await newTaskDialog + .getByLabel(/虚拟环境目录/) + .fill('.venv') await newTaskDialog .getByText('调用预览 (Runtime Preview)') .scrollIntoViewIfNeeded() await expect( - newTaskDialog.getByText('--mode full', { exact: false }), + newTaskDialog.getByText( + '.venv\\Scripts\\python.exe main.py --mode full', + { exact: false }, + ), ).toBeVisible() await page.screenshot({ path: 'test-results/dashboard-task-editor-runtime.png', diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index b2e1d71..8581b0c 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -302,6 +302,7 @@ export interface components { script: string; /** @enum {string} */ type: "script"; + venv?: string | null; working_dir: string; } | { body?: string | null; diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index 4aeb5d9..f29662e 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -328,6 +328,16 @@ export function TaskEditorModal({ /> + + + Windows 原生环境
+ {scriptPreview.venv.trim() ? ( +
+ set VIRTUAL_ENV={scriptPreview.venv.trim()} +
+ ) : null} {scriptPreview.env .filter((item) => item.key.trim()) .map((item, index) => ( @@ -416,7 +431,7 @@ export function TaskEditorModal({
))}
- {scriptPreview.interpreter.trim() || 'python.exe'}{' '} + {formatInterpreterPreview(scriptPreview)}{' '} {scriptPreview.script.trim() || 'main.py'} {formatArgumentPreview(scriptPreview.args)}
@@ -598,6 +613,15 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string { return values.length ? ` ${values.join(' ')}` : '' } +function formatInterpreterPreview( + script: TaskFormValues['script'], +): string { + if (script.venv.trim()) { + return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe` + } + return script.interpreter.trim() || 'python.exe' +} + function isSensitiveName(name: string): boolean { const normalized = name.toUpperCase() return [ diff --git a/frontend/src/lib/taskForm.test.ts b/frontend/src/lib/taskForm.test.ts index e185823..367d7e1 100644 --- a/frontend/src/lib/taskForm.test.ts +++ b/frontend/src/lib/taskForm.test.ts @@ -6,6 +6,28 @@ import { } from './taskForm' describe('task form payload', () => { + it('submits an explicit Python virtual environment', () => { + const values = createEmptyTaskFormValues() + values.id = 'venv_task' + values.name = 'Venv task' + values.script.working_dir = 'D:\\Projects\\venv-task' + values.script.venv = '.venv' + values.script.interpreter = 'python.exe' + values.script.script = 'main.py' + values.script.args = [{ key: '--mode', value: 'full' }] + + const payload = valuesToCreateRequest(values) + + expect(payload.action).toMatchObject({ + type: 'script', + working_dir: 'D:\\Projects\\venv-task', + venv: '.venv', + interpreter: 'python.exe', + script: 'main.py', + args: ['--mode', 'full'], + }) + }) + it('creates an HTTP action instead of forcing script type', () => { const values = createEmptyTaskFormValues() values.id = 'health_check' diff --git a/frontend/src/lib/taskForm.ts b/frontend/src/lib/taskForm.ts index 395127d..8368524 100644 --- a/frontend/src/lib/taskForm.ts +++ b/frontend/src/lib/taskForm.ts @@ -22,6 +22,7 @@ export interface TaskFormValues { actionType: TaskActionType script: { working_dir: string + venv: string interpreter: string script: string args: KeyValueFormValue[] @@ -86,6 +87,7 @@ export const taskFormSchema = z actionType: z.enum(['script', 'http', 'process']), script: z.object({ working_dir: z.string(), + venv: z.string(), interpreter: z.string(), script: z.string(), args: z.array(keyValueSchema), @@ -168,6 +170,7 @@ export function createEmptyTaskFormValues(): TaskFormValues { actionType: 'script', script: { working_dir: '', + venv: '', interpreter: 'python.exe', script: 'main.py', args: [], @@ -202,6 +205,7 @@ export function taskToFormValues(task: Task): TaskFormValues { if (task.action.type === 'script') { values.script = { working_dir: task.action.working_dir, + venv: task.action.venv ?? '', interpreter: task.action.interpreter, script: task.action.script, args: argsToRows(task.action.args ?? []), @@ -240,6 +244,7 @@ export function valuesToAction(values: TaskFormValues): TaskAction { return { type: 'script', working_dir: values.script.working_dir.trim(), + venv: values.script.venv.trim() || null, interpreter: values.script.interpreter.trim(), script: values.script.script.trim(), args: argumentsToArray(values.script.args),