Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: remove job cob
Merged fintohaps opened 11 months ago

The Job COB is defined as separate crate: radicle-job. So this change removes its definition and use from the heartwood repository.

11 files changed +0 -1007 f4c8ff7a e6ef767f
deleted radicle-cli/examples/rad-job.md
@@ -1,87 +0,0 @@
-
The `rad job` command lets you manage job COBs. Let's first checkout the
-
`heartwood` repository:
-

-
```
-
$ rad checkout rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji
-
✓ Repository checkout successful under ./heartwood
-
$ cd heartwood
-
```
-

-
Using the `rad job` (or `rad job list`) command we can see that there are
-
currently no jobs listed:
-

-
```
-
$ rad job
-
Nothing to show.
-
```
-

-
Let's create a job to represent a new CI run. We check what the current `HEAD`
-
of the repository is, and use the `rad job trigger` to start a fresh job for
-
that commit:
-

-
```
-
$ git rev-parse HEAD
-
f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
$ rad job trigger f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354
-
╭──────────────────────────────────────────────────╮
-
│ Job     fbbda2447c30ebbab9b746498cd41a383ff05225 │
-
│ Commit  f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
-
│ State   fresh                                    │
-
╰──────────────────────────────────────────────────╯
-
```
-

-
Let's check the list again, and we should we see our fresh job there:
-

-
```
-
$ rad job
-
╭───────────────────────────────╮
-
│ ●   ID        Commit    State │
-
├───────────────────────────────┤
-
│ ●   fbbda24   f2de534   fresh │
-
╰───────────────────────────────╯
-
```
-

-
From there we can start a new job, assigning an arbitrary identifier `xyzzy`,
-
which would usually from the CI system that is running the job:
-

-
```
-
$ rad job start fbbda2447c30ebbab9b746498cd41a383ff05225 xyzzy
-
```
-

-
Checking the job again, we can now see that the job is `running`:
-

-
```
-
$ rad job
-
╭─────────────────────────────────╮
-
│ ●   ID        Commit    State   │
-
├─────────────────────────────────┤
-
│ ●   fbbda24   f2de534   running │
-
╰─────────────────────────────────╯
-
$ rad job show fbbda2447c30ebbab9b746498cd41a383ff05225
-
╭──────────────────────────────────────────────────╮
-
│ Job     fbbda2447c30ebbab9b746498cd41a383ff05225 │
-
│ Commit  f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
-
│ State   running                                  │
-
│ Run ID  xyzzy                                    │
-
╰──────────────────────────────────────────────────╯
-
```
-

-
When a job has finished, we can mark it as done -- either with a `--success` or
-
`--failed` flag -- using the `rad job finish` command:
-

-
```
-
$ rad job finish --success fbbda2447c30ebbab9b746498cd41a383ff05225
-
$ rad job
-
╭───────────────────────────────────╮
-
│ ●   ID        Commit    State     │
-
├───────────────────────────────────┤
-
│ ●   fbbda24   f2de534   succeeded │
-
╰───────────────────────────────────╯
-
$ rad job show fbbda2447c30ebbab9b746498cd41a383ff05225
-
╭──────────────────────────────────────────────────╮
-
│ Job     fbbda2447c30ebbab9b746498cd41a383ff05225 │
-
│ Commit  f2de534b5e81d7c6e2dcaf58c3dd91573c0a0354 │
-
│ State   succeeded                                │
-
│ Run ID  xyzzy                                    │
-
╰──────────────────────────────────────────────────╯
-
```
modified radicle-cli/src/commands.rs
@@ -32,8 +32,6 @@ pub mod rad_init;
pub mod rad_inspect;
#[path = "commands/issue.rs"]
pub mod rad_issue;
-
#[path = "commands/job.rs"]
-
pub mod rad_job;
#[path = "commands/ls.rs"]
pub mod rad_ls;
#[path = "commands/node.rs"]
modified radicle-cli/src/commands/help.rs
@@ -25,7 +25,6 @@ const COMMANDS: &[Help] = &[
    rad_inbox::HELP,
    rad_inspect::HELP,
    rad_issue::HELP,
-
    rad_job::HELP,
    rad_ls::HELP,
    rad_node::HELP,
    rad_patch::HELP,
deleted radicle-cli/src/commands/job.rs
@@ -1,380 +0,0 @@
-
#![allow(clippy::or_fun_call)]
-
use std::ffi::OsString;
-

-
use anyhow::{anyhow, Context as _};
-

-
use radicle::cob::job::{JobStore, Reason, State};
-
use radicle::crypto;
-
use radicle::node::device::Device;
-
use radicle::node::{Handle, NodeId};
-
use radicle::storage::{WriteRepository, WriteStorage};
-
use radicle::{cob, Node};
-

-
use crate::git::Rev;
-
use crate::terminal as term;
-
use crate::terminal::args::{Args, Error, Help};
-
use crate::terminal::Element;
-

-
pub const HELP: Help = Help {
-
    name: "job",
-
    description: "Manage automated jobs on a repository",
-
    version: env!("CARGO_PKG_VERSION"),
-
    usage: r#"
-
Usage
-

-
    rad job [<option>...]
-
    rad job trigger <commit-id>
-
    rad job start <job-id> <run-id> [<url>]
-
    rad job list
-
    rad job show <job-id>
-
    rad job finish <job-id> [--success | --failed]
-
    rad job delete <job-id>
-

-
Options
-

-
    --no-announce     Don't announce job records to peers
-
    --quiet, -q       Don't print anything
-
    --help            Print help
-
"#,
-
};
-

-
#[derive(Default, Debug, PartialEq, Eq)]
-
pub enum OperationName {
-
    Trigger,
-
    Start,
-
    #[default]
-
    List,
-
    Show,
-
    Finish,
-
    Delete,
-
}
-

-
#[derive(Debug, PartialEq, Eq)]
-
pub enum Operation {
-
    Trigger {
-
        commit: Rev,
-
    },
-
    Start {
-
        job_id: Rev,
-
        run_id: String,
-
        info_url: Option<String>,
-
    },
-
    List,
-
    Show {
-
        job_id: Rev,
-
    },
-
    Finish {
-
        job_id: Rev,
-
        reason: Reason,
-
    },
-
    Delete {
-
        job_id: Rev,
-
    },
-
}
-

-
#[derive(Debug)]
-
pub struct Options {
-
    pub op: Operation,
-
    pub announce: bool,
-
    pub quiet: bool,
-
}
-

-
impl Args for Options {
-
    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
-
        use lexopt::prelude::*;
-

-
        let mut parser = lexopt::Parser::from_args(args);
-
        let mut op: Option<OperationName> = None;
-
        let mut commit: Option<Rev> = None;
-
        let mut job_id: Option<Rev> = None;
-
        let mut run_id: Option<String> = None;
-
        let mut info_url: Option<String> = None;
-
        let mut announce = true;
-
        let mut quiet = false;
-
        let mut succeeded = false;
-
        let mut failed = false;
-

-
        while let Some(arg) = parser.next()? {
-
            match arg {
-
                Long("help") | Short('h') => {
-
                    return Err(Error::Help.into());
-
                }
-
                Long("no-announce") => {
-
                    announce = false;
-
                }
-
                Long("quiet") | Short('q') => {
-
                    quiet = true;
-
                }
-
                Long("success") | Long("succeeded") | Short('s') => {
-
                    succeeded = true;
-
                }
-
                Long("failure") | Long("failed") | Short('f') => {
-
                    failed = true;
-
                }
-
                Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
-
                    "trigger" => op = Some(OperationName::Trigger),
-
                    "start" => op = Some(OperationName::Start),
-
                    "list" => op = Some(OperationName::List),
-
                    "show" => op = Some(OperationName::Show),
-
                    "finish" => op = Some(OperationName::Finish),
-
                    "delete" => op = Some(OperationName::Delete),
-

-
                    unknown => anyhow::bail!("unknown operation '{}'", unknown),
-
                },
-
                Value(val) if commit.is_none() && op == Some(OperationName::Trigger) => {
-
                    let val = term::args::oid(&val)?;
-
                    let val = Rev::from(val.to_string());
-
                    commit = Some(val);
-
                }
-
                Value(val)
-
                    if job_id.is_none()
-
                        && op.is_some()
-
                        && matches!(
-
                            op.as_ref().unwrap(),
-
                            OperationName::Start
-
                                | OperationName::Show
-
                                | OperationName::Finish
-
                                | OperationName::Delete
-
                        ) =>
-
                {
-
                    let val = term::args::oid(&val)?;
-
                    let val = Rev::from(val.to_string());
-
                    job_id = Some(val);
-
                }
-
                Value(val)
-
                    if job_id.is_some()
-
                        && run_id.is_none()
-
                        && op.is_some()
-
                        && matches!(op.as_ref().unwrap(), OperationName::Start) =>
-
                {
-
                    run_id = Some(val.to_str().unwrap().to_string());
-
                }
-
                Value(val)
-
                    if job_id.is_some()
-
                        && run_id.is_some()
-
                        && op.is_some()
-
                        && matches!(op.as_ref().unwrap(), OperationName::Start) =>
-
                {
-
                    info_url = Some(val.to_str().unwrap().to_string());
-
                }
-
                _ => {
-
                    return Err(anyhow!(arg.unexpected()));
-
                }
-
            }
-
        }
-

-
        let op = match op.unwrap_or_default() {
-
            OperationName::Trigger => Operation::Trigger {
-
                commit: commit.ok_or_else(|| anyhow!("a commit id must be provided"))?,
-
            },
-
            OperationName::Start => Operation::Start {
-
                job_id: job_id.ok_or_else(|| anyhow!("a job id must be provided"))?,
-
                run_id: run_id.ok_or_else(|| anyhow!("a run id must be provided"))?,
-
                info_url,
-
            },
-
            OperationName::List => Operation::List,
-
            OperationName::Show => Operation::Show {
-
                job_id: job_id.ok_or_else(|| anyhow!("a job id must be provided"))?,
-
            },
-
            OperationName::Finish => Operation::Finish {
-
                job_id: job_id.ok_or_else(|| anyhow!("a job id must be provided"))?,
-
                reason: if !succeeded && !failed {
-
                    return Err(anyhow!("must give one of --success or --failure"))?;
-
                } else if succeeded && failed {
-
                    return Err(anyhow!("must give one of --success or --failure, not both"))?;
-
                } else if succeeded {
-
                    Reason::Succeeded
-
                } else {
-
                    Reason::Failed
-
                },
-
            },
-
            OperationName::Delete => Operation::Delete {
-
                job_id: job_id.ok_or_else(|| anyhow!("a job id to remove must be provided"))?,
-
            },
-
        };
-

-
        Ok((
-
            Options {
-
                op,
-
                announce,
-
                quiet,
-
            },
-
            vec![],
-
        ))
-
    }
-
}
-

-
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
-
    let profile = ctx.profile()?;
-
    let (_, rid) = radicle::rad::cwd()?;
-
    let repo = profile.storage.repository_mut(rid)?;
-
    let announce = options.announce
-
        && matches!(
-
            &options.op,
-
            Operation::Trigger { .. }
-
                | Operation::Start { .. }
-
                | Operation::Finish { .. }
-
                | Operation::Delete { .. }
-
        );
-

-
    let mut node = Node::new(profile.socket());
-
    let mut ci_store = JobStore::open(&repo)?;
-

-
    match options.op {
-
        Operation::Trigger { commit } => {
-
            let signer = term::signer(&profile)?;
-
            trigger(&commit, &mut ci_store, &repo, &signer, options.quiet)?;
-
        }
-
        Operation::Start {
-
            job_id,
-
            run_id,
-
            info_url,
-
        } => {
-
            let signer = term::signer(&profile)?;
-
            start(&job_id, &run_id, info_url, &mut ci_store, &repo, &signer)?;
-
        }
-
        Operation::List => {
-
            list(&ci_store)?;
-
        }
-
        Operation::Show { job_id } => {
-
            show(&job_id, &ci_store, &repo)?;
-
        }
-
        Operation::Finish { job_id, reason } => {
-
            let signer = term::signer(&profile)?;
-
            finish(&job_id, reason, &mut ci_store, &repo, &signer)?;
-
        }
-
        Operation::Delete { job_id } => {
-
            let signer = term::signer(&profile)?;
-
            let job_id = job_id.resolve(&repo.backend)?;
-
            ci_store.remove(&job_id, &signer)?;
-
        }
-
    }
-

-
    if announce {
-
        match node.announce_refs(rid) {
-
            Ok(_) => {}
-
            Err(e) if e.is_connection_err() => {}
-
            Err(e) => return Err(e.into()),
-
        }
-
    }
-

-
    Ok(())
-
}
-

-
fn trigger<R, G>(
-
    commit: &Rev,
-
    store: &mut JobStore<R>,
-
    repo: &radicle::storage::git::Repository,
-
    signer: &Device<G>,
-
    quiet: bool,
-
) -> anyhow::Result<()>
-
where
-
    R: WriteRepository + cob::Store<Namespace = NodeId>,
-
    G: crypto::signature::Signer<crypto::Signature>,
-
{
-
    let commit = commit.resolve(&repo.backend)?;
-
    let job = store.create(commit, signer)?;
-
    if !quiet {
-
        term::job::show(&job, job.id())?;
-
    }
-
    Ok(())
-
}
-

-
fn start<R, G>(
-
    job_id: &Rev,
-
    run_id: &str,
-
    info_url: Option<String>,
-
    store: &mut JobStore<R>,
-
    repo: &radicle::storage::git::Repository,
-
    signer: &Device<G>,
-
) -> anyhow::Result<()>
-
where
-
    R: WriteRepository + cob::Store<Namespace = NodeId>,
-
    G: crypto::signature::Signer<crypto::Signature>,
-
{
-
    let job_id = job_id.resolve(&repo.backend)?;
-
    let mut job = store.get_mut(&job_id)?;
-

-
    job.start(run_id.to_string(), info_url, signer)?;
-

-
    Ok(())
-
}
-

-
// TODO: This should use the COB cache for performance.
-
fn list<R: WriteRepository + cob::Store<Namespace = NodeId>>(
-
    store: &JobStore<R>,
-
) -> anyhow::Result<()> {
-
    if store.is_empty()? {
-
        term::print(term::format::italic("Nothing to show."));
-
        return Ok(());
-
    }
-

-
    let mut table = term::Table::new(term::table::TableOptions::bordered());
-
    table.header([
-
        term::format::dim(String::from("●")),
-
        term::format::bold(String::from("ID")),
-
        term::format::bold(String::from("Commit")),
-
        term::format::bold(String::from("State")),
-
    ]);
-
    table.divider();
-

-
    for result in store.all()? {
-
        let Ok((id, ci)) = result else {
-
            // Skip COBs that failed to load.
-
            continue;
-
        };
-
        table.push([
-
            match ci.state() {
-
                State::Fresh => term::format::positive("●").into(),
-
                State::Running => term::format::positive("●").into(),
-
                State::Finished(Reason::Succeeded) => term::format::positive("●").into(),
-
                State::Finished(Reason::Failed) => term::format::negative("●").into(),
-
            },
-
            term::format::tertiary(term::format::cob(&id).to_string()),
-
            term::format::tertiary(term::format::oid(ci.commit()).to_string()),
-
            term::format::tertiary(term::format::job_state(ci.state()).to_string()),
-
        ]);
-
    }
-

-
    if table.is_empty() {
-
        term::print(term::format::dim("No jobs to show."));
-
    } else {
-
        table.print();
-
    }
-

-
    Ok(())
-
}
-

-
fn show<R: WriteRepository + cob::Store<Namespace = NodeId>>(
-
    job_id: &Rev,
-
    store: &JobStore<R>,
-
    repo: &radicle::storage::git::Repository,
-
) -> anyhow::Result<()> {
-
    let job_id = job_id.resolve(&repo.backend)?;
-
    let job = store
-
        .get(&job_id)?
-
        .context("No job with the given ID exists")?;
-

-
    term::job::show(&job, &job_id)?;
-

-
    Ok(())
-
}
-

-
fn finish<R, G>(
-
    job_id: &Rev,
-
    reason: Reason,
-
    store: &mut JobStore<R>,
-
    repo: &radicle::storage::git::Repository,
-
    signer: &Device<G>,
-
) -> anyhow::Result<()>
-
where
-
    R: WriteRepository + cob::Store<Namespace = NodeId>,
-
    G: crypto::signature::Signer<crypto::Signature>,
-
{
-
    let job_id = job_id.resolve(&repo.backend)?;
-
    let mut job = store.get_mut(&job_id)?;
-

-
    job.finish(reason, signer)?;
-

-
    Ok(())
-
}
modified radicle-cli/src/main.rs
@@ -232,13 +232,6 @@ fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyhow::Error>>
                args.to_vec(),
            );
        }
-
        "job" => {
-
            term::run_command_args::<rad_job::Options, _>(
-
                rad_job::HELP,
-
                rad_job::run,
-
                args.to_vec(),
-
            );
-
        }
        "ls" => {
            term::run_command_args::<rad_ls::Options, _>(rad_ls::HELP, rad_ls::run, args.to_vec());
        }
modified radicle-cli/src/terminal.rs
@@ -2,7 +2,6 @@ pub mod args;
pub use args::{Args, Error, Help};
pub mod format;
pub mod io;
-
pub mod job;
pub use io::signer;
pub mod cob;
pub mod comment;
modified radicle-cli/src/terminal/format.rs
@@ -30,11 +30,6 @@ pub fn oid(oid: impl Into<radicle::git::Oid>) -> Paint<String> {
    Paint::new(format!("{:.7}", oid.into()))
}

-
/// Format a job COB state.
-
pub fn job_state(state: radicle::cob::job::State) -> Paint<String> {
-
    Paint::new(format!("{}", state))
-
}
-

/// Wrap parenthesis around styled input, eg. `"input"` -> `"(input)"`.
pub fn parens<D: fmt::Display>(input: Paint<D>) -> Paint<String> {
    Paint::new(format!("({})", input.item)).with_style(input.style)
deleted radicle-cli/src/terminal/job.rs
@@ -1,51 +0,0 @@
-
use radicle::cob;
-
use radicle::cob::job;
-
use radicle_term::table::TableOptions;
-
use radicle_term::{Table, VStack};
-

-
use crate::terminal as term;
-
use crate::terminal::Element;
-

-
pub fn show(job: &job::Job, id: &cob::ObjectId) -> anyhow::Result<()> {
-
    let mut attrs = Table::<2, term::Line>::new(TableOptions {
-
        spacing: 2,
-
        ..TableOptions::default()
-
    });
-

-
    attrs.push([
-
        term::format::tertiary("Job".to_owned()).into(),
-
        term::format::bold(id.to_string()).into(),
-
    ]);
-

-
    attrs.push([
-
        term::format::tertiary("Commit".to_owned()).into(),
-
        term::format::bold(job.commit().to_owned()).into(),
-
    ]);
-

-
    attrs.push([
-
        term::format::tertiary("State".to_owned()).into(),
-
        term::format::bold(job.state().to_string()).into(),
-
    ]);
-

-
    if let Some(run_id) = job.run_id() {
-
        attrs.push([
-
            term::format::tertiary("Run ID".to_owned()).into(),
-
            term::format::bold(run_id.to_string()).into(),
-
        ]);
-
    }
-

-
    if let Some(info_url) = job.info_url() {
-
        attrs.push([
-
            term::format::tertiary("Info URL".to_owned()).into(),
-
            term::format::bold(info_url.to_string()).into(),
-
        ]);
-
    }
-

-
    let widget = VStack::default()
-
        .border(Some(term::colors::FAINT))
-
        .child(attrs);
-

-
    widget.print();
-

-
    Ok(())
-
}
modified radicle-cli/tests/commands.rs
@@ -3159,17 +3159,3 @@ fn rad_workflow() {
    )
    .unwrap();
}
-

-
#[test]
-
fn rad_job() {
-
    let mut environment = Environment::new();
-
    let profile = environment.profile(config::profile("alice"));
-
    let home = &profile.home;
-
    let working = environment.tmp().join("working");
-

-
    // Setup a test repository.
-
    fixtures::repository(&working);
-

-
    test("examples/rad-init.md", &working, Some(home), []).unwrap();
-
    test("examples/rad-job.md", &working, Some(home), []).unwrap();
-
}
modified radicle/src/cob.rs
@@ -4,7 +4,6 @@ pub mod common;
pub mod external;
pub mod identity;
pub mod issue;
-
pub mod job;
pub mod op;
pub mod patch;
pub mod store;
deleted radicle/src/cob/job.rs
@@ -1,458 +0,0 @@
-
//! Track "jobs" related to a repository.
-
//!
-
//! The purpose of this COB is to allow users of Radicle to have a way
-
//! of keeping track of what automated processing of changes to a
-
//! repository have been done. A "job" might be a continuous
-
//! integration automation building the software in a repository and
-
//! running its automated tests. A delegate for the repository could
-
//! track COBs emitted by trusted nodes to help with deciding when a
-
//! patch is ready for them to merge.
-

-
use std::{ops::Deref, str::FromStr};
-

-
use once_cell::sync::Lazy;
-
use serde::{Deserialize, Serialize};
-

-
use crate::cob;
-
use crate::cob::change::store::Entry;
-
use crate::cob::store;
-
use crate::cob::store::{Cob, CobAction, Store, Transaction};
-
use crate::cob::{EntryId, ObjectId, TypeName};
-
use crate::crypto::ssh::ExtendedSignature;
-
use crate::git;
-
use crate::node::device::Device;
-
use crate::node::NodeId;
-
use crate::prelude::ReadRepository;
-
use crate::storage::{Oid, WriteRepository};
-

-
use super::store::CobWithType;
-

-
/// The name of this COB type. Note that this is a "beta" COB, which
-
/// means it's not meant for others to rely on yet, and we may change
-
/// it without warning.
-
pub static TYPENAME: Lazy<TypeName> =
-
    Lazy::new(|| FromStr::from_str("xyz.radicle.beta.job").expect("type name is valid"));
-

-
/// An identifier for the job.
-
pub type JobId = ObjectId;
-

-
/// All the possible errors from this type of COB.
-
#[derive(Debug, thiserror::Error)]
-
pub enum Error {
-
    #[error("initialization failed: {0}")]
-
    Init(&'static str),
-
    #[error("op decoding failed: {0}")]
-
    Op(#[from] cob::op::OpEncodingError),
-
    #[error("store: {0}")]
-
    Store(#[from] store::Error),
-
    #[error("can't trigger a job which is not fresh")]
-
    TriggerWhenNotFresh,
-
    #[error("can't start a job which is not triggered")]
-
    StartWhenNotFresh,
-
    #[error("can't finish a job which is not running")]
-
    FinishWhenNotRunning,
-
}
-

-
/// All the possible states of this COB.
-
///
-
/// This is primarily modeled for CI, for now. A COB is created by a
-
/// node when it triggers a CI run, and then updated when the CI
-
/// system actually starts executing the run, and finishes. The CI
-
/// system assigns an identifier to the run, and may have a URL for
-
/// the log. These can also be stored in the COB.
-
///
-
/// This COB is essentially a state machine that tracks the state of
-
/// an automated run Ci. When the COB is created, in state `Fresh`, it
-
/// just records the git commit the run uses. This can't be changed.
-
/// The COB may be created before the run actually starts. Once the
-
/// run starts, the COB state changes to `Running`. When the run
-
/// finished, the state changes to `Finished`, and the state records
-
/// why the run finished: `Succeeded` or `Failed`.
-
///
-
/// No other state changes are allowed for the COB.
-
///
-
/// Note that if CI runs again for the same commit, a new COB is
-
/// created. The two runs may result in different outcomes, even if
-
/// nothing in the source code has changed. For example, the CI system
-
/// may run out of disk space, or use different versions of the
-
/// software used in the run.
-
#[derive(Debug, Default, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
-
#[serde(rename_all = "camelCase", tag = "status")]
-
pub enum State {
-
    /// COB has been created, job has not yet started running.
-
    #[default]
-
    Fresh,
-
    /// Job has started running.
-
    Running,
-
    /// Job has finished.
-
    Finished(Reason),
-
}
-

-
impl std::fmt::Display for State {
-
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-
        match self {
-
            Self::Fresh => write!(f, "fresh"),
-
            Self::Running => write!(f, "running"),
-
            Self::Finished(Reason::Succeeded) => write!(f, "succeeded"),
-
            Self::Finished(Reason::Failed) => write!(f, "failed"),
-
        }
-
    }
-
}
-

-
/// Why did build finish?
-
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq, Serialize, Deserialize)]
-
pub enum Reason {
-
    /// Build was successful.
-
    Succeeded,
-
    /// Build failed for some reason.
-
    Failed,
-
}
-

-
/// Actions to update this COB.
-
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
-
#[serde(tag = "type", rename_all = "camelCase")]
-
pub enum Action {
-
    /// Initialize the COB for a new job.
-
    Trigger { commit: git::Oid },
-

-
    /// Start a job.
-
    Start {
-
        run_id: String,
-
        info_url: Option<String>,
-
    },
-

-
    /// Finish a job.
-
    Finish { reason: Reason },
-
}
-

-
impl CobAction for Action {}
-

-
/// Type of COB operation.
-
pub type Op = cob::Op<Action>;
-

-
/// The COB with actions applied.
-
///
-
/// A job is based on a specific commit. This is set when the COB is
-
/// created and can't be changed.
-
///
-
/// A job has a specific [`State`].
-
///
-
/// A job may have a "run id", which is an arbitrary string. It might
-
/// be the identifier for a CI run, set by an external CI system, for
-
/// example. The id is informational and there no guarantees what it
-
/// means, or that it's unique.
-
///
-
/// A job may also store a URL to more information. This might be a
-
/// link to a run log in a CI system, for example.
-
#[derive(Debug, Clone, PartialEq, Eq)]
-
pub struct Job {
-
    commit: git::Oid,
-
    state: State,
-
    run_id: Option<String>,
-
    info_url: Option<String>,
-
}
-

-
impl Job {
-
    /// Create a new `Job` in the `Fresh` state, using the provided `commit`.
-
    fn new(commit: git::Oid) -> Self {
-
        Self {
-
            commit,
-
            state: State::default(),
-
            run_id: None,
-
            info_url: None,
-
        }
-
    }
-

-
    /// Get the commit that this `Job` was created with.
-
    pub fn commit(&self) -> git::Oid {
-
        self.commit
-
    }
-

-
    /// Get the run identifier, if any, that was associated with this `Job`.
-
    pub fn run_id(&self) -> Option<&str> {
-
        self.run_id.as_deref()
-
    }
-

-
    /// Get the info URL, if any, that was associated with this `Job`.
-
    pub fn info_url(&self) -> Option<&str> {
-
        self.info_url.as_deref()
-
    }
-

-
    /// Get the `State` of this `Job`.
-
    pub fn state(&self) -> State {
-
        self.state
-
    }
-

-
    /// Apply a single action to the job.
-
    fn action(&mut self, action: Action) -> Result<(), Error> {
-
        match action {
-
            Action::Trigger { .. } => {
-
                if self.state != State::Fresh {
-
                    return Err(Error::TriggerWhenNotFresh);
-
                }
-
            }
-

-
            Action::Start { run_id, info_url } => {
-
                if self.state != State::Fresh {
-
                    return Err(Error::StartWhenNotFresh);
-
                }
-
                self.state = State::Running;
-
                self.run_id = Some(run_id);
-
                self.info_url = info_url;
-
            }
-

-
            Action::Finish { reason } => {
-
                if self.state != State::Running {
-
                    return Err(Error::FinishWhenNotRunning);
-
                }
-
                self.state = State::Finished(reason);
-
            }
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl Cob for Job {
-
    type Action = Action;
-
    type Error = Error;
-

-
    fn from_root<R: ReadRepository>(op: Op, _repo: &R) -> Result<Self, Self::Error> {
-
        let mut actions = op.actions.into_iter();
-
        let Some(Action::Trigger { commit }) = actions.next() else {
-
            return Err(Error::Init("the first action must be of type `trigger`"));
-
        };
-
        let mut job = Job::new(commit);
-

-
        for action in actions {
-
            job.action(action)?;
-
        }
-
        Ok(job)
-
    }
-

-
    fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a cob::Entry>>(
-
        &mut self,
-
        op: Op,
-
        _concurrent: I,
-
        _repo: &R,
-
    ) -> Result<(), Error> {
-
        // Some day this needs to check authorization. However, we
-
        // don't yet know what the rules should be.
-
        for action in op.actions {
-
            self.action(action)?;
-
        }
-
        Ok(())
-
    }
-
}
-

-
impl CobWithType for Job {
-
    fn type_name() -> &'static TypeName {
-
        &TYPENAME
-
    }
-
}
-

-
impl<R: ReadRepository> cob::Evaluate<R> for Job {
-
    type Error = Error;
-

-
    fn init(entry: &cob::Entry, repo: &R) -> Result<Self, Self::Error> {
-
        let op = Op::try_from(entry)?;
-
        let job = Job::from_root(op, repo)?;
-
        Ok(job)
-
    }
-

-
    fn apply<'a, I>(
-
        &mut self,
-
        entry: &cob::Entry,
-
        concurrent: I,
-
        repo: &R,
-
    ) -> Result<(), Self::Error>
-
    where
-
        I: Iterator<Item = (&'a Oid, &'a Entry<Oid, Oid, ExtendedSignature>)>,
-
    {
-
        let op = Op::try_from(entry)?;
-
        self.op(op, concurrent.map(|(_, e)| e), repo)
-
    }
-
}
-

-
impl<R: ReadRepository> Transaction<Job, R> {
-
    /// Push an [`Action::Trigger`] which will create a new `Job` with the
-
    /// provided `commit` in the [`State::Fresh`] state.
-
    pub fn trigger(&mut self, commit: git::Oid) -> Result<(), store::Error> {
-
        self.push(Action::Trigger { commit })
-
    }
-

-
    /// Push an [`Action::Start`] which will start the `Job` with the provided
-
    /// metadata and move the `Job` into the [`State::Running`] state.
-
    pub fn start(&mut self, run_id: String, info_url: Option<String>) -> Result<(), store::Error> {
-
        self.push(Action::Start { run_id, info_url })
-
    }
-

-
    /// Push an [`Action::Finish`] which will finish the `Job` with the provided
-
    /// reason, moving the `Job` into the [`State::Finished`] state.
-
    pub fn finish(&mut self, reason: Reason) -> Result<(), store::Error> {
-
        self.push(Action::Finish { reason })
-
    }
-
}
-

-
pub struct JobMut<'a, 'g, R> {
-
    id: ObjectId,
-
    job: Job,
-
    store: &'g mut JobStore<'a, R>,
-
}
-

-
impl<'a, 'g, R> From<JobMut<'a, 'g, R>> for (JobId, Job) {
-
    fn from(value: JobMut<'a, 'g, R>) -> Self {
-
        (value.id, value.job)
-
    }
-
}
-

-
impl<R> std::fmt::Debug for JobMut<'_, '_, R> {
-
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
-
        f.debug_struct("JobMut")
-
            .field("id", &self.id)
-
            .field("job", &self.job)
-
            .finish()
-
    }
-
}
-

-
impl<R> JobMut<'_, '_, R>
-
where
-
    R: WriteRepository + cob::Store<Namespace = NodeId>,
-
{
-
    /// Reload the COB from storage.
-
    pub fn reload(&mut self) -> Result<(), store::Error> {
-
        self.job = self
-
            .store
-
            .get(&self.id)?
-
            .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), self.id))?;
-
        Ok(())
-
    }
-

-
    pub fn id(&self) -> &ObjectId {
-
        &self.id
-
    }
-

-
    /// Transition the `Job` into a running state, storing the provided
-
    /// metadata.
-
    pub fn start<G>(
-
        &mut self,
-
        run_id: String,
-
        info_url: Option<String>,
-
        signer: &Device<G>,
-
    ) -> Result<EntryId, Error>
-
    where
-
        G: crypto::signature::Signer<crypto::Signature>,
-
    {
-
        self.transaction("Start", signer, |tx| {
-
            tx.start(run_id, info_url)?;
-
            Ok(())
-
        })
-
    }
-

-
    /// Transition the `Job` into a finished state, with the provided `reason`.
-
    pub fn finish<G>(&mut self, reason: Reason, signer: &Device<G>) -> Result<EntryId, Error>
-
    where
-
        G: crypto::signature::Signer<crypto::Signature>,
-
    {
-
        self.transaction("Finish", signer, |tx| tx.finish(reason))
-
    }
-

-
    pub fn transaction<G, F>(
-
        &mut self,
-
        message: &str,
-
        signer: &Device<G>,
-
        operations: F,
-
    ) -> Result<EntryId, Error>
-
    where
-
        G: crypto::signature::Signer<crypto::Signature>,
-
        F: FnOnce(&mut Transaction<Job, R>) -> Result<(), store::Error>,
-
    {
-
        let mut tx = Transaction::default();
-
        operations(&mut tx)?;
-

-
        let (job, id) = tx.commit(message, self.id, &mut self.store.raw, signer)?;
-
        self.job = job;
-

-
        Ok(id)
-
    }
-
}
-

-
impl<R> Deref for JobMut<'_, '_, R> {
-
    type Target = Job;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.job
-
    }
-
}
-

-
pub struct JobStore<'a, R> {
-
    raw: Store<'a, Job, R>,
-
}
-

-
impl<'a, R> Deref for JobStore<'a, R> {
-
    type Target = Store<'a, Job, R>;
-

-
    fn deref(&self) -> &Self::Target {
-
        &self.raw
-
    }
-
}
-

-
impl<'a, R> JobStore<'a, R>
-
where
-
    R: WriteRepository + ReadRepository + cob::Store<Namespace = NodeId>,
-
{
-
    pub fn open(repository: &'a R) -> Result<Self, store::Error> {
-
        let raw = store::Store::open(repository)?;
-
        Ok(Self { raw })
-
    }
-

-
    /// Get the `Job`, if any, identified by `id`.
-
    pub fn get(&self, id: &JobId) -> Result<Option<Job>, store::Error> {
-
        self.raw.get(id)
-
    }
-

-
    /// Get the `Job`, identified by `id`, which can be mutated.
-
    ///
-
    /// # Errors
-
    ///
-
    /// This will fail if the `Job` could not be found.
-
    pub fn get_mut<'g>(&'g mut self, id: &JobId) -> Result<JobMut<'a, 'g, R>, store::Error> {
-
        let job = self
-
            .raw
-
            .get(id)?
-
            .ok_or_else(|| store::Error::NotFound(TYPENAME.clone(), *id))?;
-
        Ok(JobMut {
-
            id: *id,
-
            job,
-
            store: self,
-
        })
-
    }
-

-
    /// Create a fresh `Job` with the provided `commit_id`.
-
    pub fn create<'g, G>(
-
        &'g mut self,
-
        commit_id: git::Oid,
-
        signer: &Device<G>,
-
    ) -> Result<JobMut<'a, 'g, R>, Error>
-
    where
-
        G: crypto::signature::Signer<crypto::Signature>,
-
    {
-
        let (id, job) = Transaction::initial("Create job", &mut self.raw, signer, |tx, _| {
-
            tx.trigger(commit_id)?;
-
            Ok(())
-
        })?;
-

-
        Ok(JobMut {
-
            id,
-
            job,
-
            store: self,
-
        })
-
    }
-

-
    /// Delete the `Job` identified by `id`.
-
    pub fn remove<G>(&self, id: &JobId, signer: &Device<G>) -> Result<(), store::Error>
-
    where
-
        G: crypto::signature::Signer<crypto::Signature>,
-
    {
-
        self.raw.remove(id, signer)
-
    }
-
}