返回 CodeWhale
registry.rs
根目录 / crates / lane / src / registry.rs
1 //! Durable lane registry under `$CODEWHALE_HOME/lanes/`.
2
3 use std::fs::{self, OpenOptions};
4 use std::path::{Path, PathBuf};
5
6 use anyhow::{Context, Result, bail};
7 use chrono::{SecondsFormat, Utc};
8 use serde::{Deserialize, Serialize};
9
10 use crate::runtime::RuntimeBackendKind;
11
12 const LANES_SUBDIR: &str = "lanes";
13 const LOGS_SUBDIR: &str = "logs";
14
15 /// Lifecycle status for a running workflow instance.
16 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17 #[serde(rename_all = "snake_case")]
18 pub enum LaneStatus {
19 Pending,
20 Running,
21 Stopped,
22 Failed,
23 Completed,
24 }
25
26 impl LaneStatus {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Self::Pending => "pending",
30 Self::Running => "running",
31 Self::Stopped => "stopped",
32 Self::Failed => "failed",
33 Self::Completed => "completed",
34 }
35 }
36
37 pub fn is_active(self) -> bool {
38 matches!(self, Self::Pending | Self::Running)
39 }
40 }
41
42 /// Result of an attempted terminal transition.
43 ///
44 /// The caller needs all three cases distinguished to report a truthful
45 /// receipt: "I stopped it", "it was already terminal", and "it moved on since
46 /// you read it, so I refused". Collapsing them into a bool made a concurrent
47 /// stop by another process indistinguishable from our own transition.
48 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
49 pub enum TerminalTransition {
50 /// This call performed the active -> terminal transition.
51 Transitioned,
52 /// The record was already terminal; nothing changed.
53 AlreadyTerminal,
54 /// The caller pinned a lifecycle generation and the record has moved on.
55 /// Nothing was changed and no backend teardown ran.
56 FenceMismatch { observed: u64 },
57 }
58
59 impl TerminalTransition {
60 #[must_use]
61 pub const fn transitioned(self) -> bool {
62 matches!(self, Self::Transitioned)
63 }
64 }
65
66 /// One lane record: a running (or completed) workflow instance.
67 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68 pub struct LaneRecord {
69 pub id: String,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub workflow: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub fleet: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub issue: Option<String>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub goal: Option<String>,
78 pub runtime: RuntimeBackendKind,
79 pub status: LaneStatus,
80 /// Monotonic durable lifecycle sequence used by Work Graph reconciliation.
81 #[serde(default)]
82 pub lifecycle_seq: u64,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub worktree_path: Option<PathBuf>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub branch: Option<String>,
87 /// tmux session name when `runtime == tmux`.
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub tmux_session: Option<String>,
90 /// Explicit tmux server socket used for this Lane. Pinning the socket keeps
91 /// start/attach/stop/reconcile in the same server namespace even when
92 /// `TMUX_TMPDIR` or the caller environment changes between commands.
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub tmux_socket: Option<PathBuf>,
95 /// Absolute path to the stream-json / NDJSON journal for this lane.
96 pub log_path: PathBuf,
97 pub started_at: String,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub stopped_at: Option<String>,
100 /// Optional human-readable attach target (e.g. `tmux attach -t …`).
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub attach_target: Option<String>,
103 /// Worktree cleanup TTL in seconds (None = no auto-cleanup).
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub worktree_ttl_secs: Option<u64>,
106 }
107
108 impl LaneRecord {
109 pub fn new_id() -> String {
110 let short = uuid::Uuid::new_v4().to_string();
111 format!("lane-{}", &short[..8])
112 }
113
114 pub fn now_rfc3339() -> String {
115 Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
116 }
117 }
118
119 /// Registry root: `$CODEWHALE_HOME/lanes`.
120 pub fn lanes_dir() -> Result<PathBuf> {
121 codewhale_config::ensure_state_dir(LANES_SUBDIR)
122 }
123
124 /// Where the Lane registry *would* live, without creating it.
125 ///
126 /// [`lanes_dir`] creates the directory as a side effect, which makes it
127 /// useless for answering "is there a durable Lane registry?" — a read-only
128 /// status surface must not conjure the store it is reporting on. Availability
129 /// probing goes through this instead (see [`crate::control::ControlContext`]).
130 pub fn lane_registry_root() -> Result<PathBuf> {
131 Ok(codewhale_config::codewhale_home()?.join(LANES_SUBDIR))
132 }
133
134 /// A lane id names files under the registry root, so it must be a plain
135 /// name: ASCII letters, digits, `-` and `_` only.
136 fn checked_lane_id(id: &str) -> Result<()> {
137 let plain = !id.is_empty()
138 && id.len() <= 128
139 && id
140 .bytes()
141 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
142 anyhow::ensure!(plain, "invalid lane id {id:?}");
143 Ok(())
144 }
145
146 /// Persist and load lane records.
147 #[derive(Debug, Clone)]
148 pub struct LaneRegistry {
149 root: PathBuf,
150 }
151
152 impl LaneRegistry {
153 /// Open the default registry under `$CODEWHALE_HOME/lanes`.
154 pub fn open_default() -> Result<Self> {
155 Self::open(lanes_dir()?)
156 }
157
158 /// Open a registry at an explicit root (tests / custom homes).
159 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
160 let root = root.into();
161 fs::create_dir_all(&root)
162 .with_context(|| format!("create lane registry {}", root.display()))?;
163 fs::create_dir_all(root.join(LOGS_SUBDIR))
164 .with_context(|| format!("create lane logs under {}", root.display()))?;
165 Ok(Self { root })
166 }
167
168 pub fn root(&self) -> &Path {
169 &self.root
170 }
171
172 pub fn logs_dir(&self) -> PathBuf {
173 self.root.join(LOGS_SUBDIR)
174 }
175
176 pub fn record_path(&self, id: &str) -> PathBuf {
177 self.root.join(format!("{id}.json"))
178 }
179
180 pub fn log_path_for(&self, id: &str) -> PathBuf {
181 self.logs_dir().join(format!("{id}.ndjson"))
182 }
183
184 pub fn save(&self, record: &LaneRecord) -> Result<()> {
185 checked_lane_id(&record.id)?;
186 let path = self.record_path(&record.id);
187 let json = serde_json::to_string_pretty(record).context("serialize lane record")?;
188 // A fresh, exclusively created temporary: a predictable name could be
189 // pre-placed as a link and written through.
190 let tmp = self.root.join(format!(
191 ".{}.{}.{}.tmp",
192 record.id,
193 std::process::id(),
194 uuid::Uuid::new_v4().simple()
195 ));
196 let written = (|| -> std::io::Result<()> {
197 use std::io::Write as _;
198 let mut file = fs::OpenOptions::new()
199 .write(true)
200 .create_new(true)
201 .open(&tmp)?;
202 file.write_all(json.as_bytes())?;
203 file.sync_all()
204 })();
205 if let Err(error) = written {
206 let _ = fs::remove_file(&tmp);
207 return Err(error).with_context(|| format!("write {}", tmp.display()));
208 }
209 if let Err(error) = fs::rename(&tmp, &path) {
210 let _ = fs::remove_file(&tmp);
211 return Err(error).with_context(|| format!("rename {}", path.display()));
212 }
213 Ok(())
214 }
215
216 pub fn load(&self, id: &str) -> Result<LaneRecord> {
217 checked_lane_id(id)?;
218 let path = self.record_path(id);
219 let text = fs::read_to_string(&path)
220 .with_context(|| format!("read lane record {}", path.display()))?;
221 serde_json::from_str(&text).with_context(|| format!("parse lane record {}", path.display()))
222 }
223
224 pub fn list(&self) -> Result<Vec<LaneRecord>> {
225 let mut records = Vec::new();
226 for entry in fs::read_dir(&self.root)
227 .with_context(|| format!("read lane registry {}", self.root.display()))?
228 {
229 let entry = entry?;
230 let path = entry.path();
231 if path.extension().and_then(|e| e.to_str()) != Some("json") {
232 continue;
233 }
234 let text =
235 fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
236 match serde_json::from_str::<LaneRecord>(&text) {
237 Ok(record) => records.push(record),
238 Err(err) => {
239 // Skip corrupt records rather than failing the whole list.
240 eprintln!(
241 "warning: skip corrupt lane record {}: {err}",
242 path.display()
243 );
244 }
245 }
246 }
247 records.sort_by(|a, b| b.started_at.cmp(&a.started_at));
248 Ok(records)
249 }
250
251 /// Create a pending lane with log file reserved.
252 pub fn create_pending(
253 &self,
254 workflow: Option<String>,
255 fleet: Option<String>,
256 issue: Option<String>,
257 goal: Option<String>,
258 runtime: RuntimeBackendKind,
259 worktree_ttl_secs: Option<u64>,
260 ) -> Result<LaneRecord> {
261 let id = LaneRecord::new_id();
262 let log_path = self.log_path_for(&id);
263 // Touch the log so `lane logs` works immediately.
264 fs::write(&log_path, "").with_context(|| format!("create log {}", log_path.display()))?;
265 let record = LaneRecord {
266 id,
267 workflow,
268 fleet,
269 issue,
270 goal,
271 runtime,
272 status: LaneStatus::Pending,
273 lifecycle_seq: 1,
274 worktree_path: None,
275 branch: None,
276 tmux_session: None,
277 tmux_socket: None,
278 log_path,
279 started_at: LaneRecord::now_rfc3339(),
280 stopped_at: None,
281 attach_target: None,
282 worktree_ttl_secs,
283 };
284 self.save(&record)?;
285 Ok(record)
286 }
287
288 /// Atomically promote a Pending Lane to Running.
289 ///
290 /// A concurrent `lane stop` is allowed to win while a backend is still
291 /// launching. In that case this returns `false`, reloads the terminal
292 /// record, and the backend must tear down any process it just created.
293 pub fn mark_running_if_pending(&self, record: &mut LaneRecord) -> Result<bool> {
294 self.mark_running_if_pending_with(record, || Ok(()), || Ok(()))
295 }
296
297 /// Atomically launch backend state and promote a Pending Lane to Running.
298 ///
299 /// The per-Lane lock spans the durable Pending check, `before_transition`,
300 /// and the Running save. A concurrent stop therefore either wins before
301 /// launch (and this returns `false` without calling the closure) or waits
302 /// until the Running record is visible. Backend metadata is first saved in
303 /// the Pending record so a failed final save still leaves enough data for
304 /// a later stop. `rollback` is attempted if that final save fails.
305 pub fn mark_running_if_pending_with<Start, Rollback>(
306 &self,
307 record: &mut LaneRecord,
308 before_transition: Start,
309 rollback: Rollback,
310 ) -> Result<bool>
311 where
312 Start: FnOnce() -> Result<()>,
313 Rollback: FnOnce() -> Result<()>,
314 {
315 let lock_path = self.root.join(format!("{}.lock", record.id));
316 let lock_file = OpenOptions::new()
317 .create(true)
318 .truncate(false)
319 .read(true)
320 .write(true)
321 .open(&lock_path)
322 .with_context(|| format!("open lane lock {}", lock_path.display()))?;
323 let mut lock = fd_lock::RwLock::new(lock_file);
324 let _guard = lock
325 .write()
326 .with_context(|| format!("lock lane record {}", record.id))?;
327
328 let current = self.load(&record.id)?;
329 if current.status != LaneStatus::Pending {
330 *record = current;
331 return Ok(false);
332 }
333 record.lifecycle_seq = current.lifecycle_seq.max(1);
334
335 // `record` carries backend metadata (tmux session, worktree, attach
336 // target) populated during launch. Persist it while still Pending so
337 // a failed launch/final save remains discoverable and stoppable.
338 record.status = LaneStatus::Pending;
339 record.stopped_at = None;
340 self.save(record)?;
341
342 before_transition()?;
343 record.status = LaneStatus::Running;
344 record.lifecycle_seq = record.lifecycle_seq.saturating_add(1);
345 record.stopped_at = None;
346 if let Err(save_error) = self.save(record) {
347 record.status = LaneStatus::Pending;
348 record.lifecycle_seq = current.lifecycle_seq.max(1);
349 if let Err(rollback_error) = rollback() {
350 return Err(save_error).context(format!(
351 "persist running Lane; backend rollback also failed: {rollback_error:#}"
352 ));
353 }
354 return Err(save_error).context("persist running Lane after backend launch");
355 }
356 Ok(true)
357 }
358
359 /// Atomically transition an active Lane to a terminal state.
360 ///
361 /// Detached tmux reconciliation, an explicit stop, and a second status
362 /// reader can race in separate CLI processes. Serialize those terminal
363 /// decisions on a per-Lane advisory lock, reload the live record under the
364 /// lock, and only let the first active -> terminal transition win.
365 pub fn mark_terminal_if_active(
366 &self,
367 record: &mut LaneRecord,
368 status: LaneStatus,
369 ) -> Result<bool> {
370 self.mark_terminal_if_active_with(record, status, |_| Ok(()))
371 }
372
373 /// Atomically perform backend teardown and transition an active Lane.
374 ///
375 /// `before_transition` runs while holding the per-Lane lifecycle lock.
376 /// If teardown fails, the record remains active. This keeps a failed tmux
377 /// kill from being persisted as Stopped and prevents cleanup racing a
378 /// concurrent reconciliation decision.
379 pub fn mark_terminal_if_active_with<F>(
380 &self,
381 record: &mut LaneRecord,
382 status: LaneStatus,
383 before_transition: F,
384 ) -> Result<bool>
385 where
386 F: FnOnce(&LaneRecord) -> Result<()>,
387 {
388 self.mark_terminal_if_active_fenced(record, status, None, before_transition)
389 .map(TerminalTransition::transitioned)
390 }
391
392 /// [`mark_terminal_if_active_with`] with an optional lifecycle fence.
393 ///
394 /// `expected_lifecycle_seq` is evaluated **after** the live record is
395 /// reloaded under the per-Lane advisory lock, not by the caller before it.
396 /// A pre-lock check is a TOCTOU: another process can transition the record
397 /// between the caller's read and this write, and the caller would then act
398 /// on a generation it never observed. Checking here means a stale fence
399 /// refuses without running `before_transition`, so no backend teardown
400 /// happens for a run the caller did not actually target.
401 ///
402 /// [`mark_terminal_if_active_with`]: Self::mark_terminal_if_active_with
403 pub fn mark_terminal_if_active_fenced<F>(
404 &self,
405 record: &mut LaneRecord,
406 status: LaneStatus,
407 expected_lifecycle_seq: Option<u64>,
408 before_transition: F,
409 ) -> Result<TerminalTransition>
410 where
411 F: FnOnce(&LaneRecord) -> Result<()>,
412 {
413 if status.is_active() {
414 bail!("terminal lane transition requires a terminal status");
415 }
416
417 let lock_path = self.root.join(format!("{}.lock", record.id));
418 let lock_file = OpenOptions::new()
419 .create(true)
420 .truncate(false)
421 .read(true)
422 .write(true)
423 .open(&lock_path)
424 .with_context(|| format!("open lane lock {}", lock_path.display()))?;
425 let mut lock = fd_lock::RwLock::new(lock_file);
426 let _guard = lock
427 .write()
428 .with_context(|| format!("lock lane record {}", record.id))?;
429
430 let mut current = self.load(&record.id)?;
431 // Fence first: a mismatched generation must not run backend teardown.
432 if let Some(expected) = expected_lifecycle_seq
433 && expected != current.lifecycle_seq
434 {
435 let observed = current.lifecycle_seq;
436 *record = current;
437 return Ok(TerminalTransition::FenceMismatch { observed });
438 }
439 if !current.status.is_active() {
440 *record = current;
441 return Ok(TerminalTransition::AlreadyTerminal);
442 }
443 before_transition(&current)?;
444 current.lifecycle_seq = current.lifecycle_seq.max(1).saturating_add(1);
445 current.status = status;
446 current.stopped_at = Some(LaneRecord::now_rfc3339());
447 current.attach_target = None;
448 self.save(&current)?;
449 *record = current;
450 Ok(TerminalTransition::Transitioned)
451 }
452 }
453
454 #[cfg(test)]
455 mod tests {
456 use super::*;
457 use std::sync::atomic::{AtomicUsize, Ordering};
458 use std::sync::{Arc, mpsc};
459 use tempfile::tempdir;
460
461 #[test]
462 fn registry_persists_across_open() {
463 let dir = tempdir().unwrap();
464 let reg = LaneRegistry::open(dir.path()).unwrap();
465 let record = reg
466 .create_pending(
467 Some("stopship".into()),
468 Some("stopship".into()),
469 Some("4375".into()),
470 None,
471 RuntimeBackendKind::Tmux,
472 Some(3600),
473 )
474 .unwrap();
475 let id = record.id.clone();
476
477 let reg2 = LaneRegistry::open(dir.path()).unwrap();
478 let loaded = reg2.load(&id).unwrap();
479 assert_eq!(loaded.workflow.as_deref(), Some("stopship"));
480 assert_eq!(loaded.fleet.as_deref(), Some("stopship"));
481 assert_eq!(loaded.issue.as_deref(), Some("4375"));
482 assert_eq!(loaded.runtime, RuntimeBackendKind::Tmux);
483 assert_eq!(loaded.status, LaneStatus::Pending);
484 assert_eq!(loaded.lifecycle_seq, 1);
485 assert!(loaded.log_path.is_file() || loaded.log_path.exists());
486
487 let listed = reg2.list().unwrap();
488 assert_eq!(listed.len(), 1);
489 assert_eq!(listed[0].id, id);
490 }
491
492 #[test]
493 fn terminal_lane_cannot_launch_after_stop_wins() {
494 let dir = tempdir().unwrap();
495 let reg = LaneRegistry::open(dir.path()).unwrap();
496 let mut record = reg
497 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
498 .unwrap();
499 assert!(
500 reg.mark_terminal_if_active(&mut record, LaneStatus::Stopped)
501 .unwrap()
502 );
503 let starts = AtomicUsize::new(0);
504 assert!(
505 !reg.mark_running_if_pending_with(
506 &mut record,
507 || {
508 starts.fetch_add(1, Ordering::SeqCst);
509 Ok(())
510 },
511 || Ok(()),
512 )
513 .unwrap()
514 );
515 assert_eq!(starts.load(Ordering::SeqCst), 0);
516 assert_eq!(record.status, LaneStatus::Stopped);
517 assert_eq!(record.lifecycle_seq, 2);
518 let loaded = reg.load(&record.id).unwrap();
519 assert_eq!(loaded.status, LaneStatus::Stopped);
520 assert_eq!(loaded.lifecycle_seq, 2);
521 }
522
523 #[test]
524 fn start_and_stop_are_serialized_across_backend_launch() {
525 let dir = tempdir().unwrap();
526 let reg = LaneRegistry::open(dir.path()).unwrap();
527 let record = reg
528 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
529 .unwrap();
530 let id = record.id.clone();
531 let starts = Arc::new(AtomicUsize::new(0));
532 let teardowns = Arc::new(AtomicUsize::new(0));
533 let (entered_tx, entered_rx) = mpsc::channel();
534 let (release_tx, release_rx) = mpsc::channel();
535
536 let start_reg = reg.clone();
537 let start_count = Arc::clone(&starts);
538 let start_thread = std::thread::spawn(move || {
539 let mut record = record;
540 let started = start_reg
541 .mark_running_if_pending_with(
542 &mut record,
543 || {
544 start_count.fetch_add(1, Ordering::SeqCst);
545 entered_tx.send(()).unwrap();
546 release_rx.recv().unwrap();
547 Ok(())
548 },
549 || Ok(()),
550 )
551 .unwrap();
552 assert!(started);
553 });
554 entered_rx.recv().unwrap();
555
556 let stop_reg = reg.clone();
557 let stop_id = id.clone();
558 let teardown_count = Arc::clone(&teardowns);
559 let (stopped_tx, stopped_rx) = mpsc::channel();
560 let stop_thread = std::thread::spawn(move || {
561 let mut record = stop_reg.load(&stop_id).unwrap();
562 let stopped = stop_reg
563 .mark_terminal_if_active_with(&mut record, LaneStatus::Stopped, |_| {
564 teardown_count.fetch_add(1, Ordering::SeqCst);
565 Ok(())
566 })
567 .unwrap();
568 stopped_tx.send(stopped).unwrap();
569 });
570 assert!(matches!(
571 stopped_rx.try_recv(),
572 Err(mpsc::TryRecvError::Empty)
573 ));
574 release_tx.send(()).unwrap();
575 start_thread.join().unwrap();
576 assert!(stopped_rx.recv().unwrap());
577 stop_thread.join().unwrap();
578
579 assert_eq!(starts.load(Ordering::SeqCst), 1);
580 assert_eq!(teardowns.load(Ordering::SeqCst), 1);
581 let loaded = reg.load(&id).unwrap();
582 assert_eq!(loaded.status, LaneStatus::Stopped);
583 assert_eq!(
584 loaded.lifecycle_seq, 3,
585 "pending, running, and stopped are three durable owner states"
586 );
587 }
588
589 #[test]
590 fn teardown_failure_keeps_durable_lane_active() {
591 let dir = tempdir().unwrap();
592 let reg = LaneRegistry::open(dir.path()).unwrap();
593 let mut record = reg
594 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
595 .unwrap();
596 assert!(reg.mark_running_if_pending(&mut record).unwrap());
597 let error = reg
598 .mark_terminal_if_active_with(&mut record, LaneStatus::Stopped, |_| {
599 bail!("backend still alive")
600 })
601 .unwrap_err();
602 assert!(error.to_string().contains("backend still alive"));
603 assert_eq!(record.status, LaneStatus::Running);
604 assert_eq!(record.lifecycle_seq, 2);
605 let loaded = reg.load(&record.id).unwrap();
606 assert_eq!(loaded.status, LaneStatus::Running);
607 assert_eq!(loaded.lifecycle_seq, 2);
608 }
609
610 #[test]
611 fn ids_that_are_not_plain_names_are_refused() {
612 let dir = tempdir().unwrap();
613 let reg = LaneRegistry::open(dir.path().join("lanes")).unwrap();
614 std::fs::write(dir.path().join("outside.json"), "{}").unwrap();
615
616 for id in ["../outside", "a/b", "", "lane 1", "lane.json"] {
617 assert!(reg.load(id).is_err(), "{id:?} must be refused");
618 }
619 let mut record = reg
620 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
621 .unwrap();
622 record.id = "../outside".into();
623 assert!(reg.save(&record).is_err());
624 assert_eq!(
625 std::fs::read_to_string(dir.path().join("outside.json")).unwrap(),
626 "{}"
627 );
628 }
629
630 #[cfg(unix)]
631 #[test]
632 fn save_does_not_write_through_a_planted_temporary_link() {
633 let dir = tempdir().unwrap();
634 let reg = LaneRegistry::open(dir.path().join("lanes")).unwrap();
635 let record = reg
636 .create_pending(None, None, None, None, RuntimeBackendKind::Tmux, None)
637 .unwrap();
638 let victim = dir.path().join("victim");
639 std::fs::write(&victim, "keep").unwrap();
640 std::os::unix::fs::symlink(
641 &victim,
642 reg.record_path(&record.id).with_extension("json.tmp"),
643 )
644 .unwrap();
645
646 reg.save(&record).unwrap();
647
648 assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
649 assert_eq!(reg.load(&record.id).unwrap().id, record.id);
650 }
651 }
652
652 lines RUST