diff --git a/README.md b/README.md index 7767cb5..e6fa8de 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标 - 运行历史支持按状态和触发方式筛选,并支持下载完整日志。 - 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。 - Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。 +- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。 - 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。 +- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。 - 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。 - React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。 - 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。 @@ -154,8 +156,11 @@ cargo run --manifest-path backend/Cargo.toml $env:TASK_PILOT_LOG_DIR = "logs" $env:TASK_PILOT_LOG_RETENTION_DAYS = "30" $env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512" +$env:TASK_PILOT_LOG_ENCODING = "auto" ``` +`TASK_PILOT_LOG_ENCODING` 默认为 `auto`:逐行先按 UTF-8 解码,失败时回退到系统 ANSI 代码页(简体中文为 GBK),保证中文程序输出不会变成 `����`。需要强制某一种编码时可填写 `gbk`、`utf-8`、`windows-1251` 等标签。 + 单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。 ## API @@ -165,6 +170,7 @@ $env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512" | `GET` | `/api/v1/health` | 后端健康检查 | | `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 | | `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 | +| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 | | `GET` | `/api/v1/tasks` | 查询任务列表 | | `POST` | `/api/v1/tasks` | 创建任务 | | `GET` | `/api/v1/tasks/{id}` | 查询任务详情 | @@ -348,6 +354,7 @@ 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) +- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md) ## 当前限制 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 15c7169..28473a8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -279,6 +279,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -485,6 +491,20 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -1208,6 +1228,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2140,6 +2166,7 @@ dependencies = [ "chrono", "chrono-tz", "croner", + "encoding_rs", "futures-util", "http-body-util", "reqwest", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 757dce5..9f80bc1 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -9,6 +9,7 @@ axum = "0.8.9" chrono = { version = "0.4.45", features = ["serde"] } chrono-tz = "0.10.4" croner = "4.0.1" +encoding_rs = "0.8.35" futures-util = { version = "0.3.34", features = ["std"] } reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] } serde = { version = "1.0.229", features = ["derive"] } @@ -25,7 +26,7 @@ url = "2.5.8" utoipa = { version = "6.0.0", features = ["axum_extras", "chrono"] } utoipa-axum = "0.3.0" uuid = { version = "1.28.0", features = ["v4"] } -windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects"] } +windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_JobObjects"] } [dev-dependencies] http-body-util = "0.1.5" diff --git a/backend/src/api.rs b/backend/src/api.rs index a2899f1..35fdcbf 100644 --- a/backend/src/api.rs +++ b/backend/src/api.rs @@ -14,6 +14,9 @@ use utoipa::{OpenApi, ToSchema}; use crate::{ cron::{self, CronPreviewRequest, CronPreviewResponse}, error::{ApiError, ApiJson, ApiResult, ErrorResponse}, + python_env::{ + self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource, + }, runs::{ model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse}, routes::{ @@ -107,6 +110,25 @@ pub async fn preview_cron( Ok(Json(preview)) } +#[utoipa::path( + post, + path = "/api/v1/python/venv", + tag = "system", + request_body = VenvProbeRequest, + responses( + (status = 200, description = "Detected Python virtual environment", body = VenvProbeResponse), + (status = 400, description = "Invalid venv probe request", body = ErrorResponse) + ) +)] +pub async fn probe_python_venv( + ApiJson(request): ApiJson, +) -> ApiResult> { + tokio::task::spawn_blocking(move || python_env::probe_venv(&request)) + .await + .map(Json) + .map_err(ApiError::internal) +} + #[derive(OpenApi)] #[openapi( info( @@ -118,6 +140,7 @@ pub async fn preview_cron( health, session, preview_cron, + probe_python_venv, list_tasks, create_task, get_task, @@ -138,6 +161,11 @@ pub async fn preview_cron( SessionResponse, CronPreviewRequest, CronPreviewResponse, + VenvProbeRequest, + VenvProbeResponse, + VenvProbeStatus, + VenvSource, + InterpreterKind, ErrorResponse, TaskActionType, HttpMethod, @@ -183,6 +211,10 @@ pub fn router(state: AppState) -> Router { .route("/api/v1/health", get(health)) .route("/api/v1/session", get(session)) .route("/api/v1/cron/preview", axum::routing::post(preview_cron)) + .route( + "/api/v1/python/venv", + axum::routing::post(probe_python_venv), + ) .route("/api/v1/openapi.json", get(openapi_json)) .merge(crate::tasks::routes::router()) .merge(crate::runs::routes::router()) diff --git a/backend/src/python_env.rs b/backend/src/python_env.rs index 4c9b721..e4371b7 100644 --- a/backend/src/python_env.rs +++ b/backend/src/python_env.rs @@ -6,7 +6,9 @@ use std::{ path::{Path, PathBuf}, }; +use serde::{Deserialize, Serialize}; use tokio::process::Command; +use utoipa::ToSchema; const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"]; @@ -42,23 +44,8 @@ pub fn prepare_script_environment( }); } - 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 detected_venv = resolve_venv(working_dir, interpreter, configured_venv)?.venv; let mut environment = BTreeMap::new(); for (key, value) in user_env { @@ -115,6 +102,265 @@ pub fn prepare_script_environment( }) } +/// 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum VenvSource { + /// 手动填写的 `action.venv` + Configured, + /// 由解释器路径(`...\.venv\Scripts\python.exe`)识别 + Interpreter, + /// 从工作目录或上级目录探测到的 `.venv`、`venv` 或 `env` + WorkingDirectory, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedVenv { + pub venv: Option, + pub source: Option, +} + +/// 解析任务实际会使用的虚拟环境,顺序与执行期保持一致: +/// 手动指定 → 解释器路径推断 → 工作目录向上探测。 +pub fn resolve_venv( + working_dir: &str, + interpreter: &str, + configured_venv: Option<&str>, +) -> io::Result { + if !is_python_interpreter(interpreter) { + return Ok(ResolvedVenv { + venv: None, + source: None, + }); + } + + 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()?; + + if let Some(venv) = explicit_venv + .as_deref() + .map(validate_venv) + .transpose()? + .flatten() + { + return Ok(ResolvedVenv { + venv: Some(venv), + source: Some(VenvSource::Configured), + }); + } + if let Some(venv) = detect_venv_from_interpreter(&interpreter_path) { + return Ok(ResolvedVenv { + venv: Some(venv), + source: Some(VenvSource::Interpreter), + }); + } + if is_generic_python(interpreter) + && let Some(venv) = detect_venv_from_ancestors(&working_directory) + { + return Ok(ResolvedVenv { + venv: Some(venv), + source: Some(VenvSource::WorkingDirectory), + }); + } + + Ok(ResolvedVenv { + venv: None, + source: None, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum InterpreterKind { + Python, + Node, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum VenvProbeStatus { + /// 已关联到有效的虚拟环境 + Linked, + /// 手动指定的虚拟环境无效 + Invalid, + /// 没有虚拟环境,将直接使用系统解释器 + Missing, + /// 解释器不是 Python,虚拟环境不适用 + NotApplicable, + /// 输入不完整,暂时无法探测 + Incomplete, +} + +/// 编辑器在用户填写工作目录时发起的探测请求。 +#[derive(Debug, Deserialize, ToSchema)] +#[serde(deny_unknown_fields)] +pub struct VenvProbeRequest { + /// 任务工作目录,对应 `action.working_dir` + pub working_dir: String, + /// 解释器或可执行程序,对应 `action.interpreter` + pub interpreter: String, + /// 手动填写的虚拟环境目录,对应 `action.venv` + #[serde(default)] + pub venv: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct VenvProbeResponse { + pub status: VenvProbeStatus, + pub interpreter_kind: InterpreterKind, + /// 虚拟环境根目录的绝对路径 + pub venv_path: Option, + /// 供界面展示的解释器路径;位于工作目录内时使用相对路径 + pub display_path: Option, + pub source: Option, + pub message: String, +} + +pub fn probe_venv(request: &VenvProbeRequest) -> VenvProbeResponse { + let working_dir = request.working_dir.trim(); + let interpreter = request.interpreter.trim(); + let configured_venv = request + .venv + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let interpreter_kind = classify_interpreter(interpreter); + + if working_dir.is_empty() { + return incomplete_probe(interpreter_kind, "请先填写工作目录"); + } + if interpreter.is_empty() { + return incomplete_probe(interpreter_kind, "请先填写解释器或可执行程序"); + } + if !matches!(interpreter_kind, InterpreterKind::Python) { + return VenvProbeResponse { + status: VenvProbeStatus::NotApplicable, + interpreter_kind, + venv_path: None, + display_path: Some(interpreter.to_owned()), + source: None, + message: "当前解释器不是 Python,不需要虚拟环境".to_owned(), + }; + } + + let working_directory = match absolute_path(Path::new(working_dir)) { + Ok(path) => path, + Err(error) => { + return incomplete_probe(interpreter_kind, &format!("工作目录无效: {error}")); + } + }; + if !working_directory.is_dir() { + return incomplete_probe(interpreter_kind, "工作目录不存在,暂时无法探测虚拟环境"); + } + + match resolve_venv(working_dir, interpreter, configured_venv) { + Ok(ResolvedVenv { + venv: Some(venv), + source, + }) => VenvProbeResponse { + status: VenvProbeStatus::Linked, + interpreter_kind, + display_path: Some(python_display_path(&venv, working_dir)), + venv_path: Some(venv.to_string_lossy().into_owned()), + source, + message: venv_source_message(source), + }, + Ok(ResolvedVenv { venv: None, .. }) => VenvProbeResponse { + status: VenvProbeStatus::Missing, + interpreter_kind, + venv_path: None, + display_path: Some(interpreter.to_owned()), + source: None, + message: "未检测到虚拟环境,将直接使用系统解释器".to_owned(), + }, + Err(error) => VenvProbeResponse { + status: VenvProbeStatus::Invalid, + interpreter_kind, + venv_path: None, + display_path: None, + source: None, + message: error.to_string(), + }, + } +} + +fn incomplete_probe(interpreter_kind: InterpreterKind, message: &str) -> VenvProbeResponse { + VenvProbeResponse { + status: VenvProbeStatus::Incomplete, + interpreter_kind, + venv_path: None, + display_path: None, + source: None, + message: message.to_owned(), + } +} + +fn venv_source_message(source: Option) -> String { + match source { + Some(VenvSource::Configured) => "已关联手动指定的虚拟环境".to_owned(), + Some(VenvSource::Interpreter) => "已从解释器路径识别虚拟环境".to_owned(), + Some(VenvSource::WorkingDirectory) => "已自动关联工作目录中的虚拟环境".to_owned(), + None => "已关联虚拟环境".to_owned(), + } +} + +fn python_display_path(venv: &Path, working_dir: &str) -> String { + let python = venv.join("Scripts").join("python.exe"); + absolute_path(Path::new(working_dir)) + .ok() + .and_then(|base| relative_display(&base, &python)) + .unwrap_or_else(|| python.to_string_lossy().into_owned()) +} + +fn relative_display(base: &Path, target: &Path) -> Option { + let base_components = base.components().collect::>(); + let target_components = target.components().collect::>(); + if target_components.len() <= base_components.len() { + return None; + } + let shares_prefix = base_components + .iter() + .zip(&target_components) + .all(|(base, target)| { + base.as_os_str() + .to_string_lossy() + .eq_ignore_ascii_case(&target.as_os_str().to_string_lossy()) + }); + if !shares_prefix { + return None; + } + + let relative = target_components[base_components.len()..] + .iter() + .map(|component| component.as_os_str()) + .collect::(); + Some(relative.to_string_lossy().into_owned()) +} + +fn classify_interpreter(interpreter: &str) -> InterpreterKind { + if is_python_interpreter(interpreter) { + return InterpreterKind::Python; + } + + let name = Path::new(interpreter) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(interpreter) + .to_ascii_lowercase(); + let stem = name.strip_suffix(".exe").unwrap_or(&name); + + if matches!(stem, "node" | "nodejs") { + InterpreterKind::Node + } else { + InterpreterKind::Other + } +} + 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)?; @@ -383,4 +629,116 @@ mod tests { assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV")); assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT")); } + + fn probe(working_dir: &str, interpreter: &str, venv: Option<&str>) -> VenvProbeResponse { + probe_venv(&VenvProbeRequest { + working_dir: working_dir.to_owned(), + interpreter: interpreter.to_owned(), + venv: venv.map(str::to_owned), + }) + } + + #[test] + fn probe_links_venv_detected_in_working_directory() { + let temp = TempDir::new().unwrap(); + create_fake_venv(temp.path()); + + let response = probe(temp.path().to_str().unwrap(), "python.exe", None); + + assert_eq!(response.status, VenvProbeStatus::Linked); + assert_eq!(response.source, Some(VenvSource::WorkingDirectory)); + assert_eq!(response.interpreter_kind, InterpreterKind::Python); + let expected = Path::new(".venv") + .join("Scripts") + .join("python.exe") + .to_string_lossy() + .into_owned(); + assert_eq!(response.display_path, Some(expected)); + } + + #[test] + fn probe_prefers_configured_venv_over_detection() { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + let venv = create_fake_venv(&project); + + let response = probe( + project.to_str().unwrap(), + "python.exe", + Some(venv.to_str().unwrap()), + ); + + assert_eq!(response.status, VenvProbeStatus::Linked); + assert_eq!(response.source, Some(VenvSource::Configured)); + } + + #[test] + fn probe_reports_system_interpreter_when_no_venv_exists() { + let temp = TempDir::new().unwrap(); + + let response = probe(temp.path().to_str().unwrap(), "python.exe", None); + + assert_eq!(response.status, VenvProbeStatus::Missing); + assert_eq!(response.display_path.as_deref(), Some("python.exe")); + assert!(response.venv_path.is_none()); + } + + #[test] + fn probe_rejects_invalid_configured_venv() { + let temp = TempDir::new().unwrap(); + let missing = temp.path().join("missing-venv"); + + let response = probe( + temp.path().to_str().unwrap(), + "python.exe", + Some(missing.to_str().unwrap()), + ); + + assert_eq!(response.status, VenvProbeStatus::Invalid); + assert!(response.message.contains("pyvenv.cfg")); + } + + #[test] + fn probe_marks_non_python_interpreters_as_not_applicable() { + let temp = TempDir::new().unwrap(); + let working_dir = temp.path().to_str().unwrap(); + + let node = probe(working_dir, "node.exe", None); + assert_eq!(node.status, VenvProbeStatus::NotApplicable); + assert_eq!(node.interpreter_kind, InterpreterKind::Node); + + let shell = probe(working_dir, "cmd.exe", None); + assert_eq!(shell.status, VenvProbeStatus::NotApplicable); + assert_eq!(shell.interpreter_kind, InterpreterKind::Other); + } + + #[test] + fn probe_requires_existing_working_directory() { + let temp = TempDir::new().unwrap(); + + let empty = probe(" ", "python.exe", None); + assert_eq!(empty.status, VenvProbeStatus::Incomplete); + assert!(empty.message.contains("工作目录")); + + let missing = temp.path().join("absent"); + let response = probe(missing.to_str().unwrap(), "python.exe", None); + assert_eq!(response.status, VenvProbeStatus::Incomplete); + assert!(response.message.contains("工作目录")); + } + + #[test] + fn probe_matches_execution_environment_for_same_input() { + let temp = TempDir::new().unwrap(); + let venv = create_fake_venv(temp.path()); + let working_dir = temp.path().to_str().unwrap(); + + let prepared = + prepare_script_environment(working_dir, "python.exe", None, &BTreeMap::new()).unwrap(); + let response = probe(working_dir, "python.exe", None); + + assert_eq!(prepared.virtual_env.as_deref(), Some(venv.as_path())); + assert_eq!(response.venv_path.as_deref(), Some(venv.to_str().unwrap())); + assert_eq!(response.status, VenvProbeStatus::Linked); + } } diff --git a/backend/src/runs/executor.rs b/backend/src/runs/executor.rs index c6262c4..a0dc92f 100644 --- a/backend/src/runs/executor.rs +++ b/backend/src/runs/executor.rs @@ -10,6 +10,7 @@ use std::{ }; use chrono::Utc; +use encoding_rs::Encoding; use futures_util::StreamExt; use reqwest::Method; use serde::Serialize; @@ -39,6 +40,77 @@ const TERMINATION_WAIT_SECS: u64 = 5; const CREATE_NO_WINDOW: u32 = 0x0800_0000; const DEFAULT_RETENTION_DAYS: u64 = 30; const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024; +const LOG_ENCODING_ENV: &str = "TASK_PILOT_LOG_ENCODING"; + +/// 子进程输出的解码方式。 +/// +/// `TASK_PILOT_LOG_ENCODING=auto`(默认)逐行先按 UTF-8 严格解码,失败时回退到系统 +/// ANSI 代码页;填写编码标签(`gbk`、`utf-8`、`windows-1251` 等)则始终按该编码解码。 +#[derive(Clone, Copy, Debug)] +pub struct OutputEncoding { + forced: Option<&'static Encoding>, + fallback: &'static Encoding, +} + +impl OutputEncoding { + pub fn from_env() -> Self { + let fallback = ansi_code_page_encoding(); + let forced = std::env::var(LOG_ENCODING_ENV).ok().and_then(|value| { + let label = value.trim().to_ascii_lowercase(); + if label.is_empty() || label == "auto" { + None + } else { + Encoding::for_label(label.as_bytes()) + } + }); + + Self { forced, fallback } + } + + pub fn decode(&self, bytes: &[u8]) -> String { + if let Some(encoding) = self.forced { + let (text, _) = encoding.decode_without_bom_handling(bytes); + return text.into_owned(); + } + + match std::str::from_utf8(bytes) { + Ok(text) => text.to_owned(), + Err(_) => { + let (text, _) = self.fallback.decode_without_bom_handling(bytes); + text.into_owned() + } + } + } + + /// 实际使用的编码名,写入运行日志便于排查乱码。 + pub fn name(&self) -> &'static str { + self.forced.unwrap_or(self.fallback).name() + } +} + +#[cfg(windows)] +fn ansi_code_page_encoding() -> &'static Encoding { + let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() }; + code_page_encoding(code_page) +} + +#[cfg(not(windows))] +fn ansi_code_page_encoding() -> &'static Encoding { + encoding_rs::UTF_8 +} + +fn code_page_encoding(code_page: u32) -> &'static Encoding { + let label = match code_page { + 936 => "gbk".to_owned(), + 950 => "big5".to_owned(), + 932 => "shift_jis".to_owned(), + 949 => "euc-kr".to_owned(), + 65001 => "utf-8".to_owned(), + other => format!("windows-{other}"), + }; + + Encoding::for_label(label.as_bytes()).unwrap_or(encoding_rs::UTF_8) +} #[derive(Clone, Debug)] pub struct LogRetentionConfig { @@ -92,6 +164,7 @@ struct ExecutorInner { http: reqwest::Client, log_config: LogRetentionConfig, cleanup_lock: AsyncMutex<()>, + output_encoding: OutputEncoding, } struct RunningHandle { @@ -107,6 +180,7 @@ struct LogSink { run_id: i64, events: broadcast::Sender, secrets: Arc>, + encoding: OutputEncoding, } struct ScriptOutcome { @@ -124,6 +198,8 @@ enum ProcessEnd { impl TaskExecutor { pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self { let (events, _) = broadcast::channel(512); + let output_encoding = OutputEncoding::from_env(); + tracing::info!(encoding = output_encoding.name(), "task output decoding"); Self { inner: Arc::new(ExecutorInner { pool, @@ -138,6 +214,7 @@ impl TaskExecutor { .unwrap_or_default(), log_config, cleanup_lock: AsyncMutex::new(()), + output_encoding, }), } } @@ -353,6 +430,7 @@ impl TaskExecutor { run_id, self.inner.events.clone(), secrets, + self.inner.output_encoding, ) .await { @@ -517,6 +595,7 @@ impl TaskExecutor { run_id, self.inner.events.clone(), sensitive_values(&task.action), + self.inner.output_encoding, ) .await { @@ -638,7 +717,7 @@ impl TaskExecutor { None => break, } } - let body_text = String::from_utf8_lossy(&response_body); + let body_text = self.inner.output_encoding.decode(&response_body); if !body_text.is_empty() { sink.http(&body_text).await; } @@ -697,6 +776,7 @@ impl LogSink { run_id: i64, events: broadcast::Sender, secrets: Vec, + encoding: OutputEncoding, ) -> std::io::Result { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent).await?; @@ -721,9 +801,14 @@ impl LogSink { run_id, events, secrets: Arc::new(secrets), + encoding, }) } + fn decode_output(&self, bytes: &[u8]) -> String { + self.encoding.decode(bytes) + } + async fn append(&self, text: &str) { let redacted = redact_text(text, &self.secrets); let bytes = redacted.as_bytes(); @@ -783,7 +868,7 @@ where match reader.read_until(b'\n', &mut line).await { Ok(0) => break, Ok(_) => { - let text = String::from_utf8_lossy(&line); + let text = sink.decode_output(&line); sink.append(&format!("[{stream}] {text}")).await; } Err(error) => { @@ -957,6 +1042,54 @@ mod tests { assert!(!is_sensitive_name("PYTHONUNBUFFERED")); } + fn auto_encoding(fallback: &'static Encoding) -> OutputEncoding { + OutputEncoding { + forced: None, + fallback, + } + } + + #[test] + fn decodes_utf8_output_without_touching_it() { + let encoding = auto_encoding(encoding_rs::GBK); + + assert_eq!(encoding.decode("中文输出".as_bytes()), "中文输出"); + } + + #[test] + fn decodes_gbk_output_through_the_system_fallback() { + let encoding = auto_encoding(encoding_rs::GBK); + // "中文" 的 GBK 编码 + let gbk = [0xD6, 0xD0, 0xCE, 0xC4]; + + assert_eq!(encoding.decode(&gbk), "中文"); + } + + #[test] + fn forced_encoding_skips_utf8_sniffing() { + let encoding = OutputEncoding { + forced: Some(encoding_rs::GBK), + fallback: encoding_rs::UTF_8, + }; + let gbk = [0xD6, 0xD0, 0xCE, 0xC4]; + + assert_eq!(encoding.decode(&gbk), "中文"); + } + + #[test] + fn undecodable_bytes_still_produce_text() { + let encoding = auto_encoding(encoding_rs::UTF_8); + + assert!(encoding.decode(&[0xFF, 0xFE, 0xFF]).contains('\u{fffd}')); + } + + #[test] + fn maps_windows_code_pages_to_encodings() { + assert_eq!(code_page_encoding(936).name(), "GBK"); + assert_eq!(code_page_encoding(65001).name(), "UTF-8"); + assert_eq!(code_page_encoding(1252).name(), "windows-1252"); + } + #[test] fn cleanup_enforces_total_log_capacity() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/backend/src/runs/routes.rs b/backend/src/runs/routes.rs index 0689eb5..9462790 100644 --- a/backend/src/runs/routes.rs +++ b/backend/src/runs/routes.rs @@ -155,13 +155,43 @@ pub async fn get_run_logs( .await .map_err(ApiError::internal)?; + let terminal = run.status.is_terminal(); + // 读取期间任务可能仍在追加日志,用最新的文件长度判断是否真的读到了末尾。 + let current_length = tokio::fs::metadata(&path) + .await + .map_err(ApiError::internal)? + .len(); + let at_end_of_file = offset + bytes.len() as u64 >= current_length; + let (content, consumed) = decode_log_chunk(&bytes, at_end_of_file); + let complete = terminal && offset + consumed as u64 >= current_length; + Ok(Json(LogChunkResponse { - content: String::from_utf8_lossy(&bytes).into_owned(), - next_offset: offset + bytes.len() as u64, - complete: run.status.is_terminal(), + content, + next_offset: offset + consumed as u64, + complete, })) } +/// 日志文件始终以 UTF-8 写入,但按 64 KiB 切块时可能切开多字节字符。 +/// 返回解码后的文本和实际消费的字节数;未读到文件末尾时保留不完整字符给下一次读取。 +fn decode_log_chunk(bytes: &[u8], at_end_of_file: bool) -> (String, usize) { + match std::str::from_utf8(bytes) { + Ok(text) => (text.to_owned(), bytes.len()), + Err(error) if error.error_len().is_none() && !at_end_of_file => { + let boundary = error.valid_up_to(); + if boundary > 0 { + let text = std::str::from_utf8(&bytes[..boundary]) + .unwrap_or_default() + .to_owned(); + return (text, boundary); + } + // 整个块都落在字符中间时不要空转,直接按损失方式消费。 + (String::from_utf8_lossy(bytes).into_owned(), bytes.len()) + } + Err(_) => (String::from_utf8_lossy(bytes).into_owned(), bytes.len()), + } +} + #[utoipa::path( post, path = "/api/v1/runs/{run_id}/cancel", @@ -263,3 +293,55 @@ pub async fn execution_events( Sse::new(events).keep_alive(KeepAlive::new().interval(std::time::Duration::from_secs(15))) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_complete_utf8_chunks_untouched() { + let text = "中文日志"; + let (content, consumed) = decode_log_chunk(text.as_bytes(), false); + + assert_eq!(content, text); + assert_eq!(consumed, text.len()); + } + + #[test] + fn defers_a_split_character_until_the_next_read() { + let bytes = [b'a', b'b', b'c', 0xE4]; + + let (content, consumed) = decode_log_chunk(&bytes, false); + + assert_eq!(content, "abc"); + assert_eq!(consumed, 3); + } + + #[test] + fn flushes_a_split_character_at_the_end_of_the_file() { + let bytes = [b'a', b'b', b'c', 0xE4]; + + let (content, consumed) = decode_log_chunk(&bytes, true); + + assert!(content.starts_with("abc")); + assert!(content.contains('\u{fffd}')); + assert_eq!(consumed, bytes.len()); + } + + #[test] + fn never_stalls_when_the_whole_chunk_is_mid_character() { + let bytes = [0xE4, 0xB8]; + + let (_, consumed) = decode_log_chunk(&bytes, false); + + assert_eq!(consumed, bytes.len()); + } + + #[test] + fn replaces_truly_invalid_bytes() { + let (content, consumed) = decode_log_chunk(&[0xFF], false); + + assert_eq!(consumed, 1); + assert!(content.contains('\u{fffd}')); + } +} diff --git a/backend/tests/python_env_probe.rs b/backend/tests/python_env_probe.rs new file mode 100644 index 0000000..e9f6b91 --- /dev/null +++ b/backend/tests/python_env_probe.rs @@ -0,0 +1,181 @@ +use std::{fs, path::Path}; + +use axum::{ + Router, + body::Body, + http::{Request, StatusCode, header}, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tower::ServiceExt; + +async fn test_app() -> (Router, TempDir) { + let temp_dir = TempDir::new().expect("temporary directory should be created"); + let pool = task_pilot_backend::database::connect("sqlite::memory:") + .await + .expect("test database should initialize"); + let app = task_pilot_backend::app(pool, temp_dir.path().join("logs")) + .await + .expect("test app should start"); + (app, temp_dir) +} + +async fn fetch_token(app: &Router) -> String { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/v1/session") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("router should respond"); + let body = response + .into_body() + .collect() + .await + .expect("session body should be readable") + .to_bytes(); + let json: Value = serde_json::from_slice(&body).expect("session should be JSON"); + + json["token"] + .as_str() + .expect("session token should be a string") + .to_owned() +} + +async fn post_probe(app: &Router, token: Option<&str>, payload: Value) -> (StatusCode, Value) { + let mut builder = Request::builder() + .method("POST") + .uri("/api/v1/python/venv") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(token) = token { + builder = builder.header("x-taskpilot-token", token); + } + let response = app + .clone() + .oneshot( + builder + .body(Body::from(payload.to_string())) + .expect("request should build"), + ) + .await + .expect("router should respond"); + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .expect("probe body should be readable") + .to_bytes(); + + (status, serde_json::from_slice(&body).unwrap_or(Value::Null)) +} + +fn create_fake_venv(project: &Path) { + let venv = project.join(".venv"); + fs::create_dir_all(venv.join("Scripts")).expect("venv directory should be created"); + fs::write(venv.join("pyvenv.cfg"), "home = C:\\Python\n") + .expect("pyvenv.cfg should be written"); + fs::write(venv.join("Scripts").join("python.exe"), "") + .expect("python.exe placeholder should be written"); +} + +#[tokio::test] +async fn venv_probe_requires_management_token() { + let (app, _temp_dir) = test_app().await; + + let (status, body) = post_probe( + &app, + None, + json!({ "working_dir": ".", "interpreter": "python.exe" }), + ) + .await; + + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body["code"], "FORBIDDEN"); +} + +#[tokio::test] +async fn venv_probe_reports_linked_environment() { + let (app, temp_dir) = test_app().await; + let project = temp_dir.path().join("project"); + fs::create_dir_all(&project).expect("project directory should be created"); + create_fake_venv(&project); + let token = fetch_token(&app).await; + + let (status, body) = post_probe( + &app, + Some(&token), + json!({ + "working_dir": project.to_str().expect("project path should be UTF-8"), + "interpreter": "python.exe", + "venv": null + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "linked"); + assert_eq!(body["interpreter_kind"], "python"); + assert_eq!(body["source"], "working_directory"); + let expected_python = Path::new(".venv") + .join("Scripts") + .join("python.exe") + .to_string_lossy() + .into_owned(); + assert_eq!(body["display_path"], expected_python); + assert_eq!( + body["venv_path"], + project.join(".venv").to_string_lossy().into_owned() + ); +} + +#[tokio::test] +async fn venv_probe_skips_non_python_interpreters() { + let (app, temp_dir) = test_app().await; + let token = fetch_token(&app).await; + + let (status, body) = post_probe( + &app, + Some(&token), + json!({ + "working_dir": temp_dir.path().to_str().expect("temp path should be UTF-8"), + "interpreter": "node.exe" + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "not_applicable"); + assert_eq!(body["interpreter_kind"], "node"); + assert_eq!(body["display_path"], "node.exe"); +} + +#[tokio::test] +async fn venv_probe_reports_missing_working_directory() { + let (app, temp_dir) = test_app().await; + let token = fetch_token(&app).await; + let absent = temp_dir.path().join("absent"); + + let (status, body) = post_probe( + &app, + Some(&token), + json!({ + "working_dir": absent.to_str().expect("absent path should be UTF-8"), + "interpreter": "python.exe" + }), + ) + .await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["status"], "incomplete"); + assert!( + body["message"] + .as_str() + .expect("message should be a string") + .contains("工作目录") + ); +} diff --git a/backend/tests/runs.rs b/backend/tests/runs.rs index 1455146..ddb6a4a 100644 --- a/backend/tests/runs.rs +++ b/backend/tests/runs.rs @@ -719,3 +719,105 @@ async fn scheduler_restores_enabled_tasks_after_restart() { second_state.shutdown(); second_pool.close().await; } + +async fn run_command_task(app: &TestApp, id: &str, args: Vec<&str>) -> i64 { + let working_dir = app._temp_dir.path().to_string_lossy().into_owned(); + let task = script_task(id, &working_dir, "cmd.exe", "/C", args, "0 0 2 * * *", 10); + let create_response = send_json(&app.router, Method::POST, "/api/v1/tasks", Some(task)).await; + assert_eq!(create_response.status(), StatusCode::CREATED); + let run_response = send_json( + &app.router, + Method::POST, + &format!("/api/v1/tasks/{id}/run"), + None, + ) + .await; + assert_eq!(run_response.status(), StatusCode::ACCEPTED); + response_json(run_response).await["id"] + .as_i64() + .expect("run id should be an integer") +} + +async fn read_log_chunk(app: &TestApp, run_id: i64, offset: u64) -> Value { + let response = send_json( + &app.router, + Method::GET, + &format!("/api/v1/runs/{run_id}/logs?offset={offset}"), + None, + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + + response_json(response).await +} + +#[tokio::test] +async fn gbk_encoded_output_is_decoded_to_utf8() { + let app = test_app().await; + // 固定的 GBK 字节只在中日韩等使用该代码页的系统上才有意义。 + let encoding = task_pilot_backend::runs::executor::OutputEncoding::from_env(); + if encoding.name() != "GBK" { + return; + } + + let payload = app._temp_dir.path().join("gbk.txt"); + // "中文" 的 GBK 编码 + std::fs::write(&payload, [0xD6, 0xD0, 0xCE, 0xC4]).expect("GBK fixture should be written"); + let run_id = run_command_task(&app, "gbk_task", vec!["type", "gbk.txt"]).await; + + let completed = wait_for_run(&app.router, run_id).await; + assert_eq!(completed["status"], "succeeded"); + let log = read_log_chunk(&app, run_id, 0).await; + let content = log["content"].as_str().expect("log content should be text"); + + assert!( + content.contains("中文"), + "unexpected log content: {content}" + ); + assert!(!content.contains('\u{fffd}')); + app.state.shutdown(); +} + +#[tokio::test] +async fn long_utf8_log_is_reassembled_across_chunk_boundaries() { + let app = test_app().await; + let payload = app._temp_dir.path().join("long.txt"); + let body = format!("{}\n尾巴\n", "中".repeat(30_000)); + std::fs::write(&payload, body.as_bytes()).expect("UTF-8 fixture should be written"); + let run_id = run_command_task(&app, "long_log_task", vec!["type", "long.txt"]).await; + + let completed = wait_for_run(&app.router, run_id).await; + assert_eq!(completed["status"], "succeeded"); + + let first = read_log_chunk(&app, run_id, 0).await; + assert_eq!( + first["complete"], false, + "a terminal run must not report completion before the whole log is read" + ); + let mut content = first["content"].as_str().unwrap_or_default().to_owned(); + let mut offset = first["next_offset"] + .as_u64() + .expect("next_offset should be an integer"); + + for _ in 0..20 { + let chunk = read_log_chunk(&app, run_id, offset).await; + content.push_str(chunk["content"].as_str().unwrap_or_default()); + offset = chunk["next_offset"] + .as_u64() + .expect("next_offset should be an integer"); + if chunk["complete"].as_bool().unwrap_or(false) { + break; + } + } + + assert!( + !content.contains('\u{fffd}'), + "log lost a character boundary" + ); + assert!(content.contains("尾巴")); + assert_eq!( + content.len(), + usize::try_from(offset).expect("offset fits usize") + ); + app.state.shutdown(); +} diff --git a/contracts/openapi.json b/contracts/openapi.json index 5be4157..75d3b57 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -89,6 +89,46 @@ } } }, + "/api/v1/python/venv": { + "post": { + "tags": [ + "system" + ], + "operationId": "probe_python_venv", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VenvProbeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Detected Python virtual environment", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VenvProbeResponse" + } + } + } + }, + "400": { + "description": "Invalid venv probe request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/v1/runs/{run_id}": { "get": { "tags": [ @@ -892,6 +932,14 @@ "POST" ] }, + "InterpreterKind": { + "type": "string", + "enum": [ + "python", + "node", + "other" + ] + }, "LogChunkResponse": { "type": "object", "required": [ @@ -1323,6 +1371,94 @@ } }, "additionalProperties": false + }, + "VenvProbeRequest": { + "type": "object", + "description": "编辑器在用户填写工作目录时发起的探测请求。", + "required": [ + "working_dir", + "interpreter" + ], + "properties": { + "interpreter": { + "type": "string", + "description": "解释器或可执行程序,对应 `action.interpreter`" + }, + "venv": { + "type": [ + "string", + "null" + ], + "description": "手动填写的虚拟环境目录,对应 `action.venv`" + }, + "working_dir": { + "type": "string", + "description": "任务工作目录,对应 `action.working_dir`" + } + }, + "additionalProperties": false + }, + "VenvProbeResponse": { + "type": "object", + "required": [ + "status", + "interpreter_kind", + "message" + ], + "properties": { + "display_path": { + "type": [ + "string", + "null" + ], + "description": "供界面展示的解释器路径;位于工作目录内时使用相对路径" + }, + "interpreter_kind": { + "$ref": "#/components/schemas/InterpreterKind" + }, + "message": { + "type": "string" + }, + "source": { + "oneOf": [ + { + "$ref": "#/components/schemas/VenvSource" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/components/schemas/VenvProbeStatus" + }, + "venv_path": { + "type": [ + "string", + "null" + ], + "description": "虚拟环境根目录的绝对路径" + } + } + }, + "VenvProbeStatus": { + "type": "string", + "enum": [ + "linked", + "invalid", + "missing", + "not_applicable", + "incomplete" + ] + }, + "VenvSource": { + "type": "string", + "description": "虚拟环境的来源,用于在界面上说明\"为什么\"关联到了这个环境。", + "enum": [ + "configured", + "interpreter", + "working_directory" + ] } } } diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8f50a9c..710e8ee 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -27,9 +27,12 @@ $env:TASK_PILOT_DATABASE_URL = "sqlite://data/task_pilot.db" $env:TASK_PILOT_LOG_DIR = "logs" $env:TASK_PILOT_LOG_RETENTION_DAYS = "30" $env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512" +$env:TASK_PILOT_LOG_ENCODING = "auto" $env:TASK_PILOT_STATIC_DIR = "web" ``` +`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。 + ## 安全边界 - 服务只监听 `127.0.0.1`。 diff --git a/docs/decisions/0003-python-environment.md b/docs/decisions/0003-python-environment.md index 32ccb29..60c3785 100644 --- a/docs/decisions/0003-python-environment.md +++ b/docs/decisions/0003-python-environment.md @@ -31,6 +31,15 @@ - 仅按目录名判断不可靠,必须验证 venv 结构。 - Windows 环境变量大小写不敏感,必须主动处理 `PATH`/`Path` 重复。 +## 编辑器内的环境探测 + +任务编辑器需要在用户填写工作目录时就显示"会关联到哪个虚拟环境",否则用户只能靠事后查看日志猜测实际使用的解释器。 + +1. 新增 `POST /api/v1/python/venv` 探测接口,请求只包含 `working_dir`、`interpreter` 和可选的 `venv`。 +2. 探测与执行共用同一个 `resolve_venv`,解析顺序完全一致:显式配置 → 解释器路径推断 → 从工作目录向上搜索。 +3. 响应区分 `linked`、`missing`、`invalid`、`not_applicable`、`incomplete` 五种状态,并携带相对工作目录的展示路径,例如 `.venv\Scripts\python.exe`。 +4. 探测是只读的文件系统检查,属于写操作语义,因此同样要求管理令牌;工作目录不存在或非 Python 解释器时不会误报为"已关联"。 + ## 边界 - Conda 环境不会被识别为标准 venv;当前可通过显式解释器路径运行,但不会自动设置 `CONDA_PREFIX`。 diff --git a/docs/decisions/0004-log-encoding.md b/docs/decisions/0004-log-encoding.md new file mode 100644 index 0000000..b423df7 --- /dev/null +++ b/docs/decisions/0004-log-encoding.md @@ -0,0 +1,32 @@ +# ADR-0004:运行日志的输出编码 + +- 状态:已接受 +- 日期:2026-10-24 + +## 背景 + +任务日志原先按 UTF-8 无损方式(`String::from_utf8_lossy`)解码子进程输出。中文 Windows 上多数程序并不输出 UTF-8:Python 默认使用系统 ANSI 代码页(简体中文为 GBK/936),`cmd.exe`、部分 CLI 使用控制台代码页。结果日志里出现成片的 `����`,例如 `release/TaskPilot/logs/copyparty/1.log` 中真实的 `copyparty ������ (PID: 22056)`。 + +同一处还有两个次要问题:读取日志按 64 KiB 切块时可能切开多字节字符;终态任务只要日志超过 64 KiB,前端拿到首块就停止轮询,导致尾部丢失。 + +## 决策 + +1. 在写入侧解码:子进程每读完一行就解码为 UTF-8 再落盘,日志文件因此始终是合法 UTF-8,字节 offset 语义不变。 +2. 默认 `auto`:先按 UTF-8 严格解码,失败时回退到系统 ANSI 代码页(`GetACP`),并按代码页映射到 `encoding_rs` 编码(936→GBK、950→Big5、932→Shift_JIS、949→EUC-KR、125x→windows-125x)。 +3. 允许用 `TASK_PILOT_LOG_ENCODING` 指定编码标签(如 `gbk`、`utf-8`、`windows-1251`);显式指定时不再嗅探,行为完全确定。 +4. HTTP 任务响应体沿用同一解码器。 +5. 读取侧只做 UTF-8 边界对齐:块尾落在多字节字符中间时,把不完整字节留给下一次读取;只有真正非法的字节才替换为 `U+FFFD`。 +6. `complete` 改为"运行已结束且已读到文件末尾",修复长日志被截断的问题。 + +## 原因 + +- 解码放在写入侧,只需处理一次,SSE、增量读取、下载、`grep` 得到的内容完全一致。 +- 先 UTF-8 再回退,是因为 UTF-8 合法序列有强约束,误判概率低;反过来(先 GBK)会把大量 UTF-8 日志解坏。 +- 使用系统代码页而不是硬编码 GBK,保证非中文 Windows 上的行为同样正确。 +- 逐行解码避免多字节字符被缓冲区边界切开,且不受行尾差异影响。 + +## 边界 + +- 逐行启发式无法覆盖"一行内混用多种编码",也无法识别恰好构成合法 UTF-8 的 ANSI 字节序列;这两种情况需要显式设置 `TASK_PILOT_LOG_ENCODING`。 +- 修复前写入的历史日志已经是替换字符,不会恢复。 +- 非 Windows 平台回退编码为 UTF-8,不做代码页探测。 diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts index 3814248..09356f3 100644 --- a/frontend/e2e/dashboard.spec.ts +++ b/frontend/e2e/dashboard.spec.ts @@ -1,5 +1,20 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { expect, test, type APIRequestContext } from '@playwright/test' +const venvProject = fileURLToPath( + new URL('../../.e2e-data/venv-project', import.meta.url), +) + +function createVenvFixture(): string { + const scripts = join(venvProject, '.venv', 'Scripts') + mkdirSync(scripts, { recursive: true }) + writeFileSync(join(venvProject, '.venv', 'pyvenv.cfg'), 'home = C:\\Python\n') + writeFileSync(join(scripts, 'python.exe'), '') + return venvProject +} + const taskPayload = { id: 'e2e_echo', name: 'E2E echo', @@ -56,6 +71,18 @@ test('manages a task and reads a real run log', async ({ page, request }) => { path: 'test-results/dashboard-task-editor.png', fullPage: true, }) + + const workingDir = createVenvFixture() + await newTaskDialog.getByLabel(/项目工作目录/).fill(workingDir) + await expect( + newTaskDialog.locator('.env-status-badge.ok code'), + ).toHaveText('.venv\\Scripts\\python.exe') + await expect(newTaskDialog.getByText(/已关联虚拟环境/)).toBeVisible() + await page.screenshot({ + path: 'test-results/dashboard-venv-detected.png', + fullPage: true, + }) + const addArgumentButton = newTaskDialog.getByRole('button', { name: '+ 添加参数', }) diff --git a/frontend/src/App.css b/frontend/src/App.css index 97944d8..45f2813 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -767,6 +767,59 @@ code { color: var(--danger); } +.env-detector-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 6px 12px; + margin-top: -1px; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + font-size: 12px; +} + +.env-status-badge { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; + color: var(--text-secondary); + font-weight: 500; +} + +.env-status-badge.ok { + color: var(--success); +} + +.env-status-badge.warn { + color: var(--warning); +} + +.env-status-badge.pending { + color: var(--text-tertiary); +} + +.env-status-badge code { + overflow: hidden; + padding: 1px 6px; + border-radius: 4px; + background: var(--badge-bg); + color: var(--text-main); + font-family: var(--font-mono); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.env-detector-hint { + flex-shrink: 0; + color: var(--text-tertiary); + font-size: 11px; +} + .field-input[aria-invalid='true'] { border-color: #f08aa0; background: #fffafb; diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 62b301f..1797b24 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -73,6 +73,22 @@ function createFetchMock() { ], }) } + if (url.endsWith('/api/v1/python/venv')) { + const probe = JSON.parse(String(init?.body)) as { + working_dir: string + } + const linked = probe.working_dir.toLowerCase().includes('sync') + return jsonResponse({ + status: linked ? 'linked' : 'missing', + interpreter_kind: 'python', + venv_path: linked ? 'D:\\Projects\\sync\\.venv' : null, + display_path: linked ? '.venv\\Scripts\\python.exe' : 'python.exe', + source: linked ? 'working_directory' : null, + message: linked + ? '已自动关联工作目录中的虚拟环境' + : '未检测到虚拟环境,将直接使用系统解释器', + }) + } if (url.includes('/api/v1/tasks') && init?.method === 'POST') { return jsonResponse( { @@ -136,7 +152,7 @@ describe('TaskDashboard', () => { await user.clear(screen.getByLabelText(/任务标识/)) await user.type(screen.getByLabelText(/任务标识/), 'new_task') await user.type(screen.getByLabelText('名称'), 'New task') - await user.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new') + await user.type(screen.getByLabelText(/项目工作目录/), 'D:\\Projects\\new') await user.click( screen.getByRole('button', { name: '保存并热刷新' }), @@ -160,4 +176,40 @@ describe('TaskDashboard', () => { expect(payload.action.type).toBe('script') expect(payload.action.working_dir).toBe('D:\\Projects\\new') }) + + it('shows the virtual environment detected for the working directory', async () => { + vi.stubGlobal('fetch', createFetchMock()) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.type(screen.getByLabelText(/项目工作目录/), 'D:\\Projects\\sync') + + expect( + await screen.findByText(/已关联虚拟环境/, {}, { timeout: 3000 }), + ).toBeInTheDocument() + expect(screen.getByText('.venv\\Scripts\\python.exe')).toBeInTheDocument() + expect( + screen.getByText('Rust 自动前置 PATH 及 VIRTUAL_ENV'), + ).toBeInTheDocument() + }) + + it('falls back to the system interpreter when no venv is detected', async () => { + vi.stubGlobal('fetch', createFetchMock()) + const user = userEvent.setup() + renderApp() + + await user.click(screen.getByRole('button', { name: '+ 新建任务' })) + await user.type( + screen.getByLabelText(/项目工作目录/), + 'D:\\Projects\\plain', + ) + + expect( + await screen.findByText(/使用系统解释器:/, {}, { timeout: 3000 }), + ).toBeInTheDocument() + expect( + screen.getByText('未检测到虚拟环境,将直接使用系统解释器'), + ).toBeInTheDocument() + }) }) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7d8a461..aec58fd 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -16,6 +16,11 @@ export type ErrorResponse = components['schemas']['ErrorResponse'] export type SessionResponse = components['schemas']['SessionResponse'] export type CronPreviewRequest = components['schemas']['CronPreviewRequest'] export type CronPreviewResponse = components['schemas']['CronPreviewResponse'] +export type VenvProbeRequest = components['schemas']['VenvProbeRequest'] +export type VenvProbeResponse = components['schemas']['VenvProbeResponse'] +export type VenvProbeStatus = components['schemas']['VenvProbeStatus'] +export type VenvSource = components['schemas']['VenvSource'] +export type InterpreterKind = components['schemas']['InterpreterKind'] export interface TaskFilters { q?: string @@ -232,3 +237,14 @@ export function previewCron( signal, }) } + +export function probePythonVenv( + request: VenvProbeRequest, + signal?: AbortSignal, +): Promise { + return requestJson('/api/v1/python/venv', { + method: 'POST', + body: JSON.stringify(request), + signal, + }) +} diff --git a/frontend/src/api/generated.ts b/frontend/src/api/generated.ts index 8581b0c..8b36fec 100644 --- a/frontend/src/api/generated.ts +++ b/frontend/src/api/generated.ts @@ -52,6 +52,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/python/venv": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["probe_python_venv"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/runs/{run_id}": { parameters: { query?: never; @@ -273,6 +289,8 @@ export interface components { }; /** @enum {string} */ HttpMethod: "GET" | "POST"; + /** @enum {string} */ + InterpreterKind: "python" | "node" | "other"; LogChunkResponse: { complete: boolean; content: string; @@ -389,6 +407,32 @@ export interface components { timeout_secs?: number; timezone?: string; }; + /** @description 编辑器在用户填写工作目录时发起的探测请求。 */ + VenvProbeRequest: { + /** @description 解释器或可执行程序,对应 `action.interpreter` */ + interpreter: string; + /** @description 手动填写的虚拟环境目录,对应 `action.venv` */ + venv?: string | null; + /** @description 任务工作目录,对应 `action.working_dir` */ + working_dir: string; + }; + VenvProbeResponse: { + /** @description 供界面展示的解释器路径;位于工作目录内时使用相对路径 */ + display_path?: string | null; + interpreter_kind: components["schemas"]["InterpreterKind"]; + message: string; + source?: components["schemas"]["VenvSource"] | null; + status: components["schemas"]["VenvProbeStatus"]; + /** @description 虚拟环境根目录的绝对路径 */ + venv_path?: string | null; + }; + /** @enum {string} */ + VenvProbeStatus: "linked" | "invalid" | "missing" | "not_applicable" | "incomplete"; + /** + * @description 虚拟环境的来源,用于在界面上说明"为什么"关联到了这个环境。 + * @enum {string} + */ + VenvSource: "configured" | "interpreter" | "working_directory"; }; responses: never; parameters: never; @@ -471,6 +515,39 @@ export interface operations { }; }; }; + probe_python_venv: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VenvProbeRequest"]; + }; + }; + responses: { + /** @description Detected Python virtual environment */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VenvProbeResponse"]; + }; + }; + /** @description Invalid venv probe request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; get_run: { parameters: { query?: never; diff --git a/frontend/src/components/TaskEditorModal.tsx b/frontend/src/components/TaskEditorModal.tsx index f29662e..a44ed73 100644 --- a/frontend/src/components/TaskEditorModal.tsx +++ b/frontend/src/components/TaskEditorModal.tsx @@ -7,7 +7,13 @@ import { useFieldArray, useForm, } from 'react-hook-form' -import { ApiRequestError, previewCron, type Task } from '../api/client' +import { + ApiRequestError, + previewCron, + probePythonVenv, + type Task, + type VenvProbeResponse, +} from '../api/client' import { createEmptyTaskFormValues, mapServerFieldToFormField, @@ -57,6 +63,15 @@ export function TaskEditorModal({ const scriptPreview = watch('script') const debouncedCron = useDebouncedValue(cronValue, 300) const debouncedTimezone = useDebouncedValue(timezoneValue, 300) + const debouncedWorkingDir = useDebouncedValue( + scriptPreview.working_dir ?? '', + 350, + ) + const debouncedInterpreter = useDebouncedValue( + scriptPreview.interpreter ?? '', + 350, + ) + const debouncedVenv = useDebouncedValue(scriptPreview.venv ?? '', 350) const cronPreview = useQuery({ queryKey: [ 'cron-preview', @@ -79,6 +94,31 @@ export function TaskEditorModal({ Boolean(debouncedTimezone.trim()), retry: false, }) + const venvProbe = useQuery({ + queryKey: [ + 'python-venv', + debouncedWorkingDir, + debouncedInterpreter, + debouncedVenv, + ], + queryFn: ({ signal }) => + probePythonVenv( + { + working_dir: debouncedWorkingDir, + interpreter: debouncedInterpreter, + venv: debouncedVenv.trim() || null, + }, + signal, + ), + enabled: + open && actionType === 'script' && Boolean(debouncedWorkingDir.trim()), + retry: false, + staleTime: 15_000, + }) + const linkedVenv = venvProbe.data?.status === 'linked' ? venvProbe.data : null + const previewVirtualEnv = + scriptPreview.venv.trim() || linkedVenv?.venv_path || '' + const previewInterpreter = linkedVenv?.display_path ?? null const scriptArgs = useFieldArray({ control, name: 'script.args' }) const scriptEnv = useFieldArray({ control, name: 'script.env' }) const httpHeaders = useFieldArray({ control, name: 'http.headers' }) @@ -306,17 +346,23 @@ export function TaskEditorModal({ {actionType === 'script' ? (
+ + + +
- - - + + +
- - - Windows 原生环境
- {scriptPreview.venv.trim() ? ( + {previewVirtualEnv ? (
- set VIRTUAL_ENV={scriptPreview.venv.trim()} + set VIRTUAL_ENV={previewVirtualEnv}
) : null} {scriptPreview.env @@ -431,7 +477,7 @@ export function TaskEditorModal({
))}
- {formatInterpreterPreview(scriptPreview)}{' '} + {formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '} {scriptPreview.script.trim() || 'main.py'} {formatArgumentPreview(scriptPreview.args)}
@@ -615,11 +661,95 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string { function formatInterpreterPreview( script: TaskFormValues['script'], + linkedPythonPath?: string | null, ): string { if (script.venv.trim()) { return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe` } - return script.interpreter.trim() || 'python.exe' + return linkedPythonPath ?? (script.interpreter.trim() || 'python.exe') +} + +interface VenvDetectorBarProps { + workingDir: string + probe: VenvProbeResponse | undefined + pending: boolean + failed: boolean +} + +function VenvDetectorBar({ + workingDir, + probe, + pending, + failed, +}: VenvDetectorBarProps) { + if (!workingDir.trim()) { + return ( +
+ + ● 请输入有效的工作目录以探测环境 + +
+ ) + } + + if (pending) { + return ( +
+ ⏳ 正在探测解释器... +
+ ) + } + + if (failed || !probe) { + return ( +
+ ● 环境探测失败,可稍后重试 +
+ ) + } + + if (probe.status === 'linked') { + return ( +
+ + ● 已关联虚拟环境: {probe.display_path} + + + Rust 自动前置 PATH 及 VIRTUAL_ENV + +
+ ) + } + + if (probe.status === 'not_applicable') { + const label = + probe.interpreter_kind === 'node' ? '识别为 Node.js' : '使用指定解释器' + return ( +
+ + ● {label}: {probe.display_path} + + {probe.message} +
+ ) + } + + if (probe.status === 'missing') { + return ( +
+ + ● 使用系统解释器: {probe.display_path} + + {probe.message} +
+ ) + } + + return ( +
+ ● {probe.message} +
+ ) } function isSensitiveName(name: string): boolean { diff --git a/frontend/src/index.css b/frontend/src/index.css index 276114c..0b6b5dd 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -25,6 +25,8 @@ --success-bg: #eefbf5; --danger: #f43f5e; --danger-bg: #fff1f2; + --warning: #d97706; + --warning-bg: #fffbeb; --shadow: var(--shadow-card); --font-sans: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,