feat: 编辑器实时探测并显示已关联虚拟环境

fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
2026-10-24 22:30:10 +08:00
parent 23565b40a4
commit dd7612bc74
20 changed files with 1508 additions and 48 deletions
+7
View File
@@ -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)
## 当前限制
+27
View File
@@ -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",
+2 -1
View File
@@ -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"
+32
View File
@@ -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<VenvProbeRequest>,
) -> ApiResult<Json<VenvProbeResponse>> {
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())
+374 -16
View File
@@ -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<PathBuf>,
pub source: Option<VenvSource>,
}
/// 解析任务实际会使用的虚拟环境,顺序与执行期保持一致:
/// 手动指定 → 解释器路径推断 → 工作目录向上探测。
pub fn resolve_venv(
working_dir: &str,
interpreter: &str,
configured_venv: Option<&str>,
) -> io::Result<ResolvedVenv> {
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<String>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct VenvProbeResponse {
pub status: VenvProbeStatus,
pub interpreter_kind: InterpreterKind,
/// 虚拟环境根目录的绝对路径
pub venv_path: Option<String>,
/// 供界面展示的解释器路径;位于工作目录内时使用相对路径
pub display_path: Option<String>,
pub source: Option<VenvSource>,
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<VenvSource>) -> 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<String> {
let base_components = base.components().collect::<Vec<_>>();
let target_components = target.components().collect::<Vec<_>>();
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::<PathBuf>();
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<PathBuf> {
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);
}
}
+135 -2
View File
@@ -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<ExecutionEvent>,
secrets: Arc<Vec<String>>,
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<ExecutionEvent>,
secrets: Vec<String>,
encoding: OutputEncoding,
) -> std::io::Result<Self> {
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();
+85 -3
View File
@@ -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}'));
}
}
+181
View File
@@ -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("工作目录")
);
}
+102
View File
@@ -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();
}
+136
View File
@@ -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"
]
}
}
}
+3
View File
@@ -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`。
@@ -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`。
+32
View File
@@ -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,不做代码页探测。
+27
View File
@@ -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: '+ 添加参数',
})
+53
View File
@@ -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;
+53 -1
View File
@@ -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()
})
})
+16
View File
@@ -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<VenvProbeResponse> {
return requestJson<VenvProbeResponse>('/api/v1/python/venv', {
method: 'POST',
body: JSON.stringify(request),
signal,
})
}
+77
View File
@@ -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;
+155 -25
View File
@@ -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' ? (
<div className="dynamic-fields">
<Field
label="项目工作目录 (Working Directory)"
error={errors.script?.working_dir?.message}
>
<input
{...register('script.working_dir')}
className="field-input mono"
placeholder="D:\Projects\sync"
/>
</Field>
<VenvDetectorBar
workingDir={debouncedWorkingDir}
probe={venvProbe.data}
pending={venvProbe.isPending}
failed={venvProbe.isError}
/>
<div className="form-grid">
<Field
label="工作目录"
error={errors.script?.working_dir?.message}
>
<input
{...register('script.working_dir')}
className="field-input mono"
placeholder="D:\Projects\sync"
/>
</Field>
<Field
label="解释器"
error={errors.script?.interpreter?.message}
@@ -327,17 +373,17 @@ export function TaskEditorModal({
placeholder="python.exe"
/>
</Field>
<Field
label="虚拟环境目录 (可选)"
hint="留空时自动探测;填写后优先使用该目录"
>
<input
{...register('script.venv')}
className="field-input mono"
placeholder="例如:.venv"
/>
</Field>
</div>
<Field
label="虚拟环境目录 (可选)"
hint="留空时自动从工作目录及上级目录探测 .venv、venv、env"
>
<input
{...register('script.venv')}
className="field-input mono"
placeholder="例如:.venv"
/>
</Field>
<Field
label="脚本文件或相对路径"
error={errors.script?.script?.message}
@@ -412,9 +458,9 @@ export function TaskEditorModal({
<span>Windows 原生环境</span>
</div>
<div className="cli-preview-body">
{scriptPreview.venv.trim() ? (
{previewVirtualEnv ? (
<div className="cli-line-env">
set VIRTUAL_ENV={scriptPreview.venv.trim()}
set VIRTUAL_ENV={previewVirtualEnv}
</div>
) : null}
{scriptPreview.env
@@ -431,7 +477,7 @@ export function TaskEditorModal({
</div>
))}
<div className="cli-line-cmd">
{formatInterpreterPreview(scriptPreview)}{' '}
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
{scriptPreview.script.trim() || 'main.py'}
{formatArgumentPreview(scriptPreview.args)}
</div>
@@ -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 (
<div className="env-detector-bar" role="status">
<span className="env-status-badge warn">
● 请输入有效的工作目录以探测环境
</span>
</div>
)
}
if (pending) {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge pending">⏳ 正在探测解释器...</span>
</div>
)
}
if (failed || !probe) {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge warn">● 环境探测失败,可稍后重试</span>
</div>
)
}
if (probe.status === 'linked') {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge ok">
● 已关联虚拟环境: <code>{probe.display_path}</code>
</span>
<span className="env-detector-hint">
Rust 自动前置 PATH 及 VIRTUAL_ENV
</span>
</div>
)
}
if (probe.status === 'not_applicable') {
const label =
probe.interpreter_kind === 'node' ? '识别为 Node.js' : '使用指定解释器'
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge ok">
● {label}: <code>{probe.display_path}</code>
</span>
<span className="env-detector-hint">{probe.message}</span>
</div>
)
}
if (probe.status === 'missing') {
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge ok">
● 使用系统解释器: <code>{probe.display_path}</code>
</span>
<span className="env-detector-hint">{probe.message}</span>
</div>
)
}
return (
<div className="env-detector-bar" role="status">
<span className="env-status-badge warn">● {probe.message}</span>
</div>
)
}
function isSensitiveName(name: string): boolean {
+2
View File
@@ -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,