Skip to main content

rig_harness/host/
session.rs

1//! Where this process keeps its session: the directory the launcher names,
2//! its store ([`StoreOpener`]), the plain [`Scratch`] files with the text
3//! log, and the log's warnings and errors as [`Logged`] data. Deleting
4//! ([`DeleteSession`], crash-safe: the next start finishes it) and
5//! rewriting a stopped session ([`rewrite_stopped`]) are here, and so is
6//! why this app started ([`AppStart`]).
7
8use std::collections::BTreeSet;
9use std::fmt::{Debug, Write};
10use std::fs::{self, File, OpenOptions};
11use std::io::{self, Seek, SeekFrom};
12use std::ops::Deref;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex, PoisonError};
15
16use bevy_app::OnAppExitSystems;
17use bevy_app::prelude::*;
18use bevy_ecs::prelude::*;
19use bevy_log::tracing::field::{Field, Visit};
20use bevy_log::tracing::{Event, Level};
21use bevy_log::tracing_subscriber::layer::{Context, Layer};
22use bevy_log::tracing_subscriber::{Registry, fmt};
23use bevy_log::{BoxedFmtLayer, BoxedLayer, error, warn};
24use bevy_reflect::Reflect;
25use crossbeam_channel::{Receiver, Sender};
26use rig_harness_protocol::{Home, SessionDir, SessionId, StartReason};
27
28use rig_core::conversation::Author;
29use rig_ecs::WriteLogs;
30use rig_ecs::agent::{Notice, NoticeLevel};
31use rig_ecs::restore::SavedKeys;
32use rig_ecs::session_log::{SessionLog, SessionStore, open_session};
33use rig_memory::{
34    DirFiles, DirStore, Files, Rewrite, Rewritten, Session, SessionError, SessionMeta, Store,
35    rewrite, rewrite_file,
36};
37use rig_tools::Spill;
38
39use super::cli::{self, Cli};
40use super::exit::ExitReason;
41use super::launcher::{LauncherRun, refuse};
42use crate::describe::Describing;
43use crate::plugin_data::PluginData;
44use crate::services::{ProjectRoot, RigHome};
45use crate::timings::{self, SystemTimings, TimeSystems};
46use crate::workspace::ProjectWorkspace;
47
48/// The session's directory. Its files are kept through the session's
49/// store ([`StoreOpener`]): a plugin keeps its own files of the session
50/// there too, under a directory of its own, such as
51/// `store.write_file("notes/today.md", ..)`.
52#[derive(Resource, Clone, Debug)]
53pub struct SessionPaths(pub SessionDir);
54
55impl Deref for SessionPaths {
56    type Target = SessionDir;
57
58    fn deref(&self) -> &SessionDir {
59        &self.0
60    }
61}
62
63/// Opens the store of a session's files: the running session's, an
64/// import's, one `/resume` lists. By default a [`DirStore`] in the
65/// session's directory (`logs/`, `files/`), which refuses a
66/// session from before `rig-session/2` ([`SessionError::OldLayout`]). A
67/// plugin replaces it while the app is built, such as with a sealed store:
68/// `app.insert_resource(StoreOpener::new(|dir| ..))`.
69///
70/// Once every plugin is built the harness opens the running session's
71/// [`SessionStore`] from the front's own store
72/// ([`Front::session_store`](super::cli::Front::session_store)), else from
73/// this, and reads it before writing; a run whose session does not open or
74/// read is refused, unchanged. The harness marks the running session saved
75/// once its log first writes, so `/resume` lists it (not a sessionless
76/// run's); a program that saves a new session through this marks it
77/// itself ([`SessionDir::mark_saved`]). [`Self::relist`] keeps the listing
78/// of a session no rig runs current.
79#[derive(Resource, Clone)]
80pub struct StoreOpener {
81    /// How a store is opened.
82    open: Arc<OpenStore>,
83    /// The keys whose values a listing holds.
84    listed: Arc<BTreeSet<String>>,
85}
86
87/// How [`StoreOpener`] opens a store.
88type OpenStore = dyn Fn(&SessionDir) -> Result<Arc<dyn Store>, SessionError> + Send + Sync;
89
90impl StoreOpener {
91    /// Stores that `open` opens.
92    pub fn new(
93        open: impl Fn(&SessionDir) -> Result<Arc<dyn Store>, SessionError> + Send + Sync + 'static,
94    ) -> Self {
95        Self {
96            open: Arc::new(open),
97            listed: Arc::default(),
98        }
99    }
100
101    /// The store of the session in `dir`.
102    ///
103    /// # Errors
104    ///
105    /// When the store cannot be opened, such as without its key, or holds
106    /// a session from before `rig-session/2`.
107    pub fn open(&self, dir: &SessionDir) -> Result<Arc<dyn Store>, SessionError> {
108        (self.open)(dir)
109    }
110
111    /// Brings the listing of the session `session` read from or saved to
112    /// `store`, which no rig runs, up to it, as a running rig keeps its
113    /// own: its listed values, its main agent's model and message count,
114    /// and when it changed ([`SessionMeta::relist`]). Every writer of a
115    /// stopped session calls it after writing; [`edit_stopped`] and
116    /// [`rewrite_stopped`] do.
117    ///
118    /// # Errors
119    ///
120    /// When the listing cannot be written.
121    pub fn relist(&self, store: &dyn Store, session: &Session) -> io::Result<()> {
122        let mut meta = SessionMeta::read(store).unwrap_or_default();
123        meta.relist(session, &self.listed);
124        meta.write(store)
125    }
126}
127
128impl Debug for StoreOpener {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        f.write_str("StoreOpener")
131    }
132}
133
134impl Default for StoreOpener {
135    fn default() -> Self {
136        Self::new(|dir| Ok(Arc::new(DirStore::open(dir.path())?)))
137    }
138}
139
140/// Where rig keeps plain files of the session, which are named by path
141/// and so stay out of its store: tool output cut short ([`Self::spill`]),
142/// pasted images ([`Self::images`]) and the text log ([`Self::log`]: tracing,
143/// panics, stray output). The session's directory by default; a plugin
144/// places it elsewhere, or with `None` nowhere, while the app is built:
145/// tools then only cut, pasting an image is refused, and the text log and
146/// stray output are dropped.
147#[derive(Resource, Clone, Debug, PartialEq, Eq)]
148pub struct Scratch(pub Option<PathBuf>);
149
150impl Scratch {
151    /// Where tools keep output they cut, and plugins large answers, whole,
152    /// under handles the model can `read` or `search`.
153    pub fn spill(&self) -> Option<Spill> {
154        self.0.as_ref().map(|dir| Spill(dir.join("spill")))
155    }
156
157    /// Where images pasted into the input are kept, which messages attach
158    /// by path.
159    pub fn images(&self) -> Option<PathBuf> {
160        self.0.as_ref().map(|dir| dir.join("images"))
161    }
162
163    /// The text log.
164    pub fn log(&self) -> Option<PathBuf> {
165        self.0.as_ref().map(|dir| dir.join("agent.log"))
166    }
167}
168
169/// The session the launcher names, or a new one when the agent runs
170/// without it, under `home`, with its directory created.
171pub(crate) fn paths_in(home: &Home) -> SessionPaths {
172    let id = SessionId::from_env()
173        .ok()
174        .flatten()
175        .unwrap_or_else(SessionId::generate);
176    let dir = home.session(&id);
177    // A directory that cannot be created shows up as a failed log write.
178    // A build describing itself keeps none.
179    if crate::describe::describing().is_none() {
180        fs::create_dir_all(dir.path()).ok();
181    }
182    SessionPaths(dir)
183}
184
185/// The session's paths in the app's home, taken from the environment the
186/// first time.
187fn session_paths(world: &mut World) -> SessionPaths {
188    let home = RigHome::of(world);
189    world
190        .get_resource_or_insert_with(|| paths_in(&home))
191        .clone()
192}
193
194/// Why this app started, from the launcher ([`StartReason`]), inserted
195/// when the session plugin is built unless the app has one. `New` exactly
196/// when no app of the session ran a whole frame before, whatever the
197/// launcher said. Without a launcher, any other start is `Resumed`.
198#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
199pub struct AppStart(pub StartReason);
200
201/// Until the end of its first frame: this app is its session's first, and
202/// marks the session started then. A refused run does not.
203#[derive(Resource)]
204struct Unmarked;
205
206/// Until the session's log first writes, in a run that keeps its session:
207/// the session is marked saved then ([`SessionDir::mark_saved`]), so
208/// `/resume` lists it. A sessionless run never has it.
209#[derive(Resource)]
210struct Unsaved;
211
212/// Inserts the [`SessionPaths`] from the environment, unless the log took
213/// them already, the default [`StoreOpener`] and [`Scratch`], the
214/// [`SystemTimings`] (which stay empty without the log), opens the
215/// [`SessionStore`] once every plugin is built and then inserts the
216/// [`PluginData`], routes panics to the log,
217/// says why the app started ([`AppStart`]), and deletes sessions,
218/// finishing at startup the deletes a crash cut short.
219pub struct SessionPlugin;
220
221impl Plugin for SessionPlugin {
222    fn build(&self, app: &mut App) {
223        std::panic::set_hook(Box::new(|info| {
224            error!("{info}\n{}", std::backtrace::Backtrace::capture());
225        }));
226        let paths = session_paths(app.world_mut());
227        if !app.world().contains_resource::<Scratch>() {
228            app.insert_resource(Scratch(Some(paths.path().to_owned())));
229        }
230        let first = !paths.started().exists();
231        if !app.world().contains_resource::<AppStart>() {
232            let reason = match StartReason::from_env() {
233                _ if first => StartReason::New,
234                None | Some(StartReason::New) => StartReason::Resumed,
235                Some(reason) => reason,
236            };
237            app.insert_resource(AppStart(reason));
238        }
239        if first {
240            app.insert_resource(Unmarked).add_systems(
241                Last,
242                mark_started
243                    .run_if(resource_exists::<Unmarked>)
244                    .before(discard_unsaved),
245            );
246        }
247        app.init_resource::<StoreOpener>()
248            .init_resource::<SystemTimings>()
249            .add_message::<SessionDeleted>()
250            .add_message::<SessionDeleteFailed>()
251            .add_message::<Notice>()
252            .add_observer(delete_session)
253            .add_systems(PreStartup, list_keys)
254            .add_systems(
255                Startup,
256                finish_deletes.run_if(not(resource_exists::<Describing>)),
257            )
258            .add_systems(First, timings::next_frame)
259            .add_systems(
260                Last,
261                mark_saved
262                    .after(WriteLogs)
263                    .before(discard_unsaved)
264                    .run_if(resource_exists::<Unsaved>),
265            )
266            .add_systems(
267                Last,
268                discard_unsaved
269                    .after(OnAppExitSystems)
270                    .run_if(on_message::<AppExit>),
271            );
272    }
273
274    /// Every plugin chose the stores and the scratch place and offered its fronts
275    /// by now: chooses the run's front, opens the session's store, or the one a
276    /// test or a nested tool put in, and reads the session; only then moves the
277    /// text log where [`Scratch`] says. A session that cannot be opened or read
278    /// refuses the run, and the run writes nothing into its directory
279    /// (`leave_session`). A build describing itself opens none.
280    fn finish(&self, app: &mut App) {
281        if !app.world().contains_resource::<Describing>() {
282            match open_store(app.world_mut()) {
283                Ok(()) => {
284                    let wanted = app.world().get_resource::<Scratch>().and_then(Scratch::log);
285                    move_text_log(app.world_mut(), wanted);
286                }
287                Err(failure) => {
288                    refuse(
289                        app,
290                        format!(
291                            "the session cannot be opened, so nothing of it was changed: {failure}"
292                        ),
293                    );
294                }
295            }
296        }
297        insert_plugin_data(app.world_mut());
298    }
299}
300
301/// Tells the [`StoreOpener`] the listed keys, once every plugin said its
302/// own.
303fn list_keys(keys: Option<Res<SavedKeys>>, stores: Option<ResMut<StoreOpener>>) {
304    if let Some(mut stores) = stores {
305        let listed = keys.map(|keys| keys.listed().clone());
306        stores.listed = Arc::new(listed.unwrap_or_default());
307    }
308}
309
310/// Inserts the [`PluginData`] of this run, unless the app has one: in the
311/// session's store when it opened, for the [`ProjectRoot`] in the
312/// [`ProjectWorkspace`].
313fn insert_plugin_data(world: &mut World) {
314    if world.contains_resource::<PluginData>() {
315        return;
316    }
317    let home = RigHome::of(world);
318    let session = world
319        .get_resource::<SessionStore>()
320        .map(|store| -> Arc<dyn Files> { store.0.clone() });
321    let root = world
322        .get_resource::<ProjectRoot>()
323        .cloned()
324        .unwrap_or_default();
325    let workspace = world.get_resource::<ProjectWorkspace>();
326    let host = workspace.and_then(|workspace| workspace.0.host());
327    let data = PluginData::new(&home, session, &root.0, host);
328    world.insert_resource(data);
329}
330
331/// Opens the session's store, the one in the world or else the one the
332/// run's front or [`StoreOpener`] opens for [`SessionPaths`], and reads
333/// the session it holds ([`open_session`]), before anything is written to
334/// it. A sessionless run never marks its session saved, so a crash leaves
335/// no session for `/resume` to list.
336fn open_store(world: &mut World) -> Result<(), SessionError> {
337    let store = match world.remove_resource::<SessionStore>() {
338        Some(SessionStore(store)) => store,
339        None => {
340            cli::choose(world);
341            let front = world.get_resource::<Cli>().and_then(Cli::chosen);
342            let keeps = !front.is_some_and(|front| front.sessionless);
343            let stores = front
344                .and_then(|front| front.session_store.as_ref())
345                .or_else(|| world.get_resource::<StoreOpener>());
346            let (Some(paths), Some(stores)) = (world.get_resource::<SessionPaths>(), stores) else {
347                return Ok(());
348            };
349            let store = stores.open(paths)?;
350            if keeps && !paths.is_saved() {
351                world.insert_resource(Unsaved);
352            }
353            store
354        }
355    };
356    open_session(world, store)
357}
358
359/// Keeps a refused run out of its session's directory: what it logged
360/// goes to the launcher's run directory, where the launcher points, or
361/// nowhere; it keeps no scratch files; and the session is not marked
362/// started.
363pub(crate) fn leave_session(world: &mut World) {
364    let elsewhere = world.get_resource::<LauncherRun>().map(|run| run.0.log());
365    move_text_log(world, elsewhere);
366    world.insert_resource(Scratch(None));
367    world.remove_resource::<Unmarked>();
368}
369
370/// Marks the session saved after its log first wrote.
371fn mark_saved(log: Option<Res<SessionLog>>, paths: Res<SessionPaths>, mut commands: Commands) {
372    if !log.is_some_and(|log| log.has_written()) {
373        return;
374    }
375    commands.remove_resource::<Unsaved>();
376    if let Err(failure) = paths.mark_saved() {
377        warn!("could not mark the session saved: {failure}");
378    }
379}
380
381/// Marks the session started at the end of its first app's first frame.
382fn mark_started(paths: Res<SessionPaths>, mut commands: Commands) {
383    commands.remove_resource::<Unmarked>();
384    if let Err(failure) = fs::write(paths.started(), b"") {
385        error!("could not mark the session started: {failure}");
386    }
387}
388
389/// Removes the session's directory when the run leaves it for good with
390/// nothing saved there, such as a run that switches to another session at
391/// startup, or when a sessionless front's run ends, however, once
392/// everything was written there; never a saved session a front keeps,
393/// never on a reload, which runs the session again, never on a switch the
394/// launcher is told of (its switch file is in the directory, and the
395/// launcher removes an unsaved session once it read it), and never a
396/// resumed session's, which a sessionless front refuses. A run that
397/// failed first moves its text log into the launcher's run directory,
398/// where the launcher's failure message points.
399fn discard_unsaved(
400    mut exits: MessageReader<AppExit>,
401    (cli, start, reason): (
402        Option<Res<Cli>>,
403        Option<Res<AppStart>>,
404        Option<Res<ExitReason>>,
405    ),
406    paths: Res<SessionPaths>,
407    (run, scratch): (Option<Res<LauncherRun>>, Option<Res<Scratch>>),
408) {
409    let exits: Vec<&AppExit> = exits.read().collect();
410    let reload = match reason.as_deref() {
411        Some(reason) => *reason == ExitReason::Reload,
412        None => exits
413            .iter()
414            .any(|exit| ExitReason::of(exit) == ExitReason::Reload),
415    };
416    let discard = if cli.is_some_and(|cli| cli.sessionless()) {
417        start.is_none_or(|start| start.0 != StartReason::Resumed)
418    } else {
419        !reload && !paths.is_saved() && !paths.switch().exists()
420    };
421    if !discard {
422        return;
423    }
424    if !exits.iter().all(|exit| exit.is_success())
425        && let Some(run) = run
426        && let Some(log) = scratch.and_then(|scratch| scratch.log())
427    {
428        fs::rename(log, run.0.log()).ok();
429    }
430    fs::remove_dir_all(paths.path()).ok();
431}
432
433/// Changes the saved session in `dir`, which no rig runs, as
434/// [`rewrite_stopped`] rewrites one: read, given
435/// to `edit`, and saved, its listing brought up to it
436/// ([`StoreOpener::relist`]). It holds the session's launcher lock
437/// ([`SessionDir::hold`]) from the check to the last write, so a rig
438/// starting meanwhile finds it running elsewhere. The running session,
439/// `running`, is refused: edit its agents' `Conversation` instead.
440///
441/// # Errors
442///
443/// When `dir` is the running session or another rig runs it, when its
444/// store does not open or its session cannot be read (nothing written),
445/// when `edit` fails (nothing written), or when a write fails.
446pub fn edit_stopped(
447    stores: &StoreOpener,
448    running: &SessionDir,
449    dir: &SessionDir,
450    edit: impl FnOnce(&mut Session) -> Result<(), String>,
451) -> Result<(), String> {
452    let _held = hold_stopped(running, dir, "edit its agents' conversations instead")?;
453    let store = stores.open(dir).map_err(|failure| failure.to_string())?;
454    let mut session = Session::read(&*store).map_err(|failure| failure.to_string())?;
455    edit(&mut session)?;
456    session
457        .save(&*store)
458        .map_err(|failure| failure.to_string())?;
459    relist(stores, &*store, &session);
460    Ok(())
461}
462
463/// Brings the listing of `session`, saved to `store`, up to it; a listing
464/// not written is logged, as a running rig's is, since the session is.
465fn relist(stores: &StoreOpener, store: &dyn Store, session: &Session) {
466    if let Err(failure) = stores.relist(store, session) {
467        warn!("could not write a stopped session's listing: {failure}");
468    }
469}
470
471/// The launcher lock of the session in `dir`, held for an edit of it:
472/// refused for the running session (`running`, which is edited as `this`
473/// says), a session that is not there and one another rig runs.
474fn hold_stopped(running: &SessionDir, dir: &SessionDir, this: &str) -> Result<File, String> {
475    if dir.path() == running.path() {
476        return Err(format!("it is this session: {this}"));
477    }
478    if !dir.path().is_dir() {
479        return Err("there is no such session".to_owned());
480    }
481    dir.hold()
482        .map_err(|failure| failure.to_string())?
483        .ok_or_else(|| "another rig runs it".to_owned())
484}
485
486/// Rewrites the saved session in `dir`, which no rig runs, through
487/// `with`, as done by `by` (rig-memory's `rewrite`), holding its launcher
488/// lock throughout, as [`edit_stopped`] does: its store opened by
489/// `stores`, then the files kept in its directory: its name
490/// ([`SessionDir::name`]), the text log and the spilled tool output, and
491/// its listing brought up to it ([`StoreOpener::relist`]). The running
492/// session, `running`, is refused: only a stopped session is rewritten.
493/// For a plugin redacting another saved session:
494///
495/// ```no_run
496/// use rig_harness::harness_protocol::SessionId;
497/// use rig_harness::prelude::*;
498///
499/// fn redact(
500///     other: SessionId,
501///     (home, stores, paths): (Res<RigHome>, Res<StoreOpener>, Res<SessionPaths>),
502/// ) -> Result<(), String> {
503///     let redact = Strings(|text: &mut String| {
504///         let hit = text.contains("sk-123");
505///         if hit {
506///             *text = text.replace("sk-123", "[REDACTED]");
507///         }
508///         hit
509///     });
510///     let dir = home.0.session(&other);
511///     rewrite_stopped(&stores, &paths, &dir, Author::plugin("redact"), &redact).map(drop)
512/// }
513/// ```
514///
515/// # Errors
516///
517/// When `dir` is the running session or another rig runs it, when its
518/// store does not open or its session cannot be read (nothing written), or
519/// when a write fails.
520pub fn rewrite_stopped(
521    stores: &StoreOpener,
522    running: &SessionDir,
523    dir: &SessionDir,
524    by: Author,
525    with: &dyn Rewrite,
526) -> Result<Rewritten, String> {
527    let _held = hold_stopped(running, dir, "only a stopped session is rewritten")?;
528    let store = stores.open(dir).map_err(|failure| failure.to_string())?;
529    let rewritten = rewrite(&*store, by, with).map_err(|failure| failure.to_string())?;
530    let scratch = DirFiles::new(dir.path());
531    map_spill(&scratch, with)
532        .and_then(|()| map_name(dir, with))
533        .and_then(|_| rewrite_file(&scratch, LOG, with))
534        .map_err(|failure| format!("rewriting its scratch files failed: {failure}"))?;
535    match Session::read(&*store) {
536        Ok(session) => relist(stores, &*store, &session),
537        Err(failure) => warn!("could not read a rewritten session to list it: {failure}"),
538    }
539    Ok(rewritten)
540}
541
542/// The text log's name to a [`Rewrite`].
543const LOG: &str = "agent.log";
544
545/// Maps the session's name in `dir` ([`SessionDir::name`]) through
546/// `with`, as the text file `name`; whether it changed. A name mapped to
547/// blank is removed.
548fn map_name(dir: &SessionDir, with: &dyn Rewrite) -> io::Result<bool> {
549    let Some(name) = dir.name() else {
550        return Ok(false);
551    };
552    let mut bytes = name.into_bytes();
553    if !with.file("name", &mut bytes) {
554        return Ok(false);
555    }
556    dir.set_name(Some(&String::from_utf8_lossy(&bytes)))?;
557    Ok(true)
558}
559
560/// Maps every file of the spilled tool output in `scratch`, a session's
561/// scratch directory, through `with`, each named `spill/<file>`.
562fn map_spill(scratch: &DirFiles, with: &dyn Rewrite) -> io::Result<()> {
563    for name in scratch.files()? {
564        if name.starts_with("spill/") {
565            rewrite_file(scratch, &name, with)?;
566        }
567    }
568    Ok(())
569}
570
571/// Removes a saved session: not this one, and not one another rig runs.
572/// The session is marked deleting first ([`SessionDir::mark_deleting`]),
573/// which hides it from listings, then everything its store holds is
574/// destroyed, wherever [`StoreOpener`] keeps it, then its directory is
575/// removed. A delete a crash cut short is finished at the next start.
576/// [`SessionDeleted`] follows, or [`SessionDeleteFailed`] and an error
577/// notice say why not.
578#[derive(Event, Clone, Debug)]
579pub struct DeleteSession(pub SessionId);
580
581/// A session removed with [`DeleteSession`], or by the next start after
582/// a crash cut its delete short.
583#[derive(Message, Clone, Debug)]
584pub struct SessionDeleted(pub SessionId);
585
586/// A session [`DeleteSession`], or the next start finishing a delete a
587/// crash cut short, could not remove, and why, such as `another rig runs
588/// it`.
589#[derive(Message, Clone, Debug)]
590pub struct SessionDeleteFailed {
591    /// The session.
592    pub id: SessionId,
593    /// Why it is still there.
594    pub why: String,
595}
596
597fn delete_session(
598    delete: On<DeleteSession>,
599    (paths, stores): (Res<SessionPaths>, Res<StoreOpener>),
600    home: Option<Res<RigHome>>,
601    mut deleted: MessageWriter<SessionDeleted>,
602    mut failed: MessageWriter<SessionDeleteFailed>,
603    mut notices: MessageWriter<Notice>,
604) {
605    let home = home.map_or_else(Home::from_env, |home| home.0.clone());
606    let session = home.session(&delete.0);
607    let refusal = if session.path() == paths.path() {
608        Some("it is this session".to_owned())
609    } else if !session.path().is_dir() {
610        Some("there is no such session".to_owned())
611    } else if session.runs_elsewhere() {
612        Some("another rig runs it".to_owned())
613    } else {
614        session
615            .mark_deleting()
616            .map_err(SessionError::from)
617            .and_then(|()| finish_delete(&session, &stores))
618            .err()
619            .map(|failure| failure.to_string())
620    };
621    match refusal {
622        None => {
623            deleted.write(SessionDeleted(delete.0.clone()));
624        }
625        Some(why) => {
626            notices.write(Notice::error(
627                None,
628                format!("Could not delete session {}: {why}.", delete.0),
629            ));
630            let id = delete.0.clone();
631            failed.write(SessionDeleteFailed { id, why });
632        }
633    }
634}
635
636/// Removes the session in `session`, marked deleting: everything its store
637/// holds, then its directory. Running it again after a crash finishes it.
638/// A session in the layout before `rig-session/2` has nothing outside it.
639fn finish_delete(session: &SessionDir, stores: &StoreOpener) -> Result<(), SessionError> {
640    match (stores.open)(session) {
641        Ok(store) => store.destroy()?,
642        Err(SessionError::OldLayout) => {}
643        Err(failure) => return Err(failure),
644    }
645    Ok(fs::remove_dir_all(session.path())?)
646}
647
648/// Finishes the deletes a crash cut short: every session of the app's
649/// home marked deleting that no other rig runs.
650fn finish_deletes(
651    home: Option<Res<RigHome>>,
652    (paths, stores): (Res<SessionPaths>, Res<StoreOpener>),
653    mut deleted: MessageWriter<SessionDeleted>,
654    mut failed: MessageWriter<SessionDeleteFailed>,
655    mut notices: MessageWriter<Notice>,
656) {
657    let Some(home) = home else {
658        return;
659    };
660    let Ok(entries) = fs::read_dir(home.0.sessions()) else {
661        return;
662    };
663    for entry in entries.flatten() {
664        let session = SessionDir::at(entry.path());
665        if !session.is_deleting() || session.path() == paths.path() || session.runs_elsewhere() {
666            continue;
667        }
668        match (finish_delete(&session, &stores), session.id()) {
669            (Ok(()), Some(id)) => {
670                deleted.write(SessionDeleted(id));
671            }
672            (Ok(()), None) => {}
673            (Err(failure), id) => {
674                notices.write(Notice::warn(
675                    None,
676                    format!(
677                        "Could not finish deleting the session in {}: {failure}.",
678                        session.path().display()
679                    ),
680                ));
681                if let Some(id) = id {
682                    let why = failure.to_string();
683                    failed.write(SessionDeleteFailed { id, why });
684                }
685            }
686        }
687    }
688}
689
690/// The `LogPlugin` formatter: plain text appended to the session's text
691/// log, so nothing is written to stderr. The log comes before the
692/// session's other plugins, so it takes the session from the environment
693/// first, and logs into the session's directory until every plugin is
694/// built, when the log moves where [`Scratch`] says.
695pub(crate) fn log_layer(app: &mut App) -> Option<BoxedFmtLayer> {
696    let at = session_paths(app.world_mut()).log();
697    let earlier = fs::metadata(&at).ok().map(|earlier| earlier.len());
698    let file = OpenOptions::new().create(true).append(true).open(&at).ok();
699    let log = TextLog {
700        at: file.is_some().then_some(at),
701        earlier,
702        file: Arc::new(Mutex::new(file)),
703    };
704    let writer = log.file.clone();
705    app.insert_resource(log);
706    let layer = fmt::Layer::default().with_ansi(false);
707    Some(Box::new(
708        layer.with_writer(move || LogWriter(writer.clone())),
709    ))
710}
711
712/// The text log's file, and where it is.
713#[derive(Resource)]
714struct TextLog {
715    at: Option<PathBuf>,
716    /// The length of the file earlier runs left at `at`, which this run
717    /// appends to; `None` when this run made it.
718    earlier: Option<u64>,
719    file: Arc<Mutex<Option<File>>>,
720}
721
722/// Writes a line of the text log, or drops it without a file.
723struct LogWriter(Arc<Mutex<Option<File>>>);
724
725impl io::Write for LogWriter {
726    fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
727        let mut file = self.0.lock().unwrap_or_else(PoisonError::into_inner);
728        match file.as_mut() {
729            Some(file) => file.write(bytes),
730            None => Ok(bytes.len()),
731        }
732    }
733
734    fn flush(&mut self) -> io::Result<()> {
735        let mut file = self.0.lock().unwrap_or_else(PoisonError::into_inner);
736        file.as_mut().map_or(Ok(()), io::Write::flush)
737    }
738}
739
740/// Moves what this run wrote to the text log so far to `wanted`, or
741/// removes it for no place, and logs there from now on. What earlier runs
742/// logged stays where it was.
743fn move_text_log(world: &mut World, wanted: Option<PathBuf>) {
744    let Some(mut log) = world.get_resource_mut::<TextLog>() else {
745        return;
746    };
747    if log.at == wanted {
748        return;
749    }
750    let handle = log.file.clone();
751    let mut file = handle.lock().unwrap_or_else(PoisonError::into_inner);
752    let moved = match (&log.at, log.earlier, &wanted) {
753        (Some(at), None, Some(to)) => move_file(at, to),
754        (Some(at), None, None) => fs::remove_file(at),
755        (Some(at), Some(earlier), to) => take_tail(at, earlier, to.as_deref()),
756        (None, _, _) => Ok(()),
757    };
758    if let Err(failure) = moved {
759        // Said in the old log, which stays where it was.
760        drop(file);
761        error!("could not move the text log: {failure}");
762        return;
763    }
764    *file = wanted
765        .as_deref()
766        .and_then(|to| OpenOptions::new().create(true).append(true).open(to).ok());
767    drop(file);
768    log.at = wanted;
769    log.earlier = None;
770}
771
772/// Cuts the file `from` back to its first `length` bytes, appending what
773/// followed to `to` when there is one.
774fn take_tail(from: &Path, length: u64, to: Option<&Path>) -> io::Result<()> {
775    if let Some(to) = to {
776        let mut tail = File::open(from)?;
777        tail.seek(SeekFrom::Start(length))?;
778        if let Some(directory) = to.parent() {
779            fs::create_dir_all(directory)?;
780        }
781        let mut moved = OpenOptions::new().create(true).append(true).open(to)?;
782        io::copy(&mut tail, &mut moved)?;
783    }
784    OpenOptions::new().write(true).open(from)?.set_len(length)
785}
786
787/// Moves the file `from` to `to`, making its directory, across file
788/// systems too.
789fn move_file(from: &Path, to: &Path) -> io::Result<()> {
790    if let Some(directory) = to.parent() {
791        fs::create_dir_all(directory)?;
792    }
793    fs::rename(from, to).or_else(|_| {
794        fs::copy(from, to)?;
795        fs::remove_file(from)
796    })
797}
798
799/// A warning or error this process logged, Bevy's own included.
800#[derive(Reflect, Clone, Debug, Default)]
801pub struct Logged {
802    /// `Warn` or `Error`.
803    pub level: NoticeLevel,
804    /// The module that logged it, such as `bevy_app::hierarchy`.
805    pub target: String,
806    /// Its message, then its other fields.
807    pub message: String,
808    /// The `AgentId` its `agent` field names.
809    pub agent: Option<String>,
810    /// The plugin (its `Name`) whose module logged it, where that can be told.
811    pub plugin: Option<String>,
812}
813
814/// The warnings and errors logged that nothing took yet. At most 256
815/// wait; the rest are only in the text log.
816#[derive(Resource)]
817pub struct LoggedQueue(pub Receiver<Logged>);
818
819/// The `LogPlugin` layer passing warnings and errors on to [`LoggedQueue`],
820/// which exists before any plugin `plugins.toml` lists is added, and
821/// timing systems into [`SystemTimings`].
822pub fn log_events(app: &mut App) -> Option<BoxedLayer> {
823    let (sender, receiver) = crossbeam_channel::bounded(256);
824    let timings = SystemTimings::default();
825    app.insert_resource(LoggedQueue(receiver))
826        .insert_resource(timings.clone());
827    Some(Box::new(PassOn(sender).and_then(TimeSystems(timings))))
828}
829
830struct PassOn(Sender<Logged>);
831
832impl Layer<Registry> for PassOn {
833    fn on_event(&self, event: &Event<'_>, _: Context<'_, Registry>) {
834        let level = *event.metadata().level();
835        if level <= Level::WARN {
836            let target = event.metadata().target().to_owned();
837            let level = if level == Level::ERROR {
838                NoticeLevel::Error
839            } else {
840                NoticeLevel::Warn
841            };
842            let mut logged = Logged {
843                level,
844                target,
845                ..Logged::default()
846            };
847            event.record(&mut logged);
848            self.0.try_send(logged).ok();
849        }
850    }
851}
852
853/// A `log` record, such as Bevy's own warnings, names its module in
854/// `log.target`.
855impl Visit for Logged {
856    fn record_str(&mut self, field: &Field, value: &str) {
857        match field.name() {
858            "log.target" => self.target = value.to_owned(),
859            _ => self.record_debug(field, &format_args!("{value}")),
860        }
861    }
862
863    fn record_debug(&mut self, field: &Field, value: &dyn Debug) {
864        match field.name() {
865            "agent" => self.agent = Some(format!("{value:?}")),
866            "message" => self.message.insert_str(0, &format!("{value:?}")),
867            name if name.starts_with("log.") => {}
868            name => write!(self.message, " {name}={value:?}").unwrap_or_default(),
869        }
870    }
871}
872
873#[cfg(test)]
874mod tests;