feat: 本地 uv 与托管 Python 环境管理

- 新增 uv 管理:只认程序目录副本或指定路径,支持从科大镜像下载并按 sha256 校验

- uv 环境变量(UV_*)可配置,默认相对程序目录(python / uv-cache),同时注入 uv 调用与任务进程

- 新增托管虚拟环境与 Python 版本管理:用 uv 下载解释器、预创建环境、查看已装包

- 新建任务保存时自动创建 .venv,运行期只校验、不再自动装依赖;镜像只在装包时使用

- 前端改为顶部品牌栏 + 三栏工作台,uv / 镜像源 / 虚拟环境各自独立弹窗

- 发布包构建后去掉工作区继承的低完整性标签

- 修复调度集成测试并发时误取 skipped 运行的问题
This commit is contained in:
2026-11-07 16:57:27 +08:00
parent 2aa2a1ac18
commit 1914d38451
36 changed files with 5168 additions and 508 deletions
+4 -5
View File
@@ -225,17 +225,16 @@ async fn venv_probe_reports_project_dependency_files() {
}
#[tokio::test]
async fn venv_create_rejects_project_without_dependency_files() {
async fn venv_create_rejects_missing_working_directory() {
let (app, temp_dir) = test_app().await;
let token = fetch_token(&app).await;
let project = temp_dir.path().join("empty-project");
fs::create_dir_all(&project).expect("project directory should be created");
let absent = temp_dir.path().join("absent-project");
let (status, body) = post_create(
&app,
Some(&token),
json!({
"working_dir": project.to_str().expect("project path should be UTF-8")
"working_dir": absent.to_str().expect("absent path should be UTF-8")
}),
)
.await;
@@ -246,7 +245,7 @@ async fn venv_create_rejects_project_without_dependency_files() {
body["message"]
.as_str()
.expect("message should be a string")
.contains("pyproject.toml")
.contains("工作目录")
);
}
+174
View File
@@ -0,0 +1,174 @@
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::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 auth_token(app: &axum::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("body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
json["token"]
.as_str()
.expect("token should be a string")
.to_owned()
}
async fn send(
app: &axum::Router,
method: Method,
uri: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = if let Some(body) = body {
builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&body).expect("body should serialize"),
))
.expect("request should build")
} else {
builder.body(Body::empty()).expect("request should build")
};
let response = app
.clone()
.oneshot(request)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
}
#[tokio::test]
async fn managed_env_list_returns_an_array() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send(&app, Method::GET, "/api/v1/python/envs", None, None).await;
assert_eq!(status, StatusCode::OK);
assert!(body.is_array(), "envs should be an array: {body}");
}
#[tokio::test]
async fn managed_env_create_validates_input() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
Some(&token),
Some(json!({ "name": "../escape" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "name");
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
Some(&token),
Some(json!({ "name": "data-science", "python_version": "latest" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "python_version");
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
Some(&token),
Some(json!({ "name": "data-science", "packages": ["--index-url=http://x"] })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "packages");
}
#[tokio::test]
async fn missing_managed_env_returns_not_found() {
let (app, _temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send(
&app,
Method::GET,
"/api/v1/python/envs/absent/packages",
None,
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "ENV_NOT_FOUND");
let (status, body) = send(
&app,
Method::DELETE,
"/api/v1/python/envs/absent",
Some(&token),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "ENV_NOT_FOUND");
}
#[tokio::test]
async fn managed_env_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send(
&app,
Method::POST,
"/api/v1/python/envs",
None,
Some(json!({ "name": "no-token" })),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
+46 -5
View File
@@ -201,6 +201,46 @@ async fn wait_for_run(router: &Router, run_id: i64) -> Value {
panic!("run {run_id} did not reach a terminal state");
}
#[tokio::test]
async fn python_task_without_venv_fails_with_editor_hint() {
let app = test_app().await;
let working_dir = app._temp_dir.path().join("bare-python");
fs::create_dir_all(&working_dir).expect("working directory should be created");
fs::write(working_dir.join("main.py"), "print('hi')\n").expect("script should be written");
let mut task = script_task(
"bare_python",
&working_dir.to_string_lossy(),
"python.exe",
"main.py",
vec![],
"0 0 2 * * *",
10,
);
task["action"]["language"] = json!("python");
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,
"/api/v1/tasks/bare_python/run",
None,
)
.await;
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
let run = response_json(run_response).await;
let completed = wait_for_run(&app.router, run["id"].as_i64().unwrap()).await;
assert_eq!(completed["status"], "failed");
let message = completed["error_message"]
.as_str()
.expect("failed run should carry an error message");
// 运行期不再自动装依赖,只提示去编辑器里创建。
assert!(message.contains("编辑器里用 uv 创建"), "{message}");
app.state.shutdown();
}
#[tokio::test]
async fn shell_language_runs_batch_file_with_cmd_c() {
let app = test_app().await;
@@ -706,11 +746,12 @@ async fn scheduler_runs_tasks_and_tracks_enable_state() {
)
.await;
let runs = response_json(response).await;
if let Some(item) = runs["items"]
.as_array()
.and_then(|items| items.first())
.filter(|item| item["trigger"] == "schedule")
{
// 每秒触发一次,机器忙时上一轮还没结束,最新一条可能是 skipped;取真正跑起来那条。
if let Some(item) = runs["items"].as_array().and_then(|items| {
items
.iter()
.find(|item| item["trigger"] == "schedule" && item["status"] != "skipped")
}) {
scheduled_run = Some(item.clone());
break;
}
+208
View File
@@ -0,0 +1,208 @@
use std::{fs, path::Path};
use axum::{
body::Body,
http::{Method, Request, StatusCode, header},
};
use http_body_util::BodyExt;
use serde_json::{Value, json};
use tempfile::TempDir;
use tower::ServiceExt;
async fn test_app() -> (axum::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 auth_token(app: &axum::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("body should be readable")
.to_bytes();
let json: Value = serde_json::from_slice(&body).expect("session should be JSON");
json["token"]
.as_str()
.expect("token should be a string")
.to_owned()
}
async fn send_json(
app: &axum::Router,
method: Method,
uri: &str,
token: Option<&str>,
body: Option<Value>,
) -> (StatusCode, Value) {
let mut builder = Request::builder().method(method).uri(uri);
if let Some(token) = token {
builder = builder.header("x-taskpilot-token", token);
}
let request = if let Some(body) = body {
builder
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::to_vec(&body).expect("body should serialize"),
))
.expect("request should build")
} else {
builder.body(Body::empty()).expect("request should build")
};
let response = app
.clone()
.oneshot(request)
.await
.expect("router should respond");
let status = response.status();
let body = response
.into_body()
.collect()
.await
.expect("body should be readable")
.to_bytes();
(status, serde_json::from_slice(&body).unwrap_or(Value::Null))
}
#[tokio::test]
async fn uv_settings_default_to_the_managed_uv() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["source"], "managed");
assert_eq!(body["custom_path"], Value::Null);
let managed_dir = body["managed_dir"]
.as_str()
.expect("managed_dir should be a string");
assert_eq!(
Path::new(managed_dir).file_name().unwrap(),
std::ffi::OsStr::new("uv")
);
// 不再读系统 PATH,候选里只有程序目录 uv(自定义路径未设置时也不出现)。
let sources = body["candidates"]
.as_array()
.expect("candidates should be an array")
.iter()
.map(|candidate| candidate["source"].as_str().unwrap().to_owned())
.collect::<Vec<_>>();
assert_eq!(sources, vec!["managed"]);
}
#[tokio::test]
async fn uv_settings_reject_unusable_custom_path_and_persist_valid_ones() {
let (app, temp_dir) = test_app().await;
let token = auth_token(&app).await;
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({ "source": "custom", "custom_path": null })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "custom_path");
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({ "source": "custom", "custom_path": "C:\\missing\\uv.exe" })),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "custom_path");
// 文件存在但不是可运行的 uv,也要拒绝保存。
let fake = temp_dir.path().join("fake.exe");
fs::write(&fake, b"not a program").expect("fake executable should be written");
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "custom",
"custom_path": fake.to_string_lossy()
})),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["field"], "custom_path");
// 切回程序目录 uv 时保留已填写的自定义路径,方便再次切换。
let (status, saved) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
Some(&token),
Some(json!({
"source": "managed",
"custom_path": " C:\\tools\\uv.exe "
})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved["source"], "managed");
assert_eq!(saved["custom_path"], "C:\\tools\\uv.exe");
let (status, body) = send_json(&app, Method::GET, "/api/v1/uv", None, None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["source"], "managed");
assert_eq!(body["custom_path"], "C:\\tools\\uv.exe");
assert!(
body["candidates"]
.as_array()
.expect("candidates should be an array")
.iter()
.any(|candidate| candidate["source"] == "custom")
);
}
#[tokio::test]
async fn uv_settings_mutations_require_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(
&app,
Method::PUT,
"/api/v1/uv",
None,
Some(json!({ "source": "managed", "custom_path": null })),
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
#[tokio::test]
async fn uv_download_requires_token() {
let (app, _temp_dir) = test_app().await;
let (status, body) = send_json(&app, Method::POST, "/api/v1/uv/download", None, None).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}