Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
[WIP] radicle-job
Archived fintohaps opened 1 year ago

I wanted to put together a draft of what I think a Job COB could look like, that allows nodes to collaborate on running jobs for a given commit.

The basic idea is that Job is for a single commit (Oid), and any node in the network can commit to running the job for that entry.

Each node can run that job as many times as it wants, as long as it provides a UUID for that run, so we can uniquely identify a new run.

Nodes can only say they’ve started and say they finished, giving success or failure as the reason.

The only bit of extra information is that a Run holds a log: Url to point to.

I believe this should be minimal enough for our use cases:

  • Reference updates can be listened for to see if there are new requests, and the node can have its own list of whom it wants to run jobs for
  • A user can list jobs and their history
  • We can see if things have failed or succeeded
  • We have a URL to follow for accessing logs or artifacts
6 files changed +1210 -66 3b5fac17 de2d2ddd
modified Cargo.lock
@@ -574,6 +574,17 @@ dependencies = [
]

[[package]]
+
name = "displaydoc"
+
version = "0.2.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
name = "dyn-clone"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -587,7 +598,7 @@ checksum = "bdfd533a2fc01178c738c99412ae1f7e1ad2cb37c2e14bfd87e9d4618171c825"
dependencies = [
 "ct-codecs",
 "ed25519",
-
 "getrandom",
+
 "getrandom 0.2.14",
]

[[package]]
@@ -747,7 +758,19 @@ checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c"
dependencies = [
 "cfg-if",
 "libc",
-
 "wasi",
+
 "wasi 0.11.0+wasi-snapshot-preview1",
+
]
+

+
[[package]]
+
name = "getrandom"
+
version = "0.3.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
+
dependencies = [
+
 "cfg-if",
+
 "libc",
+
 "wasi 0.13.3+wasi-0.2.2",
+
 "windows-targets 0.52.5",
]

[[package]]
@@ -778,7 +801,7 @@ checksum = "ebb6549ddc63ba5722acb98c823b0eccb7f8b979407bd2a8fd616f581ae50982"
dependencies = [
 "bstr",
 "serde",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -816,7 +839,7 @@ dependencies = [
 "gix-date",
 "gix-utils",
 "itoa",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "winnow",
]

@@ -826,7 +849,7 @@ version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45c8751169961ba7640b513c3b24af61aa962c967aaf04116734975cd5af0c52"
dependencies = [
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -852,7 +875,7 @@ dependencies = [
 "gix-features",
 "gix-hash",
 "memmap2",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -865,7 +888,7 @@ dependencies = [
 "bstr",
 "gix-path",
 "libc",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -882,7 +905,7 @@ dependencies = [
 "gix-sec",
 "gix-trace",
 "gix-url",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -893,7 +916,7 @@ checksum = "9eed6931f21491ee0aeb922751bd7ec97b4b2fe8fbfedcb678e2a2dce5f3b8c0"
dependencies = [
 "bstr",
 "itoa",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "time",
]

@@ -906,7 +929,7 @@ dependencies = [
 "bstr",
 "gix-hash",
 "gix-object",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -923,7 +946,7 @@ dependencies = [
 "libc",
 "prodash",
 "sha1_smol",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "walkdir",
]

@@ -945,7 +968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f93d7df7366121b5018f947a04d37f034717e113dcf9ccd85c34b58e57a74d5e"
dependencies = [
 "faster-hex",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -955,7 +978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ddf80e16f3c19ac06ce415a38b8591993d3f73aede049cb561becb5b3a8e242"
dependencies = [
 "gix-hash",
-
 "hashbrown",
+
 "hashbrown 0.14.3",
 "parking_lot",
]

@@ -974,7 +997,7 @@ dependencies = [
 "gix-validate",
 "itoa",
 "smallvec",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "winnow",
]

@@ -995,7 +1018,7 @@ dependencies = [
 "gix-quote",
 "parking_lot",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1016,7 +1039,7 @@ dependencies = [
 "memmap2",
 "parking_lot",
 "smallvec",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1028,7 +1051,7 @@ dependencies = [
 "bstr",
 "faster-hex",
 "gix-trace",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1041,7 +1064,7 @@ dependencies = [
 "gix-trace",
 "home",
 "once_cell",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1054,7 +1077,7 @@ dependencies = [
 "gix-config-value",
 "parking_lot",
 "rustix",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1071,7 +1094,7 @@ dependencies = [
 "gix-transport",
 "gix-utils",
 "maybe-async",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "winnow",
]

@@ -1083,7 +1106,7 @@ checksum = "cbff4f9b9ea3fa7a25a70ee62f545143abef624ac6aa5884344e70c8b0a1d9ff"
dependencies = [
 "bstr",
 "gix-utils",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1098,7 +1121,7 @@ dependencies = [
 "gix-hashtable",
 "gix-object",
 "smallvec",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1145,7 +1168,7 @@ dependencies = [
 "gix-quote",
 "gix-sec",
 "gix-url",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1162,7 +1185,7 @@ dependencies = [
 "gix-object",
 "gix-revwalk",
 "smallvec",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1175,7 +1198,7 @@ dependencies = [
 "gix-features",
 "gix-path",
 "home",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "url",
]

@@ -1196,7 +1219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82c27dd34a49b1addf193c92070bcbf3beaf6e10f16a78544de6372e146a0acf"
dependencies = [
 "bstr",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1217,6 +1240,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"

[[package]]
+
name = "hashbrown"
+
version = "0.15.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
+

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1258,23 +1287,153 @@ dependencies = [
]

[[package]]
+
name = "icu_collections"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
+
dependencies = [
+
 "displaydoc",
+
 "yoke",
+
 "zerofrom",
+
 "zerovec",
+
]
+

+
[[package]]
+
name = "icu_locid"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
+
dependencies = [
+
 "displaydoc",
+
 "litemap",
+
 "tinystr",
+
 "writeable",
+
 "zerovec",
+
]
+

+
[[package]]
+
name = "icu_locid_transform"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
+
dependencies = [
+
 "displaydoc",
+
 "icu_locid",
+
 "icu_locid_transform_data",
+
 "icu_provider",
+
 "tinystr",
+
 "zerovec",
+
]
+

+
[[package]]
+
name = "icu_locid_transform_data"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e"
+

+
[[package]]
+
name = "icu_normalizer"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
+
dependencies = [
+
 "displaydoc",
+
 "icu_collections",
+
 "icu_normalizer_data",
+
 "icu_properties",
+
 "icu_provider",
+
 "smallvec",
+
 "utf16_iter",
+
 "utf8_iter",
+
 "write16",
+
 "zerovec",
+
]
+

+
[[package]]
+
name = "icu_normalizer_data"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516"
+

+
[[package]]
+
name = "icu_properties"
+
version = "1.5.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
+
dependencies = [
+
 "displaydoc",
+
 "icu_collections",
+
 "icu_locid_transform",
+
 "icu_properties_data",
+
 "icu_provider",
+
 "tinystr",
+
 "zerovec",
+
]
+

+
[[package]]
+
name = "icu_properties_data"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569"
+

+
[[package]]
+
name = "icu_provider"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
+
dependencies = [
+
 "displaydoc",
+
 "icu_locid",
+
 "icu_provider_macros",
+
 "stable_deref_trait",
+
 "tinystr",
+
 "writeable",
+
 "yoke",
+
 "zerofrom",
+
 "zerovec",
+
]
+

+
[[package]]
+
name = "icu_provider_macros"
+
version = "1.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
name = "idna"
-
version = "0.5.0"
+
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+
checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
dependencies = [
-
 "unicode-bidi",
-
 "unicode-normalization",
+
 "idna_adapter",
+
 "smallvec",
+
 "utf8_iter",
+
]
+

+
[[package]]
+
name = "idna_adapter"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
+
dependencies = [
+
 "icu_normalizer",
+
 "icu_properties",
]

[[package]]
name = "indexmap"
-
version = "2.2.6"
+
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+
checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
dependencies = [
 "equivalent",
-
 "hashbrown",
+
 "hashbrown 0.15.2",
+
 "serde",
]

[[package]]
@@ -1418,6 +1577,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"

[[package]]
+
name = "litemap"
+
version = "0.7.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104"
+

+
[[package]]
name = "localtime"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1538,6 +1703,12 @@ dependencies = [
]

[[package]]
+
name = "nonempty"
+
version = "0.11.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "549e471b99ccaf2f89101bec68f4d244457d5a95a9c3d0672e9564124397741d"
+

+
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1613,9 +1784,9 @@ checksum = "b8f8bdf33df195859076e54ab11ee78a1b208382d3a26ec40d142ffc1ecc49ef"

[[package]]
name = "once_cell"
-
version = "1.19.0"
+
version = "1.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"

[[package]]
name = "opaque-debug"
@@ -1897,7 +2068,7 @@ dependencies = [
 "siphasher 1.0.1",
 "sqlite",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "unicode-normalization",
]

@@ -1925,7 +2096,7 @@ dependencies = [
 "serde_json",
 "shlex",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "timeago",
 "tree-sitter",
 "tree-sitter-bash",
@@ -1954,7 +2125,7 @@ dependencies = [
 "radicle",
 "shlex",
 "snapbox",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1974,7 +2145,7 @@ dependencies = [
 "serde",
 "serde_json",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -1988,7 +2159,7 @@ dependencies = [
 "radicle-crypto",
 "serde",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -2008,7 +2179,7 @@ dependencies = [
 "sqlite",
 "ssh-key",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "zeroize",
]

@@ -2036,7 +2207,7 @@ dependencies = [
 "nonempty 0.9.0",
 "radicle",
 "radicle-git-ext",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -2050,7 +2221,22 @@ dependencies = [
 "percent-encoding",
 "radicle-std-ext",
 "serde",
-
 "thiserror",
+
 "thiserror 1.0.69",
+
]
+

+
[[package]]
+
name = "radicle-job"
+
version = "0.9.0"
+
dependencies = [
+
 "indexmap",
+
 "nonempty 0.11.0",
+
 "once_cell",
+
 "qcheck",
+
 "radicle",
+
 "serde",
+
 "thiserror 2.0.11",
+
 "url",
+
 "uuid",
]

[[package]]
@@ -2089,7 +2275,7 @@ dependencies = [
 "socket2",
 "sqlite",
 "tempfile",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -2101,7 +2287,7 @@ dependencies = [
 "radicle-cli",
 "radicle-crypto",
 "radicle-git-ext",
-
 "thiserror",
+
 "thiserror 1.0.69",
]

[[package]]
@@ -2118,7 +2304,7 @@ version = "0.9.0"
dependencies = [
 "byteorder",
 "log",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "zeroize",
]

@@ -2143,7 +2329,7 @@ dependencies = [
 "radicle-git-ext",
 "radicle-std-ext",
 "tar",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "url",
]

@@ -2167,7 +2353,7 @@ dependencies = [
 "shlex",
 "tempfile",
 "termion 3.0.0",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "unicode-display-width",
 "unicode-segmentation",
 "zeroize",
@@ -2211,7 +2397,7 @@ version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
-
 "getrandom",
+
 "getrandom 0.2.14",
]

[[package]]
@@ -2359,18 +2545,18 @@ dependencies = [

[[package]]
name = "serde"
-
version = "1.0.198"
+
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "9846a40c979031340571da2545a4e5b7c4163bdae79b301d5f86d03979451fcc"
+
checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
dependencies = [
 "serde_derive",
]

[[package]]
name = "serde_derive"
-
version = "1.0.198"
+
version = "1.0.217"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "e88edab869b01783ba905e7d0153f9fc1a6505a96e4ad3018011eedb838566d9"
+
checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
dependencies = [
 "proc-macro2",
 "quote",
@@ -2606,6 +2792,12 @@ dependencies = [
]

[[package]]
+
name = "stable_deref_trait"
+
version = "1.2.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+

+
[[package]]
name = "streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2640,6 +2832,17 @@ dependencies = [
]

[[package]]
+
name = "synstructure"
+
version = "0.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
name = "tar"
version = "0.4.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2692,7 +2895,16 @@ version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
-
 "thiserror-impl",
+
 "thiserror-impl 1.0.69",
+
]
+

+
[[package]]
+
name = "thiserror"
+
version = "2.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
+
dependencies = [
+
 "thiserror-impl 2.0.11",
]

[[package]]
@@ -2707,6 +2919,17 @@ dependencies = [
]

[[package]]
+
name = "thiserror-impl"
+
version = "2.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
+

+
[[package]]
name = "time"
version = "0.3.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2746,6 +2969,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1710e589de0a76aaf295cd47a6699f6405737dbfd3cf2b75c92d000b548d0e6"

[[package]]
+
name = "tinystr"
+
version = "0.7.6"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+
dependencies = [
+
 "displaydoc",
+
 "zerovec",
+
]
+

+
[[package]]
name = "tinyvec"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2822,7 +3055,7 @@ dependencies = [
 "lazy_static",
 "regex",
 "streaming-iterator",
-
 "thiserror",
+
 "thiserror 1.0.69",
 "tree-sitter",
]

@@ -2919,12 +3152,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"

[[package]]
-
name = "unicode-bidi"
-
version = "0.3.15"
-
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
-

-
[[package]]
name = "unicode-display-width"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2972,22 +3199,45 @@ dependencies = [

[[package]]
name = "url"
-
version = "2.5.0"
+
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-
checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
+
checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
dependencies = [
 "form_urlencoded",
 "idna",
 "percent-encoding",
+
 "serde",
]

[[package]]
+
name = "utf16_iter"
+
version = "1.0.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
+

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

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

[[package]]
+
name = "uuid"
+
version = "1.13.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0"
+
dependencies = [
+
 "getrandom 0.3.1",
+
 "serde",
+
]
+

+
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3016,6 +3266,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"

[[package]]
+
name = "wasi"
+
version = "0.13.3+wasi-0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
+
dependencies = [
+
 "wit-bindgen-rt",
+
]
+

+
[[package]]
name = "wasm-bindgen"
version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3236,6 +3495,27 @@ dependencies = [
]

[[package]]
+
name = "wit-bindgen-rt"
+
version = "0.33.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
+
dependencies = [
+
 "bitflags 2.5.0",
+
]
+

+
[[package]]
+
name = "write16"
+
version = "1.0.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
+

+
[[package]]
+
name = "writeable"
+
version = "0.5.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
+

+
[[package]]
name = "xattr"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3253,7 +3533,74 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec"

[[package]]
+
name = "yoke"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
+
dependencies = [
+
 "serde",
+
 "stable_deref_trait",
+
 "yoke-derive",
+
 "zerofrom",
+
]
+

+
[[package]]
+
name = "yoke-derive"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
 "synstructure",
+
]
+

+
[[package]]
+
name = "zerofrom"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e"
+
dependencies = [
+
 "zerofrom-derive",
+
]
+

+
[[package]]
+
name = "zerofrom-derive"
+
version = "0.1.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
 "synstructure",
+
]
+

+
[[package]]
name = "zeroize"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525b4ec142c6b68a2d10f01f7bbf6755599ca3f81ea53b8431b7dd348f5fdb2d"
+

+
[[package]]
+
name = "zerovec"
+
version = "0.10.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
+
dependencies = [
+
 "yoke",
+
 "zerofrom",
+
 "zerovec-derive",
+
]
+

+
[[package]]
+
name = "zerovec-derive"
+
version = "0.10.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
+
dependencies = [
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.89",
+
]
modified Cargo.toml
@@ -15,6 +15,7 @@ members = [
  "radicle-tools",
  "radicle-signals",
  "radicle-systemd",
+
  "radicle-job",
]
default-members = [
  "radicle",
added radicle-job/Cargo.toml
@@ -0,0 +1,24 @@
+
[package]
+
name = "radicle-job"
+
edition = "2021"
+
version.workspace = true
+

+
[lints]
+
workspace = true
+

+
[dependencies]
+
indexmap = { version = "2.7.1", features = ["serde"] }
+
nonempty = "0.11.0"
+
once_cell = "1.20.3"
+
qcheck = "1.0.0"
+
serde = { version = "1.0", features = ["derive"] }
+
thiserror = "2.0.11"
+
url = { version = "2.5.4", features = ["serde"] }
+
uuid = { version = "1.13.1", features = ["serde", "v4"] }
+

+
[dependencies.radicle]
+
path = "../radicle"
+

+
[dev-dependencies.radicle]
+
path = "../radicle"
+
features = ["test"]
added radicle-job/src/error.rs
@@ -0,0 +1,22 @@
+
use radicle::{cob, git};
+
use thiserror::Error;
+

+
#[derive(Debug, Error)]
+
pub enum Build {
+
    #[error("initial action of job must request an OID")]
+
    Initial,
+
    #[error("missing commit for job run {oid}: {err}")]
+
    MissingCommit {
+
        oid: git::Oid,
+
        #[source]
+
        err: git::Error,
+
    },
+
}
+

+
#[derive(Debug, Error)]
+
pub enum Apply {
+
    #[error(transparent)]
+
    Build(#[from] Build),
+
    #[error(transparent)]
+
    Op(#[from] cob::op::OpEncodingError),
+
}
added radicle-job/src/lib.rs
@@ -0,0 +1,747 @@
+
use std::collections::HashMap;
+
use std::ops::{Deref, DerefMut};
+
use std::str::FromStr;
+

+
use indexmap::IndexMap;
+
use once_cell::sync::Lazy;
+
use radicle::cob::store::Cob;
+
use radicle::cob::{self, store, EntryId, Evaluate, ObjectId, Op, TypeName};
+
use radicle::crypto::Signer;
+
use radicle::node::NodeId;
+
use radicle::prelude::ReadRepository;
+
use radicle::storage::{RepositoryError, SignRepository, WriteRepository};
+
use radicle::{cob::store::CobAction, git::Oid};
+
use serde::{Deserialize, Serialize};
+
use url::Url;
+
use uuid::Uuid;
+

+
pub mod error;
+

+
/// Type name of a patch.
+
pub static TYPENAME: Lazy<TypeName> =
+
    Lazy::new(|| FromStr::from_str("xyz.radworks.job").expect("type name is valid"));
+

+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct Job {
+
    oid: Oid,
+
    runs: HashMap<NodeId, Runs>,
+
}
+

+
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+
pub struct Runs(IndexMap<Uuid, Run>);
+

+
impl Runs {
+
    pub fn insert(&mut self, uuid: Uuid, run: Run) -> Option<Run> {
+
        self.0.insert(uuid, run)
+
    }
+

+
    pub fn contains_key(&self, uuid: &Uuid) -> bool {
+
        self.0.contains_key(uuid)
+
    }
+

+
    pub fn latest(&self) -> Option<(&Uuid, &Run)> {
+
        self.0.iter().next_back()
+
    }
+

+
    pub fn started(&self) -> Runs {
+
        self.iter()
+
            .filter_map(|(uuid, run)| run.is_started().then_some((*uuid, run.clone())))
+
            .collect()
+
    }
+

+
    pub fn finished(&self) -> Runs {
+
        self.iter()
+
            .filter_map(|(uuid, run)| run.is_finished().then_some((*uuid, run.clone())))
+
            .collect()
+
    }
+

+
    pub fn succeeded(&self) -> Runs {
+
        self.iter()
+
            .filter_map(|(uuid, run)| run.succeeded().then_some((*uuid, run.clone())))
+
            .collect()
+
    }
+

+
    pub fn failed(&self) -> Runs {
+
        self.iter()
+
            .filter_map(|(uuid, run)| run.failed().then_some((*uuid, run.clone())))
+
            .collect()
+
    }
+

+
    pub fn partition(&self) -> (Runs, Runs, Runs) {
+
        let mut started = IndexMap::new();
+
        let mut succeeded = IndexMap::new();
+
        let mut failed = IndexMap::new();
+

+
        for (uuid, run) in self.0.iter() {
+
            match run.status {
+
                Status::Started => started.insert(*uuid, run.clone()),
+
                Status::Finished(Reason::Succeeded) => succeeded.insert(*uuid, run.clone()),
+
                Status::Finished(Reason::Failed) => failed.insert(*uuid, run.clone()),
+
            };
+
        }
+
        (Runs(started), Runs(succeeded), Runs(failed))
+
    }
+

+
    pub fn is_empty(&self) -> bool {
+
        self.0.is_empty()
+
    }
+

+
    pub fn len(&self) -> usize {
+
        self.0.len()
+
    }
+

+
    pub fn iter(&self) -> impl Iterator<Item = (&Uuid, &Run)> {
+
        self.0.iter()
+
    }
+
}
+

+
impl FromIterator<(Uuid, Run)> for Runs {
+
    fn from_iter<T: IntoIterator<Item = (Uuid, Run)>>(iter: T) -> Self {
+
        Self(iter.into_iter().collect())
+
    }
+
}
+

+
impl<'a> IntoIterator for &'a Runs {
+
    type Item = (&'a Uuid, &'a Run);
+
    type IntoIter = indexmap::map::Iter<'a, Uuid, Run>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.0.iter()
+
    }
+
}
+

+
impl IntoIterator for Runs {
+
    type Item = (Uuid, Run);
+
    type IntoIter = indexmap::map::IntoIter<Uuid, Run>;
+

+
    fn into_iter(self) -> Self::IntoIter {
+
        self.0.into_iter()
+
    }
+
}
+

+
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub enum Action {
+
    Request {
+
        oid: Oid,
+
    },
+
    Run {
+
        node: NodeId,
+
        uuid: Uuid,
+
        log: Url,
+
    },
+
    Finished {
+
        node: NodeId,
+
        uuid: Uuid,
+
        reason: Reason,
+
    },
+
}
+

+
impl CobAction for Action {
+
    fn parents(&self) -> Vec<radicle::git::Oid> {
+
        Vec::new()
+
    }
+
}
+

+
impl Job {
+
    pub fn new(oid: Oid) -> Self {
+
        Self {
+
            oid,
+
            runs: HashMap::new(),
+
        }
+
    }
+

+
    pub fn oid(&self) -> &Oid {
+
        &self.oid
+
    }
+

+
    pub fn started(&self) -> HashMap<NodeId, Runs> {
+
        self.filter_map_by(|runs| runs.started())
+
    }
+

+
    pub fn finished(&self) -> HashMap<NodeId, Runs> {
+
        self.filter_map_by(|runs| runs.finished())
+
    }
+

+
    pub fn succeeded(&self) -> HashMap<NodeId, Runs> {
+
        self.filter_map_by(|runs| runs.succeeded())
+
    }
+

+
    pub fn failed(&self) -> HashMap<NodeId, Runs> {
+
        self.filter_map_by(|runs| runs.failed())
+
    }
+

+
    pub fn partition(&self) -> HashMap<NodeId, (Runs, Runs, Runs)> {
+
        self.runs
+
            .iter()
+
            .map(|(node, runs)| (*node, runs.partition()))
+
            .collect()
+
    }
+

+
    pub fn latest_of(&self, node: &NodeId) -> Option<(&Uuid, &Run)> {
+
        self.runs
+
            .get(node)
+
            .and_then(|runs| runs.0.iter().next_back())
+
    }
+

+
    pub fn latest(&self) -> impl Iterator<Item = (&NodeId, &Uuid, &Run)> + '_ {
+
        self.runs
+
            .iter()
+
            .filter_map(|(node, runs)| runs.latest().map(|(uuid, run)| (node, uuid, run)))
+
    }
+

+
    pub fn runs(&self) -> &HashMap<NodeId, Runs> {
+
        &self.runs
+
    }
+

+
    pub fn runs_of(&self, node: &NodeId) -> Option<&Runs> {
+
        self.runs.get(node)
+
    }
+

+
    fn filter_map_by<P>(&self, p: P) -> HashMap<NodeId, Runs>
+
    where
+
        P: Fn(&Runs) -> Runs,
+
    {
+
        self.runs
+
            .iter()
+
            .filter_map(|(node, runs)| {
+
                let runs = p(runs);
+
                (!runs.is_empty()).then_some((*node, runs))
+
            })
+
            .collect()
+
    }
+

+
    fn insert(&mut self, node: NodeId, uuid: Uuid, run: Run) -> bool {
+
        let runs = self.runs.entry(node).or_default();
+
        if runs.contains_key(&uuid) {
+
            false
+
        } else {
+
            runs.insert(uuid, run);
+
            true
+
        }
+
    }
+

+
    fn update(&mut self, node: NodeId, uuid: Uuid, reason: Reason) -> bool {
+
        let Some(runs) = self.runs.get_mut(&node) else {
+
            return false;
+
        };
+
        let mut updated = false;
+
        runs.0.entry(uuid).and_modify(|run| {
+
            updated = true;
+
            *run = run.clone().finish(reason);
+
        });
+
        updated
+
    }
+

+
    fn action(&mut self, action: Action) -> Result<(), error::Build> {
+
        match action {
+
            // Cannot request for another `oid`, so we ignore any superfluous
+
            // request actions
+
            Action::Request { .. } => Ok(()),
+
            Action::Run { node, uuid, log } => {
+
                self.insert(node, uuid, Run::new(log));
+
                Ok(())
+
            }
+
            Action::Finished { node, uuid, reason } => {
+
                self.update(node, uuid, reason);
+
                Ok(())
+
            }
+
        }
+
    }
+
}
+

+
impl store::Cob for Job {
+
    type Action = Action;
+
    type Error = error::Build;
+

+
    fn type_name() -> &'static TypeName {
+
        &TYPENAME
+
    }
+

+
    fn from_root<R: ReadRepository>(op: Op<Self::Action>, repo: &R) -> Result<Self, Self::Error> {
+
        let mut actions = op.actions.into_iter();
+
        let Some(Action::Request { oid }) = actions.next() else {
+
            return Err(error::Build::Initial);
+
        };
+
        repo.commit(oid)
+
            .map_err(|err| error::Build::MissingCommit { oid, err })?;
+
        let mut runs = Self::new(oid);
+
        for action in actions {
+
            runs.action(action)?;
+
        }
+
        Ok(runs)
+
    }
+

+
    fn op<'a, R: ReadRepository, I: IntoIterator<Item = &'a radicle::cob::Entry>>(
+
        &mut self,
+
        op: Op<Self::Action>,
+
        _concurrent: I,
+
        _repo: &R,
+
    ) -> Result<(), Self::Error> {
+
        for action in op.actions {
+
            self.action(action)?;
+
        }
+
        Ok(())
+
    }
+
}
+

+
impl<R: ReadRepository> Evaluate<R> for Job {
+
    type Error = error::Apply;
+

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

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

+
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub struct Run {
+
    status: Status,
+
    log: Url,
+
}
+

+
impl Run {
+
    pub fn new(log: Url) -> Self {
+
        Self {
+
            status: Status::Started,
+
            log,
+
        }
+
    }
+

+
    pub fn finish(self, reason: Reason) -> Self {
+
        Self {
+
            status: Status::Finished(reason),
+
            log: self.log,
+
        }
+
    }
+

+
    pub fn status(&self) -> &Status {
+
        &self.status
+
    }
+

+
    pub fn is_started(&self) -> bool {
+
        match self.status {
+
            Status::Started => true,
+
            Status::Finished(_) => false,
+
        }
+
    }
+

+
    pub fn is_finished(&self) -> bool {
+
        !self.is_started()
+
    }
+

+
    pub fn succeeded(&self) -> bool {
+
        match self.status {
+
            Status::Started => false,
+
            Status::Finished(Reason::Failed) => false,
+
            Status::Finished(Reason::Succeeded) => true,
+
        }
+
    }
+

+
    pub fn failed(&self) -> bool {
+
        match self.status {
+
            Status::Started => false,
+
            Status::Finished(Reason::Failed) => true,
+
            Status::Finished(Reason::Succeeded) => false,
+
        }
+
    }
+
}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub enum Status {
+
    Started,
+
    Finished(Reason),
+
}
+

+
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
+
pub enum Reason {
+
    Failed,
+
    Succeeded,
+
}
+

+
pub struct Jobs<'a, R> {
+
    raw: store::Store<'a, Job, R>,
+
}
+

+
impl<'a, R> Deref for Jobs<'a, R> {
+
    type Target = store::Store<'a, Job, R>;
+

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

+
impl<'a, R> Jobs<'a, R>
+
where
+
    R: ReadRepository + cob::Store,
+
{
+
    /// Open a jobs store.
+
    pub fn open(repository: &'a R) -> Result<Self, RepositoryError> {
+
        let identity = repository.identity_head()?;
+
        let raw = store::Store::open(repository)?.identity(identity);
+

+
        Ok(Self { raw })
+
    }
+

+
    /// Return the number of [`Job`]s in the store.
+
    pub fn counts(&self) -> Result<usize, store::Error> {
+
        Ok(self.all()?.count())
+
    }
+

+
    /// Get a [`Job`].
+
    pub fn get(&self, id: &ObjectId) -> Result<Option<Job>, store::Error> {
+
        self.raw.get(id)
+
    }
+
}
+

+
impl<'a, R> Jobs<'a, R>
+
where
+
    R: ReadRepository + SignRepository + cob::Store,
+
{
+
    /// Get a [`JobMut`].
+
    pub fn get_mut<'g, C>(&'g mut self, id: &ObjectId) -> Result<JobMut<'a, 'g, R>, store::Error> {
+
        let job = self
+
            .raw
+
            .get(id)?
+
            .ok_or_else(move || store::Error::NotFound(TYPENAME.clone(), *id))?;
+

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

+
    pub fn create<'g, G>(
+
        &'g mut self,
+
        oid: Oid,
+
        signer: &G,
+
    ) -> Result<JobMut<'a, 'g, R>, store::Error>
+
    where
+
        G: Signer,
+
    {
+
        let (id, job) = store::Transaction::initial::<_, _, Transaction<R>>(
+
            "Request job",
+
            &mut self.raw,
+
            signer,
+
            |tx, _| {
+
                tx.request(oid)?;
+
                Ok(())
+
            },
+
        )?;
+

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

+
pub struct JobMut<'a, 'g, R> {
+
    pub id: ObjectId,
+

+
    job: Job,
+
    store: &'g mut Jobs<'a, R>,
+
}
+

+
impl<'a, 'g, R> Deref for JobMut<'a, 'g, R> {
+
    type Target = Job;
+

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

+
impl<'a, 'g, R> JobMut<'a, 'g, R>
+
where
+
    R: WriteRepository + cob::Store,
+
{
+
    pub fn new(id: ObjectId, job: Job, store: &'g mut Jobs<'a, R>) -> Self {
+
        Self { id, job, store }
+
    }
+

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

+
    /// Reload the patch data 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 request<G>(&mut self, oid: Oid, signer: &G) -> Result<EntryId, store::Error>
+
    where
+
        G: Signer,
+
    {
+
        self.transaction("Request OID", signer, |tx| tx.request(oid))
+
    }
+

+
    pub fn run<G>(
+
        &mut self,
+
        node: NodeId,
+
        uuid: Uuid,
+
        log: Url,
+
        signer: &G,
+
    ) -> Result<EntryId, store::Error>
+
    where
+
        G: Signer,
+
    {
+
        self.transaction("Run node job", signer, |tx| tx.run(node, uuid, log))
+
    }
+

+
    pub fn finish<G>(
+
        &mut self,
+
        node: NodeId,
+
        uuid: Uuid,
+
        reason: Reason,
+
        signer: &G,
+
    ) -> Result<EntryId, store::Error>
+
    where
+
        G: Signer,
+
    {
+
        self.transaction("Finished node job", signer, |tx| {
+
            tx.finish(node, uuid, reason)
+
        })
+
    }
+

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

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

+
        Ok(commit)
+
    }
+
}
+

+
pub struct Transaction<R: ReadRepository>(store::Transaction<Job, R>);
+

+
impl<R> From<store::Transaction<Job, R>> for Transaction<R>
+
where
+
    R: ReadRepository,
+
{
+
    fn from(tx: store::Transaction<Job, R>) -> Self {
+
        Self(tx)
+
    }
+
}
+

+
impl<R> From<Transaction<R>> for store::Transaction<Job, R>
+
where
+
    R: ReadRepository,
+
{
+
    fn from(Transaction(tx): Transaction<R>) -> Self {
+
        tx
+
    }
+
}
+

+
impl<R> Default for Transaction<R>
+
where
+
    R: ReadRepository,
+
{
+
    fn default() -> Self {
+
        Self(Default::default())
+
    }
+
}
+

+
impl<R> Deref for Transaction<R>
+
where
+
    R: ReadRepository,
+
{
+
    type Target = store::Transaction<Job, R>;
+

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

+
impl<R> DerefMut for Transaction<R>
+
where
+
    R: ReadRepository,
+
{
+
    fn deref_mut(&mut self) -> &mut Self::Target {
+
        &mut self.0
+
    }
+
}
+

+
impl<R> Transaction<R>
+
where
+
    R: ReadRepository,
+
{
+
    pub fn request(&mut self, oid: Oid) -> Result<(), store::Error> {
+
        self.0.push(Action::Request { oid })
+
    }
+

+
    pub fn run(&mut self, node: NodeId, uuid: Uuid, log: Url) -> Result<(), store::Error> {
+
        self.0.push(Action::Run { node, uuid, log })
+
    }
+

+
    pub fn finish(&mut self, node: NodeId, uuid: Uuid, reason: Reason) -> Result<(), store::Error> {
+
        self.0.push(Action::Finished { node, uuid, reason })
+
    }
+
}
+

+
#[cfg(test)]
+
#[allow(clippy::unwrap_used)]
+
mod test {
+
    use radicle::crypto::Signer;
+
    use radicle::git::{raw::Repository, Oid};
+
    use radicle::test;
+
    use url::Url;
+
    use uuid::Uuid;
+

+
    use crate::{Jobs, Reason, Run, Runs};
+

+
    fn node_run() -> (Uuid, Url) {
+
        let uuid = Uuid::new_v4();
+
        let log = Url::parse(&format!("https://example.com/ci/logs?run={}", uuid)).unwrap();
+
        (uuid, log)
+
    }
+

+
    fn commit(repo: &Repository) -> Oid {
+
        let tree = {
+
            let tree = repo.treebuilder(None).unwrap();
+
            let oid = tree.write().unwrap();
+
            repo.find_tree(oid).unwrap()
+
        };
+

+
        let author = repo.signature().unwrap();
+
        repo.commit(None, &author, &author, "Test Commit", &tree, &[])
+
            .unwrap()
+
            .into()
+
    }
+

+
    #[test]
+
    fn e2e() {
+
        let test::setup::NodeWithRepo {
+
            node: alice, repo, ..
+
        } = test::setup::NodeWithRepo::default();
+
        let oid = commit(&repo.backend);
+
        let mut jobs = Jobs::open(&*repo).unwrap();
+

+
        let test::setup::NodeWithRepo { node: bob, .. } = test::setup::NodeWithRepo::default();
+
        let mut job = jobs.create(oid, &alice.signer).unwrap();
+

+
        let (alice_uuid, alice_log) = node_run();
+
        job.run(
+
            *alice.signer.public_key(),
+
            alice_uuid,
+
            alice_log.clone(),
+
            &alice.signer,
+
        )
+
        .unwrap();
+

+
        let (bob_uuid, bob_log) = node_run();
+
        job.run(
+
            *bob.signer.public_key(),
+
            bob_uuid,
+
            bob_log.clone(),
+
            &bob.signer,
+
        )
+
        .unwrap();
+

+
        let alice_runs = job.runs_of(alice.signer.public_key());
+
        assert!(alice_runs.is_some());
+
        assert_eq!(
+
            *alice_runs.unwrap(),
+
            [(alice_uuid, Run::new(alice_log))]
+
                .into_iter()
+
                .collect::<Runs>()
+
        );
+

+
        let bob_runs = job.runs_of(bob.signer.public_key());
+
        assert!(bob_runs.is_some());
+
        assert_eq!(
+
            *bob_runs.unwrap(),
+
            [(bob_uuid, Run::new(bob_log))]
+
                .into_iter()
+
                .collect::<Runs>()
+
        );
+

+
        job.finish(
+
            *alice.signer.public_key(),
+
            alice_uuid,
+
            Reason::Succeeded,
+
            &alice.signer,
+
        )
+
        .unwrap();
+

+
        let finished = job.finished();
+
        assert!(finished.contains_key(alice.signer.public_key()));
+
        assert!(!finished.contains_key(bob.signer.public_key()));
+

+
        job.finish(
+
            *bob.signer.public_key(),
+
            bob_uuid,
+
            Reason::Failed,
+
            &bob.signer,
+
        )
+
        .unwrap();
+

+
        let succeeded = job.succeeded();
+
        assert!(succeeded.contains_key(alice.signer.public_key()));
+
        assert!(!succeeded.contains_key(bob.signer.public_key()));
+
        let failed = job.failed();
+
        assert!(!failed.contains_key(alice.signer.public_key()));
+
        assert!(failed.contains_key(bob.signer.public_key()));
+
        let started = job.started();
+
        assert!(started.is_empty());
+
    }
+

+
    #[test]
+
    fn missing_commit() {
+
        let test::setup::NodeWithRepo {
+
            node: alice, repo, ..
+
        } = test::setup::NodeWithRepo::default();
+
        let mut jobs = Jobs::open(&*repo).unwrap();
+
        let oid = test::arbitrary::oid();
+
        let job = jobs.create(oid, &alice.signer);
+
        assert!(job.is_err())
+
    }
+

+
    #[test]
+
    fn idempotent_create() {
+
        let test::setup::NodeWithRepo {
+
            node: alice, repo, ..
+
        } = test::setup::NodeWithRepo::default();
+
        let oid = commit(&repo.backend);
+
        let mut jobs = Jobs::open(&*repo).unwrap();
+
        let job1 = {
+
            let job1 = jobs.create(oid, &alice.signer).unwrap();
+
            job1.id
+
        };
+
        let job2 = {
+
            let job2 = jobs.create(oid, &alice.signer).unwrap();
+
            job2.id
+
        };
+

+
        assert_eq!(job1, job2);
+
        assert_eq!(jobs.get(&job1).unwrap(), jobs.get(&job2).unwrap());
+
    }
+
}
modified radicle/src/cob/store.rs
@@ -314,20 +314,23 @@ where
    T: Cob + cob::Evaluate<R>,
{
    /// Create a new transaction to be used as the initial set of operations for a COB.
-
    pub fn initial<G, F>(
+
    pub fn initial<G, F, Tx>(
        message: &str,
        store: &mut Store<T, R>,
        signer: &G,
        operations: F,
    ) -> Result<(ObjectId, T), Error>
    where
+
        Tx: From<Self>,
+
        Self: From<Tx>,
        G: Signer,
-
        F: FnOnce(&mut Self, &R) -> Result<(), Error>,
+
        F: FnOnce(&mut Tx, &R) -> Result<(), Error>,
        R: ReadRepository + SignRepository + cob::Store,
        T::Action: Serialize + Clone,
    {
-
        let mut tx = Transaction::default();
+
        let mut tx = Tx::from(Transaction::default());
        operations(&mut tx, store.as_ref())?;
+
        let tx = Self::from(tx);

        let actions = NonEmpty::from_vec(tx.actions)
            .expect("Transaction::initial: transaction must contain at least one action");