feat: 编辑器实时探测并显示已关联虚拟环境
fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
@@ -31,7 +31,9 @@ React 页面沿用该原型作为视觉基线:1040px 主容器、紧凑指标
|
|||||||
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
|
- 运行历史支持按状态和触发方式筛选,并支持下载完整日志。
|
||||||
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
- 敏感环境变量和敏感 Header 的值不会写入系统日志,输出中的匹配值会替换为 `[REDACTED]`。
|
||||||
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
- Python 脚本支持显式虚拟环境配置,并会自动向上探测 `.venv`、`venv`、`env`。
|
||||||
|
- 编辑器会随工作目录实时探测虚拟环境,显示 `已关联虚拟环境: .venv\Scripts\python.exe` 并同步更新调用预览。
|
||||||
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
- 执行前会校验 `pyvenv.cfg` 和 `Scripts/python.exe`,优先使用虚拟环境解释器,并注入 PATH、`VIRTUAL_ENV` 和无缓冲输出设置。
|
||||||
|
- 日志按系统 ANSI 代码页(如 GBK)解码非 UTF-8 输出,中文程序不再显示为 `����`,且长日志跨 64 KiB 分块读取不会丢失字符。
|
||||||
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
- 日志支持保留期限和总容量清理,单次日志仍有 5 MiB 上限。
|
||||||
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
|
- React 页面已接入真实任务列表、搜索筛选、分页、CRUD、启停、手动运行、运行历史和日志轮询。
|
||||||
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
|
- 三类 Action 使用不同动态表单,并以类型化载荷提交,不再固定为脚本任务。
|
||||||
@@ -154,8 +156,11 @@ cargo run --manifest-path backend/Cargo.toml
|
|||||||
$env:TASK_PILOT_LOG_DIR = "logs"
|
$env:TASK_PILOT_LOG_DIR = "logs"
|
||||||
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
||||||
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
$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 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
|
单次运行日志达到 5 MiB 后会停止记录后续输出。程序启动和每次运行结束时会清理过期日志,并在总容量超限时优先删除最旧日志。
|
||||||
|
|
||||||
## API
|
## API
|
||||||
@@ -165,6 +170,7 @@ $env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
|||||||
| `GET` | `/api/v1/health` | 后端健康检查 |
|
| `GET` | `/api/v1/health` | 后端健康检查 |
|
||||||
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
| `GET` | `/api/v1/session` | 获取当前进程的本地管理令牌 |
|
||||||
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
| `GET` | `/api/v1/openapi.json` | 运行时 OpenAPI 文档 |
|
||||||
|
| `POST` | `/api/v1/python/venv` | 探测工作目录会关联的 Python 虚拟环境 |
|
||||||
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
| `GET` | `/api/v1/tasks` | 查询任务列表 |
|
||||||
| `POST` | `/api/v1/tasks` | 创建任务 |
|
| `POST` | `/api/v1/tasks` | 创建任务 |
|
||||||
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
| `GET` | `/api/v1/tasks/{id}` | 查询任务详情 |
|
||||||
@@ -348,6 +354,7 @@ task_pilot/
|
|||||||
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
- [ADR-0001:Cron 解析与任务调度](docs/decisions/0001-cron-engine.md)
|
||||||
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
- [ADR-0002:前后端开发与生产托管](docs/decisions/0002-static-hosting.md)
|
||||||
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
- [ADR-0003:Python 虚拟环境启动环境](docs/decisions/0003-python-environment.md)
|
||||||
|
- [ADR-0004:运行日志的输出编码](docs/decisions/0004-log-encoding.md)
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
|
|||||||
Generated
+27
@@ -279,6 +279,12 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core_detect"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -485,6 +491,20 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
@@ -1208,6 +1228,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "nu-ansi-term"
|
name = "nu-ansi-term"
|
||||||
version = "0.50.3"
|
version = "0.50.3"
|
||||||
@@ -2140,6 +2166,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"croner",
|
"croner",
|
||||||
|
"encoding_rs",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
|||||||
+2
-1
@@ -9,6 +9,7 @@ axum = "0.8.9"
|
|||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
chrono-tz = "0.10.4"
|
chrono-tz = "0.10.4"
|
||||||
croner = "4.0.1"
|
croner = "4.0.1"
|
||||||
|
encoding_rs = "0.8.35"
|
||||||
futures-util = { version = "0.3.34", features = ["std"] }
|
futures-util = { version = "0.3.34", features = ["std"] }
|
||||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
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 = { version = "6.0.0", features = ["axum_extras", "chrono"] }
|
||||||
utoipa-axum = "0.3.0"
|
utoipa-axum = "0.3.0"
|
||||||
uuid = { version = "1.28.0", features = ["v4"] }
|
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]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1.5"
|
http-body-util = "0.1.5"
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ use utoipa::{OpenApi, ToSchema};
|
|||||||
use crate::{
|
use crate::{
|
||||||
cron::{self, CronPreviewRequest, CronPreviewResponse},
|
cron::{self, CronPreviewRequest, CronPreviewResponse},
|
||||||
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
|
error::{ApiError, ApiJson, ApiResult, ErrorResponse},
|
||||||
|
python_env::{
|
||||||
|
self, InterpreterKind, VenvProbeRequest, VenvProbeResponse, VenvProbeStatus, VenvSource,
|
||||||
|
},
|
||||||
runs::{
|
runs::{
|
||||||
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
model::{LogChunkResponse, TaskRunListResponse, TaskRunResponse},
|
||||||
routes::{
|
routes::{
|
||||||
@@ -107,6 +110,25 @@ pub async fn preview_cron(
|
|||||||
Ok(Json(preview))
|
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)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
info(
|
info(
|
||||||
@@ -118,6 +140,7 @@ pub async fn preview_cron(
|
|||||||
health,
|
health,
|
||||||
session,
|
session,
|
||||||
preview_cron,
|
preview_cron,
|
||||||
|
probe_python_venv,
|
||||||
list_tasks,
|
list_tasks,
|
||||||
create_task,
|
create_task,
|
||||||
get_task,
|
get_task,
|
||||||
@@ -138,6 +161,11 @@ pub async fn preview_cron(
|
|||||||
SessionResponse,
|
SessionResponse,
|
||||||
CronPreviewRequest,
|
CronPreviewRequest,
|
||||||
CronPreviewResponse,
|
CronPreviewResponse,
|
||||||
|
VenvProbeRequest,
|
||||||
|
VenvProbeResponse,
|
||||||
|
VenvProbeStatus,
|
||||||
|
VenvSource,
|
||||||
|
InterpreterKind,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
TaskActionType,
|
TaskActionType,
|
||||||
HttpMethod,
|
HttpMethod,
|
||||||
@@ -183,6 +211,10 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/v1/health", get(health))
|
.route("/api/v1/health", get(health))
|
||||||
.route("/api/v1/session", get(session))
|
.route("/api/v1/session", get(session))
|
||||||
.route("/api/v1/cron/preview", axum::routing::post(preview_cron))
|
.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))
|
.route("/api/v1/openapi.json", get(openapi_json))
|
||||||
.merge(crate::tasks::routes::router())
|
.merge(crate::tasks::routes::router())
|
||||||
.merge(crate::runs::routes::router())
|
.merge(crate::runs::routes::router())
|
||||||
|
|||||||
+374
-16
@@ -6,7 +6,9 @@ use std::{
|
|||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
const VENV_DIRECTORY_NAMES: [&str; 3] = [".venv", "venv", "env"];
|
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 interpreter_path = resolve_interpreter(working_dir, interpreter);
|
||||||
let explicit_venv = configured_venv
|
let detected_venv = resolve_venv(working_dir, interpreter, configured_venv)?.venv;
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.map(|value| resolve_against(&working_directory, value))
|
|
||||||
.transpose()?;
|
|
||||||
let detected_venv = explicit_venv
|
|
||||||
.as_deref()
|
|
||||||
.map(validate_venv)
|
|
||||||
.transpose()?
|
|
||||||
.flatten()
|
|
||||||
.or_else(|| detect_venv_from_interpreter(&interpreter_path))
|
|
||||||
.or_else(|| {
|
|
||||||
is_generic_python(interpreter)
|
|
||||||
.then(|| detect_venv_from_ancestors(&working_directory))
|
|
||||||
.flatten()
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut environment = BTreeMap::new();
|
let mut environment = BTreeMap::new();
|
||||||
for (key, value) in user_env {
|
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> {
|
pub fn validate_configured_venv(working_dir: &str, configured_venv: &str) -> io::Result<PathBuf> {
|
||||||
let working_directory = absolute_path(Path::new(working_dir))?;
|
let working_directory = absolute_path(Path::new(working_dir))?;
|
||||||
let venv = resolve_against(&working_directory, configured_venv)?;
|
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"));
|
||||||
assert!(prepared.remove_environment.contains(&"VIRTUAL_ENV_PROMPT"));
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use encoding_rs::Encoding;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -39,6 +40,77 @@ const TERMINATION_WAIT_SECS: u64 = 5;
|
|||||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
const DEFAULT_RETENTION_DAYS: u64 = 30;
|
const DEFAULT_RETENTION_DAYS: u64 = 30;
|
||||||
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct LogRetentionConfig {
|
pub struct LogRetentionConfig {
|
||||||
@@ -92,6 +164,7 @@ struct ExecutorInner {
|
|||||||
http: reqwest::Client,
|
http: reqwest::Client,
|
||||||
log_config: LogRetentionConfig,
|
log_config: LogRetentionConfig,
|
||||||
cleanup_lock: AsyncMutex<()>,
|
cleanup_lock: AsyncMutex<()>,
|
||||||
|
output_encoding: OutputEncoding,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RunningHandle {
|
struct RunningHandle {
|
||||||
@@ -107,6 +180,7 @@ struct LogSink {
|
|||||||
run_id: i64,
|
run_id: i64,
|
||||||
events: broadcast::Sender<ExecutionEvent>,
|
events: broadcast::Sender<ExecutionEvent>,
|
||||||
secrets: Arc<Vec<String>>,
|
secrets: Arc<Vec<String>>,
|
||||||
|
encoding: OutputEncoding,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ScriptOutcome {
|
struct ScriptOutcome {
|
||||||
@@ -124,6 +198,8 @@ enum ProcessEnd {
|
|||||||
impl TaskExecutor {
|
impl TaskExecutor {
|
||||||
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
|
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
|
||||||
let (events, _) = broadcast::channel(512);
|
let (events, _) = broadcast::channel(512);
|
||||||
|
let output_encoding = OutputEncoding::from_env();
|
||||||
|
tracing::info!(encoding = output_encoding.name(), "task output decoding");
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(ExecutorInner {
|
inner: Arc::new(ExecutorInner {
|
||||||
pool,
|
pool,
|
||||||
@@ -138,6 +214,7 @@ impl TaskExecutor {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
log_config,
|
log_config,
|
||||||
cleanup_lock: AsyncMutex::new(()),
|
cleanup_lock: AsyncMutex::new(()),
|
||||||
|
output_encoding,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,6 +430,7 @@ impl TaskExecutor {
|
|||||||
run_id,
|
run_id,
|
||||||
self.inner.events.clone(),
|
self.inner.events.clone(),
|
||||||
secrets,
|
secrets,
|
||||||
|
self.inner.output_encoding,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -517,6 +595,7 @@ impl TaskExecutor {
|
|||||||
run_id,
|
run_id,
|
||||||
self.inner.events.clone(),
|
self.inner.events.clone(),
|
||||||
sensitive_values(&task.action),
|
sensitive_values(&task.action),
|
||||||
|
self.inner.output_encoding,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -638,7 +717,7 @@ impl TaskExecutor {
|
|||||||
None => break,
|
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() {
|
if !body_text.is_empty() {
|
||||||
sink.http(&body_text).await;
|
sink.http(&body_text).await;
|
||||||
}
|
}
|
||||||
@@ -697,6 +776,7 @@ impl LogSink {
|
|||||||
run_id: i64,
|
run_id: i64,
|
||||||
events: broadcast::Sender<ExecutionEvent>,
|
events: broadcast::Sender<ExecutionEvent>,
|
||||||
secrets: Vec<String>,
|
secrets: Vec<String>,
|
||||||
|
encoding: OutputEncoding,
|
||||||
) -> std::io::Result<Self> {
|
) -> std::io::Result<Self> {
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
@@ -721,9 +801,14 @@ impl LogSink {
|
|||||||
run_id,
|
run_id,
|
||||||
events,
|
events,
|
||||||
secrets: Arc::new(secrets),
|
secrets: Arc::new(secrets),
|
||||||
|
encoding,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_output(&self, bytes: &[u8]) -> String {
|
||||||
|
self.encoding.decode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
async fn append(&self, text: &str) {
|
async fn append(&self, text: &str) {
|
||||||
let redacted = redact_text(text, &self.secrets);
|
let redacted = redact_text(text, &self.secrets);
|
||||||
let bytes = redacted.as_bytes();
|
let bytes = redacted.as_bytes();
|
||||||
@@ -783,7 +868,7 @@ where
|
|||||||
match reader.read_until(b'\n', &mut line).await {
|
match reader.read_until(b'\n', &mut line).await {
|
||||||
Ok(0) => break,
|
Ok(0) => break,
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
let text = String::from_utf8_lossy(&line);
|
let text = sink.decode_output(&line);
|
||||||
sink.append(&format!("[{stream}] {text}")).await;
|
sink.append(&format!("[{stream}] {text}")).await;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
@@ -957,6 +1042,54 @@ mod tests {
|
|||||||
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
|
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]
|
#[test]
|
||||||
fn cleanup_enforces_total_log_capacity() {
|
fn cleanup_enforces_total_log_capacity() {
|
||||||
let temp = tempfile::TempDir::new().unwrap();
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
@@ -155,13 +155,43 @@ pub async fn get_run_logs(
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::internal)?;
|
.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 {
|
Ok(Json(LogChunkResponse {
|
||||||
content: String::from_utf8_lossy(&bytes).into_owned(),
|
content,
|
||||||
next_offset: offset + bytes.len() as u64,
|
next_offset: offset + consumed as u64,
|
||||||
complete: run.status.is_terminal(),
|
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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/api/v1/runs/{run_id}/cancel",
|
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)))
|
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}'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("工作目录")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -719,3 +719,105 @@ async fn scheduler_restores_enabled_tasks_after_restart() {
|
|||||||
second_state.shutdown();
|
second_state.shutdown();
|
||||||
second_pool.close().await;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}": {
|
"/api/v1/runs/{run_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -892,6 +932,14 @@
|
|||||||
"POST"
|
"POST"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"InterpreterKind": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"python",
|
||||||
|
"node",
|
||||||
|
"other"
|
||||||
|
]
|
||||||
|
},
|
||||||
"LogChunkResponse": {
|
"LogChunkResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
@@ -1323,6 +1371,94 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,9 +27,12 @@ $env:TASK_PILOT_DATABASE_URL = "sqlite://data/task_pilot.db"
|
|||||||
$env:TASK_PILOT_LOG_DIR = "logs"
|
$env:TASK_PILOT_LOG_DIR = "logs"
|
||||||
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
$env:TASK_PILOT_LOG_RETENTION_DAYS = "30"
|
||||||
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
$env:TASK_PILOT_LOG_MAX_TOTAL_MB = "512"
|
||||||
|
$env:TASK_PILOT_LOG_ENCODING = "auto"
|
||||||
$env:TASK_PILOT_STATIC_DIR = "web"
|
$env:TASK_PILOT_STATIC_DIR = "web"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`TASK_PILOT_LOG_ENCODING` 默认 `auto`,会按 UTF-8 → 系统 ANSI 代码页(简体中文为 GBK)的顺序解码子进程输出;日志出现乱码时可显式指定 `gbk` 或 `utf-8`。
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
- 服务只监听 `127.0.0.1`。
|
- 服务只监听 `127.0.0.1`。
|
||||||
|
|||||||
@@ -31,6 +31,15 @@
|
|||||||
- 仅按目录名判断不可靠,必须验证 venv 结构。
|
- 仅按目录名判断不可靠,必须验证 venv 结构。
|
||||||
- Windows 环境变量大小写不敏感,必须主动处理 `PATH`/`Path` 重复。
|
- 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`。
|
- Conda 环境不会被识别为标准 venv;当前可通过显式解释器路径运行,但不会自动设置 `CONDA_PREFIX`。
|
||||||
|
|||||||
@@ -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,不做代码页探测。
|
||||||
@@ -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'
|
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 = {
|
const taskPayload = {
|
||||||
id: 'e2e_echo',
|
id: 'e2e_echo',
|
||||||
name: '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',
|
path: 'test-results/dashboard-task-editor.png',
|
||||||
fullPage: true,
|
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', {
|
const addArgumentButton = newTaskDialog.getByRole('button', {
|
||||||
name: '+ 添加参数',
|
name: '+ 添加参数',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -767,6 +767,59 @@ code {
|
|||||||
color: var(--danger);
|
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'] {
|
.field-input[aria-invalid='true'] {
|
||||||
border-color: #f08aa0;
|
border-color: #f08aa0;
|
||||||
background: #fffafb;
|
background: #fffafb;
|
||||||
|
|||||||
@@ -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') {
|
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
@@ -136,7 +152,7 @@ describe('TaskDashboard', () => {
|
|||||||
await user.clear(screen.getByLabelText(/任务标识/))
|
await user.clear(screen.getByLabelText(/任务标识/))
|
||||||
await user.type(screen.getByLabelText(/任务标识/), 'new_task')
|
await user.type(screen.getByLabelText(/任务标识/), 'new_task')
|
||||||
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(
|
await user.click(
|
||||||
screen.getByRole('button', { name: '保存并热刷新' }),
|
screen.getByRole('button', { name: '保存并热刷新' }),
|
||||||
@@ -160,4 +176,40 @@ describe('TaskDashboard', () => {
|
|||||||
expect(payload.action.type).toBe('script')
|
expect(payload.action.type).toBe('script')
|
||||||
expect(payload.action.working_dir).toBe('D:\\Projects\\new')
|
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,6 +16,11 @@ export type ErrorResponse = components['schemas']['ErrorResponse']
|
|||||||
export type SessionResponse = components['schemas']['SessionResponse']
|
export type SessionResponse = components['schemas']['SessionResponse']
|
||||||
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
|
||||||
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
|
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 {
|
export interface TaskFilters {
|
||||||
q?: string
|
q?: string
|
||||||
@@ -232,3 +237,14 @@ export function previewCron(
|
|||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function probePythonVenv(
|
||||||
|
request: VenvProbeRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<VenvProbeResponse> {
|
||||||
|
return requestJson<VenvProbeResponse>('/api/v1/python/venv', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/api/v1/runs/{run_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -273,6 +289,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
HttpMethod: "GET" | "POST";
|
HttpMethod: "GET" | "POST";
|
||||||
|
/** @enum {string} */
|
||||||
|
InterpreterKind: "python" | "node" | "other";
|
||||||
LogChunkResponse: {
|
LogChunkResponse: {
|
||||||
complete: boolean;
|
complete: boolean;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -389,6 +407,32 @@ export interface components {
|
|||||||
timeout_secs?: number;
|
timeout_secs?: number;
|
||||||
timezone?: string;
|
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;
|
responses: never;
|
||||||
parameters: 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: {
|
get_run: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
useFieldArray,
|
useFieldArray,
|
||||||
useForm,
|
useForm,
|
||||||
} from 'react-hook-form'
|
} from 'react-hook-form'
|
||||||
import { ApiRequestError, previewCron, type Task } from '../api/client'
|
import {
|
||||||
|
ApiRequestError,
|
||||||
|
previewCron,
|
||||||
|
probePythonVenv,
|
||||||
|
type Task,
|
||||||
|
type VenvProbeResponse,
|
||||||
|
} from '../api/client'
|
||||||
import {
|
import {
|
||||||
createEmptyTaskFormValues,
|
createEmptyTaskFormValues,
|
||||||
mapServerFieldToFormField,
|
mapServerFieldToFormField,
|
||||||
@@ -57,6 +63,15 @@ export function TaskEditorModal({
|
|||||||
const scriptPreview = watch('script')
|
const scriptPreview = watch('script')
|
||||||
const debouncedCron = useDebouncedValue(cronValue, 300)
|
const debouncedCron = useDebouncedValue(cronValue, 300)
|
||||||
const debouncedTimezone = useDebouncedValue(timezoneValue, 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({
|
const cronPreview = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'cron-preview',
|
'cron-preview',
|
||||||
@@ -79,6 +94,31 @@ export function TaskEditorModal({
|
|||||||
Boolean(debouncedTimezone.trim()),
|
Boolean(debouncedTimezone.trim()),
|
||||||
retry: false,
|
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 scriptArgs = useFieldArray({ control, name: 'script.args' })
|
||||||
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
const scriptEnv = useFieldArray({ control, name: 'script.env' })
|
||||||
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
|
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
|
||||||
@@ -306,17 +346,23 @@ export function TaskEditorModal({
|
|||||||
|
|
||||||
{actionType === 'script' ? (
|
{actionType === 'script' ? (
|
||||||
<div className="dynamic-fields">
|
<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">
|
<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
|
<Field
|
||||||
label="解释器"
|
label="解释器"
|
||||||
error={errors.script?.interpreter?.message}
|
error={errors.script?.interpreter?.message}
|
||||||
@@ -327,17 +373,17 @@ export function TaskEditorModal({
|
|||||||
placeholder="python.exe"
|
placeholder="python.exe"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="虚拟环境目录 (可选)"
|
||||||
|
hint="留空时自动探测;填写后优先使用该目录"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
{...register('script.venv')}
|
||||||
|
className="field-input mono"
|
||||||
|
placeholder="例如:.venv"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field
|
|
||||||
label="虚拟环境目录 (可选)"
|
|
||||||
hint="留空时自动从工作目录及上级目录探测 .venv、venv、env"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
{...register('script.venv')}
|
|
||||||
className="field-input mono"
|
|
||||||
placeholder="例如:.venv"
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
<Field
|
<Field
|
||||||
label="脚本文件或相对路径"
|
label="脚本文件或相对路径"
|
||||||
error={errors.script?.script?.message}
|
error={errors.script?.script?.message}
|
||||||
@@ -412,9 +458,9 @@ export function TaskEditorModal({
|
|||||||
<span>Windows 原生环境</span>
|
<span>Windows 原生环境</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cli-preview-body">
|
<div className="cli-preview-body">
|
||||||
{scriptPreview.venv.trim() ? (
|
{previewVirtualEnv ? (
|
||||||
<div className="cli-line-env">
|
<div className="cli-line-env">
|
||||||
set VIRTUAL_ENV={scriptPreview.venv.trim()}
|
set VIRTUAL_ENV={previewVirtualEnv}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{scriptPreview.env
|
{scriptPreview.env
|
||||||
@@ -431,7 +477,7 @@ export function TaskEditorModal({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="cli-line-cmd">
|
<div className="cli-line-cmd">
|
||||||
{formatInterpreterPreview(scriptPreview)}{' '}
|
{formatInterpreterPreview(scriptPreview, previewInterpreter)}{' '}
|
||||||
{scriptPreview.script.trim() || 'main.py'}
|
{scriptPreview.script.trim() || 'main.py'}
|
||||||
{formatArgumentPreview(scriptPreview.args)}
|
{formatArgumentPreview(scriptPreview.args)}
|
||||||
</div>
|
</div>
|
||||||
@@ -615,11 +661,95 @@ function formatArgumentPreview(argumentsList: KeyValueFormValue[]): string {
|
|||||||
|
|
||||||
function formatInterpreterPreview(
|
function formatInterpreterPreview(
|
||||||
script: TaskFormValues['script'],
|
script: TaskFormValues['script'],
|
||||||
|
linkedPythonPath?: string | null,
|
||||||
): string {
|
): string {
|
||||||
if (script.venv.trim()) {
|
if (script.venv.trim()) {
|
||||||
return `${script.venv.trim().replace(/[\\/]+$/, '')}\\Scripts\\python.exe`
|
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 {
|
function isSensitiveName(name: string): boolean {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
--success-bg: #eefbf5;
|
--success-bg: #eefbf5;
|
||||||
--danger: #f43f5e;
|
--danger: #f43f5e;
|
||||||
--danger-bg: #fff1f2;
|
--danger-bg: #fff1f2;
|
||||||
|
--warning: #d97706;
|
||||||
|
--warning-bg: #fffbeb;
|
||||||
--shadow: var(--shadow-card);
|
--shadow: var(--shadow-card);
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
|||||||
Reference in New Issue
Block a user