1use std::fs;
16use std::path::{Component, Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use crate::chain::ChainRequest;
21use crate::error::{MoldError, Result};
22
23pub const CHAIN_JOB_SCHEMA: &str = "mold.chainjob.v1";
25
26pub const MANIFEST_FILE: &str = "manifest.toml";
29pub const STAGES_DIR: &str = "stages";
30pub const FINAL_DIR: &str = "final";
31pub const SEGMENT_FILE: &str = "segment.mp4";
32pub const TAIL_DIR: &str = "tail";
33pub const BOUNDARY_IN_DIR: &str = "boundary-in";
34pub const BOUNDARY_OUT_DIR: &str = "boundary-out";
35pub const AUDIO_FILE: &str = "audio.pcm";
36pub const PREVIEW_FILE: &str = "preview.jpg";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
42#[serde(rename_all = "snake_case")]
43pub enum ChainJobState {
44 Queued,
45 Running,
46 Interrupted,
47 Failed,
48 Completed,
49 Cancelled,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
58#[serde(rename_all = "snake_case")]
59pub enum ChainExecutionPhase {
60 Queued,
61 Running,
62 Finalizing,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
67#[serde(rename_all = "snake_case")]
68pub enum StageState {
69 Pending,
70 Running,
71 Completed,
72 Failed,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
77#[serde(rename_all = "snake_case")]
78pub enum RetakeMode {
79 Cascade,
80 Splice,
81}
82
83impl ChainJobState {
84 pub fn as_str(self) -> &'static str {
85 match self {
86 ChainJobState::Queued => "queued",
87 ChainJobState::Running => "running",
88 ChainJobState::Interrupted => "interrupted",
89 ChainJobState::Failed => "failed",
90 ChainJobState::Completed => "completed",
91 ChainJobState::Cancelled => "cancelled",
92 }
93 }
94
95 pub fn is_terminal(self) -> bool {
99 matches!(self, ChainJobState::Completed)
100 }
101}
102
103pub fn settled(state: ChainJobState) -> bool {
111 matches!(
112 state,
113 ChainJobState::Completed | ChainJobState::Failed | ChainJobState::Cancelled
114 )
115}
116
117impl std::str::FromStr for ChainJobState {
118 type Err = MoldError;
119
120 fn from_str(s: &str) -> std::result::Result<Self, MoldError> {
121 match s {
122 "queued" => Ok(ChainJobState::Queued),
123 "running" => Ok(ChainJobState::Running),
124 "interrupted" => Ok(ChainJobState::Interrupted),
125 "failed" => Ok(ChainJobState::Failed),
126 "completed" => Ok(ChainJobState::Completed),
127 "cancelled" => Ok(ChainJobState::Cancelled),
128 other => Err(MoldError::Validation(format!(
129 "unknown chain job state '{other}'"
130 ))),
131 }
132 }
133}
134
135impl StageState {
136 pub fn as_str(self) -> &'static str {
137 match self {
138 StageState::Pending => "pending",
139 StageState::Running => "running",
140 StageState::Completed => "completed",
141 StageState::Failed => "failed",
142 }
143 }
144}
145
146impl std::str::FromStr for StageState {
147 type Err = MoldError;
148
149 fn from_str(s: &str) -> std::result::Result<Self, MoldError> {
150 match s {
151 "pending" => Ok(StageState::Pending),
152 "running" => Ok(StageState::Running),
153 "completed" => Ok(StageState::Completed),
154 "failed" => Ok(StageState::Failed),
155 other => Err(MoldError::Validation(format!(
156 "unknown chain job stage state '{other}'"
157 ))),
158 }
159 }
160}
161
162impl RetakeMode {
163 pub fn as_str(self) -> &'static str {
164 match self {
165 RetakeMode::Cascade => "cascade",
166 RetakeMode::Splice => "splice",
167 }
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct ChainJobManifest {
177 pub schema: String,
179 pub job_id: String,
180 pub created_at_unix_ms: u64,
181 #[serde(default)]
183 pub ephemeral: bool,
184 pub request_json: String,
186 #[serde(default)]
190 pub frozen_model: Option<FrozenChainModel>,
191 #[serde(default)]
192 pub stage_status: Vec<StageStatus>,
193 #[serde(default)]
198 pub retakes: Vec<RetakeAmendment>,
199 #[serde(default)]
201 pub finalizes: Vec<FinalizeRecord>,
202 #[serde(default)]
211 pub needs_finalize: Option<bool>,
212 #[serde(default)]
215 pub amends: Vec<AmendRecord>,
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct FrozenChainModel {
220 #[serde(default)]
224 pub runtime_model_id: String,
225 pub config: crate::ModelConfig,
226 pub model_fingerprint: String,
227}
228
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct StageStatus {
231 pub idx: u32,
232 pub state: StageState,
233 #[serde(with = "u64_as_string")]
236 pub seed: u64,
237 pub frames_emitted: Option<u32>,
238 pub generation_time_ms: Option<u64>,
239 pub segment: Option<String>,
241 pub tail_frames: Option<u32>,
242 pub audio: Option<String>,
244 pub error: Option<String>,
245 #[serde(default)]
251 pub raw_segment: bool,
252}
253
254#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
255pub struct RetakeAmendment {
256 pub stage_idx: u32,
257 pub mode: RetakeMode,
258 #[serde(with = "u64_as_string")]
259 pub old_seed: u64,
260 #[serde(with = "u64_as_string")]
261 pub new_seed: u64,
262 pub old_prompt: Option<String>,
264 pub new_prompt: Option<String>,
265 pub at_unix_ms: u64,
266}
267
268#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
274pub struct AmendRecord {
275 pub at_unix_ms: u64,
276 pub previous_request_json: String,
277 pub preserved_stages: u32,
278}
279
280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)]
281pub struct FinalizeRecord {
282 pub output: String,
284 pub at_unix_ms: u64,
285 #[serde(with = "u64_vec_as_strings")]
288 pub stage_seeds: Vec<u64>,
289}
290
291impl ChainJobManifest {
292 pub fn new(job_id: String, created_at_unix_ms: u64, request: &ChainRequest) -> Result<Self> {
296 let request_json = serde_json::to_string(request).map_err(|e| {
297 MoldError::Other(anyhow::anyhow!(
298 "chain job request JSON serialise failed: {e}"
299 ))
300 })?;
301 let base_seed = request.seed.unwrap_or(0);
302 let stage_status = request
303 .stages
304 .iter()
305 .enumerate()
306 .map(|(idx, stage)| StageStatus {
307 idx: idx as u32,
308 state: StageState::Pending,
309 seed: effective_stage_seed(base_seed, stage.seed_offset),
310 frames_emitted: None,
311 generation_time_ms: None,
312 segment: None,
313 tail_frames: None,
314 audio: None,
315 error: None,
316 raw_segment: false,
317 })
318 .collect();
319
320 Ok(Self {
321 schema: CHAIN_JOB_SCHEMA.into(),
322 job_id,
323 created_at_unix_ms,
324 ephemeral: false,
325 request_json,
326 frozen_model: None,
327 stage_status,
328 retakes: vec![],
329 finalizes: vec![],
330 needs_finalize: Some(true),
331 amends: vec![],
332 })
333 }
334
335 pub fn current_revision_is_finalized(&self, indexed_state: ChainJobState) -> bool {
338 if self.finalizes.is_empty()
339 || self
340 .stage_status
341 .iter()
342 .any(|stage| stage.state != StageState::Completed)
343 {
344 return false;
345 }
346 match self.needs_finalize {
347 Some(needs_finalize) => !needs_finalize,
348 None => indexed_state == ChainJobState::Completed,
353 }
354 }
355
356 pub fn request(&self) -> Result<ChainRequest> {
360 serde_json::from_str(&self.request_json)
361 .map_err(|e| MoldError::Validation(format!("chain job request JSON parse failed: {e}")))
362 }
363
364 pub fn from_toml(s: &str) -> Result<Self> {
367 #[derive(Deserialize)]
368 struct SchemaPeek {
369 schema: Option<String>,
370 }
371
372 let peek: SchemaPeek = toml::from_str(s).map_err(|e| {
373 MoldError::Validation(format!("chain job manifest TOML parse failed: {e}"))
374 })?;
375 let schema = peek.schema.as_deref().unwrap_or("<missing>");
376 if schema != CHAIN_JOB_SCHEMA {
377 return Err(MoldError::Validation(format!(
378 "chain job manifest schema '{schema}' is not supported by this mold version \
379 (supported: '{CHAIN_JOB_SCHEMA}')"
380 )));
381 }
382
383 let manifest: Self = toml::from_str(s).map_err(|e| {
384 MoldError::Validation(format!("chain job manifest TOML parse failed: {e}"))
385 })?;
386 manifest.validate_artifact_paths()?;
387 Ok(manifest)
388 }
389
390 pub fn to_toml(&self) -> Result<String> {
391 toml::to_string(self).map_err(|e| {
392 MoldError::Other(anyhow::anyhow!(
393 "chain job manifest TOML serialise failed: {e}"
394 ))
395 })
396 }
397
398 pub fn write_atomic(&self, job_dir: &Path) -> Result<()> {
402 let manifest_path = job_dir.join(MANIFEST_FILE);
403 let tmp_path = job_dir.join(format!("{MANIFEST_FILE}.tmp"));
404 fs::write(&tmp_path, self.to_toml()?).map_err(|e| {
405 MoldError::Other(anyhow::anyhow!(
406 "writing chain job manifest temp file '{}': {e}",
407 tmp_path.display()
408 ))
409 })?;
410 fs::rename(&tmp_path, &manifest_path).map_err(|e| {
411 MoldError::Other(anyhow::anyhow!(
412 "renaming chain job manifest '{}' to '{}': {e}",
413 tmp_path.display(),
414 manifest_path.display()
415 ))
416 })?;
417 Ok(())
418 }
419
420 pub fn read_from_dir(job_dir: &Path) -> Result<Self> {
421 let manifest_path = job_dir.join(MANIFEST_FILE);
422 let body = fs::read_to_string(&manifest_path).map_err(|e| {
423 MoldError::Other(anyhow::anyhow!(
424 "reading chain job manifest '{}': {e}",
425 manifest_path.display()
426 ))
427 })?;
428 Self::from_toml(&body)
429 }
430
431 fn validate_artifact_paths(&self) -> Result<()> {
432 for (idx, stage) in self.stage_status.iter().enumerate() {
433 if let Some(segment) = &stage.segment {
434 validate_manifest_relative_path(&format!("stage_status[{idx}].segment"), segment)?;
435 }
436 if let Some(audio) = &stage.audio {
437 validate_manifest_relative_path(&format!("stage_status[{idx}].audio"), audio)?;
438 }
439 }
440 for (idx, finalize) in self.finalizes.iter().enumerate() {
441 validate_manifest_relative_path(&format!("finalizes[{idx}].output"), &finalize.output)?;
442 }
443 Ok(())
444 }
445}
446
447fn validate_manifest_relative_path(field: &str, value: &str) -> Result<()> {
448 let path = Path::new(value);
449 if is_manifest_absolute_path(path, value) || has_parent_path_component(path, value) {
450 return Err(MoldError::Validation(format!(
451 "chain job manifest {field} path '{value}' must be relative and must not contain '..'"
452 )));
453 }
454 Ok(())
455}
456
457fn is_manifest_absolute_path(path: &Path, value: &str) -> bool {
458 path.is_absolute()
459 || value.starts_with('/')
460 || value.starts_with('\\')
461 || has_windows_drive_absolute_prefix(value)
462}
463
464fn has_windows_drive_absolute_prefix(value: &str) -> bool {
465 let bytes = value.as_bytes();
466 bytes.len() >= 3
467 && bytes[0].is_ascii_alphabetic()
468 && bytes[1] == b':'
469 && matches!(bytes[2], b'/' | b'\\')
470}
471
472fn has_parent_path_component(path: &Path, value: &str) -> bool {
473 path.components()
474 .any(|component| matches!(component, Component::ParentDir))
475 || value.split(['/', '\\']).any(|component| component == "..")
476}
477
478pub struct JobDirLayout {
483 root: PathBuf,
484}
485
486impl JobDirLayout {
487 pub fn new(root: PathBuf) -> Self {
488 Self { root }
489 }
490
491 pub fn root(&self) -> &Path {
492 &self.root
493 }
494
495 pub fn manifest_path(&self) -> PathBuf {
496 self.root.join(MANIFEST_FILE)
497 }
498
499 pub fn stage_dir(&self, idx: u32) -> PathBuf {
501 self.root.join(STAGES_DIR).join(format!("{idx:03}"))
502 }
503
504 pub fn segment_path(&self, idx: u32) -> PathBuf {
505 self.stage_dir(idx).join(SEGMENT_FILE)
506 }
507
508 pub fn tail_dir(&self, idx: u32) -> PathBuf {
509 self.stage_dir(idx).join(TAIL_DIR)
510 }
511
512 pub fn boundary_in_dir(&self, idx: u32) -> PathBuf {
513 self.stage_dir(idx).join(BOUNDARY_IN_DIR)
514 }
515
516 pub fn boundary_out_dir(&self, idx: u32) -> PathBuf {
517 self.stage_dir(idx).join(BOUNDARY_OUT_DIR)
518 }
519
520 pub fn audio_path(&self, idx: u32) -> PathBuf {
521 self.stage_dir(idx).join(AUDIO_FILE)
522 }
523
524 pub fn preview_path(&self, idx: u32) -> PathBuf {
525 self.stage_dir(idx).join(PREVIEW_FILE)
526 }
527
528 pub fn final_output_path(&self, n: u32) -> PathBuf {
531 self.root.join(FINAL_DIR).join(format!("output-{n}.mp4"))
532 }
533
534 pub fn segment_rel(&self, idx: u32) -> String {
536 format!("{STAGES_DIR}/{idx:03}/{SEGMENT_FILE}")
537 }
538
539 pub fn audio_rel(&self, idx: u32) -> String {
540 format!("{STAGES_DIR}/{idx:03}/{AUDIO_FILE}")
541 }
542
543 pub fn ensure_root(&self) -> Result<()> {
544 fs::create_dir_all(&self.root).map_err(|e| {
545 MoldError::Other(anyhow::anyhow!(
546 "creating chain job root '{}': {e}",
547 self.root.display()
548 ))
549 })
550 }
551
552 pub fn ensure_stage_dirs(&self, idx: u32) -> Result<()> {
555 for dir in [
556 self.stage_dir(idx),
557 self.tail_dir(idx),
558 self.boundary_in_dir(idx),
559 self.boundary_out_dir(idx),
560 ] {
561 fs::create_dir_all(&dir).map_err(|e| {
562 MoldError::Other(anyhow::anyhow!(
563 "creating chain job stage directory '{}': {e}",
564 dir.display()
565 ))
566 })?;
567 }
568 Ok(())
569 }
570}
571
572mod u64_as_string {
575 use serde::de::Error as _;
576 use serde::Deserialize;
577 use serde::{Deserializer, Serializer};
578
579 pub fn serialize<S: Serializer>(v: &u64, s: S) -> std::result::Result<S::Ok, S::Error> {
580 s.collect_str(v)
581 }
582
583 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<u64, D::Error> {
584 let s = String::deserialize(d)?;
585 s.parse::<u64>()
586 .map_err(|_| D::Error::custom("expected u64 encoded as a decimal string"))
587 }
588}
589
590mod u64_vec_as_strings {
591 use serde::de::Error as _;
592 use serde::ser::SerializeSeq;
593 use serde::Deserialize;
594 use serde::{Deserializer, Serializer};
595
596 pub fn serialize<S: Serializer>(v: &Vec<u64>, s: S) -> std::result::Result<S::Ok, S::Error> {
597 let mut seq = s.serialize_seq(Some(v.len()))?;
598 for seed in v {
599 seq.serialize_element(&seed.to_string())?;
600 }
601 seq.end()
602 }
603
604 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> std::result::Result<Vec<u64>, D::Error> {
605 let strings = Vec::<String>::deserialize(d)?;
606 strings
607 .into_iter()
608 .map(|s| {
609 s.parse::<u64>()
610 .map_err(|_| D::Error::custom("expected u64 encoded as a decimal string"))
611 })
612 .collect()
613 }
614}
615
616fn is_false(value: &bool) -> bool {
619 !*value
620}
621
622#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
623pub struct ChainJobSummary {
624 pub id: String,
625 pub state: ChainJobState,
626 pub model: String,
627 pub stage_count: u32,
628 pub current_stage: u32,
629 pub created_at_unix_ms: u64,
630 pub updated_at_unix_ms: u64,
631 pub error: Option<String>,
632 pub ephemeral: bool,
633 #[serde(default, skip_serializing_if = "Option::is_none")]
634 pub execution_phase: Option<ChainExecutionPhase>,
635 #[serde(default, skip_serializing_if = "is_false")]
638 pub cancelling: bool,
639}
640
641#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
642pub struct ChainJobStageDetail {
643 pub idx: u32,
644 pub state: StageState,
645 #[serde(with = "u64_as_string")]
646 pub seed: u64,
647 pub frames_emitted: Option<u32>,
648 pub generation_time_ms: Option<u64>,
649 pub has_preview: bool,
650 #[serde(default)]
652 pub has_media: bool,
653 #[serde(default)]
656 pub cache_ready: bool,
657 pub error: Option<String>,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
661pub struct ChainJobDetail {
662 #[serde(flatten)]
663 pub summary: ChainJobSummary,
664 pub stages: Vec<ChainJobStageDetail>,
665 pub finalizes: Vec<FinalizeRecord>,
666 pub retakes: Vec<RetakeAmendment>,
667 #[serde(default)]
669 pub amends: Vec<AmendRecord>,
670 pub script: crate::chain::ChainScript,
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
676pub struct ChainJobListing {
677 pub jobs: Vec<ChainJobSummary>,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
681pub struct CreateChainJobResponse {
682 pub job_id: String,
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
686pub struct RetakeRequest {
687 pub stage_idx: u32,
688 pub mode: RetakeMode,
689 #[serde(default, with = "u64_opt_as_string")]
690 pub seed_offset: Option<u64>,
691 pub prompt: Option<String>,
692}
693
694#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
700pub struct AmendRequest {
701 pub stages: Vec<crate::chain::ChainStage>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub motion_tail_frames: Option<u32>,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
705 pub fps: Option<u32>,
706 #[serde(default, with = "u64_opt_as_string")]
708 pub seed: Option<u64>,
709 #[serde(default, skip_serializing_if = "Option::is_none")]
710 pub steps: Option<u32>,
711 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub guidance: Option<f64>,
713 #[serde(default, skip_serializing_if = "Option::is_none")]
714 pub strength: Option<f64>,
715 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub enable_audio: Option<bool>,
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
721pub struct AmendResponse {
722 #[serde(flatten)]
723 pub summary: ChainJobSummary,
724 pub preserved_stages: u32,
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
731pub struct GcOutcome {
732 pub swept_ephemeral_jobs: usize,
733 pub pruned_artifact_dirs: usize,
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
737#[expect(
738 clippy::large_enum_variant,
739 reason = "approved wire contract keeps Snapshot inline for utoipa/serde shape"
740)]
741#[serde(tag = "type", rename_all = "snake_case")]
742pub enum ChainJobEvent {
743 Snapshot {
744 job: ChainJobDetail,
745 },
746 StageStart {
747 stage_idx: u32,
748 },
749 DenoiseStep {
750 stage_idx: u32,
751 step: u32,
752 total: u32,
753 },
754 StageDone {
755 stage_idx: u32,
756 frames_emitted: u32,
757 has_preview: bool,
758 #[serde(default)]
759 has_media: bool,
760 #[serde(default)]
761 cache_ready: bool,
762 },
763 Yielded {
764 pending_small_jobs: usize,
765 },
766 Finalizing {
767 total_frames: u32,
768 },
769 Finalized {
770 output: String,
771 take: u32,
772 },
773 StateChanged {
774 state: ChainJobState,
775 error: Option<String>,
776 },
777}
778
779pub fn effective_stage_seed(base_seed: u64, seed_offset: Option<u64>) -> u64 {
780 seed_offset.map_or(base_seed, |offset| base_seed ^ offset)
781}
782
783mod u64_opt_as_string {
784 use serde::de::Error as _;
785 use serde::Deserialize;
786
787 pub fn serialize<S: serde::Serializer>(
788 v: &Option<u64>,
789 s: S,
790 ) -> std::result::Result<S::Ok, S::Error> {
791 match v {
792 Some(seed) => s.serialize_some(&seed.to_string()),
793 None => s.serialize_none(),
794 }
795 }
796
797 pub fn deserialize<'de, D: serde::Deserializer<'de>>(
798 d: D,
799 ) -> std::result::Result<Option<u64>, D::Error> {
800 let raw = Option::<String>::deserialize(d)?;
801 raw.map(|s| {
802 s.parse::<u64>()
803 .map_err(|_| D::Error::custom("expected optional u64 encoded as a decimal string"))
804 })
805 .transpose()
806 }
807}
808
809#[cfg(test)]
810mod tests {
811 use super::*;
812 use crate::chain::{ChainStage, TransitionMode};
813 use crate::types::OutputFormat;
814 use std::str::FromStr;
815
816 fn sample_stage(prompt: &str, seed_offset: Option<u64>) -> ChainStage {
817 ChainStage {
818 prompt: prompt.into(),
819 frames: 97,
820 source_image: None,
821 negative_prompt: None,
822 seed_offset,
823 transition: TransitionMode::Smooth,
824 fade_frames: None,
825 model: None,
826 loras: vec![],
827 references: vec![],
828 }
829 }
830
831 fn sample_request() -> ChainRequest {
832 ChainRequest {
833 model: "ltx-2-19b-distilled:fp8".into(),
834 stages: vec![
835 sample_stage("stage zero", None),
836 sample_stage("stage one", Some(u64::MAX - 3)),
837 ],
838 motion_tail_frames: 17,
839 width: 1216,
840 height: 704,
841 fps: 24,
842 seed: Some(42),
843 steps: 8,
844 guidance: 3.0,
845 strength: 1.0,
846 output_format: OutputFormat::Mp4,
847 placement: None,
848 original_prompt: None,
849 prompt_transform: None,
850 batch_id: None,
851 batch_index: None,
852 batch_count: None,
853 prompt: None,
854 total_frames: None,
855 clip_frames: None,
856 source_image: None,
857 enable_audio: Some(true),
858 }
859 }
860
861 #[test]
862 fn state_enums_round_trip_through_canonical_snake_case() {
863 for (state, text) in [
864 (ChainJobState::Queued, "queued"),
865 (ChainJobState::Running, "running"),
866 (ChainJobState::Interrupted, "interrupted"),
867 (ChainJobState::Failed, "failed"),
868 (ChainJobState::Completed, "completed"),
869 (ChainJobState::Cancelled, "cancelled"),
870 ] {
871 assert_eq!(state.as_str(), text);
872 assert_eq!(ChainJobState::from_str(text).unwrap(), state);
873 assert_eq!(serde_json::to_value(state).unwrap(), text);
874 }
875
876 for (state, text) in [
877 (StageState::Pending, "pending"),
878 (StageState::Running, "running"),
879 (StageState::Completed, "completed"),
880 (StageState::Failed, "failed"),
881 ] {
882 assert_eq!(state.as_str(), text);
883 assert_eq!(StageState::from_str(text).unwrap(), state);
884 assert_eq!(serde_json::to_value(state).unwrap(), text);
885 }
886
887 for (mode, text) in [
888 (RetakeMode::Cascade, "cascade"),
889 (RetakeMode::Splice, "splice"),
890 ] {
891 assert_eq!(mode.as_str(), text);
892 assert_eq!(serde_json::to_value(mode).unwrap(), text);
893 }
894 }
895
896 #[test]
897 fn only_completed_job_state_is_terminal() {
898 assert!(!ChainJobState::Queued.is_terminal());
899 assert!(!ChainJobState::Running.is_terminal());
900 assert!(!ChainJobState::Interrupted.is_terminal());
901 assert!(!ChainJobState::Failed.is_terminal());
902 assert!(ChainJobState::Completed.is_terminal());
903 assert!(!ChainJobState::Cancelled.is_terminal());
904 }
905
906 #[test]
907 fn settled_states_are_distinct_from_terminal_states() {
908 assert!(!settled(ChainJobState::Queued));
909 assert!(!settled(ChainJobState::Running));
910 assert!(!settled(ChainJobState::Interrupted));
911 assert!(settled(ChainJobState::Failed));
912 assert!(settled(ChainJobState::Completed));
913 assert!(settled(ChainJobState::Cancelled));
914
915 assert!(!ChainJobState::Failed.is_terminal());
916 assert!(ChainJobState::Completed.is_terminal());
917 assert!(!ChainJobState::Cancelled.is_terminal());
918 }
919
920 #[test]
921 fn manifest_toml_round_trips_full_range_seeds() {
922 let request = sample_request();
923 let mut manifest =
924 ChainJobManifest::new("01JBR55TEST".into(), 1_783_200_000_000, &request).unwrap();
925 assert_eq!(manifest.needs_finalize, Some(true));
926 manifest.stage_status[1].state = StageState::Completed;
927 manifest.stage_status[1].frames_emitted = Some(97);
928 manifest.stage_status[1].generation_time_ms = Some(12_345);
929 manifest.stage_status[1].segment = Some("stages/001/segment.mp4".into());
930 manifest.stage_status[1].tail_frames = Some(17);
931 manifest.stage_status[1].audio = Some("stages/001/audio.pcm".into());
932 manifest.finalizes.push(FinalizeRecord {
933 output: "final/output-1.mp4".into(),
934 at_unix_ms: 1_783_200_000_500,
935 stage_seeds: vec![u64::MAX - 3, u64::MAX - 2],
936 });
937
938 assert_eq!(
939 manifest.request_json,
940 serde_json::to_string(&request).unwrap()
941 );
942 assert_eq!(manifest.stage_status[0].seed, 42);
943 assert_eq!(manifest.stage_status[1].seed, 42 ^ (u64::MAX - 3));
944
945 let toml = manifest.to_toml().unwrap();
946 assert!(toml.contains("seed = \"18446744073709551574\""));
947 assert!(toml.contains("\"18446744073709551612\""));
948
949 let round_tripped = ChainJobManifest::from_toml(&toml).unwrap();
950 assert_eq!(round_tripped, manifest);
951 assert_eq!(round_tripped.request().unwrap(), request);
952 }
953
954 #[test]
955 fn legacy_finalized_manifest_without_revision_flag_stays_finalized() {
956 let request = sample_request();
957 let mut manifest =
958 ChainJobManifest::new("01JBR55LEGACYFINAL".into(), 1_783_200_000_000, &request)
959 .unwrap();
960 for stage in &mut manifest.stage_status {
961 stage.state = StageState::Completed;
962 }
963 manifest.needs_finalize = Some(false);
964 manifest.finalizes.push(FinalizeRecord {
965 output: "final/output-1.mp4".into(),
966 at_unix_ms: 1_783_200_000_500,
967 stage_seeds: manifest
968 .stage_status
969 .iter()
970 .map(|stage| stage.seed)
971 .collect(),
972 });
973 let current = manifest.to_toml().unwrap();
974 let legacy = current.replace("needs_finalize = false\n", "");
975
976 let parsed = ChainJobManifest::from_toml(&legacy).unwrap();
977
978 assert_eq!(parsed.needs_finalize, None);
979 assert!(parsed.current_revision_is_finalized(ChainJobState::Completed));
980 assert!(
981 !parsed.current_revision_is_finalized(ChainJobState::Queued),
982 "a flag-less non-terminal revision must conservatively re-finalize"
983 );
984 }
985
986 #[test]
987 fn effective_stage_seed_matches_approved_vectors() {
988 assert_eq!(effective_stage_seed(42, None), 42);
989 assert_eq!(effective_stage_seed(42, Some(0)), 42);
990 assert_eq!(effective_stage_seed(42, Some(1)), 43);
991 assert_eq!(
992 effective_stage_seed(0xF0F0_F0F0_F0F0_F0F0, Some(0xFFFF_0000_5555_AAAA)),
993 0x0F0F_F0F0_A5A5_5A5A
994 );
995 assert_eq!(
996 effective_stage_seed(42, Some(u64::MAX - 3)),
997 42 ^ (u64::MAX - 3)
998 );
999 }
1000
1001 #[test]
1002 fn retake_request_seed_offset_round_trips_as_optional_string() {
1003 let max = u64::MAX;
1004 let json = serde_json::json!({
1005 "stage_idx": 7,
1006 "mode": "cascade",
1007 "seed_offset": max.to_string(),
1008 "prompt": "new prompt"
1009 });
1010
1011 let req: RetakeRequest = serde_json::from_value(json.clone()).unwrap();
1012 assert_eq!(req.seed_offset, Some(max));
1013 assert_eq!(
1014 serde_json::to_value(&req).unwrap()["seed_offset"],
1015 max.to_string()
1016 );
1017
1018 let none_json = serde_json::json!({
1019 "stage_idx": 0,
1020 "mode": "splice"
1021 });
1022 let none_req: RetakeRequest = serde_json::from_value(none_json).unwrap();
1023 assert_eq!(none_req.seed_offset, None);
1024 assert!(serde_json::to_value(&none_req).unwrap()["seed_offset"].is_null());
1025 }
1026
1027 #[test]
1028 fn amend_request_round_trips_u64_seed_as_string() {
1029 let max = u64::MAX;
1030 let json = serde_json::json!({
1031 "stages": [
1032 {
1033 "prompt": "edited clip",
1034 "frames": 97,
1035 "transition": "cut"
1036 }
1037 ],
1038 "seed": max.to_string(),
1039 "steps": 8
1040 });
1041
1042 let req: AmendRequest = serde_json::from_value(json).unwrap();
1043 assert_eq!(req.seed, Some(max));
1044 assert_eq!(req.steps, Some(8));
1045 assert_eq!(req.motion_tail_frames, None);
1046 assert_eq!(req.fps, None);
1047 assert_eq!(req.guidance, None);
1048 assert_eq!(req.strength, None);
1049 assert_eq!(req.enable_audio, None);
1050 assert_eq!(req.stages.len(), 1);
1051 assert_eq!(req.stages[0].prompt, "edited clip");
1052 assert_eq!(
1053 serde_json::to_value(&req).unwrap()["seed"],
1054 max.to_string(),
1055 "u64 seeds must round-trip as decimal strings"
1056 );
1057
1058 let none: AmendRequest = serde_json::from_value(serde_json::json!({
1059 "stages": []
1060 }))
1061 .unwrap();
1062 assert_eq!(none.seed, None);
1063 }
1064
1065 #[test]
1069 fn manifest_amends_and_raw_segment_default_for_v1_toml() {
1070 let toml = r#"
1071schema = "mold.chainjob.v1"
1072job_id = "01JBR55V1"
1073created_at_unix_ms = 1
1074
1075request_json = "{}"
1076
1077[[stage_status]]
1078idx = 0
1079state = "completed"
1080seed = "42"
1081frames_emitted = 97
1082segment = "stages/000/segment.mp4"
1083tail_frames = 17
1084"#;
1085 let manifest = ChainJobManifest::from_toml(toml).unwrap();
1086 assert!(manifest.amends.is_empty());
1087 assert!(!manifest.stage_status[0].raw_segment);
1088 assert_eq!(manifest.stage_status[0].state, StageState::Completed);
1089 }
1090
1091 #[test]
1092 fn chain_job_event_serde_uses_tagged_snapshot_shape() {
1093 let request = sample_request();
1094 let manifest =
1095 ChainJobManifest::new("01JBR55EVENT".into(), 1_783_200_000_000, &request).unwrap();
1096 let detail = ChainJobDetail {
1097 summary: ChainJobSummary {
1098 id: manifest.job_id.clone(),
1099 state: ChainJobState::Queued,
1100 model: request.model.clone(),
1101 stage_count: request.stages.len() as u32,
1102 current_stage: 0,
1103 created_at_unix_ms: manifest.created_at_unix_ms,
1104 updated_at_unix_ms: manifest.created_at_unix_ms,
1105 error: None,
1106 ephemeral: false,
1107 execution_phase: None,
1108 cancelling: false,
1109 },
1110 stages: manifest
1111 .stage_status
1112 .iter()
1113 .map(|stage| ChainJobStageDetail {
1114 idx: stage.idx,
1115 state: stage.state,
1116 seed: stage.seed,
1117 frames_emitted: stage.frames_emitted,
1118 generation_time_ms: stage.generation_time_ms,
1119 has_preview: false,
1120 has_media: false,
1121 cache_ready: false,
1122 error: stage.error.clone(),
1123 })
1124 .collect(),
1125 finalizes: vec![],
1126 retakes: vec![],
1127 amends: vec![],
1128 script: crate::chain::ChainScript::from(&request),
1129 };
1130 let value = serde_json::to_value(ChainJobEvent::Snapshot { job: detail }).unwrap();
1131 assert_eq!(value["type"], "snapshot");
1132 assert_eq!(value["job"]["id"], "01JBR55EVENT");
1133 assert!(
1134 value["job"].get("cancelling").is_none(),
1135 "false cancellation state must remain wire-compatible"
1136 );
1137
1138 let step = serde_json::to_value(ChainJobEvent::DenoiseStep {
1139 stage_idx: 2,
1140 step: 3,
1141 total: 8,
1142 })
1143 .unwrap();
1144 assert_eq!(
1145 step,
1146 serde_json::json!({
1147 "type": "denoise_step",
1148 "stage_idx": 2,
1149 "step": 3,
1150 "total": 8
1151 })
1152 );
1153 }
1154
1155 #[test]
1156 fn chain_job_summary_serializes_active_cancellation_additively() {
1157 let mut detail = event_detail_fixture();
1158 detail.summary.cancelling = true;
1159
1160 let value = serde_json::to_value(detail.summary).unwrap();
1161
1162 assert_eq!(value["state"], "running");
1163 assert_eq!(value["cancelling"], true);
1164 }
1165
1166 #[test]
1167 fn from_toml_rejects_wrong_schema() {
1168 let err = ChainJobManifest::from_toml(
1169 r#"
1170schema = "mold.chainjob.v2"
1171job_id = "01JBR55TEST"
1172created_at_unix_ms = 1
1173request_json = "{}"
1174"#,
1175 )
1176 .unwrap_err();
1177 assert!(err.to_string().contains("mold.chainjob.v2"));
1178 assert!(err.to_string().contains("supported: 'mold.chainjob.v1'"));
1179 }
1180
1181 #[test]
1182 fn from_toml_rejects_absolute_stage_segment_path() {
1183 let request = sample_request();
1184 let mut manifest =
1185 ChainJobManifest::new("01JBR55TEST".into(), 1_783_200_000_000, &request).unwrap();
1186 manifest.stage_status[0].segment = Some("/tmp/segment.mp4".into());
1187
1188 let err = ChainJobManifest::from_toml(&manifest.to_toml().unwrap()).unwrap_err();
1189
1190 let msg = err.to_string();
1191 assert!(msg.contains("stage_status[0].segment"));
1192 assert!(msg.contains("/tmp/segment.mp4"));
1193 }
1194
1195 #[test]
1196 fn from_toml_rejects_parent_component_in_stage_audio_path() {
1197 let request = sample_request();
1198 let mut manifest =
1199 ChainJobManifest::new("01JBR55TEST".into(), 1_783_200_000_000, &request).unwrap();
1200 manifest.stage_status[1].audio = Some("stages/001/../audio.pcm".into());
1201
1202 let err = ChainJobManifest::from_toml(&manifest.to_toml().unwrap()).unwrap_err();
1203
1204 let msg = err.to_string();
1205 assert!(msg.contains("stage_status[1].audio"));
1206 assert!(msg.contains("stages/001/../audio.pcm"));
1207 }
1208
1209 #[test]
1210 fn from_toml_rejects_parent_component_in_finalize_output_path() {
1211 let request = sample_request();
1212 let mut manifest =
1213 ChainJobManifest::new("01JBR55TEST".into(), 1_783_200_000_000, &request).unwrap();
1214 manifest.finalizes.push(FinalizeRecord {
1215 output: "final/../output.mp4".into(),
1216 at_unix_ms: 1_783_200_000_500,
1217 stage_seeds: vec![42],
1218 });
1219
1220 let err = ChainJobManifest::from_toml(&manifest.to_toml().unwrap()).unwrap_err();
1221
1222 let msg = err.to_string();
1223 assert!(msg.contains("finalizes[0].output"));
1224 assert!(msg.contains("final/../output.mp4"));
1225 }
1226
1227 #[test]
1228 fn from_toml_accepts_normal_relative_artifact_paths() {
1229 let request = sample_request();
1230 let mut manifest =
1231 ChainJobManifest::new("01JBR55TEST".into(), 1_783_200_000_000, &request).unwrap();
1232 manifest.stage_status[0].segment = Some("stages/000/segment.mp4".into());
1233 manifest.stage_status[0].audio = Some("stages/000/audio.pcm".into());
1234 manifest.finalizes.push(FinalizeRecord {
1235 output: "final/output-1.mp4".into(),
1236 at_unix_ms: 1_783_200_000_500,
1237 stage_seeds: vec![42],
1238 });
1239
1240 let parsed = ChainJobManifest::from_toml(&manifest.to_toml().unwrap()).unwrap();
1241
1242 assert_eq!(parsed, manifest);
1243 }
1244
1245 #[test]
1246 fn write_atomic_and_read_from_dir_round_trip() {
1247 let dir = tempfile::tempdir().unwrap();
1248 let request = sample_request();
1249 let manifest =
1250 ChainJobManifest::new("01JBR55TEST".into(), 1_783_200_000_000, &request).unwrap();
1251
1252 manifest.write_atomic(dir.path()).unwrap();
1253
1254 let manifest_path = dir.path().join(MANIFEST_FILE);
1255 assert!(manifest_path.exists());
1256 assert!(!dir.path().join("manifest.toml.tmp").exists());
1257 let read = ChainJobManifest::read_from_dir(dir.path()).unwrap();
1258 assert_eq!(read, manifest);
1259 }
1260
1261 #[test]
1262 fn frozen_chain_model_round_trips_and_old_manifest_defaults_to_unfrozen() {
1263 let request = sample_request();
1264 let mut manifest =
1265 ChainJobManifest::new("frozen".into(), 1_783_200_000_000, &request).unwrap();
1266 manifest.frozen_model = Some(FrozenChainModel {
1267 runtime_model_id: "mold-frozen-chain:test".to_string(),
1268 config: crate::ModelConfig {
1269 transformer: Some("/models/original-transformer.safetensors".into()),
1270 vae: Some("/models/original-vae.safetensors".into()),
1271 text_encoder_files: Some(vec!["/models/original-projection.safetensors".into()]),
1272 family: Some("ltx2".into()),
1273 ..crate::ModelConfig::default()
1274 },
1275 model_fingerprint: "frozen-fingerprint".into(),
1276 });
1277 let encoded = manifest.to_toml().unwrap();
1278 let decoded = ChainJobManifest::from_toml(&encoded).unwrap();
1279 assert_eq!(decoded.frozen_model, manifest.frozen_model);
1280
1281 let legacy = encoded
1282 .lines()
1283 .take_while(|line| !line.starts_with("[frozen_model]"))
1284 .collect::<Vec<_>>()
1285 .join("\n");
1286 let decoded_legacy = ChainJobManifest::from_toml(&legacy).unwrap();
1287 assert!(decoded_legacy.frozen_model.is_none());
1288 }
1289
1290 #[test]
1291 fn job_dir_layout_paths_match_spec() {
1292 let root = PathBuf::from("/tmp/mold-job");
1293 let layout = JobDirLayout::new(root.clone());
1294
1295 assert_eq!(layout.root(), root.as_path());
1296 assert_eq!(layout.manifest_path(), root.join("manifest.toml"));
1297 assert_eq!(layout.stage_dir(7), root.join("stages").join("007"));
1298 assert_eq!(
1299 layout.segment_path(7),
1300 root.join("stages").join("007").join("segment.mp4")
1301 );
1302 assert_eq!(
1303 layout.tail_dir(7),
1304 root.join("stages").join("007").join("tail")
1305 );
1306 assert_eq!(
1307 layout.boundary_in_dir(7),
1308 root.join("stages").join("007").join("boundary-in")
1309 );
1310 assert_eq!(
1311 layout.boundary_out_dir(7),
1312 root.join("stages").join("007").join("boundary-out")
1313 );
1314 assert_eq!(
1315 layout.audio_path(7),
1316 root.join("stages").join("007").join("audio.pcm")
1317 );
1318 assert_eq!(
1319 layout.preview_path(7),
1320 root.join("stages").join("007").join("preview.jpg")
1321 );
1322 assert_eq!(
1323 layout.final_output_path(3),
1324 root.join("final").join("output-3.mp4")
1325 );
1326 assert_eq!(layout.segment_rel(7), "stages/007/segment.mp4");
1327 assert_eq!(layout.audio_rel(7), "stages/007/audio.pcm");
1328 }
1329
1330 #[test]
1331 fn job_dir_layout_ensure_helpers_create_required_directories() {
1332 let dir = tempfile::tempdir().unwrap();
1333 let layout = JobDirLayout::new(dir.path().join("job"));
1334
1335 layout.ensure_root().unwrap();
1336 layout.ensure_stage_dirs(7).unwrap();
1337
1338 assert!(layout.root().is_dir());
1339 assert!(layout.stage_dir(7).is_dir());
1340 assert!(layout.tail_dir(7).is_dir());
1341 assert!(layout.boundary_in_dir(7).is_dir());
1342 assert!(layout.boundary_out_dir(7).is_dir());
1343 }
1344
1345 fn event_detail_fixture() -> ChainJobDetail {
1346 let request = crate::chain::ChainRequest {
1347 model: "ltx-2-19b-distilled:fp8".into(),
1348 stages: vec![sample_stage("stage zero", None)],
1349 motion_tail_frames: 0,
1350 width: 64,
1351 height: 64,
1352 fps: 12,
1353 seed: Some(42),
1354 steps: 4,
1355 guidance: 3.0,
1356 strength: 1.0,
1357 output_format: OutputFormat::Mp4,
1358 placement: None,
1359 original_prompt: None,
1360 prompt_transform: None,
1361 batch_id: None,
1362 batch_index: None,
1363 batch_count: None,
1364 prompt: None,
1365 total_frames: None,
1366 clip_frames: None,
1367 source_image: None,
1368 enable_audio: None,
1369 };
1370 ChainJobDetail {
1371 summary: ChainJobSummary {
1372 id: "job-1".into(),
1373 state: ChainJobState::Running,
1374 model: request.model.clone(),
1375 stage_count: 1,
1376 current_stage: 0,
1377 created_at_unix_ms: 1,
1378 updated_at_unix_ms: 2,
1379 error: None,
1380 ephemeral: false,
1381 execution_phase: None,
1382 cancelling: false,
1383 },
1384 stages: vec![ChainJobStageDetail {
1385 idx: 0,
1386 state: StageState::Pending,
1387 seed: 42,
1388 frames_emitted: None,
1389 generation_time_ms: None,
1390 has_preview: false,
1391 has_media: false,
1392 cache_ready: false,
1393 error: None,
1394 }],
1395 finalizes: vec![],
1396 retakes: vec![],
1397 amends: vec![],
1398 script: crate::chain::ChainScript::from(&request),
1399 }
1400 }
1401
1402 #[test]
1403 fn chain_job_event_serde_tag_fixtures_match_web_contract() {
1404 let fixtures = vec![
1405 (
1406 ChainJobEvent::Snapshot {
1407 job: event_detail_fixture(),
1408 },
1409 serde_json::json!("snapshot"),
1410 ),
1411 (
1412 ChainJobEvent::StageStart { stage_idx: 2 },
1413 serde_json::json!({"type":"stage_start","stage_idx":2}),
1414 ),
1415 (
1416 ChainJobEvent::DenoiseStep {
1417 stage_idx: 2,
1418 step: 3,
1419 total: 8,
1420 },
1421 serde_json::json!({"type":"denoise_step","stage_idx":2,"step":3,"total":8}),
1422 ),
1423 (
1424 ChainJobEvent::StageDone {
1425 stage_idx: 2,
1426 frames_emitted: 97,
1427 has_preview: true,
1428 has_media: true,
1429 cache_ready: true,
1430 },
1431 serde_json::json!({"type":"stage_done","stage_idx":2,"frames_emitted":97,"has_preview":true,"has_media":true,"cache_ready":true}),
1432 ),
1433 (
1434 ChainJobEvent::Yielded {
1435 pending_small_jobs: 4,
1436 },
1437 serde_json::json!({"type":"yielded","pending_small_jobs":4}),
1438 ),
1439 (
1440 ChainJobEvent::Finalizing { total_frames: 194 },
1441 serde_json::json!({"type":"finalizing","total_frames":194}),
1442 ),
1443 (
1444 ChainJobEvent::Finalized {
1445 output: "final/output-1.mp4".into(),
1446 take: 1,
1447 },
1448 serde_json::json!({"type":"finalized","output":"final/output-1.mp4","take":1}),
1449 ),
1450 (
1451 ChainJobEvent::StateChanged {
1452 state: ChainJobState::Completed,
1453 error: None,
1454 },
1455 serde_json::json!({"type":"state_changed","state":"completed","error":null}),
1456 ),
1457 ];
1458
1459 for (event, expected) in fixtures {
1460 let value = serde_json::to_value(&event).expect("event serializes");
1461 if expected == serde_json::json!("snapshot") {
1462 assert_eq!(value.get("type"), Some(&serde_json::json!("snapshot")));
1463 assert_eq!(value.pointer("/job/id"), Some(&serde_json::json!("job-1")));
1464 } else {
1465 assert_eq!(value, expected);
1466 }
1467 }
1468 }
1469}