Skip to main content

mold_core/
chain_job.rs

1//! Durable chain-job manifest and job-directory layout.
2//!
3//! A chain job is a persistent, resumable chained-video generation. Its
4//! portable source of truth is `manifest.toml` (schema `mold.chainjob.v1`)
5//! inside a self-contained job directory; `mold.db` holds a queryable index
6//! of the same state (see `mold_db::chain_jobs`). When the two disagree,
7//! the manifest wins. See `docs/superpowers/specs/2026-07-03-durable-chain-jobs-design.md` §3.
8//!
9//! The embedded generation request is stored as canonical JSON
10//! (`request_json`), not TOML tables: TOML 0.8 integers are i64-limited and
11//! full-range u64 seeds (`base ^ seed_offset`) would abort the manifest
12//! write. JSON is also the exact encoding the DB row uses, so both stores
13//! share one canonical request serialization.
14
15use std::fs;
16use std::path::{Component, Path, PathBuf};
17
18use serde::{Deserialize, Serialize};
19
20use crate::chain::ChainRequest;
21use crate::error::{MoldError, Result};
22
23/// Manifest schema identifier. `from_toml` rejects anything else.
24pub const CHAIN_JOB_SCHEMA: &str = "mold.chainjob.v1";
25
26// ── Job-dir layout: fixed relative names (spec §3.2) ──────────────────
27
28pub 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// ── State enums (canonical here; mold-db imports them) ────────────────
39
40/// Job lifecycle state, stored as snake_case TEXT in `chain_jobs.state`.
41#[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/// Present-tense execution phase for an active durable sequence.
53///
54/// The parent job becomes `running` when its actor claims the record, before
55/// any stage necessarily owns a scheduler lease. Clients use this additive
56/// phase instead of mistaking that orchestration state for GPU activity.
57#[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/// Per-stage state, stored as snake_case TEXT in `chain_job_stages.state`.
66#[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/// Retake mode (spec §8): cascade re-renders N..end, splice re-renders N only.
76#[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    /// terminal = "excluded from startup reconcile and will never transition again;
96    /// only Completed. Failed/Interrupted/Cancelled are resumable (cancel is
97    /// pause-with-intent, spec §6)."
98    pub fn is_terminal(self) -> bool {
99        matches!(self, ChainJobState::Completed)
100    }
101}
102
103/// Settled jobs have reached a durable outcome for this attempt:
104/// completed, failed, or cancelled.
105///
106/// This is deliberately broader than [`ChainJobState::is_terminal`]. Only
107/// `completed` is terminal in the resume/reconcile sense; `failed` and
108/// `cancelled` are settled for cancel/bus cleanup but may be re-queued by
109/// resume or retake.
110pub 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// ── Manifest types (spec §3.2, current) ───────────
172
173/// Portable job description + per-stage status. Everything needed to
174/// resume or retake lives here; all paths are relative to the job dir.
175#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
176pub struct ChainJobManifest {
177    /// Must equal [`CHAIN_JOB_SCHEMA`] on read.
178    pub schema: String,
179    pub job_id: String,
180    pub created_at_unix_ms: u64,
181    /// Sync-shim jobs (spec §9): artifacts deleted immediately after success.
182    #[serde(default)]
183    pub ephemeral: bool,
184    /// Full normalised [`ChainRequest`], canonically serde_json-encoded.
185    pub request_json: String,
186    /// Exact resolved component paths and artifact identity captured when the
187    /// durable job is created. Older manifests omit this and are migrated on
188    /// their first safe resume before any stage is submitted.
189    #[serde(default)]
190    pub frozen_model: Option<FrozenChainModel>,
191    #[serde(default)]
192    pub stage_status: Vec<StageStatus>,
193    /// Retake amendment history (spec §8.3). The original request stays
194    /// intact for provenance; edits are recorded here. An amend folds any
195    /// pending retakes into the rewritten `request_json` and clears this
196    /// list (their content lives on in the amend's request snapshot).
197    #[serde(default)]
198    pub retakes: Vec<RetakeAmendment>,
199    /// Versioned finalize history (spec §8.3).
200    #[serde(default)]
201    pub finalizes: Vec<FinalizeRecord>,
202    /// Whether the current request/stage revision still needs a final output.
203    ///
204    /// `finalizes` is historical: retakes and amends keep earlier takes, so
205    /// non-emptiness cannot prove that the current revision is finalized.
206    /// `None` preserves the fact that a legacy manifest omitted this field.
207    /// That absence is intentionally not collapsed to `false`: pre-field
208    /// binaries already supported retakes/amends under the same schema, so a
209    /// historical finalize record may belong to an older revision.
210    #[serde(default)]
211    pub needs_finalize: Option<bool>,
212    /// Amend history (spec §17): each entry snapshots the pre-amend
213    /// EFFECTIVE request so any preserved stage remains attributable.
214    #[serde(default)]
215    pub amends: Vec<AmendRecord>,
216}
217
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219pub struct FrozenChainModel {
220    /// Synthetic runtime key used to bypass mutable manifest/sidecar
221    /// discovery. Empty in legacy manifests, which continue to use the
222    /// request's model key.
223    #[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    /// Effective seed (`base ^ seed_offset`); full-range u64, so encoded
234    /// as a decimal string in TOML (i64-limited integers).
235    #[serde(with = "u64_as_string")]
236    pub seed: u64,
237    pub frames_emitted: Option<u32>,
238    pub generation_time_ms: Option<u64>,
239    /// Relative to the job dir. Never absolute (portability contract).
240    pub segment: Option<String>,
241    pub tail_frames: Option<u32>,
242    /// Relative path to the stage's PCM sidecar, when audio was rendered.
243    pub audio: Option<String>,
244    pub error: Option<String>,
245    /// `true` for stages written under the raw-segment contract (2026-07-28):
246    /// the segment holds every frame the engine emitted (no boundary trims or
247    /// fade blends) and all boundary math is deferred to finalize. `false`
248    /// (the pre-amend default) marks a legacy stage whose segment was trimmed
249    /// and blended at write time and passes through finalize untouched.
250    #[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    /// Set only when the retake changed the prompt.
263    pub old_prompt: Option<String>,
264    pub new_prompt: Option<String>,
265    pub at_unix_ms: u64,
266}
267
268/// One amend applied to a chain job: the full edited stage list replaced the
269/// request's stages (plus optional chain-level overlays), and rendering
270/// requeued from the earliest genuinely-dirty stage. `previous_request_json`
271/// is the pre-amend EFFECTIVE request (retakes folded in), serialised with
272/// the same canonical JSON encoding as `request_json`.
273#[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    /// Relative path under `final/`, e.g. `final/output-1.mp4`.
283    pub output: String,
284    pub at_unix_ms: u64,
285    /// Per-stage effective seeds that produced this take (spec §8.3:
286    /// every take attributable and reproducible).
287    #[serde(with = "u64_vec_as_strings")]
288    pub stage_seeds: Vec<u64>,
289}
290
291impl ChainJobManifest {
292    /// Build a fresh manifest for a new job, serialising `request` into
293    /// `request_json` (canonical JSON). `ephemeral` defaults to false;
294    /// shim callers set the pub field directly.
295    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    /// True only when a finalize record belongs to the current manifest
336    /// revision rather than to a historical retake/amend take.
337    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            // Legacy omission is ambiguous for a non-terminal DB row: it may
349            // be an accepted retake/amend whose replacement stages were
350            // checkpointed before finalization. Prefer one conservative
351            // re-finalization over silently losing that accepted revision.
352            None => indexed_state == ChainJobState::Completed,
353        }
354    }
355
356    /// Parsed-value access to the embedded request. Reconcile logic
357    /// compares THIS, never raw `request_json` strings (field-order
358    /// changes alter bytes without altering meaning).
359    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    /// Parse a manifest, rejecting `schema != mold.chainjob.v1` with a
365    /// clear error naming the supported schema (chain_toml.rs precedent).
366    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    /// Write `<job_dir>/manifest.toml` via write-temp + rename so a crash
399    /// mid-write never leaves a truncated manifest (spec §3.3 ordering:
400    /// artifacts → manifest → DB).
401    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
478// ── Job-directory layout helpers (pure path math + mkdir) ─────────────
479
480/// Path helpers for one job directory. Pure path math except the
481/// `ensure_*` mkdir helpers; all artifact paths follow spec §3.2.
482pub 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    /// `stages/NNN/` with zero-padded 3-digit stage index.
500    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    /// `final/output-<n>.mp4`. `n` derives from `manifest.finalizes.len()`,
529    /// never from a directory scan.
530    pub fn final_output_path(&self, n: u32) -> PathBuf {
531        self.root.join(FINAL_DIR).join(format!("output-{n}.mp4"))
532    }
533
534    /// Relative-to-root form for manifest fields (portability contract).
535    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    /// Create `stages/NNN/` plus its `tail/`, `boundary-in/`,
553    /// `boundary-out/` subdirectories.
554    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
572// ── serde helpers: full-range u64 as decimal strings in TOML ──────────
573
574mod 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
616// ── Chain-job API wire types ──────────────────────────────────────────
617
618fn 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    /// Cooperative cancellation has been requested for the active stage and
636    /// will settle at its next safe engine boundary.
637    #[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    /// A standalone stage MP4 exists and can be streamed immediately.
651    #[serde(default)]
652    pub has_media: bool,
653    /// Every disk artifact needed to reuse this stage during amend/finalize
654    /// is present. This is authoritative filesystem state, not manifest intent.
655    #[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    /// Amend history (additive; empty for never-amended jobs).
668    #[serde(default)]
669    pub amends: Vec<AmendRecord>,
670    /// EFFECTIVE script (original request + retake amendments applied);
671    /// provenance stays in `retakes`.
672    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/// Body of `POST /api/chain-jobs/:id/amend`: the FULL edited stage list (in
695/// canonical order) replaces the job's stages, plus optional chain-level
696/// overlays (omitted = keep current). NOT amendable — the client must create
697/// a fresh job instead: model, width, height, output_format, placement,
698/// and batch provenance.
699#[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    /// Full-range u64, encoded as a decimal string on the wire.
707    #[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/// 202 body of `POST /api/chain-jobs/:id/amend`.
720#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
721pub struct AmendResponse {
722    #[serde(flatten)]
723    pub summary: ChainJobSummary,
724    /// Leading stages whose cached artifacts were preserved; rendering
725    /// requeues from this index.
726    pub preserved_stages: u32,
727}
728
729/// Result shape for GC passes; also the JSON body of POST /api/chain-jobs/gc.
730#[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    /// Old manifests written before the amend/raw-segment fields existed
1066    /// must keep parsing under `mold.chainjob.v1`: `amends` defaults to
1067    /// empty and every stage is a legacy (`raw_segment == false`) stage.
1068    #[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}