Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cob: Add an experimental "job" COB
✗ CI failure Lars Wirzenius committed 1 year ago
commit df44cee9ef009fbbb4a7d6ab741a8563c34ab139
parent eb095c109b176eb9594ab5f99881bccf06213459
1 passed 1 failed (2 total) View logs
11 files changed +972 -0
added radicle-cli/examples/rad-job.md
@@ -0,0 +1,87 @@
+
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,6 +32,8 @@ 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,6 +25,7 @@ const COMMANDS: &[Help] = &[
    rad_inbox::HELP,
    rad_inspect::HELP,
    rad_issue::HELP,
+
    rad_job::HELP,
    rad_ls::HELP,
    rad_node::HELP,
    rad_patch::HELP,
added radicle-cli/src/commands/job.rs
@@ -0,0 +1,362 @@
+
#![allow(clippy::or_fun_call)]
+
use std::ffi::OsString;
+

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

+
use radicle::cob::job::{JobStore, Reason, State};
+
use radicle::crypto::Signer;
+
use radicle::node::Handle;
+
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 signer = term::signer(&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 } => {
+
            trigger(&commit, &mut ci_store, &repo, &signer, options.quiet)?;
+
        }
+
        Operation::Start {
+
            job_id,
+
            run_id,
+
            info_url,
+
        } => {
+
            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 } => {
+
            finish(&job_id, reason, &mut ci_store, &repo, &signer)?;
+
        }
+
        Operation::Delete { job_id } => {
+
            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: WriteRepository + cob::Store, G: Signer>(
+
    commit: &Rev,
+
    store: &mut JobStore<R>,
+
    repo: &radicle::storage::git::Repository,
+
    signer: &G,
+
    quiet: bool,
+
) -> anyhow::Result<()> {
+
    let commit = commit.resolve(&repo.backend)?;
+
    let job = store.create(commit, signer)?;
+
    if !quiet {
+
        term::job::show(&job, job.id())?;
+
    }
+
    Ok(())
+
}
+

+
fn start<R: WriteRepository + cob::Store, G: Signer>(
+
    job_id: &Rev,
+
    run_id: &str,
+
    info_url: Option<String>,
+
    store: &mut JobStore<R>,
+
    repo: &radicle::storage::git::Repository,
+
    signer: &G,
+
) -> anyhow::Result<()> {
+
    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>(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>(
+
    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: WriteRepository + cob::Store, G: Signer>(
+
    job_id: &Rev,
+
    reason: Reason,
+
    store: &mut JobStore<R>,
+
    repo: &radicle::storage::git::Repository,
+
    signer: &G,
+
) -> anyhow::Result<()> {
+
    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,6 +232,13 @@ 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,6 +2,7 @@ pub mod args;
pub use args::{Args, Error, Help};
pub mod format;
pub mod io;
+
pub mod job;
pub use io::signer;
pub mod comment;
pub mod highlight;
modified radicle-cli/src/terminal/format.rs
@@ -30,6 +30,11 @@ 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)
added radicle-cli/src/terminal/job.rs
@@ -0,0 +1,51 @@
+
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
@@ -3020,3 +3020,17 @@ 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
@@ -3,6 +3,7 @@ pub mod cache;
pub mod common;
pub mod identity;
pub mod issue;
+
pub mod job;
pub mod op;
pub mod patch;
pub mod store;
added radicle/src/cob/job.rs
@@ -0,0 +1,441 @@
+
//! 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::crypto::Signer;
+
use crate::git;
+
use crate::prelude::ReadRepository;
+
use crate::storage::{Oid, WriteRepository};
+

+
/// 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 type_name() -> &'static TypeName {
+
        &TYPENAME
+
    }
+

+
    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<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<'a, 'g, R> std::fmt::Debug for JobMut<'a, 'g, 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<'a, 'g, R> JobMut<'a, 'g, R>
+
where
+
    R: WriteRepository + cob::Store,
+
{
+
    /// 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: Signer>(
+
        &mut self,
+
        run_id: String,
+
        info_url: Option<String>,
+
        signer: &G,
+
    ) -> Result<EntryId, Error> {
+
        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: Signer>(&mut self, reason: Reason, signer: &G) -> Result<EntryId, Error> {
+
        self.transaction("Finish", signer, |tx| tx.finish(reason))
+
    }
+

+
    pub fn transaction<G, F>(
+
        &mut self,
+
        message: &str,
+
        signer: &G,
+
        operations: F,
+
    ) -> Result<EntryId, Error>
+
    where
+
        G: Signer,
+
        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<'a, 'g, R> Deref for JobMut<'a, 'g, 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,
+
{
+
    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: Signer>(
+
        &'g mut self,
+
        commit_id: git::Oid,
+
        signer: &G,
+
    ) -> Result<JobMut<'a, 'g, R>, Error> {
+
        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: Signer>(&self, id: &JobId, signer: &G) -> Result<(), store::Error> {
+
        self.raw.remove(id, signer)
+
    }
+
}