1#![allow(
12 clippy::if_not_else,
13 clippy::missing_errors_doc,
14 clippy::must_use_candidate,
15 clippy::pedantic
16)]
17#[doc(hidden)]
18#[path = "io/fs.rs"]
19pub mod fs;
20#[doc(hidden)]
21#[path = "storage/hardening.rs"]
22pub mod hardening;
23#[doc(hidden)]
24#[path = "integrity/hash.rs"]
25pub mod hash;
26#[doc(hidden)]
27#[path = "integrity/index.rs"]
28pub mod index;
29#[cfg(feature = "experimental-public-api")]
30#[path = "integrity/lifecycle_and_cache_contracts.rs"]
31mod lifecycle_and_cache_contracts;
32#[doc(hidden)]
33#[path = "lifecycle/lineage.rs"]
34pub mod lineage;
35#[doc(hidden)]
36#[path = "storage/models.rs"]
37pub mod models;
38#[doc(hidden)]
39#[path = "layout/paths.rs"]
40pub mod paths;
41#[doc(hidden)]
42#[path = "layout/platform.rs"]
43pub mod platform;
44#[doc(hidden)]
45#[path = "lifecycle/promotion.rs"]
46pub mod promotion;
47#[doc(hidden)]
48#[path = "integrity/proof.rs"]
49pub mod proof;
50#[doc(hidden)]
51#[path = "lifecycle/retention.rs"]
52pub mod retention;
53#[cfg(feature = "experimental-public-api")]
54#[path = "integrity/run_layout_contracts.rs"]
55mod run_layout_contracts;
56#[doc(hidden)]
57#[path = "integrity/schema.rs"]
58pub mod schema;
59#[doc(hidden)]
60#[path = "storage/services.rs"]
61pub mod services;
62#[doc(hidden)]
63#[path = "io/store.rs"]
64pub mod store;
65
66#[doc(hidden)]
67pub use hardening::{
68 build_cleanup_plan, finalize_run_manifest, finalize_run_manifest_with_mode, verify_run_dir,
69 write_incomplete_run_marker, write_json_atomic_durable, ArtifactCleanupPlan, RunDirAuditReport,
70 RunFinalizationMode, VerificationMode,
71};
72#[doc(hidden)]
73pub use hash::sha256_hex;
74#[doc(hidden)]
75pub use index::{
76 dedup_metrics_for_hashes, normalize_metadata_pairs, ArtifactId, ArtifactPackManifest,
77};
78#[doc(hidden)]
79pub use lineage::{write_lineage_snapshot, ArtifactLineageEdge, ArtifactLineageSnapshot};
80#[doc(hidden)]
81pub use models::*;
82#[doc(hidden)]
83pub use paths::is_normalized_relative_path;
84#[doc(hidden)]
85pub use platform::{
86 compact_lineage, explain_lineage_safe_gc, lineage_dependencies, lineage_dependents,
87};
88#[doc(hidden)]
89pub use promotion::{
90 append_promotion_record, append_promotion_summary, build_promoted_output_summary,
91 promotion_record_path, ArtifactPromotionIndex, ArtifactPromotionRecord, PromotionEnvironment,
92 PromotionLineageSummary,
93};
94#[doc(hidden)]
95pub use proof::{ArtifactIntegrityProof, CorruptionDetectionResult, CorruptionRepairPolicy};
96#[doc(hidden)]
97pub use retention::RetentionPolicy;
98#[doc(hidden)]
99pub use schema::{
100 validate_output_schema_descriptor, ArtifactSchemaDescriptor, SchemaValidationMode,
101};
102#[doc(hidden)]
103pub use services::{RunArtifactStore, RunArtifactVerifier};
104
105use serde::Serialize;
106use std::fs as std_fs;
107use std::io::{self, Write};
108use std::path::{Path, PathBuf};
109use std::sync::atomic::{AtomicU64, Ordering};
110use std::time::{SystemTime, UNIX_EPOCH};
111
112pub mod stable {
114 pub use crate::{
115 artifact_size_bytes, compact_lineage, dedup_metrics_for_hashes, explain_lineage_safe_gc,
116 lineage_dependencies, lineage_dependents, normalize_metadata_pairs, sha256_artifact_path,
117 sha256_hex, validate_output_schema_descriptor, verify_run_dir, write_inputs_index,
118 write_lineage_snapshot, write_outputs_index, ArtifactError, ArtifactId,
119 ArtifactIntegrityProof, ArtifactLineageEdge, ArtifactLineageSnapshot, ArtifactPackManifest,
120 ArtifactSchemaDescriptor, CorruptionDetectionResult, CorruptionRepairPolicy,
121 RetentionPolicy, RunArtifactStore, RunArtifactVerifier, RunDir, RunDirLayout,
122 SchemaValidationMode,
123 };
124}
125
126pub mod prelude {
128 pub use crate::stable::{
129 artifact_size_bytes, sha256_artifact_path, sha256_hex, validate_output_schema_descriptor,
130 verify_run_dir, write_inputs_index, write_outputs_index, ArtifactError,
131 ArtifactSchemaDescriptor, RunDir, RunDirLayout, SchemaValidationMode,
132 };
133}
134
135#[cfg(feature = "experimental-public-api")]
137pub mod experimental {
138 pub mod run_layout {
139 pub use crate::run_layout_contracts::*;
140 }
141 pub mod lifecycle_and_cache {
142 pub use crate::lifecycle_and_cache_contracts::*;
143 }
144}
145
146#[derive(Debug, thiserror::Error)]
147pub enum ArtifactError {
148 #[error("io error: {0}")]
149 Io(#[from] io::Error),
150 #[error("json error: {0}")]
151 Json(#[from] serde_json::Error),
152 #[error("path violation: {0}")]
153 PathViolation(String),
154 #[error("missing output: {0}")]
155 MissingOutput(String),
156}
157
158#[derive(Debug, Clone)]
159pub struct RunDir {
160 staging_path: PathBuf,
161 final_path: PathBuf,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
165pub struct RunDirLayout {
166 pub run_id: String,
167 pub staging_path: PathBuf,
168 pub final_path: PathBuf,
169}
170
171impl RunDirLayout {
172 pub fn preview(
173 out_base: impl AsRef<Path>,
174 run_id: Option<&str>,
175 ) -> Result<Self, ArtifactError> {
176 let run_id = match run_id {
177 Some(run_id) => normalize_run_id(run_id)?,
178 None => generate_run_id(),
179 };
180 Ok(Self {
181 staging_path: out_base.as_ref().join(format!("run.tmp-{}", run_id)),
182 final_path: out_base.as_ref().join(format!("run-{}", run_id)),
183 run_id,
184 })
185 }
186
187 pub fn node_dir(&self, node_id: &str) -> PathBuf {
188 self.staging_path.join("nodes").join(node_id)
189 }
190
191 pub fn node_outputs_dir(&self, node_id: &str) -> PathBuf {
192 self.node_dir(node_id).join("outputs")
193 }
194
195 pub fn node_inputs_dir(&self, node_id: &str) -> PathBuf {
196 self.node_dir(node_id).join("inputs")
197 }
198
199 pub fn node_work_dir(&self, node_id: &str) -> PathBuf {
200 self.node_dir(node_id).join("work")
201 }
202
203 pub fn node_temp_dir(&self, node_id: &str) -> PathBuf {
204 self.node_work_dir(node_id).join("temp")
205 }
206
207 pub fn stop_request_path(&self) -> PathBuf {
208 self.staging_path.join("run.stop-request.json")
209 }
210}
211
212impl RunDir {
213 pub fn create(out_base: impl AsRef<Path>) -> Result<Self, ArtifactError> {
214 let layout = RunDirLayout::preview(out_base, None)?;
215 Self::create_with_layout(layout)
216 }
217
218 pub fn create_with_id(out_base: impl AsRef<Path>, run_id: &str) -> Result<Self, ArtifactError> {
219 let layout = RunDirLayout::preview(out_base, Some(run_id))?;
220 Self::create_with_layout(layout)
221 }
222
223 pub fn resume_with_id(out_base: impl AsRef<Path>, run_id: &str) -> Result<Self, ArtifactError> {
224 let layout = RunDirLayout::preview(out_base, Some(run_id))?;
225 Self::resume_with_layout(layout)
226 }
227
228 pub fn staging_path(&self) -> &Path {
229 &self.staging_path
230 }
231
232 pub fn final_path(&self) -> &Path {
233 &self.final_path
234 }
235
236 pub fn write_manifest(&self, manifest: &Manifest) -> Result<(), ArtifactError> {
237 let path = self.staging_path.join("manifest.json");
238 write_json_atomic(path, manifest)
239 }
240
241 pub fn write_graph_snapshot(&self, graph_json: &str) -> Result<(), ArtifactError> {
242 let path = self.staging_path.join("graph.snapshot.json");
243 write_bytes_atomic(path, graph_json.as_bytes())
244 }
245
246 pub fn node_dir(&self, node_id: &str) -> PathBuf {
247 self.staging_path.join("nodes").join(node_id)
248 }
249
250 pub fn node_outputs_dir(&self, node_id: &str) -> PathBuf {
251 self.node_dir(node_id).join("outputs")
252 }
253
254 pub fn node_inputs_dir(&self, node_id: &str) -> PathBuf {
255 self.node_dir(node_id).join("inputs")
256 }
257
258 pub fn node_work_dir(&self, node_id: &str) -> PathBuf {
259 self.node_dir(node_id).join("work")
260 }
261
262 pub fn node_temp_dir(&self, node_id: &str) -> PathBuf {
263 self.node_work_dir(node_id).join("temp")
264 }
265
266 pub fn node_stdout_path(&self, node_id: &str) -> PathBuf {
267 self.node_dir(node_id).join("stdout.log")
268 }
269
270 pub fn node_stderr_path(&self, node_id: &str) -> PathBuf {
271 self.node_dir(node_id).join("stderr.log")
272 }
273
274 pub fn node_trace_path(&self, node_id: &str) -> PathBuf {
275 self.node_dir(node_id).join("trace.json")
276 }
277
278 pub fn node_resolved_params_path(&self, node_id: &str) -> PathBuf {
279 self.node_dir(node_id).join("resolved_params.json")
280 }
281
282 pub fn node_attempts_path(&self, node_id: &str) -> PathBuf {
283 self.node_dir(node_id).join("attempts.json")
284 }
285
286 pub fn node_attempt_dir(&self, node_id: &str, attempt: u32) -> PathBuf {
287 self.node_dir(node_id).join("attempts").join(attempt.to_string())
288 }
289
290 pub fn node_attempt_stdout_path(&self, node_id: &str, attempt: u32) -> PathBuf {
291 self.node_attempt_dir(node_id, attempt).join("stdout.log")
292 }
293
294 pub fn node_attempt_stderr_path(&self, node_id: &str, attempt: u32) -> PathBuf {
295 self.node_attempt_dir(node_id, attempt).join("stderr.log")
296 }
297
298 pub fn run_log_path(&self) -> PathBuf {
299 self.staging_path.join("run.log.jsonl")
300 }
301
302 pub fn run_outputs_index_path(&self) -> PathBuf {
303 self.staging_path.join("outputs").join("index.json")
304 }
305
306 pub fn provenance_path(&self) -> PathBuf {
307 self.staging_path.join("provenance.json")
308 }
309
310 pub fn stop_request_path(&self) -> PathBuf {
311 self.staging_path.join("run.stop-request.json")
312 }
313
314 pub fn node_outputs_index_path(&self, node_id: &str) -> PathBuf {
315 self.node_outputs_dir(node_id).join("index.json")
316 }
317
318 pub fn node_output_relpath(&self, node_id: &str, file: &str) -> String {
319 paths::node_output_relpath(node_id, file)
320 }
321
322 pub fn node_inputs_index_path(&self, node_id: &str) -> PathBuf {
323 self.node_inputs_dir(node_id).join("index.json")
324 }
325
326 pub fn finalize(self) -> Result<PathBuf, ArtifactError> {
327 if let Some(parent) = self.final_path.parent() {
328 std_fs::create_dir_all(parent)?;
329 }
330 std_fs::rename(&self.staging_path, &self.final_path)?;
331 Ok(self.final_path)
332 }
333}
334
335impl RunDir {
336 fn create_with_layout(layout: RunDirLayout) -> Result<Self, ArtifactError> {
337 ensure_run_path_absent(&layout.staging_path, "staging run directory")?;
338 ensure_run_path_absent(&layout.final_path, "final run directory")?;
339 std_fs::create_dir_all(layout.staging_path.join("nodes"))?;
340 Ok(Self { staging_path: layout.staging_path, final_path: layout.final_path })
341 }
342
343 fn resume_with_layout(layout: RunDirLayout) -> Result<Self, ArtifactError> {
344 let staging_exists = layout.staging_path.exists();
345 let final_exists = layout.final_path.exists();
346 match (staging_exists, final_exists) {
347 (true, false) => Ok(Self {
348 staging_path: layout.staging_path,
349 final_path: layout.final_path,
350 }),
351 (false, true) => {
352 if let Some(parent) = layout.staging_path.parent() {
353 std_fs::create_dir_all(parent)?;
354 }
355 std_fs::rename(&layout.final_path, &layout.staging_path)?;
356 Ok(Self {
357 staging_path: layout.staging_path,
358 final_path: layout.final_path,
359 })
360 }
361 (false, false) => Err(io::Error::new(
362 io::ErrorKind::NotFound,
363 format!(
364 "resume run directory missing: {}",
365 layout.final_path.display()
366 ),
367 )
368 .into()),
369 (true, true) => Err(io::Error::new(
370 io::ErrorKind::AlreadyExists,
371 format!(
372 "resume run directory is ambiguous because both staging and final paths exist: {} and {}",
373 layout.staging_path.display(),
374 layout.final_path.display()
375 ),
376 )
377 .into()),
378 }
379 }
380}
381
382fn ensure_run_path_absent(path: &Path, label: &str) -> Result<(), ArtifactError> {
383 if path.exists() {
384 return Err(io::Error::new(
385 io::ErrorKind::AlreadyExists,
386 format!("{label} already exists: {}", path.display()),
387 )
388 .into());
389 }
390 Ok(())
391}
392
393fn write_json<T: Serialize>(path: impl AsRef<Path>, value: &T) -> Result<(), ArtifactError> {
394 let data = serde_json::to_vec_pretty(value)?;
395 let mut f = std_fs::File::create(path)?;
396 f.write_all(&data)?;
397 Ok(())
398}
399
400fn write_json_atomic<T: Serialize>(path: impl AsRef<Path>, value: &T) -> Result<(), ArtifactError> {
401 let path = path.as_ref();
402 let tmp = path.with_extension("tmp");
403 write_json(&tmp, value)?;
404 std_fs::rename(tmp, path)?;
405 Ok(())
406}
407
408fn write_bytes_atomic(path: impl AsRef<Path>, bytes: &[u8]) -> Result<(), ArtifactError> {
409 let path = path.as_ref();
410 let tmp = path.with_extension("tmp");
411 let mut file = std_fs::File::create(&tmp)?;
412 file.write_all(bytes)?;
413 file.sync_all()?;
414 std_fs::rename(tmp, path)?;
415 Ok(())
416}
417
418pub fn write_outputs_index(
419 dir: impl AsRef<Path>,
420 node_id: &str,
421 node_fingerprint: &str,
422 declared_outputs: &[DeclaredOutputArtifact],
423) -> Result<(), ArtifactError> {
424 let mut files = Vec::new();
425 for output in declared_outputs {
426 let rel = &output.path;
427 if !paths::is_normalized_relative_path(rel) {
428 return Err(ArtifactError::PathViolation(format!(
429 "output path must be normalized relative path: {rel}"
430 )));
431 }
432 let path = dir.as_ref().join(rel);
433 if !path.exists() {
434 return Err(ArtifactError::MissingOutput(rel.clone()));
435 }
436 let size_bytes = artifact_size_bytes(&path)?;
437 let sha = sha256_artifact_path(&path)?;
438 files.push(OutputFile {
439 name: output.name.clone(),
440 path: rel.clone(),
441 kind: output.kind.clone(),
442 media_type: output.media_type.clone(),
443 size_bytes,
444 sha256: sha,
445 node_id: node_id.to_string(),
446 node_fingerprint: node_fingerprint.to_string(),
447 promotable: output.promotable,
448 });
449 }
450 files.sort_by(|a, b| a.path.cmp(&b.path));
451 let index = OutputsIndex { files };
452 write_json(dir.as_ref().join("index.json"), &index)
453}
454
455pub fn sha256_artifact_path(path: impl AsRef<Path>) -> Result<String, ArtifactError> {
456 sha256_artifact_path_inner(path.as_ref(), path.as_ref())
457}
458
459pub fn artifact_size_bytes(path: impl AsRef<Path>) -> Result<u64, ArtifactError> {
460 artifact_size_bytes_inner(path.as_ref())
461}
462
463fn sha256_artifact_path_inner(root: &Path, path: &Path) -> Result<String, ArtifactError> {
464 let metadata = std_fs::symlink_metadata(path)?;
465 if metadata.file_type().is_symlink() {
466 return Err(ArtifactError::PathViolation(format!(
467 "artifact path must not be a symlink: {}",
468 path.display()
469 )));
470 }
471 if metadata.is_file() {
472 return Ok(sha256_bytes(&std_fs::read(path)?));
473 }
474 if metadata.is_dir() {
475 let mut entries = Vec::new();
476 for entry in std_fs::read_dir(path)? {
477 let entry = entry?;
478 entries.push(entry.path());
479 }
480 entries.sort();
481 let mut payload = Vec::new();
482 for child in entries {
483 let relative = child
484 .strip_prefix(root)
485 .map_err(|_| {
486 ArtifactError::PathViolation("artifact path escaped root".to_string())
487 })?
488 .to_string_lossy()
489 .replace('\\', "/");
490 payload.extend_from_slice(relative.as_bytes());
491 payload.push(b'\n');
492 let child_hash = sha256_artifact_path_inner(root, &child)?;
493 payload.extend_from_slice(child_hash.as_bytes());
494 payload.push(b'\n');
495 }
496 return Ok(sha256_bytes(&payload));
497 }
498 Err(ArtifactError::PathViolation(format!(
499 "artifact path must be a regular file or directory: {}",
500 path.display()
501 )))
502}
503
504fn artifact_size_bytes_inner(path: &Path) -> Result<u64, ArtifactError> {
505 let metadata = std_fs::symlink_metadata(path)?;
506 if metadata.file_type().is_symlink() {
507 return Err(ArtifactError::PathViolation(format!(
508 "artifact path must not be a symlink: {}",
509 path.display()
510 )));
511 }
512 if metadata.is_file() {
513 return Ok(metadata.len());
514 }
515 if metadata.is_dir() {
516 let mut total = 0u64;
517 for entry in std_fs::read_dir(path)? {
518 let child = entry?.path();
519 total = total.checked_add(artifact_size_bytes_inner(&child)?).ok_or_else(|| {
520 ArtifactError::PathViolation(format!(
521 "artifact size exceeded u64 accounting: {}",
522 path.display()
523 ))
524 })?;
525 }
526 return Ok(total);
527 }
528 Err(ArtifactError::PathViolation(format!(
529 "artifact path must be a regular file or directory: {}",
530 path.display()
531 )))
532}
533
534pub fn write_run_outputs_index(
535 dir: impl AsRef<Path>,
536 index: &RunOutputsIndex,
537) -> Result<(), ArtifactError> {
538 let dir = dir.as_ref();
539 std_fs::create_dir_all(dir)?;
540 write_json_atomic(dir.join("index.json"), index)
541}
542
543pub fn write_provenance(path: impl AsRef<Path>, prov: &Provenance) -> Result<(), ArtifactError> {
544 write_json_atomic(path, prov)
545}
546
547pub fn write_run_schema_index(
548 path: impl AsRef<Path>,
549 schema: &RunDirSchemaIndex,
550) -> Result<(), ArtifactError> {
551 write_json_atomic(path, schema)
552}
553
554pub fn write_inputs_index(dir: impl AsRef<Path>, index: &InputsIndex) -> Result<(), ArtifactError> {
555 write_json_atomic(dir.as_ref().join("index.json"), index)
556}
557
558pub fn now_unix_ms() -> u128 {
559 SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis()
560}
561
562pub fn build_artifact_identity(
563 run_id: &str,
564 node_id: &str,
565 output_path: &str,
566 node_fingerprint: &str,
567 artifact_sha256: &str,
568) -> ArtifactIdentity {
569 let output_name = Path::new(output_path)
570 .file_name()
571 .and_then(|value| value.to_str())
572 .unwrap_or(output_path)
573 .to_string();
574 let legacy_artifact_id = format!("{node_id}:{output_name}");
575 let canonical_artifact_id =
576 format!("run={run_id};node={node_id};path={output_path};sha256={artifact_sha256}");
577 ArtifactIdentity {
578 canonical_artifact_id,
579 legacy_artifact_id,
580 run_id: run_id.to_string(),
581 node_id: node_id.to_string(),
582 output_name,
583 output_path: output_path.to_string(),
584 node_fingerprint: node_fingerprint.to_string(),
585 artifact_sha256: artifact_sha256.to_string(),
586 }
587}
588
589fn generate_run_id() -> String {
590 static RUN_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
591 let seq = RUN_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
592 format!("{}-{}-{:06}", now_unix_ms(), std::process::id(), seq % 1_000_000)
593}
594
595fn normalize_run_id(run_id: &str) -> Result<String, ArtifactError> {
596 let trimmed = run_id.trim();
597 if trimmed.is_empty() {
598 return Err(ArtifactError::PathViolation("run id must not be empty".to_string()));
599 }
600 let normalized = trimmed.strip_prefix("run-").unwrap_or(trimmed);
601 if normalized.is_empty()
602 || normalized.contains('/')
603 || normalized.contains('\\')
604 || normalized.contains("..")
605 || !normalized.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
606 {
607 return Err(ArtifactError::PathViolation(format!("invalid run id: {run_id}")));
608 }
609 Ok(normalized.to_string())
610}
611
612fn sha256_bytes(bytes: &[u8]) -> String {
613 hash::sha256_hex(bytes)
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn create_and_finalize() {
622 let dir = tempfile::tempdir().unwrap();
623 let run = RunDir::create(dir.path()).unwrap();
624 assert!(run.staging_path().exists());
625 let final_path = run.finalize().unwrap();
626 assert!(final_path.exists());
627 }
628
629 #[test]
630 fn generated_run_ids_do_not_collide_within_process() {
631 let first = generate_run_id();
632 let second = generate_run_id();
633 assert_ne!(first, second);
634 assert!(first.contains('-'));
635 assert!(second.contains('-'));
636 }
637
638 #[test]
639 fn graph_snapshot_writes_atomically() {
640 let dir = tempfile::tempdir().unwrap();
641 let run = RunDir::create(dir.path()).unwrap();
642 run.write_graph_snapshot("{\"graph\":\"first\"}").unwrap();
643 run.write_graph_snapshot("{\"graph\":\"second\"}").unwrap();
644 let snapshot =
645 std_fs::read_to_string(run.staging_path().join("graph.snapshot.json")).unwrap();
646 assert_eq!(snapshot, "{\"graph\":\"second\"}");
647 assert!(!run.staging_path().join("graph.snapshot.tmp").exists());
648 }
649
650 #[test]
651 fn explicit_run_ids_are_normalized_and_validated() {
652 let dir = tempfile::tempdir().unwrap();
653 let run = RunDir::create_with_id(dir.path(), "run-2026_04").unwrap();
654 assert!(run.final_path().ends_with("run-2026_04"));
655
656 let err = RunDir::create_with_id(dir.path(), "../escape").unwrap_err();
657 assert!(err.to_string().contains("invalid run id"));
658 }
659
660 #[test]
661 fn run_dir_layout_previews_paths_without_materializing_directories() {
662 let dir = tempfile::tempdir().unwrap();
663 let layout = RunDirLayout::preview(dir.path(), Some("path-preview")).unwrap();
664 assert_eq!(layout.run_id, "path-preview");
665 assert!(layout.staging_path.ends_with("run.tmp-path-preview"));
666 assert!(layout.final_path.ends_with("run-path-preview"));
667 assert_eq!(
668 layout.node_outputs_dir("align"),
669 dir.path().join("run.tmp-path-preview").join("nodes").join("align").join("outputs")
670 );
671 assert_eq!(
672 layout.node_temp_dir("align"),
673 dir.path()
674 .join("run.tmp-path-preview")
675 .join("nodes")
676 .join("align")
677 .join("work")
678 .join("temp")
679 );
680 assert!(!layout.staging_path.exists());
681 }
682
683 #[test]
684 fn run_dir_layout_exposes_stop_request_path() {
685 let dir = tempfile::tempdir().unwrap();
686 let layout = RunDirLayout::preview(dir.path(), Some("run-stop")).unwrap();
687 assert_eq!(
688 layout.stop_request_path(),
689 dir.path().join("run.tmp-stop").join("run.stop-request.json")
690 );
691 }
692
693 #[test]
694 fn run_dir_creation_rejects_existing_paths() {
695 let dir = tempfile::tempdir().unwrap();
696 let staging = dir.path().join("run.tmp-fixed");
697 std_fs::create_dir_all(staging.join("nodes")).unwrap();
698 let err = RunDir::create_with_id(dir.path(), "fixed").unwrap_err();
699 assert!(err.to_string().contains("staging run directory already exists"));
700
701 std_fs::remove_dir_all(&staging).unwrap();
702 std_fs::create_dir_all(dir.path().join("run-fixed")).unwrap();
703 let err = RunDir::create_with_id(dir.path(), "fixed").unwrap_err();
704 assert!(err.to_string().contains("final run directory already exists"));
705 }
706
707 #[test]
708 fn run_dir_resume_moves_final_path_back_to_staging() {
709 let dir = tempfile::tempdir().unwrap();
710 let final_path = dir.path().join("run-resume-ready");
711 std_fs::create_dir_all(final_path.join("nodes")).unwrap();
712
713 let run_dir = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap();
714
715 assert_eq!(run_dir.staging_path(), dir.path().join("run.tmp-resume-ready").as_path());
716 assert_eq!(run_dir.final_path(), dir.path().join("run-resume-ready").as_path());
717 assert!(run_dir.staging_path().exists());
718 assert!(!run_dir.final_path().exists());
719 }
720
721 #[test]
722 fn run_dir_resume_reuses_existing_staging_path() {
723 let dir = tempfile::tempdir().unwrap();
724 let staging_path = dir.path().join("run.tmp-resume-ready");
725 std_fs::create_dir_all(staging_path.join("nodes")).unwrap();
726
727 let run_dir = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap();
728
729 assert_eq!(run_dir.staging_path(), staging_path.as_path());
730 assert!(!run_dir.final_path().exists());
731 }
732
733 #[test]
734 fn run_dir_resume_rejects_missing_or_ambiguous_paths() {
735 let dir = tempfile::tempdir().unwrap();
736 let missing = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap_err();
737 assert!(missing.to_string().contains("resume run directory missing"));
738
739 let staging_path = dir.path().join("run.tmp-resume-ready");
740 let final_path = dir.path().join("run-resume-ready");
741 std_fs::create_dir_all(staging_path.join("nodes")).unwrap();
742 std_fs::create_dir_all(final_path.join("nodes")).unwrap();
743
744 let ambiguous = RunDir::resume_with_id(dir.path(), "resume-ready").unwrap_err();
745 assert!(ambiguous.to_string().contains("resume run directory is ambiguous"));
746 }
747
748 #[test]
749 fn provenance_and_indexes_replace_atomically() {
750 let dir = tempfile::tempdir().unwrap();
751 let run = RunDir::create(dir.path()).unwrap();
752 let outputs_dir = run.staging_path().join("outputs");
753 let inputs_dir = run.node_inputs_dir("node");
754 let provenance = Provenance {
755 os: "linux".to_string(),
756 arch: "x86_64".to_string(),
757 rustc: "rustc".to_string(),
758 tool_version: "0.1.0".to_string(),
759 planner_contract_version: Some("bijux-dag-planner/v1".to_string()),
760 graph_fingerprint: None,
761 planner_fingerprint: None,
762 execution_fingerprint: None,
763 evidence_fingerprint: None,
764 runtime_fingerprint: None,
765 policy_fingerprint: None,
766 adapters: Vec::new(),
767 policy: PolicyInfo {
768 deny_network: true,
769 deny_env: true,
770 deny_clock: true,
771 clean_env: true,
772 container_image_reference_policy: ContainerImageReferencePolicy::RequireDigest,
773 },
774 time_source: "system_clock".to_string(),
775 };
776 let run_outputs = RunOutputsIndex { files: Vec::new() };
777 let inputs = InputsIndex { collections: Vec::new(), files: Vec::new() };
778
779 write_provenance(run.provenance_path(), &provenance).unwrap();
780 write_run_outputs_index(&outputs_dir, &run_outputs).unwrap();
781 std_fs::create_dir_all(&inputs_dir).unwrap();
782 write_inputs_index(&inputs_dir, &inputs).unwrap();
783
784 assert!(!run.staging_path().join("provenance.tmp").exists());
785 assert!(!outputs_dir.join("index.tmp").exists());
786 assert!(!inputs_dir.join("index.tmp").exists());
787 }
788}