mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 17:09:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f61a0b747f | ||
|
|
65c374f53c | ||
|
|
faf3012a36 | ||
|
|
ce193ff2d2 | ||
|
|
7881998d93 | ||
|
|
d967e620cc | ||
|
|
7b884c4a24 | ||
|
|
d45d5977a0 | ||
|
|
d29811db6b | ||
|
|
25dcbf78b8 |
Generated
+125
-6
@@ -1510,7 +1510,7 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
|
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm 0.29.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1731,7 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
|
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crokey-proc_macros",
|
"crokey-proc_macros",
|
||||||
"crossterm",
|
"crossterm 0.29.0",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"serde",
|
"serde",
|
||||||
"strict",
|
"strict",
|
||||||
@@ -1743,7 +1743,7 @@ version = "1.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
|
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crossterm",
|
"crossterm 0.29.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"strict",
|
"strict",
|
||||||
@@ -1817,6 +1817,22 @@ version = "0.8.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossterm"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crossterm_winapi",
|
||||||
|
"mio",
|
||||||
|
"parking_lot",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"signal-hook",
|
||||||
|
"signal-hook-mio",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crossterm"
|
name = "crossterm"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2476,6 +2492,21 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -3118,7 +3149,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots 1.0.6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3133,6 +3163,22 @@ dependencies = [
|
|||||||
"tokio-io-timeout",
|
"tokio-io-timeout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper 1.8.1",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -3410,7 +3456,7 @@ dependencies = [
|
|||||||
"clap_complete",
|
"clap_complete",
|
||||||
"criterion",
|
"criterion",
|
||||||
"cron",
|
"cron",
|
||||||
"crossterm",
|
"crossterm 0.28.1",
|
||||||
"deadpool-postgres",
|
"deadpool-postgres",
|
||||||
"dirs 6.0.0",
|
"dirs 6.0.0",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
@@ -4089,6 +4135,23 @@ dependencies = [
|
|||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe 0.2.1",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework 3.7.0",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "new_debug_unreachable"
|
name = "new_debug_unreachable"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -4311,6 +4374,32 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.76"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -4323,6 +4412,18 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.112"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -5312,11 +5413,13 @@ dependencies = [
|
|||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper 1.8.1",
|
"hyper 1.8.1",
|
||||||
"hyper-rustls 0.27.7",
|
"hyper-rustls 0.27.7",
|
||||||
|
"hyper-tls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"mime",
|
"mime",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"native-tls",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"quinn",
|
"quinn",
|
||||||
@@ -5328,6 +5431,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper 1.0.2",
|
"sync_wrapper 1.0.2",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
"tokio-rustls 0.26.4",
|
"tokio-rustls 0.26.4",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
@@ -5338,7 +5442,6 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots 1.0.6",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6671,6 +6774,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-postgres"
|
name = "tokio-postgres"
|
||||||
version = "0.7.16"
|
version = "0.7.16"
|
||||||
@@ -7354,6 +7467,12 @@ version = "0.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
|
|||||||
+326
-25
@@ -715,6 +715,7 @@ impl RoutineEngine {
|
|||||||
status,
|
status,
|
||||||
Some(summary),
|
Some(summary),
|
||||||
thread_id.as_deref(),
|
thread_id.as_deref(),
|
||||||
|
run.job_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -1085,7 +1086,7 @@ struct EngineContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||||
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
|
async fn execute_routine(ctx: EngineContext, routine: Routine, mut run: RoutineRun) {
|
||||||
// Increment running count (atomic: survives panics in the execution below)
|
// Increment running count (atomic: survives panics in the execution below)
|
||||||
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
@@ -1118,7 +1119,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
|||||||
description,
|
description,
|
||||||
max_iterations: *max_iterations,
|
max_iterations: *max_iterations,
|
||||||
};
|
};
|
||||||
execute_full_job(&ctx, &routine, &run, &execution).await
|
execute_full_job(&ctx, &routine, &mut run, &execution).await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1218,6 +1219,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
|||||||
status,
|
status,
|
||||||
summary.as_deref(),
|
summary.as_deref(),
|
||||||
thread_id.as_deref(),
|
thread_id.as_deref(),
|
||||||
|
run.job_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -1253,7 +1255,7 @@ struct FullJobExecutionConfig<'a> {
|
|||||||
async fn execute_full_job(
|
async fn execute_full_job(
|
||||||
ctx: &EngineContext,
|
ctx: &EngineContext,
|
||||||
routine: &Routine,
|
routine: &Routine,
|
||||||
run: &RoutineRun,
|
run: &mut RoutineRun,
|
||||||
execution: &FullJobExecutionConfig<'_>,
|
execution: &FullJobExecutionConfig<'_>,
|
||||||
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
) -> Result<(RunStatus, Option<String>, Option<i32>), RoutineError> {
|
||||||
match ctx.sandbox_readiness {
|
match ctx.sandbox_readiness {
|
||||||
@@ -1315,6 +1317,9 @@ async fn execute_full_job(
|
|||||||
reason: format!("failed to link run to job: {e}"),
|
reason: format!("failed to link run to job: {e}"),
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Keep the in-memory struct in sync so send_notification can read run.job_id.
|
||||||
|
run.job_id = Some(job_id);
|
||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
routine = %routine.name,
|
routine = %routine.name,
|
||||||
job_id = %job_id,
|
job_id = %job_id,
|
||||||
@@ -1827,7 +1832,18 @@ async fn execute_routine_tool(
|
|||||||
Ok(result_str)
|
Ok(result_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Human-readable label for a run status, suitable for user-facing notifications.
|
||||||
|
fn status_display_label(status: RunStatus) -> &'static str {
|
||||||
|
match status {
|
||||||
|
RunStatus::Ok => "Completed",
|
||||||
|
RunStatus::Attention => "Needs attention",
|
||||||
|
RunStatus::Failed => "Failed",
|
||||||
|
RunStatus::Running => "Running",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a notification based on the routine's notify config and run status.
|
/// Send a notification based on the routine's notify config and run status.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn send_notification(
|
async fn send_notification(
|
||||||
tx: &mpsc::Sender<OutgoingResponse>,
|
tx: &mpsc::Sender<OutgoingResponse>,
|
||||||
notify: &NotifyConfig,
|
notify: &NotifyConfig,
|
||||||
@@ -1836,6 +1852,7 @@ async fn send_notification(
|
|||||||
status: RunStatus,
|
status: RunStatus,
|
||||||
summary: Option<&str>,
|
summary: Option<&str>,
|
||||||
thread_id: Option<&str>,
|
thread_id: Option<&str>,
|
||||||
|
job_id: Option<Uuid>,
|
||||||
) {
|
) {
|
||||||
let should_notify = match status {
|
let should_notify = match status {
|
||||||
RunStatus::Ok => notify.on_success,
|
RunStatus::Ok => notify.on_success,
|
||||||
@@ -1855,23 +1872,37 @@ async fn send_notification(
|
|||||||
RunStatus::Running => "⏳",
|
RunStatus::Running => "⏳",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let label = status_display_label(status);
|
||||||
|
|
||||||
let message = match summary {
|
let message = match summary {
|
||||||
Some(s) => format!("{} *Routine '{}'*: {}\n\n{}", icon, routine_name, status, s),
|
Some(s) => {
|
||||||
None => format!("{} *Routine '{}'*: {}", icon, routine_name, status),
|
let sanitized = sanitize_summary(s);
|
||||||
|
format!(
|
||||||
|
"{} *Routine '{}'*: {}\n\n{}",
|
||||||
|
icon, routine_name, label, sanitized
|
||||||
|
)
|
||||||
|
}
|
||||||
|
None => format!("{} *Routine '{}'*: {}", icon, routine_name, label),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut metadata = serde_json::json!({
|
||||||
|
"source": "routine",
|
||||||
|
"routine_name": routine_name,
|
||||||
|
"status": status.to_string(),
|
||||||
|
"owner_id": owner_id,
|
||||||
|
"notify_user": notify.user,
|
||||||
|
"notify_channel": notify.channel,
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(jid) = job_id {
|
||||||
|
metadata["job_id"] = serde_json::json!(jid.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let response = OutgoingResponse {
|
let response = OutgoingResponse {
|
||||||
content: message,
|
content: message,
|
||||||
thread_id: thread_id.map(String::from),
|
thread_id: thread_id.map(String::from),
|
||||||
attachments: Vec::new(),
|
attachments: Vec::new(),
|
||||||
metadata: serde_json::json!({
|
metadata,
|
||||||
"source": "routine",
|
|
||||||
"routine_name": routine_name,
|
|
||||||
"status": status.to_string(),
|
|
||||||
"owner_id": owner_id,
|
|
||||||
"notify_user": notify.user,
|
|
||||||
"notify_channel": notify.channel,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = tx.send(response).await {
|
if let Err(e) = tx.send(response).await {
|
||||||
@@ -1934,7 +1965,6 @@ fn truncate(s: &str, max: usize) -> String {
|
|||||||
/// 2. Strip HTML tags to prevent injection in web-rendered notifications
|
/// 2. Strip HTML tags to prevent injection in web-rendered notifications
|
||||||
/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output
|
/// 3. Collapse multiple whitespace/newlines to single spaces for cleaner output
|
||||||
/// 4. Truncate to 500 chars to prevent oversized notifications
|
/// 4. Truncate to 500 chars to prevent oversized notifications
|
||||||
#[cfg(test)]
|
|
||||||
fn sanitize_summary(s: &str) -> String {
|
fn sanitize_summary(s: &str) -> String {
|
||||||
// Strip control characters (keep newline for now, collapse later)
|
// Strip control characters (keep newline for now, collapse later)
|
||||||
let no_control: String = s
|
let no_control: String = s
|
||||||
@@ -1961,19 +1991,59 @@ fn sanitize_summary(s: &str) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove HTML/XML tags from a string.
|
/// Remove actual HTML tags from a string while preserving non-HTML angle brackets.
|
||||||
#[cfg(test)]
|
///
|
||||||
|
/// Only strips patterns that look like real HTML/XML tags (e.g. `<div>`, `</p>`,
|
||||||
|
/// `<img src=...>`), not generic angle-bracket content like `Vec<String>`,
|
||||||
|
/// `cat < input.txt`, or comparison operators.
|
||||||
|
///
|
||||||
|
/// Also strips HTML comments (`<!--...-->`), SVG/MathML tags, and custom elements
|
||||||
|
/// (tags containing hyphens like `<custom-element>`).
|
||||||
fn strip_html_tags(s: &str) -> String {
|
fn strip_html_tags(s: &str) -> String {
|
||||||
let mut result = String::with_capacity(s.len());
|
use std::sync::LazyLock;
|
||||||
let mut in_tag = false;
|
|
||||||
for c in s.chars() {
|
// HTML comment pattern: <!--...-->
|
||||||
match c {
|
static COMMENT_RE: LazyLock<Option<Regex>> =
|
||||||
'<' => in_tag = true,
|
LazyLock::new(|| Regex::new(r"<!--[\s\S]*?-->").ok());
|
||||||
'>' if in_tag => in_tag = false,
|
|
||||||
_ if !in_tag => result.push(c),
|
// Known HTML/SVG/MathML tag names. Includes SVG tags (svg, path, circle, etc.)
|
||||||
_ => {}
|
// and MathML tags (math, mrow, etc.) that can carry event handlers.
|
||||||
}
|
static HTML_TAG_RE: LazyLock<Option<Regex>> = LazyLock::new(|| {
|
||||||
|
let tags = "a|abbr|address|area|article|aside|audio|b|base|bdi|bdo|blockquote|\
|
||||||
|
body|br|button|canvas|caption|cite|code|col|colgroup|data|datalist|dd|del|\
|
||||||
|
details|dfn|dialog|div|dl|dt|em|embed|fieldset|figcaption|figure|footer|\
|
||||||
|
form|h[1-6]|head|header|hgroup|hr|html|i|iframe|img|input|ins|kbd|label|\
|
||||||
|
legend|li|link|main|map|mark|meta|meter|nav|noscript|object|ol|optgroup|\
|
||||||
|
option|output|p|param|picture|pre|progress|q|rp|rt|ruby|s|samp|script|\
|
||||||
|
section|select|slot|small|source|span|strong|style|sub|summary|sup|table|\
|
||||||
|
tbody|td|template|textarea|tfoot|th|thead|time|title|tr|track|u|ul|var|\
|
||||||
|
video|wbr|\
|
||||||
|
svg|g|path|circle|ellipse|line|polyline|polygon|rect|text|tspan|defs|\
|
||||||
|
clippath|mask|pattern|image|use|symbol|marker|lineargradient|\
|
||||||
|
radialgradient|stop|filter|foreignobject|animate|animatetransform|\
|
||||||
|
math|mrow|mi|mo|mn|ms|mtext|mfrac|msqrt|mroot|msub|msup|msubsup|\
|
||||||
|
munder|mover|munderover|mtable|mtr|mtd|mspace|mpadded|mfenced|menclose";
|
||||||
|
// Handles: <tag>, </tag>, <tag/>, <tag />, <tag attr="val">, <tag attr="val"/>
|
||||||
|
Regex::new(&format!(r"(?i)</?(?:{})(?:\s[^>]*)?\s*/?>", tags)).ok()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom elements: tags containing a hyphen (web components spec requires it).
|
||||||
|
// E.g. <custom-element>, <my-widget foo="bar">, </x-foo>
|
||||||
|
static CUSTOM_ELEMENT_RE: LazyLock<Option<Regex>> =
|
||||||
|
LazyLock::new(|| Regex::new(r"(?i)</?\w+-[\w-]*(?:\s[^>]*)?\s*/?>").ok());
|
||||||
|
|
||||||
|
let mut result = s.to_string();
|
||||||
|
|
||||||
|
if let Some(re) = COMMENT_RE.as_ref() {
|
||||||
|
result = re.replace_all(&result, "").into_owned();
|
||||||
}
|
}
|
||||||
|
if let Some(re) = HTML_TAG_RE.as_ref() {
|
||||||
|
result = re.replace_all(&result, "").into_owned();
|
||||||
|
}
|
||||||
|
if let Some(re) = CUSTOM_ELEMENT_RE.as_ref() {
|
||||||
|
result = re.replace_all(&result, "").into_owned();
|
||||||
|
}
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2548,6 +2618,33 @@ mod tests {
|
|||||||
assert_eq!(sanitize_summary("<img src=x onerror=alert(1)>"), "");
|
assert_eq!(sanitize_summary("<img src=x onerror=alert(1)>"), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_summary_preserves_non_html_angle_brackets() {
|
||||||
|
use super::sanitize_summary;
|
||||||
|
|
||||||
|
// Rust/Java generics must pass through unchanged
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("expected Vec<String>"),
|
||||||
|
"expected Vec<String>"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("HashMap<String, Vec<u8>>"),
|
||||||
|
"HashMap<String, Vec<u8>>"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Shell redirects must pass through unchanged
|
||||||
|
assert_eq!(sanitize_summary("cat < input.txt"), "cat < input.txt");
|
||||||
|
|
||||||
|
// Comparison operators must pass through unchanged
|
||||||
|
assert_eq!(sanitize_summary("x < 10 && y > 20"), "x < 10 && y > 20");
|
||||||
|
|
||||||
|
// Mixed: real HTML stripped but generics preserved
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("Error in Vec<String>: <b>failed</b>"),
|
||||||
|
"Error in Vec<String>: failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_sanitize_summary_multibyte_truncation() {
|
fn test_sanitize_summary_multibyte_truncation() {
|
||||||
use super::sanitize_summary;
|
use super::sanitize_summary;
|
||||||
@@ -2558,4 +2655,208 @@ mod tests {
|
|||||||
assert!(result.len() <= 503);
|
assert!(result.len() <= 503);
|
||||||
assert!(result.ends_with("..."));
|
assert!(result.ends_with("..."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_summary_truncates_long_text() {
|
||||||
|
use super::sanitize_summary;
|
||||||
|
|
||||||
|
let short = "This is a short summary.";
|
||||||
|
assert_eq!(sanitize_summary(short), short);
|
||||||
|
|
||||||
|
let long = "x".repeat(600);
|
||||||
|
let result = sanitize_summary(&long);
|
||||||
|
assert!(
|
||||||
|
result.len() <= 503,
|
||||||
|
"Truncated summary should be at most 503 bytes (500 + '...')"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.ends_with("..."),
|
||||||
|
"Truncated summary should end with ellipsis"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_summary_strips_all_html_forms() {
|
||||||
|
use super::sanitize_summary;
|
||||||
|
|
||||||
|
// Self-closing tags without whitespace: <br/>, <img/>
|
||||||
|
assert_eq!(sanitize_summary("line1<br/>line2"), "line1line2");
|
||||||
|
assert_eq!(sanitize_summary("text<img/>more"), "textmore");
|
||||||
|
assert_eq!(sanitize_summary("text<br />more"), "textmore");
|
||||||
|
|
||||||
|
// HTML comments
|
||||||
|
assert_eq!(sanitize_summary("before<!--x-->after"), "beforeafter");
|
||||||
|
assert_eq!(sanitize_summary("a<!-- multi\nline -->b"), "ab");
|
||||||
|
|
||||||
|
// SVG tags (can carry event handlers)
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("<svg onload=alert(1)>payload</svg>"),
|
||||||
|
"payload"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_summary("<svg><circle r=10/></svg>"), "");
|
||||||
|
|
||||||
|
// MathML tags
|
||||||
|
assert_eq!(sanitize_summary("<math><mrow>x</mrow></math>"), "x");
|
||||||
|
|
||||||
|
// Custom elements (web components with hyphens)
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("before<custom-element>inner</custom-element>after"),
|
||||||
|
"beforeinnerafter"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("<my-widget foo=\"bar\">content</my-widget>"),
|
||||||
|
"content"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generics must still be preserved
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_summary("expected Vec<String>"),
|
||||||
|
"expected Vec<String>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_status_display_label_readable() {
|
||||||
|
use super::status_display_label;
|
||||||
|
|
||||||
|
assert_eq!(status_display_label(RunStatus::Ok), "Completed");
|
||||||
|
assert_eq!(status_display_label(RunStatus::Failed), "Failed");
|
||||||
|
assert_eq!(
|
||||||
|
status_display_label(RunStatus::Attention),
|
||||||
|
"Needs attention"
|
||||||
|
);
|
||||||
|
assert_eq!(status_display_label(RunStatus::Running), "Running");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_notification_message_uses_readable_status() {
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
|
let notify = NotifyConfig {
|
||||||
|
on_success: true,
|
||||||
|
on_failure: true,
|
||||||
|
on_attention: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
super::send_notification(
|
||||||
|
&tx,
|
||||||
|
¬ify,
|
||||||
|
"user-1",
|
||||||
|
"my-routine",
|
||||||
|
RunStatus::Ok,
|
||||||
|
Some("All good"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let msg = rx.recv().await.expect("should receive notification");
|
||||||
|
assert!(
|
||||||
|
msg.content.contains("Completed"),
|
||||||
|
"Notification should use readable label 'Completed', got: {}",
|
||||||
|
msg.content
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!msg.content.contains(": ok"),
|
||||||
|
"Notification should not contain raw lowercase status"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_notification_includes_job_id_in_metadata() {
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
|
let notify = NotifyConfig {
|
||||||
|
on_failure: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let job_id = uuid::Uuid::new_v4();
|
||||||
|
|
||||||
|
super::send_notification(
|
||||||
|
&tx,
|
||||||
|
¬ify,
|
||||||
|
"user-1",
|
||||||
|
"my-routine",
|
||||||
|
RunStatus::Failed,
|
||||||
|
Some("something broke"),
|
||||||
|
None,
|
||||||
|
Some(job_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let msg = rx.recv().await.expect("should receive notification");
|
||||||
|
let meta_job_id = msg.metadata["job_id"]
|
||||||
|
.as_str()
|
||||||
|
.expect("metadata should contain job_id");
|
||||||
|
assert_eq!(meta_job_id, job_id.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_notification_omits_job_id_when_none() {
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
|
let notify = NotifyConfig {
|
||||||
|
on_success: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
super::send_notification(
|
||||||
|
&tx,
|
||||||
|
¬ify,
|
||||||
|
"user-1",
|
||||||
|
"my-routine",
|
||||||
|
RunStatus::Ok,
|
||||||
|
Some("done"),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let msg = rx.recv().await.expect("should receive notification");
|
||||||
|
assert!(
|
||||||
|
msg.metadata.get("job_id").is_none(),
|
||||||
|
"metadata should not contain job_id when None"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_notification_truncates_long_summary() {
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
let (tx, mut rx) = mpsc::channel(1);
|
||||||
|
let notify = NotifyConfig {
|
||||||
|
on_failure: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let long_summary = "z".repeat(1000);
|
||||||
|
super::send_notification(
|
||||||
|
&tx,
|
||||||
|
¬ify,
|
||||||
|
"user-1",
|
||||||
|
"my-routine",
|
||||||
|
RunStatus::Failed,
|
||||||
|
Some(&long_summary),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let msg = rx.recv().await.expect("should receive notification");
|
||||||
|
// The sanitized summary should be truncated to ~500 chars + "..."
|
||||||
|
// The full message includes icon + routine name + label, so just check
|
||||||
|
// it doesn't contain the full 1000-char string.
|
||||||
|
assert!(
|
||||||
|
!msg.content.contains(&long_summary),
|
||||||
|
"Notification should truncate long summaries"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
msg.content.contains("..."),
|
||||||
|
"Truncated notification should contain ellipsis"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -981,7 +981,7 @@ mod tests {
|
|||||||
assert!(alice_stats.last_active_at.is_some());
|
assert!(alice_stats.last_active_at.is_some());
|
||||||
|
|
||||||
// Bob has no LLM calls so doesn't appear in summary stats
|
// Bob has no LLM calls so doesn't appear in summary stats
|
||||||
assert!(stats.iter().find(|s| s.user_id == "bob").is_none());
|
assert!(!stats.iter().any(|s| s.user_id == "bob"));
|
||||||
|
|
||||||
// Filter to single user
|
// Filter to single user
|
||||||
let alice_only = db.user_summary_stats(Some("alice")).await.unwrap();
|
let alice_only = db.user_summary_stats(Some("alice")).await.unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user