feat: 拆分任务类型并支持脚本语言联动
- 任务类型改为「执行脚本 / 发起 HTTP 请求 / 运行二进制程序」,脚本类型下新增语言选择(Python / Shell / Node / Lua) - 语言联动表单:Python 才显示虚拟环境、解释器和 uv 状态;Shell/Node/Lua 隐藏这些字段并切换脚本标签、占位符和调用预览 - 切换语言时自动设置解释器与默认脚本,Shell 通过 cmd.exe /C 执行批处理,Node/Lua 使用对应解释器 - TaskAction 新增可选 language,旧任务按解释器推断;补 Shell 执行测试、语言切换单测与 Playwright 用例
This commit is contained in:
+3
-2
@@ -41,8 +41,8 @@ use crate::{
|
||||
tasks::{
|
||||
model::{
|
||||
ConcurrencyPolicy, CreateTaskRequest, HttpMethod, RetryPolicy, ScheduledTaskResponse,
|
||||
TaskAction, TaskActionType, TaskListResponse, TaskResponse, TaskStatsResponse,
|
||||
UpdateTaskRequest,
|
||||
ScriptLanguage, TaskAction, TaskActionType, TaskListResponse, TaskResponse,
|
||||
TaskStatsResponse, UpdateTaskRequest,
|
||||
},
|
||||
routes::{
|
||||
__path_create_task, __path_delete_task, __path_disable_task, __path_enable_task,
|
||||
@@ -299,6 +299,7 @@ pub async fn list_schedule(
|
||||
ScheduledTaskResponse,
|
||||
ErrorResponse,
|
||||
TaskActionType,
|
||||
ScriptLanguage,
|
||||
HttpMethod,
|
||||
ConcurrencyPolicy,
|
||||
RetryPolicy,
|
||||
|
||||
@@ -26,12 +26,12 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::{
|
||||
error::ApiResult,
|
||||
process_job::ProcessJob,
|
||||
python_env::{is_python_interpreter, prepare_script_environment},
|
||||
python_env::prepare_script_environment,
|
||||
runs::{
|
||||
model::{RunStatus, RunTrigger, TaskRunResponse},
|
||||
repository,
|
||||
},
|
||||
tasks::model::{HttpMethod, TaskAction, TaskResponse},
|
||||
tasks::model::{HttpMethod, ScriptLanguage, TaskAction, TaskResponse},
|
||||
};
|
||||
|
||||
const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
|
||||
@@ -429,11 +429,14 @@ impl TaskExecutor {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
language,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
env,
|
||||
} => {
|
||||
let script_language =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter));
|
||||
let prepared = match prepare_script_environment(
|
||||
working_dir,
|
||||
interpreter,
|
||||
@@ -451,7 +454,7 @@ impl TaskExecutor {
|
||||
let mut notes = Vec::new();
|
||||
if let Some(venv) = &prepared.virtual_env {
|
||||
notes.push(format!("使用 Python 虚拟环境: {}", venv.display()));
|
||||
} else if is_python_interpreter(interpreter) {
|
||||
} else if script_language == ScriptLanguage::Python {
|
||||
// Python 任务强制使用项目虚拟环境:没有 .venv 时不再悄悄退回系统解释器。
|
||||
let project = crate::python_env::scan_project_files(working_dir);
|
||||
if project.has_pyproject || !project.requirements.is_empty() {
|
||||
@@ -463,7 +466,7 @@ impl TaskExecutor {
|
||||
"项目没有虚拟环境,也没有 pyproject.toml / requirements.txt;请先创建 .venv 或补充依赖文件",
|
||||
);
|
||||
}
|
||||
if is_python_interpreter(interpreter) {
|
||||
if script_language == ScriptLanguage::Python {
|
||||
match crate::python_mirrors::repository::default_enabled(&self.inner.pool).await
|
||||
{
|
||||
Ok(Some(mirror)) => {
|
||||
@@ -501,6 +504,12 @@ impl TaskExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
if script_language == ScriptLanguage::Shell
|
||||
&& is_cmd_interpreter(interpreter)
|
||||
&& !script.trim_start().starts_with('/')
|
||||
{
|
||||
command.arg("/C");
|
||||
}
|
||||
command.arg(script).args(args).current_dir(working_dir);
|
||||
let startup_note = (!notes.is_empty()).then(|| notes.join(";"));
|
||||
self.run_command(task, run_id, cancellation, command, startup_note)
|
||||
@@ -1120,6 +1129,13 @@ fn env_has(env: &BTreeMap<String, String>, key: &str) -> bool {
|
||||
.any(|candidate| candidate.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
fn is_cmd_interpreter(interpreter: &str) -> bool {
|
||||
Path::new(interpreter)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.is_some_and(|stem| stem.eq_ignore_ascii_case("cmd"))
|
||||
}
|
||||
|
||||
fn sensitive_values(action: &TaskAction) -> Vec<String> {
|
||||
match action {
|
||||
TaskAction::Script { env, .. } | TaskAction::Process { env, .. } => env
|
||||
|
||||
@@ -198,7 +198,7 @@ fn lock_next_runs(
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Timelike};
|
||||
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, TaskAction};
|
||||
use crate::tasks::model::{ConcurrencyPolicy, RetryPolicy, ScriptLanguage, TaskAction};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -215,6 +215,7 @@ mod tests {
|
||||
action: TaskAction::Script {
|
||||
working_dir: ".".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Shell),
|
||||
interpreter: "cmd.exe".to_owned(),
|
||||
script: "/C".to_owned(),
|
||||
args: vec!["exit".to_owned(), "0".to_owned()],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -8,6 +8,56 @@ use utoipa::ToSchema;
|
||||
pub const DEFAULT_TIMEZONE: &str = "Asia/Shanghai";
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScriptLanguage {
|
||||
Python,
|
||||
Shell,
|
||||
Node,
|
||||
Lua,
|
||||
}
|
||||
|
||||
impl ScriptLanguage {
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Python => "python",
|
||||
Self::Shell => "shell",
|
||||
Self::Node => "node",
|
||||
Self::Lua => "lua",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn default_interpreter(self) -> &'static str {
|
||||
match self {
|
||||
Self::Python => "python.exe",
|
||||
Self::Shell => "cmd.exe",
|
||||
Self::Node => "node.exe",
|
||||
Self::Lua => "lua.exe",
|
||||
}
|
||||
}
|
||||
|
||||
/// 旧任务没有 `language` 字段时,从解释器文件名推断。
|
||||
pub fn from_interpreter(interpreter: &str) -> Self {
|
||||
let stem = Path::new(interpreter)
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or(interpreter)
|
||||
.to_ascii_lowercase();
|
||||
if matches!(stem.as_str(), "node" | "nodejs") {
|
||||
Self::Node
|
||||
} else if stem.starts_with("lua") {
|
||||
Self::Lua
|
||||
} else if matches!(
|
||||
stem.as_str(),
|
||||
"cmd" | "powershell" | "pwsh" | "bash" | "sh" | "zsh"
|
||||
) {
|
||||
Self::Shell
|
||||
} else {
|
||||
Self::Python
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TaskActionType {
|
||||
@@ -57,6 +107,8 @@ pub enum TaskAction {
|
||||
working_dir: String,
|
||||
#[serde(default)]
|
||||
venv: Option<String>,
|
||||
#[serde(default)]
|
||||
language: Option<ScriptLanguage>,
|
||||
interpreter: String,
|
||||
script: String,
|
||||
#[serde(default)]
|
||||
@@ -93,6 +145,18 @@ impl TaskAction {
|
||||
Self::Process { .. } => TaskActionType::Process,
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本语言;旧任务未存 language 时从解释器推断。
|
||||
pub fn script_language(&self) -> Option<ScriptLanguage> {
|
||||
match self {
|
||||
Self::Script {
|
||||
language,
|
||||
interpreter,
|
||||
..
|
||||
} => Some(language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
|
||||
@@ -184,11 +184,17 @@ fn validate_common(
|
||||
|
||||
fn normalize_action(action: &mut TaskAction) {
|
||||
if let TaskAction::Script {
|
||||
venv, interpreter, ..
|
||||
venv,
|
||||
language,
|
||||
interpreter,
|
||||
..
|
||||
} = action
|
||||
{
|
||||
let resolved =
|
||||
language.unwrap_or_else(|| ScriptLanguage::from_interpreter(interpreter.trim()));
|
||||
*language = Some(resolved);
|
||||
if interpreter.trim().is_empty() {
|
||||
*interpreter = "python.exe".to_owned();
|
||||
*interpreter = resolved.default_interpreter().to_owned();
|
||||
}
|
||||
if venv.as_deref().is_some_and(|value| value.trim().is_empty()) {
|
||||
*venv = None;
|
||||
@@ -201,6 +207,7 @@ fn validate_action(action: &TaskAction) -> ApiResult<()> {
|
||||
TaskAction::Script {
|
||||
working_dir,
|
||||
venv,
|
||||
language: _,
|
||||
interpreter,
|
||||
script,
|
||||
args,
|
||||
@@ -348,6 +355,7 @@ mod tests {
|
||||
TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Python),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: "main.py".to_owned(),
|
||||
args: vec!["--mode".to_owned(), "full".to_owned()],
|
||||
@@ -388,6 +396,7 @@ mod tests {
|
||||
let action = TaskAction::Script {
|
||||
working_dir: "D:\\Projects\\sync".to_owned(),
|
||||
venv: None,
|
||||
language: Some(ScriptLanguage::Python),
|
||||
interpreter: "python.exe".to_owned(),
|
||||
script: " ".to_owned(),
|
||||
args: Vec::new(),
|
||||
|
||||
+53
-1
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{fs, time::Duration};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
@@ -201,6 +201,58 @@ 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 shell_language_runs_batch_file_with_cmd_c() {
|
||||
let app = test_app().await;
|
||||
let working_dir = app._temp_dir.path().to_string_lossy().into_owned();
|
||||
fs::write(
|
||||
app._temp_dir.path().join("hello.bat"),
|
||||
"@echo off\r\necho shell-language-ok\r\n",
|
||||
)
|
||||
.expect("batch file should be written");
|
||||
|
||||
let mut task = script_task(
|
||||
"shell_task",
|
||||
&working_dir,
|
||||
"cmd.exe",
|
||||
"hello.bat",
|
||||
vec![],
|
||||
"0 0 2 * * *",
|
||||
10,
|
||||
);
|
||||
task["action"]["language"] = json!("shell");
|
||||
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/shell_task/run",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run_response.status(), StatusCode::ACCEPTED);
|
||||
let run = response_json(run_response).await;
|
||||
let run_id = run["id"].as_i64().expect("run id should be an integer");
|
||||
|
||||
let completed = wait_for_run(&app.router, run_id).await;
|
||||
assert_eq!(completed["status"], "succeeded");
|
||||
let log_response = send_json(
|
||||
&app.router,
|
||||
Method::GET,
|
||||
&format!("/api/v1/runs/{run_id}/logs"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let log = response_json(log_response).await;
|
||||
assert!(
|
||||
log["content"]
|
||||
.as_str()
|
||||
.expect("log content should be a string")
|
||||
.contains("shell-language-ok")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_script_run_captures_exit_code_and_logs() {
|
||||
let app = test_app().await;
|
||||
|
||||
@@ -124,6 +124,7 @@ async fn empty_interpreter_defaults_to_python() {
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let created = response_json(response).await;
|
||||
assert_eq!(created["action"]["interpreter"], "python.exe");
|
||||
assert_eq!(created["action"]["language"], "python");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user