Compare commits

...
Author SHA1 Message Date
Brandon Thomas 4ade8f9923 codegen 2026-05-04 02:56:53 -04:00
Brandon Thomas b20f73c094 change type 2026-05-04 02:54:05 -04:00
Brandon Thomas 82892c1aae new mod endpoint 2026-05-04 02:51:31 -04:00
Brandon Thomas 25ef5c085c alter endpoint, codegen 2026-05-04 02:39:41 -04:00
Brandon Thomas 3178df86e3 debug endpoint 2026-05-04 02:33:06 -04:00
Brandon Thomas a90a27e45d debug logs (#1472) 2026-05-04 02:18:33 -04:00
Brandon Thomas 769faf3daa fix omni image refs 2026-05-03 16:44:12 -04:00
Brandon Thomas 9d38b45bc2 prompting tool for artcraft (#1470) 2026-05-03 00:26:38 -04:00
Brandon Thomas 4b5e171261 generate cli tool (#1469) 2026-05-02 20:32:29 -04:00
100 changed files with 2767 additions and 192 deletions
@@ -1,6 +1,6 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"query": "\nSELECT\n jobs.token as `job_token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n\n jobs.status as `status: enums::common::job_status_plus::JobStatusPlus`,\n \n jobs.maybe_external_third_party as `external_third_party: enums::by_table::generic_inference_jobs::inference_job_external_third_party::InferenceJobExternalThirdParty`,\n jobs.maybe_external_third_party_id as `external_third_party_id`,\n\n jobs.maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n jobs.maybe_creator_anonymous_visitor_token as `maybe_creator_anonymous_visitor_token: tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken`,\n jobs.creator_ip_address,\n \n jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n\n jobs.created_at,\n jobs.updated_at\n\nFROM generic_inference_jobs as jobs\n\nWHERE jobs.maybe_external_third_party = ?\nAND jobs.maybe_external_third_party_id = ?\n ",
"describe": {
"columns": [
{
@@ -85,6 +85,16 @@
},
{
"ordinal": 8,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "created_at",
"type_info": {
"type": "Timestamp",
@@ -94,7 +104,7 @@
}
},
{
"ordinal": 9,
"ordinal": 10,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
@@ -116,9 +126,10 @@
true,
false,
true,
true,
false,
false
]
},
"hash": "fbe482c8987231c3b5d75f2e792d0fdd033de8a7eb821428fa034752c73edfb7"
"hash": "2c98621c08055374fd09502c94ac40bb01d23fcea3f844cceea7446a5c0cb4dc"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_wallet_ledger_entry_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
},
"nullable": []
},
"hash": "8f8b64c0af5383ff3baaa8aa2f2aa22cc760addd850d618ce543fe05816d11e0"
}
@@ -0,0 +1,80 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n id as `id: u64`,\n event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n message,\n created_at\nFROM debug_logs\nWHERE event_token = ?\nORDER BY id ASC\nLIMIT ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id: u64",
"type_info": {
"type": "LongLong",
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
"char_set": 63,
"max_size": 20
}
},
{
"ordinal": 1,
"name": "event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 2,
"name": "debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 96
}
},
{
"ordinal": 3,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "message",
"type_info": {
"type": "Blob",
"flags": "NOT_NULL | BLOB | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 67108860
}
},
{
"ordinal": 5,
"name": "created_at",
"type_info": {
"type": "Datetime",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
true,
false,
false
]
},
"hash": "a62b88fec40a11952cc199383090ea338f97091e8bfc6b68aad67416685889af"
}
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO debug_logs\nSET\n event_token = ?,\n debug_log_type = ?,\n maybe_creator_user_token = ?,\n message = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "a9745ecf60b564ab096ee1dbe124221b5f288a4ae38e788e19bbd359db0b580f"
}
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"query": "\nINSERT INTO generic_inference_jobs\nSET\n token = ?,\n uuid_idempotency_token = ?,\n\n job_type = ?,\n\n maybe_external_third_party = ?,\n maybe_external_third_party_id = ?,\n\n product_category = ?,\n inference_category = ?,\n\n maybe_model_type = NULL,\n maybe_model_token = NULL,\n\n maybe_input_source_token = NULL,\n maybe_input_source_token_type = NULL,\n\n maybe_download_url = NULL,\n maybe_cover_image_media_file_token = NULL,\n\n maybe_prompt_token = ?,\n\n maybe_raw_inference_text = NULL,\n\n maybe_inference_args = ?,\n\n maybe_creator_user_token = ?,\n maybe_creator_anonymous_visitor_token = ?,\n creator_ip_address = ?,\n creator_set_visibility = ?,\n\n priority_level = 0,\n is_keepalive_required = FALSE,\n max_duration_seconds = 0,\n\n is_debug_request = FALSE,\n maybe_routing_tag = NULL,\n\n maybe_debug_log_event_token = ?,\n\n frontend_failure_category = ?,\n failure_reason = ?,\n\n status = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 16
"Right": 17
},
"nullable": []
},
"hash": "29916e73a1784ea0b70ef4f637520b05efccc31e01840f681e9b042f82eb8f7e"
"hash": "c73be10c5677f78f81bc1c29872fd5af889790c3b2a8de27eb8ab64383c638d6"
}
@@ -0,0 +1,355 @@
{
"db_name": "MySQL",
"query": "\nSELECT\n token as `token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,\n uuid_idempotency_token,\n job_type as `maybe_job_type`,\n product_category as `maybe_product_category`,\n inference_category,\n maybe_external_third_party,\n maybe_external_third_party_id,\n maybe_model_type,\n maybe_model_token,\n maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,\n maybe_inference_args,\n maybe_download_url,\n on_success_result_entity_type,\n on_success_result_entity_token,\n on_success_result_batch_token,\n maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,\n maybe_creator_anonymous_visitor_token,\n creator_ip_address,\n creator_set_visibility,\n maybe_wallet_ledger_entry_token,\n maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,\n status,\n attempt_count as `attempt_count: u16`,\n failure_reason,\n internal_debugging_failure_reason,\n frontend_failure_category,\n maybe_routing_tag,\n assigned_worker,\n assigned_cluster,\n created_at,\n updated_at\nFROM generic_inference_jobs\nWHERE token = ?\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token: tokens::tokens::generic_inference_jobs::InferenceJobToken",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 1,
"name": "uuid_idempotency_token",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 144
}
},
{
"ordinal": 2,
"name": "maybe_job_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 3,
"name": "maybe_product_category",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 4,
"name": "inference_category",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 5,
"name": "maybe_external_third_party",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 64
}
},
{
"ordinal": 6,
"name": "maybe_external_third_party_id",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 7,
"name": "maybe_model_type",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 8,
"name": "maybe_model_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 9,
"name": "maybe_prompt_token: tokens::tokens::prompts::PromptToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 10,
"name": "maybe_inference_args",
"type_info": {
"type": "Blob",
"flags": "BLOB | BINARY",
"char_set": 224,
"max_size": 262140
}
},
{
"ordinal": 11,
"name": "maybe_download_url",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 4096
}
},
{
"ordinal": 12,
"name": "on_success_result_entity_type",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 13,
"name": "on_success_result_entity_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 14,
"name": "on_success_result_batch_token",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 15,
"name": "maybe_creator_user_token: tokens::tokens::users::UserToken",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 16,
"name": "maybe_creator_anonymous_visitor_token",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 17,
"name": "creator_ip_address",
"type_info": {
"type": "VarString",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
"char_set": 224,
"max_size": 160
}
},
{
"ordinal": 18,
"name": "creator_set_visibility",
"type_info": {
"type": "String",
"flags": "NOT_NULL | BINARY | ENUM",
"char_set": 224,
"max_size": 28
}
},
{
"ordinal": 19,
"name": "maybe_wallet_ledger_entry_token",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 20,
"name": "maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 21,
"name": "status",
"type_info": {
"type": "String",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | ENUM",
"char_set": 224,
"max_size": 76
}
},
{
"ordinal": 22,
"name": "attempt_count: u16",
"type_info": {
"type": "Short",
"flags": "NOT_NULL | UNSIGNED",
"char_set": 63,
"max_size": 5
}
},
{
"ordinal": 23,
"name": "failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 24,
"name": "internal_debugging_failure_reason",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 2048
}
},
{
"ordinal": 25,
"name": "frontend_failure_category",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 128
}
},
{
"ordinal": 26,
"name": "maybe_routing_tag",
"type_info": {
"type": "VarString",
"flags": "MULTIPLE_KEY | BINARY",
"char_set": 224,
"max_size": 256
}
},
{
"ordinal": 27,
"name": "assigned_worker",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 512
}
},
{
"ordinal": 28,
"name": "assigned_cluster",
"type_info": {
"type": "VarString",
"flags": "BINARY",
"char_set": 224,
"max_size": 512
}
},
{
"ordinal": 29,
"name": "created_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
"char_set": 63,
"max_size": 19
}
},
{
"ordinal": 30,
"name": "updated_at",
"type_info": {
"type": "Timestamp",
"flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW",
"char_set": 63,
"max_size": 19
}
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
false,
true,
true,
false,
false,
true,
true,
true,
true,
true,
true,
false,
false
]
},
"hash": "e869f065e6f184abbabae04ecd0da6ea02c0576cc22ee55e47eb91dd34048ef1"
}
Generated
+5
View File
@@ -11469,13 +11469,18 @@ name = "support-tool"
version = "0.0.1"
dependencies = [
"anyhow",
"artcraft_api_defs",
"artcraft_client",
"clap 4.5.60",
"easyenv",
"enums",
"errors",
"log",
"seedance2pro_client",
"serde_json",
"tokens",
"tokio",
"uuid 1.22.0",
]
[[package]]
@@ -0,0 +1,5 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
DROP TABLE IF EXISTS debug_logs;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE debug_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Event token. Not unique — multiple log rows can share the same event.
event_token VARCHAR(32) NOT NULL,
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
DROP COLUMN maybe_debug_log_event_token;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE generic_inference_jobs
ADD COLUMN maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL;
@@ -0,0 +1,31 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
CREATE TABLE debug_logs (
-- Not used for anything except replication.
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
-- Event token. Not unique — multiple log rows can share the same event.
event_token VARCHAR(32) NOT NULL,
-- The type of debug log entry.
debug_log_type VARCHAR(24) NOT NULL,
-- The user who created the log entry (if any).
maybe_creator_user_token VARCHAR(32) DEFAULT NULL,
-- The log message body.
message MEDIUMTEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- INDICES --
PRIMARY KEY (id),
KEY index_event_token (event_token),
KEY index_debug_log_type (debug_log_type),
KEY index_maybe_creator_user_token (maybe_creator_user_token),
KEY index_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
@@ -204,6 +204,10 @@ CREATE TABLE generic_inference_jobs (
-- off special workers to handle certain jobs.
maybe_routing_tag VARCHAR(64) DEFAULT NULL,
-- Foreign key to the debug_logs table (event_token column).
-- Multiple debug_logs rows can share the same event token.
maybe_debug_log_event_token VARCHAR(32) DEFAULT NULL,
-- ========== JOB SYSTEM DETAILS ==========
-- Jobs begin as "pending", then transition to other states.
@@ -1,5 +1,6 @@
use cloudflare_errors::cloudflare_error::CloudflareError;
use errors::AnyhowError;
use reqwest::StatusCode;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::io;
@@ -30,6 +31,12 @@ pub enum ApiError {
backend_hostname: Option<String>,
},
/// An uncategorized bad HTTP response with status code and body.
UncategorizedBadResponseWithStatusAndBody {
status_code: StatusCode,
body: String,
},
/// Cloudflare errors.
CloudflareError(CloudflareError),
@@ -66,6 +73,8 @@ impl Display for ApiError {
ApiError::TooManyRequests(msg) => write!(f, "Too many requests to Storyteller backend: {}", msg),
ApiError::InternalServerError {body, backend_hostname} =>
write!(f, "Internal Server Error; backend hostname: {:?} ; body: {}; ", backend_hostname, body),
ApiError::UncategorizedBadResponseWithStatusAndBody { status_code, body } =>
write!(f, "Uncategorized bad response with status code {} and body: {}", status_code, body),
// Server response handling errors
ApiError::DeserializationError(error) => write!(f, "Deserialization error: {}", error),
// Network errors
@@ -0,0 +1,157 @@
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use log::{info, warn};
use reqwest::Client;
use url::Url;
use tokens::tokens::media_files::MediaFileToken;
use crate::endpoints::media_files::get_media_file::{get_media_file, GetMediaFileSuccessResponse};
use crate::error::api_error::ApiError;
use crate::error::client_error::ClientError;
use crate::error::storyteller_error::StorytellerError;
use crate::utils::api_host::ApiHost;
pub struct DownloadMediaFileArgs<'a, P: AsRef<Path>> {
pub media_token: &'a MediaFileToken,
pub api_host: &'a ApiHost,
pub download_path: DownloadPath<P>,
}
pub enum DownloadPath<P: AsRef<Path>> {
/// Write the file to this exact path.
ExactFilename(P),
/// Write the file into this directory, generating a filename from the CDN URL extension.
Directory(P),
}
pub struct DownloadMediaFileResult {
/// The path the file was written to.
pub downloaded_file_path: PathBuf,
/// The full media file response from the API.
pub media_file_response: GetMediaFileSuccessResponse,
/// Size of downloaded file in bytes.
pub filesize_bytes: usize,
}
pub async fn download_media_file<P: AsRef<Path>>(
args: DownloadMediaFileArgs<'_, P>,
) -> Result<DownloadMediaFileResult, StorytellerError> {
let DownloadMediaFileArgs {
media_token,
api_host,
download_path
} = args;
// 1. Fetch media file info from the API.
let response = get_media_file(api_host, media_token).await?;
let media_class = &response.media_file.media_class;
let cdn_url = &response.media_file.media_links.cdn_url;
info!("Downloading media file {} of class {} from CDN: {}",
media_token.as_str(), media_class.to_str(), cdn_url);
// 2. Determine the output file path.
let output_path = match &download_path {
DownloadPath::ExactFilename(path) => path.as_ref().to_path_buf(),
DownloadPath::Directory(dir) => {
let filename = derive_filename_from_url(cdn_url, &media_token);
dir.as_ref().join(filename)
}
};
// 3. Download the bytes from the CDN.
let bytes = download_bytes(cdn_url).await?;
// 4. Write to disk.
let mut file = fs::File::create(&output_path)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.write_all(&bytes)
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
file.flush()
.map_err(|err| StorytellerError::Client(ClientError::IoError(err)))?;
info!("Downloaded {} bytes to {:?}", bytes.len(), output_path);
Ok(DownloadMediaFileResult {
downloaded_file_path: output_path,
media_file_response: response,
filesize_bytes: bytes.len(),
})
}
// ── Helpers ──
async fn download_bytes(url: &Url) -> Result<Vec<u8>, StorytellerError> {
let client = Client::builder()
.gzip(true)
.build()
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
let response = client.get(url.as_str())
.send()
.await
.map_err(|err| StorytellerError::Api(ApiError::OtherReqwestError(err)))?;
let status_code = response.status();
if !status_code.is_success() {
let body = response.text().await.unwrap_or_else(|err| {
warn!("Failed to retrieve response body: {}", err);
"".to_string()
});
return Err(StorytellerError::Api(ApiError::UncategorizedBadResponseWithStatusAndBody {
status_code,
body,
}));
}
let bytes = response.bytes()
.await
.map_err(|err| StorytellerError::Client(ClientError::ReqwestError(err)))?;
Ok(bytes.to_vec())
}
/// Derive a filename from the CDN URL's path extension, falling back to the media token.
fn derive_filename_from_url(url: &Url, media_token: &MediaFileToken) -> String {
let path = url.path();
let extension = Path::new(path)
.extension() // NB: without dot '.'
.and_then(|ext| ext.to_str())
.unwrap_or("bin");
format!("{}.{}", media_token.as_str(), extension)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_filename_from_url_extracts_extension() {
let url = Url::parse("https://cdn.example.com/files/abc123.png").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.png");
}
#[test]
fn derive_filename_from_url_handles_no_extension() {
let url = Url::parse("https://cdn.example.com/files/abc123").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.bin");
}
#[test]
fn derive_filename_from_url_handles_query_params() {
let url = Url::parse("https://cdn.example.com/files/abc123.mp4?token=xyz").unwrap();
let token = MediaFileToken::new_from_str("mf_test123");
assert_eq!(derive_filename_from_url(&url, &token), "mf_test123.mp4");
}
}
@@ -1 +1,2 @@
pub mod download_media_file;
pub mod upload_media_file_from_file;
@@ -0,0 +1,58 @@
# support-tool
Production support CLI for debugging and manual operations against external services.
## Building & Running
```bash
cargo build -p support-tool
cargo run -p support-tool -- seedance2pro generate_video --prompt "A corgi at the lake"
```
## Environment
Requires a `.env-support-tool-secrets` file (or env vars) with:
- `SEEDANCE2PRO_COOKIES` — session cookies for Kinovi/Seedance2Pro API auth
- `ARTCRAFT_COOKIES` — session cookies for ArtCraft API auth (format: `session=...; visitor=...`)
## Architecture
- Entry point: `src/main.rs` → normalizes args (underscores optional) → dispatches
- Top-level commands in `src/commands/run.rs` (`TopLevelCommand` enum)
- Each top-level command has its own module with `dispatch.rs`, `state.rs`, `subcommands/`
- Subcommands use `#[derive(Args)]` for their argument structs
- All subcommand `run()` functions are `async fn` returning `anyhow::Result<()>`
## Adding a New Subcommand (to seedance2pro)
1. Create `src/commands/seedance2pro/subcommands/my_command.rs`
2. Add `pub mod my_command;` to `subcommands/mod.rs`
3. Add the name to `SUBCOMMAND_NAMES` in `dispatch.rs`
4. Add a variant to `Seedance2proCommand` enum in `dispatch.rs`
5. Add a match arm in `dispatch.rs::run()`
## Utilities
- `src/utils/parse_video_model.rs` — shared parser for loose video model names → `CommonVideoModel`
- `src/utils/normalize_subcommands.rs` — underscore-insensitive arg normalizer
## Conventions
- Use `log::info!()` for status output, never `println!` (except for final results)
- Use `anyhow::anyhow!()` for ad-hoc errors
- Get `Seedance2ProSession` from `state.cookies` via `from_cookies_string()`
- Get `StorytellerCredentialSet` from `state.creds` (parsed from ARTCRAFT_COOKIES)
- External crates used: `artcraft_client` (media file download, omni API), `seedance2pro_client` (Kinovi direct), `artcraft_api_defs` (request types)
## Subcommands
### seedance2pro (direct Kinovi API)
- `seedance2pro find_job --token <order_id>` — search for a job across all pages
- `seedance2pro failed_job_histogram` — histogram of failure reasons
- `seedance2pro generate_video --prompt <text_or_file> [--model seedance2p0] [--start_frame_media_token mf_xxx] [--localhost] [--download_path /tmp/media_files]`
### artcraft (omni API)
- `artcraft generate_video --prompt <text_or_file> [--model seedance2p0] [--start_frame_media_token mf_xxx] [--duration 5] [--localhost]`
@@ -11,8 +11,12 @@ path = "src/main.rs"
easyenv.workspace = true
# Internal
artcraft_api_defs.workspace = true
artcraft_client.workspace = true
enums.workspace = true
errors.workspace = true
seedance2pro_client.workspace = true
tokens.workspace = true
# External
anyhow.workspace = true
@@ -20,3 +24,4 @@ clap.workspace = true
log.workspace = true
serde_json.workspace = true
tokio.workspace = true
uuid = { version = "1.8.0", features = ["v4"] }
@@ -0,0 +1,33 @@
use anyhow::anyhow;
use clap::Subcommand;
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
use super::state::ArtcraftState;
use super::subcommands;
/// All canonical subcommand names for this module.
/// Used by the underscore-insensitive arg normalizer.
pub const SUBCOMMAND_NAMES: &[&str] = &["generate_video"];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum ArtcraftCommand {
/// Generate a video via the ArtCraft omni endpoint
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: ArtcraftCommand) -> anyhow::Result<()> {
let cookies_str = easyenv::get_env_string_required("ARTCRAFT_COOKIES")
.map_err(|err| anyhow!("Missing ARTCRAFT_COOKIES env var: {:?}", err))?;
let creds = StorytellerCredentialSet::parse_multi_cookie_header(&cookies_str)
.map_err(|err| anyhow!("Failed to parse ARTCRAFT_COOKIES: {:?}", err))?
.ok_or_else(|| anyhow!("ARTCRAFT_COOKIES contained no recognized cookies (expected session=... and/or visitor=...)"))?;
let state = ArtcraftState { creds };
match command {
ArtcraftCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
@@ -0,0 +1,6 @@
pub mod dispatch;
mod state;
mod subcommands;
pub use dispatch::ArtcraftCommand;
pub use dispatch::run;
@@ -0,0 +1,5 @@
use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;
pub struct ArtcraftState {
pub creds: StorytellerCredentialSet,
}
@@ -0,0 +1,179 @@
use std::fs;
use std::path::Path;
use anyhow::anyhow;
use clap::Args;
use log::info;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_client::endpoints::omni_gen::generate::video::omni_gen_video::omni_gen_video_generate;
use artcraft_client::utils::api_host::ApiHost;
use tokens::tokens::media_files::MediaFileToken;
use crate::utils::parse_video_model::parse_video_model;
use super::super::state::ArtcraftState;
// ── Args ──
#[derive(Args)]
#[command(
override_usage = "support-tool artcraft generate_video [OPTIONS] --prompt <PROMPT>",
after_help = "\
EXAMPLES:
support-tool artcraft generate_video --prompt \"A corgi at the lake\" --model seedance2
support-tool artcraft generate_video --prompt prompt.txt --model veo3
support-tool artcraft generate_video --prompt \"Dancing\" --start_frame_media_token mf_abc123
support-tool artcraft generate_video --prompt \"Cat\" --image_reference_tokens \"mf_abc,mf_def\" --model happyhorse
",
)]
pub struct GenerateVideoArgs {
/// A prompt string or path to a file containing the prompt (.txt or .md).
#[arg(long)]
pub prompt: String,
/// A single media token for the start frame image.
#[arg(long)]
pub start_frame_media_token: Option<String>,
/// A single media token for the end frame image.
#[arg(long)]
pub end_frame_media_token: Option<String>,
/// Comma or space separated media tokens for image references.
#[arg(long)]
pub image_reference_tokens: Option<String>,
/// Comma or space separated media tokens for video references.
#[arg(long)]
pub video_reference_tokens: Option<String>,
/// Comma or space separated media tokens for audio references.
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// Use localhost:12345 instead of production API.
#[arg(long)]
pub localhost: bool,
/// Video model to use. Accepts canonical names (eg. "seedance_2p0") or aliases
/// (eg. "seedance2", "happyhorse", "veo3fast"). [default: seedance2p0]
#[arg(long)]
pub model: Option<String>,
/// Duration in seconds. [default: 5]
#[arg(long)]
pub duration: Option<u16>,
/// Idempotency token (auto-generated if not specified).
#[arg(long)]
pub idempotency_token: Option<String>,
}
// ── Entry point ──
pub async fn run(state: &ArtcraftState, args: GenerateVideoArgs) -> anyhow::Result<()> {
let model_str = args.model.as_deref().unwrap_or("seedance2p0");
let model = parse_video_model(model_str)
.ok_or_else(|| anyhow!("Unknown model '{}'. Try --help for supported names.", model_str))?;
let prompt = resolve_prompt(&args.prompt)?;
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
let duration = args.duration.unwrap_or(5);
info!("Model: {:?}", model);
info!("Prompt: {:?}", prompt);
info!("Duration: {}s", duration);
// Parse media tokens.
let start_frame_token = args.start_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let end_frame_token = args.end_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let image_ref_tokens = parse_media_tokens(args.image_reference_tokens.as_deref());
let video_ref_tokens = parse_media_tokens(args.video_reference_tokens.as_deref());
let audio_ref_tokens = parse_media_tokens(args.audio_reference_tokens.as_deref());
if let Some(t) = &start_frame_token { info!("Start frame: {}", t.as_str()); }
if let Some(t) = &end_frame_token { info!("End frame: {}", t.as_str()); }
if !image_ref_tokens.is_empty() { info!("Image refs: {:?}", image_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
if !video_ref_tokens.is_empty() { info!("Video refs: {:?}", video_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
if !audio_ref_tokens.is_empty() { info!("Audio refs: {:?}", audio_ref_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>()); }
// Build the omni request. Media tokens are already uploaded — no download/re-upload needed.
let idempotency_token = args.idempotency_token
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let request = OmniGenVideoCostAndGenerateRequest {
idempotency_token: Some(idempotency_token.clone()),
model: Some(model),
prompt: Some(prompt),
negative_prompt: None,
start_frame_image_media_token: start_frame_token,
end_frame_image_media_token: end_frame_token,
reference_image_media_tokens: if image_ref_tokens.is_empty() { None } else { Some(image_ref_tokens) },
reference_video_media_tokens: if video_ref_tokens.is_empty() { None } else { Some(video_ref_tokens) },
reference_audio_media_tokens: if audio_ref_tokens.is_empty() { None } else { Some(audio_ref_tokens) },
reference_character_tokens: None,
resolution: None,
aspect_ratio: None,
quality: None,
duration_seconds: Some(duration),
video_batch_count: None,
generate_audio: None,
};
info!("Sending request to omni endpoint (idempotency_token={})...", idempotency_token);
let response = omni_gen_video_generate(
&api_host,
Some(&state.creds),
request,
).await.map_err(|err| anyhow!("Omni video generation failed: {}", err))?;
info!("Success!");
info!("Primary job token: {}", response.inference_job_token.as_str());
info!("All job tokens: {:?}", response.all_job_tokens.iter().map(|t| t.as_str()).collect::<Vec<_>>());
Ok(())
}
// ── Helpers ──
fn resolve_prompt(input: &str) -> anyhow::Result<String> {
let looks_like_file = input.ends_with(".txt") || input.ends_with(".md");
if looks_like_file {
let path = Path::new(input);
if !path.exists() {
return Err(anyhow!("Prompt file does not exist: {:?}", path));
}
let content = fs::read_to_string(path)
.map_err(|err| anyhow!("Failed to read prompt file {:?}: {}", path, err))?;
let trimmed = content.trim().to_string();
info!("Read prompt from file {:?} ({} chars)", path, trimmed.len());
Ok(trimmed)
} else {
info!("Using prompt string directly ({} chars)", input.len());
Ok(input.to_string())
}
}
fn parse_media_tokens(input: Option<&str>) -> Vec<MediaFileToken> {
let input = match input {
Some(s) if !s.trim().is_empty() => s,
_ => return Vec::new(),
};
input
.split(|c: char| c == ',' || c.is_whitespace())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s))
.collect()
}
@@ -0,0 +1 @@
pub mod generate_video;
@@ -1,2 +1,3 @@
pub mod artcraft;
pub mod run;
pub mod seedance2pro;
@@ -1,12 +1,14 @@
use clap::{Parser, Subcommand};
use super::artcraft;
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"];
let mut names: Vec<&str> = vec!["seedance2pro", "artcraft"];
names.extend_from_slice(seedance2pro::dispatch::SUBCOMMAND_NAMES);
names.extend_from_slice(artcraft::dispatch::SUBCOMMAND_NAMES);
names
}
@@ -20,11 +22,17 @@ pub struct Cli {
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
pub enum TopLevelCommand {
/// Seedance2 Pro support commands
/// Seedance2 Pro support commands (direct Kinovi API)
Seedance2pro {
#[command(subcommand)]
command: seedance2pro::Seedance2proCommand,
},
/// ArtCraft support commands (omni API)
Artcraft {
#[command(subcommand)]
command: artcraft::ArtcraftCommand,
},
}
pub async fn run(cli: Cli) -> anyhow::Result<()> {
@@ -32,5 +40,8 @@ pub async fn run(cli: Cli) -> anyhow::Result<()> {
TopLevelCommand::Seedance2pro { command } => {
seedance2pro::run(command).await
}
TopLevelCommand::Artcraft { command } => {
artcraft::run(command).await
}
}
}
@@ -6,7 +6,11 @@ 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"];
pub const SUBCOMMAND_NAMES: &[&str] = &[
"failed_job_histogram",
"find_job",
"generate_video",
];
#[derive(Subcommand)]
#[command(rename_all = "snake_case")]
@@ -16,6 +20,9 @@ pub enum Seedance2proCommand {
/// Scan all jobs and print a histogram of failure reasons
FailedJobHistogram,
/// Generate a video via Seedance2Pro/Kinovi directly
GenerateVideo(subcommands::generate_video::GenerateVideoArgs),
}
pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
@@ -27,5 +34,6 @@ pub async fn run(command: Seedance2proCommand) -> anyhow::Result<()> {
match command {
Seedance2proCommand::FindJob(args) => subcommands::find_job::run(&state, args).await,
Seedance2proCommand::FailedJobHistogram => subcommands::failed_job_histogram::run(&state).await,
Seedance2proCommand::GenerateVideo(args) => subcommands::generate_video::run(&state, args).await,
}
}
@@ -0,0 +1,475 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::anyhow;
use clap::Args;
use log::info;
use artcraft_client::recipes::download_media_file::{
download_media_file, DownloadMediaFileArgs, DownloadPath,
};
use artcraft_client::utils::api_host::ApiHost;
use seedance2pro_client::creds::seedance2pro_session::Seedance2ProSession;
use seedance2pro_client::generate::video::generate_happy_horse_1p0::{
generate_happy_horse_1p0, GenerateHappyHorse1p0Args, GenerateHappyHorse1p0Request,
};
use seedance2pro_client::generate::video::generate_seedance_2p0::{
generate_seedance_2p0, GenerateSeedance2p0Args, GenerateSeedance2p0Request,
};
use seedance2pro_client::generate::video::generate_seedance_2p0_fast::{
generate_seedance_2p0_fast, GenerateSeedance2p0FastArgs, GenerateSeedance2p0FastRequest,
};
use seedance2pro_client::requests::prepare_file_upload::prepare_file_upload::{
prepare_file_upload, PrepareFileUploadArgs,
};
use seedance2pro_client::requests::upload_file::upload_file::{upload_file, UploadFileArgs};
use tokens::tokens::media_files::MediaFileToken;
use super::super::state::Seedance2ProState;
const DEFAULT_DOWNLOAD_PATH: &str = "/tmp/media_files";
// ── Args ──
#[derive(Args)]
#[command(
override_usage = "support-tool seedance2pro generate_video [OPTIONS] --prompt <PROMPT>",
after_help = "\
EXAMPLES:
support-tool seedance2pro generate_video --prompt \"A corgi at the lake\"
support-tool seedance2pro generate_video --prompt prompt.txt --model happyhorse
support-tool seedance2pro generate_video --prompt \"Dancing\" --start_frame_media_token mf_abc123
support-tool seedance2pro generate_video --prompt \"Cat\" --image_reference_tokens \"mf_abc,mf_def\"
",
)]
pub struct GenerateVideoArgs {
/// A prompt string or path to a file containing the prompt (.txt or .md).
#[arg(long)]
pub prompt: String,
/// A single media token for the start frame image.
#[arg(long)]
pub start_frame_media_token: Option<String>,
/// A single media token for the end frame image.
#[arg(long)]
pub end_frame_media_token: Option<String>,
/// Comma or space separated media tokens for image references.
#[arg(long)]
pub image_reference_tokens: Option<String>,
/// Comma or space separated media tokens for video references.
#[arg(long)]
pub video_reference_tokens: Option<String>,
/// Comma or space separated media tokens for audio references.
#[arg(long)]
pub audio_reference_tokens: Option<String>,
/// Use localhost:12345 instead of production API for artcraft_client lookups.
#[arg(long)]
pub localhost: bool,
/// Directory to cache downloaded media files. [default: /tmp/media_files]
#[arg(long)]
pub download_path: Option<String>,
/// Model: seedance2p0, seedance2p0fast, happyhorse (and underscore variants).
/// [default: seedance2p0]
#[arg(long)]
pub model: Option<String>,
}
// ── Model enum ──
#[derive(Debug, Clone, Copy)]
enum VideoModel {
Seedance2p0,
Seedance2p0Fast,
HappyHorse,
}
fn parse_model(value: &str) -> anyhow::Result<VideoModel> {
match value.to_lowercase().as_str() {
"seedance2" | "seedance2p0" | "seedance_2p0" => Ok(VideoModel::Seedance2p0),
"seedance2fast" | "seedance2p0fast" | "seedance_2p0_fast" => Ok(VideoModel::Seedance2p0Fast),
"happyhorse" | "happy_horse" => Ok(VideoModel::HappyHorse),
other => Err(anyhow!(
"Unknown model '{}'. Valid: seedance2, seedance2p0, seedance_2p0, \
seedance2fast, seedance2p0fast, seedance_2p0_fast, happyhorse, happy_horse",
other
)),
}
}
// ── Resolved media inputs ──
struct ResolvedMediaInputs {
start_frame_url: Option<String>,
end_frame_url: Option<String>,
reference_image_urls: Option<Vec<String>>,
reference_video_urls: Option<Vec<String>>,
reference_audio_urls: Option<Vec<String>>,
}
// ── Entry point ──
pub async fn run(state: &Seedance2ProState, args: GenerateVideoArgs) -> anyhow::Result<()> {
let session = Seedance2ProSession::from_cookies_string(state.cookies.clone());
let model = parse_model(args.model.as_deref().unwrap_or("seedance2p0"))?;
let prompt = resolve_prompt(&args.prompt)?;
let api_host = if args.localhost { ApiHost::Localhost { port: 12345 } } else { ApiHost::Storyteller };
info!("Model: {:?}", model);
info!("Prompt: {:?}", prompt);
// Resolve download path.
let download_dir = resolve_download_dir(args.download_path.as_deref())?;
info!("Download cache directory: {:?}", download_dir);
// Parse media tokens.
let start_frame_token = args.start_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let end_frame_token = args.end_frame_media_token.as_deref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s));
let image_ref_tokens = parse_media_tokens(args.image_reference_tokens.as_deref());
let video_ref_tokens = parse_media_tokens(args.video_reference_tokens.as_deref());
let audio_ref_tokens = parse_media_tokens(args.audio_reference_tokens.as_deref());
// Collect all tokens that need downloading.
let mut all_tokens: Vec<MediaFileToken> = Vec::new();
if let Some(t) = &start_frame_token { all_tokens.push(t.clone()); }
if let Some(t) = &end_frame_token { all_tokens.push(t.clone()); }
all_tokens.extend(image_ref_tokens.iter().cloned());
all_tokens.extend(video_ref_tokens.iter().cloned());
all_tokens.extend(audio_ref_tokens.iter().cloned());
info!("Total media tokens to process: {}", all_tokens.len());
// Download media files (with caching).
let downloaded_files = download_media_files(&all_tokens, &api_host, &download_dir).await?;
// Upload media files to Kinovi/Seedance2Pro.
let uploaded_urls = upload_media_files_to_kinovi(&session, &downloaded_files).await?;
// Map uploaded URLs back to their roles.
let mut url_iter = uploaded_urls.into_iter();
let start_frame_url = if start_frame_token.is_some() { url_iter.next() } else { None };
let end_frame_url = if end_frame_token.is_some() { url_iter.next() } else { None };
let reference_image_urls: Vec<String> = url_iter.by_ref().take(image_ref_tokens.len()).collect();
let reference_video_urls: Vec<String> = url_iter.by_ref().take(video_ref_tokens.len()).collect();
let reference_audio_urls: Vec<String> = url_iter.by_ref().take(audio_ref_tokens.len()).collect();
let media_inputs = ResolvedMediaInputs {
start_frame_url,
end_frame_url,
reference_image_urls: if reference_image_urls.is_empty() { None } else { Some(reference_image_urls) },
reference_video_urls: if reference_video_urls.is_empty() { None } else { Some(reference_video_urls) },
reference_audio_urls: if reference_audio_urls.is_empty() { None } else { Some(reference_audio_urls) },
};
// Generate video.
generate(model, &session, &prompt, media_inputs).await
}
// ── Prompt resolution ──
fn resolve_prompt(input: &str) -> anyhow::Result<String> {
let looks_like_file = input.ends_with(".txt") || input.ends_with(".md");
if looks_like_file {
let path = Path::new(input);
if !path.exists() {
return Err(anyhow!("Prompt file does not exist: {:?}", path));
}
let content = fs::read_to_string(path)
.map_err(|err| anyhow!("Failed to read prompt file {:?}: {}", path, err))?;
let trimmed = content.trim().to_string();
info!("Read prompt from file {:?} ({} chars)", path, trimmed.len());
Ok(trimmed)
} else {
info!("Using prompt string directly ({} chars)", input.len());
Ok(input.to_string())
}
}
// ── Download path resolution ──
fn resolve_download_dir(maybe_path: Option<&str>) -> anyhow::Result<PathBuf> {
let dir = match maybe_path {
Some(path) => {
let p = PathBuf::from(path);
if p.exists() && !p.is_dir() {
return Err(anyhow!("--download_path {:?} exists but is not a directory", p));
}
p
}
None => PathBuf::from(DEFAULT_DOWNLOAD_PATH),
};
if !dir.exists() {
info!("Creating download directory: {:?}", dir);
fs::create_dir_all(&dir)
.map_err(|err| anyhow!("Failed to create download directory {:?}: {}", dir, err))?;
}
Ok(dir)
}
// ── Media token parsing ──
fn parse_media_tokens(input: Option<&str>) -> Vec<MediaFileToken> {
let input = match input {
Some(s) if !s.trim().is_empty() => s,
_ => return Vec::new(),
};
input
.split(|c: char| c == ',' || c.is_whitespace())
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| MediaFileToken::new_from_str(s))
.collect()
}
// ── Download media files with caching ──
struct DownloadedFile {
path: PathBuf,
extension: String,
}
async fn download_media_files(
tokens: &[MediaFileToken],
api_host: &ApiHost,
download_dir: &Path,
) -> anyhow::Result<Vec<DownloadedFile>> {
let mut results = Vec::new();
for token in tokens {
// Check if file already exists in cache.
let maybe_cached = find_cached_file(download_dir, token);
if let Some(cached) = maybe_cached {
info!(
"Cache hit: {} already exists at {:?} ({} bytes)",
token.as_str(),
cached.path,
fs::metadata(&cached.path).map(|m| m.len()).unwrap_or(0),
);
results.push(cached);
continue;
}
info!("Downloading {} from API...", token.as_str());
let result = download_media_file(DownloadMediaFileArgs {
media_token: token,
api_host,
download_path: DownloadPath::Directory(download_dir),
}).await.map_err(|err| anyhow!("Failed to download {}: {}", token.as_str(), err))?;
let extension = result.downloaded_file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_string();
info!(
"Downloaded: {} -> {:?} ({} bytes, class: {})",
token.as_str(),
result.downloaded_file_path,
result.filesize_bytes,
result.media_file_response.media_file.media_class.to_str(),
);
results.push(DownloadedFile {
path: result.downloaded_file_path,
extension,
});
}
Ok(results)
}
/// Look for an existing cached file matching the token (any extension).
fn find_cached_file(download_dir: &Path, token: &MediaFileToken) -> Option<DownloadedFile> {
let prefix = format!("{}.", token.as_str());
let entries = fs::read_dir(download_dir).ok()?;
for entry in entries.flatten() {
let filename = entry.file_name();
let filename_str = filename.to_string_lossy();
if filename_str.starts_with(&prefix) {
let path = entry.path();
// Skip zero-byte files — treat as incomplete downloads.
let metadata = fs::metadata(&path).ok()?;
if metadata.len() == 0 {
continue;
}
let extension = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("bin")
.to_string();
return Some(DownloadedFile { path, extension });
}
}
None
}
// ── Upload to Kinovi/Seedance2Pro ──
async fn upload_media_files_to_kinovi(
session: &Seedance2ProSession,
files: &[DownloadedFile],
) -> anyhow::Result<Vec<String>> {
let mut urls = Vec::new();
for file in files {
info!("Uploading {:?} to Kinovi (extension: {})...", file.path, file.extension);
// 1. Prepare upload (get signed URL).
let prepare_result = prepare_file_upload(PrepareFileUploadArgs {
session,
extension: file.extension.clone(),
host_override: None,
}).await.map_err(|err| anyhow!("Failed to prepare upload for {:?}: {:?}", file.path, err))?;
// 2. Read file bytes.
let file_bytes = fs::read(&file.path)
.map_err(|err| anyhow!("Failed to read {:?}: {}", file.path, err))?;
info!("Uploading {} bytes to signed URL...", file_bytes.len());
// 3. Upload to Cloudflare R2.
let upload_result = upload_file(UploadFileArgs {
upload_url: prepare_result.upload_url,
file_bytes,
host_override: None,
}).await.map_err(|err| anyhow!("Failed to upload {:?}: {:?}", file.path, err))?;
info!("Uploaded: {:?} -> {}", file.path, upload_result.public_url);
urls.push(upload_result.public_url);
}
Ok(urls)
}
// ── Generate video ──
async fn generate(
model: VideoModel,
session: &Seedance2ProSession,
prompt: &str,
media: ResolvedMediaInputs,
) -> anyhow::Result<()> {
match model {
VideoModel::HappyHorse => {
// Happy Horse only supports start_frame_url.
if media.end_frame_url.is_some() {
return Err(anyhow!("Happy Horse does not support --end_frame_media_token"));
}
if media.reference_image_urls.is_some() {
return Err(anyhow!("Happy Horse does not support --image_reference_tokens"));
}
if media.reference_video_urls.is_some() {
return Err(anyhow!("Happy Horse does not support --video_reference_tokens"));
}
if media.reference_audio_urls.is_some() {
return Err(anyhow!("Happy Horse does not support --audio_reference_tokens"));
}
info!("Generating video with Happy Horse 1.0...");
let result = generate_happy_horse_1p0(GenerateHappyHorse1p0Args {
request: GenerateHappyHorse1p0Request {
prompt: prompt.to_string(),
aspect_ratio: None,
output_resolution: None,
batch_count: None,
duration_seconds: 5,
start_frame_url: media.start_frame_url,
},
session,
host_override: None,
}).await.map_err(|err| anyhow!("Happy Horse generation failed: {:?}", err))?;
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
if let Some(task_ids) = &result.task_ids {
info!("All task_ids: {:?}", task_ids);
}
if let Some(order_ids) = &result.order_ids {
info!("All order_ids: {:?}", order_ids);
}
}
VideoModel::Seedance2p0 => {
info!("Generating video with Seedance 2.0 Pro...");
let result = generate_seedance_2p0(GenerateSeedance2p0Args {
request: GenerateSeedance2p0Request {
prompt: prompt.to_string(),
aspect_ratio: None,
output_resolution: None,
duration_seconds: 5,
batch_count: None,
start_frame_url: media.start_frame_url,
end_frame_url: media.end_frame_url,
reference_image_urls: media.reference_image_urls,
reference_video_urls: media.reference_video_urls,
reference_audio_urls: media.reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
session,
host_override: None,
}).await.map_err(|err| anyhow!("Seedance 2.0 generation failed: {:?}", err))?;
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
if let Some(task_ids) = &result.task_ids {
info!("All task_ids: {:?}", task_ids);
}
if let Some(order_ids) = &result.order_ids {
info!("All order_ids: {:?}", order_ids);
}
}
VideoModel::Seedance2p0Fast => {
info!("Generating video with Seedance 2.0 Fast...");
let result = generate_seedance_2p0_fast(GenerateSeedance2p0FastArgs {
request: GenerateSeedance2p0FastRequest {
prompt: prompt.to_string(),
aspect_ratio: None,
output_resolution: None,
duration_seconds: 5,
batch_count: None,
start_frame_url: media.start_frame_url,
end_frame_url: media.end_frame_url,
reference_image_urls: media.reference_image_urls,
reference_video_urls: media.reference_video_urls,
reference_audio_urls: media.reference_audio_urls,
character_ids: None,
use_face_blur_hack: None,
},
session,
host_override: None,
}).await.map_err(|err| anyhow!("Seedance 2.0 Fast generation failed: {:?}", err))?;
info!("Success! task_id={}, order_id={}", result.task_id, result.order_id);
if let Some(task_ids) = &result.task_ids {
info!("All task_ids: {:?}", task_ids);
}
if let Some(order_ids) = &result.order_ids {
info!("All order_ids: {:?}", order_ids);
}
}
}
Ok(())
}
@@ -1,2 +1,3 @@
pub mod failed_job_histogram;
pub mod find_job;
pub mod generate_video;
@@ -1 +1,2 @@
pub mod normalize_subcommands;
pub mod parse_video_model;
@@ -0,0 +1,83 @@
use enums::common::generation::common_video_model::CommonVideoModel;
/// Parse a loose model name into a CommonVideoModel.
///
/// Accepts the canonical serde names (eg. "seedance_2p0") as well as
/// human-friendly aliases (eg. "seedance2", "happyhorse", "veo3fast").
pub fn parse_video_model(value: &str) -> Option<CommonVideoModel> {
let value = value.trim();
// Try canonical serde deserialization first (eg. "seedance_2p0", "happy_horse_1p0").
if let Ok(model) = serde_json::from_str::<CommonVideoModel>(&format!("\"{}\"", value)) {
return Some(model);
}
// Fallback: human-friendly aliases.
match value.to_lowercase().replace('-', "_").as_str() {
// Seedance family
"seedance2" | "seedance2p0" | "seedance_2p0" => Some(CommonVideoModel::Seedance2p0),
"seedance2fast" | "seedance2p0fast" | "seedance_2p0_fast" => Some(CommonVideoModel::Seedance2p0Fast),
"seedance1" | "seedance10lite" | "seedance_1p0_lite" => Some(CommonVideoModel::Seedance10Lite),
"seedance15" | "seedance1p5" | "seedance_1p5_pro" => Some(CommonVideoModel::Seedance1p5Pro),
// Happy Horse
"happyhorse" | "happy_horse" | "happyhorse1" | "happy_horse_1p0" => Some(CommonVideoModel::HappyHorse1p0),
// Kling family
"kling16" | "kling_1p6_pro" => Some(CommonVideoModel::Kling16Pro),
"kling21" | "kling_2p1_pro" => Some(CommonVideoModel::Kling21Pro),
"kling21master" | "kling_2p1_master" => Some(CommonVideoModel::Kling21Master),
"kling25turbo" | "kling_2p5_turbo_pro" => Some(CommonVideoModel::Kling2p5TurboPro),
"kling26" | "kling_2p6_pro" => Some(CommonVideoModel::Kling2p6Pro),
"kling3" | "kling30" | "kling_3p0_standard" => Some(CommonVideoModel::Kling3p0Standard),
"kling3pro" | "kling30pro" | "kling_3p0_pro" => Some(CommonVideoModel::Kling3p0Pro),
// Veo family
"veo2" | "veo_2" => Some(CommonVideoModel::Veo2),
"veo3" | "veo_3" => Some(CommonVideoModel::Veo3),
"veo3fast" | "veo_3_fast" => Some(CommonVideoModel::Veo3Fast),
"veo31" | "veo3p1" | "veo_3p1" => Some(CommonVideoModel::Veo3p1),
"veo31fast" | "veo3p1fast" | "veo_3p1_fast" => Some(CommonVideoModel::Veo3p1Fast),
// Sora family
"sora2" | "sora_2" => Some(CommonVideoModel::Sora2),
"sora2pro" | "sora_2_pro" => Some(CommonVideoModel::Sora2Pro),
// Grok
"grok" | "grok_video" | "grokvideo" => Some(CommonVideoModel::GrokVideo),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_names() {
assert_eq!(parse_video_model("seedance_2p0"), Some(CommonVideoModel::Seedance2p0));
assert_eq!(parse_video_model("happy_horse_1p0"), Some(CommonVideoModel::HappyHorse1p0));
assert_eq!(parse_video_model("veo_3"), Some(CommonVideoModel::Veo3));
}
#[test]
fn aliases() {
assert_eq!(parse_video_model("seedance2"), Some(CommonVideoModel::Seedance2p0));
assert_eq!(parse_video_model("seedance2fast"), Some(CommonVideoModel::Seedance2p0Fast));
assert_eq!(parse_video_model("happyhorse"), Some(CommonVideoModel::HappyHorse1p0));
assert_eq!(parse_video_model("veo3fast"), Some(CommonVideoModel::Veo3Fast));
assert_eq!(parse_video_model("kling3pro"), Some(CommonVideoModel::Kling3p0Pro));
}
#[test]
fn unknown() {
assert_eq!(parse_video_model("nonexistent"), None);
assert_eq!(parse_video_model(""), None);
}
#[test]
fn trims_whitespace() {
assert_eq!(parse_video_model(" seedance2 "), Some(CommonVideoModel::Seedance2p0));
}
}
@@ -0,0 +1,83 @@
use std::marker::PhantomData;
use log::warn;
use sqlx::{Executor, MySql, QueryBuilder};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
pub struct BulkInsertDebugLogsArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// If provided, all rows will share this event token.
/// If not provided, one will be generated.
pub apriori_debug_log_event_token: Option<&'e DebugLogEventToken>,
/// The user associated with these debug log events (if any).
pub maybe_creator_user_token: Option<&'e UserToken>,
/// The events to insert.
pub events: Vec<DebugLogEvent>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// A single debug log event to insert.
///
/// The `message` field should be a pre-serialized JSON string (or any text).
/// Callers should use `serde_json::to_string(...)` before constructing this.
pub struct DebugLogEvent {
pub debug_log_type: DebugLogType,
pub message: String,
}
/// Bulk-insert debug log rows. All rows share the same event token.
///
/// Returns the event token used. No-op (with a warning) if `events` is empty.
pub async fn bulk_insert_debug_logs<'e, 'c: 'e, E>(
args: BulkInsertDebugLogsArgs<'e, 'c, E>,
) -> Result<DebugLogEventToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let event_token = match args.apriori_debug_log_event_token {
Some(token) => token.clone(),
None => DebugLogEventToken::generate(),
};
if args.events.is_empty() {
warn!("bulk_insert_debug_logs called with no events; no-op.");
return Ok(event_token);
}
let maybe_user_token_str = args.maybe_creator_user_token.map(|t| t.as_str().to_string());
let mut query_builder = QueryBuilder::new(
"INSERT INTO debug_logs (event_token, debug_log_type, maybe_creator_user_token, message) VALUES "
);
for (i, event) in args.events.iter().enumerate() {
query_builder.push("(");
query_builder.push_bind(event_token.as_str());
query_builder.push(", ");
query_builder.push_bind(event.debug_log_type.to_str());
query_builder.push(", ");
query_builder.push_bind(&maybe_user_token_str);
query_builder.push(", ");
query_builder.push_bind(&event.message);
query_builder.push(")");
if i < args.events.len() - 1 {
query_builder.push(", ");
}
}
query_builder.build()
.execute(args.mysql_executor)
.await?;
Ok(event_token)
}
@@ -0,0 +1,62 @@
use std::marker::PhantomData;
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
pub struct InsertDebugLogArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
/// If provided, the row will use this event token.
/// If not provided, one will be generated.
pub apriori_debug_log_event_token: Option<&'e DebugLogEventToken>,
/// The user associated with this debug log event (if any).
pub maybe_creator_user_token: Option<&'e UserToken>,
/// The type of debug log event.
pub debug_log_type: DebugLogType,
/// The log message body (pre-serialized JSON or any text).
pub message: &'e str,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
/// Insert a single debug log row.
///
/// Returns the event token used.
pub async fn insert_debug_log<'e, 'c: 'e, E>(
args: InsertDebugLogArgs<'e, 'c, E>,
) -> Result<DebugLogEventToken, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let event_token = match args.apriori_debug_log_event_token {
Some(token) => token.clone(),
None => DebugLogEventToken::generate(),
};
sqlx::query!(
r#"
INSERT INTO debug_logs
SET
event_token = ?,
debug_log_type = ?,
maybe_creator_user_token = ?,
message = ?
"#,
event_token.as_str(),
args.debug_log_type.to_str(),
args.maybe_creator_user_token.map(|t| t.as_str()),
args.message,
)
.execute(args.mysql_executor)
.await?;
Ok(event_token)
}
@@ -0,0 +1,83 @@
use std::marker::PhantomData;
use chrono::{DateTime, NaiveDateTime, Utc};
use sqlx::{Executor, MySql};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
const DEFAULT_LIMIT: u32 = 50;
pub struct ListDebugLogsForTokenArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub event_token: &'e DebugLogEventToken,
pub limit: Option<u32>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct DebugLogRow {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug)]
struct RawDebugLogRow {
id: u64,
event_token: DebugLogEventToken,
debug_log_type: DebugLogType,
maybe_creator_user_token: Option<UserToken>,
message: String,
created_at: NaiveDateTime,
}
pub async fn list_debug_logs_for_token<'e, 'c: 'e, E>(
args: ListDebugLogsForTokenArgs<'e, 'c, E>,
) -> Result<Vec<DebugLogRow>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let limit = args.limit.unwrap_or(DEFAULT_LIMIT).min(200) as i64;
let rows = sqlx::query_as!(
RawDebugLogRow,
r#"
SELECT
id as `id: u64`,
event_token as `event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
debug_log_type as `debug_log_type: enums::by_table::debug_logs::debug_log_type::DebugLogType`,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
message,
created_at
FROM debug_logs
WHERE event_token = ?
ORDER BY id ASC
LIMIT ?
"#,
args.event_token.as_str(),
limit,
)
.fetch_all(args.mysql_executor)
.await?;
let results = rows.into_iter().map(|row| {
DebugLogRow {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at.and_utc(),
}
}).collect();
Ok(results)
}
@@ -0,0 +1,3 @@
pub mod bulk_insert_debug_logs;
pub mod insert_debug_log;
pub mod list_debug_logs_for_token;
@@ -9,6 +9,7 @@ use enums::common::job_status_plus::JobStatusPlus;
use errors::AnyhowResult;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
@@ -29,6 +30,7 @@ pub struct FalJobDetails {
pub creator_ip_address: String,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -51,6 +53,7 @@ struct RawJobRecord {
creator_ip_address: String,
maybe_prompt_token: Option<PromptToken>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
@@ -88,6 +91,7 @@ SELECT
jobs.creator_ip_address,
jobs.maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
jobs.maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
jobs.created_at,
jobs.updated_at
@@ -129,6 +133,7 @@ fn raw_record_to_public_result(record: RawJobRecord) -> AnyhowResult<FalJobDetai
maybe_creator_anonymous_visitor_token: record.maybe_creator_anonymous_visitor_token,
creator_ip_address: record.creator_ip_address,
maybe_prompt_token: record.maybe_prompt_token,
maybe_debug_log_event_token: record.maybe_debug_log_event_token,
created_at: record.created_at,
updated_at: record.updated_at,
})
@@ -65,6 +65,7 @@ pub async fn insert_generic_inference_job_for_fal_queue<'e, 'c : 'e, E>(args: In
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
};
@@ -68,6 +68,7 @@ pub async fn insert_generic_inference_job_for_fal_queue_mock_failure<'e, 'c: 'e,
starting_job_status_override: Some(JobStatusPlus::CompleteFailure),
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
maybe_failure_reason: args.maybe_failure_reason,
maybe_debug_log_event_token: None,
mysql_executor: args.mysql_executor,
phantom: args.phantom,
};
@@ -13,6 +13,7 @@ use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
@@ -47,6 +48,8 @@ pub struct InsertGenericInferenceForFalWithAprioriJobTokenArgs<'e, 'c, E>
/// Set to `Some(JobStatusPlus::CompleteFailure)` for mock/test failure jobs.
pub starting_job_status_override: Option<JobStatusPlus>,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
pub maybe_failure_reason: Option<&'e str>,
@@ -139,6 +142,8 @@ SET
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
frontend_failure_category = ?,
failure_reason = ?,
@@ -164,6 +169,8 @@ SET
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
maybe_frontend_failure_category_str,
maybe_truncated_failure_reason,
@@ -1,5 +1,6 @@
use anyhow::anyhow;
use sqlx::MySqlPool;
use sqlx::pool::PoolConnection;
use sqlx::{MySql, MySqlPool};
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use enums::common::job_status_plus::JobStatusPlus;
@@ -14,10 +15,52 @@ pub struct MarkJobFailedByTokenArgs<'a> {
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
}
pub struct MarkJobFailedByTokenFromConnectionArgs<'a> {
pub mysql_connection: &'a mut PoolConnection<MySql>,
pub job_token: &'a InferenceJobToken,
pub maybe_public_failure_reason: Option<&'a str>,
pub internal_debugging_failure_reason: &'a str,
pub maybe_frontend_failure_category: Option<FrontendFailureCategory>,
}
/// Permanently mark an inference job as failed, looked up by its token.
/// Unlike `mark_generic_inference_job_failure`, this does not allow retries —
/// the job will always land in `complete_failure`.
/// Uses a pool to acquire a connection.
pub async fn mark_job_failed_by_token(args: MarkJobFailedByTokenArgs<'_>) -> AnyhowResult<()> {
execute_mark_failed(ExecuteMarkJobFailedByTokenArgs {
executor: args.pool,
job_token: args.job_token,
maybe_public_failure_reason: args.maybe_public_failure_reason,
internal_debugging_failure_reason: args.internal_debugging_failure_reason,
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
}).await
}
/// Permanently mark an inference job as failed, looked up by its token.
/// Uses an existing connection.
pub async fn mark_job_failed_by_token_from_connection(args: MarkJobFailedByTokenFromConnectionArgs<'_>) -> AnyhowResult<()> {
execute_mark_failed(ExecuteMarkJobFailedByTokenArgs {
executor: &mut **args.mysql_connection,
job_token: args.job_token,
maybe_public_failure_reason: args.maybe_public_failure_reason,
internal_debugging_failure_reason: args.internal_debugging_failure_reason,
maybe_frontend_failure_category: args.maybe_frontend_failure_category,
}).await
}
struct ExecuteMarkJobFailedByTokenArgs<'a, E> {
executor: E,
job_token: &'a InferenceJobToken,
maybe_public_failure_reason: Option<&'a str>,
internal_debugging_failure_reason: &'a str,
maybe_frontend_failure_category: Option<FrontendFailureCategory>,
}
async fn execute_mark_failed<'e, E>(
args: ExecuteMarkJobFailedByTokenArgs<'_, E>,
) -> AnyhowResult<()>
where
E: sqlx::Executor<'e, Database = MySql>,
{
let maybe_public_failure_reason = args.maybe_public_failure_reason.map(|reason| {
let mut reason = reason.trim().to_string();
reason.truncate(512);
@@ -46,7 +89,7 @@ WHERE token = ?
args.maybe_frontend_failure_category,
args.job_token.as_str()
)
.execute(args.pool)
.execute(args.executor)
.await;
match query_result {
@@ -1,5 +1,6 @@
pub mod fal;
pub mod job;
pub mod moderation;
pub mod seedance2pro;
pub mod web;
pub mod worldlabs;
@@ -0,0 +1,172 @@
use std::marker::PhantomData;
use chrono::{DateTime, Utc};
use sqlx::{Executor, MySql};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
pub struct GetJobByTokenForModerationArgs<'e, 'c, E>
where
E: 'e + Executor<'c, Database = MySql>,
{
pub job_token: &'e InferenceJobToken,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
}
#[derive(Debug)]
pub struct ModerationJobDetails {
pub token: InferenceJobToken,
pub uuid_idempotency_token: String,
pub maybe_job_type: Option<String>,
pub maybe_product_category: Option<String>,
pub inference_category: String,
pub maybe_external_third_party: Option<String>,
pub maybe_external_third_party_id: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_inference_args: Option<String>,
pub maybe_download_url: Option<String>,
pub on_success_result_entity_type: Option<String>,
pub on_success_result_entity_token: Option<String>,
pub on_success_result_batch_token: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<String>,
pub creator_ip_address: String,
pub creator_set_visibility: String,
pub maybe_wallet_ledger_entry_token: Option<String>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub status: String,
pub attempt_count: u16,
pub failure_reason: Option<String>,
pub internal_debugging_failure_reason: Option<String>,
pub frontend_failure_category: Option<String>,
pub maybe_routing_tag: Option<String>,
pub assigned_worker: Option<String>,
pub assigned_cluster: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug)]
struct RawRow {
token: InferenceJobToken,
uuid_idempotency_token: String,
maybe_job_type: Option<String>,
maybe_product_category: Option<String>,
inference_category: String,
maybe_external_third_party: Option<String>,
maybe_external_third_party_id: Option<String>,
maybe_model_type: Option<String>,
maybe_model_token: Option<String>,
maybe_prompt_token: Option<PromptToken>,
maybe_inference_args: Option<String>,
maybe_download_url: Option<String>,
on_success_result_entity_type: Option<String>,
on_success_result_entity_token: Option<String>,
on_success_result_batch_token: Option<String>,
maybe_creator_user_token: Option<UserToken>,
maybe_creator_anonymous_visitor_token: Option<String>,
creator_ip_address: String,
creator_set_visibility: String,
maybe_wallet_ledger_entry_token: Option<String>,
maybe_debug_log_event_token: Option<DebugLogEventToken>,
status: String,
attempt_count: u16,
failure_reason: Option<String>,
internal_debugging_failure_reason: Option<String>,
frontend_failure_category: Option<String>,
maybe_routing_tag: Option<String>,
assigned_worker: Option<String>,
assigned_cluster: Option<String>,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
pub async fn get_job_by_token_for_moderation<'e, 'c: 'e, E>(
args: GetJobByTokenForModerationArgs<'e, 'c, E>,
) -> Result<Option<ModerationJobDetails>, sqlx::Error>
where
E: 'e + Executor<'c, Database = MySql>,
{
let maybe_row = sqlx::query_as!(
RawRow,
r#"
SELECT
token as `token: tokens::tokens::generic_inference_jobs::InferenceJobToken`,
uuid_idempotency_token,
job_type as `maybe_job_type`,
product_category as `maybe_product_category`,
inference_category,
maybe_external_third_party,
maybe_external_third_party_id,
maybe_model_type,
maybe_model_token,
maybe_prompt_token as `maybe_prompt_token: tokens::tokens::prompts::PromptToken`,
maybe_inference_args,
maybe_download_url,
on_success_result_entity_type,
on_success_result_entity_token,
on_success_result_batch_token,
maybe_creator_user_token as `maybe_creator_user_token: tokens::tokens::users::UserToken`,
maybe_creator_anonymous_visitor_token,
creator_ip_address,
creator_set_visibility,
maybe_wallet_ledger_entry_token,
maybe_debug_log_event_token as `maybe_debug_log_event_token: tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken`,
status,
attempt_count as `attempt_count: u16`,
failure_reason,
internal_debugging_failure_reason,
frontend_failure_category,
maybe_routing_tag,
assigned_worker,
assigned_cluster,
created_at,
updated_at
FROM generic_inference_jobs
WHERE token = ?
"#,
args.job_token.as_str(),
)
.fetch_optional(args.mysql_executor)
.await?;
Ok(maybe_row.map(|row| ModerationJobDetails {
token: row.token,
uuid_idempotency_token: row.uuid_idempotency_token,
maybe_job_type: row.maybe_job_type,
maybe_product_category: row.maybe_product_category,
inference_category: row.inference_category,
maybe_external_third_party: row.maybe_external_third_party,
maybe_external_third_party_id: row.maybe_external_third_party_id,
maybe_model_type: row.maybe_model_type,
maybe_model_token: row.maybe_model_token,
maybe_prompt_token: row.maybe_prompt_token,
maybe_inference_args: row.maybe_inference_args,
maybe_download_url: row.maybe_download_url,
on_success_result_entity_type: row.on_success_result_entity_type,
on_success_result_entity_token: row.on_success_result_entity_token,
on_success_result_batch_token: row.on_success_result_batch_token,
maybe_creator_user_token: row.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: row.maybe_creator_anonymous_visitor_token,
creator_ip_address: row.creator_ip_address,
creator_set_visibility: row.creator_set_visibility,
maybe_wallet_ledger_entry_token: row.maybe_wallet_ledger_entry_token,
maybe_debug_log_event_token: row.maybe_debug_log_event_token,
status: row.status,
attempt_count: row.attempt_count,
failure_reason: row.failure_reason,
internal_debugging_failure_reason: row.internal_debugging_failure_reason,
frontend_failure_category: row.frontend_failure_category,
maybe_routing_tag: row.maybe_routing_tag,
assigned_worker: row.assigned_worker,
assigned_cluster: row.assigned_cluster,
created_at: row.created_at,
updated_at: row.updated_at,
}))
}
@@ -0,0 +1 @@
pub mod get_job_by_token_for_moderation;
@@ -11,6 +11,7 @@ use enums::common::job_status_plus::JobStatusPlus;
use enums::common::visibility::Visibility;
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use tokens::tokens::wallet_ledger_entries::WalletLedgerEntryToken;
@@ -40,6 +41,8 @@ pub struct InsertGenericInferenceForSeedance2ProWithAprioriJobTokenArgs<'e, 'c,
pub creator_ip_address: &'e str,
pub creator_set_visibility: Visibility,
pub maybe_debug_log_event_token: Option<&'e DebugLogEventToken>,
pub mysql_executor: E,
pub phantom: PhantomData<&'c E>,
@@ -103,6 +106,8 @@ SET
is_debug_request = FALSE,
maybe_routing_tag = NULL,
maybe_debug_log_event_token = ?,
status = ?
"#,
args.apriori_job_token.as_str(),
@@ -127,6 +132,8 @@ SET
args.creator_ip_address,
args.creator_set_visibility.to_str(),
args.maybe_debug_log_event_token.map(|t| t.as_str()),
STATUS.to_str(),
);
@@ -8,6 +8,7 @@ pub mod billing;
pub mod browser_session_logs;
pub mod characters;
pub mod comments;
pub mod debug_logs;
pub mod database_time;
pub mod email_sender_jobs;
pub mod entity_stats;
@@ -0,0 +1,160 @@
use std::collections::BTreeSet;
#[cfg(test)]
use strum::EnumCount;
#[cfg(test)]
use strum::EnumIter;
use utoipa::ToSchema;
use crate::error::enum_error::EnumError;
/// Maximum serialized string length for database storage.
pub const MAX_LENGTH: usize = 24;
/// Used in the `debug_logs` table in a `VARCHAR(24)` field.
///
/// DO NOT CHANGE VALUES WITHOUT A MIGRATION STRATEGY.
#[cfg_attr(test, derive(EnumIter, EnumCount))]
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum DebugLogType {
HttpRequest,
FalRequest,
KinoviRequest,
FalWebhook,
}
impl_enum_display_and_debug_using_to_str!(DebugLogType);
impl_mysql_enum_coders!(DebugLogType);
impl_mysql_from_row!(DebugLogType);
impl DebugLogType {
pub fn to_str(&self) -> &'static str {
match self {
Self::HttpRequest => "http_request",
Self::FalRequest => "fal_request",
Self::KinoviRequest => "kinovi_request",
Self::FalWebhook => "fal_webhook",
}
}
pub fn from_str(value: &str) -> Result<Self, EnumError> {
match value {
"http_request" => Ok(Self::HttpRequest),
"fal_request" => Ok(Self::FalRequest),
"kinovi_request" => Ok(Self::KinoviRequest),
"fal_webhook" => Ok(Self::FalWebhook),
_ => Err(EnumError::CouldNotConvertFromString(value.to_string())),
}
}
pub fn all_variants() -> BTreeSet<Self> {
BTreeSet::from([
Self::HttpRequest,
Self::FalRequest,
Self::KinoviRequest,
Self::FalWebhook,
])
}
}
#[cfg(test)]
mod tests {
use crate::by_table::debug_logs::debug_log_type::DebugLogType;
use crate::by_table::debug_logs::debug_log_type::MAX_LENGTH;
use crate::test_helpers::assert_serialization;
mod explicit_checks {
use super::*;
use crate::error::enum_error::EnumError;
#[test]
fn test_serialization() {
assert_serialization(DebugLogType::HttpRequest, "http_request");
assert_serialization(DebugLogType::FalRequest, "fal_request");
assert_serialization(DebugLogType::KinoviRequest, "kinovi_request");
assert_serialization(DebugLogType::FalWebhook, "fal_webhook");
}
#[test]
fn to_str() {
assert_eq!(DebugLogType::HttpRequest.to_str(), "http_request");
assert_eq!(DebugLogType::FalRequest.to_str(), "fal_request");
assert_eq!(DebugLogType::KinoviRequest.to_str(), "kinovi_request");
assert_eq!(DebugLogType::FalWebhook.to_str(), "fal_webhook");
}
#[test]
fn from_str() {
assert_eq!(DebugLogType::from_str("http_request").unwrap(), DebugLogType::HttpRequest);
assert_eq!(DebugLogType::from_str("fal_request").unwrap(), DebugLogType::FalRequest);
assert_eq!(DebugLogType::from_str("kinovi_request").unwrap(), DebugLogType::KinoviRequest);
assert_eq!(DebugLogType::from_str("fal_webhook").unwrap(), DebugLogType::FalWebhook);
}
#[test]
fn from_str_err() {
let result = DebugLogType::from_str("invalid");
assert!(result.is_err());
if let Err(EnumError::CouldNotConvertFromString(value)) = result {
assert_eq!(value, "invalid");
} else {
panic!("Expected EnumError::CouldNotConvertFromString");
}
}
#[test]
fn all_variants() {
let mut variants = DebugLogType::all_variants();
assert_eq!(variants.len(), 4);
assert_eq!(variants.pop_first(), Some(DebugLogType::HttpRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::KinoviRequest));
assert_eq!(variants.pop_first(), Some(DebugLogType::FalWebhook));
assert_eq!(variants.pop_first(), None);
}
}
mod mechanical_checks {
use super::*;
#[test]
fn variant_length() {
use strum::IntoEnumIterator;
assert_eq!(DebugLogType::all_variants().len(), DebugLogType::iter().len());
}
#[test]
fn round_trip() {
for variant in DebugLogType::all_variants() {
assert_eq!(variant, DebugLogType::from_str(variant.to_str()).unwrap());
assert_eq!(variant, DebugLogType::from_str(&format!("{}", variant)).unwrap());
assert_eq!(variant, DebugLogType::from_str(&format!("{:?}", variant)).unwrap());
}
}
#[test]
fn serialized_length_ok_for_database() {
for variant in DebugLogType::all_variants() {
let serialized = variant.to_str();
assert!(serialized.len() > 0, "variant {:?} is too short", variant);
assert!(serialized.len() <= MAX_LENGTH, "variant {:?} is too long via to_str()", variant);
}
for variant in DebugLogType::all_variants() {
let json = serde_json::to_string(&variant).unwrap().replace('"', "");
assert!(json.len() <= MAX_LENGTH, "variant {:?} is too long via JSON: {:?}", variant, json);
}
}
#[test]
fn serialized_names_must_only_contain_lowercase_alphanumeric_and_underscore() {
let valid_pattern = regex::Regex::new(r"^[a-z0-9_]+$").unwrap();
for variant in DebugLogType::all_variants() {
let to_str_value = variant.to_str();
assert!(valid_pattern.is_match(to_str_value),
"to_str() for {:?} contains invalid characters: {:?} (only a-z, 0-9, _ allowed)", variant, to_str_value);
}
}
}
}
@@ -0,0 +1 @@
pub mod debug_log_type;
@@ -4,6 +4,7 @@ pub mod batch_generations;
pub mod characters;
pub mod beta_keys;
pub mod comments;
pub mod debug_logs;
pub mod email_sender_jobs;
pub mod entity_stats;
pub mod featured_items;
@@ -30,6 +30,7 @@ pub(crate) enum TokenPrefix {
BrowserSessionLog,
Character,
Comment,
DebugLogEvent,
DownloadJob,
EmailSenderJob,
GoogleSignInAccount,
@@ -116,6 +117,7 @@ impl PrefixGenerator for TokenPrefix {
Self::BrowserSessionLog => "bsl_",
Self::Character => "character_",
Self::Comment => "comment_",
Self::DebugLogEvent => "dbg_",
Self::DownloadJob => "jdown_", // NB: Previously "JGUP:"
Self::EmailSenderJob => "email_job_",
Self::GoogleSignInAccount => "gsi_",
@@ -47,3 +47,6 @@ pub mod wallets;
pub mod zs_voice_dataset_samples;
pub mod zs_voice_datasets;
pub mod zs_voices;
// Non-unique tokens (used as indices, not primary keys)
pub mod non_unique;
@@ -0,0 +1,15 @@
use serde::Deserialize;
use serde::Serialize;
use utoipa::ToSchema;
use crate::prefixes::TokenPrefix;
/// A non-unique event token for the `debug_logs` table.
/// Multiple log rows can share the same event token.
#[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 DebugLogEventToken(pub String);
impl_string_token!(DebugLogEventToken);
impl_crockford_generator!(DebugLogEventToken, 32usize, TokenPrefix::DebugLogEvent, CrockfordLower);
@@ -0,0 +1 @@
pub mod debug_logs_event_token;
@@ -85,6 +85,8 @@ use crate::http_server::endpoints::media_files::upsert_upload::write_engine_asse
use crate::http_server::endpoints::media_files::upsert_upload::write_error::MediaFileWriteError;
use crate::http_server::endpoints::media_files::upsert_upload::write_scene_file::write_scene_file_media_file_handler::*;
use crate::http_server::endpoints::model_download::enqueue_gptsovits_model_download_handler::*;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::*;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::*;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::*;
@@ -519,6 +521,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
crate::http_server::endpoints::moderation::wallets::moderator_add_banked_balance_to_wallet_handler::moderator_add_banked_balance_to_wallet_handler,
crate::http_server::endpoints::moderation::wallets::moderator_create_wallet_for_user_handler::moderator_create_wallet_for_user_handler,
crate::http_server::endpoints::moderation::wallets::moderator_get_wallet_handler::moderator_get_wallet_handler,
crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler,
crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::moderation_get_job_by_token_handler,
crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler,
crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_handler::moderation_ban_user_handler,
crate::http_server::endpoints::moderation::user_sessions::moderator_list_user_session_impersonation_requests_for_user_handler::moderator_list_user_session_impersonation_requests_for_user_handler,
@@ -909,6 +913,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
ListAllImpersonationRequestsQueryParams,
ListAllImpersonationRequestsSuccessResponse,
ListImpersonationRequestsPathInfo,
GetJobByTokenPathInfo,
GetJobByTokenSuccessResponse,
ModerationJobResponse,
ListDebugLogsPathInfo,
ListDebugLogsQueryParams,
ListDebugLogsSuccessResponse,
DebugLogEntry,
ListStaffAuditLogsQueryParams,
ListStaffAuditLogsSuccessResponse,
ListImpersonationRequestsQueryParams,
@@ -238,6 +238,7 @@ pub async fn flux_2_lora_edit_image_angle_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -239,6 +239,7 @@ pub async fn qwen_edit_2511_edit_image_angle_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -265,6 +265,7 @@ pub async fn gemini_25_flash_edit_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -297,6 +297,7 @@ pub async fn gpt_image_1_edit_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -270,6 +270,7 @@ pub async fn qwen_edit_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -313,6 +313,7 @@ pub async fn bytedance_seedream_5_lite_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -334,6 +334,7 @@ pub async fn bytedance_seedream_v4_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -331,6 +331,7 @@ pub async fn bytedance_seedream_v4p5_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -382,6 +382,7 @@ pub async fn gpt_image_1p5_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -343,6 +343,7 @@ pub async fn nano_banana_2_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -333,6 +333,7 @@ pub async fn nano_banana_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -354,6 +354,7 @@ pub async fn nano_banana_pro_multi_function_image_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -215,6 +215,7 @@ pub async fn generate_flux_pro_11_text_to_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -223,6 +223,7 @@ pub async fn generate_flux_pro_11_ultra_text_to_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -229,6 +229,7 @@ pub async fn generate_gpt_image_1_text_to_image_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -266,6 +266,7 @@ pub async fn generate_kling_1_6_pro_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -251,6 +251,7 @@ pub async fn generate_kling_2_1_master_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -266,6 +266,7 @@ pub async fn generate_kling_2_1_pro_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -285,6 +285,7 @@ pub async fn generate_seedance_1_0_lite_image_to_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -261,6 +261,7 @@ pub async fn generate_seedance_1_0_pro_image_to_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -257,6 +257,7 @@ pub async fn generate_veo_2_image_to_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -263,6 +263,7 @@ pub async fn generate_veo_3_fast_image_to_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -265,6 +265,7 @@ pub async fn generate_veo_3_image_to_video_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -340,6 +340,7 @@ pub async fn kling_2p5_turbo_pro_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -337,6 +337,7 @@ pub async fn kling_2p6_pro_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -342,6 +342,7 @@ pub async fn kling_3p0_pro_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -342,6 +342,7 @@ pub async fn kling_3p0_standard_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -329,6 +329,7 @@ pub async fn seedance_1p5_pro_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -422,6 +422,7 @@ pub async fn seedance_2p0_multi_function_video_gen_handler(
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: None,
mysql_executor: &mut *transaction,
phantom: Default::default(),
}
@@ -358,6 +358,7 @@ pub async fn sora_2_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -360,6 +360,7 @@ pub async fn sora_2_pro_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -424,6 +424,7 @@ pub async fn veo_3p1_fast_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -424,6 +424,7 @@ pub async fn veo_3p1_multi_function_video_gen_handler(
starting_job_status_override: None,
maybe_frontend_failure_category: None,
maybe_failure_reason: None,
maybe_debug_log_event_token: None,
phantom: Default::default(),
}).await;
@@ -0,0 +1 @@
pub mod moderation_list_debug_logs_for_token_handler;
@@ -0,0 +1,113 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use chrono::{DateTime, Utc};
use log::warn;
use serde_derive::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use mysql_queries::queries::debug_logs::list_debug_logs_for_token::{
list_debug_logs_for_token, ListDebugLogsForTokenArgs,
};
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
// ── Path params ──
#[derive(Deserialize, ToSchema)]
pub struct ListDebugLogsPathInfo {
pub token: DebugLogEventToken,
}
// ── Query params ──
#[derive(Deserialize, IntoParams, ToSchema)]
pub struct ListDebugLogsQueryParams {
pub limit: Option<u32>,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct ListDebugLogsSuccessResponse {
pub success: bool,
pub debug_logs: Vec<DebugLogEntry>,
}
#[derive(Serialize, ToSchema)]
pub struct DebugLogEntry {
pub id: u64,
pub event_token: DebugLogEventToken,
pub debug_log_type: DebugLogType,
pub maybe_creator_user_token: Option<UserToken>,
pub message: String,
pub created_at: DateTime<Utc>,
}
// ── Handler ──
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/debug_logs/list/{token}",
params(
("token" = String, Path, description = "Debug log event token"),
ListDebugLogsQueryParams,
),
responses(
(status = 200, description = "Success", body = ListDebugLogsSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_list_debug_logs_for_token_handler(
http_request: HttpRequest,
path: web::Path<ListDebugLogsPathInfo>,
query: web::Query<ListDebugLogsQueryParams>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<ListDebugLogsSuccessResponse>, AdvancedCommonWebError> {
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let rows = list_debug_logs_for_token(ListDebugLogsForTokenArgs {
event_token: &path.token,
limit: query.limit,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error listing debug logs for token {}: {:?}", path.token, err);
AdvancedCommonWebError::from_error(err)
})?;
let debug_logs: Vec<DebugLogEntry> = rows.into_iter().map(|row| {
DebugLogEntry {
id: row.id,
event_token: row.event_token,
debug_log_type: row.debug_log_type,
maybe_creator_user_token: row.maybe_creator_user_token,
message: row.message,
created_at: row.created_at,
}
}).collect();
Ok(Json(ListDebugLogsSuccessResponse {
success: true,
debug_logs,
}))
}
@@ -1,4 +1,5 @@
pub mod get_tts_inference_queue_count;
pub mod get_w2l_inference_queue_count;
pub mod kill_tts_inference_jobs;
pub mod moderation_get_job_by_token_handler;
pub mod user;
@@ -0,0 +1,153 @@
use std::sync::Arc;
use actix_web::web::Json;
use actix_web::{web, HttpRequest};
use chrono::{DateTime, Utc};
use log::warn;
use serde_derive::Serialize;
use utoipa::ToSchema;
use mysql_queries::queries::generic_inference::moderation::get_job_by_token_for_moderation::{
get_job_by_token_for_moderation, GetJobByTokenForModerationArgs,
};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::web_utils::user_session::require_moderator::{
require_moderator, UseDatabase,
};
use crate::state::server_state::ServerState;
// ── Path params ──
#[derive(serde_derive::Deserialize, ToSchema)]
pub struct GetJobByTokenPathInfo {
pub token: InferenceJobToken,
}
// ── Response ──
#[derive(Serialize, ToSchema)]
pub struct GetJobByTokenSuccessResponse {
pub success: bool,
pub job: ModerationJobResponse,
}
#[derive(Serialize, ToSchema)]
pub struct ModerationJobResponse {
pub token: InferenceJobToken,
pub uuid_idempotency_token: String,
pub maybe_job_type: Option<String>,
pub maybe_product_category: Option<String>,
pub inference_category: String,
pub maybe_external_third_party: Option<String>,
pub maybe_external_third_party_id: Option<String>,
pub maybe_model_type: Option<String>,
pub maybe_model_token: Option<String>,
pub maybe_prompt_token: Option<PromptToken>,
pub maybe_inference_args: Option<String>,
pub maybe_download_url: Option<String>,
pub on_success_result_entity_type: Option<String>,
pub on_success_result_entity_token: Option<String>,
pub on_success_result_batch_token: Option<String>,
pub maybe_creator_user_token: Option<UserToken>,
pub maybe_creator_anonymous_visitor_token: Option<String>,
pub creator_ip_address: String,
pub creator_set_visibility: String,
pub maybe_wallet_ledger_entry_token: Option<String>,
pub maybe_debug_log_event_token: Option<DebugLogEventToken>,
pub status: String,
pub attempt_count: u16,
pub failure_reason: Option<String>,
pub internal_debugging_failure_reason: Option<String>,
pub frontend_failure_category: Option<String>,
pub maybe_routing_tag: Option<String>,
pub assigned_worker: Option<String>,
pub assigned_cluster: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
// ── Handler ──
#[utoipa::path(
get,
tag = "Moderation",
path = "/v1/moderation/job/{token}",
params(
("token" = String, Path, description = "Inference job token"),
),
responses(
(status = 200, description = "Success", body = GetJobByTokenSuccessResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Not found"),
(status = 500, description = "Server error"),
),
)]
pub async fn moderation_get_job_by_token_handler(
http_request: HttpRequest,
path: web::Path<GetJobByTokenPathInfo>,
server_state: web::Data<Arc<ServerState>>,
) -> Result<Json<GetJobByTokenSuccessResponse>, AdvancedCommonWebError> {
let _user_session = require_moderator(
&http_request,
&server_state,
UseDatabase::GrabNewConnection,
).await.map_err(|err| {
warn!("Moderator check failed: {:?}", err);
AdvancedCommonWebError::NotAuthorized
})?;
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
let job = get_job_by_token_for_moderation(GetJobByTokenForModerationArgs {
job_token: &path.token,
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error fetching job by token {}: {:?}", path.token, err);
AdvancedCommonWebError::from_error(err)
})?;
let job = job.ok_or(AdvancedCommonWebError::NotFound)?;
Ok(Json(GetJobByTokenSuccessResponse {
success: true,
job: ModerationJobResponse {
token: job.token,
uuid_idempotency_token: job.uuid_idempotency_token,
maybe_job_type: job.maybe_job_type,
maybe_product_category: job.maybe_product_category,
inference_category: job.inference_category,
maybe_external_third_party: job.maybe_external_third_party,
maybe_external_third_party_id: job.maybe_external_third_party_id,
maybe_model_type: job.maybe_model_type,
maybe_model_token: job.maybe_model_token,
maybe_prompt_token: job.maybe_prompt_token,
maybe_inference_args: job.maybe_inference_args,
maybe_download_url: job.maybe_download_url,
on_success_result_entity_type: job.on_success_result_entity_type,
on_success_result_entity_token: job.on_success_result_entity_token,
on_success_result_batch_token: job.on_success_result_batch_token,
maybe_creator_user_token: job.maybe_creator_user_token,
maybe_creator_anonymous_visitor_token: job.maybe_creator_anonymous_visitor_token,
creator_ip_address: job.creator_ip_address,
creator_set_visibility: job.creator_set_visibility,
maybe_wallet_ledger_entry_token: job.maybe_wallet_ledger_entry_token,
maybe_debug_log_event_token: job.maybe_debug_log_event_token,
status: job.status,
attempt_count: job.attempt_count,
failure_reason: job.failure_reason,
internal_debugging_failure_reason: job.internal_debugging_failure_reason,
frontend_failure_category: job.frontend_failure_category,
maybe_routing_tag: job.maybe_routing_tag,
assigned_worker: job.assigned_worker,
assigned_cluster: job.assigned_cluster,
created_at: job.created_at,
updated_at: job.updated_at,
},
}))
}
@@ -1,4 +1,5 @@
pub mod alerts;
pub mod debug_logs;
pub mod info;
pub mod ip_bans;
pub mod jobs;
@@ -12,6 +12,7 @@ use artcraft_api_defs::omni_gen::generate_response::omni_gen_image_generate_resp
use artcraft_router::client::router_client::RouterClient;
use artcraft_router::client::router_fal_client::RouterFalClient;
use artcraft_router::generate::generate_image::generate_image_response::GenerateImageResponse;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
@@ -19,6 +20,7 @@ use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation_provider::GenerationProvider;
use enums::common::visibility::Visibility;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue::FalCategory;
use mysql_queries::queries::generic_inference::fal::insert_generic_inference_job_for_fal_queue_with_apriori_job_token::{
insert_generic_inference_job_for_fal_queue_with_apriori_job_token,
@@ -31,6 +33,7 @@ use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_it
use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPromptArgs};
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::billing::wallets::attempt_wallet_deduction::attempt_wallet_deduction_else_common_web_error;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
@@ -63,6 +66,8 @@ pub async fn omni_gen_image_generate_handler(
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let debug_log_event_token = DebugLogEventToken::generate();
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
@@ -154,6 +159,19 @@ pub async fn omni_gen_image_generate_handler(
).await?;
}
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert HTTP request debug log: {:?}", err);
}
// ==================== EXECUTE GENERATION ==================== //
let fal_client = RouterFalClient::new(
@@ -236,7 +254,7 @@ pub async fn omni_gen_image_generate_handler(
for media_token in ref_tokens {
context_items.push(PromptContextItem {
media_token: media_token.clone(),
context_semantic_type: PromptContextSemanticType::Imgsrc,
context_semantic_type: PromptContextSemanticType::Imgref,
});
}
}
@@ -266,6 +284,7 @@ pub async fn omni_gen_image_generate_handler(
maybe_avt_token: maybe_avt_token.as_ref(),
creator_ip_address: &ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: Some(&debug_log_event_token),
mysql_executor: &mut *transaction,
starting_job_status_override: None,
maybe_frontend_failure_category: None,
@@ -34,6 +34,7 @@ pub async fn insert_fal_job(args: InsertFalJobArgs<'_, '_>) -> Result<InferenceJ
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
starting_job_status_override: None,
@@ -64,6 +64,7 @@ pub async fn insert_seedance2pro_jobs(args: InsertSeedance2proJobsArgs<'_, '_>)
maybe_avt_token: shared.maybe_avt_token,
creator_ip_address: shared.ip_address,
creator_set_visibility: Visibility::Public,
maybe_debug_log_event_token: shared.maybe_debug_log_event_token,
mysql_executor: &mut **shared.transaction,
maybe_inference_args: None,
phantom: Default::default(),
@@ -1,5 +1,6 @@
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use tokens::tokens::prompts::PromptToken;
use tokens::tokens::users::UserToken;
@@ -9,6 +10,7 @@ pub struct SharedJobArgs<'a, 'tx> {
pub user_token: &'a UserToken,
pub maybe_avt_token: Option<&'a AnonymousVisitorTrackingToken>,
pub maybe_prompt_token: Option<&'a PromptToken>,
pub maybe_debug_log_event_token: Option<&'a DebugLogEventToken>,
pub ip_address: &'a str,
pub transaction: &'a mut sqlx::Transaction<'tx, sqlx::MySql>,
}
@@ -10,6 +10,7 @@ use url::Url;
use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_video_cost_and_generate_request::OmniGenVideoCostAndGenerateRequest;
use artcraft_api_defs::omni_gen::generate_response::omni_gen_video_generate_response::OmniGenVideoGenerateResponse;
use artcraft_router::generate::generate_video::generate_video_response::GenerateVideoResponse;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::prompt_context_items::prompt_context_semantic_type::PromptContextSemanticType;
use enums::by_table::prompts::prompt_type::PromptType;
use enums::common::generation::common_generation_mode::CommonGenerationMode;
@@ -17,6 +18,7 @@ use enums::common::generation::common_model_type::CommonModelType;
use enums::common::generation::common_video_model::CommonVideoModel;
use enums::common::generation_provider::GenerationProvider;
use http_server_common::request::get_request_ip::get_request_ip;
use mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_idempotency_token;
use mysql_queries::queries::prompt_context_items::insert_batch_prompt_context_items::{
insert_batch_prompt_context_items, InsertBatchArgs, PromptContextItem,
@@ -25,6 +27,7 @@ use mysql_queries::queries::prompts::insert_prompt::{insert_prompt, InsertPrompt
use tokens::tokens::characters::CharacterToken;
use tokens::tokens::generic_inference_jobs::InferenceJobToken;
use tokens::tokens::media_files::MediaFileToken;
use tokens::tokens::non_unique::debug_logs_event_token::DebugLogEventToken;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::http_server::endpoints::generate::common::payments_error_test::payments_error_test;
@@ -64,6 +67,8 @@ pub async fn omni_gen_video_generate_handler(
payments_error_test(&request.prompt.as_deref().unwrap_or(""))?;
let debug_log_event_token = DebugLogEventToken::generate();
let maybe_prompt_model_type: Option<CommonModelType> = request.model
.as_ref()
.map(|m| m.to_common_model_type());
@@ -207,6 +212,19 @@ pub async fn omni_gen_video_generate_handler(
}).await?
};
// ==================== DEBUG LOG: HTTP REQUEST ==================== //
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(&debug_log_event_token),
maybe_creator_user_token: Some(user_token),
debug_log_type: DebugLogType::HttpRequest,
message: &serde_json::to_string(&*request).unwrap_or_default(),
mysql_executor: &mut *mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert HTTP request debug log: {:?}", err);
}
// ==================== WRITE RESULT ==================== //
let ip_address = get_request_ip(&http_request);
@@ -304,6 +322,7 @@ pub async fn omni_gen_video_generate_handler(
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
ip_address: &ip_address,
transaction: &mut transaction,
},
@@ -324,6 +343,7 @@ pub async fn omni_gen_video_generate_handler(
user_token,
maybe_avt_token: maybe_avt_token.as_ref(),
maybe_prompt_token: prompt_token.as_ref(),
maybe_debug_log_event_token: Some(&debug_log_event_token),
ip_address: &ip_address,
transaction: &mut transaction,
},
@@ -53,17 +53,22 @@ pub async fn fal_webhook_handler(
// Step 3: Hydrate the webhook contents.
let hydrated_contents = hydrate_webhook_contents(&webhook_payload);
// Step 3.5: Acquire a MySQL connection for the handlers.
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
// Step 4 & 5: Branch on the inner payload type.
let result = match hydrated_contents {
HydratedWebhookContents::Success(success_data) => {
handle_successful_fal_webhook(&server_state, request_id, &success_data, &server_state.pager).await
handle_successful_fal_webhook(&server_state, &mut mysql_connection, request_id, &success_data, &raw_body, &server_state.pager).await
}
HydratedWebhookContents::Error(error_data) => {
handle_failed_fal_webhook(
&server_state,
&mut mysql_connection,
request_id,
&error_data,
webhook_payload.error.as_deref(),
&raw_body,
).await
}
HydratedWebhookContents::PayloadError(payload_error_data) => {
@@ -1,22 +1,28 @@
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
use actix_web::web::Json;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use enums::by_table::generic_inference_jobs::frontend_failure_category::FrontendFailureCategory;
use fal_client::webhook_api::raw::webhook_error_type::WebhookErrorType;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::ErrorData;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
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 mysql_queries::queries::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id_from_connection;
use mysql_queries::queries::generic_inference::job::mark_job_failed_by_token::{mark_job_failed_by_token_from_connection, MarkJobFailedByTokenFromConnectionArgs};
use sqlx::pool::PoolConnection;
use sqlx::MySql;
/// 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,
mysql_connection: &mut PoolConnection<MySql>,
request_id: &str,
error_data: &ErrorData,
maybe_top_level_error: Option<&str>,
raw_body: &str,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
info!(
@@ -28,7 +34,7 @@ pub async fn handle_failed_fal_webhook(
);
// Look up the job record.
let job = match get_inference_job_by_fal_id(request_id, &server_state.mysql_pool).await {
let job = match get_inference_job_by_fal_id_from_connection(request_id, mysql_connection).await {
Ok(Some(record)) => record,
Ok(None) => {
warn!("Could not find job record by fal request_id: {}", request_id);
@@ -40,6 +46,20 @@ pub async fn handle_failed_fal_webhook(
}
};
// Insert debug log for the webhook payload.
if let Some(debug_log_event_token) = &job.maybe_debug_log_event_token {
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::FalWebhook,
message: raw_body,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert Fal webhook debug log: {:?}", err);
}
}
// 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()
@@ -67,8 +87,8 @@ pub async fn handle_failed_fal_webhook(
public_failure_reason,
);
if let Err(err) = mark_job_failed_by_token(MarkJobFailedByTokenArgs {
pool: &server_state.mysql_pool,
if let Err(err) = mark_job_failed_by_token_from_connection(MarkJobFailedByTokenFromConnectionArgs {
mysql_connection,
job_token: &job.job_token,
maybe_public_failure_reason: Some(&public_failure_reason),
internal_debugging_failure_reason: &internal_failure_reason,
@@ -1,10 +1,14 @@
use actix_web::web::Json;
use enums::by_table::debug_logs::debug_log_type::DebugLogType;
use fal_client::webhook_api::hydrated::hydrated_webhook_contents::WebhookSuccessData;
use http_server_common::response::response_success_helpers::SimpleGenericJsonSuccess;
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::debug_logs::insert_debug_log::{insert_debug_log, InsertDebugLogArgs};
use mysql_queries::queries::generic_inference::fal::get_inference_job_by_fal_id::get_inference_job_by_fal_id_from_connection;
use mysql_queries::queries::generic_inference::fal::mark_fal_generic_inference_job_successfully_done::{mark_fal_generic_inference_job_successfully_done, MarkJobArgs};
use pager::client::pager::Pager;
use sqlx::pool::PoolConnection;
use sqlx::MySql;
use crate::http_server::common_responses::advanced_common_web_error::AdvancedCommonWebError;
use crate::state::server_state::ServerState;
@@ -16,14 +20,16 @@ use super::process_video_payload::process_video_payload;
pub async fn handle_successful_fal_webhook(
server_state: &ServerState,
mysql_connection: &mut PoolConnection<MySql>,
request_id: &str,
success_data: &WebhookSuccessData,
raw_body: &str,
pager: &Pager,
) -> Result<Json<SimpleGenericJsonSuccess>, AdvancedCommonWebError> {
let db_result = get_inference_job_by_fal_id(
let db_result = get_inference_job_by_fal_id_from_connection(
request_id,
&server_state.mysql_pool,
mysql_connection,
).await;
let job = match db_result {
@@ -40,6 +46,20 @@ pub async fn handle_successful_fal_webhook(
info!("Fal webhook job record for request_id {}: {:?}", request_id, job);
// Insert debug log for the webhook payload.
if let Some(debug_log_event_token) = &job.maybe_debug_log_event_token {
if let Err(err) = insert_debug_log(InsertDebugLogArgs {
apriori_debug_log_event_token: Some(debug_log_event_token),
maybe_creator_user_token: job.maybe_creator_user_token.as_ref(),
debug_log_type: DebugLogType::FalWebhook,
message: raw_body,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
}).await {
warn!("Failed to insert Fal webhook debug log: {:?}", err);
}
}
let mut maybe_media_token = None;
let mut maybe_batch_token = None;
@@ -92,7 +112,7 @@ pub async fn handle_successful_fal_webhook(
job_token: &job.job_token,
media_file_token: &media_token,
maybe_batch_token: maybe_batch_token.as_ref(),
mysql_executor: &server_state.mysql_pool,
mysql_executor: &mut **mysql_connection,
phantom: Default::default(),
}).await.map_err(|err| {
warn!("Error marking job as successfully done for request_id {}: {:?}", request_id, err);
@@ -36,6 +36,8 @@ use crate::http_server::endpoints::moderation::user::moderator_user_lookup_by_st
use crate::http_server::endpoints::moderation::user::moderator_user_lookup_handler::moderator_user_lookup_handler;
use crate::http_server::endpoints::moderation::alerts::moderation_send_alert_handler::moderation_send_alert_handler;
use crate::http_server::endpoints::moderation::staff_audit_logs::moderator_list_staff_audit_logs_handler::moderator_list_staff_audit_logs_handler;
use crate::http_server::endpoints::moderation::debug_logs::moderation_list_debug_logs_for_token_handler::moderation_list_debug_logs_for_token_handler;
use crate::http_server::endpoints::moderation::jobs::moderation_get_job_by_token_handler::moderation_get_job_by_token_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_edit_user_feature_flags_handler::moderator_edit_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_all_available_user_feature_flags_handler::moderator_list_all_available_user_feature_flags_handler;
use crate::http_server::endpoints::moderation::user_feature_flags::moderator_list_user_feature_flags_handler::moderator_list_user_feature_flags_handler;
@@ -61,6 +63,12 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::scope("/debug_logs")
.service(web::resource("/list/{token}")
.route(web::get().to(moderation_list_debug_logs_for_token_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/user_feature_flags/list")
.route(web::get().to(moderator_list_all_available_user_feature_flags_handler))
.route(web::head().to(|| HttpResponse::Ok()))
@@ -88,6 +96,10 @@ pub fn add_moderator_routes<T, B> (app: App<T>) -> App<T>
.route(web::head().to(|| HttpResponse::Ok()))
)
)
.service(web::resource("/job/{token}")
.route(web::get().to(moderation_get_job_by_token_handler))
.route(web::head().to(|| HttpResponse::Ok()))
)
.service(web::scope("/jobs")
.service(web::resource("/user/{user_token}/list")
.route(web::get().to(list_user_jobs_handler))
+85 -168
View File
File diff suppressed because it is too large Load Diff