//! Enhance orchestration (#192): submit, poll, approve, idle `/free`. //! //! The job row (`enhance_jobs`) is the only state that matters. ComfyUI is //! allowed to be down (a normal state) and to forget everything when it //! restarts (unapproved results are cleared), so every transition tolerates //! both. use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use log::{info, warn}; use serde::{Deserialize, Serialize}; use super::comfy::{ApproveError, ComfyClient, ComfyError, HistoryEntry, Staged, SubmitError}; use super::config::{EnhanceConfig, Loaded, PromptMode}; use super::lora_catalog::{self, CatalogEntry}; use super::path_map::PathMap; use super::template::{SlotPick, Template, random_seed}; use crate::database::enhance_dao::{ EnhanceJobDao, EnhanceJobRow, JobUpdate, RecentPrompt, STATUS_APPROVED, STATUS_EXPIRED, STATUS_FAILED, STATUS_QUEUED, STATUS_READY, STATUS_RUNNING, STATUS_UNAVAILABLE, }; /// Short timeout for the `available` probe, so the templates call stays snappy /// when ComfyUI's host is off. const PROBE_TIMEOUT: Duration = Duration::from_secs(2); /// How often the idle check looks at ComfyUI. const IDLE_CHECK_EVERY: Duration = Duration::from_secs(30); /// Finished jobs are kept this long. const JOB_RETENTION_SECS: i64 = 14 * 24 * 3600; /// Jobs shown per photo. pub const JOBS_PER_PHOTO: i64 = 20; /// The jobs manager lists finished-but-unhandled jobs (ready, failed, /// expired) for this long; active ones always. pub const OPEN_JOBS_WINDOW_SECS: i64 = 3 * 24 * 3600; /// Most jobs the jobs manager lists. pub const OPEN_JOBS_LIMIT: i64 = 100; pub const MSG_UNAVAILABLE: &str = "ComfyUI isn't running"; pub const MSG_EXPIRED: &str = "This result expired (ComfyUI was restarted). Run it again."; pub const MSG_DISABLED: &str = "Enhance isn't configured"; /// A library file a request names. #[derive(Debug, Clone)] pub struct SourceFile { pub library_id: i32, pub rel_path: String, pub abs: PathBuf, pub hash: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum EnhanceError { /// No `url` configured. Disabled, /// ComfyUI isn't reachable. Unavailable, UnknownTemplate(String), BadRequest(String), /// ComfyUI refused the workflow. Rejected(String), /// The result is gone (ComfyUI restarted). Expired, NotFound, /// Approved, but ImageApi can't place the file in a library. Unplaceable(String), Upstream(String), Internal(String), } impl EnhanceError { pub fn message(&self) -> String { match self { EnhanceError::Disabled => MSG_DISABLED.into(), EnhanceError::Unavailable => MSG_UNAVAILABLE.into(), EnhanceError::UnknownTemplate(t) => format!("unknown template {t:?}"), EnhanceError::BadRequest(m) | EnhanceError::Rejected(m) | EnhanceError::Unplaceable(m) | EnhanceError::Upstream(m) | EnhanceError::Internal(m) => m.clone(), EnhanceError::Expired => MSG_EXPIRED.into(), EnhanceError::NotFound => "not found".into(), } } } fn comfy_err(e: ComfyError) -> EnhanceError { match e { ComfyError::Unreachable(_) => EnhanceError::Unavailable, other => EnhanceError::Upstream(other.to_string()), } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SavedEntry { pub n: usize, pub comfy_path: String, pub library_id: Option, pub rel_path: Option, } /// What the idle check decided. #[derive(Debug, Default)] pub struct IdleState { initialised: bool, marker: Option, seen_at_ms: i64, freed: bool, } impl IdleState { /// One observation of a reachable ComfyUI. `marker` identifies the /// newest history entry (its last event time): when it changes, or the /// queue isn't empty, or one of our jobs is active, ComfyUI is in use. /// Returns true once per quiet spell when it has been idle for /// `idle_ms` and `/free` should be called. /// /// The marker's *value* is ComfyUI's clock; only changes in it matter, /// and "how long ago" is measured on ImageApi's clock, so clock skew /// between the hosts can't cause an early unload. After ImageApi /// (re)starts the first observation counts as activity. pub fn observe( &mut self, now_ms: i64, queue_empty: bool, active_jobs: usize, marker: Option, idle_ms: i64, ) -> bool { if !self.initialised || marker != self.marker || !queue_empty || active_jobs > 0 { self.initialised = true; self.marker = marker; self.seen_at_ms = now_ms; self.freed = false; return false; } if !self.freed && now_ms - self.seen_at_ms >= idle_ms { self.freed = true; return true; } false } /// ComfyUI went away: the next observation starts a fresh baseline. pub fn reset(&mut self) { *self = IdleState::default(); } /// `/free` failed; try again on the next check. pub fn free_failed(&mut self) { self.freed = false; } } pub struct EnhanceService { pub config: EnhanceConfig, templates: Vec