Compare commits

...
Author SHA1 Message Date
Brandon Thomas bc32d7c2ee cleanup 2026-04-02 00:31:49 -04:00
Brandon Thomas bf0703f0c1 refactor web errors with cause + notification details 2026-04-02 00:20:23 -04:00
Brandon Thomas c7943bce59 AdvancedCommonWebError with wrapped errors 2026-04-01 23:41:51 -04:00
Brandon Thomas c5f62daa2b categorize seedance credits error (#1285) 2026-04-01 22:51:25 -04:00
bflatastic 7122e4507f fixed issue where duration isn't passed through when clicking on recreate 2026-04-02 09:35:44 +07:00
Tanish Manku 238cb10499 Add copy prompt button and prompt preview to failed task notifications (#1284)
- Copy button on failed task cards copies the prompt to clipboard with a
3s check/green feedback
- Prompt preview row with marquee scroll on hover (only when text
overflows), tooltip showing full prompt up to 300 chars
- Fix dismiss and action button icon centering across all card types
(InProgress, Completed, Failed)
- Add marquee keyframe animation to tailwind.config.ts
2026-04-01 19:08:43 -07:00
Brandon Thomas 1ebb124f05 cleaned up pager (#1283) 2026-04-01 22:00:38 -04:00
Brandon Thomas e7bb263755 mysql backup tool 2026-04-01 13:15:27 -04:00
Tanish Manku bbfea2ae96 Code cleanup: Delete PageEdit dead code and migrate shared types to ui-pagedraw (#1281)
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:29:17 -07:00
BFlat 3bc55c761c footnote update pricing apge 2026-04-01 22:45:42 +07:00
BFlat 8348a91693 show seedance in pricing table 2026-04-01 21:55:11 +07:00
BFlat d1adc0b179 website footer reddit link 2026-04-01 21:51:53 +07:00
BFlat c0ac3cbc9c pricing plan description updated 2026-04-01 20:03:59 +07:00
Brandon Thomas 0a13d36070 no more "free user downgrade" since we're not giving away nano banana… (#1279)
… pro anymore
2026-04-01 03:00:00 -04:00
Brandon Thomas 8662e79824 use tested typesafe fal bindings in webhook (#1278)
wip - still needs tested extractors for image, video, etc.
2026-04-01 02:46:21 -04:00
Brandon Thomas dc30cf9669 fal webhooks - tested cases for error extraction (#1277) 2026-04-01 02:21:44 -04:00
Tanish Manku b09a0fd854 Drag-N-Drop assets into Artcraft to upload them (#1231) 2026-03-31 21:05:38 -04:00
Brandon Thomas 400bccc155 more claude config files (#1276) 2026-03-31 19:22:45 -04:00
Tanish Manku 11011dacaf 2D: Add blank canvas option with preset sizes (#1275) 2026-03-31 15:28:22 -07:00
Tanish Manku 2f73e861d1 2D: Make PageDraw a self-contained lib (#1244)
Moves PageDraw from artcraft app to a component lib, leaving an adapter
implementation and import behind instead
2026-03-31 14:43:21 -07:00
Brandon Thomas 0151a2e28d fix password null bytes paging (#1273) 2026-03-31 14:55:10 -04:00
Brandon Thomas 12d3f82fe2 fal handle webhook errors (#1272) 2026-03-31 10:50:52 -04:00
Brandon Thomas 4489c90125 fal webhook and seedance job alerting with additonal context (#1267) 2026-03-31 06:22:27 -04:00
Brandon Thomas fd54477f73 page on seedance jobs (#1266)
also fix threading on video-thumbnail-job
2026-03-31 05:01:12 -04:00
Brandon Thomas c093603b3d config crates for sharing between services (#1265) 2026-03-31 04:01:34 -04:00
174 changed files with 5121 additions and 6316 deletions
+47
View File
@@ -0,0 +1,47 @@
# API Conventions
## HTTP Handlers (Actix-Web)
Handlers in `storyteller_web` follow this structure:
1. **Request/Response types** with `#[derive(Deserialize/Serialize, ToSchema)]`
2. **Error enum** implementing `ResponseError`, `Display`, `Serialize`, `ToSchema`
3. **Handler function** annotated with `#[utoipa::path(...)]` for OpenAPI docs
4. Handler returns `Result<Json<Response>, ErrorType>`. Only use `Result<HttpResponse, ErrorType>` when you need to set headers/cookies.
### Error Types
- Use `CommonWebError` for simple handlers that only need standard HTTP error codes
- Use a custom error enum when you need endpoint-specific error variants (e.g. `LoginErrorType::InvalidCredentials`)
- Custom error enums MUST implement `ResponseError` (actix), `Display`, and `Serialize`
- When adding `From<RequireUserSessionError>` or similar conversions, implement them on `CommonWebError` in its own module (orphan rule)
- Error response bodies include: `success: false`, `error_code`, `error_code_str`
### Session/Auth
- `require_user_session_using_connection()` reuses an open DB connection (preferred)
- `require_user_session()` acquires its own connection (deprecated)
- `require_moderator()` for admin-only endpoints
### API Type Definitions
- Shared request/response types go in `artcraft_api_defs` crate (under `crates/api_clients/artcraft/artcraft_api_defs/`)
- Types that need `ResponseError` impl must stay in `storyteller_web` (orphan rule)
- New types must be added to `api_doc.rs` schemas (in storyteller-web) for Swagger generation
## Enums (Database-backed)
- Located in `crates/schema/public/enums/src/by_table/{table_name}/`
- Require manual `to_str()` and `from_str()` implementations
- Use `impl_enum_display_and_debug_using_to_str!` and `impl_mysql_enum_coders!` macros
- Add comprehensive tests: serialization, round-trip, variant count
- YOU CAN ADD NEW VALUES but DO NOT CHANGE EXISTING VALUES without a migration strategy
- Note that adding new values can break older versions of the Tauri desktop app that read
responses over Rust - make sure it consumes variants with an "Unknown(String)" variant
catch-all to future proof the APIs.
## Tokens (Primary Keys)
- Located in `crates/schema/public/tokens/src/tokens/`
- Opaque `String` wrappers with Stripe-like prefixes (e.g. `user_`, `mf_`)
- Use `impl_string_token!`, `impl_mysql_token_from_row!`, `impl_crockford_generator!` macros
+59
View File
@@ -0,0 +1,59 @@
# Code Style
## Rust
- Two spaces for indentation (not four)
- No minimum supported Rust version; use latest stable features freely
- `#[macro_use] extern crate serde_derive;` is used in binary crates; library crates use `use serde_derive::{Serialize, Deserialize};`
- Prefer `log` crate macros (`info!`, `warn!`, `error!`) over `println!`
- Use `anyhow::Result` / `AnyhowResult` for fallible functions in application code
- Enum Display/Debug: use `impl_enum_display_and_debug_using_to_str!` macro, not `derive_more::Display` (CLion doesn't understand it)
- 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`)
- Error enums: `{Feature}Error` (e.g. `FalWebhookError`, `HealthCheckError`)
- Request/response structs: `{Action}Request`, `{Action}Response`, `{Action}SuccessResponse`
- Path params: `{Action}PathInfo`
- Builder structs: `{Thing}Builder` with `set_` prefixed setters and a `build()` method
## Imports
- Group imports: std, external crates, internal workspace crates, `crate::` imports
- Use fully qualified paths for one-off references; `use` for repeated references
- Prefer specific imports over wildcards, except in `api_doc.rs` and test modules
## TypeScript / Frontend
- TypeScript with React, Vite, Zustand, Three.js
- Nx monorepo under `crates/frontend/`
- Two spaces for indentation
+27
View File
@@ -0,0 +1,27 @@
# Testing
## Running Tests
- Most crates: `cargo test -p {crate_name}`
- Crates using SQLx offline mode: `SQLX_OFFLINE=true cargo test -p {crate_name}`
- The `storyteller-web` and `mysql_queries` crates require `SQLX_OFFLINE=true` for compilation
- Use `cargo check -p {crate_name}` for fast compilation checks without running tests
## Test Patterns
- Tests live in `#[cfg(test)] mod tests { ... }` at the bottom of each file
- Group related tests in sub-modules: `mod explicit_checks`, `mod mechanical_checks`
- Enum tests should cover: serialization, deserialization, `to_str`/`from_str` round-trip, variant count
- Password/crypto tests: be mindful of computation cost, don't add too many bcrypt test cases
## What to Test
- New error types and their mappings
- Enum variant serialization stability (these are stored in the database)
- Hash/deduplication functions with known inputs
- Edge cases in parsers (empty input, missing fields, malformed data)
## What Not to Test
- Don't add integration tests that require a running database unless explicitly asked
- Don't add tests for simple struct construction or trivial getters
+11
View File
@@ -62,3 +62,14 @@ 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
Generated
+26
View File
@@ -5163,6 +5163,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-types"
version = "2.12.0"
@@ -9620,6 +9626,10 @@ dependencies = [
"workspace-build-acceleration",
]
[[package]]
name = "rootly_config"
version = "0.0.1"
[[package]]
name = "rsa"
version = "0.9.6"
@@ -10195,12 +10205,16 @@ dependencies = [
"jobs_common",
"log",
"mysql_queries",
"pager",
"reqwest 0.12.23",
"rootly_client",
"rootly_config",
"seedance2pro_client",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tokens",
"tokio",
@@ -10701,6 +10715,13 @@ dependencies = [
"keccak",
]
[[package]]
name = "shared_env_var_config"
version = "0.0.1"
dependencies = [
"easyenv",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -11278,6 +11299,7 @@ dependencies = [
"resend-rs",
"reusable_types",
"rootly_client",
"rootly_config",
"rust-s3",
"rustls 0.23.36",
"seedance2pro_client",
@@ -11285,6 +11307,7 @@ dependencies = [
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"speculoos",
"sqlx",
"strum",
@@ -11829,6 +11852,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http 1.3.1",
"http-range",
"jni 0.21.1",
"libc",
"log",
@@ -13362,10 +13386,12 @@ dependencies = [
"mysql_queries",
"pager",
"rootly_client",
"rootly_config",
"serde",
"serde_derive",
"serde_json",
"server_environment",
"shared_env_var_config",
"sqlx",
"tempdir",
"tokio",
+7 -1
View File
@@ -101,6 +101,10 @@ members = [
# Schema (buckets): S3/R2 topology
"crates/schema/buckets/bucket_paths",
# Schema (config): configuration constants for external services
"crates/schema/config/rootly_config",
"crates/schema/config/shared_env_var_config",
# Schema (database): queries, database layouts, Redis keys, etc.
"crates/schema/database/elasticsearch_schema",
"crates/schema/database/migration",
@@ -242,6 +246,8 @@ file_to_base64_url = { path = "crates/lib/files/file_to_base64_url" }
filesys = { path = "crates/lib/files/filesys" }
grok_client = { path = "crates/api_clients/grok_client" }
rootly_client = { path = "crates/api_clients/rootly_client" }
rootly_config = { path = "crates/schema/config/rootly_config" }
shared_env_var_config = { path = "crates/schema/config/shared_env_var_config" }
http_headers = { path = "crates/lib/http_headers" }
images = { path = "crates/lib/files/images" }
jobs_common = { path = "crates/lib/jobs_common" }
@@ -321,7 +327,7 @@ sha2 = "0.11.0-rc.5"
sha3 = "0.10.8"
speculoos = "0.11.0"
strum = { version = "0.28.0", features = ["derive"] }
tauri = { version = "2.8.3", features = ["unstable", "devtools"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri = { version = "2.8.3", features = ["unstable", "devtools", "protocol-asset"] } # NB: "devtools" prevents App Store distribution; "ubstable" is needed for window access
tauri-plugin-dialog = "2.3.3"
tauri-plugin-fs = "2.4.2"
tauri-plugin-log = "2.6.0"
@@ -0,0 +1,22 @@
# artcraft_api_defs
Shared HTTP API type definitions used by both the server (`storyteller_web`) and clients.
## Structure
Organized by feature: `users/`, `media_file/`, `jobs/`, `moderation/`, etc.
## Conventions
- Request types: `#[derive(Deserialize, ToSchema)]`
- Response types: `#[derive(Serialize, ToSchema)]`
- Path params: `#[derive(Deserialize, ToSchema)]` with `PathInfo` suffix
- Query params: `#[derive(Deserialize, IntoParams)]`
## Orphan Rule
Types that need `ResponseError` impl (actix-web) CANNOT live here because of Rust's orphan rule. Keep error response types in `storyteller_web` and only put data types here.
## Dependencies
Only `serde`, `serde_derive`, `utoipa`, `chrono`, `url`, and internal schema crates (`enums`, `tokens`). No actix-web dependency.
+1
View File
@@ -5,3 +5,4 @@ pub mod error;
pub mod model;
pub mod requests;
pub mod utils;
pub mod webhook_api;
@@ -0,0 +1,3 @@
pub mod parse_webhook_inner_payload;
pub mod parse_webhook_payload;
pub mod payload;
@@ -0,0 +1,240 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
use crate::webhook_api::payload::webhook_inner_payload::{ErrorData, PayloadErrorData, SuccessData, WebhookInnerPayload};
use crate::webhook_api::payload::webhook_payload::{WebhookPayload, WebhookStatus};
/// Parse the inner payload of a FAL webhook into one of three cases.
///
/// 1. If status is ERROR, parse out the first `detail` entry's `msg` and `type`.
/// 2. If status is OK but there's no payload and there is a `payload_error`, return PayloadError.
/// 3. Otherwise, return Success with the payload.
pub fn parse_webhook_inner_payload(webhook: &WebhookPayload) -> WebhookInnerPayload {
match webhook.status {
WebhookStatus::Error => {
let (message, error_type) = extract_error_first_detail(&webhook.payload);
WebhookInnerPayload::Error(ErrorData {
message,
error_type,
})
}
WebhookStatus::Ok => {
// Check for payload_error case: status=OK but no payload, has payload_error.
if webhook.payload.is_none() {
if let Some(ref payload_error) = webhook.payload_error {
return WebhookInnerPayload::PayloadError(PayloadErrorData {
payload_error: payload_error.clone(),
});
}
}
WebhookInnerPayload::Success(SuccessData {
payload: webhook.payload.clone().unwrap_or(Value::Null),
})
}
}
}
/// Extract the first `msg` and `type` from `payload.detail[]`.
fn extract_error_first_detail(payload: &Option<Value>) -> (Option<String>, Option<WebhookErrorType>) {
let payload = match payload {
Some(p) => p,
None => return (None, None),
};
let detail_array = match payload.get("detail").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return (None, None),
};
let first = match detail_array.first() {
Some(item) => item,
None => return (None, None),
};
let message = first.get("msg").and_then(|v| v.as_str()).map(|s| s.to_string());
let error_type = first
.get("type")
.and_then(|v| v.as_str())
.map(WebhookErrorType::from_str);
(message, error_type)
}
#[cfg(test)]
mod tests {
use super::*;
fn load_test_webhook(filename: &str) -> WebhookPayload {
let path = format!("test_data/webhooks/{}", filename);
let json = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("Failed to read {}: {}", path, e));
serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("Failed to parse {}: {}", path, e))
}
#[test]
fn gpt_image_1p5_content_policy_violation() {
let webhook = load_test_webhook("gpt_image_1p5_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn gpt_image_invalid_api_key() {
let webhook = load_test_webhook("gpt_image_fail_invalid_api_key.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::InvalidApiKey),
);
assert_eq!(
data.message.as_deref(),
Some("Invalid API key"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_1p6_pro_file_too_large() {
let webhook = load_test_webhook("kling_1p6_pro_file_too_large_error.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::FileTooLarge),
);
assert_eq!(
data.message.as_deref(),
Some("File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn kling_3p0_pro_content_policy_violation() {
let webhook = load_test_webhook("kling_3p0_pro_fail_content_policy.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::ContentPolicyViolation),
);
assert_eq!(
data.message.as_deref(),
Some("The content could not be processed because it contained material flagged by a content checker."),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn nano_banana_pro_no_media_generated() {
let webhook = load_test_webhook("nano_banana_pro_fail_no_media_generated.json");
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(
data.error_type,
Some(WebhookErrorType::NoMediaGenerated),
);
assert!(
data.message.as_deref().unwrap().starts_with("The model did not generate"),
);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
#[test]
fn payload_error_case() {
let webhook = WebhookPayload {
request_id: "test-123".to_string(),
gateway_request_id: "test-123".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: None,
payload_error: Some("encoding error occurred".to_string()),
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::PayloadError(data) => {
assert_eq!(data.payload_error, "encoding error occurred");
}
other => panic!("Expected WebhookInnerPayload::PayloadError, got {:?}", other),
}
}
#[test]
fn success_case() {
let webhook = WebhookPayload {
request_id: "test-456".to_string(),
gateway_request_id: "test-456".to_string(),
status: WebhookStatus::Ok,
error: None,
payload: Some(serde_json::json!({"images": [{"url": "https://example.com/img.png"}]})),
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Success(data) => {
assert!(data.payload.get("images").is_some());
}
other => panic!("Expected WebhookInnerPayload::Success, got {:?}", other),
}
}
#[test]
fn error_with_no_payload() {
let webhook = WebhookPayload {
request_id: "test-789".to_string(),
gateway_request_id: "test-789".to_string(),
status: WebhookStatus::Error,
error: Some("Internal server error".to_string()),
payload: None,
payload_error: None,
};
let result = parse_webhook_inner_payload(&webhook);
match result {
WebhookInnerPayload::Error(data) => {
assert_eq!(data.message, None);
assert_eq!(data.error_type, None);
}
other => panic!("Expected WebhookInnerPayload::Error, got {:?}", other),
}
}
}
@@ -0,0 +1,5 @@
use crate::webhook_api::payload::webhook_payload::WebhookPayload;
pub fn parse_webhook_payload(json: &str) -> Result<WebhookPayload, serde_json::Error> {
serde_json::from_str(json)
}
@@ -0,0 +1,3 @@
pub mod webhook_inner_payload;
pub mod webhook_payload;
pub mod webhook_error_type;
@@ -0,0 +1,104 @@
use serde::Deserialize;
/// Machine-readable error type from FAL webhook error payloads.
///
/// These are the `type` field values from the `detail` array in FAL error responses.
/// See: https://fal.ai/docs/documentation/model-apis/errors
#[derive(Clone, Debug, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebhookErrorType {
FileTooLarge,
InternalServerError,
GenerationTimeout,
DownstreamServiceError,
DownstreamServiceUnavailable,
ContentPolicyViolation,
NoMediaGenerated,
ImageTooSmall,
ImageTooLarge,
ImageLoadError,
FileDownloadError,
FaceDetectionError,
GreaterThan,
GreaterThanEqual,
LessThan,
LessThanEqual,
MultipleOf,
SequenceTooShort,
SequenceTooLong,
OneOf,
FeatureNotSupported,
InvalidArchive,
ArchiveFileCountBelowMinimum,
ArchiveFileCountExceedsMaximum,
InvalidApiKey,
/// Catch-all for unrecognized error types from FAL.
#[serde(untagged)]
Unknown(String),
}
impl WebhookErrorType {
pub fn from_str(value: &str) -> Self {
serde_json::from_value(serde_json::Value::String(value.to_string()))
.unwrap_or_else(|_| Self::Unknown(value.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_known_types() {
let cases = vec![
("\"file_too_large\"", WebhookErrorType::FileTooLarge),
("\"internal_server_error\"", WebhookErrorType::InternalServerError),
("\"generation_timeout\"", WebhookErrorType::GenerationTimeout),
("\"downstream_service_error\"", WebhookErrorType::DownstreamServiceError),
("\"downstream_service_unavailable\"", WebhookErrorType::DownstreamServiceUnavailable),
("\"content_policy_violation\"", WebhookErrorType::ContentPolicyViolation),
("\"no_media_generated\"", WebhookErrorType::NoMediaGenerated),
("\"image_too_small\"", WebhookErrorType::ImageTooSmall),
("\"image_too_large\"", WebhookErrorType::ImageTooLarge),
("\"image_load_error\"", WebhookErrorType::ImageLoadError),
("\"file_download_error\"", WebhookErrorType::FileDownloadError),
("\"face_detection_error\"", WebhookErrorType::FaceDetectionError),
("\"greater_than\"", WebhookErrorType::GreaterThan),
("\"greater_than_equal\"", WebhookErrorType::GreaterThanEqual),
("\"less_than\"", WebhookErrorType::LessThan),
("\"less_than_equal\"", WebhookErrorType::LessThanEqual),
("\"multiple_of\"", WebhookErrorType::MultipleOf),
("\"sequence_too_short\"", WebhookErrorType::SequenceTooShort),
("\"sequence_too_long\"", WebhookErrorType::SequenceTooLong),
("\"one_of\"", WebhookErrorType::OneOf),
("\"feature_not_supported\"", WebhookErrorType::FeatureNotSupported),
("\"invalid_archive\"", WebhookErrorType::InvalidArchive),
("\"archive_file_count_below_minimum\"", WebhookErrorType::ArchiveFileCountBelowMinimum),
("\"archive_file_count_exceeds_maximum\"", WebhookErrorType::ArchiveFileCountExceedsMaximum),
("\"invalid_api_key\"", WebhookErrorType::InvalidApiKey),
];
for (json, expected) in cases {
let deserialized: WebhookErrorType = serde_json::from_str(json).unwrap();
assert_eq!(deserialized, expected, "failed for {}", json);
}
}
#[test]
fn deserialize_unknown_type() {
let deserialized: WebhookErrorType = serde_json::from_str("\"some_new_error_type\"").unwrap();
assert_eq!(deserialized, WebhookErrorType::Unknown("some_new_error_type".to_string()));
}
#[test]
fn from_str_known() {
assert_eq!(WebhookErrorType::from_str("content_policy_violation"), WebhookErrorType::ContentPolicyViolation);
assert_eq!(WebhookErrorType::from_str("face_detection_error"), WebhookErrorType::FaceDetectionError);
}
#[test]
fn from_str_unknown() {
assert_eq!(WebhookErrorType::from_str("brand_new_type"), WebhookErrorType::Unknown("brand_new_type".to_string()));
}
}
@@ -0,0 +1,32 @@
use serde_json::Value;
use crate::webhook_api::payload::webhook_error_type::WebhookErrorType;
/// The parsed inner payload of a FAL webhook.
#[derive(Debug)]
pub enum WebhookInnerPayload {
/// The webhook reported success and has a payload.
Success(SuccessData),
/// The webhook reported an error (status=ERROR) with optional detail info.
Error(ErrorData),
/// The webhook reported success but had no payload and instead had a payload_error.
PayloadError(PayloadErrorData),
}
#[derive(Debug)]
pub struct SuccessData {
pub payload: Value,
}
#[derive(Debug)]
pub struct ErrorData {
/// The first human-readable message from `payload.detail[].msg`, if any.
pub message: Option<String>,
/// The first machine-readable error type from `payload.detail[].type`, if any.
pub error_type: Option<WebhookErrorType>,
}
#[derive(Debug)]
pub struct PayloadErrorData {
pub payload_error: String,
}
@@ -0,0 +1,43 @@
use serde::Deserialize;
use serde_json::Value;
/// The value FAL sends to webhook endpoints.
#[derive(Deserialize, Debug, Clone)]
pub struct WebhookPayload {
/// Value originally used in the queue API
pub request_id: String,
/// Mostly the same as `request_id`, but if the request failed and was retried,
/// then gateway_request_id will have the value of the last tried request,
/// while request_id will be the value used in the queue API.
pub gateway_request_id: String,
/// Status of the request: OK or ERROR.
pub status: WebhookStatus,
/// When an error happens, the status will be ERROR.
/// The error property will contain a message and payload will contain the error details.
/// eg. ""Invalid status code: 422"
pub error: Option<String>,
/// Payload of the webhook (typically present)
/// THIS FIELD IS HIGHLY VARIABLE JSON.
/// This is present for both *success* and *failure* cases,
/// though the payload differs. Sometimes it is absent.
pub payload: Option<Value>,
/// In rare instances, if there was an error encoding the payload,
/// this may be set. This can seemingly be set on "OK" responses.
pub payload_error: Option<String>,
}
#[derive(Deserialize, Debug, Clone, Copy)]
pub enum WebhookStatus {
/// Success case
#[serde(alias = "OK")]
Ok,
/// Failure case
#[serde(alias = "ERROR")]
Error
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4756-130b-7141-a16c-8f411b785844",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": null,
"output_format": "png",
"prompt": "[redacted nsfw query]",
"quality": "medium"
},
"loc": [
"body",
"prompt"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4756-130b-7141-a16c-8f411b785844",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"payload": {
"detail": [
{
"input": {
"background": "auto",
"image_size": "1536x1024",
"input_fidelity": null,
"num_images": 1,
"openai_api_key": "REDACTED",
"output_format": "png",
"prompt": "[redacted]",
"quality": "high"
},
"loc": [
"body",
"openai_api_key"
],
"msg": "Invalid API key",
"type": "invalid_api_key",
"url": "https://docs.fal.ai/errors#invalid_api_key"
}
]
},
"request_id": "019d4771-5b51-7480-afc6-67e6e74b9f22",
"status": "ERROR"
}
@@ -0,0 +1,23 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"payload": {
"detail": [
{
"ctx": {
"max_size": 10485760
},
"input": "[redacted]",
"loc": [
"body",
"image_url"
],
"msg": "File size exceeds the maximum allowed size of 10485760 bytes. Please upload a smaller file.",
"type": "file_too_large",
"url": "https://docs.fal.ai/errors#file_too_large"
}
]
},
"request_id": "019d477e-c123-7120-81c7-30d6b58a6720",
"status": "ERROR"
}
@@ -0,0 +1,29 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"cfg_scale": 0.5,
"duration": "3",
"generate_audio": false,
"multi_prompt": null,
"negative_prompt": "blur, distort, and low quality",
"prompt": "[redacted prompt]",
"shot_type": "customize",
"voice_ids": null
},
"loc": [
"body"
],
"msg": "The content could not be processed because it contained material flagged by a content checker.",
"type": "content_policy_violation",
"url": "https://docs.fal.ai/errors#content_policy_violation"
}
]
},
"request_id": "019d4761-035d-7b50-8c42-1521efdd4f34",
"status": "ERROR"
}
@@ -0,0 +1,30 @@
{
"error": "Unexpected status code: 422",
"gateway_request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"payload": {
"detail": [
{
"input": {
"aspect_ratio": "16:9",
"enable_web_search": false,
"limit_generations": false,
"num_images": 1,
"output_format": "png",
"prompt": "[nsfw prompt redacted]",
"resolution": "1K",
"safety_tolerance": "4",
"seed": null,
"sync_mode": false
},
"loc": [
"body"
],
"msg": "The model did not generate the expected output for this prompt. This may occur for several reasons, including unsafe content, a prompt that is incompatible with the selected media type (e.g., prompting an image model to generate audio), references to missing attachments, or other cases where the input cannot be processed as the requested output type. Please review your inputs and try again. If the issue persists, contact support.",
"type": "no_media_generated",
"url": "https://docs.fal.ai/errors#no_media_generated"
}
]
},
"request_id": "019d475a-291a-7681-81dc-b27d2489beae",
"status": "ERROR"
}
@@ -1,6 +1,8 @@
use std::error::Error;
use std::fmt::{Display, Formatter};
use wreq::StatusCode;
#[derive(Debug)]
pub enum Seedance2ProSpecificApiError {
/// The session cookies are expired or invalid.
@@ -8,6 +10,12 @@ 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 {}
@@ -17,6 +25,7 @@ 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,6 +5,7 @@ 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;
@@ -308,10 +309,7 @@ pub async fn generate_video(args: GenerateVideoArgs<'_>) -> Result<GenerateVideo
info!("Response status: {}, body: {}", status, response_body);
if !status.is_success() {
return Err(Seedance2ProGenericApiError::UncategorizedBadResponseWithStatusAndBody {
status_code: status,
body: response_body,
}.into());
return Err(categorize_seedance2pro_error(status, response_body));
}
let batch_response: Vec<BatchResponseItem> = serde_json::from_str(&response_body)
@@ -0,0 +1,111 @@
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 +1,2 @@
pub mod categorize_seedance2pro_error;
pub mod common_headers;
@@ -0,0 +1,25 @@
[
{
"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
}
}
}
]
@@ -10,6 +10,7 @@
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-start-dragging",
"core:window:allow-set-focus",
"core:window:allow-toggle-maximize",
"core:window:default",
"dialog:default",
@@ -34,10 +34,6 @@ pub async fn setup_main_window(
let win_builder = win_builder
.decorations(false); // NB: This breaks Mac! (And breaks resize on Linux)
#[cfg(target_os = "windows")]
let win_builder = win_builder
.drag_and_drop(false); // TODO: Is this necessary on Windows?
let _window = win_builder.build()?;
Ok(())
+5
View File
@@ -14,6 +14,10 @@
"windows": [
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["**"]
},
"csp": {
"default-src": [
"'self'",
@@ -32,6 +36,7 @@
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"blob:",
"data:",
"http://ipc.localhost",
+40
View File
@@ -0,0 +1,40 @@
# Pager Crate
Alerting/paging system that sends notifications via Rootly.
## Key Types
- `Pager` - Main client, cloneable. Has `enqueue_page()` and `shutdown_worker()`
- `PagerBuilder` - Constructs a Pager with optional background worker
- `NotificationDetails` - Alert payload with title, description, urgency, HTTP context, entity tokens
- `NotificationDetailsBuilder` - Fluent builder for constructing notifications
- `NotificationUrgency` - High, Medium, Low
## Building Notifications
```rust
let notification = NotificationDetailsBuilder::from_title("Something happened".to_string())
.set_description(Some("Details...".to_string()))
.set_urgency(Some(NotificationUrgency::High))
.set_http_method(Some("POST".to_string()))
.set_http_path(Some("/v1/endpoint".to_string()))
.set_inference_job_token(Some(job_token.to_string()))
.build();
```
For errors, use `from_error()` which auto-populates title and description from the error:
```rust
NotificationDetailsBuilder::from_error(&err)
.set_title("Custom title".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build()
```
## Deduplication
`NotificationDetails::to_deduplication_key()` generates a SHA-256 hash for dedup. Same title + HTTP context + hour = same key.
## Worker Thread
The pager worker uses `Condvar::wait()` (blocking). It MUST run on a dedicated OS thread, not a tokio task.
+49 -69
View File
@@ -66,7 +66,11 @@ pub struct PageSentResult {
pub short_id: Option<String>,
}
// =============== Implementation ===============
impl PagerClient {
// --- Constructor ---
pub fn new(
client_config: PagerClientConfig,
application_name: Option<String>,
@@ -77,6 +81,8 @@ 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)
}
@@ -94,6 +100,8 @@ impl PagerClient {
}
}
// --- Private helpers (called by send_page) ---
async fn send_page_via_rootly(
&self,
notification: &NotificationDetails,
@@ -115,76 +123,13 @@ impl PagerClient {
debug!("Sending page via Rootly (source={}): {}", source, notification.title);
let mut labels: Vec<(String, String)> = Vec::new();
let labels = self.build_labels(notification);
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(h) = &self.hostname {
parts.push(format!("Hostname: {}", h));
}
Some(parts.join("\n\n"))
}
None => None,
};
let description = notification.build_enriched_description(
self.application_name.as_deref(),
self.service_id.as_deref(),
self.hostname.as_deref(),
);
// https://docs.rootly.com/api-reference/alerts/creates-an-alert
let result = create_alert(CreateAlertArgs {
@@ -225,4 +170,39 @@ 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) }
}
}
@@ -59,6 +59,11 @@ mod tests {
http_status_code: None,
is_from_error,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -214,6 +219,11 @@ mod tests {
http_status_code,
is_from_error,
urgency: None,
user_token: None,
media_file_token: None,
inference_job_token: None,
third_party_id: None,
maybe_error: None,
}
}
@@ -1,138 +1,220 @@
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(Clone, Debug)]
#[derive(Debug)]
pub struct NotificationDetails {
/// Title or summary of the alert.
pub title: String,
pub(crate) title: String,
/// Full details for the alert.
pub description: Option<String>,
/// When the event occurred.
pub event_time: DateTime<Utc>,
/// HTTP method associated with the event, if any.
pub http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub http_status_code: Option<u16>,
/// Whether this notification originated from an error.
pub is_from_error: bool,
pub(crate) description: Option<String>,
/// Urgency level for the notification.
pub urgency: Option<NotificationUrgency>,
pub(crate) urgency: Option<NotificationUrgency>,
/// 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,
/// HTTP method associated with the event, if any.
pub(crate) http_method: Option<String>,
/// HTTP endpoint path associated with the event, if any.
pub(crate) http_path: Option<String>,
/// HTTP status code associated with the event, if any.
pub(crate) http_status_code: Option<u16>,
/// User token associated with the event, if any.
pub(crate) user_token: Option<String>,
/// Media file token associated with the event, if any.
pub(crate) media_file_token: Option<String>,
/// Inference job token associated with the event, if any.
pub(crate) inference_job_token: Option<String>,
/// Third-party identifier associated with the event, if any.
pub(crate) 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)
}
/// 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,
}
}
// =============== Description Building ===============
/// 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,
}
}
/// Create a notification from any error type.
/// Build the full enriched description for this notification.
///
/// 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);
/// 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();
// Truncate the title to a reasonable length for alert titles.
let title = if title.len() > 200 {
format!("{}...", &title[..197])
// 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 {
title
};
Some(sections.join("\n\n"))
}
}
let event_time = Utc::now();
/// 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()?;
let mut description_parts: Vec<String> = Vec::new();
let mut 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));
// Top-level error
parts.push(format!("Error: {}", error));
// Include the Debug representation if it differs from Display (often has more detail).
// Debug representation if it differs
let debug_repr = format!("{:?}", error);
let display_repr = format!("{}", error);
if debug_repr != display_repr {
description_parts.push(String::new());
description_parts.push(format!("Debug: {}", debug_repr));
parts.push(format!("Debug: {}", debug_repr));
}
// 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).
// 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}"));
// 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));
let cause_debug = format!("{:?}", cause);
let cause_display = format!("{}", cause);
if cause_debug != cause_display {
parts.push(format!(" Debug ({depth}): {cause_debug}"));
}
source = cause.source();
}
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,
}
Some(parts.join("\n"))
}
/// 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]);
/// 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));
}
notification
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")) }
}
}
@@ -1,18 +1,28 @@
use chrono::{DateTime, Utc};
use std::fmt::{Debug, Display};
use std::sync::Arc;
use chrono::{DateTime, Utc};
use crate::notification::notification_details::NotificationDetails;
use crate::notification::notification_urgency::NotificationUrgency;
pub struct NotificationDetailsBuilder {
title: 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>,
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>,
}
impl NotificationDetailsBuilder {
@@ -21,34 +31,44 @@ impl NotificationDetailsBuilder {
Self {
title,
description: None,
urgency: None,
event_time: Utc::now(),
maybe_error: None,
is_from_error: false,
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,
}
}
/// 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);
/// 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: details.title,
description: details.description,
event_time: details.event_time,
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,
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
@@ -59,6 +79,16 @@ 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
@@ -74,8 +104,23 @@ impl NotificationDetailsBuilder {
self
}
pub fn set_urgency(mut self, urgency: Option<NotificationUrgency>) -> Self {
self.urgency = urgency;
pub fn set_user_token(mut self, user_token: Option<String>) -> Self {
self.user_token = user_token;
self
}
pub fn set_media_file_token(mut self, media_file_token: Option<String>) -> Self {
self.media_file_token = media_file_token;
self
}
pub fn set_inference_job_token(mut self, inference_job_token: Option<String>) -> Self {
self.inference_job_token = inference_job_token;
self
}
pub fn set_third_party_id(mut self, third_party_id: Option<String>) -> Self {
self.third_party_id = third_party_id;
self
}
@@ -83,12 +128,17 @@ 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,
third_party_id: self.third_party_id,
}
}
}
@@ -6,7 +6,9 @@ pub fn bcrypt_confirm_password(password: String, bcrypt_hash: &str) -> Result<bo
return Err(PasswordConfirmError::HashNotProvided);
}
if bcrypt_hash == PASSWORD_HASH_SENTINEL_VALUE {
// The database stores the sentinel "*" in a BINARY(60) column, which pads with null bytes.
// This reproduces the real-world value: "*" followed by 59 null bytes.
if bcrypt_hash.trim_end_matches('\0') == PASSWORD_HASH_SENTINEL_VALUE {
return Err(PasswordConfirmError::HashIsSentinelValue);
}
@@ -20,7 +22,7 @@ mod tests {
use crate::errors::password_confirm_error::PasswordConfirmError;
#[test]
fn test_confirmation() {
fn test_several_password_hash_pairs() {
// NB: This is to make sure we don't migrate the algorithm to a state where it won't work anymore.
// NB: Be mindful that these are expensive to compute and don't add too many test cases.
let hash_to_passwords = [
@@ -47,4 +49,18 @@ mod tests {
let result = bcrypt_confirm_password("password".to_string(), "*");
assert!(matches!(result, Err(PasswordConfirmError::HashIsSentinelValue)));
}
#[test]
fn test_sentinel_value_with_null_bytes_returns_bcrypt_error() {
// The database stores the sentinel "*" in a BINARY(60) column, which pads with null bytes.
// This reproduces the real-world value: "*" followed by 59 null bytes.
// It's currently paging me (until I fix it):
// [2026-03-31T16:03:00Z WARN storyteller_web::http_server::endpoints::users::login_handler] Login hash comparison error: BcryptVerifyError(InvalidHash("*\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0"))
let mut hash_with_nulls = String::from("*");
for _ in 0..59 {
hash_with_nulls.push('\0');
}
let result = bcrypt_confirm_password("password".to_string(), &hash_with_nulls);
assert!(matches!(result, Err(PasswordConfirmError::HashIsSentinelValue)));
}
}
@@ -0,0 +1,9 @@
[package]
name = "rootly_config"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "rootly_config"
path = "src/lib.rs"
@@ -0,0 +1,2 @@
pub mod services;
pub mod urgencies;
@@ -0,0 +1,3 @@
pub const ROOTLY_SERVICE_ID_STORYTELLER_WEB: &str = "9ebebb09-0c56-4c8a-8f57-d5f0f85f3f16";
pub const ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB: &str = "2a78f884-c42c-4745-b826-b7a572780ea0";
pub const ROOTLY_SERVICE_ID_VIDEO_THUMBNAIL_JOB: &str = "9d22997c-4474-47cb-af7b-53119eed1a88";
@@ -0,0 +1,3 @@
pub const ROOTLY_URGENCY_ID_HIGH: &str = "62fde143-1258-4639-9ee6-1400ebf7308d";
pub const ROOTLY_URGENCY_ID_MEDIUM: &str = "7366ba5e-f6ea-4a4e-a1b4-ae43d512e1e2";
pub const ROOTLY_URGENCY_ID_LOW: &str = "4db3d3ed-f4ed-4818-82f5-7746da404bd2";
@@ -0,0 +1,12 @@
[package]
name = "shared_env_var_config"
edition = "2024"
version = "0.0.1"
publish = false
[lib]
name = "shared_env_var_config"
path = "src/lib.rs"
[dependencies]
easyenv.workspace = true
@@ -0,0 +1 @@
pub mod paging;
@@ -0,0 +1,29 @@
// ----- Environment Variable Names -----
const ENV_ENABLE_PAGING: &str = "ENABLE_PAGING";
const ENV_ENABLE_PAGING_FOR_500S: &str = "ENABLE_PAGING_FOR_500S";
const ENV_ROOTLY_API_KEY: &str = "ROOTLY_API_KEY";
const ENV_ROOTLY_NOTIFICATION_TARGET_TYPE: &str = "ROOTLY_NOTIFICATION_TARGET_TYPE";
const ENV_ROOTLY_NOTIFICATION_TARGET_ID: &str = "ROOTLY_NOTIFICATION_TARGET_ID";
// ----- Read Environment Variables -----
pub fn env_enable_paging_default_false() -> bool {
easyenv::get_env_bool_or_default(ENV_ENABLE_PAGING, false)
}
pub fn env_enable_paging_for_500s_default_false() -> bool {
easyenv::get_env_bool_or_default(ENV_ENABLE_PAGING_FOR_500S, false)
}
pub fn env_optional_rootly_api_key() -> Option<String> {
easyenv::get_env_string_optional(ENV_ROOTLY_API_KEY)
}
pub fn env_optional_rootly_notification_target_type() -> Option<String> {
easyenv::get_env_string_optional(ENV_ROOTLY_NOTIFICATION_TARGET_TYPE)
}
pub fn env_optional_rootly_notification_target_id() -> Option<String> {
easyenv::get_env_string_optional(ENV_ROOTLY_NOTIFICATION_TARGET_ID)
}
+26
View File
@@ -0,0 +1,26 @@
# Enums Crate
Database-backed enums stored as VARCHAR fields in MySQL.
## Adding a New Enum
1. Create file: `src/by_table/{table_name}/{field_name}.rs`
2. Define the enum with required derives:
```rust
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
pub enum MyEnum { ... }
```
3. Implement `to_str()` and `from_str()` manually
4. Add macros: `impl_enum_display_and_debug_using_to_str!(MyEnum);` and `impl_mysql_enum_coders!(MyEnum);`
5. Add to parent `mod.rs`
6. Write tests: serialization, round-trip, `all_variants()` count
## Rules
- **NEVER change existing variant string values** - they are stored in the database
- New variants CAN be added freely - but be very careful about the Tauri API callers
ingesting new values. Make sure to pair the client with variants with an "Unknown(String)"
catch-all / future-proof bucket.
- All string values must be snake_case
- Use `#[serde(rename = "snake_case_value")]` on each variant
+22
View File
@@ -0,0 +1,22 @@
# Tokens Crate
Strongly-typed primary key identifiers with Stripe-like prefixes (e.g. `user_abc123`, `prompt_xyz789`, etc.)
## Adding a New Token
1. Create file in `src/tokens/{entity_name}.rs`
2. Define as a newtype wrapper:
```rust
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
#[cfg_attr(feature = "database", derive(sqlx::Type))]
#[cfg_attr(feature = "database", sqlx(transparent))]
pub struct MyToken(pub String);
```
3. Apply macros:
```rust
impl_string_token!(MyToken);
impl_mysql_token_from_row!(MyToken);
impl_crockford_generator!(MyToken, 32usize, TokenPrefix::MyEntity, CrockfordLower);
```
4. Add the prefix to `TokenPrefix` enum in `src/token_prefix.rs`
5. Add to parent `mod.rs`
+42
View File
@@ -0,0 +1,42 @@
# Background Jobs
Background job crates that run as standalone processes alongside storyteller-web.
## Common Pattern
All jobs follow the same structure:
1. `main.rs` - Bootstrap, DB pool, bucket client, pager setup, thread spawning
2. `job_dependencies.rs` - Shared state struct passed to the main loop
3. `startup/build_pager.rs` - Pager initialization from env vars
4. `http_server/` - Health check endpoint for k8s liveness probes
5. `job/` or `jobs/` - Main processing logic
## Threading Model
- **Main thread** (`#[tokio::main]`): runs the job's main loop
- **HTTP server thread** (`std::thread::spawn`): dedicated OS thread with its own `actix_web::rt::System`
- **Pager worker thread** (`std::thread::spawn`): dedicated OS thread (uses blocking `Condvar::wait`)
- **Signal handler** (`tokio::spawn`): listens for SIGTERM/SIGINT to set `application_shutdown` flag
Important: Do NOT use `#[actix_web::main]` - it registers signal handlers that steal SIGINT. Use `#[tokio::main]` instead.
Important: Do NOT spawn the pager worker via `tokio::spawn` - its `Condvar::wait` blocks the runtime. Use a dedicated OS thread.
## Pager Integration
Use the `alert_pager_and_return_err()` helper pattern for error reporting:
```rust
if let Err(err) = some_operation().await {
error!("Descriptive message: {:?}", err);
return alert_pager_and_return_err(&deps.pager, "Alert title", err, optional_job_ref);
}
```
## Environment Variables
Shared across jobs (via `shared_env_var_config`):
- `ENABLE_PAGING` - Master switch for pager alerts
- `ROOTLY_API_KEY` - Rootly API key for sending alerts
- `ROOTLY_NOTIFICATION_TARGET_TYPE` / `ROOTLY_NOTIFICATION_TARGET_ID`
@@ -32,8 +32,12 @@ errors.workspace = true
hashing = { path = "../../../lib/files/hashing" }
jobs_common = { path = "../../../lib/jobs_common" }
mysql_queries = { path = "../../../schema/database/mysql_queries" }
pager = { path = "../../../lib/pager" }
rootly_client.workspace = true
rootly_config.workspace = true
seedance2pro_client = { path = "../../../api_clients/seedance2pro_client" }
server_environment = { path = "../../../lib/server_environment" }
shared_env_var_config.workspace = true
tokens.workspace = true
# External
@@ -7,6 +7,8 @@ use actix_web::{web, HttpRequest, HttpResponse};
use log::error;
use actix_helpers::response_serializers::error_to_json_http_response::error_to_json_http_response;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
@@ -46,7 +48,7 @@ impl std::fmt::Display for HealthCheckError {
}
pub async fn get_health_check_handler(
_http_request: HttpRequest,
http_request: HttpRequest,
server_state: web::Data<Arc<HttpServerSharedState>>,
) -> Result<HttpResponse, HealthCheckError> {
let job_stats = server_state
@@ -76,6 +78,30 @@ pub async fn get_health_check_handler(
let is_healthy =
job_stats.consecutive_failure_count < server_state.consecutive_failure_unhealthy_threshold;
if !is_healthy {
let notification = NotificationDetailsBuilder::from_title(
format!("Health check unhealthy on {}", server_state.hostname))
.set_description(Some(format!(
"Health check returned unhealthy.\n\n\
Hostname: {}\n\
Consecutive failure count: {}\n\
Total failure count: {}\n\
Total success count: {}",
server_state.hostname,
job_stats.consecutive_failure_count,
job_stats.total_failure_count,
job_stats.total_success_count,
)))
.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(err) = server_state.pager.enqueue_page(notification) {
error!("Failed to enqueue health check alert: {:?}", err);
}
}
let response = HealthCheckResponse {
success: true,
is_healthy,
@@ -1,7 +1,10 @@
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
#[derive(Clone)]
pub struct HttpServerSharedState {
pub job_stats: JobStats,
pub consecutive_failure_unhealthy_threshold: u64,
pub pager: Pager,
pub hostname: String,
}
@@ -8,6 +8,7 @@ use log::info;
use bootstrap::bootstrap::ContainerEnvironment;
use errors::AnyhowResult;
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
use crate::http_server::endpoints::health_check_handler::get_health_check_handler;
use crate::http_server::http_server_shared_state::HttpServerSharedState;
@@ -18,12 +19,13 @@ const DEFAULT_NUM_WORKERS: usize = 2;
pub struct CreateServerArgs {
pub container_environment: ContainerEnvironment,
pub job_stats: JobStats,
pub pager: Pager,
}
pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
let bind_address = easyenv::get_env_string_or_default("HTTP_BIND_ADDRESS", DEFAULT_BIND_ADDRESS);
let num_workers = easyenv::get_env_num("HTTP_NUM_WORKERS", DEFAULT_NUM_WORKERS)?;
let _hostname = args.container_environment.hostname.clone();
let hostname = args.container_environment.hostname.clone();
let server_state = HttpServerSharedState {
job_stats: args.job_stats.clone(),
@@ -31,6 +33,8 @@ pub fn run_http_server(args: CreateServerArgs) -> AnyhowResult<Server> {
"CONSECUTIVE_FAILURE_UNHEALTHY_THRESHOLD",
3,
)?,
pager: args.pager,
hostname,
};
let server_state_arc = web::Data::new(Arc::new(server_state));
@@ -1,6 +1,7 @@
use cloud_storage::bucket_client::BucketClient;
use concurrency::relaxed_atomic_bool::RelaxedAtomicBool;
use jobs_common::job_stats::JobStats;
use pager::client::pager::Pager;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use server_environment::ServerEnvironment;
use sqlx::MySqlPool;
@@ -28,4 +29,7 @@ pub struct JobDependencies {
/// Set to `true` from another thread to trigger graceful shutdown.
pub application_shutdown: RelaxedAtomicBool,
/// Pager client for sending alerts.
pub pager: Pager,
}
@@ -0,0 +1,34 @@
use log::error;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use pager::client::pager::Pager;
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
/// Enqueue a pager alert for the error, then return it as `Err`.
pub fn alert_pager_and_return_err<T>(
pager: &Pager,
title: &str,
err: anyhow::Error,
job: Option<&PendingSeedance2ProJob>,
) -> anyhow::Result<T> {
let err_message = format!("{:#}", err);
let mut builder = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium));
if let Some(job) = job {
builder = builder
.set_inference_job_token(Some(job.job_token.to_string()))
.set_third_party_id(Some(job.order_id.clone()))
.set_user_token(job.maybe_creator_user_token.as_ref().map(|t| t.to_string()));
}
let notification = builder.build();
if let Err(pager_err) = pager.enqueue_page(notification) {
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(anyhow::anyhow!(err_message))
}
@@ -1,22 +1,47 @@
use std::collections::HashMap;
use std::time::Duration;
use std::time::{Duration, Instant};
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::list_pending_seedance2pro_jobs;
use seedance2pro_client::requests::poll_orders::poll_orders::{poll_orders, OrderStatus, PollOrdersArgs, TaskStatus};
use crate::jobs::alert_on_error::alert_pager_and_return_err;
use crate::jobs::process_page_batch::process_page_batch;
use crate::job_dependencies::JobDependencies;
const POLL_ALERT_THRESHOLD: Duration = Duration::from_secs(600);
pub async fn main_loop(job_dependencies: JobDependencies) {
while !job_dependencies.application_shutdown.get() {
let start = Instant::now();
let result = run_poll_iteration(&job_dependencies).await;
let elapsed = start.elapsed();
if let Err(err) = result {
error!("Error in poll iteration: {:?}", err);
let _ = alert_pager_and_return_err::<()>(&job_dependencies.pager, "Seedance2Pro poll iteration error", err, None);
let _ = job_dependencies.job_stats.increment_failure_count();
}
if elapsed > POLL_ALERT_THRESHOLD {
warn!("Poll iteration took {:.1}s (threshold: {}s)", elapsed.as_secs_f64(), POLL_ALERT_THRESHOLD.as_secs());
let notification = NotificationDetailsBuilder::from_title(
"Seedance2Pro poll iteration slow".to_string())
.set_description(Some(format!(
"Poll iteration took {:.1} seconds, exceeding the 10-minute threshold.",
elapsed.as_secs_f64(),
)))
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = job_dependencies.pager.enqueue_page(notification) {
error!("Failed to enqueue slow iteration alert: {:?}", pager_err);
}
}
tokio::time::sleep(Duration::from_millis(job_dependencies.poll_interval_millis)).await;
}
@@ -25,7 +50,13 @@ pub async fn main_loop(job_dependencies: JobDependencies) {
async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
// 1. Query all non-terminal Seedance2Pro jobs from DB.
let pending_jobs = list_pending_seedance2pro_jobs(&deps.mysql_pool).await?;
let pending_jobs = match list_pending_seedance2pro_jobs(&deps.mysql_pool).await {
Ok(jobs) => jobs,
Err(err) => {
error!("Failed to list pending seedance2pro jobs: {:?}", err);
return alert_pager_and_return_err(&deps.pager, "Seedance2Pro DB query failed", err.into(), None);
}
};
if pending_jobs.is_empty() {
info!("No pending Seedance2Pro jobs.");
@@ -52,6 +83,11 @@ async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
let mut pages_in_current_batch: u32 = 0;
loop {
if deps.application_shutdown.get() {
info!("Shutdown requested during pagination. Stopping early.");
break;
}
total_page_number += 1;
pages_in_current_batch += 1;
@@ -60,16 +96,22 @@ async fn run_poll_iteration(deps: &JobDependencies) -> anyhow::Result<()> {
total_page_number, cursor, total_orders_seen
);
let response = poll_orders(PollOrdersArgs {
let response = match poll_orders(PollOrdersArgs {
session: &deps.seedance2pro_session,
cursor,
host_override: None,
})
.await
.map_err(|err| {
}).await {
Ok(r) => r,
Err(err) => {
warn!("Error polling seedance2pro orders: {:?}", err);
anyhow::anyhow!("poll_orders failed: {:?}", err)
})?;
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro API poll failed",
anyhow::anyhow!("poll_orders failed: {:?}", err),
None,
);
}
};
let page_count = response.orders.len() as u32;
info!("Done polling page {}. Got {} orders on this page.", total_page_number, page_count);
@@ -1,3 +1,4 @@
pub mod alert_on_error;
pub mod main_loop;
pub mod process_job;
pub mod process_page_batch;
@@ -4,6 +4,8 @@ use enums::by_table::generic_inference_jobs::frontend_failure_category::Frontend
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedance2pro_jobs::PendingSeedance2ProJob;
use mysql_queries::queries::wallets::refund::try_to_refund_ledger_entry::{try_to_refund_ledger_entry, WalletRefundOutcome};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use seedance2pro_client::requests::poll_orders::failure_type::FailureType;
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
@@ -82,6 +84,19 @@ pub async fn process_failed_job(
job.job_token.as_str(),
err,
);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.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()))
.set_user_token(job.maybe_creator_user_token.as_ref().map(|t| t.to_string()))
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = deps.pager.enqueue_page(notification) {
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
let _ = transaction.rollback().await;
return;
}
@@ -124,5 +139,14 @@ pub async fn process_failed_job(
job.job_token.as_str(),
err
);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance2Pro mark job failed error".to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
if let Err(pager_err) = deps.pager.enqueue_page(notification) {
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
}
}
@@ -13,6 +13,8 @@ use mysql_queries::queries::generic_inference::seedance2pro::list_pending_seedan
use mysql_queries::queries::media_files::create::insert_builder::media_file_insert_builder::MediaFileInsertBuilder;
use mysql_queries::queries::generic_inference::web::mark_generic_inference_job_successfully_done_by_token::mark_generic_inference_job_successfully_done_by_token;
use seedance2pro_client::requests::poll_orders::poll_orders::OrderStatus;
use crate::jobs::alert_on_error::alert_pager_and_return_err;
use crate::job_dependencies::JobDependencies;
const PREFIX: &str = "artcraft_";
@@ -47,13 +49,29 @@ pub async fn process_successful_job(
);
// Download the video bytes.
let video_bytes: Vec<u8> = reqwest::get(video_url)
.await
.map_err(|err| anyhow!("reqwest error downloading video: {:?}", err))?
.bytes()
.await
.map_err(|err| anyhow!("error reading video bytes: {:?}", err))?
.to_vec();
let video_bytes: Vec<u8> = match reqwest::get(video_url).await {
Ok(resp) => match resp.bytes().await {
Ok(bytes) => bytes.to_vec(),
Err(err) => {
error!("Error reading video bytes for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro video download failed",
anyhow!("error reading video bytes: {:?}", err),
Some(job),
);
}
},
Err(err) => {
error!("Error downloading video for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro video download failed",
anyhow!("reqwest error downloading video: {:?}", err),
Some(job),
);
}
};
info!(
"Downloaded {} bytes for order {}",
@@ -76,11 +94,19 @@ pub async fn process_successful_job(
);
// Upload to public bucket.
deps
if let Err(err) = deps
.public_bucket_client
.upload_file_with_content_type_process(object_path, &video_bytes, "video/mp4")
.await
.map_err(|err| anyhow!("error uploading video to bucket: {:?}", err))?;
{
error!("Error uploading video for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro bucket upload failed",
anyhow!("error uploading video to bucket: {:?}", err),
Some(job),
);
}
info!(
"Uploaded video for order {}. Creating media file record.",
@@ -92,7 +118,7 @@ pub async fn process_successful_job(
let maybe_frame_height = order.results.first().map(|r| r.height);
// Insert media file record.
let media_file_token = MediaFileInsertBuilder::new()
let media_file_token = match MediaFileInsertBuilder::new()
.maybe_creator_user(job.maybe_creator_user_token.as_ref())
.maybe_creator_anonymous_visitor(job.maybe_creator_anonymous_visitor_token.as_ref())
.creator_ip_address(&job.creator_ip_address)
@@ -110,7 +136,18 @@ pub async fn process_successful_job(
.public_bucket_directory_hash(&bucket_path)
.insert_pool(&deps.mysql_pool)
.await
.map_err(|err| anyhow!("error inserting media file record: {:?}", err))?;
{
Ok(token) => token,
Err(err) => {
error!("Error inserting media file record for order {}: {:?}", order.order_id, err);
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro media file insert failed",
anyhow!("error inserting media file record: {:?}", err),
Some(job),
);
}
};
info!(
"Created media file {} for order {}. Marking job {} complete.",
@@ -120,23 +157,22 @@ pub async fn process_successful_job(
);
// Mark inference job as successfully completed.
mark_generic_inference_job_successfully_done_by_token(
if let Err(err) = mark_generic_inference_job_successfully_done_by_token(
&deps.mysql_pool,
&job.job_token,
Some(InferenceResultType::MediaFile),
Some(media_file_token.as_str()),
None,
None,
)
.await
.map_err(|err| {
error!(
"Error marking job {} done: {:?}",
job.job_token.as_str(),
err
);
anyhow!("error marking job done: {:?}", err)
})?;
).await {
error!("Error marking job {} done: {:?}", job.job_token.as_str(), err);
return alert_pager_and_return_err(
&deps.pager,
"Seedance2Pro job completion update failed",
anyhow!("error marking job done: {:?}", err),
Some(job),
);
}
info!("Job {} completed successfully.", job.job_token.as_str());
@@ -28,10 +28,12 @@ use server_environment::ServerEnvironment;
use crate::http_server::run_http_server::{launch_http_server, CreateServerArgs};
use crate::jobs::main_loop::main_loop;
use crate::job_dependencies::JobDependencies;
use crate::startup::build_pager::build_pager;
pub mod http_server;
pub mod job_dependencies;
pub mod jobs;
pub mod startup;
// Bucket config
const ENV_ACCESS_KEY: &str = "ACCESS_KEY";
@@ -41,7 +43,7 @@ const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
const ENV_SEEDANCE2PRO_COOKIES : &str = "SEEDANCE2PRO_COOKIES";
#[actix_web::main]
#[tokio::main]
async fn main() -> AnyhowResult<()> {
let container_environment = bootstrap(BootstrapArgs {
@@ -113,12 +115,27 @@ async fn main() -> AnyhowResult<()> {
info!("Batch page count: {}", count);
}
let (pager, pager_worker) = build_pager(server_environment, &container_environment.hostname);
info!("Spawning pager worker.");
// NB: The pager worker uses Condvar::wait() which is a blocking syscall.
// It must run on a dedicated OS thread, not a tokio task, to avoid blocking
// the tokio runtime.
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("pager worker tokio runtime");
rt.block_on(pager_worker.run());
});
let application_shutdown = RelaxedAtomicBool::new(false);
let job_stats = JobStats::new();
let pager_for_shutdown = pager.clone();
let create_server_args = CreateServerArgs {
container_environment: container_environment.clone(),
job_stats: job_stats.clone(),
pager: pager.clone(),
};
let job_dependencies = JobDependencies {
@@ -130,8 +147,10 @@ async fn main() -> AnyhowResult<()> {
poll_interval_millis,
maybe_pages_per_batch,
application_shutdown: application_shutdown.clone(),
pager,
};
// HTTP server runs on a separate OS thread with its own actix System.
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
let http_server_handle = launch_http_server(create_server_args);
@@ -139,11 +158,30 @@ async fn main() -> AnyhowResult<()> {
actix_runtime.block_on(http_server_handle)
.expect("HTTP server should not exit.");
warn!("Server thread is shut down.");
application_shutdown.set(true);
warn!("HTTP server thread is shut down.");
});
// Listen for SIGTERM / Ctrl-C to trigger graceful shutdown.
let application_shutdown_for_signal = application_shutdown.clone();
tokio::spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
info!("Received shutdown signal. Shutting down...");
application_shutdown_for_signal.set(true);
}
Err(err) => {
warn!("Error listening for shutdown signal: {:?}", err);
}
}
});
main_loop(job_dependencies).await;
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
info!("Seedance2Pro job exiting.");
Ok(())
}
@@ -0,0 +1,65 @@
use log::{info, warn};
use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use rootly_config::services::ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB;
use rootly_config::urgencies::{ROOTLY_URGENCY_ID_HIGH, ROOTLY_URGENCY_ID_LOW, ROOTLY_URGENCY_ID_MEDIUM};
use shared_env_var_config::paging::{env_enable_paging_default_false, env_optional_rootly_api_key, env_optional_rootly_notification_target_id, env_optional_rootly_notification_target_type};
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
hostname: &str,
) -> (Pager, PagerWorker) {
let is_paging_enabled = env_enable_paging_default_false();
info!("Paging enabled: {}", is_paging_enabled);
let environment = if server_environment.is_deployed_in_production() {
"production"
} else {
"development"
};
let builder = PagerBuilder::new()
.application_name("seedance2-pro-job".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string())
.service_id(ROOTLY_SERVICE_ID_SEEDANCE2_PRO_JOB.to_string());
// If paging is globally disabled, use a NoOp pager regardless of API key.
if !is_paging_enabled {
warn!("ENABLE_PAGING is false. Pager will be NoOp.");
return builder.build_with_worker();
}
let maybe_api_key = env_optional_rootly_api_key();
match maybe_api_key {
Some(api_key) => {
info!("Rootly API key found. Configuring pager with Rootly backend.");
build_rootly_pager(builder, api_key)
}
None => {
warn!("ROOTLY_API_KEY not set. Pager will not send real pages.");
builder.build_with_worker()
}
}
}
fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWorker) {
let mut rootly_builder = builder
.rootly(RootlyApiKey::new(api_key))
.urgency_id_high(ROOTLY_URGENCY_ID_HIGH.to_string())
.urgency_id_medium(ROOTLY_URGENCY_ID_MEDIUM.to_string())
.urgency_id_low(ROOTLY_URGENCY_ID_LOW.to_string());
let target_type = env_optional_rootly_notification_target_type();
let target_id = env_optional_rootly_notification_target_id();
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
}
rootly_builder.build_with_worker()
}
@@ -0,0 +1 @@
pub mod build_pager;
@@ -33,6 +33,8 @@ jobs_common.workspace = true
mysql_queries.workspace = true
pager = { path = "../../../lib/pager" }
rootly_client.workspace = true
rootly_config.workspace = true
shared_env_var_config.workspace = true
server_environment = { path = "../../../lib/server_environment" }
# External
@@ -9,7 +9,9 @@ pub fn alert_pager_and_return_err<T>(
title: &str,
err: anyhow::Error,
) -> anyhow::Result<T> {
let notification = NotificationDetailsBuilder::from_error(&err)
let err_message = format!("{:#}", err);
let notification = NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title(title.to_string())
.set_urgency(Some(NotificationUrgency::Medium))
.build();
@@ -18,5 +20,5 @@ pub fn alert_pager_and_return_err<T>(
error!("Failed to enqueue pager alert: {:?}", pager_err);
}
Err(err)
Err(anyhow::anyhow!(err_message))
}
@@ -42,7 +42,7 @@ const ENV_REGION_NAME: &str = "REGION_NAME";
const ENV_PUBLIC_BUCKET_NAME: &str = "PUBLIC_BUCKET_NAME";
const ENV_S3_ENDPOINT: &str = "S3_COMPATIBLE_ENDPOINT_URL";
#[actix_web::main]
#[tokio::main]
async fn main() -> AnyhowResult<()> {
let container_environment = bootstrap(BootstrapArgs {
@@ -151,15 +151,21 @@ async fn main() -> AnyhowResult<()> {
let (pager, pager_worker) = build_pager(server_environment, &container_environment.hostname);
info!("Spawning pager worker thread.");
info!("Spawning pager worker.");
tokio::spawn(async move {
pager_worker.run().await;
// NB: The pager worker uses Condvar::wait() which is a blocking syscall.
// It must run on a dedicated OS thread, not a tokio task, to avoid blocking
// the tokio runtime.
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("pager worker tokio runtime");
rt.block_on(pager_worker.run());
});
let application_shutdown = RelaxedAtomicBool::new(false);
let job_stats = JobStats::new();
let pager_for_shutdown = pager.clone();
let create_server_args = CreateServerArgs {
container_environment: container_environment.clone(),
job_stats: job_stats.clone(),
@@ -182,6 +188,7 @@ async fn main() -> AnyhowResult<()> {
pager,
};
// HTTP server runs on a separate OS thread with its own actix System.
std::thread::spawn(move || {
let actix_runtime = actix_web::rt::System::new();
let http_server_handle = launch_http_server(create_server_args);
@@ -189,11 +196,30 @@ async fn main() -> AnyhowResult<()> {
actix_runtime.block_on(http_server_handle)
.expect("HTTP server should not exit.");
warn!("Server thread is shut down.");
application_shutdown.set(true);
warn!("HTTP server thread is shut down.");
});
// Listen for SIGTERM / Ctrl-C to trigger graceful shutdown.
let application_shutdown_for_signal = application_shutdown.clone();
tokio::spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
info!("Received shutdown signal. Shutting down...");
application_shutdown_for_signal.set(true);
}
Err(err) => {
warn!("Error listening for shutdown signal: {:?}", err);
}
}
});
main_loop(job_dependencies).await;
info!("Shutting down pager worker...");
pager_for_shutdown.shutdown_worker();
info!("Video thumbnail job exiting.");
Ok(())
}
@@ -3,19 +3,15 @@ use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
/// `video-thumbnail-job` rootly service ID
const ROOTLY_SERVICE_ID: &str = "9d22997c-4474-47cb-af7b-53119eed1a88";
const ROOTLY_URGENCY_ID_HIGH: &str = "62fde143-1258-4639-9ee6-1400ebf7308d";
const ROOTLY_URGENCY_ID_MEDIUM: &str = "7366ba5e-f6ea-4a4e-a1b4-ae43d512e1e2";
const ROOTLY_URGENCY_ID_LOW: &str = "4db3d3ed-f4ed-4818-82f5-7746da404bd2";
use rootly_config::services::ROOTLY_SERVICE_ID_VIDEO_THUMBNAIL_JOB;
use rootly_config::urgencies::{ROOTLY_URGENCY_ID_HIGH, ROOTLY_URGENCY_ID_LOW, ROOTLY_URGENCY_ID_MEDIUM};
use shared_env_var_config::paging::{env_enable_paging_default_false, env_optional_rootly_api_key, env_optional_rootly_notification_target_id, env_optional_rootly_notification_target_type};
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
hostname: &str,
) -> (Pager, PagerWorker) {
let is_paging_enabled = easyenv::get_env_bool_or_default("ENABLE_PAGING", false);
let is_paging_enabled = env_enable_paging_default_false();
info!("Paging enabled: {}", is_paging_enabled);
@@ -29,7 +25,7 @@ pub fn build_pager(
.application_name("video-thumbnail-job".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string())
.service_id(ROOTLY_SERVICE_ID.to_string());
.service_id(ROOTLY_SERVICE_ID_VIDEO_THUMBNAIL_JOB.to_string());
// If paging is globally disabled, use a NoOp pager regardless of API key.
if !is_paging_enabled {
@@ -37,7 +33,7 @@ pub fn build_pager(
return builder.build_with_worker();
}
let maybe_api_key = easyenv::get_env_string_optional("ROOTLY_API_KEY");
let maybe_api_key = env_optional_rootly_api_key();
match maybe_api_key {
Some(api_key) => {
@@ -58,8 +54,8 @@ fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWo
.urgency_id_medium(ROOTLY_URGENCY_ID_MEDIUM.to_string())
.urgency_id_low(ROOTLY_URGENCY_ID_LOW.to_string());
let target_type = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_TYPE");
let target_id = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_ID");
let target_type = env_optional_rootly_notification_target_type();
let target_id = env_optional_rootly_notification_target_id();
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
@@ -0,0 +1,44 @@
# storyteller-web
The main HTTP API monolith. This is the backend for the ArtCraft application.
## Building
```
cargo check -p storyteller-web
```
There are ~370 pre-existing warnings. Check the last 8-10 lines of output for actual errors.
## Structure
- `src/http_server/endpoints/` - HTTP handlers organized by feature
- `src/http_server/middleware/` - Actix middleware (error alerting, etc.)
- `src/http_server/common_responses/` - Shared error types (`CommonWebError`)
- `src/http_server/web_utils/` - Session checking, auth helpers
- `src/state/` - Server state, feature flags
- `src/startup/` - Initialization (pager, etc.)
- `src/threads/` - Background threads (health checker, etc.)
- `src/docs/` - OpenAPI/Swagger documentation (`api_doc.rs`)
## Handler Pattern
Handlers return `Result<Json<Response>, ErrorType>`. The `Result<HttpResponse, ErrorType>` pattern is
deprecated or only used when setting other HTTP headers, like cookies.
Prefer `CommonWebError` for new handlers unless you need custom error variants.
When a handler uses `require_user_session`, prefer `require_user_session_using_connection`
which reuses an existing DB connection.
## api_doc.rs
When adding new request/response/error types, add them to `src/docs/api_doc.rs` in the
schemas section (alphabetically sorted). Types from `artcraft_api_defs` need explicit imports.
## Pager Integration
- The `Pager` is available on `ServerState` as `server_state.pager`
- Health check handler sends alerts when unhealthy
- Error alerting middleware sends alerts on HTTP 500s (when enabled)
- Use `NotificationDetailsBuilder` for constructing alerts
@@ -68,6 +68,8 @@ mysql_queries.workspace = true
pager.workspace = true
password = { path = "../../../lib/password" }
rootly_client.workspace = true
rootly_config.workspace = true
shared_env_var_config.workspace = true
primitives = { path = "../../../lib/data/primitives" }
prompt_engineering = { path = "../../../lib/deprecated/machine_learning/prompt_engineering" }
redis_caching = { path = "../../../lib/caching/redis_caching" }
@@ -307,7 +307,6 @@ use artcraft_api_defs::moderation::wallets::moderator_create_wallet_for_user::*;
use artcraft_api_defs::moderation::wallets::moderator_get_wallet::*;
use artcraft_api_defs::web_referrals::log_web_referral::*;
use crate::http_server::endpoints::web_referrals::log_web_referral_handler::*;
use crate::http_server::endpoints::webhooks::fal_webhook_handler::*;
use crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::*;
use crate::http_server::endpoints::voice_conversion::enqueue_seed_vc_inference_handler::*;
use crate::http_server::endpoints::credits::get_session_credits_handler::*;
@@ -515,8 +514,6 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::subscriptions::get_session_subscription_handler::get_session_subscription_handler,
// Web Referrals
crate::http_server::endpoints::web_referrals::log_web_referral_handler::log_web_referral_handler,
// Webhooks
crate::http_server::endpoints::webhooks::fal_webhook_handler::fal_webhook_handler,
// Image Studio
crate::http_server::endpoints::image_studio::update_gpt_image_job_status_handler::update_gpt_image_job_status_handler,
),
@@ -0,0 +1,332 @@
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,3 +1,4 @@
pub mod advanced_common_web_error;
pub mod common_web_error;
pub mod media;
pub mod media_file_origin_details;
@@ -14,8 +14,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use fal_client::creds::open_ai_api_key::OpenAiApiKey;
@@ -32,7 +30,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use utoipa::ToSchema;
use fal_client::requests::traits::fal_request_cost_calculator_trait::FalRequestCostCalculator;
@@ -89,28 +86,6 @@ pub async fn gemini_25_flash_edit_image_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let tokens = match request.image_media_tokens.as_ref() {
@@ -177,9 +152,6 @@ pub async fn gemini_25_flash_edit_image_handler(
None => Gemini25FlashEditNumImages::One, // Default to One
};
if downgrade_for_free_user {
num_images = Gemini25FlashEditNumImages::One;
}
let args = Gemini25FlashEditArgs {
image_urls,
@@ -15,8 +15,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -34,7 +32,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
@@ -88,29 +85,6 @@ pub async fn gpt_image_1_edit_image_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
&user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
&user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let tokens = match request.image_media_tokens.as_ref() {
@@ -196,9 +170,6 @@ pub async fn gpt_image_1_edit_image_handler(
let openai_api_key = OpenAiApiKey::from_str(&server_state.openai.api_key);
if downgrade_for_free_user {
num_images = GptEditImageNumImages::One;
}
let args = GptEditImageByokArgs {
image_urls,
@@ -18,8 +18,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -37,7 +35,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -93,29 +90,6 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let mut query_media_tokens = None;
if let Some(media_tokens) = request.image_media_tokens.as_ref() {
@@ -216,10 +190,6 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
None => EnqueueGptImage1p5EditImageInputFidelity::High,
};
if downgrade_for_free_user {
num_images = EnqueueGptImage1p5EditImageNumImages::One;
}
let args = EnqueueGptImage1p5EditImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -285,10 +255,6 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
None => EnqueueGptImage1p5TextToImageQuality::High,
};
if downgrade_for_free_user {
num_images = EnqueueGptImage1p5TextToImageNumImages::One;
}
let args = EnqueueGptImage1p5TextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -19,8 +19,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -40,7 +38,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -96,29 +93,6 @@ pub async fn nano_banana_multi_function_image_gen_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let image_urls = match request.image_media_tokens.as_ref() {
Some(media_tokens) => {
info!("Looking up image media tokens: {:?}", media_tokens);
@@ -173,10 +147,6 @@ pub async fn nano_banana_multi_function_image_gen_handler(
None => Gemini25FlashEditAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = Gemini25FlashEditNumImages::One;
}
let args = Gemini25FlashEditArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -231,10 +201,6 @@ pub async fn nano_banana_multi_function_image_gen_handler(
None => Gemini25FlashTextToImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = Gemini25FlashTextToImageNumImages::One;
}
let args = Gemini25FlashTextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -18,8 +18,6 @@ use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptC
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -38,7 +36,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use server_environment::ServerEnvironment;
use sqlx::pool::PoolConnection;
use sqlx::{Acquire, MySql};
@@ -94,29 +91,6 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
let image_urls = match request.image_media_tokens.as_ref() {
Some(media_tokens) => {
info!("Looking up image media tokens: {:?}", media_tokens);
@@ -178,11 +152,6 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProEditImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = EnqueueNanoBananaProEditImageNumImages::One;
resolution = EnqueueNanoBananaProEditImageResolution::OneK;
}
let args = EnqueueNanoBananaProEditImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
image_urls: input_image_urls.to_owned(),
@@ -245,11 +214,6 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
None => EnqueueNanoBananaProTextToImageAspectRatio::OneByOne,
};
if downgrade_for_free_user {
num_images = EnqueueNanoBananaProTextToImageNumImages::One;
resolution = EnqueueNanoBananaProTextToImageResolution::OneK;
}
let args = EnqueueNanoBananaProTextToImageArgs {
prompt: request.prompt.as_deref().unwrap_or(""),
num_images,
@@ -14,8 +14,6 @@ use bucket_paths::legacy::typified_paths::public::media_files::bucket_file_path:
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::generation::common_model_type::CommonModelType;
use enums::common::payments_namespace::PaymentsNamespace;
use enums::common::stripe_subscription_status::StripeSubscriptionStatus;
use enums::common::visibility::Visibility;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
use enums::common::generation::common_aspect_ratio::CommonAspectRatio;
@@ -32,7 +30,6 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
use mysql_queries::queries::media_files::get::batch_get_media_files_by_tokens::{batch_get_media_files_by_tokens, batch_get_media_files_by_tokens_with_connection};
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem};
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use mysql_queries::queries::users::user_subscriptions::find_subscription_for_owner_user::find_subscription_for_owner_user_using_connection;
use sqlx::Acquire;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use utoipa::ToSchema;
@@ -85,29 +82,6 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
}
};
let mut downgrade_for_free_user = true;
let result = find_subscription_for_owner_user_using_connection(
&user_token,
PaymentsNamespace::Artcraft,
&mut mysql_connection,
).await;
if let Ok(Some(subscription)) = result {
info!("User {:?} has subscription: {:?} (stripe customer: {:?}, status: {:?})",
&user_token,
subscription.token,
subscription.stripe_customer_id,
subscription.stripe_subscription_status);
// NB: Failing open means subscribers might get fewer results, but they're free right now.
if subscription.stripe_subscription_status == StripeSubscriptionStatus::Active {
downgrade_for_free_user = false;
}
}
info!("downgrade_for_free_user: {}", downgrade_for_free_user);
const CAN_SEE_DELETED: bool = false;
let media_domain = get_media_domain(&http_request);
@@ -146,10 +120,6 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
None => GptTextToImageQuality::High, // Default to High
};
if downgrade_for_free_user {
num_images = GptTextToImageNumImages::One;
}
let openai_api_key = OpenAiApiKey::from_str(&server_state.openai.api_key);
let args = GptTextToImageByokArgs {
@@ -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_error(&err)
NotificationDetailsBuilder::from_boxed_error(err.into())
.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_error(&err)
NotificationDetailsBuilder::from_boxed_error(err.into())
.set_title("Seedance 2.0 generation failed".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build()
@@ -1,58 +1,25 @@
use std::fmt;
use std::sync::Arc;
use crate::http_server::endpoints::webhooks::handle_image_payload::handle_image_payload;
use crate::http_server::endpoints::webhooks::handle_images_payload::handle_images_payload;
use crate::http_server::endpoints::webhooks::handle_model_glb_payload::handle_model_glb_payload;
use crate::http_server::endpoints::webhooks::handle_model_mesh_payload::handle_model_mesh_payload;
use crate::http_server::endpoints::webhooks::handle_video_payload::handle_video_payload;
use crate::http_server::endpoints::webhooks::process_failure::handle_failed_fal_webhook::handle_failed_fal_webhook;
use crate::http_server::endpoints::webhooks::process_success::handle_successful_fal_webhook::handle_successful_fal_webhook;
use crate::state::server_state::ServerState;
use actix_web::error::ResponseError;
use actix_web::http::StatusCode;
use actix_web::web::Bytes;
use actix_web::web::Json;
use actix_web::{web, HttpRequest, HttpResponse};
use anyhow::Error;
use fal_client::webhook_api::parse_webhook_inner_payload::parse_webhook_inner_payload;
use fal_client::webhook_api::payload::webhook_inner_payload::WebhookInnerPayload;
use fal_client::webhook_api::parse_webhook_payload::parse_webhook_payload;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use http_server_common::response::serialize_as_json_error::serialize_as_json_error;
use log::{info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{mark_fal_generic_inference_job_successfully_done, MarkJobArgs};
use serde_json::Value;
use log::{error, info, warn};
use pager::notification::notification_details_builder::NotificationDetailsBuilder;
use pager::notification::notification_urgency::NotificationUrgency;
use utoipa::ToSchema;
// 1. tauri --> hit endpoint to enqueue
//
// 2. webhook
// - upload media file
// - update job record with media token + status
//
// 3. tauri --> storyteller jobs endpoint polls
// - alert frontend of completion
//
// 4. javascript polls tauri (backend removal)
// TODO(bt, 2025-06-03): Handle webhook crypto authentication
#[derive(Debug, Deserialize, ToSchema)]
pub struct FalWebhookRequest {
pub status: FalWebhookStatus,
pub request_id: Option<String>,
pub gateway_request_id: Option<String>,
pub error: Option<String>,
/// Payload of the webhook, if any.
pub payload: Option<Value>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub enum FalWebhookStatus {
#[serde(alias = "OK")]
Ok,
#[serde(alias = "ERROR")]
Error
}
// =============== Error Response ===============
#[derive(Debug, Serialize, ToSchema)]
@@ -78,6 +45,8 @@ 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 {
@@ -94,97 +63,112 @@ impl From<anyhow::Error> for FalWebhookError {
// =============== Handler ===============
/// Fal webhook
#[utoipa::path(
post,
tag = "Webhooks",
path = "/v1/webhooks/fal",
responses(
(status = 200, description = "Success", body = SimpleGenericJsonSuccess),
(status = 400, description = "Bad input", body = FalWebhookError),
(status = 401, description = "Not authorized", body = FalWebhookError),
(status = 500, description = "Server error", body = FalWebhookError),
),
params(
("request" = FalWebhookRequest, description = "Payload for Request"),
)
)]
// TODO(bt, 2025-06-03): Handle webhook crypto authentication
pub async fn fal_webhook_handler(
http_request: HttpRequest,
request_body_bytes: Bytes,
server_state: web::Data<Arc<ServerState>>,
request: Json<FalWebhookRequest>,
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
info!("Received FAL webhook body: {:?}", request);
let request_id = request.request_id
.as_deref()
.ok_or_else(|| FalWebhookError::BadInput("Missing request_id".to_string()))?;
let payload = request.payload
.as_ref()
.ok_or_else(|| FalWebhookError::BadInput("Missing payload".to_string()))?;
let db_result = get_inference_job_by_fal_id(
request_id,
&server_state.mysql_pool,
).await;
let job = match db_result {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by fal request id: {:?} ; request payload = {:?}", request_id, request);
return Err(FalWebhookError::NotFound)
},
Err(err) => {
warn!("Error querying job record: {:?}", err);
return Err(FalWebhookError::ServerError);
// Step 1: Parse bytes into a UTF-8 string and log it.
let raw_body = String::from_utf8(request_body_bytes.to_vec())
.map_err(|err| {
error!("FAL webhook: could not decode request body to UTF-8: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "UTF-8 decode failed", &err, None);
FalWebhookError::BadInput("Could not decode request body to UTF-8".to_string())
})?;
info!("Received FAL webhook body: {}", raw_body);
// Step 2: Parse into WebhookPayload.
let webhook_payload = parse_webhook_payload(&raw_body)
.map_err(|err| {
error!("FAL webhook: could not parse webhook payload: {:?}", err);
enqueue_parse_error_alert(&server_state, &http_request, "JSON parse failed", &err, Some(&raw_body));
FalWebhookError::BadInput("Could not parse webhook payload".to_string())
})?;
let request_id = webhook_payload.request_id.as_str();
info!("FAL webhook request_id: {} (status: {:?})", request_id, webhook_payload.status);
// Step 3: Parse the inner payload.
let inner_payload = parse_webhook_inner_payload(&webhook_payload);
// Step 4 & 5: Branch on the inner payload type.
let result = match inner_payload {
WebhookInnerPayload::Success(success_data) => {
handle_successful_fal_webhook(&server_state, request_id, &success_data.payload).await
}
WebhookInnerPayload::Error(error_data) => {
handle_failed_fal_webhook(
&server_state,
request_id,
&error_data,
webhook_payload.error.as_deref(),
).await
}
WebhookInnerPayload::PayloadError(payload_error_data) => {
warn!(
"FAL webhook payload_error for request_id {}: {}",
request_id, payload_error_data.payload_error,
);
// Treat payload encoding errors as server errors — the request was OK but
// FAL couldn't encode the payload, so we can't process it.
Err(FalWebhookError::ServerError)
}
};
info!("Fal webhook job record: {:?}", job);
match result {
Ok(success) => Ok(success),
Err(err) => {
error!("FAL webhook error for request_id {}: {:?}", request_id, err);
let mut maybe_media_token = None;
let mut maybe_batch_token = None;
let description = format!(
"FAL webhook failed for request_id: {}\n\nError: {:?}\n\nWebhook JSON Payload: {}",
request_id, err, raw_body,
);
if let Some(payload_obj) = payload.as_object() {
if payload_obj.contains_key("image") {
info!("Handling image payload for job: {:?}", job.job_token);
let token = handle_image_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("images") {
(maybe_media_token, maybe_batch_token) = handle_images_payload(payload_obj, &job, &server_state).await?;
} else if payload_obj.contains_key("video") {
info!("Handling video payload for job: {:?}", job.job_token);
let token = handle_video_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_glb") {
info!("Handling model_glb payload for job: {:?}", job.job_token);
let token = handle_model_glb_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_mesh") {
info!("Handling model_mesh payload for job: {:?}", job.job_token);
let token = handle_model_mesh_payload(payload_obj, &job, &server_state).await?;
maybe_media_token = Some(token);
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 Some(media_token) = maybe_media_token {
info!("Media file token: {:?}", media_token);
// TODO: Update job metadata.
mark_fal_generic_inference_job_successfully_done(MarkJobArgs {
job_token: &job.job_token,
media_file_token: &media_token,
maybe_batch_token: maybe_batch_token.as_ref(),
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error marking job as successfully done: {:?}", err);
FalWebhookError::ServerError
})?;
} else {
warn!("No media token found in payload for job: {:?}", job.job_token);
}
/// Send a pager alert for early parse failures (before we have a request_id).
fn enqueue_parse_error_alert<E: std::fmt::Debug>(
server_state: &ServerState,
http_request: &HttpRequest,
context: &str,
err: &E,
maybe_raw_body: Option<&str>,
) {
let description = match maybe_raw_body {
Some(body) => format!("Error: {:?}\n\nWebhook JSON Payload: {}", err, body),
None => format!("Error: {:?}", err),
};
let notification = NotificationDetailsBuilder::from_title(
format!("FAL webhook parse failure: {}", context))
.set_description(Some(description))
.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 parse error alert: {:?}", pager_err);
}
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
@@ -1,6 +1,3 @@
pub mod fal_webhook_handler;
pub mod handle_image_payload;
pub mod handle_images_payload;
pub mod handle_model_mesh_payload;
pub mod handle_video_payload;
pub mod handle_model_glb_payload;
pub (super) mod process_failure;
pub (super) mod process_success;
@@ -0,0 +1,112 @@
use actix_web::web::Json;
use log::{error, info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token, MarkJobFailedByTokenArgs};
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use fal_client::webhook_api::payload::webhook_error_type::WebhookErrorType;
use fal_client::webhook_api::payload::webhook_inner_payload::ErrorData;
use crate::http_server::endpoints::webhooks::fal_webhook_handler::FalWebhookError;
use crate::state::server_state::ServerState;
/// Map a FAL WebhookErrorType to a frontend failure category.
///
/// Returns `GenerationFailed` as the default if the error type is None or unrecognized.
fn guess_failure_category(error_type: Option<&WebhookErrorType>) -> FrontendFailureCategory {
match error_type {
Some(WebhookErrorType::ContentPolicyViolation) => FrontendFailureCategory::RuleBansUserContent,
Some(WebhookErrorType::FaceDetectionError) => FrontendFailureCategory::FaceNotDetected,
// These all map to GenerationFailed for now, but are listed explicitly,
// so we can refine them later.
Some(WebhookErrorType::NoMediaGenerated)
| Some(WebhookErrorType::ImageTooSmall)
| Some(WebhookErrorType::ImageTooLarge)
| Some(WebhookErrorType::ImageLoadError)
| Some(WebhookErrorType::FileDownloadError)
| Some(WebhookErrorType::FileTooLarge) => FrontendFailureCategory::GenerationFailed,
_ => FrontendFailureCategory::GenerationFailed,
}
}
/// Handle a FAL webhook with status ERROR.
///
/// Looks up the job by request_id and marks it as failed using the parsed error data.
pub async fn handle_failed_fal_webhook(
server_state: &ServerState,
request_id: &str,
error_data: &ErrorData,
maybe_top_level_error: Option<&str>,
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
info!(
"FAL webhook ERROR for request_id {}: top_level_error={:?}, error_type={:?}, message={:?}",
request_id,
maybe_top_level_error,
error_data.error_type,
error_data.message,
);
// Look up the job record.
let job = match get_inference_job_by_fal_id(request_id, &server_state.mysql_pool).await {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by fal request_id: {}", request_id);
return Err(FalWebhookError::NotFound);
}
Err(err) => {
error!("Error querying job record for request_id {}: {:?}", request_id, err);
return Err(FalWebhookError::ServerError);
}
};
// Build a failure reason from the error data or the top-level error string.
let public_failure_reason = if let Some(msg) = &error_data.message {
msg.clone()
} else if let Some(top_level) = maybe_top_level_error {
top_level.to_string()
} else {
"Unknown FAL error".to_string()
};
let internal_failure_reason = format!(
"FAL request_id={}, top_level_error={:?}, error_type={:?}, message={:?}",
request_id,
maybe_top_level_error,
error_data.error_type,
error_data.message,
);
let failure_category = guess_failure_category(error_data.error_type.as_ref());
info!(
"Marking job {} as failed for request_id {}. Category: {:?}, Reason: {}",
job.job_token.as_str(),
request_id,
failure_category,
public_failure_reason,
);
if let Err(err) = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &server_state.mysql_pool,
job_token: &job.job_token,
maybe_public_failure_reason: Some(&public_failure_reason),
internal_debugging_failure_reason: &internal_failure_reason,
maybe_frontend_failure_category: Some(failure_category),
}).await {
error!(
"Error marking job {} as failed for request_id {}: {:?}",
job.job_token.as_str(),
request_id,
err,
);
return Err(FalWebhookError::ServerError);
}
info!(
"Job {} marked as failed for request_id {}.",
job.job_token.as_str(),
request_id,
);
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
@@ -0,0 +1 @@
pub mod handle_failed_fal_webhook;
@@ -0,0 +1,84 @@
use actix_web::web::Json;
use log::{info, warn};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id;
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{mark_fal_generic_inference_job_successfully_done, MarkJobArgs};
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
use serde_json::Value;
use crate::http_server::endpoints::webhooks::fal_webhook_handler::FalWebhookError;
use crate::state::server_state::ServerState;
use super::process_image_payload::process_image_payload;
use super::process_images_payload::process_images_payload;
use super::process_model_glb_payload::process_model_glb_payload;
use super::process_model_mesh_payload::process_model_mesh_payload;
use super::process_video_payload::process_video_payload;
pub async fn handle_successful_fal_webhook(
server_state: &ServerState,
request_id: &str,
payload: &Value,
) -> Result<Json<SimpleGenericJsonSuccess>, FalWebhookError> {
let db_result = get_inference_job_by_fal_id(
request_id,
&server_state.mysql_pool,
).await;
let job = match db_result {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by fal request_id: {}", request_id);
return Err(FalWebhookError::NotFound)
},
Err(err) => {
warn!("Error querying job record for request_id {}: {:?}", request_id, err);
return Err(FalWebhookError::ServerError);
}
};
info!("Fal webhook job record for request_id {}: {:?}", request_id, job);
let mut maybe_media_token = None;
let mut maybe_batch_token = None;
if let Some(payload_obj) = payload.as_object() {
if payload_obj.contains_key("image") {
info!("Handling image payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_image_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("images") {
(maybe_media_token, maybe_batch_token) = process_images_payload(payload_obj, &job, server_state).await?;
} else if payload_obj.contains_key("video") {
info!("Handling video payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_video_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_glb") {
info!("Handling model_glb payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_glb_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
} else if payload_obj.contains_key("model_mesh") {
info!("Handling model_mesh payload for request_id {} / job {:?}", request_id, job.job_token);
let token = process_model_mesh_payload(payload_obj, &job, server_state).await?;
maybe_media_token = Some(token);
}
}
if let Some(media_token) = maybe_media_token {
info!("Media file token for request_id {}: {:?}", request_id, media_token);
mark_fal_generic_inference_job_successfully_done(MarkJobArgs {
job_token: &job.job_token,
media_file_token: &media_token,
maybe_batch_token: maybe_batch_token.as_ref(),
mysql_executor: &server_state.mysql_pool,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error marking job as successfully done for request_id {}: {:?}", request_id, err);
FalWebhookError::ServerError
})?;
} else {
warn!("No media token found in payload for request_id {} / job {:?}", request_id, job.job_token);
}
Ok(SimpleGenericJsonSuccess::wrapped(true))
}
@@ -0,0 +1,6 @@
pub mod handle_successful_fal_webhook;
mod process_image_payload;
mod process_images_payload;
mod process_model_glb_payload;
mod process_model_mesh_payload;
mod process_video_payload;
@@ -29,7 +29,7 @@ pub struct FalWebhookImage {
pub url: Option<String>,
}
pub async fn handle_image_payload(
pub async fn process_image_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
@@ -30,7 +30,7 @@ pub struct FalWebhookImage {
pub url: Option<String>,
}
pub async fn handle_images_payload(
pub async fn process_images_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
@@ -63,7 +63,7 @@ pub struct FalMediaPayload {
pub url: Option<String>,
}
pub async fn handle_model_glb_payload(
pub async fn process_model_glb_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
@@ -25,7 +25,7 @@ pub struct FalWebhookModelMesh {
pub url: Option<String>,
}
pub async fn handle_model_mesh_payload(
pub async fn process_model_mesh_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
@@ -31,7 +31,7 @@ pub struct FalWebhookVideo {
pub url: Option<String>,
}
pub async fn handle_video_payload(
pub async fn process_video_payload(
payload: &Map<String, Value>,
job: &FalJobDetails,
server_state: &ServerState,
@@ -0,0 +1,49 @@
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,8 +6,10 @@ 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;
@@ -121,6 +123,12 @@ 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;
@@ -141,6 +149,12 @@ 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,3 +1,4 @@
mod check_advanced_common_web_error;
mod check_common_web_error;
mod check_status_code_fallback;
@@ -1,17 +1,12 @@
use crate::state::flags::paging_flags::PagingFlags;
use log::{info, warn};
use pager::client::pager::Pager;
use pager::client::pager_builder::PagerBuilder;
use pager::worker::pager_worker::PagerWorker;
use rootly_client::creds::rootly_api_key::RootlyApiKey;
use crate::state::flags::paging_flags::PagingFlags;
/// `storyteller-web` rootly service ID
const ROOTLY_SERVICE_ID: &str = "9ebebb09-0c56-4c8a-8f57-d5f0f85f3f16";
const ROOTLY_URGENCY_ID_HIGH: &str = "62fde143-1258-4639-9ee6-1400ebf7308d";
const ROOTLY_URGENCY_ID_MEDIUM: &str = "7366ba5e-f6ea-4a4e-a1b4-ae43d512e1e2";
const ROOTLY_URGENCY_ID_LOW: &str = "4db3d3ed-f4ed-4818-82f5-7746da404bd2";
use rootly_config::services::ROOTLY_SERVICE_ID_STORYTELLER_WEB;
use rootly_config::urgencies::{ROOTLY_URGENCY_ID_HIGH, ROOTLY_URGENCY_ID_LOW, ROOTLY_URGENCY_ID_MEDIUM};
use shared_env_var_config::paging::{env_optional_rootly_api_key, env_optional_rootly_notification_target_id, env_optional_rootly_notification_target_type};
pub fn build_pager(
server_environment: server_environment::ServerEnvironment,
@@ -31,7 +26,7 @@ pub fn build_pager(
.application_name("storyteller-web".to_string())
.environment(environment.to_string())
.hostname(hostname.to_string())
.service_id(ROOTLY_SERVICE_ID.to_string());
.service_id(ROOTLY_SERVICE_ID_STORYTELLER_WEB.to_string());
// If paging is globally disabled, use a NoOp pager regardless of API key.
if !paging_flags.is_paging_enabled {
@@ -40,7 +35,7 @@ pub fn build_pager(
return (pager, worker, paging_flags);
}
let maybe_api_key = easyenv::get_env_string_optional("ROOTLY_API_KEY");
let maybe_api_key = env_optional_rootly_api_key();
let (pager, worker) = match maybe_api_key {
Some(api_key) => {
@@ -63,8 +58,8 @@ fn build_rootly_pager(builder: PagerBuilder, api_key: String) -> (Pager, PagerWo
.urgency_id_medium(ROOTLY_URGENCY_ID_MEDIUM.to_string())
.urgency_id_low(ROOTLY_URGENCY_ID_LOW.to_string());
let target_type = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_TYPE");
let target_id = easyenv::get_env_string_optional("ROOTLY_NOTIFICATION_TARGET_ID");
let target_type = env_optional_rootly_notification_target_type();
let target_id = env_optional_rootly_notification_target_id();
if let (Some(t_type), Some(t_id)) = (target_type, target_id) {
rootly_builder = rootly_builder.notification_target(t_type, t_id);
@@ -1,3 +1,5 @@
use shared_env_var_config::paging::{env_enable_paging_default_false, env_enable_paging_for_500s_default_false};
/// Flags controlling the pager system.
#[derive(Clone, Debug)]
pub struct PagingFlags {
@@ -14,8 +16,8 @@ pub struct PagingFlags {
impl PagingFlags {
pub fn from_env() -> Self {
Self {
is_paging_enabled: easyenv::get_env_bool_or_default("ENABLE_PAGING", false),
is_paging_for_500s_enabled: easyenv::get_env_bool_or_default("ENABLE_PAGING_FOR_500S", false),
is_paging_enabled: env_enable_paging_default_false(),
is_paging_for_500s_enabled: env_enable_paging_for_500s_default_false(),
}
}
}
@@ -35,7 +35,7 @@ pub async fn db_health_checker_thread(
Ok(_) => {},
}
let notification = NotificationDetailsBuilder::from_error(&database_error)
let notification = NotificationDetailsBuilder::from_boxed_error(database_error.into())
.set_title("DB health check thread failed check".to_string())
.set_urgency(Some(NotificationUrgency::High))
.build();
@@ -6,6 +6,7 @@ import {
faGithubAlt,
faLinkedinIn,
faInstagram,
faRedditAlien,
} from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../config/links";
import { getFaqItems, getTutorialItems } from "@storyteller/markdown-content";
@@ -51,6 +52,11 @@ const navigation = {
href: SOCIAL_LINKS.LINKEDIN,
icon: (props: any) => <FontAwesomeIcon icon={faLinkedinIn} {...props} />,
},
{
name: "Reddit",
href: SOCIAL_LINKS.REDDIT,
icon: (props: any) => <FontAwesomeIcon icon={faRedditAlien} {...props} />,
},
],
};
@@ -5,4 +5,5 @@ export const SOCIAL_LINKS = {
GITHUB: "https://github.com/storytold/artcraft",
INSTAGRAM: "https://www.instagram.com/get_artcraft",
LINKEDIN: "https://www.linkedin.com/company/artcraft-ai",
REDDIT: "https://www.reddit.com/r/ArtCraftAI/",
} as const;
@@ -96,6 +96,7 @@ const Pricing = () => {
<PricingTable
title="Invest in Yourself"
subtitle="You'll get a ton of generations and you'll be investing in a tool that you'll always own."
showSeedanceFeatures
showEnterprise
/>
</main>
@@ -107,8 +108,6 @@ const Pricing = () => {
&dagger; ArtCraft can be used without paying for a subscription. You
can bring your own compute and third party subscriptions. We hope
you'll subscribe, though, as that helps accelerate our development.
Pricing table is a placeholder. We charge below FAL API pricing, which
is highly competitive.
</p>
</div>
@@ -0,0 +1,171 @@
import { useEffect, useRef, useState } from "react";
import { faCube, faImages, faUpRightAndDownLeftFromCenter } from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UploadModal3D } from "../reusable/UploadModal3D/UploadModal3D";
import { UploadModalImage } from "../reusable/UploadModalImage/UploadModalImage";
import { UploadModalSplat } from "../reusable/UploadModalSplat/UploadModalSplat";
import { FilterEngineCategories } from "../../enums";
type ModalType = "3d" | "image" | "splat" | null;
function getModalTypeForFileName(name: string): ModalType {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "glb") return "3d";
if (ext === "png" || ext === "jpg" || ext === "jpeg") return "image";
if (ext === "spz") return "splat";
return null;
}
function isAnyModalOpen(): boolean {
return !!document.querySelector("[data-radix-dialog-content]");
}
const isTauri = typeof window !== "undefined" && "__TAURI__" in window;
export function GlobalFileDropHandler() {
const dragCounter = useRef(0);
const [isDragging, setIsDragging] = useState(false);
const [modalType, setModalType] = useState<ModalType>(null);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const closeModal = () => {
setModalType(null);
setPendingFile(null);
};
useEffect(() => {
if (isTauri) {
const unlisteners: Array<() => void> = [];
const setup = async () => {
try {
const { getCurrentWebviewWindow } = await import("@tauri-apps/api/webviewWindow");
const { convertFileSrc } = await import("@tauri-apps/api/core");
const appWindow = getCurrentWebviewWindow();
const unlisten = await appWindow.onDragDropEvent(async (event) => {
const payload = event.payload;
if (payload.type === "enter") {
if (!isAnyModalOpen()) setIsDragging(true);
} else if (payload.type === "over") {
// overlay stays visible
} else if (payload.type === "drop") {
setIsDragging(false);
if (isAnyModalOpen()) return;
const path = payload.paths[0];
if (!path) return;
const fileName = path.split(/[/\\]/).pop() ?? "file";
const type = getModalTypeForFileName(fileName);
if (type === null) return;
try {
const assetUrl = convertFileSrc(path);
const response = await fetch(assetUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blob = await response.blob();
const file = new File([blob], fileName);
await appWindow.setFocus();
setModalType(type);
setPendingFile(file);
} catch (err) {
console.error("[DragDrop] file read failed:", err);
}
} else {
setIsDragging(false);
}
});
unlisteners.push(unlisten);
} catch (err) {
console.error("[DragDrop] setup failed:", err);
}
};
setup();
return () => { unlisteners.forEach((fn) => fn()); };
} else {
// HTML5 path (browser dev mode)
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.types.includes("Files")) return;
if (isAnyModalOpen()) return;
dragCounter.current++;
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes("Files")) return;
dragCounter.current--;
if (dragCounter.current <= 0) { dragCounter.current = 0; setIsDragging(false); }
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
};
const handleDrop = (e: DragEvent) => {
if (!e.dataTransfer?.types.includes("Files")) return;
e.preventDefault();
setIsDragging(false);
dragCounter.current = 0;
if (isAnyModalOpen()) return;
const file = e.dataTransfer?.files[0];
if (!file) return;
const type = getModalTypeForFileName(file.name);
if (type === null) return;
setModalType(type);
setPendingFile(file);
};
window.addEventListener("dragenter", handleDragEnter);
window.addEventListener("dragleave", handleDragLeave);
window.addEventListener("dragover", handleDragOver);
window.addEventListener("drop", handleDrop);
return () => {
window.removeEventListener("dragenter", handleDragEnter);
window.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragover", handleDragOver);
window.removeEventListener("drop", handleDrop);
};
}
}, []);
return (
<>
{isDragging && modalType === null && (
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/40">
<div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-white/60 bg-black/30 px-16 py-12 text-white backdrop-blur-sm">
<FontAwesomeIcon icon={faUpRightAndDownLeftFromCenter} className="text-4xl opacity-80" />
<div className="text-xl font-semibold">Drop to Upload</div>
<div className="text-sm opacity-60">GLB, PNG, JPG, JPEG, SPZ</div>
</div>
</div>
)}
<UploadModal3D
isOpen={modalType === "3d"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={(_category: FilterEngineCategories) => closeModal()}
title="Upload a 3D Model"
titleIcon={faCube}
/>
<UploadModalImage
isOpen={modalType === "image"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={() => closeModal()}
title="Upload an Image"
titleIcon={faImages}
/>
<UploadModalSplat
isOpen={modalType === "splat"}
initialFile={pendingFile ?? undefined}
onClose={closeModal}
onSuccess={() => {}}
title="Upload a Splat"
titleIcon={faCube}
/>
</>
);
}
@@ -19,6 +19,9 @@ interface UploadEntryCardProps {
secondaryLabel?: string;
secondaryIcon?: IconDefinition;
onSecondaryClick?: () => void;
tertiaryLabel?: string;
tertiaryIcon?: IconDefinition;
onTertiaryClick?: () => void;
disabled?: boolean;
}
@@ -36,6 +39,9 @@ export const UploadEntryCard = ({
secondaryLabel,
secondaryIcon,
onSecondaryClick,
tertiaryLabel,
tertiaryIcon,
onTertiaryClick,
disabled,
}: UploadEntryCardProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -162,6 +168,17 @@ export const UploadEntryCard = ({
{secondaryLabel}
</Button>
)}
{tertiaryLabel && onTertiaryClick && (
<Button
variant="action"
icon={tertiaryIcon}
onClick={onTertiaryClick}
className="border-2 px-8 py-3 text-base font-semibold"
disabled={disabled}
>
{tertiaryLabel}
</Button>
)}
</div>
</div>
</div>
@@ -13,6 +13,7 @@ interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -25,6 +26,7 @@ interface Props {
export const UploadFiles3D = ({
fileTypes,
engineCategory,
initialFile,
options,
onClose,
onUploadProgress,
@@ -49,7 +51,7 @@ export const UploadFiles3D = ({
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
@@ -143,7 +145,7 @@ export const UploadFiles3D = ({
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
className="pointer-events-none h-full !w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (
@@ -22,6 +22,7 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -32,7 +33,7 @@ interface Props {
const objectFileTypes = Object.values(OBJECT_FILE_TYPE);
export function UploadModal3D(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon, options } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile, options } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
const [isCharacter, setIsCharacter] = useState(false);
@@ -101,6 +102,7 @@ export function UploadModal3D(props: Props) {
title={title}
engineCategory={selectedCategory}
fileTypes={objectFileTypes}
initialFile={initialFile}
options={{
...(options ?? {}),
fileSubtypes: characterAnimationOptions,
@@ -7,19 +7,21 @@ import { UploaderState } from "../../../models";
interface Props {
title: string;
fileTypes: string[];
initialFile?: File;
onClose: () => void;
onUploadProgress: (newState: UploaderState) => void;
}
export const UploadFilesImage = ({
fileTypes,
initialFile,
onClose,
onUploadProgress,
}: Props) => {
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const handleSubmit = () => {
if (!assetFile.value) {
@@ -21,12 +21,13 @@ interface Props {
isOpen: boolean;
title: string;
titleIcon: IconDefinition;
initialFile?: File;
}
const imageFileTypes = Object.values(IMAGEPLANE_FILE_TYPE);
export function UploadModalImage(props: Props) {
const { isOpen, onClose, onSuccess, title, titleIcon } = props;
const { isOpen, onClose, onSuccess, title, titleIcon, initialFile } = props;
const [uploaderState, setUploaderState] =
useState<UploaderState>(initialUploaderState);
@@ -67,6 +68,7 @@ export function UploadModalImage(props: Props) {
<UploadFilesImage
title={title}
fileTypes={imageFileTypes}
initialFile={initialFile}
onClose={onClose}
onUploadProgress={updateUploaderState}
/>
@@ -14,6 +14,7 @@ interface Props {
title: string;
fileTypes: string[];
engineCategory: FilterEngineCategories;
initialFile?: File;
options?: {
fileSubtypes?: { [key: string]: string }[];
hasLength?: boolean;
@@ -27,6 +28,7 @@ interface Props {
export const UploadFilesSplat = ({
fileTypes,
engineCategory,
initialFile,
options,
onClose,
onUploadProgress,
@@ -52,7 +54,7 @@ export const UploadFilesSplat = ({
const [assetFile, setAssetFile] = useState<{
value: File | null;
error?: string;
}>({ value: null });
}>({ value: initialFile ?? null });
const [previewStatus, setPreviewStatus] = useState<{
type: string;
@@ -166,7 +168,7 @@ export const UploadFilesSplat = ({
<div className="relative m-auto w-full overflow-hidden rounded-lg bg-brand-secondary">
<canvas
className="pointer-events-none h-full w-full"
className="pointer-events-none h-full !w-full"
ref={canvasCallbackRef}
/>
{!assetFile.value && (

Some files were not shown because too many files have changed in this diff Show More