Radish alpha
r
rad:z2UcCU1LgMshWvXj6hXSDDrwB8q8M
Radicle Job Collaborative Object
Radicle
Git
feat: add program to manipulate and inspect job COBs
Fintan Halpenny committed 9 months ago
commit 841ea446c09563b642ae805f3626d6eac5884e69
parent 4e85d1c
3 files changed +646 -0
modified Cargo.lock
@@ -82,6 +82,56 @@ dependencies = [
]

[[package]]
+
name = "anstream"
+
version = "0.6.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+
dependencies = [
+
 "anstyle",
+
 "anstyle-parse",
+
 "anstyle-query",
+
 "anstyle-wincon",
+
 "colorchoice",
+
 "is_terminal_polyfill",
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle"
+
version = "1.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+

+
[[package]]
+
name = "anstyle-parse"
+
version = "0.2.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+
dependencies = [
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle-query"
+
version = "1.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+
dependencies = [
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "anstyle-wincon"
+
version = "3.0.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+
dependencies = [
+
 "anstyle",
+
 "once_cell_polyfill",
+
 "windows-sys",
+
]
+

+
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -237,6 +287,53 @@ dependencies = [
]

[[package]]
+
name = "clap"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+
dependencies = [
+
 "clap_builder",
+
 "clap_derive",
+
]
+

+
[[package]]
+
name = "clap_builder"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "clap_lex",
+
 "strsim",
+
 "terminal_size",
+
]
+

+
[[package]]
+
name = "clap_derive"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.101",
+
]
+

+
[[package]]
+
name = "clap_lex"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+

+
[[package]]
+
name = "colorchoice"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+

+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -596,6 +693,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"

[[package]]
+
name = "heck"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -733,6 +836,12 @@ dependencies = [
]

[[package]]
+
name = "is_terminal_polyfill"
+
version = "1.70.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+

+
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -915,6 +1024,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
+
name = "once_cell_polyfill"
+
version = "1.70.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+

+
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1218,12 +1333,14 @@ dependencies = [
name = "radicle-job"
version = "0.1.0"
dependencies = [
+
 "clap",
 "indexmap",
 "nonempty 0.11.0",
 "once_cell",
 "qcheck",
 "radicle",
 "serde",
+
 "serde_json",
 "thiserror 2.0.12",
 "url",
 "uuid",
@@ -1546,6 +1663,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"

[[package]]
+
name = "strsim"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+

+
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1598,6 +1721,16 @@ dependencies = [
]

[[package]]
+
name = "terminal_size"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
+
dependencies = [
+
 "rustix",
+
 "windows-sys",
+
]
+

+
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1712,6 +1845,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
+
name = "utf8parse"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+

+
[[package]]
name = "uuid"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -12,12 +12,14 @@ edition = "2021"
rust-version = "1.84.0"

[dependencies]
+
clap = { version = "4.5.41", features = ["derive", "wrap_help"] }
indexmap = { version = "2.7.1", features = ["serde"] }
nonempty = "0.11.0"
once_cell = "1.20.3"
qcheck = "1.0.0"
radicle = { version = "0.15" }
serde = { version = "1.0", features = ["derive"] }
+
serde_json = "1.0.140"
thiserror = "2.0.11"
url = { version = "2.5.4", features = ["serde"] }
uuid = { version = "1.13.1", features = ["serde", "v4"] }
added src/bin/rad-job.rs
@@ -0,0 +1,505 @@
+
//! A program to create and inspect job COBs and the automated
+
//! processing runs they record, for a repository.
+
//!
+
//! Run `radicle-job --help` to see how to use the program.
+

+
use std::{collections::BTreeSet, error::Error as _, time::Duration};
+

+
use clap::Parser;
+
use uuid::Uuid;
+

+
use radicle::{
+
    cob, crypto,
+
    crypto::signature::Signer,
+
    git::Oid,
+
    node::{
+
        device::Device,
+
        sync::{Announcer, AnnouncerConfig, ReplicationFactor},
+
        Handle, Node,
+
    },
+
    prelude::{Profile, ReadStorage, RepoId},
+
    profile,
+
    storage::git::Repository,
+
};
+
use radicle_job::*;
+

+
const TIMEOUT: Duration = Duration::from_millis(5000);
+

+
fn main() {
+
    if let Err(err) = fallible_main() {
+
        eprintln!("ERROR: {err}");
+
        let mut err = err.source();
+
        while let Some(underlying) = err {
+
            eprintln!("caused by: {underlying}");
+
            err = underlying.source();
+
        }
+
        std::process::exit(1);
+
    }
+
}
+

+
fn fallible_main() -> Result<(), Error> {
+
    let args = Args::parse();
+
    run(args)
+
}
+

+
/// Create, update, and query Radicle job COBs.
+
///
+
/// A job COB records results of automated processing of a
+
/// repository for a given Git commit, by one or more nodes. For
+
/// example, CI runs would be recorded as job COBs.
+
///
+
/// This program is a utility for Radicle developers to experiment
+
/// with and investigate job COBs. It allows you to see what jobs have
+
/// jobs and runs have been recorded. Output is JSON, for developer
+
/// convenience.
+
#[derive(Parser)]
+
struct Args {
+
    /// Use this repository. Default is the current working directory.
+
    #[clap(short, long)]
+
    repository: Option<RepoId>,
+

+
    /// Use this to not sync with the network after any modifications.
+
    ///
+
    /// Note that if `--no-sync` was used, you can use `rad sync -a` to announce
+
    /// at a later point.
+
    #[clap(long)]
+
    no_sync: bool,
+

+
    #[clap(subcommand)]
+
    command: command::Command,
+
}
+

+
impl Args {
+
    fn repository(&self, profile: &Profile) -> Result<Repository, error::Repository> {
+
        let repo_id = if let Some(repo_id) = self.repository {
+
            repo_id
+
        } else {
+
            let (_repo, repo_id) = radicle::rad::cwd().map_err(error::Repository::Cwd)?;
+
            repo_id
+
        };
+
        profile
+
            .storage
+
            .repository(repo_id)
+
            .map_err(|err| error::Repository::Open { rid: repo_id, err })
+
    }
+
}
+

+
fn profile() -> Result<Profile, error::Profile> {
+
    Profile::load().map_err(error::Profile)
+
}
+

+
fn jobs(repo: &Repository) -> Result<Jobs<Repository>, error::Jobs> {
+
    Jobs::open(repo).map_err(|err| error::Jobs { rid: repo.id, err })
+
}
+

+
fn announce(profile: &Profile, repo_id: RepoId) -> Result<(), error::Announce> {
+
    let mut node = Node::new(profile.home.socket());
+

+
    let (synced, unsynced) = node
+
        .seeds(repo_id)
+
        .map_err(error::Announce::Seeds)?
+
        .iter()
+
        .fold(
+
            (BTreeSet::new(), BTreeSet::new()),
+
            |(mut synced, mut unsynced), seed| {
+
                if seed.is_synced() {
+
                    synced.insert(seed.nid);
+
                } else {
+
                    unsynced.insert(seed.nid);
+
                }
+
                (synced, unsynced)
+
            },
+
        );
+

+
    let announcer = Announcer::new(AnnouncerConfig::public(
+
        *profile.id(),
+
        ReplicationFactor::MustReach(1),
+
        BTreeSet::new(),
+
        synced,
+
        unsynced,
+
    ))
+
    .map_err(error::Announce::Announcer)?;
+

+
    node.announce(repo_id, TIMEOUT, announcer, |_, _| ())
+
        .map_err(error::Announce::Announcement)?;
+

+
    Ok(())
+
}
+

+
fn run(args: Args) -> Result<(), Error> {
+
    use command::*;
+

+
    let profile = profile()?;
+
    let repo = args.repository(&profile)?;
+
    let mut jobs = jobs(&repo)?;
+
    match args.command {
+
        Command::New(command) => {
+
            let signer = profile.signer().map_err(error::Signer)?;
+
            new_job(command, &mut jobs, &signer)?;
+
            if !args.no_sync {
+
                announce(&profile, repo.id)?
+
            }
+
        }
+
        Command::List(command) => list_jobs(command, &jobs)?,
+
        Command::Run(command) => {
+
            let signer = profile.signer().map_err(error::Signer)?;
+
            run_job(command, &mut jobs, &signer)?;
+
            if !args.no_sync {
+
                announce(&profile, repo.id)?
+
            }
+
        }
+
        Command::Show(command) => show_job(command, &jobs)?,
+
        Command::Failed(command) => {
+
            let signer = profile.signer().map_err(error::Signer)?;
+
            failed_job(command, &mut jobs, &signer)?;
+
            if !args.no_sync {
+
                announce(&profile, repo.id)?
+
            }
+
        }
+
        Command::Succeeded(command) => {
+
            let signer = profile.signer().map_err(error::Signer)?;
+
            succeeded_job(command, &mut jobs, &signer)?;
+
            if !args.no_sync {
+
                announce(&profile, repo.id)?
+
            }
+
        }
+
    }
+

+
    Ok(())
+
}
+

+
fn run_job<G>(
+
    command::Run { oid, url }: command::Run,
+
    jobs: &mut Jobs<'_, Repository>,
+
    signer: &Device<G>,
+
) -> Result<(), error::Run>
+
where
+
    G: Signer<crypto::Signature>,
+
{
+
    let uuid = Uuid::new_v4();
+
    let (id, job) = find_by_commit(oid, jobs)?;
+
    let mut job = JobMut::new(id, job, jobs);
+
    job.run(uuid, url, signer)
+
        .map_err(|err| error::Run::Store { id, err })?;
+
    println!("{uuid}");
+
    Ok(())
+
}
+

+
fn succeeded_job<G>(
+
    command::Succeeded { oid, run }: command::Succeeded,
+
    jobs: &mut Jobs<'_, Repository>,
+
    signer: &Device<G>,
+
) -> Result<(), error::Succeeded>
+
where
+
    G: Signer<crypto::Signature>,
+
{
+
    let (id, job) = find_by_commit(oid, jobs)?;
+
    let mut job = JobMut::new(id, job, jobs);
+
    job.finish(run, Reason::Succeeded, signer)
+
        .map_err(|err| error::Succeeded::Store { id, err })?;
+
    Ok(())
+
}
+

+
fn failed_job<G>(
+
    command::Failed { oid, run }: command::Failed,
+
    jobs: &mut Jobs<'_, Repository>,
+
    signer: &Device<G>,
+
) -> Result<(), error::Failed>
+
where
+
    G: Signer<crypto::Signature>,
+
{
+
    let (id, job) = find_by_commit(oid, jobs)?;
+
    let mut job = JobMut::new(id, job, jobs);
+
    job.finish(run, Reason::Failed, signer)
+
        .map_err(|err| error::Failed::Store { id, err })?;
+
    Ok(())
+
}
+

+
fn list_jobs(
+
    command::List { pretty, verbose }: command::List,
+
    jobs: &Jobs<'_, Repository>,
+
) -> Result<(), error::List> {
+
    let iter = jobs
+
        .all()
+
        .map_err(error::List::All)?
+
        .filter_map(|res| match res {
+
            Ok((id, job)) => Some((JobId::from(id), job)),
+
            Err(err) => {
+
                if verbose {
+
                    eprintln!("Failed to retrieve job: {err}");
+
                }
+
                None
+
            }
+
        });
+
    let jobs = display::Jobs::new(iter);
+
    if pretty {
+
        println!("{}", jobs.pretty());
+
    } else {
+
        println!(
+
            "{}",
+
            serde_json::to_string_pretty(&jobs).map_err(error::List::Json)?
+
        )
+
    }
+
    Ok(())
+
}
+

+
fn new_job<G>(
+
    command::New { oid }: command::New,
+
    jobs: &mut Jobs<Repository>,
+
    signer: &Device<G>,
+
) -> Result<(), error::New>
+
where
+
    G: Signer<crypto::Signature>,
+
{
+
    let job = jobs
+
        .create(oid, signer)
+
        .map_err(|err| error::New { oid, err })?;
+
    println!("{}", job.id());
+
    Ok(())
+
}
+

+
fn show_job(
+
    command::Show { oid }: command::Show,
+
    jobs: &Jobs<Repository>,
+
) -> Result<(), error::Show> {
+
    let (id, job) = find_by_commit(oid, jobs)?;
+
    let show = radicle_job::display::Job::new(id, &job);
+
    println!(
+
        "{}",
+
        serde_json::to_string_pretty(&show).map_err(error::Show::Json)?
+
    );
+
    Ok(())
+
}
+

+
fn find_by_commit(oid: Oid, jobs: &Jobs<Repository>) -> Result<(JobId, Job), error::Find> {
+
    jobs.find_by_commit(oid)
+
        .map_err(|err| error::Find::FindCommit { oid, err })?
+
        .next()
+
        .ok_or(error::Find::NoJob(oid))?
+
        .map_err(|err| error::Find::FindCommit { oid, err })
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
enum Error {
+
    #[error(transparent)]
+
    Profile(#[from] error::Profile),
+
    #[error(transparent)]
+
    Signer(#[from] error::Signer),
+
    #[error(transparent)]
+
    Repository(#[from] error::Repository),
+
    #[error(transparent)]
+
    Jobs(#[from] error::Jobs),
+
    #[error(transparent)]
+
    Announce(#[from] error::Announce),
+
    #[error(transparent)]
+
    Show(#[from] error::Show),
+
    #[error(transparent)]
+
    New(#[from] error::New),
+
    #[error(transparent)]
+
    List(#[from] error::List),
+
    #[error(transparent)]
+
    Run(#[from] error::Run),
+
    #[error(transparent)]
+
    Failed(#[from] error::Failed),
+
    #[error(transparent)]
+
    Succeeded(#[from] error::Succeeded),
+
}
+

+
mod command {
+
    use clap::Parser;
+
    use radicle::git::Oid;
+
    use url::Url;
+
    use uuid::Uuid;
+

+
    #[derive(Parser)]
+
    pub enum Command {
+
        New(New),
+
        List(List),
+
        Run(Run),
+
        Show(Show),
+
        Failed(Failed),
+
        Succeeded(Succeeded),
+
    }
+

+
    /// List all job COBs for a repository.
+
    #[derive(Parser)]
+
    pub struct List {
+
        /// Format output in a more human oriented way than JSON.
+
        #[clap(long)]
+
        pub pretty: bool,
+
        /// Output all information of listing jobs, including intermediate errors
+
        #[clap(long, short)]
+
        pub verbose: bool,
+
    }
+

+
    /// Create a job COB for a specific Git commit.
+
    ///
+
    /// Write the job ID to the standard output. It is not usually needed,
+
    /// as job COBs are found using the Git commit ID they refer to.
+
    #[derive(Parser)]
+
    pub struct New {
+
        /// Git object id for the commit.
+
        pub oid: Oid,
+
    }
+

+
    /// Show the job COB for a Git commit.
+
    #[derive(Parser)]
+
    pub struct Show {
+
        /// Git object for the commit.
+
        pub oid: Oid,
+
    }
+

+
    /// Record a new run for an existing job COB.
+
    ///
+
    /// The new run is marked as started. See subcommands "succeeded" and
+
    /// "failed" to change status.
+
    ///
+
    /// Write the UUID of the new run to standard output. It is needed to
+
    /// change mark run as finished.
+
    #[derive(Parser)]
+
    pub struct Run {
+
        /// Git commit which the run is processing.
+
        pub oid: Oid,
+
        /// URL to information about the run, such a a run log.
+
        pub url: Url,
+
    }
+

+
    /// Mark a run as having finished in failure.
+
    #[derive(Parser)]
+
    pub struct Failed {
+
        /// Git commit which the run is processing.
+
        pub oid: Oid,
+
        /// Run identifier, a UUID.
+
        pub run: Uuid,
+
    }
+

+
    /// Mark a run as having finished successfully.
+
    #[derive(Parser)]
+
    pub struct Succeeded {
+
        /// Git commit which the run is processing.
+
        pub oid: Oid,
+
        /// Run identifier, a UUID.
+
        pub run: Uuid,
+
    }
+
}
+

+
mod error {
+
    use radicle::{
+
        node::{self, sync::AnnouncerError},
+
        rad::RemoteError,
+
        storage::RepositoryError,
+
    };
+
    use thiserror::Error;
+

+
    use super::*;
+

+
    #[derive(Debug, Error)]
+
    pub enum Show {
+
        #[error(transparent)]
+
        Find(#[from] Find),
+
        #[error("failed to show job, could not serialize to JSON")]
+
        Json(#[source] serde_json::Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[error("failed to create a new job for the commit {oid}")]
+
    pub struct New {
+
        pub oid: Oid,
+
        #[source]
+
        pub err: cob::store::Error,
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum List {
+
        #[error("failed to list jobs")]
+
        All(#[source] cob::store::Error),
+
        #[error("failed to list jobs, could not serialize to JSON")]
+
        Json(#[source] serde_json::Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Run {
+
        #[error(transparent)]
+
        Find(#[from] Find),
+
        #[error("failed to add new run for job {id}")]
+
        Store {
+
            id: JobId,
+
            #[source]
+
            err: cob::store::Error,
+
        },
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Succeeded {
+
        #[error(transparent)]
+
        Find(#[from] Find),
+
        #[error("failed to mark job {id} as succeeded")]
+
        Store {
+
            id: JobId,
+
            #[source]
+
            err: cob::store::Error,
+
        },
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Failed {
+
        #[error(transparent)]
+
        Find(#[from] Find),
+
        #[error("failed to mark job {id} as failed")]
+
        Store {
+
            id: JobId,
+
            #[source]
+
            err: cob::store::Error,
+
        },
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Find {
+
        #[error("no job was found for the commit {0}")]
+
        NoJob(Oid),
+
        #[error("failed to find a job for the commit {oid}")]
+
        FindCommit {
+
            oid: Oid,
+
            #[source]
+
            err: cob::store::Error,
+
        },
+
    }
+

+
    #[derive(Debug, Error)]
+
    pub enum Repository {
+
        #[error("failed to find Radicle repository for current working directory")]
+
        Cwd(#[source] RemoteError),
+
        #[error("failed to open Radicle repository {rid}")]
+
        Open {
+
            rid: RepoId,
+
            #[source]
+
            err: RepositoryError,
+
        },
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[error("failed to load Radicle profile")]
+
    pub struct Profile(#[source] pub profile::Error);
+

+
    #[derive(Debug, Error)]
+
    #[error("failed to get the signing key of the Radicle profile")]
+
    pub struct Signer(#[source] pub profile::Error);
+

+
    #[derive(Debug, Error)]
+
    pub enum Announce {
+
        #[error("failed to get seeds for announcing changes")]
+
        Seeds(#[source] node::Error),
+
        #[error("failed to announce changes")]
+
        Announcer(AnnouncerError),
+
        #[error("failed to announce changes")]
+
        Announcement(#[source] node::Error),
+
    }
+

+
    #[derive(Debug, Error)]
+
    #[error("failed to open job store for {rid}")]
+
    pub struct Jobs {
+
        pub rid: RepoId,
+
        #[source]
+
        pub err: RepositoryError,
+
    }
+
}