1use std::collections::{BTreeMap, BTreeSet};
7use std::ffi::OsStr;
8use std::io::{self, ErrorKind, Write as _};
9use std::path::{Component, Path, PathBuf};
10use std::process::{Command, Stdio};
11use std::sync::{Arc, Mutex, PoisonError};
12use std::time::{Duration, SystemTime};
13
14use rig_core::completion::UnsupportedOption;
15use rig_core::effect::{EffectKind, HandlerDescriptor, HandlerKey};
16use rig_core::serve::{Dispatch, ErasedHandler, Reply, Serve};
17use rig_core::tool::{
18 ContentHash, FileChange, FileState, FilesChanged, PublishedContext, Touches, content_hash,
19};
20
21use crate::fs::Previous;
22use crate::process::Launcher;
23use crate::workspace::{Listing, Stat, Workspace};
24use crate::{MAX_FILE_BYTES, blocking};
25
26#[derive(Clone, Debug)]
40pub struct Snapshot(Taken);
41
42#[derive(Clone, Debug)]
44enum Taken {
45 Git { git: Git, tree: String },
47 Scan {
50 files: Arc<BTreeMap<PathBuf, Seen>>,
51 began: SystemTime,
52 blobs: Option<PathBuf>,
53 },
54}
55
56#[derive(Clone, Debug, Default, PartialEq, Eq)]
58pub struct Taking {
59 pub keep: Option<PathBuf>,
63 pub also: Vec<PathBuf>,
67 pub launcher: Launcher,
70}
71
72#[derive(Clone, Debug, PartialEq, Eq)]
74struct Seen {
75 size: u64,
77 modified: Option<SystemTime>,
79 hash: Option<ContentHash>,
81 lines: usize,
83}
84
85impl Seen {
86 fn state(&self) -> FileState {
88 FileState {
89 bytes: self.size,
90 lines: self.hash.is_some().then_some(self.lines),
91 hash: self.hash.clone(),
92 }
93 }
94}
95
96pub fn snapshot(
118 workspace: &dyn Workspace,
119 root: &Path,
120 taking: &Taking,
121 earlier: Option<&Snapshot>,
122) -> io::Result<Snapshot> {
123 let keep = taking.keep.as_deref();
124 let git = match earlier.map(|it| &it.0) {
125 Some(Taken::Git { git, .. }) => Some(git.clone()),
126 Some(Taken::Scan { .. }) => None,
127 None => keep
128 .filter(|_| workspace.host().is_none())
129 .and_then(|keep| Git::find(root, keep, &taking.launcher)),
130 };
131 let also: Vec<PathBuf> = taking
132 .also
133 .iter()
134 .map(|path| root.join(path))
135 .filter(|path| {
136 path.starts_with(root) && !path.components().any(|part| part == Component::ParentDir)
137 })
138 .collect();
139 match git {
140 Some(git) => {
141 let tree = git.write_tree(&also)?;
142 Ok(Snapshot(Taken::Git { git, tree }))
143 }
144 None => {
145 let before = match earlier.map(|it| &it.0) {
146 Some(Taken::Scan { files, began, .. }) => Some((&**files, *began)),
147 _ => None,
148 };
149 let blobs = keep.map(|keep| private(keep, root).join("blobs"));
150 let began = SystemTime::now();
151 let files = scan(workspace, root, &also, before, blobs.as_deref())?;
152 let files = Arc::new(files);
153 Ok(Snapshot(Taken::Scan {
154 files,
155 began,
156 blobs,
157 }))
158 }
159 }
160}
161
162fn private(keep: &Path, dir: &Path) -> PathBuf {
164 let name = content_hash(dir.as_os_str().as_encoded_bytes()).0;
165 keep.join("checkpoints")
166 .join(name.get(..16).unwrap_or(&name))
167}
168
169pub fn forget_kept(keep: &Path) -> io::Result<()> {
178 let Ok(dirs) = std::fs::read_dir(keep.join("checkpoints")) else {
179 return Ok(());
180 };
181 for dir in dirs.flatten() {
182 match std::fs::remove_dir_all(dir.path().join("blobs")) {
183 Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
184 _ => {}
185 }
186 }
187 Ok(())
188}
189
190impl Snapshot {
191 pub fn changes_since(&self, earlier: &Snapshot) -> io::Result<Vec<FileChange>> {
200 match (&earlier.0, &self.0) {
201 (Taken::Git { tree: before, .. }, Taken::Git { git, tree }) => {
202 git.changes(before, tree)
203 }
204 (Taken::Scan { files: before, .. }, Taken::Scan { files, blobs, .. }) => {
205 Ok(scan_changes(before, files, blobs.as_deref()))
206 }
207 _ => Err(io::Error::new(
208 ErrorKind::InvalidInput,
209 "the two snapshots were taken different ways",
210 )),
211 }
212 }
213
214 pub fn content(&self, path: &Path) -> Option<io::Result<Vec<u8>>> {
217 match &self.0 {
218 Taken::Git { git, tree } => Some(git.content(tree, path)),
219 Taken::Scan { files, blobs, .. } => {
220 let blobs = blobs.as_deref()?;
221 Some(match files.get(path).map(|seen| &seen.hash) {
222 Some(Some(hash)) => std::fs::read(blobs.join(&hash.0)),
223 Some(None) => Err(io::Error::new(
224 ErrorKind::FileTooLarge,
225 "the file was too large to keep",
226 )),
227 None => Err(ErrorKind::NotFound.into()),
228 })
229 }
230 }
231 }
232}
233
234fn scan(
240 workspace: &dyn Workspace,
241 root: &Path,
242 also: &[PathBuf],
243 earlier: Option<(&BTreeMap<PathBuf, Seen>, SystemTime)>,
244 blobs: Option<&Path>,
245) -> io::Result<BTreeMap<PathBuf, Seen>> {
246 if let Some(blobs) = blobs {
247 std::fs::create_dir_all(blobs)?;
248 }
249 let mut listed = workspace.files(root, None, Listing::Visible)?;
250 for path in also {
251 listed.extend(
252 workspace
253 .files(path, None, Listing::All)
254 .unwrap_or_default(),
255 );
256 }
257 let mut seen = BTreeMap::new();
258 for path in listed {
259 let Ok(Stat::File { size, modified, .. }) = workspace.stat(&path) else {
260 continue;
261 };
262 let same = earlier.and_then(|(files, began)| {
263 let was = files.get(&path)?;
264 let settled = modified.is_some_and(|modified| settled_before(modified, began));
265 (settled && was.size == size && was.modified == modified).then_some(was)
266 });
267 let file = match same {
268 Some(was) => was.clone(),
269 None => {
270 let bytes = (size <= MAX_FILE_BYTES)
271 .then(|| workspace.read(&path, MAX_FILE_BYTES).ok())
272 .flatten()
273 .map(|contents| contents.bytes);
274 let hash = bytes.as_deref().map(content_hash);
275 if let (Some(blobs), Some(bytes), Some(hash)) = (blobs, &bytes, &hash) {
276 let blob = blobs.join(&hash.0);
277 if !blob.exists() {
278 crate::fs::write_scratch(&blob, bytes).ok();
279 }
280 }
281 Seen {
282 size,
283 modified,
284 hash,
285 lines: bytes.map_or(0, |bytes| String::from_utf8_lossy(&bytes).lines().count()),
286 }
287 }
288 };
289 seen.insert(path, file);
290 }
291 Ok(seen)
292}
293
294fn settled_before(modified: SystemTime, began: SystemTime) -> bool {
303 let whole_seconds = modified
304 .duration_since(SystemTime::UNIX_EPOCH)
305 .is_ok_and(|since| since.subsec_nanos() == 0);
306 let precision = if whole_seconds {
307 Duration::from_secs(2)
308 } else {
309 Duration::from_millis(100)
310 };
311 began
312 .checked_sub(precision)
313 .is_some_and(|settled| modified < settled)
314}
315
316fn scan_changes(
320 before: &BTreeMap<PathBuf, Seen>,
321 after: &BTreeMap<PathBuf, Seen>,
322 blobs: Option<&Path>,
323) -> Vec<FileChange> {
324 let paths: BTreeSet<&PathBuf> = before.keys().chain(after.keys()).collect();
325 paths
326 .into_iter()
327 .filter_map(|path| {
328 let shown = path.to_string_lossy();
329 let (was, now) = (before.get(path), after.get(path));
330 match (was, now) {
331 (Some(was), Some(now)) if was.hash.is_some() && was.hash == now.hash => {
332 return None;
333 }
334 (Some(was), Some(now)) if was.hash.is_none() && was == now => return None,
335 (None, None) => return None,
336 _ => {}
337 }
338 let kept = |seen: Option<&Seen>| match (blobs, seen) {
339 (_, None) => Some(None),
340 (
341 Some(blobs),
342 Some(Seen {
343 hash: Some(hash), ..
344 }),
345 ) => std::fs::read(blobs.join(&hash.0)).ok().map(Some),
346 _ => None,
347 };
348 if let (Some(was_bytes), Some(now_bytes)) = (kept(was), kept(now)) {
349 return Some(change(&shown, was_bytes, now_bytes.as_deref()));
350 }
351 let mut change = match (was, now) {
352 (None, Some(now)) => FileChange::created(shown, now.lines),
353 (Some(was), None) => FileChange::deleted(shown, was.lines),
354 _ => FileChange::modified(shown, 0, 0),
355 };
356 change.before = was.map(Seen::state);
357 change.after = now.map(Seen::state);
358 Some(change)
359 })
360 .collect()
361}
362
363#[derive(Clone, Debug)]
366struct Git {
367 top: PathBuf,
369 dir: PathBuf,
371 objects: PathBuf,
373 root: PathBuf,
375 below: PathBuf,
377 keep: PathBuf,
379 launcher: Launcher,
381}
382
383impl Git {
384 fn find(root: &Path, keep: &Path, launcher: &Launcher) -> Option<Self> {
389 let git = |args: &[&OsStr]| {
390 launcher
391 .command("git")
392 .arg("-C")
393 .arg(root)
394 .args(args)
395 .env_remove("GIT_DIR")
396 .env_remove("GIT_WORK_TREE")
397 .env_remove("GIT_INDEX_FILE")
398 .env_remove("GIT_OBJECT_DIRECTORY")
399 .stdin(Stdio::null())
400 .stderr(Stdio::null())
401 .output()
402 .ok()
403 };
404 let output = git(&[
405 "rev-parse",
406 "--show-toplevel",
407 "--absolute-git-dir",
408 "--git-path",
409 "objects",
410 ]
411 .map(OsStr::new))
412 .filter(|output| output.status.success())?;
413 let text = String::from_utf8(output.stdout).ok()?;
414 let mut lines = text.lines();
415 let (top, dir, objects) = (lines.next()?, lines.next()?, lines.next()?);
416 let top = PathBuf::from(top);
417 let canonical = std::fs::canonicalize(root).ok()?;
418 let below = canonical.strip_prefix(&top).ok()?.to_path_buf();
419 let check = ["check-ignore", "-q", "--"].map(OsStr::new);
421 if !below.as_os_str().is_empty()
422 && git(&[check.as_slice(), &[canonical.as_os_str()]].concat())
423 .is_some_and(|output| output.status.success())
424 {
425 return None;
426 }
427 let keep = private(keep, &top);
428 Some(Self {
429 objects: canonical.join(objects),
430 root: root.to_path_buf(),
431 top,
432 dir: PathBuf::from(dir),
433 below,
434 keep,
435 launcher: launcher.clone(),
436 })
437 }
438
439 fn command(&self, args: &[&OsStr]) -> Command {
442 let mut command = self.launcher.command("git");
443 command
444 .arg("--git-dir")
445 .arg(&self.dir)
446 .arg("--work-tree")
447 .arg(&self.top)
448 .args([
449 "-c",
450 "core.fsmonitor=false",
451 "-c",
452 "core.untrackedCache=false",
453 ])
454 .args(args)
455 .current_dir(&self.top)
456 .env("GIT_INDEX_FILE", self.keep.join("index"))
457 .env("GIT_OBJECT_DIRECTORY", self.keep.join("objects"))
458 .env("GIT_ALTERNATE_OBJECT_DIRECTORIES", &self.objects)
459 .env("GIT_OPTIONAL_LOCKS", "0")
460 .env_remove("GIT_DIR")
461 .env_remove("GIT_WORK_TREE")
462 .stdin(Stdio::null());
463 command
464 }
465
466 fn run(&self, args: &[&OsStr]) -> io::Result<Vec<u8>> {
468 let output = self.command(args).output()?;
469 match output.status.success() {
470 true => Ok(output.stdout),
471 false => Err(io::Error::other(format!(
472 "git {} failed: {}",
473 args.first()
474 .map_or(std::borrow::Cow::Borrowed(""), |it| it.to_string_lossy()),
475 String::from_utf8_lossy(&output.stderr).trim()
476 ))),
477 }
478 }
479
480 fn write_tree(&self, also: &[PathBuf]) -> io::Result<String> {
483 static INDEX: Mutex<()> = Mutex::new(());
486 let _index = INDEX.lock().unwrap_or_else(PoisonError::into_inner);
487 std::fs::create_dir_all(self.keep.join("objects"))?;
488 let below = match self.below.as_os_str().is_empty() {
489 true => OsStr::new("."),
490 false => self.below.as_os_str(),
491 };
492 let add = ["add", "--all", "--ignore-errors", "--"].map(OsStr::new);
493 self.run(&[add.as_slice(), &[below]].concat())?;
494 let also: Vec<PathBuf> = also
495 .iter()
496 .filter(|path| path.exists())
497 .filter_map(|path| Some(self.below.join(path.strip_prefix(&self.root).ok()?)))
498 .collect();
499 if !also.is_empty() {
500 let force = ["add", "--all", "--force", "--ignore-errors", "--"].map(OsStr::new);
501 let also = also.iter().map(|path| path.as_os_str());
502 self.run(&force.into_iter().chain(also).collect::<Vec<_>>())?;
503 }
504 let tree = self.run(&[OsStr::new("write-tree")])?;
505 Ok(String::from_utf8_lossy(&tree).trim().to_owned())
506 }
507
508 fn blobs(&self, ids: &[&str]) -> io::Result<Vec<Vec<u8>>> {
511 if ids.is_empty() {
512 return Ok(Vec::new());
513 }
514 let mut child = self
515 .command(&["cat-file", "--batch=%(objectsize)"].map(OsStr::new))
516 .stdin(Stdio::piped())
517 .stdout(Stdio::piped())
518 .stderr(Stdio::null())
519 .spawn()?;
520 let input: String = ids.iter().map(|id| format!("{id}\n")).collect();
521 let stdin = child.stdin.take();
522 let output = std::thread::scope(|scope| {
524 scope.spawn(|| stdin.map(|mut stdin| stdin.write_all(input.as_bytes())));
525 child.wait_with_output()
526 })?;
527 let mut rest = output.stdout.as_slice();
528 let mut blobs = Vec::with_capacity(ids.len());
529 for id in ids {
530 let missing = || io::Error::new(ErrorKind::NotFound, format!("git has no {id}"));
532 let end = rest.iter().position(|byte| *byte == b'\n');
533 let header = rest.get(..end.ok_or_else(missing)?).unwrap_or_default();
534 let size: usize = String::from_utf8_lossy(header)
535 .parse()
536 .map_err(|_| missing())?;
537 let body = rest.get(header.len() + 1..).unwrap_or_default();
538 blobs.push(body.get(..size).ok_or_else(missing)?.to_vec());
539 rest = body.get(size + 1..).unwrap_or_default();
540 }
541 Ok(blobs)
542 }
543
544 fn content(&self, tree: &str, path: &Path) -> io::Result<Vec<u8>> {
547 let below = match path.strip_prefix(&self.root) {
548 Ok(rest) => self.below.join(rest),
549 Err(_) => path
550 .strip_prefix(&self.top)
551 .map_err(|_| {
552 io::Error::new(ErrorKind::InvalidInput, "the path is outside the work tree")
553 })?
554 .to_path_buf(),
555 };
556 let spec = format!("{tree}:{}", below.to_string_lossy());
557 self.run(&["cat-file", "blob", &spec].map(OsStr::new))
558 }
559
560 fn shown(&self, path: &str) -> String {
563 match Path::new(path).strip_prefix(&self.below) {
564 Ok(rest) => self.root.join(rest),
565 Err(_) => self.top.join(path),
566 }
567 .to_string_lossy()
568 .into_owned()
569 }
570
571 fn changes(&self, before: &str, after: &str) -> io::Result<Vec<FileChange>> {
573 let raw = self.run(
574 &[
575 "diff-tree",
576 "-r",
577 "-z",
578 "--no-renames",
579 "--raw",
580 before,
581 after,
582 ]
583 .map(OsStr::new),
584 )?;
585 let text = String::from_utf8_lossy(&raw);
586 let mut fields = text.split('\0');
587 let mut changed = Vec::new();
588 while let (Some(header), Some(path)) = (fields.next(), fields.next()) {
589 let mut parts = header.split(' ').skip(2);
591 if let (Some(old), Some(new)) = (parts.next(), parts.next())
592 && old != new
593 {
594 changed.push((path, old, new));
595 }
596 }
597 let present = |id: &&str| id.bytes().any(|byte| byte != b'0');
599 let sides = changed.iter().flat_map(|(_, old, new)| [*old, *new]);
600 let mut blobs = self
601 .blobs(&sides.filter(present).collect::<Vec<_>>())?
602 .into_iter();
603 let mut side = |id: &str| present(&id).then(|| blobs.next()).flatten();
604 let changes = changed.into_iter().map(|(path, old, new)| {
605 let (was, now) = (side(old), side(new));
606 change(&self.shown(path), was, now.as_deref())
607 });
608 Ok(changes.collect())
609 }
610}
611
612fn change(path: &str, before: Option<Vec<u8>>, after: Option<&[u8]>) -> FileChange {
616 let small =
617 |bytes: Option<&[u8]>| bytes.is_none_or(|bytes| bytes.len() as u64 <= MAX_FILE_BYTES);
618 if small(before.as_deref()) && small(after) {
619 return Previous::change(before.map(Previous::of), path, after);
620 }
621 let was = before.as_deref().map(FileState::of);
622 let now = after.map(FileState::of);
623 let lines = |state: &Option<FileState>| state.as_ref().and_then(|it| it.lines).unwrap_or(0);
624 let mut change = match (&was, &now) {
625 (_, None) => FileChange::deleted(path, lines(&was)),
626 (None, Some(_)) => FileChange::created(path, lines(&now)),
627 (Some(_), Some(_)) => FileChange::modified(path, 0, 0),
628 };
629 change.before = was;
630 change.after = now;
631 change
632}
633
634#[derive(Clone, Debug)]
657pub struct Checkpointed {
658 pub inner: ErasedHandler,
660 pub workspace: Arc<dyn Workspace>,
662 pub root: PathBuf,
664 pub taking: Taking,
666 pub newest: Arc<Mutex<Option<Snapshot>>>,
669 pub first: Arc<Mutex<Option<Snapshot>>>,
671 pub before: Arc<Mutex<Option<Snapshot>>>,
673}
674
675impl Checkpointed {
676 async fn take(&self, earlier: Option<Snapshot>) -> Option<(Option<Snapshot>, Snapshot)> {
678 let (workspace, root, taking) = (
679 self.workspace.clone(),
680 self.root.clone(),
681 self.taking.clone(),
682 );
683 let taken = blocking(move || {
684 let now = snapshot(&*workspace, &root, &taking, earlier.as_ref())
685 .map_err(|error| rig_core::tool::ToolExecutionError::other(error.to_string()))?;
686 Ok((earlier, now))
687 })
688 .await;
689 taken
690 .inspect_err(|error| {
691 tracing::warn!(root = %self.root.display(), "no checkpoint of the call: {error}");
692 })
693 .ok()
694 }
695}
696
697impl Serve for Checkpointed {
698 type Family = rig_core::effect::family::Dynamic;
699
700 fn descriptor(&self) -> HandlerDescriptor {
701 self.inner.descriptor()
702 }
703
704 fn key(&self) -> HandlerKey {
705 self.inner.key().clone()
706 }
707
708 async fn serve(&self, kind: EffectKind, dispatch: Dispatch) -> Reply {
709 let published = dispatch.scope::<PublishedContext>();
710 let newest = self
711 .newest
712 .lock()
713 .unwrap_or_else(PoisonError::into_inner)
714 .clone();
715 let before = self.take(newest).await.map(|(_, now)| now);
716 if let Some(before) = &before {
717 self.first
718 .lock()
719 .unwrap_or_else(PoisonError::into_inner)
720 .get_or_insert_with(|| before.clone());
721 *self.newest.lock().unwrap_or_else(PoisonError::into_inner) = Some(before.clone());
722 *self.before.lock().unwrap_or_else(PoisonError::into_inner) = Some(before.clone());
723 }
724 let reply = self.inner.handle(kind, dispatch).await;
725 if !matches!(reply, Reply::Outcome(_)) {
726 return reply;
727 }
728 let Some((Some(before), after)) = self.take(before).await else {
729 return reply;
730 };
731 let changes = after
732 .changes_since(&before)
733 .inspect_err(|error| {
734 tracing::warn!(root = %self.root.display(), "no changes of the call: {error}");
735 })
736 .unwrap_or_default();
737 *self.newest.lock().unwrap_or_else(PoisonError::into_inner) = Some(after);
738 if let Some(published) = published {
739 let own = published
740 .result_context()
741 .and_then(|context| context.get::<FilesChanged>().ok().flatten());
742 if own.is_none() && !changes.is_empty() {
743 published.insert(FilesChanged(changes)).ok();
744 }
745 }
746 reply
747 }
748
749 fn footprint(&self, kind: &EffectKind) -> Option<Touches> {
750 self.inner.footprint(kind)
751 }
752
753 fn check(&self, kind: &EffectKind) -> Vec<UnsupportedOption> {
754 self.inner.check(kind)
755 }
756}
757
758#[cfg(test)]
759mod tests;