mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 15:55:54 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3399b8185b |
@@ -11,33 +11,6 @@
|
||||
- Fields that are optional use the `maybe_` prefix: `maybe_creator_user_token`, `maybe_prompt_token`
|
||||
- When implementing `fmt::Display` for error types, use the pattern: `write!(f, "{:?}", self)`
|
||||
|
||||
## File Layout (Top-to-Bottom Reading Order)
|
||||
|
||||
Organize code so a human reading top-to-bottom encounters the most important things first and
|
||||
can drill down into details only when curious. Think of it like a newspaper: headline first,
|
||||
then the story, then the fine print.
|
||||
|
||||
### Ordering within a file
|
||||
|
||||
1. **Constants** at the top, right after imports.
|
||||
2. **The primary public type** (the main struct/enum the file is about) comes next.
|
||||
A reader opening this file should immediately see what it defines.
|
||||
3. **Supporting/subordinate types** (sub-structs, helper enums, serialization DTOs) go
|
||||
below the primary type, not above it. Ask: "Would a reader need to know this type
|
||||
exists before seeing the main type?" — if no, push it down.
|
||||
4. **Type ordering for API types**: Request, then Response (success), then Error.
|
||||
5. **`impl` blocks** after their type definitions.
|
||||
|
||||
### Ordering within an `impl` block
|
||||
|
||||
1. **Constructors** (`new`, `from_*`, `builder`) at the top.
|
||||
2. **Public methods** next — these are the main entry points a reader cares about.
|
||||
3. **Private helper methods** below public methods.
|
||||
- Among helpers: place "meaty" helpers (substantial logic, direct continuations of the
|
||||
public entry point) above "leaf" helpers (small formatters, simple lookups).
|
||||
- A method that calls another method in the same file should appear *above* the method
|
||||
it calls. This lets readers follow the call chain downward.
|
||||
|
||||
## Naming
|
||||
|
||||
- Handler functions: `{verb}_{noun}_handler` (e.g. `get_health_check_handler`, `login_handler`)
|
||||
|
||||
@@ -62,14 +62,3 @@ artcraft/
|
||||
- A mix of wreq and reqwest for Rust HTTP clients
|
||||
- TypeScript with Nx, React, Vite, Zustand, and Three.js
|
||||
- Use two spaces for indentation
|
||||
|
||||
### File Layout
|
||||
|
||||
Organize for top-to-bottom reading. Important things first, details later.
|
||||
|
||||
- **Constants** at the top (after imports)
|
||||
- **Structs/enums** next; outer structs above inner sub-structs
|
||||
- **API types** in order: Request, Response, Error
|
||||
- **In impl blocks**: constructors first, then public methods, then private helpers
|
||||
- Private helpers go *below* the methods that call them
|
||||
- Among helpers: meatier logic above leaf-level formatters
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use std::error::Error;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use wreq::StatusCode;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Seedance2ProSpecificApiError {
|
||||
/// The session cookies are expired or invalid.
|
||||
@@ -10,12 +8,6 @@ pub enum Seedance2ProSpecificApiError {
|
||||
|
||||
/// The video generation request was flagged as a content violation.
|
||||
VideoGenerationViolation(String),
|
||||
|
||||
/// The account does not have enough credits to perform the operation.
|
||||
BillingError {
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Error for Seedance2ProSpecificApiError {}
|
||||
@@ -25,7 +17,6 @@ impl Display for Seedance2ProSpecificApiError {
|
||||
match self {
|
||||
Self::UnauthorizedSessionExpired => write!(f, "Unauthorized: session cookies expired or invalid."),
|
||||
Self::VideoGenerationViolation(body) => write!(f, "Video generation violation: {}", body),
|
||||
Self::BillingError { status_code, body } => write!(f, "Billing error (status {}): {}", status_code, body),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -5,7 +5,6 @@ use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
|
||||
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
|
||||
use crate::requests::generate_video::request_types::*;
|
||||
use crate::requests::kinovi_host::{KinoviHost, resolve_host};
|
||||
use crate::utils::categorize_seedance2pro_error::categorize_seedance2pro_error;
|
||||
use crate::utils::common_headers::FIREFOX_USER_AGENT;
|
||||
use log::info;
|
||||
use wreq::Client;
|
||||
@@ -309,7 +308,10 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
|
||||
info!("Response status: {}, body: {}", status, response_body);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(categorize_seedance2pro_error(status, response_body));
|
||||
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code: status,
|
||||
body: response_body,
|
||||
}.into());
|
||||
}
|
||||
|
||||
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
use wreq::StatusCode;
|
||||
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
|
||||
use crate::error::seedance2pro_specific_api_error::Seedance2ProSpecificApiError;
|
||||
|
||||
/// Known error message substrings that indicate a billing/credits error.
|
||||
const BILLING_ERROR_MARKERS: &[&str] = &[
|
||||
"credits not enough",
|
||||
];
|
||||
|
||||
/// Categorize a non-success HTTP response into a specific or generic error.
|
||||
///
|
||||
/// Checks the response body for known patterns (e.g. billing errors) and returns
|
||||
/// a specific error variant when possible, falling back to
|
||||
/// `UncategorizedBadResponseWithStatusAndBody` otherwise.
|
||||
pub fn categorize_seedance2pro_error(
|
||||
status_code: StatusCode,
|
||||
body: String,
|
||||
) -> Seedance2ProError {
|
||||
if is_billing_error(&body) {
|
||||
return Seedance2ProSpecificApiError::BillingError {
|
||||
status_code,
|
||||
body,
|
||||
}.into();
|
||||
}
|
||||
|
||||
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
|
||||
status_code,
|
||||
body,
|
||||
}.into()
|
||||
}
|
||||
|
||||
/// Check if the response body contains a known billing error marker.
|
||||
fn is_billing_error(body: &str) -> bool {
|
||||
let body_lower = body.to_lowercase();
|
||||
BILLING_ERROR_MARKERS.iter().any(|marker| body_lower.contains(marker))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::error::seedance2pro_error::Seedance2ProError;
|
||||
|
||||
#[test]
|
||||
fn real_not_enough_credits_response() {
|
||||
let body = std::fs::read_to_string("test_data/responses/not_enough_credits.json")
|
||||
.expect("Failed to read test data file");
|
||||
|
||||
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
|
||||
|
||||
match error {
|
||||
Seedance2ProError::ApiSpecific(
|
||||
Seedance2ProSpecificApiError::BillingError { status_code, body }
|
||||
) => {
|
||||
assert_eq!(status_code, StatusCode::BAD_REQUEST);
|
||||
assert!(body.contains("credits not enough"));
|
||||
}
|
||||
other => panic!("Expected BillingError, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn billing_error_case_insensitive() {
|
||||
let body = r#"{"error":"Credits Not Enough"}"#.to_string();
|
||||
|
||||
let error = categorize_seedance2pro_error(StatusCode::BAD_REQUEST, body);
|
||||
|
||||
match error {
|
||||
Seedance2ProError::ApiSpecific(
|
||||
Seedance2ProSpecificApiError::BillingError { .. }
|
||||
) => {}
|
||||
other => panic!("Expected BillingError, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_error_falls_back_to_uncategorized() {
|
||||
let body = r#"{"error":"something else went wrong"}"#.to_string();
|
||||
|
||||
let error = categorize_seedance2pro_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
body.clone(),
|
||||
);
|
||||
|
||||
match error {
|
||||
Seedance2ProError::ApiGeneric(
|
||||
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body: b }
|
||||
) => {
|
||||
assert_eq!(status_code, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(b, body);
|
||||
}
|
||||
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_falls_back_to_uncategorized() {
|
||||
let error = categorize_seedance2pro_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
String::new(),
|
||||
);
|
||||
|
||||
match error {
|
||||
Seedance2ProError::ApiGeneric(
|
||||
Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody { .. }
|
||||
) => {}
|
||||
other => panic!("Expected UncategorizedBadResponseWithStatusAndBody, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
pub mod categorize_seedance2pro_error;
|
||||
pub mod common_headers;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
[
|
||||
{
|
||||
"error": {
|
||||
"json": {
|
||||
"message": "credits not enough",
|
||||
"code": -32600,
|
||||
"data": {
|
||||
"code": "BAD_REQUEST",
|
||||
"httpStatus": 400,
|
||||
"path": "workflow.runTask",
|
||||
"zodError": null
|
||||
},
|
||||
"stack": null
|
||||
},
|
||||
"meta": {
|
||||
"values": {
|
||||
"stack": [
|
||||
"undefined"
|
||||
]
|
||||
},
|
||||
"v": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -66,11 +66,7 @@ pub struct PageSentResult {
|
||||
pub short_id: Option<String>,
|
||||
}
|
||||
|
||||
// =============== Implementation ===============
|
||||
|
||||
impl PagerClient {
|
||||
// --- Constructor ---
|
||||
|
||||
pub fn new(
|
||||
client_config: PagerClientConfig,
|
||||
application_name: Option<String>,
|
||||
@@ -81,8 +77,6 @@ impl PagerClient {
|
||||
Self { client_config, application_name, environment, hostname, service_id }
|
||||
}
|
||||
|
||||
// --- Public interface ---
|
||||
|
||||
pub fn is_noop(&self) -> bool {
|
||||
matches!(self.client_config, PagerClientConfig::NoOp)
|
||||
}
|
||||
@@ -100,8 +94,6 @@ impl PagerClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private helpers (called by send_page) ---
|
||||
|
||||
async fn send_page_via_rootly(
|
||||
&self,
|
||||
notification: &NotificationDetails,
|
||||
@@ -123,13 +115,92 @@ impl PagerClient {
|
||||
|
||||
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
|
||||
|
||||
let labels = self.build_labels(notification);
|
||||
let mut labels: Vec<(String, String)> = Vec::new();
|
||||
|
||||
let description = notification.build_enriched_description(
|
||||
self.application_name.as_deref(),
|
||||
self.service_id.as_deref(),
|
||||
self.hostname.as_deref(),
|
||||
);
|
||||
if let Some(name) = &self.application_name {
|
||||
labels.push(("application_name".to_string(), name.clone()));
|
||||
}
|
||||
|
||||
if let Some(id) = &self.service_id {
|
||||
labels.push(("service_id".to_string(), id.clone()));
|
||||
}
|
||||
|
||||
if let Some(env) = &self.environment {
|
||||
labels.push(("environment".to_string(), env.clone()));
|
||||
}
|
||||
|
||||
if let Some(h) = &self.hostname {
|
||||
labels.push(("hostname".to_string(), h.clone()));
|
||||
}
|
||||
|
||||
if let Some(method) = ¬ification.http_method {
|
||||
labels.push(("http_method".to_string(), method.clone()));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.http_path {
|
||||
labels.push(("http_path".to_string(), path.clone()));
|
||||
}
|
||||
|
||||
if let Some(status_code) = notification.http_status_code {
|
||||
labels.push(("http_status_code".to_string(), status_code.to_string()));
|
||||
}
|
||||
|
||||
let labels = if labels.is_empty() { None } else { Some(labels) };
|
||||
|
||||
// Enrich the description with context and hostname if present.
|
||||
let description = match ¬ification.description {
|
||||
Some(desc) => {
|
||||
let mut parts = vec![desc.clone()];
|
||||
|
||||
match (&self.application_name, &self.service_id) {
|
||||
(Some(name), Some(id)) => {
|
||||
parts.push(format!("Application: {} (service_id: {})", name, id));
|
||||
}
|
||||
(Some(name), None) => {
|
||||
parts.push(format!("Application: {}", name));
|
||||
}
|
||||
(None, Some(id)) => {
|
||||
parts.push(format!("Service ID: {}", id));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
if let Some(method) = ¬ification.http_method {
|
||||
parts.push(format!("HTTP Method: {}", method));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.http_path {
|
||||
parts.push(format!("HTTP Path: {}", path));
|
||||
}
|
||||
|
||||
if let Some(status_code) = notification.http_status_code {
|
||||
parts.push(format!("HTTP Status Code: {}", status_code));
|
||||
}
|
||||
|
||||
if let Some(user_token) = ¬ification.user_token {
|
||||
parts.push(format!("User Token: {}", user_token));
|
||||
}
|
||||
|
||||
if let Some(media_file_token) = ¬ification.media_file_token {
|
||||
parts.push(format!("Media File Token: {}", media_file_token));
|
||||
}
|
||||
|
||||
if let Some(inference_job_token) = ¬ification.inference_job_token {
|
||||
parts.push(format!("Inference Job Token: {}", inference_job_token));
|
||||
}
|
||||
|
||||
if let Some(third_party_id) = ¬ification.third_party_id {
|
||||
parts.push(format!("Third Party ID: {}", third_party_id));
|
||||
}
|
||||
|
||||
if let Some(h) = &self.hostname {
|
||||
parts.push(format!("Hostname: {}", h));
|
||||
}
|
||||
|
||||
Some(parts.join("\n\n"))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
|
||||
let result = create_alert(CreateAlertArgs {
|
||||
@@ -170,39 +241,4 @@ impl PagerClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build structured labels from the client config and notification fields.
|
||||
fn build_labels(&self, notification: &NotificationDetails) -> Option<Vec<(String, String)>> {
|
||||
let mut labels: Vec<(String, String)> = Vec::new();
|
||||
|
||||
if let Some(name) = &self.application_name {
|
||||
labels.push(("application_name".to_string(), name.clone()));
|
||||
}
|
||||
|
||||
if let Some(id) = &self.service_id {
|
||||
labels.push(("service_id".to_string(), id.clone()));
|
||||
}
|
||||
|
||||
if let Some(env) = &self.environment {
|
||||
labels.push(("environment".to_string(), env.clone()));
|
||||
}
|
||||
|
||||
if let Some(h) = &self.hostname {
|
||||
labels.push(("hostname".to_string(), h.clone()));
|
||||
}
|
||||
|
||||
if let Some(method) = ¬ification.http_method {
|
||||
labels.push(("http_method".to_string(), method.clone()));
|
||||
}
|
||||
|
||||
if let Some(path) = ¬ification.http_path {
|
||||
labels.push(("http_path".to_string(), path.clone()));
|
||||
}
|
||||
|
||||
if let Some(status_code) = notification.http_status_code {
|
||||
labels.push(("http_status_code".to_string(), status_code.to_string()));
|
||||
}
|
||||
|
||||
if labels.is_empty() { None } else { Some(labels) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
maybe_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +222,6 @@ mod tests {
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
maybe_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +1,162 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use crate::notification::generate_deduplication_key::generate_deduplication_key;
|
||||
use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
/// Details for a pager notification.
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NotificationDetails {
|
||||
/// Title or summary of the alert.
|
||||
pub(crate) title: String,
|
||||
pub title: String,
|
||||
|
||||
/// Full details for the alert.
|
||||
pub(crate) description: Option<String>,
|
||||
|
||||
/// Urgency level for the notification.
|
||||
pub(crate) urgency: Option<NotificationUrgency>,
|
||||
pub description: Option<String>,
|
||||
|
||||
/// When the event occurred.
|
||||
pub(crate) event_time: DateTime<Utc>,
|
||||
|
||||
/// The error that triggered this notification, if any.
|
||||
pub(crate) maybe_error: Option<std::sync::Arc<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
|
||||
/// Whether this notification originated from an error.
|
||||
pub(crate) is_from_error: bool,
|
||||
pub event_time: DateTime<Utc>,
|
||||
|
||||
/// HTTP method associated with the event, if any.
|
||||
pub(crate) http_method: Option<String>,
|
||||
pub http_method: Option<String>,
|
||||
|
||||
/// HTTP endpoint path associated with the event, if any.
|
||||
pub(crate) http_path: Option<String>,
|
||||
pub http_path: Option<String>,
|
||||
|
||||
/// HTTP status code associated with the event, if any.
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
pub http_status_code: Option<u16>,
|
||||
|
||||
/// Whether this notification originated from an error.
|
||||
pub is_from_error: bool,
|
||||
|
||||
/// Urgency level for the notification.
|
||||
pub urgency: Option<NotificationUrgency>,
|
||||
|
||||
/// User token associated with the event, if any.
|
||||
pub(crate) user_token: Option<String>,
|
||||
pub user_token: Option<String>,
|
||||
|
||||
/// Media file token associated with the event, if any.
|
||||
pub(crate) media_file_token: Option<String>,
|
||||
pub media_file_token: Option<String>,
|
||||
|
||||
/// Inference job token associated with the event, if any.
|
||||
pub(crate) inference_job_token: Option<String>,
|
||||
pub inference_job_token: Option<String>,
|
||||
|
||||
/// Third-party identifier associated with the event, if any.
|
||||
pub(crate) third_party_id: Option<String>,
|
||||
pub third_party_id: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationDetails {
|
||||
|
||||
/// Return a key that can group notifications for deduplication,
|
||||
/// which will prevent spamming the same page over and over.
|
||||
pub fn to_deduplication_key(&self) -> String {
|
||||
generate_deduplication_key(self)
|
||||
}
|
||||
|
||||
// =============== Description Building ===============
|
||||
|
||||
/// Build the full enriched description for this notification.
|
||||
///
|
||||
/// Assembles atomic sections in a consistent order:
|
||||
/// 1. User-provided description
|
||||
/// 2. Error chain (if a boxed error is attached)
|
||||
/// 3. Event time
|
||||
/// 4. HTTP context
|
||||
/// 5. Service tokens
|
||||
/// 6. Application/service identity and hostname
|
||||
pub(crate) fn build_enriched_description(
|
||||
&self,
|
||||
application_name: Option<&str>,
|
||||
service_id: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut sections: Vec<String> = Vec::new();
|
||||
|
||||
// 1. User-provided description
|
||||
if let Some(desc) = &self.description {
|
||||
sections.push(desc.clone());
|
||||
}
|
||||
|
||||
// 2. Error chain
|
||||
if let Some(error_section) = self.format_error_chain() {
|
||||
sections.push(error_section);
|
||||
}
|
||||
|
||||
// 3. Event time
|
||||
sections.push(self.format_event_time());
|
||||
|
||||
// 4. HTTP context
|
||||
if let Some(http_section) = self.format_http_context() {
|
||||
sections.push(http_section);
|
||||
}
|
||||
|
||||
// 5. Service tokens
|
||||
if let Some(tokens_section) = self.format_service_tokens() {
|
||||
sections.push(tokens_section);
|
||||
}
|
||||
|
||||
// 6. Application/service identity and hostname
|
||||
if let Some(identity_section) = Self::format_app_identity(application_name, service_id, hostname) {
|
||||
sections.push(identity_section);
|
||||
}
|
||||
|
||||
if sections.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(sections.join("\n\n"))
|
||||
/// Create a notification with a title and description.
|
||||
pub fn with_title_and_description(title: String, description: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
description: Some(description),
|
||||
event_time: Utc::now(),
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the error chain from the boxed error, walking the source chain.
|
||||
fn format_error_chain(&self) -> Option<String> {
|
||||
let error = self.maybe_error.as_ref()?;
|
||||
/// Create a notification with just a title.
|
||||
pub fn with_title(title: String) -> Self {
|
||||
Self {
|
||||
title,
|
||||
description: None,
|
||||
event_time: Utc::now(),
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
/// Create a notification from any error type.
|
||||
///
|
||||
/// Formats the error into a structured description that includes:
|
||||
/// - The error message
|
||||
/// - The error's source chain (if any)
|
||||
/// - A backtrace (if available via `std::backtrace`)
|
||||
/// - The timestamp of the event
|
||||
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
|
||||
let title = format!("{}", error);
|
||||
|
||||
// Top-level error
|
||||
parts.push(format!("Error: {}", error));
|
||||
// Truncate the title to a reasonable length for alert titles.
|
||||
let title = if title.len() > 200 {
|
||||
format!("{}...", &title[..197])
|
||||
} else {
|
||||
title
|
||||
};
|
||||
|
||||
// Debug representation if it differs
|
||||
let event_time = Utc::now();
|
||||
|
||||
let mut description_parts: Vec<String> = Vec::new();
|
||||
|
||||
description_parts.push(format!("Event time: {}", event_time.format("%Y-%m-%d %H:%M:%S UTC")));
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Error: {}", error));
|
||||
|
||||
// Include the Debug representation if it differs from Display (often has more detail).
|
||||
let debug_repr = format!("{:?}", error);
|
||||
let display_repr = format!("{}", error);
|
||||
if debug_repr != display_repr {
|
||||
parts.push(format!("Debug: {}", debug_repr));
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Debug: {}", debug_repr));
|
||||
}
|
||||
|
||||
// Walk the source chain
|
||||
let mut depth = 0;
|
||||
let mut source = error.source();
|
||||
while let Some(cause) = source {
|
||||
depth += 1;
|
||||
parts.push(format!("Caused by ({depth}): {cause}"));
|
||||
// Attempt to walk the error source chain.
|
||||
// NB: We use the Debug trait here since we can't require std::error::Error
|
||||
// (that would require E: 'static + Error which is too restrictive for callers).
|
||||
|
||||
let cause_debug = format!("{:?}", cause);
|
||||
let cause_display = format!("{}", cause);
|
||||
if cause_debug != cause_display {
|
||||
parts.push(format!(" Debug ({depth}): {cause_debug}"));
|
||||
// Try to capture a backtrace from the current call site.
|
||||
#[cfg(feature = "backtrace")]
|
||||
{
|
||||
let bt = std::backtrace::Backtrace::capture();
|
||||
if bt.status() == std::backtrace::BacktraceStatus::Captured {
|
||||
description_parts.push(String::new());
|
||||
description_parts.push(format!("Backtrace:\n{}", bt));
|
||||
}
|
||||
|
||||
source = cause.source();
|
||||
}
|
||||
|
||||
Some(parts.join("\n"))
|
||||
let description = description_parts.join("\n");
|
||||
|
||||
Self {
|
||||
title,
|
||||
description: Some(description),
|
||||
event_time,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: true,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format the event time.
|
||||
fn format_event_time(&self) -> String {
|
||||
format!("Event time: {}", self.event_time.format("%Y-%m-%d %H:%M:%S UTC"))
|
||||
}
|
||||
|
||||
/// Format HTTP context (method, path, status code) if any fields are present.
|
||||
fn format_http_context(&self) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(method) = &self.http_method {
|
||||
parts.push(format!("HTTP Method: {}", method));
|
||||
/// Create a notification from an error, with a custom title prefix.
|
||||
pub fn from_error_with_context<E: Debug + Display>(context: &str, error: &E) -> Self {
|
||||
let mut notification = Self::from_error(error);
|
||||
notification.title = format!("{}: {}", context, notification.title);
|
||||
if notification.title.len() > 200 {
|
||||
notification.title = format!("{}...", ¬ification.title[..197]);
|
||||
}
|
||||
|
||||
if let Some(path) = &self.http_path {
|
||||
parts.push(format!("HTTP Path: {}", path));
|
||||
}
|
||||
|
||||
if let Some(status_code) = self.http_status_code {
|
||||
parts.push(format!("HTTP Status Code: {}", status_code));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n")) }
|
||||
}
|
||||
|
||||
/// Format service tokens (user, media file, inference job, third party) if any are present.
|
||||
fn format_service_tokens(&self) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(user_token) = &self.user_token {
|
||||
parts.push(format!("User Token: {}", user_token));
|
||||
}
|
||||
|
||||
if let Some(media_file_token) = &self.media_file_token {
|
||||
parts.push(format!("Media File Token: {}", media_file_token));
|
||||
}
|
||||
|
||||
if let Some(inference_job_token) = &self.inference_job_token {
|
||||
parts.push(format!("Inference Job Token: {}", inference_job_token));
|
||||
}
|
||||
|
||||
if let Some(third_party_id) = &self.third_party_id {
|
||||
parts.push(format!("Third Party ID: {}", third_party_id));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n")) }
|
||||
}
|
||||
|
||||
/// Format the application name, service ID, and hostname.
|
||||
fn format_app_identity(
|
||||
application_name: Option<&str>,
|
||||
service_id: Option<&str>,
|
||||
hostname: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
match (application_name, service_id) {
|
||||
(Some(name), Some(id)) => {
|
||||
parts.push(format!("Application: {} (service_id: {})", name, id));
|
||||
}
|
||||
(Some(name), None) => {
|
||||
parts.push(format!("Application: {}", name));
|
||||
}
|
||||
(None, Some(id)) => {
|
||||
parts.push(format!("Service ID: {}", id));
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
|
||||
if let Some(h) = hostname {
|
||||
parts.push(format!("Hostname: {}", h));
|
||||
}
|
||||
|
||||
if parts.is_empty() { None } else { Some(parts.join("\n")) }
|
||||
notification
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
use std::fmt::{Debug, Display};
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use crate::notification::notification_details::NotificationDetails;
|
||||
use crate::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
pub struct NotificationDetailsBuilder {
|
||||
title: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) urgency: Option<NotificationUrgency>,
|
||||
pub(crate) event_time: DateTime<Utc>,
|
||||
|
||||
pub(crate) maybe_error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
|
||||
pub(crate) is_from_error: bool,
|
||||
|
||||
pub(crate) http_method: Option<String>,
|
||||
pub(crate) http_path: Option<String>,
|
||||
pub(crate) http_status_code: Option<u16>,
|
||||
|
||||
pub(crate) user_token: Option<String>,
|
||||
pub(crate) media_file_token: Option<String>,
|
||||
pub(crate) inference_job_token: Option<String>,
|
||||
pub(crate) third_party_id: Option<String>,
|
||||
description: Option<String>,
|
||||
event_time: DateTime<Utc>,
|
||||
http_method: Option<String>,
|
||||
http_path: Option<String>,
|
||||
http_status_code: Option<u16>,
|
||||
is_from_error: bool,
|
||||
urgency: Option<NotificationUrgency>,
|
||||
user_token: Option<String>,
|
||||
media_file_token: Option<String>,
|
||||
inference_job_token: Option<String>,
|
||||
third_party_id: Option<String>,
|
||||
}
|
||||
|
||||
impl NotificationDetailsBuilder {
|
||||
@@ -31,37 +25,12 @@ impl NotificationDetailsBuilder {
|
||||
Self {
|
||||
title,
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
maybe_error: None,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: false,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a builder from a boxed error. Converts to `Arc` internally for shareability.
|
||||
pub fn from_boxed_error(error: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
|
||||
Self::from_error(Arc::from(error))
|
||||
}
|
||||
|
||||
/// Create a builder from an `Arc`'d error.
|
||||
pub fn from_error(error: Arc<dyn std::error::Error + Send + Sync + 'static>) -> Self {
|
||||
Self {
|
||||
title: "Notification from Error".to_string(),
|
||||
description: None,
|
||||
urgency: None,
|
||||
event_time: Utc::now(),
|
||||
maybe_error: Some(error),
|
||||
is_from_error: true,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
@@ -69,6 +38,29 @@ impl NotificationDetailsBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a builder from an error.
|
||||
///
|
||||
/// Sets `is_from_error` to true and derives the summary and description
|
||||
/// from the error, matching the behavior of `NotificationDetails::from_error`.
|
||||
pub fn from_error<E: Debug + Display>(error: &E) -> Self {
|
||||
// TODO(bt,2026-03-30): Clean this up
|
||||
let details = NotificationDetails::from_error(error);
|
||||
Self {
|
||||
title: details.title,
|
||||
description: details.description,
|
||||
event_time: details.event_time,
|
||||
http_method: None,
|
||||
http_path: None,
|
||||
http_status_code: None,
|
||||
is_from_error: true,
|
||||
urgency: None,
|
||||
user_token: None,
|
||||
media_file_token: None,
|
||||
inference_job_token: None,
|
||||
third_party_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_title(mut self, title: String) -> Self {
|
||||
self.title = title;
|
||||
self
|
||||
@@ -79,16 +71,6 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
|
||||
self.urgency = urgency;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_error(mut self, error: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
|
||||
self.maybe_error = error;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_http_method(mut self, http_method: Option<String>) -> Self {
|
||||
self.http_method = http_method;
|
||||
self
|
||||
@@ -104,6 +86,11 @@ impl NotificationDetailsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
|
||||
self.urgency = urgency;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
|
||||
self.user_token = user_token;
|
||||
self
|
||||
@@ -128,13 +115,12 @@ impl NotificationDetailsBuilder {
|
||||
NotificationDetails {
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
urgency: self.urgency,
|
||||
event_time: self.event_time,
|
||||
maybe_error: self.maybe_error,
|
||||
is_from_error: self.is_from_error,
|
||||
http_method: self.http_method,
|
||||
http_path: self.http_path,
|
||||
http_status_code: self.http_status_code,
|
||||
is_from_error: self.is_from_error,
|
||||
urgency: self.urgency,
|
||||
user_token: self.user_token,
|
||||
media_file_token: self.media_file_token,
|
||||
inference_job_token: self.inference_job_token,
|
||||
|
||||
@@ -11,9 +11,7 @@ pub fn alert_pager_and_return_err<T>(
|
||||
err: anyhow::Error,
|
||||
job: Option<&PendingSeedance2ProJob>,
|
||||
) -> anyhow::Result<T> {
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let mut builder = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
@@ -30,5 +28,5 @@ pub fn alert_pager_and_return_err<T>(
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(err_message))
|
||||
Err(err)
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ pub async fn process_failed_job(
|
||||
err,
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title("Seedance2Pro refund failed".to_string())
|
||||
.set_inference_job_token(Some(job.job_token.to_string()))
|
||||
.set_third_party_id(Some(job.order_id.to_string()))
|
||||
@@ -140,7 +140,7 @@ pub async fn process_failed_job(
|
||||
err
|
||||
);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title("Seedance2Pro mark job failed error".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
|
||||
@@ -9,9 +9,7 @@ pub fn alert_pager_and_return_err<T>(
|
||||
title: &str,
|
||||
err: anyhow::Error,
|
||||
) -> anyhow::Result<T> {
|
||||
let err_message = format!("{:#}", err);
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title(title.to_string())
|
||||
.set_urgency(Some(NotificationUrgency::Medium))
|
||||
.build();
|
||||
@@ -20,5 +18,5 @@ pub fn alert_pager_and_return_err<T>(
|
||||
error!("Failed to enqueue pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!(err_message))
|
||||
Err(err)
|
||||
}
|
||||
|
||||
-332
@@ -1,332 +0,0 @@
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::sync::Arc;
|
||||
|
||||
use actix_http::StatusCode;
|
||||
use actix_web::{HttpResponse, HttpResponseBuilder, ResponseError};
|
||||
|
||||
use crate::http_server::web_utils::user_session::require_user_session::RequireUserSessionError;
|
||||
|
||||
/// An error type for actix-web handlers that wraps causal errors for debugging
|
||||
/// and paging while presenting safe, generic HTTP responses to users.
|
||||
///
|
||||
/// ## Usage
|
||||
///
|
||||
/// ```ignore
|
||||
/// pub async fn my_handler(...) -> Result<Json<MyResponse>, AdvancedCommonWebError> {
|
||||
/// // sqlx errors, anyhow errors, session errors — all convert via ?
|
||||
/// let user = require_user_session_using_connection(&req, ...)?;
|
||||
/// let data = some_db_query(&pool).await?;
|
||||
///
|
||||
/// if data.is_none() {
|
||||
/// return Err(AdvancedCommonWebError::NotFound);
|
||||
/// }
|
||||
///
|
||||
/// Ok(Json(MyResponse { data }))
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// Errors converted via `From<T>` become `UncaughtServerError` (always 500).
|
||||
/// The wrapped cause is never shown to users but is available to the error
|
||||
/// alerting middleware for paging and logging.
|
||||
pub enum AdvancedCommonWebError {
|
||||
/// 400 Bad Request with a user-facing message.
|
||||
BadInputWithSimpleMessage(String),
|
||||
/// 401 Unauthorized.
|
||||
NotAuthorized,
|
||||
/// 404 Not Found.
|
||||
NotFound,
|
||||
/// 402 Payment Required.
|
||||
PaymentRequired,
|
||||
|
||||
/// Uncaught errors are always 500 Internal Server Error.
|
||||
/// The user will never see the error cause or message, but our
|
||||
/// middleware will handle alerting, logging, etc.
|
||||
///
|
||||
/// Stored in `Arc` so the error alerting middleware can clone it for paging
|
||||
/// without consuming the original.
|
||||
UncaughtServerError(Arc<dyn std::error::Error + Send + Sync + 'static>),
|
||||
}
|
||||
|
||||
// =============== Public accessors ===============
|
||||
|
||||
impl AdvancedCommonWebError {
|
||||
/// Wrap any error as an `UncaughtServerError`.
|
||||
pub fn from_error(error: impl std::error::Error + Send + Sync + 'static) -> Self {
|
||||
Self::UncaughtServerError(Arc::new(error))
|
||||
}
|
||||
|
||||
/// Extract the wrapped causal error (only present for `UncaughtServerError`).
|
||||
pub fn cause(&self) -> Option<&(dyn std::error::Error + Send + Sync + 'static)> {
|
||||
match self {
|
||||
Self::UncaughtServerError(err) => Some(err.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clone the wrapped causal error as an `Arc` (only present for `UncaughtServerError`).
|
||||
/// Useful for passing the error to the pager system without consuming the original.
|
||||
pub fn clone_cause_arc(&self) -> Option<Arc<dyn std::error::Error + Send + Sync + 'static>> {
|
||||
match self {
|
||||
Self::UncaughtServerError(err) => Some(Arc::clone(err)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a server error (500).
|
||||
pub fn is_server_error(&self) -> bool {
|
||||
matches!(self, Self::UncaughtServerError(_))
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Display / Debug / Error ===============
|
||||
|
||||
impl Display for AdvancedCommonWebError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(msg) => write!(f, "Bad input: {}", msg),
|
||||
Self::NotAuthorized => write!(f, "Not authorized"),
|
||||
Self::NotFound => write!(f, "Not found"),
|
||||
Self::PaymentRequired => write!(f, "Payment required"),
|
||||
Self::UncaughtServerError(err) => write!(f, "Server error: {}", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AdvancedCommonWebError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(msg) => write!(f, "BadInputWithSimpleMessage({:?})", msg),
|
||||
Self::NotAuthorized => write!(f, "NotAuthorized"),
|
||||
Self::NotFound => write!(f, "NotFound"),
|
||||
Self::PaymentRequired => write!(f, "PaymentRequired"),
|
||||
Self::UncaughtServerError(err) => write!(f, "UncaughtServerError({:?})", err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for AdvancedCommonWebError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::UncaughtServerError(err) => Some(err.as_ref()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Actix ResponseError ===============
|
||||
|
||||
impl ResponseError for AdvancedCommonWebError {
|
||||
fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(_) => StatusCode::BAD_REQUEST,
|
||||
Self::NotAuthorized => StatusCode::UNAUTHORIZED,
|
||||
Self::NotFound => StatusCode::NOT_FOUND,
|
||||
Self::PaymentRequired => StatusCode::PAYMENT_REQUIRED,
|
||||
Self::UncaughtServerError(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let status = self.status_code();
|
||||
|
||||
match self {
|
||||
Self::BadInputWithSimpleMessage(msg) => {
|
||||
HttpResponse::BadRequest()
|
||||
.json(JsonErrorWithMessage {
|
||||
success: false,
|
||||
error_code: status.as_u16(),
|
||||
error_code_str: status.canonical_reason(),
|
||||
message: msg,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
HttpResponseBuilder::new(status)
|
||||
.json(JsonErrorWithoutMessage {
|
||||
success: false,
|
||||
error_code: status.as_u16(),
|
||||
error_code_str: status.canonical_reason(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== From impls (automatic ? conversion) ===============
|
||||
|
||||
impl From<sqlx::Error> for AdvancedCommonWebError {
|
||||
fn from(err: sqlx::Error) -> Self {
|
||||
Self::from_error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AdvancedCommonWebError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
// anyhow::Error doesn't impl std::error::Error, so we go through Box -> Arc.
|
||||
let boxed: Box<dyn std::error::Error + Send + Sync> = err.into();
|
||||
Self::UncaughtServerError(Arc::from(boxed))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for AdvancedCommonWebError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::from_error(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RequireUserSessionError> for AdvancedCommonWebError {
|
||||
fn from(value: RequireUserSessionError) -> Self {
|
||||
match value {
|
||||
RequireUserSessionError::NotAuthorized => Self::NotAuthorized,
|
||||
RequireUserSessionError::ServerError => Self::from_error(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============== Serialization helpers (private) ===============
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonErrorWithoutMessage {
|
||||
success: bool,
|
||||
error_code: u16,
|
||||
error_code_str: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JsonErrorWithMessage<'a> {
|
||||
success: bool,
|
||||
error_code: u16,
|
||||
error_code_str: Option<&'static str>,
|
||||
message: &'a str,
|
||||
}
|
||||
|
||||
// =============== Tests ===============
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use actix_http::body::MessageBody;
|
||||
|
||||
#[test]
|
||||
fn bad_input_returns_400_with_message() {
|
||||
let error = AdvancedCommonWebError::BadInputWithSimpleMessage("name is required".to_string());
|
||||
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
|
||||
assert!(!error.is_server_error());
|
||||
assert!(error.cause().is_none());
|
||||
|
||||
let response = error.error_response();
|
||||
let bytes = response.into_body().try_into_bytes().unwrap();
|
||||
let body = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(body.contains("\"message\":\"name is required\""));
|
||||
assert!(body.contains("\"error_code\":400"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_found_returns_404() {
|
||||
let error = AdvancedCommonWebError::NotFound;
|
||||
assert_eq!(error.status_code(), StatusCode::NOT_FOUND);
|
||||
assert!(!error.is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_authorized_returns_401() {
|
||||
let error = AdvancedCommonWebError::NotAuthorized;
|
||||
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn payment_required_returns_402() {
|
||||
let error = AdvancedCommonWebError::PaymentRequired;
|
||||
assert_eq!(error.status_code(), StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncaught_io_error_returns_500_and_hides_cause() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk exploded");
|
||||
let error = AdvancedCommonWebError::from_error(io_err);
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
|
||||
// Cause is accessible for middleware
|
||||
let cause = error.cause().unwrap();
|
||||
assert_eq!(format!("{}", cause), "disk exploded");
|
||||
|
||||
// But never shown to the user in the HTTP response
|
||||
let response = error.error_response();
|
||||
let bytes = response.into_body().try_into_bytes().unwrap();
|
||||
let body = String::from_utf8(bytes.to_vec()).unwrap();
|
||||
assert!(!body.contains("disk exploded"));
|
||||
assert!(body.contains("\"error_code\":500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anyhow_error_converts_to_500_via_from() {
|
||||
let error: AdvancedCommonWebError = anyhow::anyhow!("something broke in the pipeline").into();
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
|
||||
let cause = error.cause().unwrap();
|
||||
assert!(format!("{}", cause).contains("something broke in the pipeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_anyhow_error_preserves_context_chain() {
|
||||
let inner = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
|
||||
let with_context = anyhow::Error::new(inner).context("failed to connect to database");
|
||||
let error: AdvancedCommonWebError = with_context.into();
|
||||
|
||||
assert!(error.is_server_error());
|
||||
let cause = error.cause().unwrap();
|
||||
let display = format!("{}", cause);
|
||||
assert!(display.contains("failed to connect to database"));
|
||||
|
||||
// The original io error should be in the source chain
|
||||
let source = cause.source();
|
||||
assert!(source.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_not_authorized_maps_to_401() {
|
||||
let error: AdvancedCommonWebError = RequireUserSessionError::NotAuthorized.into();
|
||||
assert_eq!(error.status_code(), StatusCode::UNAUTHORIZED);
|
||||
assert!(!error.is_server_error());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn require_user_session_server_error_wraps_cause() {
|
||||
let error: AdvancedCommonWebError = RequireUserSessionError::ServerError.into();
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
assert!(error.cause().is_some());
|
||||
assert!(format!("{}", error.cause().unwrap()).contains("ServerError"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_json_error_converts_to_500() {
|
||||
let bad_json = "not json at all{{{";
|
||||
let serde_err: serde_json::Error = serde_json::from_str::<serde_json::Value>(bad_json).unwrap_err();
|
||||
let error: AdvancedCommonWebError = serde_err.into();
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(error.is_server_error());
|
||||
let cause = error.cause().unwrap();
|
||||
assert!(format!("{}", cause).contains("expected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_source_returns_wrapped_error() {
|
||||
let io_err = std::io::Error::new(std::io::ErrorKind::Other, "root cause");
|
||||
let error = AdvancedCommonWebError::from_error(io_err);
|
||||
|
||||
let source = std::error::Error::source(&error);
|
||||
assert!(source.is_some());
|
||||
assert_eq!(format!("{}", source.unwrap()), "root cause");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_server_errors_have_no_source() {
|
||||
assert!(std::error::Error::source(&AdvancedCommonWebError::NotFound).is_none());
|
||||
assert!(std::error::Error::source(&AdvancedCommonWebError::NotAuthorized).is_none());
|
||||
assert!(std::error::Error::source(&AdvancedCommonWebError::PaymentRequired).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
pub mod advanced_common_web_error;
|
||||
pub mod common_web_error;
|
||||
pub mod media;
|
||||
pub mod media_file_origin_details;
|
||||
|
||||
+2
-2
@@ -222,7 +222,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
Err(err) => {
|
||||
warn!("Regular session fallback also failed for user {:?}: {:?}", user_token, err);
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(
|
||||
NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title("Seedance 2.0 generation failed (whitelist + fallback)".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
@@ -256,7 +256,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
|
||||
Err(err) => {
|
||||
warn!("Error calling seedance2pro generate_video: {:?}", err);
|
||||
if let Err(page_err) = server_state.pager.enqueue_page(
|
||||
NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
NotificationDetailsBuilder::from_error(&err)
|
||||
.set_title("Seedance 2.0 generation failed".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build()
|
||||
|
||||
+17
-24
@@ -45,8 +45,6 @@ impl ResponseError for FalWebhookError {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for FalWebhookError {}
|
||||
|
||||
// NB: Not using derive_more::Display since Clion doesn't understand it.
|
||||
impl fmt::Display for FalWebhookError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
@@ -119,32 +117,27 @@ pub async fn fal_webhook_handler(
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(success) => Ok(success),
|
||||
Err(err) => {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
if let Err(ref err) = result {
|
||||
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
|
||||
|
||||
let description = format!(
|
||||
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
|
||||
request_id, err, raw_body,
|
||||
);
|
||||
let notification = NotificationDetailsBuilder::from_error(err)
|
||||
.set_title("FAL webhook processing failed".to_string())
|
||||
.set_description(Some(format!(
|
||||
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
|
||||
request_id, err, raw_body,
|
||||
)))
|
||||
.set_third_party_id(Some(request_id.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
|
||||
.set_title("FAL webhook processing failed".to_string())
|
||||
.set_description(Some(description))
|
||||
.set_third_party_id(Some(request_id.to_string()))
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.set_http_method(Some(http_request.method().to_string()))
|
||||
.set_http_path(Some(http_request.path().to_string()))
|
||||
.build();
|
||||
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
|
||||
}
|
||||
|
||||
Err(FalWebhookError::ServerError)
|
||||
if let Err(pager_err) = server_state.pager.enqueue_page(notification) {
|
||||
error!("Failed to enqueue FAL webhook pager alert: {:?}", pager_err);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Send a pager alert for early parse failures (before we have a request_id).
|
||||
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
use log::{debug, warn};
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
|
||||
use pager::notification::notification_urgency::NotificationUrgency;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
|
||||
/// Check `AdvancedCommonWebError` and alert on uncaught server errors.
|
||||
/// Returns `true` if the error was handled (alerted or intentionally skipped).
|
||||
pub(super) fn check_advanced_common_web_error(
|
||||
pager: &Pager,
|
||||
method: &str,
|
||||
path: &str,
|
||||
error: &AdvancedCommonWebError,
|
||||
) -> bool {
|
||||
if !error.is_server_error() {
|
||||
// Non-500 errors (400, 401, 404, 402) are intentional — don't alert.
|
||||
return true;
|
||||
}
|
||||
|
||||
let title = format!("UncaughtServerError on {} {}", method, path);
|
||||
|
||||
// Clone the Arc'd causal error so the notification owns a reference to it.
|
||||
let cause_arc = error.clone_cause_arc();
|
||||
|
||||
let mut builder = if let Some(arc_err) = cause_arc {
|
||||
NotificationDetailsBuilder::from_error(arc_err)
|
||||
.set_title(title)
|
||||
} else {
|
||||
NotificationDetailsBuilder::from_title(title)
|
||||
};
|
||||
|
||||
builder = builder
|
||||
.set_http_method(Some(method.to_string()))
|
||||
.set_http_path(Some(path.to_string()))
|
||||
.set_http_status_code(Some(500))
|
||||
.set_urgency(Some(NotificationUrgency::Medium));
|
||||
|
||||
let notification = builder.build();
|
||||
|
||||
if let Err(err) = pager.enqueue_page(notification) {
|
||||
warn!("Error alerting middleware: failed to enqueue page: {:?}", err);
|
||||
} else {
|
||||
debug!("Error alerting middleware: enqueued alert for AdvancedCommonWebError::UncaughtServerError");
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
-14
@@ -6,10 +6,8 @@ use actix_web::Error;
|
||||
|
||||
use pager::client::pager::Pager;
|
||||
|
||||
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::state::flags::paging_flags::PagingFlags;
|
||||
use super::check_advanced_common_web_error::check_advanced_common_web_error;
|
||||
use super::check_common_web_error::check_common_web_error;
|
||||
use super::check_status_code_fallback::check_status_code_fallback;
|
||||
|
||||
@@ -123,12 +121,6 @@ fn check_ok_response_for_alerts<B>(
|
||||
if let Some(err) = response.response().error() {
|
||||
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
|
||||
|
||||
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
|
||||
if check_advanced_common_web_error(pager, method, path, advanced_err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(common_err) = err.as_error::<CommonWebError>() {
|
||||
if check_common_web_error(pager, method, path, common_err) {
|
||||
return;
|
||||
@@ -149,12 +141,6 @@ fn check_err_for_alerts(
|
||||
) {
|
||||
// --- Typed matchers (add new check_*.rs modules and downcast branches here) ---
|
||||
|
||||
if let Some(advanced_err) = err.as_error::<AdvancedCommonWebError>() {
|
||||
if check_advanced_common_web_error(pager, method, path, advanced_err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(common_err) = err.as_error::<CommonWebError>() {
|
||||
if check_common_web_error(pager, method, path, common_err) {
|
||||
return;
|
||||
|
||||
-1
@@ -1,4 +1,3 @@
|
||||
mod check_advanced_common_web_error;
|
||||
mod check_common_web_error;
|
||||
mod check_status_code_fallback;
|
||||
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
|
||||
Ok(_) => {},
|
||||
}
|
||||
|
||||
let notification = NotificationDetailsBuilder::from_boxed_error(database_error.into())
|
||||
let notification = NotificationDetailsBuilder::from_error(&database_error)
|
||||
.set_title("DB health check thread failed check".to_string())
|
||||
.set_urgency(Some(NotificationUrgency::High))
|
||||
.build();
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
faBomb,
|
||||
faCircleExclamation,
|
||||
faTriangleExclamation,
|
||||
faCopy,
|
||||
faCheck,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import {
|
||||
@@ -224,7 +222,7 @@ const InProgressCard = ({
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -304,7 +302,7 @@ const CompletedCard = ({
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
@@ -346,14 +344,6 @@ const FailedCard = ({
|
||||
task: FailedTask;
|
||||
onDismiss?: () => void;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [marqueePlaying, setMarqueePlaying] = useState(false);
|
||||
const [promptOverflows, setPromptOverflows] = useState(false);
|
||||
const promptRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
if (el) setPromptOverflows(el.scrollWidth > el.clientWidth);
|
||||
}, [task.prompt]);
|
||||
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
|
||||
const hasRefImages =
|
||||
task.refImageUrls && task.refImageUrls.length > 0;
|
||||
@@ -464,73 +454,19 @@ const FailedCard = ({
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{task.prompt && (
|
||||
<Tooltip
|
||||
content={task.prompt.length > 300 ? task.prompt.slice(0, 300) + "…" : task.prompt}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="max-w-[280px] text-wrap text-xs"
|
||||
zIndex={50}
|
||||
delay={400}
|
||||
>
|
||||
<div
|
||||
className="mt-1 overflow-hidden"
|
||||
onMouseEnter={promptOverflows ? () => setMarqueePlaying(true) : undefined}
|
||||
onMouseLeave={promptOverflows ? () => setMarqueePlaying(false) : undefined}
|
||||
>
|
||||
<div
|
||||
ref={promptRef}
|
||||
key={marqueePlaying ? "playing" : "idle"}
|
||||
className="whitespace-nowrap text-[11px] italic text-base-fg/40"
|
||||
style={marqueePlaying ? {
|
||||
animation: "marquee 8s linear infinite",
|
||||
animationDelay: "0.5s",
|
||||
animationFillMode: "both",
|
||||
} : undefined}
|
||||
>
|
||||
{task.prompt}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{task.prompt && (
|
||||
<Tooltip
|
||||
content={copied ? "Copied!" : "Copy prompt"}
|
||||
position="bottom"
|
||||
strategy="fixed"
|
||||
className="text-xs"
|
||||
zIndex={50}
|
||||
delay={300}
|
||||
>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Copy prompt"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(task.prompt!);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 3000);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className={copied ? "text-green-400" : ""} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
className="ml-auto h-6 w-6 rounded-full p-1 text-base-fg/60 hover:bg-ui-controls"
|
||||
aria-label="Dismiss"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,15 +22,10 @@ export default {
|
||||
"0%": { backgroundPosition: "200% 0" },
|
||||
"100%": { backgroundPosition: "-200% 0" },
|
||||
},
|
||||
marquee: {
|
||||
"0%": { transform: "translateX(0%)" },
|
||||
"100%": { transform: "translateX(-100%)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
fadeIn: "fadeIn 0.2s ease-in-out",
|
||||
shimmer: "shimmer 2s ease-in-out infinite",
|
||||
marquee: "marquee 8s linear infinite paused",
|
||||
},
|
||||
},
|
||||
colors: {
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
EnqueueImageToVideoRequest,
|
||||
} from "@storyteller/tauri-api";
|
||||
import {
|
||||
faMessageXmark,
|
||||
faMessageCheck,
|
||||
faWaveformLines,
|
||||
faClock,
|
||||
faTriangleExclamation,
|
||||
@@ -40,17 +42,6 @@ import { twMerge } from "tailwind-merge";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { GenerationProvider } from "@storyteller/api-enums";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__storeTaskEnqueueMeta?: (meta: {
|
||||
prompt?: string;
|
||||
refImageUrls?: string[];
|
||||
modelType?: string;
|
||||
timestamp: number;
|
||||
}) => void;
|
||||
}
|
||||
}
|
||||
|
||||
type GROK_ASPECT_RATIO = "landscape" | "portrait" | "square";
|
||||
|
||||
const DEFAULT_RESOLUTIONS: SizeOption[] = [
|
||||
@@ -115,6 +106,8 @@ export const PromptBoxVideo = ({
|
||||
const [content, setContent] = useState<React.ReactNode>(null);
|
||||
const prompt = usePromptVideoStore((s) => s.prompt);
|
||||
const setPrompt = usePromptVideoStore((s) => s.setPrompt);
|
||||
const useSystemPrompt = usePromptVideoStore((s) => s.useSystemPrompt);
|
||||
const setUseSystemPrompt = usePromptVideoStore((s) => s.setUseSystemPrompt);
|
||||
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
|
||||
const setGenerateWithSound = usePromptVideoStore(
|
||||
(s) => s.setGenerateWithSound,
|
||||
@@ -231,67 +224,52 @@ export const PromptBoxVideo = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Sync duration with model default when switching models.
|
||||
// Read duration from the store directly to avoid stale closure issues
|
||||
// when the model and duration are updated together (e.g. during recreate).
|
||||
// Sync duration with model default when switching models
|
||||
useEffect(() => {
|
||||
const currentDuration = usePromptVideoStore.getState().duration;
|
||||
if (selectedModel?.durationOptions && selectedModel.defaultDuration) {
|
||||
if (
|
||||
currentDuration === null ||
|
||||
!selectedModel.durationOptions.includes(currentDuration)
|
||||
duration === null ||
|
||||
!selectedModel.durationOptions.includes(duration)
|
||||
) {
|
||||
setDuration(selectedModel.defaultDuration);
|
||||
}
|
||||
} else if (currentDuration !== null) {
|
||||
} else if (duration !== null) {
|
||||
setDuration(null);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Sync resolution with model default when switching models.
|
||||
// Read from store directly to avoid stale closure (same as duration above).
|
||||
// Sync resolution with model default when switching models
|
||||
useEffect(() => {
|
||||
const currentResolution = usePromptVideoStore.getState().resolution;
|
||||
if (selectedModel?.resolutionOptions && selectedModel.defaultResolution) {
|
||||
if (
|
||||
!selectedModel.resolutionOptions.includes(currentResolution as string)
|
||||
) {
|
||||
if (!selectedModel.resolutionOptions.includes(resolution as string)) {
|
||||
setResolution(selectedModel.defaultResolution);
|
||||
}
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Reset input mode when switching to a model that doesn't support reference.
|
||||
// Read from store directly to avoid stale closure (same as duration above).
|
||||
// Reset input mode when switching to a model that doesn't support reference
|
||||
useEffect(() => {
|
||||
const currentInputMode = usePromptVideoStore.getState().inputMode;
|
||||
if (
|
||||
!selectedModel?.supportsReferenceMode &&
|
||||
currentInputMode === "reference"
|
||||
) {
|
||||
if (!selectedModel?.supportsReferenceMode && inputMode === "reference") {
|
||||
setInputMode("keyframe");
|
||||
setReferenceVideos([]);
|
||||
setReferenceAudios([]);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Reset generation count when switching away from seedance 2.0.
|
||||
// Read from store directly to avoid stale closure (same as duration above).
|
||||
// Reset generation count when switching away from seedance 2.0
|
||||
useEffect(() => {
|
||||
const currentGenerationCount =
|
||||
usePromptVideoStore.getState().generationCount;
|
||||
if (selectedModel?.id !== "seedance_2p0" && currentGenerationCount > 1) {
|
||||
if (selectedModel?.id !== "seedance_2p0" && generationCount > 1) {
|
||||
setGenerationCount(1);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
const durationRange = selectedModel?.durationOptions?.length
|
||||
? {
|
||||
min: selectedModel.durationOptions[0]!,
|
||||
max: selectedModel.durationOptions[
|
||||
selectedModel.durationOptions.length - 1
|
||||
]!,
|
||||
}
|
||||
min: selectedModel.durationOptions[0]!,
|
||||
max: selectedModel.durationOptions[
|
||||
selectedModel.durationOptions.length - 1
|
||||
]!,
|
||||
}
|
||||
: null;
|
||||
const effectiveDuration = duration ?? selectedModel?.defaultDuration ?? 5;
|
||||
const [localDuration, setLocalDuration] = useState(effectiveDuration);
|
||||
@@ -308,9 +286,9 @@ export const PromptBoxVideo = ({
|
||||
const resolutionPickerOptions: PopoverItem[] | null =
|
||||
selectedModel?.resolutionOptions
|
||||
? selectedModel.resolutionOptions.map((r) => ({
|
||||
label: r,
|
||||
selected: r === resolution,
|
||||
}))
|
||||
label: r,
|
||||
selected: r === resolution,
|
||||
}))
|
||||
: null;
|
||||
|
||||
const handleResolutionSelect = (selectedItem: PopoverItem) => {
|
||||
@@ -320,17 +298,17 @@ export const PromptBoxVideo = ({
|
||||
const inputModeOptions: PopoverItem[] | null =
|
||||
selectedModel?.supportsReferenceMode
|
||||
? [
|
||||
{
|
||||
label: "Keyframe",
|
||||
description: "First/Last frame",
|
||||
selected: inputMode === "keyframe",
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
description: "Multi-media ref",
|
||||
selected: inputMode === "reference",
|
||||
},
|
||||
]
|
||||
{
|
||||
label: "Keyframe",
|
||||
description: "First/Last frame",
|
||||
selected: inputMode === "keyframe",
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
description: "Multi-media ref",
|
||||
selected: inputMode === "reference",
|
||||
},
|
||||
]
|
||||
: null;
|
||||
|
||||
const handleInputModeSelect = (selectedItem: PopoverItem) => {
|
||||
@@ -435,26 +413,26 @@ export const PromptBoxVideo = ({
|
||||
|
||||
const mentionItems = isReferenceMode
|
||||
? [
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined as string | undefined,
|
||||
})),
|
||||
].filter((item) =>
|
||||
mentionFilter
|
||||
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
...referenceImages.map((img, i) => ({
|
||||
label: `@Image${i + 1}`,
|
||||
type: "image" as const,
|
||||
preview: img.url,
|
||||
})),
|
||||
...referenceVideos.map((vid, i) => ({
|
||||
label: `@Video${i + 1}`,
|
||||
type: "video" as const,
|
||||
preview: vid.url,
|
||||
})),
|
||||
...referenceAudios.map((_aud, i) => ({
|
||||
label: `@Audio${i + 1}`,
|
||||
type: "audio" as const,
|
||||
preview: undefined as string | undefined,
|
||||
})),
|
||||
].filter((item) =>
|
||||
mentionFilter
|
||||
? item.label.toLowerCase().includes(mentionFilter.toLowerCase())
|
||||
: true,
|
||||
)
|
||||
: [];
|
||||
|
||||
const insertMention = (label: string) => {
|
||||
@@ -525,9 +503,7 @@ export const PromptBoxVideo = ({
|
||||
return;
|
||||
}
|
||||
if (prompt.length > maxLen) {
|
||||
toast.error(
|
||||
`Prompt exceeds the ${maxLen} character limit for this model`,
|
||||
);
|
||||
toast.error(`Prompt exceeds the ${maxLen} character limit for this model`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -645,20 +621,24 @@ export const PromptBoxVideo = ({
|
||||
|
||||
window.__storeTaskEnqueueMeta?.({
|
||||
prompt,
|
||||
refImageUrls: referenceImages?.map((img) => img.url).filter(Boolean),
|
||||
refImageUrls: referenceImages
|
||||
?.map((img) => img.url)
|
||||
.filter(Boolean),
|
||||
modelType: (selectedModel as any)?.tauriId || String(selectedModel),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const subscriberIds: string[] = [];
|
||||
const enqueuePromises: Promise<unknown>[] = [];
|
||||
const enqueuePromises: Promise<void>[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const subscriberId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2);
|
||||
subscriberIds.push(subscriberId);
|
||||
enqueuePromises.push(EnqueueImageToVideo(buildRequest(subscriberId)));
|
||||
enqueuePromises.push(
|
||||
EnqueueImageToVideo(buildRequest(subscriberId)) as Promise<void>,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -924,9 +904,7 @@ export const PromptBoxVideo = ({
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
<span
|
||||
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
|
||||
>
|
||||
<span className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}>
|
||||
{prompt.length} / {maxLen}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1003,6 +981,7 @@ export const PromptBoxVideo = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
|
||||
{selectedModel?.generateWithSound && (
|
||||
<Tooltip
|
||||
content={generateWithSound ? "Sound: ON" : "Sound: OFF"}
|
||||
@@ -1034,6 +1013,7 @@ export const PromptBoxVideo = ({
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{modelNeedsAnImageButNoneAreSelected && (
|
||||
@@ -1063,9 +1043,7 @@ export const PromptBoxVideo = ({
|
||||
onClick={handleEnqueue}
|
||||
disabled={!prompt.trim()}
|
||||
loading={isEnqueueing}
|
||||
credits={
|
||||
credits != null ? credits * generationCount : credits
|
||||
}
|
||||
credits={credits != null ? credits * generationCount : credits}
|
||||
>
|
||||
Generate
|
||||
</GenerateButton>
|
||||
@@ -1074,20 +1052,13 @@ export const PromptBoxVideo = ({
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute -bottom-1 left-1/2 -translate-x-1/2">
|
||||
<Tooltip
|
||||
content={isExpanded ? "Collapse" : "Expand"}
|
||||
position="top"
|
||||
className="-mb-2"
|
||||
>
|
||||
<Tooltip content={isExpanded ? "Collapse" : "Expand"} position="top" className="-mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleExpand}
|
||||
className="text-base-fg/30 hover:text-base-fg/90 transition-colors px-3 py-0.5"
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isExpanded ? faChevronUp : faChevronDown}
|
||||
className="text-xs"
|
||||
/>
|
||||
<FontAwesomeIcon icon={isExpanded ? faChevronUp : faChevronDown} className="text-xs" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Back up a single production database table to a local SQL file using mysqldump.
|
||||
|
||||
Reads connection info from secrets.toml (same format as the other database scripts).
|
||||
|
||||
Usage:
|
||||
python mysql_dump_table.py --table=users
|
||||
python mysql_dump_table.py --table=media_files --directory=/tmp/backups
|
||||
|
||||
Requirements:
|
||||
pip install pymysql
|
||||
mysqldump must be on PATH
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
import pymysql
|
||||
except ImportError:
|
||||
print("pymysql is required: pip install pymysql")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
SECRETS_PATH = Path(__file__).parent / "secrets.toml"
|
||||
|
||||
DEFAULT_BACKUP_DIR = Path.home() / "database" / "backups"
|
||||
|
||||
ALLOWED_TABLES = [
|
||||
"audit_logs",
|
||||
"batch_generations",
|
||||
"favorites",
|
||||
"featured_items",
|
||||
"generic_inference_jobs",
|
||||
"google_sign_in_accounts",
|
||||
"media_files",
|
||||
"model_weights",
|
||||
"prompt_context_items",
|
||||
"prompts",
|
||||
"tag_uses",
|
||||
"tags",
|
||||
"user_bookmarks",
|
||||
"user_ratings",
|
||||
"user_roles",
|
||||
"user_sessions",
|
||||
"user_stripe_customer_links",
|
||||
"user_subscriptions",
|
||||
"users",
|
||||
"wallet_ledger_entries",
|
||||
"wallets",
|
||||
]
|
||||
|
||||
|
||||
def load_config():
|
||||
if not SECRETS_PATH.exists():
|
||||
print(f"Error: secrets.toml not found at {SECRETS_PATH}")
|
||||
print(f"Copy secrets.toml.example to secrets.toml and fill in the connection URL.")
|
||||
sys.exit(1)
|
||||
|
||||
with open(SECRETS_PATH, "rb") as f:
|
||||
config = tomllib.load(f)
|
||||
url = config["database"]["url"]
|
||||
parsed = urlparse(url)
|
||||
return {
|
||||
"host": parsed.hostname,
|
||||
"port": parsed.port or 3306,
|
||||
"user": parsed.username,
|
||||
"password": parsed.password,
|
||||
"database": parsed.path.lstrip("/"),
|
||||
}
|
||||
|
||||
|
||||
def connect(config):
|
||||
return pymysql.connect(
|
||||
host=config["host"],
|
||||
port=config["port"],
|
||||
user=config["user"],
|
||||
password=config["password"],
|
||||
database=config["database"],
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
|
||||
def table_exists(conn, table_name):
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SHOW TABLES LIKE %s", (table_name,))
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def count_rows(conn, table_name):
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(f"SELECT COUNT(*) AS cnt FROM `{table_name}`")
|
||||
result = cursor.fetchone()
|
||||
return result["cnt"]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Back up a database table using mysqldump.")
|
||||
parser.add_argument("--table", required=True, help="Name of the table to back up.")
|
||||
parser.add_argument("--directory", default=None, help=f"Directory to save the dump. Default: {DEFAULT_BACKUP_DIR}")
|
||||
args = parser.parse_args()
|
||||
|
||||
table_name = args.table
|
||||
backup_dir = Path(args.directory) if args.directory else DEFAULT_BACKUP_DIR
|
||||
|
||||
# Validate table is in allowed list
|
||||
if table_name not in ALLOWED_TABLES:
|
||||
print(f"Error: '{table_name}' is not in the allowed tables list.")
|
||||
print(f"Allowed tables: {', '.join(sorted(ALLOWED_TABLES))}")
|
||||
sys.exit(1)
|
||||
|
||||
# Check mysqldump is available
|
||||
if shutil.which("mysqldump") is None:
|
||||
print("Error: mysqldump not found on PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Verify table exists and count rows
|
||||
conn = connect(config)
|
||||
try:
|
||||
if not table_exists(conn, table_name):
|
||||
print(f"Error: table '{table_name}' does not exist in database '{config['database']}'.")
|
||||
sys.exit(1)
|
||||
|
||||
row_count = count_rows(conn, table_name)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Build output path
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
|
||||
output_file = backup_dir / f"{table_name}_{timestamp}.sql"
|
||||
|
||||
# Confirm with user
|
||||
print(f"Table: {table_name}")
|
||||
print(f"Database: {config['database']} @ {config['host']}:{config['port']}")
|
||||
print(f"Rows: {row_count:,}")
|
||||
print(f"Output: {output_file}")
|
||||
print()
|
||||
|
||||
confirm = input("Proceed with backup? [y/N] ").strip().lower()
|
||||
if confirm not in ("y", "yes"):
|
||||
print("Aborted.")
|
||||
sys.exit(0)
|
||||
|
||||
# Run mysqldump
|
||||
cmd = [
|
||||
"mysqldump",
|
||||
f"--host={config['host']}",
|
||||
f"--port={config['port']}",
|
||||
f"--user={config['user']}",
|
||||
f"--password={config['password']}",
|
||||
"--single-transaction",
|
||||
"--routines",
|
||||
"--triggers",
|
||||
"--set-gtid-purged=OFF",
|
||||
config["database"],
|
||||
table_name,
|
||||
]
|
||||
|
||||
print(f"\nRunning mysqldump for '{table_name}'...")
|
||||
|
||||
with open(output_file, "w") as f:
|
||||
result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Error: mysqldump failed (exit code {result.returncode})")
|
||||
if result.stderr:
|
||||
# Filter out the password warning which is expected
|
||||
for line in result.stderr.strip().splitlines():
|
||||
if "Using a password on the command line" not in line:
|
||||
print(f" {line}")
|
||||
sys.exit(1)
|
||||
|
||||
file_size = output_file.stat().st_size
|
||||
if file_size < 100:
|
||||
print(f"Warning: output file is suspiciously small ({file_size} bytes).")
|
||||
|
||||
print(f"Done. Backup saved to: {output_file} ({file_size:,} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user