Compare commits

...
Author SHA1 Message Date
Brandon Thomas 585320c499 cleanup routes 2026-03-11 00:12:51 -04:00
Brandon Thomas 649c71d62e codegen db 2026-03-11 00:08:40 -04:00
Brandon Thomas 4348d1da42 cleanup 2026-03-11 00:08:20 -04:00
Brandon Thomas 13ac5eb12b handler to create wallet for user 2026-03-11 00:05:43 -04:00
Brandon Thomas dd630c5993 tauri task failure reasons (#1130) 2026-03-10 23:27:55 -04:00
Brandon Thomas b39f208bd3 add robust job failure reasons to seedance (#1129) 2026-03-10 19:30:31 -04:00
Brandon Thomas 53f34061b6 CLI tool for support issues (#1128) 2026-03-10 17:06:38 -04:00
53 changed files with 1545 additions and 471 deletions
+1
View File
@@ -6,6 +6,7 @@
.env-elasticsearch-secrets
.env-migration-tool-secrets
.env-secrets
.env-support-tool-secrets
_develop/tools/.secrets
_develop/tools/db_secrets.toml
crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"query": "\n SELECT\n id,\n task_status,\n task_type,\n model_type,\n provider,\n provider_job_id,\n frontend_caller,\n frontend_subscriber_id,\n frontend_subscriber_payload,\n on_complete_primary_media_file_token,\n on_complete_primary_media_file_class,\n on_complete_batch_token,\n on_complete_primary_media_file_cdn_url,\n on_complete_primary_media_file_thumbnail_url_template,\n on_failure_type,\n on_failure_message,\n created_at as \"created_at: DateTime<Utc>\",\n updated_at as \"updated_at: DateTime<Utc>\",\n completed_at as \"completed_at: DateTime<Utc>\"\n FROM tasks\n WHERE is_dismissed_by_user == 0\n ",
"describe": {
"columns": [
{
@@ -74,18 +74,28 @@
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"name": "on_failure_type",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "on_failure_message",
"ordinal": 15,
"type_info": "Text"
},
{
"name": "created_at: DateTime<Utc>",
"ordinal": 16,
"type_info": "Int64"
},
{
"name": "updated_at: DateTime<Utc>",
"ordinal": 15,
"ordinal": 17,
"type_info": "Int64"
},
{
"name": "completed_at: DateTime<Utc>",
"ordinal": 16,
"ordinal": 18,
"type_info": "Int64"
}
],
@@ -107,10 +117,12 @@
true,
true,
true,
true,
true,
false,
false,
true
]
},
"hash": "47f94c127abc20683b25c257aed3c2190b9bd29eb829d61392f08e10fd986767"
"hash": "2436eb7bf4b0e23a899311a67a77f71d89eb35461e2113e43cd9d221bfa91c9a"
}
@@ -0,0 +1,58 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n username,\n display_name,\n username_is_generated as `username_is_generated: bool`,\n username_is_not_customized as `username_is_not_customized: bool`\nFROM users\nWHERE\n token = ?\n AND user_deleted_at IS NULL\n AND mod_deleted_at IS NULL\nLIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 1,
"name": "display_name",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 80
}
},
{
"ordinal": 2,
"name": "username_is_generated: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
},
{
"ordinal": 3,
"name": "username_is_not_customized: bool",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"char_set": 63,
"max_size": 1
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "be9867966f24f44e14f96966cf5113055f00c339dda75334e7e88d355b9bfe72"
}
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO wallets\nSET\n token = ?,\n wallet_namespace = ?,\n\n owner_user_token = ?,\n \n banked_credits = 0,\n monthly_credits = 0\n ",
"query": "\nINSERT INTO wallets\nSET\n token = ?,\n wallet_namespace = ?,\n\n owner_user_token = ?,\n\n banked_credits = 0,\n monthly_credits = 0\n ",
"describe": {
"columns": [],
"parameters": {
@@ -8,5 +8,5 @@
},
"nullable": []
},
"hash": "4f5100a2cfb465964e77eaacb1ae394daae94de20c56a0966a8222dc9bea63c6"
"hash": "f4fb945b4311bd4458a68d82e417b40f8c36c08d572c07881b89cd157a8a8699"
}
Generated
+34 -20
View File
@@ -578,9 +578,9 @@ dependencies = [
[[package]]
name = "anstyle"
version = "1.0.7"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
@@ -2225,9 +2225,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.13"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbb260a053428790f3de475e304ff84cdbc4face759ea7a3e64c1edd938a7fc"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
@@ -2235,21 +2235,21 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.13"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64b17d7ea74e9f833c7dbf2cbe4fb12ff26783eda4782a8975b72f895c9b4d99"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex 0.7.4",
"clap_lex 1.0.0",
"strsim 0.11.1",
]
[[package]]
name = "clap_derive"
version = "4.5.13"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "501d359d5f3dcaf6ecdeee48833ae73ec6e42723a1e52419c79abf9507eec0a0"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck 0.5.0",
"proc-macro2",
@@ -2268,9 +2268,9 @@ dependencies = [
[[package]]
name = "clap_lex"
version = "0.7.4"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cloud_storage"
@@ -3068,7 +3068,7 @@ version = "0.0.1"
dependencies = [
"anyhow",
"chrono",
"clap 4.5.13",
"clap 4.5.60",
"datetimes",
"dotenv",
"easyenv",
@@ -3103,7 +3103,7 @@ name = "db-cleanup"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"dotenv",
"easyenv",
"enums",
@@ -3263,7 +3263,7 @@ name = "dev-database-seed"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -3289,7 +3289,7 @@ name = "dev-upload-media-file"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -3763,7 +3763,7 @@ name = "elasticsearch-cli"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"config",
"easyenv",
"elasticsearch",
@@ -6744,7 +6744,7 @@ dependencies = [
name = "lru-delete-models"
version = "0.0.1"
dependencies = [
"clap 4.5.13",
"clap 4.5.60",
"errors",
"logging",
"walkdir",
@@ -7054,7 +7054,7 @@ name = "migration-tool"
version = "0.0.1"
dependencies = [
"bucket_paths",
"clap 4.5.13",
"clap 4.5.60",
"cloud_storage",
"config",
"easyenv",
@@ -10316,7 +10316,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
name = "scrape-test"
version = "0.0.1"
dependencies = [
"clap 4.5.13",
"clap 4.5.60",
"errors",
"tokio",
"web_scrapers",
@@ -11749,6 +11749,20 @@ dependencies = [
"rsa",
]
[[package]]
name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"clap 4.5.60",
"easyenv",
"errors",
"log",
"seedance2pro",
"serde_json",
"tokio",
]
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -14914,7 +14928,7 @@ dependencies = [
"bytes",
"cc",
"chrono",
"clap 4.5.13",
"clap 4.5.60",
"cookie 0.18.1",
"core-foundation-sys",
"crossbeam-utils",
+3
View File
@@ -33,6 +33,7 @@ members = [
"crates/cli/production/kubectl-pod-cleanup",
"crates/cli/production/lru-delete-models",
"crates/cli/production/migration-tool",
"crates/cli/production/support-tool",
# Desktop apps
"crates/desktop/artcraft",
@@ -222,6 +223,7 @@ component_traits = { path = "crates/service/plugins/component_traits" }
cookie_store = { path = "crates/lib/cookie_store" }
cookies = { path = "crates/lib/cookies" }
crockford = { path = "crates/lib/data/crockford" }
easyenv = { path = "crates/lib/easyenv" }
enums = { path = "crates/schema/public/enums" }
enums_public = { path = "crates/schema/public/enums_public" }
errors = { path = "crates/lib/errors" }
@@ -271,6 +273,7 @@ base64 = "0.22.1"
bstr = "1.12.1"
bytes = "1.10.1"
chrono = { version = "0.4.41", features = ["serde"] }
clap = { version = "4.5.60", features = ["derive"] }
cookie = "0.18.1"
env = "1.0.1"
env_logger = "0.11.9"
@@ -16,7 +16,9 @@
-- on_complete_primary_media_file_class,
-- on_complete_primary_media_file_cdn_url,
-- on_complete_primary_media_file_thumbnail_url_template,
--
-- tasks_v6.sqlite - added two fields:
-- on_failure_type
-- on_failure_message
CREATE TABLE tasks (
-- Task auto-incrementing primary key.
@@ -84,6 +86,15 @@ CREATE TABLE tasks (
-- The URL template for the thumbnail of the primary media file.
on_complete_primary_media_file_thumbnail_url_template TEXT,
-- OPTIONAL.
-- An enum-like string describing the failure reason (even for non-terminal failures).
-- The enum is `TaskFailureType`, which is a close analogue of the backend API's `FrontendFailureCategory`.
on_failure_type TEXT,
-- OPTIONAL.
-- A human-readable message describing the failure reason (even for non-terminal failures).
on_failure_message TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
updated_at INTEGER NOT NULL DEFAULT (unixepoch('now')),
completed_at INTEGER DEFAULT NULL
@@ -1,3 +1,4 @@
pub mod list_user_wallets;
pub mod moderator_add_banked_balance_to_wallet;
pub mod moderator_create_wallet_for_user;
pub mod moderator_get_wallet;
@@ -0,0 +1,19 @@
use enums::common::payments_namespace::PaymentsNamespace;
use serde_derive::{Deserialize, Serialize};
use tokens::tokens::users::UserToken;
use tokens::tokens::wallets::WalletToken;
use utoipa::ToSchema;
pub const MODERATOR_CREATE_WALLET_FOR_USER_PATH: &str = "/v1/moderation/wallet/create_for_user";
#[derive(Deserialize, ToSchema)]
pub struct ModeratorCreateWalletForUserRequest {
pub user_token: Option<UserToken>,
pub payments_namespace: Option<PaymentsNamespace>,
}
#[derive(Serialize, ToSchema)]
pub struct ModeratorCreateWalletForUserResponse {
pub success: bool,
pub wallet_token: WalletToken,
}
@@ -0,0 +1,26 @@
use super::failure_type::FailureType;
/// A structured failure reason parsed from the raw Seedance2 Pro API response.
#[derive(Debug, Clone)]
pub struct FailureReason {
/// The original reason string from the API.
pub reason: String,
/// The categorized failure type.
pub failure_type: FailureType,
}
impl FailureReason {
/// Parse a raw failure reason string into a structured `FailureReason`.
///
/// First checks for exact string matches against known reasons,
/// then falls back to case-insensitive substring matching.
pub fn from_reason(reason: &str) -> Self {
let failure_type = FailureType::classify_text(reason);
FailureReason {
reason: reason.to_string(),
failure_type,
}
}
}
@@ -0,0 +1,84 @@
/// Categorized failure type for a Seedance2 Pro order.
#[derive(Debug, Clone, PartialEq)]
pub enum FailureType {
/// User's uploaded image was rejected by platform content rules.
RuleBansUserImage,
/// User's uploaded image was rejected because it contains faces.
RuleBansUserImageWithFaces,
/// User's text prompt was rejected by platform content rules.
RuleBansUserTextPrompt,
/// User's content (image or text) was rejected by platform content rules (generic).
RuleBansUserContent,
/// The generated video was rejected by platform content review.
RuleBansGeneratedVideo,
/// The generated audio was rejected by platform content rules.
RuleBansGeneratedAudio,
/// The generated content (video/audio/other) was rejected by platform content rules (generic).
RuleBansGeneratedContent,
/// Video generation failed (timeout, server error, processing error, etc.)
GenerationFailed,
/// An unrecognized or absent failure reason.
OtherUnknownReason,
}
impl FailureType {
pub fn classify_text(reason: &str) -> Self {
// --- Exact matches first ---
match reason {
"Your uploaded image violates platform rules. Please modify and try again." =>
return Self::RuleBansUserImage,
"Face detected in uploaded media. Please adjust your media and try again." =>
return Self::RuleBansUserImageWithFaces,
"Your input text violates platform rules. Please modify and try again." =>
return Self::RuleBansUserTextPrompt,
"Content violates platform rules. Please modify and try again." =>
return Self::RuleBansUserContent,
"The generated video did not pass review. Credits will not be deducted." =>
return Self::RuleBansGeneratedVideo,
"The generated audio violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedAudio,
"The generated content violates platform rules. Please adjust your prompt or images and try again." =>
return Self::RuleBansGeneratedContent,
"Video generation failed. Please try again." =>
return Self::GenerationFailed,
"Generation timed out. Please try again." =>
return Self::GenerationFailed,
"Server error. Please try again later." =>
return Self::GenerationFailed,
"Your content could not be processed. Please try different images or adjust your prompt." =>
return Self::GenerationFailed,
_ => {}
}
// --- Substring matches (case-insensitive) ---
let lower = reason.to_lowercase();
if lower.contains("face detected") || lower.contains("ensure no faces") {
return Self::RuleBansUserImageWithFaces;
}
if lower.contains("uploaded image violates") {
return Self::RuleBansUserImage;
}
if lower.contains("input text violates") {
return Self::RuleBansUserTextPrompt;
}
if lower.contains("generated video") && lower.contains("not pass review") {
return Self::RuleBansGeneratedVideo;
}
if lower.contains("generated audio") && lower.contains("violates") {
return Self::RuleBansGeneratedAudio;
}
if lower.contains("generated content") && lower.contains("violates") {
return Self::RuleBansGeneratedContent;
}
if lower.contains("content violates") || lower.contains("platform rules") {
return Self::RuleBansUserContent;
}
if lower.contains("video generation failed") || lower.contains("timed out") || lower.contains("server error") {
return Self::GenerationFailed;
}
Self::OtherUnknownReason
}
}
@@ -1,2 +1,4 @@
pub mod failure_reason;
pub mod failure_type;
pub mod poll_orders;
mod request_types;
@@ -2,6 +2,7 @@ use crate::creds::seedance2pro_session::Seedance2ProSession;
use crate::error::seedance2pro_client_error::Seedance2ProClientError;
use crate::error::seedance2pro_error::Seedance2ProError;
use crate::error::seedance2pro_generic_api_error::Seedance2ProGenericApiError;
use crate::requests::poll_orders::failure_reason::FailureReason;
use crate::requests::poll_orders::request_types::*;
use crate::utils::common_headers::FIREFOX_USER_AGENT;
use log::info;
@@ -10,17 +11,6 @@ use wreq_util::Emulation;
const GET_ORDERS_BASE_URL: &str = "https://seedance2-pro.com/api/trpc/userOrder.getOrders";
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
// --- Args & response ---
@@ -97,8 +87,8 @@ pub struct OrderStatus {
/// Detailed result entries. Typically one entry per video.
pub results: Vec<VideoResult>,
/// Failure reason. Populated when `task_status` is `Failed`.
pub fail_reason: Option<String>,
/// Structured failure reason. Populated when `task_status` is `Failed` or `fail_reason` is present.
pub fail_reason: Option<FailureReason>,
/// ISO 8601 creation timestamp (e.g. `"2026-02-19T01:20:50.398Z"`).
pub created_at: String,
@@ -172,18 +162,25 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
let orders: Vec<OrderStatus> = json.orders
.into_iter()
.map(|o| OrderStatus {
order_id: o.order_id,
task_status: TaskStatus::from_str(&o.task_status),
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
// ratio: r.ratio,
}).collect(),
fail_reason: o.fail_reason,
created_at: o.created_at,
.map(|o| {
let task_status = TaskStatus::from_str(&o.task_status);
let fail_reason = match (&o.fail_reason, &task_status) {
(Some(reason), _) => Some(FailureReason::from_reason(reason)),
(None, TaskStatus::Failed) => Some(FailureReason::from_reason("(no reason)")),
_ => None,
};
OrderStatus {
order_id: o.order_id,
task_status,
result_url: o.result_url,
results: o.results.into_iter().map(|r| VideoResult {
url: r.url,
width: r.width,
height: r.height,
}).collect(),
fail_reason,
created_at: o.created_at,
}
})
.collect();
@@ -192,6 +189,18 @@ pub async fn poll_orders(args: PollOrdersArgs<'_>) -> Result<PollOrdersResponse,
Ok(PollOrdersResponse { orders, next_cursor })
}
/// Builds the tRPC `input` JSON for the getOrders endpoint.
/// When `cursor` is `Some`, it is included in the JSON payload.
fn build_input_json(cursor: Option<u64>) -> String {
match cursor {
None => r#"{"0":{"json":{"limit":30,"format":null,"direction":"forward"},"meta":{"values":{"format":["undefined"]},"v":1}}}"#.to_string(),
Some(c) => format!(
r#"{{"0":{{"json":{{"limit":30,"format":null,"cursor":{cursor},"direction":"forward"}},"meta":{{"values":{{"format":["undefined"]}},"v":1}}}}}}"#,
cursor = c
),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -0,0 +1,22 @@
[package]
name = "support-tool"
edition = "2024"
version = "0.0.1"
[[bin]]
name = "support-tool"
path = "src/main.rs"
[dependencies]
easyenv.workspace = true
# Internal
errors.workspace = true
seedance2pro.workspace = true
# External
anyhow.workspace = true
clap.workspace = true
log.workspace = true
serde_json.workspace = true
tokio.workspace = true
@@ -0,0 +1,2 @@
pub mod run;
pub mod seedance2pro;
@@ -0,0 +1,36 @@
use clap::{Parser, Subcommand};
use super::seedance2pro;
/// All canonical subcommand names across all modules.
/// Used by the underscore-insensitive arg normalizer.
pub fn all_canonical_names() -> Vec<&'static str> {
let mut names: Vec<&str> = vec!["seedance2pro"];
names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES);
names
}
#[derive(Parser)]
#[command(name = "support-tool", about = "Production support CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: TopLevelCommand,
}
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
}
}
@@ -0,0 +1,31 @@
use anyhow::anyhow;
use clap::Subcommand;
use super::state::Seedance2ProState;
use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["find_job", "failed_job_histogram"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum Seedance2proCommand {
/// Find a job by its order ID across all pages
FindJob(subcommands::find_job::FindJobArgs),
/// Scan all jobs and print a histogram of failure reasons
FailedJobHistogram,
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
let cookies = easyenv::get_env_string_required("SEEDANCE2PRO_COOKIES")
.map_err(|err| anyhow!("Missing SEEDANCE2PRO_COOKIES env var: {:?}", err))?;
let state = Seedance2ProState { cookies };
match command {
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
}
}
@@ -0,0 +1,6 @@
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::Seedance2proCommand;
pub use dispatch::run;
@@ -0,0 +1,3 @@
pub struct Seedance2ProState {
pub cookies: String,
}
@@ -0,0 +1,94 @@
use std::collections::BTreeMap;
use anyhow::anyhow;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::failure_type::FailureType;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs, TaskStatus};
use super::super::state::Seedance2ProState;
pub async fn run(state: &Seedance2ProState) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
let mut total_orders = 0usize;
let mut failed_count = 0usize;
let mut histogram: BTreeMap<String, usize> = BTreeMap::new();
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
let page_count = result.orders.len();
total_orders += page_count;
for order in &result.orders {
if order.task_status == TaskStatus::Failed {
failed_count += 1;
let reason = order.fail_reason.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("(no reason)");
*histogram.entry(reason.to_string()).or_insert(0) += 1;
}
}
info!("Page {}: {} orders ({} failed so far).", page, page_count, failed_count);
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
println!("\nScanned {} orders across {} pages.", total_orders, page);
println!("Failed: {}\n", failed_count);
if histogram.is_empty() {
println!("No failed jobs found.");
return Ok(());
}
// Sort by count descending.
let mut sorted: Vec<(&String, &usize)> = histogram.iter().collect();
sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("{:<6} {}", "Count", "Failure Reason");
println!("{:<6} {}", "-----", "--------------");
for (reason, count) in &sorted {
println!("{:<6} {}", count, reason);
}
// Build a second histogram by FailureType.
let mut type_histogram: BTreeMap<String, usize> = BTreeMap::new();
for (reason, count) in &histogram {
let failure_type = FailureType::classify_text(reason);
*type_histogram.entry(format!("{:?}", failure_type)).or_insert(0) += count;
}
let mut type_sorted: Vec<(&String, &usize)> = type_histogram.iter().collect();
type_sorted.sort_by(|a, b| b.1.cmp(a.1));
println!("\n{:<6} {}", "Count", "Failure Type");
println!("{:<6} {}", "-----", "------------");
for (failure_type, count) in &type_sorted {
println!("{:<6} {}", count, failure_type);
}
// Print reason → type mapping table.
println!("\n{:<30} {}", "Failure Type", "Failure Reason");
println!("{:<30} {}", "------------", "--------------");
for (reason, _) in &sorted {
let failure_type = FailureType::classify_text(reason);
println!("{:<30} {}", format!("{:?}", failure_type), reason);
}
Ok(())
}
@@ -0,0 +1,67 @@
use anyhow::anyhow;
use clap::Args;
use log::info;
use seedance2pro::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro::requests::poll_orders::poll_orders::{poll_orders, PollOrdersArgs};
use super::super::state::Seedance2ProState;
#[derive(Args)]
pub struct FindJobArgs {
/// The order ID (job token) to search for
#[arg(long)]
pub token: String,
}
pub async fn run(state: &Seedance2ProState, args: FindJobArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let mut cursor: Option<u64> = None;
let mut page = 0usize;
loop {
page += 1;
info!("Fetching page {} (cursor: {:?})...", page, cursor);
let result = poll_orders(PollOrdersArgs {
session: &session,
cursor,
}).await
.map_err(|err| anyhow!("Error polling orders on page {}: {:?}", page, err))?;
for order in &result.orders {
if order.order_id == args.token {
println!("{}", serde_json::to_string_pretty(&order_to_json(order))?);
return Ok(());
}
}
info!("Page {}: checked {} orders, no match.", page, result.orders.len());
cursor = result.next_cursor;
if cursor.is_none() {
break;
}
}
eprintln!("Order '{}' not found after {} pages.", args.token, page);
std::process::exit(1);
}
fn order_to_json(order: &seedance2pro::requests::poll_orders::poll_orders::OrderStatus) -> serde_json::Value {
serde_json::json!({
"order_id": order.order_id,
"task_status": format!("{:?}", order.task_status),
"result_url": order.result_url,
"fail_reason": order.fail_reason.as_ref().map(|fr| fr.reason.as_str()),
"failure_type": order.fail_reason.as_ref().map(|fr| format!("{:?}", fr.failure_type)),
"created_at": order.created_at,
"results": order.results.iter().map(|r| {
serde_json::json!({
"url": r.url,
"width": r.width,
"height": r.height,
})
}).collect::<Vec<_>>(),
})
}
@@ -0,0 +1,2 @@
pub mod failed_job_histogram;
pub mod find_job;
@@ -0,0 +1,24 @@
mod commands;
mod utils;
use clap::Parser;
use commands::run::{Cli, all_canonical_names};
use utils::normalize_subcommands::normalize_subcommand_args;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// Normalize args so underscores in subcommand names are optional.
let canonical_names = all_canonical_names();
let args = normalize_subcommand_args(std::env::args(), &canonical_names);
// Parse CLI args first so --help works without the env file.
let cli = Cli::parse_from(args);
// Load secrets from .env file.
easyenv::from_filename(".env-support-tool-secrets")
.expect("Failed to load .env-support-tool-secrets");
easyenv::init_env_logger(Some("info"));
commands::run::run(cli).await
}
@@ -0,0 +1 @@
pub mod normalize_subcommands;
@@ -0,0 +1,27 @@
/// Normalize CLI args so that underscores in subcommand names are optional.
///
/// For each arg that isn't a flag (doesn't start with `-`), if removing all
/// underscores from the arg matches removing all underscores from a canonical
/// name, the arg is replaced with the canonical name.
///
/// This lets users type `findjob`, `find_job`, or even `f_ind_job` and all
/// resolve to the canonical `find_job`.
pub fn normalize_subcommand_args(
args: impl IntoIterator<Item = String>,
canonical_names: &[&str],
) -> Vec<String> {
args.into_iter()
.map(|arg| {
if arg.starts_with('-') {
return arg;
}
let stripped = arg.replace('_', "").to_lowercase();
for &name in canonical_names {
if name.replace('_', "").to_lowercase() == stripped {
return name.to_string();
}
}
arg
})
.collect()
}
@@ -18,6 +18,7 @@ use serde_derive::{Deserialize, Serialize};
use sqlite_tasks::queries::list_tasks_for_frontend::list_tasks_for_frontend;
use artcraft_client::endpoints::media_files::delete_media_file::delete_media_file;
use tauri::{AppHandle, State};
use enums::tauri::tasks::task_failure_type::TaskFailureType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use tokens::tokens::batch_generations::BatchGenerationToken;
use tokens::tokens::media_files::MediaFileToken;
@@ -31,12 +32,20 @@ pub struct GetTaskQueueCommandResponse {
#[derive(Serialize)]
pub struct TaskQueueItem {
pub id: TaskId,
pub task_status: TaskStatus,
pub task_type: TaskType,
pub model_type: Option<TaskModelType>,
pub provider: Option<GenerationProvider>,
pub provider_job_id: Option<String>,
/// If the item is done, these will be filled out.
pub completed_item: Option<CompletedItemData>,
/// If the item failed, this will be filled out.
pub failure_reason: Option<FailedItemData>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -63,6 +72,12 @@ pub struct MediaFileData {
pub created_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct FailedItemData {
pub failure_type: TaskFailureType,
pub failure_message: Option<String>,
}
impl SerializeMarker for GetTaskQueueCommandResponse {}
#[tauri::command]
@@ -103,6 +118,7 @@ pub async fn handle_request(
for task in tasks.tasks.into_iter() {
let mut completed_item = None;
let mut failure_reason = None;
if task.status == TaskStatus::CompleteSuccess {
let token_and_url = task.on_complete_primary_media_file_token
@@ -123,6 +139,19 @@ pub async fn handle_request(
} else {
warn!("Task {} is marked complete but has no primary media file token or URL.", task.id);
}
} else {
// If either failure field is present, fill out the failure report.
if let Some(failure_message) = task.on_failure_message.as_deref() {
failure_reason = Some(FailedItemData {
failure_type: task.on_failure_type.unwrap_or(TaskFailureType::Unknown),
failure_message: Some(failure_message.to_string()),
});
} else if let Some(failure_type) = task.on_failure_type {
failure_reason = Some(FailedItemData {
failure_type,
failure_message: None,
});
}
}
transformed_tasks.push(TaskQueueItem {
@@ -136,6 +165,7 @@ pub async fn handle_request(
updated_at: task.updated_at,
completed_at: task.completed_at,
completed_item,
failure_reason,
})
}
@@ -4,7 +4,7 @@ use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::Artcraft
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::storyteller_activity_thread::storyteller_activity_thread;
use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread;
use crate::services::storyteller::threads::storyteller_task_polling_thread::storyteller_task_polling_thread::storyteller_task_polling_thread;
use errors::AnyhowResult;
use tauri::{AppHandle, Manager};
@@ -7,4 +7,4 @@
/// We increment this whenever we make a change to the database schema.
/// This prevents deadlock on startup if the schema has changed from the
/// previous version.
pub const TASK_DATABASE_VERSION: u32 = 5;
pub const TASK_DATABASE_VERSION: u32 = 6;
@@ -1,3 +1,2 @@
pub mod events;
pub mod storyteller_activity_thread;
pub mod storyteller_task_polling_thread;
@@ -1,34 +1,16 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::artcraft_platform_info::ArtcraftPlatformInfo;
use crate::core::state::artcraft_usage_tracker::artcraft_usage_tracker::ArtcraftUsageTracker;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::os_platform::OsPlatform;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use anyhow::anyhow;
use artcraft_api_defs::analytics::log_active_user::LogAppActiveUserRequest;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{debug, error, info};
use os_info::Info;
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::time::Instant;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::analytics::log_active_user::log_active_user;
use artcraft_client::endpoints::analytics::log_active_user_v2::log_active_user_v2;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use errors::AnyhowResult;
use log::{debug, error, info};
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use std::time::Instant;
use tokens::tokens::app_session::AppSessionToken;
const CLIENT_NAME : &str = "artcraft";
@@ -1,339 +0,0 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::{GenerationAction, GenerationServiceProvider};
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::data_dir::app_data_root::AppDataRoot;
use crate::core::state::data_dir::trait_data_subdir::DataSubdir;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use crate::services::sora::state::sora_credential_manager::SoraCredentialManager;
use crate::services::sora::state::sora_task_queue::SoraTaskQueue;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification;
use crate::services::storyteller::threads::events::maybe_handle_inpainting_complete_event::maybe_handle_inpainting_complete_event;
use crate::services::storyteller::threads::events::maybe_handle_text_to_image_complete_event::maybe_handle_text_to_image_complete_event;
use crate::services::storyteller::threads::events::maybe_send_background_removal_complete_event::maybe_send_background_removal_complete_event;
use anyhow::anyhow;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_status::TaskStatus;
use enums::tauri::tasks::task_type::TaskType;
use errors::AnyhowResult;
use log::{error, info};
use reqwest::Url;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use std::collections::HashMap;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::endpoints::media_files::upload_image_media_file_from_file::upload_image_media_file_from_file;
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use tauri::AppHandle;
use tempdir::TempDir;
use tokens::tokens::media_files::MediaFileToken;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
task_database: TaskDatabase,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&task_database,
&storyteller_creds_manager,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
// NB: Only sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
loop {
// Wait before next request for jobs.
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
let creds = storyteller_creds_manager.get_credentials()?;
let result = list_session_jobs(
&app_env_configs.storyteller_host,
creds.as_ref(),
States::All,
).await;
let jobs = match result {
Ok(result) => result.jobs,
Err(err) => {
match &err {
StorytellerError::Api(ApiError::TooManyRequests(message)) => {
error!("Too many requests (sleeping): {:?}", message);
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
_ => {}
}
return Err(anyhow!(err));
}
};
let job_ids = jobs.iter()
.map(|job| job.job_token.to_string())
.collect::<Vec<_>>();
let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Artcraft,
provider_job_ids: Some(job_ids),
}).await?;
let tasks = tasks.tasks;
let jobs_by_id = jobs.iter()
.map(|job| (job.job_token.to_string(), job))
.collect::<HashMap<String, _>>();
let tasks_by_provider_job_id = tasks.iter()
.filter_map(|task| {
if let Some(provider_job_id) = &task.provider_job_id {
Some((provider_job_id.clone(), task.clone()))
} else {
None
}
})
//.map(|task| (task.provider_job_id.clone(), task.clone()))
.collect::<HashMap<String, Task>>();
for job in jobs.iter() {
let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) {
Some(task) => task,
None => continue,
};
match job.status.status {
JobStatusPlus::CompleteSuccess => {
match task.status {
TaskStatus::CompleteSuccess => continue,
_ => {}
}
handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?;
}
JobStatusPlus::CompleteFailure => {
match task.status {
TaskStatus::CompleteFailure => continue,
_ => {}
}
handle_failed_job(app_handle, job, task, task_database).await?;
}
_ => continue,
}
}
}
}
async fn handle_successful_job(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
return Ok(()); // If anything breaks with queries, don't spam events.
}
send_additional_success_events(app_handle, app_env_configs, creds, job, task).await;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationCompleteEvent {
action: Some(action),
service,
model: None, // TODO
};
if let Err(err) = event.send(app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
async fn handle_failed_job(
app_handle: &AppHandle,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
info!("Marking storyteller job as failed: {:?}", task.id);
update_task_status(UpdateTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteFailure,
}).await?;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationFailedEvent {
action,
service,
model: None,
reason: None,
};
event.send_infallible(app_handle);
Ok(())
}
async fn send_additional_success_events(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task
) {
info!("Attempting to dispatch events for completed Storyteller job: {:?}", task);
let result = maybe_handle_frontend_caller_notification(
app_handle,
app_env_configs,
creds,
task,
job,
).await;
if let Err(err) = result {
error!("Failed to send generation complete event: {:?}", err);
}
//let result = maybe_handle_text_to_image_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send text-to-image complete event: {:?}", err);
//}
//let result = maybe_handle_inpainting_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send image inpainting complete event: {:?}", err);
//}
//let result = maybe_send_background_removal_complete_event(
// app_handle,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send background removal complete event: {:?}", err);
//}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class<'a>(job: &'a ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -0,0 +1,41 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::generation_failed_event::GenerationFailedEvent;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use log::info;
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_task_status::{update_task_status, UpdateTaskArgs};
use tauri::AppHandle;
pub async fn handle_failed_job(
app_handle: &AppHandle,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
info!("Marking storyteller job as failed: {:?}", task.id);
update_task_status(UpdateTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
status: TaskStatus::CompleteFailure,
}).await?;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationFailedEvent {
action,
service,
model: None,
reason: None,
};
event.send_infallible(app_handle);
Ok(())
}
@@ -0,0 +1,177 @@
use crate::core::events::basic_sendable_event_trait::BasicSendableEvent;
use crate::core::events::generation_events::common::GenerationAction;
use crate::core::events::generation_events::generation_complete_event::GenerationCompleteEvent;
use crate::core::events::sendable_event_trait::SendableEvent;
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::core::utils::enum_conversion::generation_provider::to_generation_service_provider;
use crate::core::utils::enum_conversion::task_type::to_generation_action;
use super::events::maybe_handle_frontend_caller_notification::maybe_handle_frontend_caller_notification;
use artcraft_api_defs::jobs::list_session_jobs::ListSessionJobsItem;
use artcraft_api_defs::utils::media_links_to_thumbnail_template::media_links_to_thumbnail_template;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use enums::by_table::generic_inference_jobs::inference_category::InferenceCategory;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use errors::AnyhowResult;
use log::error;
use log::info;
use sqlite_tasks::queries::task::Task;
use sqlite_tasks::queries::update_successful_task_status_with_metadata::{update_successful_task_status_with_metadata, UpdateSuccessfulTaskArgs};
use tauri::AppHandle;
use tokens::tokens::media_files::MediaFileToken;
pub async fn handle_successful_job(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task,
task_database: &TaskDatabase,
) -> AnyhowResult<()> {
let maybe_primary_media_file_token = job.maybe_result
.as_ref()
.map(|result| MediaFileToken::new_from_str(&result.entity_token));
let updated = update_successful_task_status_with_metadata(UpdateSuccessfulTaskArgs {
db: task_database.get_connection(),
task_id: &task.id,
maybe_batch_token: job.maybe_result
.as_ref()
.map(|result| result.maybe_batch_token.as_ref())
.flatten(),
maybe_primary_media_file_token: maybe_primary_media_file_token.as_ref(),
maybe_primary_media_file_class: get_media_file_class(job),
maybe_primary_media_file_thumbnail_url_template: get_thumbnail_template(job),
maybe_primary_media_file_cdn_url: job.maybe_result
.as_ref()
.map(|result| result.media_links.cdn_url.as_str()),
}).await?;
if !updated {
return Ok(()); // If anything breaks with queries, don't spam events.
}
send_additional_success_events(app_handle, app_env_configs, creds, job, task).await;
let service = to_generation_service_provider(task.provider);
let action = to_generation_action(task.task_type);
let event = GenerationCompleteEvent {
action: Some(action),
service,
model: None, // TODO
};
if let Err(err) = event.send(app_handle) {
error!("Failed to send GenerationCompleteEvent: {:?}", err); // Fail open
}
Ok(())
}
async fn send_additional_success_events(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
creds: Option<&StorytellerCredentialSet>,
job: &ListSessionJobsItem,
task: &Task
) {
info!("Attempting to dispatch events for completed Storyteller job: {:?}", task);
let result = maybe_handle_frontend_caller_notification(
app_handle,
app_env_configs,
creds,
task,
job,
).await;
if let Err(err) = result {
error!("Failed to send generation complete event: {:?}", err);
}
//let result = maybe_handle_text_to_image_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send text-to-image complete event: {:?}", err);
//}
//let result = maybe_handle_inpainting_complete_event(
// app_handle,
// app_env_configs,
// creds,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send image inpainting complete event: {:?}", err);
//}
//let result = maybe_send_background_removal_complete_event(
// app_handle,
// task,
// job,
//).await;
//if let Err(err) = result {
// error!("Failed to send background removal complete event: {:?}", err);
//}
}
fn get_thumbnail_template<'a>(job: &'a ListSessionJobsItem) -> Option<&'a str> {
let links = match job.maybe_result.as_ref() {
None => return None,
Some(result) => &result.media_links,
};
media_links_to_thumbnail_template(links)
}
fn get_media_file_class(job: &ListSessionJobsItem) -> Option<TaskMediaFileClass> {
match job.request.inference_category {
InferenceCategory::BackgroundRemoval => return Some(TaskMediaFileClass::Image),
InferenceCategory::ImageGeneration => return Some(TaskMediaFileClass::Image),
InferenceCategory::VideoGeneration => return Some(TaskMediaFileClass::Video),
InferenceCategory::ObjectGeneration => return Some(TaskMediaFileClass::Dimensional),
_ => {}, // Fall-through
}
let result = match job.maybe_result.as_ref() {
None => return None,
Some(result) => result,
};
let url = result.media_links.cdn_url.as_str();
if url.ends_with("jpg")
|| url.ends_with("jpeg")
|| url.ends_with("png")
{
return Some(TaskMediaFileClass::Image);
}
if url.ends_with("mp4")
|| url.ends_with("webm")
{
return Some(TaskMediaFileClass::Video);
}
if url.ends_with("glb") {
return Some(TaskMediaFileClass::Dimensional);
}
if url.ends_with("wav")
|| url.ends_with("mp3")
{
return Some(TaskMediaFileClass::Audio);
}
None
}
@@ -0,0 +1,4 @@
mod events;
mod handle_storyteller_failed_job;
mod handle_storyteller_successful_job;
pub mod storyteller_task_polling_thread;
@@ -0,0 +1,126 @@
use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;
use crate::core::state::task_database::TaskDatabase;
use crate::services::storyteller::state::storyteller_credential_manager::StorytellerCredentialManager;
use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_failed_job::handle_failed_job;
use crate::services::storyteller::threads::storyteller_task_polling_thread::handle_storyteller_successful_job::handle_successful_job;
use anyhow::anyhow;
use artcraft_client::endpoints::jobs::list_session_jobs::{list_session_jobs, States};
use artcraft_client::error::api_error::ApiError;
use artcraft_client::error::storyteller_error::StorytellerError;
use enums::common::generation_provider::GenerationProvider;
use enums::common::job_status_plus::JobStatusPlus;
use enums::tauri::tasks::task_status::TaskStatus;
use errors::AnyhowResult;
use log::error;
use sqlite_tasks::queries::list_tasks_by_provider_and_tokens::{list_tasks_by_provider_and_tokens, ListTasksArgs};
use sqlite_tasks::queries::task::Task;
use std::collections::HashMap;
use tauri::AppHandle;
pub async fn storyteller_task_polling_thread(
app_handle: AppHandle,
app_env_configs: AppEnvConfigs,
task_database: TaskDatabase,
storyteller_creds_manager: StorytellerCredentialManager,
) -> ! {
loop {
let res = polling_loop(
&app_handle,
&app_env_configs,
&task_database,
&storyteller_creds_manager,
).await;
if let Err(err) = res {
error!("An error occurred: {:?}", err);
}
// NB: Only sleep if an error occurs.
tokio::time::sleep(std::time::Duration::from_millis(30_000)).await;
}
}
async fn polling_loop(
app_handle: &AppHandle,
app_env_configs: &AppEnvConfigs,
task_database: &TaskDatabase,
storyteller_creds_manager: &StorytellerCredentialManager,
) -> AnyhowResult<()> {
loop {
// Wait before next request for jobs.
tokio::time::sleep(std::time::Duration::from_millis(5_000)).await;
let creds = storyteller_creds_manager.get_credentials()?;
let result = list_session_jobs(
&app_env_configs.storyteller_host,
creds.as_ref(),
States::All,
).await;
let jobs = match result {
Ok(result) => result.jobs,
Err(err) => {
match &err {
StorytellerError::Api(ApiError::TooManyRequests(message)) => {
error!("Too many requests (sleeping): {:?}", message);
tokio::time::sleep(std::time::Duration::from_millis(60_000)).await;
}
_ => {}
}
return Err(anyhow!(err));
}
};
let job_ids = jobs.iter()
.map(|job| job.job_token.to_string())
.collect::<Vec<_>>();
let tasks = list_tasks_by_provider_and_tokens(ListTasksArgs {
db: task_database.get_connection(),
provider: GenerationProvider::Artcraft,
provider_job_ids: Some(job_ids),
}).await?;
let tasks = tasks.tasks;
let jobs_by_id = jobs.iter()
.map(|job| (job.job_token.to_string(), job))
.collect::<HashMap<String, _>>();
let tasks_by_provider_job_id = tasks.iter()
.filter_map(|task| {
if let Some(provider_job_id) = &task.provider_job_id {
Some((provider_job_id.clone(), task.clone()))
} else {
None
}
})
//.map(|task| (task.provider_job_id.clone(), task.clone()))
.collect::<HashMap<String, Task>>();
for job in jobs.iter() {
let task = match tasks_by_provider_job_id.get(job.job_token.as_str()) {
Some(task) => task,
None => continue,
};
match job.status.status {
JobStatusPlus::CompleteSuccess => {
match task.status {
TaskStatus::CompleteSuccess => continue, // NB: We're done with this task.
_ => {}
}
handle_successful_job(app_handle, app_env_configs, creds.as_ref(), job, task, task_database).await?;
}
JobStatusPlus::CompleteFailure => {
match task.status {
TaskStatus::CompleteFailure => continue, // NB: We're done with this task.
_ => {}
}
handle_failed_job(app_handle, job, task, task_database).await?;
}
_ => continue,
}
}
}
}
@@ -0,0 +1,45 @@
use log::warn;
use sqlx::MySqlPool;
use crate::errors::select_optional_record_error::SelectOptionalRecordError;
use tokens::tokens::users::UserToken;
pub struct UsernameDetails {
pub username: String,
pub display_name: String,
pub username_is_generated: bool,
pub username_is_not_customized: bool,
}
pub async fn get_username_by_user_token(
user_token: &UserToken,
pool: &MySqlPool,
) -> Result<Option<UsernameDetails>, SelectOptionalRecordError> {
let result = sqlx::query_as!(
UsernameDetails,
r#"
SELECT
username,
display_name,
username_is_generated as `username_is_generated: bool`,
username_is_not_customized as `username_is_not_customized: bool`
FROM users
WHERE
token = ?
AND user_deleted_at IS NULL
AND mod_deleted_at IS NULL
LIMIT 1
"#,
user_token.as_str(),
)
.fetch_optional(pool)
.await;
match result {
Ok(maybe_record) => Ok(maybe_record),
Err(err) => {
warn!("get_username_by_user_token query error: {:?}", err);
Err(err.into())
}
}
}
@@ -1,4 +1,5 @@
pub mod get_user_token_by_username;
pub mod get_username_by_user_token;
pub mod lookup_user_for_login_by_email;
pub mod lookup_user_for_login_by_email_with_transactor;
pub mod lookup_user_for_login_by_username;
@@ -1,44 +1,12 @@
use crate::queries::wallet_ledger_entries::internal_insert_wallet_created_ledger_entry::internal_insert_wallet_created_ledger_entry;
use crate::queries::wallets::create_new_wallet_for_owner_user::create_new_wallet_for_owner_user;
use enums::common::payments_namespace::PaymentsNamespace;
use errors::AnyhowResult;
use log::error;
use sqlx::MySql;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallets::WalletToken;
const ARTCRAFT_NAMESPACE: &str = PaymentsNamespace::Artcraft.to_str();
pub async fn create_new_artcraft_wallet_for_owner_user(
user_token: &UserToken,
transaction: &mut sqlx::Transaction<'_, MySql>,
) -> Result<WalletToken, sqlx::Error> {
let token = WalletToken::generate();
let result = sqlx::query!(
r#"
INSERT INTO wallets
SET
token = ?,
wallet_namespace = ?,
owner_user_token = ?,
banked_credits = 0,
monthly_credits = 0
"#,
token.as_str(),
ARTCRAFT_NAMESPACE,
user_token.as_str()
)
.execute(&mut **transaction)
.await;
if let Err(err) = result {
error!("Error while inserting wallet: {}", err);
return Err(err);
}
internal_insert_wallet_created_ledger_entry(&token, transaction).await?;
Ok(token)
create_new_wallet_for_owner_user(user_token, PaymentsNamespace::Artcraft, transaction).await
}
@@ -0,0 +1,42 @@
use crate::queries::wallet_ledger_entries::internal_insert_wallet_created_ledger_entry::internal_insert_wallet_created_ledger_entry;
use enums::common::payments_namespace::PaymentsNamespace;
use log::error;
use sqlx::MySql;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallets::WalletToken;
pub async fn create_new_wallet_for_owner_user(
user_token: &UserToken,
namespace: PaymentsNamespace,
transaction: &mut sqlx::Transaction<'_, MySql>,
) -> Result<WalletToken, sqlx::Error> {
let token = WalletToken::generate();
let result = sqlx::query!(
r#"
INSERT INTO wallets
SET
token = ?,
wallet_namespace = ?,
owner_user_token = ?,
banked_credits = 0,
monthly_credits = 0
"#,
token.as_str(),
namespace.to_str(),
user_token.as_str()
)
.execute(&mut **transaction)
.await;
if let Err(err) = result {
error!("Error while inserting wallet: {}", err);
return Err(err);
}
internal_insert_wallet_created_ledger_entry(&token, transaction).await?;
Ok(token)
}
@@ -1,6 +1,7 @@
pub (crate) mod internal_select_wallet_balance_for_update;
pub mod add_durable_banked_balance_to_wallet;
pub mod create_new_artcraft_wallet_for_owner_user;
pub mod create_new_wallet_for_owner_user;
pub mod find_primary_wallet_for_owner;
pub mod find_primary_wallet_token_for_owner;
pub mod refill_monthly_credits_balance_on_wallet;
@@ -2,6 +2,7 @@ use crate::connection::TaskDbConnection;
use crate::error::SqliteTasksError;
use chrono::{DateTime, Utc};
use enums::common::generation_provider::GenerationProvider;
use enums::tauri::tasks::task_failure_type::TaskFailureType;
use enums::tauri::tasks::task_media_file_class::TaskMediaFileClass;
use enums::tauri::tasks::task_model_type::TaskModelType;
use enums::tauri::tasks::task_status::TaskStatus;
@@ -30,6 +31,8 @@ pub struct TaskItem {
pub on_complete_batch_token: Option<BatchGenerationToken>,
pub on_complete_primary_media_file_cdn_url: Option<String>,
pub on_complete_primary_media_file_thumbnail_url_template: Option<String>,
pub on_failure_type: Option<TaskFailureType>,
pub on_failure_message: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub completed_at: Option<DateTime<Utc>>,
@@ -56,6 +59,8 @@ pub async fn list_tasks_for_frontend(
on_complete_batch_token,
on_complete_primary_media_file_cdn_url,
on_complete_primary_media_file_thumbnail_url_template,
on_failure_type,
on_failure_message,
created_at as "created_at: DateTime<Utc>",
updated_at as "updated_at: DateTime<Utc>",
completed_at as "completed_at: DateTime<Utc>"
@@ -92,6 +97,10 @@ pub async fn list_tasks_for_frontend(
on_complete_batch_token: raw.on_complete_batch_token.map(|t| BatchGenerationToken::new_from_str(&t)),
on_complete_primary_media_file_cdn_url: raw.on_complete_primary_media_file_cdn_url,
on_complete_primary_media_file_thumbnail_url_template: raw.on_complete_primary_media_file_thumbnail_url_template,
on_failure_type: raw.on_failure_type
.map(|t| TaskFailureType::from_str(&t))
.transpose()?,
on_failure_message: raw.on_failure_message,
created_at: raw.created_at,
updated_at: raw.updated_at,
completed_at: raw.completed_at,
@@ -118,6 +127,8 @@ struct TaskItemRaw {
on_complete_batch_token: Option<String>,
on_complete_primary_media_file_cdn_url: Option<String>,
on_complete_primary_media_file_thumbnail_url_template: Option<String>,
on_failure_type: Option<String>,
on_failure_message: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
completed_at: Option<DateTime<Utc>>,
@@ -47,6 +47,56 @@ pub enum FrontendFailureCategory {
/// Eg. Seedance 2 will report: "your input text violates platform rules. please modify and try again"
#[serde(rename = "model_rules_violation")]
ModelRulesViolation,
/// Model content violation
/// e.g. "Your uploaded image violates platform rules. Please modify and try again." (seedance2pro)
/// Model content rules prohibit the uploaded image.
#[serde(rename = "rule_bans_user_image")]
RuleBansUserImage,
/// Model content violation
/// Model content rules prohibit user uploaded images containing faces (Seedance 2.0)
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
#[serde(rename = "rule_bans_user_image_with_faces")]
RuleBansUserImageWithFaces,
/// Model content violation
/// Model content rules prohibit the user's given text prompt (this fails early).
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
#[serde(rename = "rule_bans_user_text_prompt")]
RuleBansUserTextPrompt,
/// Model content violation
/// Model content rules prohibit user content. (I think this check happens early.)
/// e.g. "Content violates platform rules. Please modify and try again." (seedance2pro)
#[serde(rename = "rule_bans_user_content")]
RuleBansUserContent,
/// Model content violation
/// The video didn't pass checks after it finished generation (this fails at the very end of the generation).
/// e.g. "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
#[serde(rename = "rule_bans_generated_video")]
RuleBansGeneratedVideo,
/// Model content violation
/// The audio (even in video!) didn't pass checks after it finished generation (this fails at the very end of the generation).
/// e.g. "The generated audio violates platform rules. Please adjust your prompt or images and try again." (seedance2pro)
#[serde(rename = "rule_bans_generated_audio")]
RuleBansGeneratedAudio,
/// Model content violation
/// The content didn't pass checks after it finished generation (this fails at the very end of the generation).
/// e.g. "The generated content violates platform rules. Please adjust your prompt or images and try again." (seedance2pro)
#[serde(rename = "rule_bans_generated_content")]
RuleBansGeneratedContent,
/// Generation failed (no reason)
/// Unspecified failure reason
/// Various example failures:
/// - "The generated video did not pass review. Credits will not be deducted." (seedance2pro)
/// - "Server error. Please try again later." (seedance2pro)
#[serde(rename = "generation_failed")]
GenerationFailed,
}
// TODO(bt, 2022-12-21): This desperately needs MySQL integration tests!
@@ -62,6 +112,14 @@ impl FrontendFailureCategory {
Self::NotYetImplemented => "not_yet_implemented",
Self::RetryableWorkerError => "retryable_worker_error",
Self::ModelRulesViolation => "model_rules_violation",
Self::RuleBansUserImage => "rule_bans_user_image",
Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces",
Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt",
Self::RuleBansUserContent => "rule_bans_user_content",
Self::RuleBansGeneratedVideo => "rule_bans_generated_video",
Self::RuleBansGeneratedAudio => "rule_bans_generated_audio",
Self::RuleBansGeneratedContent => "rule_bans_generated_content",
Self::GenerationFailed => "generation_failed",
}
}
@@ -72,6 +130,14 @@ impl FrontendFailureCategory {
"not_yet_implemented" => Ok(Self::NotYetImplemented),
"retryable_worker_error" => Ok(Self::RetryableWorkerError),
"model_rules_violation" => Ok(Self::ModelRulesViolation),
"rule_bans_user_image" => Ok(Self::RuleBansUserImage),
"rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces),
"rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt),
"rule_bans_user_content" => Ok(Self::RuleBansUserContent),
"rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo),
"rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio),
"rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent),
"generation_failed" => Ok(Self::GenerationFailed),
_ => Err(format!("invalid value: {:?}", value)),
}
}
@@ -85,6 +151,14 @@ impl FrontendFailureCategory {
Self::NotYetImplemented,
Self::RetryableWorkerError,
Self::ModelRulesViolation,
Self::RuleBansUserImage,
Self::RuleBansUserImageWithFaces,
Self::RuleBansUserTextPrompt,
Self::RuleBansUserContent,
Self::RuleBansGeneratedVideo,
Self::RuleBansGeneratedAudio,
Self::RuleBansGeneratedContent,
Self::GenerationFailed,
])
}
}
@@ -104,6 +178,14 @@ mod tests {
assert_serialization(FrontendFailureCategory::NotYetImplemented, "not_yet_implemented");
assert_serialization(FrontendFailureCategory::RetryableWorkerError, "retryable_worker_error");
assert_serialization(FrontendFailureCategory::ModelRulesViolation, "model_rules_violation");
assert_serialization(FrontendFailureCategory::RuleBansUserImage, "rule_bans_user_image");
assert_serialization(FrontendFailureCategory::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces");
assert_serialization(FrontendFailureCategory::RuleBansUserTextPrompt, "rule_bans_user_text_prompt");
assert_serialization(FrontendFailureCategory::RuleBansUserContent, "rule_bans_user_content");
assert_serialization(FrontendFailureCategory::RuleBansGeneratedVideo, "rule_bans_generated_video");
assert_serialization(FrontendFailureCategory::RuleBansGeneratedAudio, "rule_bans_generated_audio");
assert_serialization(FrontendFailureCategory::RuleBansGeneratedContent, "rule_bans_generated_content");
assert_serialization(FrontendFailureCategory::GenerationFailed, "generation_failed");
}
#[test]
@@ -113,6 +195,14 @@ mod tests {
assert_eq!(FrontendFailureCategory::NotYetImplemented.to_str(), "not_yet_implemented");
assert_eq!(FrontendFailureCategory::RetryableWorkerError.to_str(), "retryable_worker_error");
assert_eq!(FrontendFailureCategory::ModelRulesViolation.to_str(), "model_rules_violation");
assert_eq!(FrontendFailureCategory::RuleBansUserImage.to_str(), "rule_bans_user_image");
assert_eq!(FrontendFailureCategory::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces");
assert_eq!(FrontendFailureCategory::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt");
assert_eq!(FrontendFailureCategory::RuleBansUserContent.to_str(), "rule_bans_user_content");
assert_eq!(FrontendFailureCategory::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video");
assert_eq!(FrontendFailureCategory::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio");
assert_eq!(FrontendFailureCategory::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content");
assert_eq!(FrontendFailureCategory::GenerationFailed.to_str(), "generation_failed");
}
#[test]
@@ -122,17 +212,34 @@ mod tests {
assert_eq!(FrontendFailureCategory::from_str("not_yet_implemented").unwrap(), FrontendFailureCategory::NotYetImplemented);
assert_eq!(FrontendFailureCategory::from_str("retryable_worker_error").unwrap(), FrontendFailureCategory::RetryableWorkerError);
assert_eq!(FrontendFailureCategory::from_str("model_rules_violation").unwrap(), FrontendFailureCategory::ModelRulesViolation);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image").unwrap(), FrontendFailureCategory::RuleBansUserImage);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_image_with_faces").unwrap(), FrontendFailureCategory::RuleBansUserImageWithFaces);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_text_prompt").unwrap(), FrontendFailureCategory::RuleBansUserTextPrompt);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_user_content").unwrap(), FrontendFailureCategory::RuleBansUserContent);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_video").unwrap(), FrontendFailureCategory::RuleBansGeneratedVideo);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_audio").unwrap(), FrontendFailureCategory::RuleBansGeneratedAudio);
assert_eq!(FrontendFailureCategory::from_str("rule_bans_generated_content").unwrap(), FrontendFailureCategory::RuleBansGeneratedContent);
assert_eq!(FrontendFailureCategory::from_str("generation_failed").unwrap(), FrontendFailureCategory::GenerationFailed);
assert_eq!(FrontendFailureCategory::from_str("invalid_value").is_err(), true);
}
#[test]
fn all_variants() {
let mut variants = FrontendFailureCategory::all_variants();
assert_eq!(variants.len(), 5);
assert_eq!(variants.len(), 13);
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::FaceNotDetected));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::KeepAliveElapsed));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::NotYetImplemented));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RetryableWorkerError));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::ModelRulesViolation));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImage));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserImageWithFaces));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserTextPrompt));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansUserContent));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedVideo));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedAudio));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::RuleBansGeneratedContent));
assert_eq!(variants.pop_first(), Some(FrontendFailureCategory::GenerationFailed));
assert_eq!(variants.pop_first(), None);
}
}
@@ -1,3 +1,4 @@
pub mod task_failure_type;
pub mod task_media_file_class;
pub mod task_model_type;
pub mod task_status;
@@ -0,0 +1,195 @@
use std::collections::BTreeSet;
use crate::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use crate::error::enum_error::EnumError;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
/// Failure type for tasks in the Tauri desktop app.
///
/// Mirrors the relevant variants from `FrontendFailureCategory` so the desktop
/// client can display localized failure information without depending / breaking on the
/// server-side enum directly.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum TaskFailureType {
/// Catch-all for unknown failures.
Unknown,
RuleBansUserImage,
RuleBansUserImageWithFaces,
RuleBansUserTextPrompt,
RuleBansUserContent,
RuleBansGeneratedVideo,
RuleBansGeneratedAudio,
RuleBansGeneratedContent,
/// No reason given for generation failure, but this matches what we were told.
GenerationFailed,
}
impl_enum_display_and_debug_using_to_str!(TaskFailureType);
impl TaskFailureType {
/// Convert the web API's `FrontendFailureCategory` to a Tauri-facing type, if there is a matching variant.
/// If there isn't a matching variant, return `Unknown`.
pub fn from_frontend_failure_category(category: FrontendFailureCategory) -> Self {
match category {
FrontendFailureCategory::ModelRulesViolation => Self::RuleBansUserContent, // NB: This is a legacy enum value.
FrontendFailureCategory::RuleBansUserImage => Self::RuleBansUserImage,
FrontendFailureCategory::RuleBansUserImageWithFaces => Self::RuleBansUserImageWithFaces,
FrontendFailureCategory::RuleBansUserTextPrompt => Self::RuleBansUserTextPrompt,
FrontendFailureCategory::RuleBansUserContent => Self::RuleBansUserContent,
FrontendFailureCategory::RuleBansGeneratedVideo => Self::RuleBansGeneratedVideo,
FrontendFailureCategory::RuleBansGeneratedAudio => Self::RuleBansGeneratedAudio,
FrontendFailureCategory::RuleBansGeneratedContent => Self::RuleBansGeneratedContent,
FrontendFailureCategory::GenerationFailed => Self::GenerationFailed,
_ => Self::Unknown,
}
}
pub const fn to_str(&self) -> &'static str {
match self {
Self::Unknown => "unknown",
Self::RuleBansUserImage => "rule_bans_user_image",
Self::RuleBansUserImageWithFaces => "rule_bans_user_image_with_faces",
Self::RuleBansUserTextPrompt => "rule_bans_user_text_prompt",
Self::RuleBansUserContent => "rule_bans_user_content",
Self::RuleBansGeneratedVideo => "rule_bans_generated_video",
Self::RuleBansGeneratedAudio => "rule_bans_generated_audio",
Self::RuleBansGeneratedContent => "rule_bans_generated_content",
Self::GenerationFailed => "generation_failed",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"unknown" => Ok(Self::Unknown),
"rule_bans_user_image" => Ok(Self::RuleBansUserImage),
"rule_bans_user_image_with_faces" => Ok(Self::RuleBansUserImageWithFaces),
"rule_bans_user_text_prompt" => Ok(Self::RuleBansUserTextPrompt),
"rule_bans_user_content" => Ok(Self::RuleBansUserContent),
"rule_bans_generated_video" => Ok(Self::RuleBansGeneratedVideo),
"rule_bans_generated_audio" => Ok(Self::RuleBansGeneratedAudio),
"rule_bans_generated_content" => Ok(Self::RuleBansGeneratedContent),
"generation_failed" => Ok(Self::GenerationFailed),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::Unknown,
Self::RuleBansUserImage,
Self::RuleBansUserImageWithFaces,
Self::RuleBansUserTextPrompt,
Self::RuleBansUserContent,
Self::RuleBansGeneratedVideo,
Self::RuleBansGeneratedAudio,
Self::RuleBansGeneratedContent,
Self::GenerationFailed,
])
}
}
#[cfg(test)]
mod tests {
use crate::tauri::tasks::task_failure_type::TaskFailureType;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(TaskFailureType::Unknown, "unknown");
assert_serialization(TaskFailureType::RuleBansUserImage, "rule_bans_user_image");
assert_serialization(TaskFailureType::RuleBansUserImageWithFaces, "rule_bans_user_image_with_faces");
assert_serialization(TaskFailureType::RuleBansUserTextPrompt, "rule_bans_user_text_prompt");
assert_serialization(TaskFailureType::RuleBansUserContent, "rule_bans_user_content");
assert_serialization(TaskFailureType::RuleBansGeneratedVideo, "rule_bans_generated_video");
assert_serialization(TaskFailureType::RuleBansGeneratedAudio, "rule_bans_generated_audio");
assert_serialization(TaskFailureType::RuleBansGeneratedContent, "rule_bans_generated_content");
assert_serialization(TaskFailureType::GenerationFailed, "generation_failed");
}
#[test]
fn to_str() {
assert_eq!(TaskFailureType::Unknown.to_str(), "unknown");
assert_eq!(TaskFailureType::RuleBansUserImage.to_str(), "rule_bans_user_image");
assert_eq!(TaskFailureType::RuleBansUserImageWithFaces.to_str(), "rule_bans_user_image_with_faces");
assert_eq!(TaskFailureType::RuleBansUserTextPrompt.to_str(), "rule_bans_user_text_prompt");
assert_eq!(TaskFailureType::RuleBansUserContent.to_str(), "rule_bans_user_content");
assert_eq!(TaskFailureType::RuleBansGeneratedVideo.to_str(), "rule_bans_generated_video");
assert_eq!(TaskFailureType::RuleBansGeneratedAudio.to_str(), "rule_bans_generated_audio");
assert_eq!(TaskFailureType::RuleBansGeneratedContent.to_str(), "rule_bans_generated_content");
assert_eq!(TaskFailureType::GenerationFailed.to_str(), "generation_failed");
}
#[test]
fn from_str() {
assert_eq!(TaskFailureType::from_str("unknown").unwrap(), TaskFailureType::Unknown);
assert_eq!(TaskFailureType::from_str("rule_bans_user_image").unwrap(), TaskFailureType::RuleBansUserImage);
assert_eq!(TaskFailureType::from_str("rule_bans_user_image_with_faces").unwrap(), TaskFailureType::RuleBansUserImageWithFaces);
assert_eq!(TaskFailureType::from_str("rule_bans_user_text_prompt").unwrap(), TaskFailureType::RuleBansUserTextPrompt);
assert_eq!(TaskFailureType::from_str("rule_bans_user_content").unwrap(), TaskFailureType::RuleBansUserContent);
assert_eq!(TaskFailureType::from_str("rule_bans_generated_video").unwrap(), TaskFailureType::RuleBansGeneratedVideo);
assert_eq!(TaskFailureType::from_str("rule_bans_generated_audio").unwrap(), TaskFailureType::RuleBansGeneratedAudio);
assert_eq!(TaskFailureType::from_str("rule_bans_generated_content").unwrap(), TaskFailureType::RuleBansGeneratedContent);
assert_eq!(TaskFailureType::from_str("generation_failed").unwrap(), TaskFailureType::GenerationFailed);
}
#[test]
fn from_str_err() {
let result = TaskFailureType::from_str("asdf");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "asdf");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = TaskFailureType::all_variants();
assert_eq!(variants.len(), 9);
assert_eq!(variants.pop_first(), Some(TaskFailureType::Unknown));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImage));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserImageWithFaces));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserTextPrompt));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansUserContent));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedVideo));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedAudio));
assert_eq!(variants.pop_first(), Some(TaskFailureType::RuleBansGeneratedContent));
assert_eq!(variants.pop_first(), Some(TaskFailureType::GenerationFailed));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(TaskFailureType::all_variants().len(), TaskFailureType::iter().len());
}
#[test]
fn round_trip() {
for variant in TaskFailureType::all_variants() {
assert_eq!(variant, TaskFailureType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, TaskFailureType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, TaskFailureType::from_str(&format!("{:?}", variant)).unwrap());
}
}
}
}
@@ -4,6 +4,7 @@ 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 seedance2pro::requests::poll_orders::failure_type::FailureType;
use seedance2pro::requests::poll_orders::poll_orders::OrderStatus;
use crate::job_dependencies::JobDependencies;
@@ -15,7 +16,8 @@ pub async fn process_failed_job(
) {
let reason = order
.fail_reason
.as_deref()
.as_ref()
.map(|fr| fr.reason.as_str())
.unwrap_or("unknown failure reason");
// --- Step 1: Attempt the refund before touching the job status. ---
@@ -89,17 +91,19 @@ pub async fn process_failed_job(
// --- Step 2: Mark the job record as failed. ---
let reason_lower = reason.to_lowercase();
let platform_rules_violation = reason_lower.contains("violates") ||
reason_lower.contains("platform rules") ||
reason_lower.contains("please modify");
let frontend_failure_category = if platform_rules_violation {
Some(FrontendFailureCategory::ModelRulesViolation)
} else {
None
};
let frontend_failure_category = order.fail_reason.as_ref().map(|fr| {
match fr.failure_type {
FailureType::RuleBansUserImage => FrontendFailureCategory::RuleBansUserImage,
FailureType::RuleBansUserImageWithFaces => FrontendFailureCategory::RuleBansUserImageWithFaces,
FailureType::RuleBansUserTextPrompt => FrontendFailureCategory::RuleBansUserTextPrompt,
FailureType::RuleBansUserContent => FrontendFailureCategory::RuleBansUserContent,
FailureType::RuleBansGeneratedVideo => FrontendFailureCategory::RuleBansGeneratedVideo,
FailureType::RuleBansGeneratedAudio => FrontendFailureCategory::RuleBansGeneratedAudio,
FailureType::RuleBansGeneratedContent => FrontendFailureCategory::RuleBansGeneratedContent,
FailureType::GenerationFailed => FrontendFailureCategory::GenerationFailed,
FailureType::OtherUnknownReason => FrontendFailureCategory::GenerationFailed,
}
});
warn!(
"Order {} failed: {}. Marking job {} failed.",
@@ -1,3 +1,4 @@
pub mod list_user_wallets_handler;
pub mod moderator_add_banked_balance_to_wallet_handler;
pub mod moderator_create_wallet_for_user_handler;
pub mod moderator_get_wallet_handler;
@@ -0,0 +1,116 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use enums::common::payments_namespace::PaymentsNamespace;
use log::{error, warn};
use sqlx::Acquire;
use artcraft_api_defs::moderation::wallets::moderator_create_wallet_for_user::{
ModeratorCreateWalletForUserRequest,
ModeratorCreateWalletForUserResponse,
};
use mysql_queries::queries::users::user::get::get_username_by_user_token::get_username_by_user_token;
use mysql_queries::queries::wallets::create_new_wallet_for_owner_user::create_new_wallet_for_owner_user;
use mysql_queries::queries::wallets::find_primary_wallet_token_for_owner::find_primary_wallet_token_for_owner_using_connection;
use crate::http_server::common_responses::common_web_error::CommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{require_moderator, UseDatabase};
use crate::state::server_state::ServerState;
/// Create a wallet for a user (moderation)
#[utoipa::path(
post,
tag = "Moderation",
path = "/v1/moderation/wallet/create_for_user",
request_body = ModeratorCreateWalletForUserRequest,
responses(
(status = 200, description = "Success", body = ModeratorCreateWalletForUserResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderator_create_wallet_for_user_handler(
http_request: HttpRequest,
request: Json<ModeratorCreateWalletForUserRequest>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ModeratorCreateWalletForUserResponse>, CommonWebError> {
let _user_session = require_moderator(&http_request, &server_state, UseDatabase::GrabNewConnection)
.await
.map_err(|_| CommonWebError::NotAuthorized)?;
let user_token = request.user_token.as_ref()
.ok_or_else(|| CommonWebError::BadInputWithSimpleMessage("user_token is required".to_string()))?;
// Verify user exists
let maybe_user = get_username_by_user_token(user_token, &server_state.mysql_pool)
.await
.map_err(|err| {
warn!("moderator_create_wallet_for_user user lookup error: {:?}", err);
CommonWebError::ServerError
})?;
if maybe_user.is_none() {
return Err(CommonWebError::BadInputWithSimpleMessage("User not found".to_string()));
}
let namespace = request.payments_namespace.unwrap_or(PaymentsNamespace::Artcraft);
// Check for existing wallet
let mut mysql_connection = server_state.mysql_pool
.acquire()
.await
.map_err(|err| {
error!("Error acquiring MySQL connection: {:?}", err);
CommonWebError::ServerError
})?;
let maybe_wallet_token = find_primary_wallet_token_for_owner_using_connection(
user_token,
namespace,
&mut mysql_connection,
).await.map_err(|err| {
error!("Error finding wallet for user {:?}: {:?}", user_token, err);
CommonWebError::ServerError
})?;
if let Some(wallet_token) = maybe_wallet_token {
return Ok(Json(ModeratorCreateWalletForUserResponse {
success: true,
wallet_token,
}));
}
// Create new wallet
let mut transaction = mysql_connection
.begin()
.await
.map_err(|err| {
error!("Error starting MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
let wallet_token = create_new_wallet_for_owner_user(
user_token,
namespace,
&mut transaction,
).await.map_err(|err| {
error!("Error creating wallet for user {:?}: {:?}", user_token, err);
CommonWebError::ServerError
})?;
transaction
.commit()
.await
.map_err(|err| {
error!("Error committing MySQL transaction: {:?}", err);
CommonWebError::ServerError
})?;
Ok(Json(ModeratorCreateWalletForUserResponse {
success: true,
wallet_token,
}))
}
@@ -24,6 +24,7 @@ use crate::http_server::endpoints::moderation::wallet_ledger_entries::list_walle
use crate::http_server::endpoints::moderation::wallet_ledger_entries::moderator_get_wallet_ledger_entry_handler::moderator_get_wallet_ledger_entry_handler;
use crate::http_server::endpoints::moderation::wallets::list_user_wallets_handler::list_user_wallets_handler;
use crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler;
use crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler;
use crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler;
use crate::http_server::endpoints::moderation::jobs::get_tts_inference_queue_count::get_tts_inference_queue_count_handler;
use crate::http_server::endpoints::moderation::jobs::get_w2l_inference_queue_count::get_w2l_inference_queue_count_handler;
@@ -66,13 +67,19 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/wallet/{wallet_token}")
.route(web::get().to(moderator_get_wallet_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/wallet/{wallet_token}/add_banked_balance")
.route(web::post().to(moderator_add_banked_balance_to_wallet_handler))
.route(web::head().to(|| HttpResponse::Ok()))
.service(web::scope("/wallet")
.service(web::resource("/create_for_user")
.route(web::post().to(moderator_create_wallet_for_user_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/{wallet_token}")
.route(web::get().to(moderator_get_wallet_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::resource("/{wallet_token}/add_banked_balance")
.route(web::post().to(moderator_add_banked_balance_to_wallet_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/wallets")
.service(web::resource("/user/{user_token}/list")