1use std::fs;
17use std::path::{Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use anyhow::{bail, ensure, Context, Result};
21use serde::{Deserialize, Serialize};
22
23use crate::artifact::{publish_bundle, verify_bundle, BundleVerificationReceipt};
24use crate::instrument::{ProfileRun, TraceSession};
25use crate::trace::{parse_trace, TraceRunMeta};
26
27pub const PUBLICATION_SCHEMA: &str = "candle-graph/publication/1";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32pub enum PublicationStatus {
33 Published,
34 AlreadyPublished,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct PublicationReceipt {
40 pub schema: String,
41 pub status: PublicationStatus,
42 pub bundle_path: PathBuf,
43 pub run_id: String,
44 pub verification: BundleVerificationReceipt,
45}
46
47#[allow(clippy::large_enum_variant)]
50pub enum CaptureBegin {
51 AlreadyPublished(Box<PublicationReceipt>),
52 Active(CaptureRun),
53}
54
55impl std::fmt::Debug for CaptureBegin {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 Self::AlreadyPublished(receipt) => {
59 f.debug_tuple("AlreadyPublished").field(receipt).finish()
60 }
61 Self::Active(run) => f
62 .debug_struct("Active")
63 .field("destination", &run.destination)
64 .field("staging_trace", &run.staging_trace)
65 .finish(),
66 }
67 }
68}
69
70pub struct CaptureRun {
72 session: Option<TraceSession>,
73 run: ProfileRun,
74 destination: PathBuf,
75 staging_trace: PathBuf,
76 nsight_dir: Option<PathBuf>,
77}
78
79impl CaptureRun {
80 pub fn begin(destination: impl Into<PathBuf>, run: ProfileRun) -> Result<CaptureBegin> {
83 let destination = destination.into();
84 for ancestor in destination.ancestors().skip(1) {
85 ensure!(
86 !ancestor.join("bundle.json").is_file(),
87 "refusing to publish bundle {} inside existing bundle {}",
88 destination.display(),
89 ancestor.display()
90 );
91 }
92 if let Some(receipt) = reconcile_published_bundle(&destination, &run)? {
93 return Ok(CaptureBegin::AlreadyPublished(Box::new(receipt)));
94 }
95 let parent = destination
96 .parent()
97 .filter(|parent| !parent.as_os_str().is_empty())
98 .unwrap_or_else(|| Path::new("."));
99 fs::create_dir_all(parent)
100 .with_context(|| format!("create bundle parent {}", parent.display()))?;
101 let name = destination
102 .file_name()
103 .and_then(|name| name.to_str())
104 .context("bundle destination needs a file name")?;
105 let nonce = SystemTime::now()
106 .duration_since(UNIX_EPOCH)
107 .unwrap_or_default()
108 .as_nanos();
109 let staging_trace = parent.join(format!(
110 ".{name}.trace-{}-{nonce}.jsonl",
111 std::process::id()
112 ));
113 let session = TraceSession::open(&staging_trace, run.clone())?;
114 Ok(CaptureBegin::Active(Self {
115 session: Some(session),
116 run,
117 destination,
118 staging_trace,
119 nsight_dir: None,
120 }))
121 }
122
123 pub fn session(&self) -> &TraceSession {
125 self.session
126 .as_ref()
127 .expect("an active capture run owns its trace session")
128 }
129
130 pub fn with_nsight_dir(mut self, nsight_dir: impl Into<PathBuf>) -> Self {
132 self.nsight_dir = Some(nsight_dir.into());
133 self
134 }
135
136 pub fn destination(&self) -> &Path {
137 &self.destination
138 }
139
140 pub fn staging_trace(&self) -> &Path {
143 &self.staging_trace
144 }
145
146 pub fn publish(mut self) -> Result<PublicationReceipt> {
148 let session = self
149 .session
150 .take()
151 .expect("an active capture run owns its trace session");
152 let trace = session.finish()?;
153 self.finalize(&trace)
154 }
155
156 pub fn publish_failed(mut self, reason: impl Into<String>) -> Result<PublicationReceipt> {
159 let session = self
160 .session
161 .take()
162 .expect("an active capture run owns its trace session");
163 let trace = session.finish_failed(reason)?;
164 self.finalize(&trace)
165 }
166
167 fn finalize(&mut self, trace: &Path) -> Result<PublicationReceipt> {
168 if let Some(receipt) = reconcile_published_bundle(&self.destination, &self.run)? {
170 let _ = fs::remove_file(trace);
171 return Ok(receipt);
172 }
173 publish_bundle(&self.destination, trace, self.nsight_dir.as_deref()).with_context(
174 || {
175 format!(
176 "publish capture bundle {} (staged trace retained at {})",
177 self.destination.display(),
178 trace.display()
179 )
180 },
181 )?;
182 let verification = verify_bundle(&self.destination).with_context(|| {
183 format!(
184 "verify published capture bundle {}",
185 self.destination.display()
186 )
187 })?;
188 let _ = fs::remove_file(trace);
189 Ok(PublicationReceipt {
190 schema: PUBLICATION_SCHEMA.into(),
191 status: PublicationStatus::Published,
192 bundle_path: self.destination.clone(),
193 run_id: verification.run_id.clone(),
194 verification,
195 })
196 }
197}
198
199pub fn reconcile_published_bundle(
207 destination: &Path,
208 run: &ProfileRun,
209) -> Result<Option<PublicationReceipt>> {
210 if !destination.exists() {
211 return Ok(None);
212 }
213 let verification = verify_bundle(destination).with_context(|| {
214 format!(
215 "existing destination {} is not a verifiable evidence bundle; refusing to reuse or overwrite it",
216 destination.display()
217 )
218 })?;
219 let trace_path = destination.join("trace.jsonl");
220 let document = parse_trace(&trace_path)
221 .with_context(|| format!("parse published bundle trace {}", trace_path.display()))?;
222 ensure!(
223 verification.run_id == document.run.run_id,
224 "published bundle manifest run ID {:?} does not match its trace run ID {:?}",
225 verification.run_id,
226 document.run.run_id
227 );
228 ensure_same_planned_capture(&document.run, run, destination)?;
229 Ok(Some(PublicationReceipt {
230 schema: PUBLICATION_SCHEMA.into(),
231 status: PublicationStatus::AlreadyPublished,
232 bundle_path: destination.to_path_buf(),
233 run_id: verification.run_id.clone(),
234 verification,
235 }))
236}
237
238fn ensure_same_planned_capture(
239 published: &TraceRunMeta,
240 run: &ProfileRun,
241 destination: &Path,
242) -> Result<()> {
243 let mismatches = [
244 ("entrypoint", published.entrypoint != run.entrypoint),
245 (
246 "correlation_id",
247 published.correlation_id != run.correlation_id,
248 ),
249 ("phase", published.phase != run.phase),
250 ("capture_step", published.capture_step != run.capture_step),
251 ("device", published.device != run.device),
252 ]
253 .into_iter()
254 .filter_map(|(field, differs)| differs.then_some(field))
255 .collect::<Vec<_>>();
256 if !mismatches.is_empty() {
257 bail!(
258 "bundle destination {} already holds a different planned capture (conflicting {})",
259 destination.display(),
260 mismatches.join(", ")
261 );
262 }
263 Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269 use crate::cli::trace_cli::load_evidence;
270 use crate::trace::RunOutcome;
271
272 fn temp_root(name: &str) -> PathBuf {
273 let nonce = SystemTime::now()
274 .duration_since(UNIX_EPOCH)
275 .unwrap()
276 .as_nanos();
277 let root = std::env::temp_dir().join(format!(
278 "candle-graph-publication-{name}-{}-{nonce}",
279 std::process::id()
280 ));
281 fs::create_dir_all(&root).unwrap();
282 root
283 }
284
285 fn active(begin: CaptureBegin) -> CaptureRun {
286 match begin {
287 CaptureBegin::Active(run) => run,
288 CaptureBegin::AlreadyPublished(receipt) => {
289 panic!("expected an active capture run, got {:?}", receipt.status)
290 }
291 }
292 }
293
294 #[test]
295 fn capture_run_publishes_verified_bundle_and_cleans_staging() {
296 let root = temp_root("publish");
297 let destination = root.join("profiles/update-2");
298 let run = ProfileRun::training("train::update", 2, "cpu");
299 let capture = active(CaptureRun::begin(&destination, run.clone()).unwrap());
300 let staging = capture.staging_trace().to_path_buf();
301 {
302 let session = capture.session();
303 let measured = session.begin_measurement("update");
304 session
305 .record_scalar(measured.id(), "loss/total", 0.75)
306 .unwrap();
307 }
308 let receipt = capture.publish().unwrap();
309 assert_eq!(receipt.status, PublicationStatus::Published);
310 assert_eq!(receipt.schema, PUBLICATION_SCHEMA);
311 assert_eq!(receipt.run_id, receipt.verification.run_id);
312 assert!(destination.join("bundle.json").is_file());
313 assert!(!staging.exists(), "staging trace must be removed");
314
315 let evidence = load_evidence(&destination).unwrap();
316 assert_eq!(evidence.provenance.run_id, receipt.run_id);
317 assert_eq!(evidence.tensor_stats.len(), 1);
318
319 let retry = CaptureRun::begin(&destination, run).unwrap();
321 match retry {
322 CaptureBegin::AlreadyPublished(reconciled) => {
323 assert_eq!(reconciled.status, PublicationStatus::AlreadyPublished);
324 assert_eq!(reconciled.run_id, receipt.run_id);
325 assert_eq!(
326 reconciled.verification.manifest_sha256,
327 receipt.verification.manifest_sha256
328 );
329 }
330 CaptureBegin::Active(_) => panic!("expected reconciliation"),
331 }
332
333 let conflict = CaptureRun::begin(
335 &destination,
336 ProfileRun::training("train::update", 3, "cpu"),
337 )
338 .unwrap_err();
339 assert!(conflict.to_string().contains("conflicting"));
340 assert!(conflict.to_string().contains("capture_step"));
341
342 fs::remove_dir_all(root).unwrap();
343 }
344
345 #[test]
346 fn non_bundle_destination_fails_closed() {
347 let root = temp_root("conflict");
348 let destination = root.join("profiles/update-2");
349 fs::create_dir_all(&destination).unwrap();
350 fs::write(destination.join("application.jsonl"), b"not a bundle").unwrap();
351 let error = CaptureRun::begin(
352 &destination,
353 ProfileRun::training("train::update", 2, "cpu"),
354 )
355 .unwrap_err();
356 assert!(error
357 .to_string()
358 .contains("not a verifiable evidence bundle"));
359 fs::remove_dir_all(root).unwrap();
360 }
361
362 #[test]
363 fn failed_captures_publish_diagnosable_verified_bundles() {
364 let root = temp_root("failed");
365 let destination = root.join("update-7");
366 let run = ProfileRun::training("train::update", 7, "cpu");
367 let capture = active(CaptureRun::begin(&destination, run.clone()).unwrap());
368 let receipt = capture.publish_failed("loss became non-finite").unwrap();
369 assert_eq!(receipt.status, PublicationStatus::Published);
370 verify_bundle(&destination).unwrap();
371 let document = parse_trace(destination.join("trace.jsonl")).unwrap();
372 assert_eq!(document.terminal.outcome, RunOutcome::Failed);
373 assert_eq!(
374 document.terminal.reason.as_deref(),
375 Some("loss became non-finite")
376 );
377
378 match CaptureRun::begin(&destination, run).unwrap() {
380 CaptureBegin::AlreadyPublished(reconciled) => {
381 assert_eq!(reconciled.run_id, receipt.run_id);
382 }
383 CaptureBegin::Active(_) => panic!("expected reconciliation"),
384 }
385 fs::remove_dir_all(root).unwrap();
386 }
387
388 #[test]
389 fn nested_bundle_destinations_are_rejected() {
390 let root = temp_root("nested");
391 let outer = root.join("outer");
392 let capture = active(
393 CaptureRun::begin(&outer, ProfileRun::training("train::update", 1, "cpu")).unwrap(),
394 );
395 drop(capture.session().begin_measurement("update"));
396 capture.publish().unwrap();
397 let error = CaptureRun::begin(
398 outer.join("inner"),
399 ProfileRun::training("train::update", 2, "cpu"),
400 )
401 .unwrap_err();
402 assert!(error.to_string().contains("inside existing bundle"));
403 fs::remove_dir_all(root).unwrap();
404 }
405}