Compare commits

..
Author SHA1 Message Date
Tanish Manku 3399b8185b delete PageEdit dead code and migrate shared types to ui-pagedraw
PageEdit was never rendered in the app. Removes all PageEdit files (~4000 lines)
and migrates the shared types (BaseSelectorImage, ImageBundle) and HistoryStack
component to @storyteller/ui-pagedraw which already owns the canonical definitions.
2026-04-01 09:27:39 -07:00
26 changed files with 350 additions and 1269 deletions
-27
View File
@@ -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`)
-11
View File
@@ -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),
}
}
}
@@ -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
}
}
}
]
+85 -49
View File
@@ -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) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.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 &notification.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) = &notification.http_method {
parts.push(format!("HTTP Method: {}", method));
}
if let Some(path) = &notification.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) = &notification.user_token {
parts.push(format!("User Token: {}", user_token));
}
if let Some(media_file_token) = &notification.media_file_token {
parts.push(format!("Media File Token: {}", media_file_token));
}
if let Some(inference_job_token) = &notification.inference_job_token {
parts.push(format!("Inference Job Token: {}", inference_job_token));
}
if let Some(third_party_id) = &notification.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) = &notification.http_method {
labels.push(("http_method".to_string(), method.clone()));
}
if let Some(path) = &notification.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!("{}...", &notification.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)
}
@@ -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;
@@ -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()
@@ -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).
@@ -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
}
@@ -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,4 +1,3 @@
mod check_advanced_common_web_error;
mod check_common_web_error;
mod check_status_code_fallback;
@@ -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>
-193
View File
@@ -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()