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

fix: 按系统 ANSI 代码页解码子进程输出,修复中文日志乱码
This commit is contained in:
2026-10-24 22:30:10 +08:00
parent 23565b40a4
commit dd7612bc74
20 changed files with 1508 additions and 48 deletions
+135 -2
View File
@@ -10,6 +10,7 @@ use std::{
};
use chrono::Utc;
use encoding_rs::Encoding;
use futures_util::StreamExt;
use reqwest::Method;
use serde::Serialize;
@@ -39,6 +40,77 @@ const TERMINATION_WAIT_SECS: u64 = 5;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const DEFAULT_RETENTION_DAYS: u64 = 30;
const DEFAULT_MAX_TOTAL_LOG_BYTES: u64 = 512 * 1024 * 1024;
const LOG_ENCODING_ENV: &str = "TASK_PILOT_LOG_ENCODING";
/// 子进程输出的解码方式。
///
/// `TASK_PILOT_LOG_ENCODING=auto`(默认)逐行先按 UTF-8 严格解码,失败时回退到系统
/// ANSI 代码页;填写编码标签(`gbk`、`utf-8`、`windows-1251` 等)则始终按该编码解码。
#[derive(Clone, Copy, Debug)]
pub struct OutputEncoding {
forced: Option<&'static Encoding>,
fallback: &'static Encoding,
}
impl OutputEncoding {
pub fn from_env() -> Self {
let fallback = ansi_code_page_encoding();
let forced = std::env::var(LOG_ENCODING_ENV).ok().and_then(|value| {
let label = value.trim().to_ascii_lowercase();
if label.is_empty() || label == "auto" {
None
} else {
Encoding::for_label(label.as_bytes())
}
});
Self { forced, fallback }
}
pub fn decode(&self, bytes: &[u8]) -> String {
if let Some(encoding) = self.forced {
let (text, _) = encoding.decode_without_bom_handling(bytes);
return text.into_owned();
}
match std::str::from_utf8(bytes) {
Ok(text) => text.to_owned(),
Err(_) => {
let (text, _) = self.fallback.decode_without_bom_handling(bytes);
text.into_owned()
}
}
}
/// 实际使用的编码名,写入运行日志便于排查乱码。
pub fn name(&self) -> &'static str {
self.forced.unwrap_or(self.fallback).name()
}
}
#[cfg(windows)]
fn ansi_code_page_encoding() -> &'static Encoding {
let code_page = unsafe { windows_sys::Win32::Globalization::GetACP() };
code_page_encoding(code_page)
}
#[cfg(not(windows))]
fn ansi_code_page_encoding() -> &'static Encoding {
encoding_rs::UTF_8
}
fn code_page_encoding(code_page: u32) -> &'static Encoding {
let label = match code_page {
936 => "gbk".to_owned(),
950 => "big5".to_owned(),
932 => "shift_jis".to_owned(),
949 => "euc-kr".to_owned(),
65001 => "utf-8".to_owned(),
other => format!("windows-{other}"),
};
Encoding::for_label(label.as_bytes()).unwrap_or(encoding_rs::UTF_8)
}
#[derive(Clone, Debug)]
pub struct LogRetentionConfig {
@@ -92,6 +164,7 @@ struct ExecutorInner {
http: reqwest::Client,
log_config: LogRetentionConfig,
cleanup_lock: AsyncMutex<()>,
output_encoding: OutputEncoding,
}
struct RunningHandle {
@@ -107,6 +180,7 @@ struct LogSink {
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Arc<Vec<String>>,
encoding: OutputEncoding,
}
struct ScriptOutcome {
@@ -124,6 +198,8 @@ enum ProcessEnd {
impl TaskExecutor {
pub fn new(pool: SqlitePool, logs_dir: PathBuf, log_config: LogRetentionConfig) -> Self {
let (events, _) = broadcast::channel(512);
let output_encoding = OutputEncoding::from_env();
tracing::info!(encoding = output_encoding.name(), "task output decoding");
Self {
inner: Arc::new(ExecutorInner {
pool,
@@ -138,6 +214,7 @@ impl TaskExecutor {
.unwrap_or_default(),
log_config,
cleanup_lock: AsyncMutex::new(()),
output_encoding,
}),
}
}
@@ -353,6 +430,7 @@ impl TaskExecutor {
run_id,
self.inner.events.clone(),
secrets,
self.inner.output_encoding,
)
.await
{
@@ -517,6 +595,7 @@ impl TaskExecutor {
run_id,
self.inner.events.clone(),
sensitive_values(&task.action),
self.inner.output_encoding,
)
.await
{
@@ -638,7 +717,7 @@ impl TaskExecutor {
None => break,
}
}
let body_text = String::from_utf8_lossy(&response_body);
let body_text = self.inner.output_encoding.decode(&response_body);
if !body_text.is_empty() {
sink.http(&body_text).await;
}
@@ -697,6 +776,7 @@ impl LogSink {
run_id: i64,
events: broadcast::Sender<ExecutionEvent>,
secrets: Vec<String>,
encoding: OutputEncoding,
) -> std::io::Result<Self> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
@@ -721,9 +801,14 @@ impl LogSink {
run_id,
events,
secrets: Arc::new(secrets),
encoding,
})
}
fn decode_output(&self, bytes: &[u8]) -> String {
self.encoding.decode(bytes)
}
async fn append(&self, text: &str) {
let redacted = redact_text(text, &self.secrets);
let bytes = redacted.as_bytes();
@@ -783,7 +868,7 @@ where
match reader.read_until(b'\n', &mut line).await {
Ok(0) => break,
Ok(_) => {
let text = String::from_utf8_lossy(&line);
let text = sink.decode_output(&line);
sink.append(&format!("[{stream}] {text}")).await;
}
Err(error) => {
@@ -957,6 +1042,54 @@ mod tests {
assert!(!is_sensitive_name("PYTHONUNBUFFERED"));
}
fn auto_encoding(fallback: &'static Encoding) -> OutputEncoding {
OutputEncoding {
forced: None,
fallback,
}
}
#[test]
fn decodes_utf8_output_without_touching_it() {
let encoding = auto_encoding(encoding_rs::GBK);
assert_eq!(encoding.decode("中文输出".as_bytes()), "中文输出");
}
#[test]
fn decodes_gbk_output_through_the_system_fallback() {
let encoding = auto_encoding(encoding_rs::GBK);
// "中文" 的 GBK 编码
let gbk = [0xD6, 0xD0, 0xCE, 0xC4];
assert_eq!(encoding.decode(&gbk), "中文");
}
#[test]
fn forced_encoding_skips_utf8_sniffing() {
let encoding = OutputEncoding {
forced: Some(encoding_rs::GBK),
fallback: encoding_rs::UTF_8,
};
let gbk = [0xD6, 0xD0, 0xCE, 0xC4];
assert_eq!(encoding.decode(&gbk), "中文");
}
#[test]
fn undecodable_bytes_still_produce_text() {
let encoding = auto_encoding(encoding_rs::UTF_8);
assert!(encoding.decode(&[0xFF, 0xFE, 0xFF]).contains('\u{fffd}'));
}
#[test]
fn maps_windows_code_pages_to_encodings() {
assert_eq!(code_page_encoding(936).name(), "GBK");
assert_eq!(code_page_encoding(65001).name(), "UTF-8");
assert_eq!(code_page_encoding(1252).name(), "windows-1252");
}
#[test]
fn cleanup_enforces_total_log_capacity() {
let temp = tempfile::TempDir::new().unwrap();