//! # External Collaborative Objects //! //! This module provides an interface for external helper programs to provide //! the evaluation logic of Collaborative Objects (COBs). //! //! An external COB is one that relies on an external program (so called //! "helper", is an executable file, for example a script, or a binary), that //! implements the evaluation logic for that particular COB: //! Whenever an operation is to be applied to an external COB, the helper is //! invoked with the current state of the COB and the operation to be applied. //! It then returns the new state of the COB, according to its internal logic. //! This concept is borrowed from Git, which supports [remote helpers] and //! [credential helpers]. //! //! External COBs must be based on JSON, that is, the COB itself and associated //! actions must serialize to and deserialize from JSON. //! Further, the helper must be able to communicate to Radicle using //! [JSON Lines] (see further details below). //! //! # Invocation //! //! The helper is invoked by Radicle without command line arguments. //! In the future, more arguments might be added. //! //! # Helper Protocol //! //! Radicle and the helper communicate back and forth [JSON Lines] via standard //! streams. //! //! 1. The helper must read and process at least one JSON Line (containing one //! operation) from standard input, which represents the operation to be //! applied to the COB, along with possible concurrent operations. //! 2. The helper must write the new state of COB to standard output in a JSON //! Line. //! 3. The helper may read additional JSON Lines from standard input, these are //! to be applied "on top of" the previous operations. //! 4. The helper must reply with the state of the COB in a JSON Line after //! processing each operation. //! 5. The helper must exit with a status code of zero on success, and a //! non-zero status code on failure. //! 6. The helper may write to standard error for logging and debugging //! purposes. //! //! # Syntax of Operations //! //! The operations sent from Radicle to the helper are of the following shape: //! //! ```json //! { //! "title": "Operation as sent to helper" //! "type": "object", //! "properties": { //! "concurrent": { //! "type": "array", //! "items": { //! "$ref": "#/definitions/radicle::cob::Op" //! } //! }, //! "value": { //! "type": "object", //! "properties": { //! "prop": { //! "$ref": "#/definitions/radicle::cob::external::External" //! } //! } //! }, //! "op": { //! "type": "object", //! "properties": { //! "prop": { //! "$ref": "#/definitions/radicle::cob::Op" //! } //! } //! } //! }, //! } //! ``` //! //! [JSON Lines]: https://jsonlines.org/ //! [credential helpers]: https://git-scm.com/doc/credential-helpers //! [remote helpers]: https://git-scm.com/docs/gitremote-helpers use std::collections::HashMap; use std::io::Error as IoError; use std::process::{Command, Stdio}; use serde::{Deserialize, Serialize}; use thiserror::Error; use serde_json::{Error as JsonError, Map, Value, from_slice, to_writer}; use crate::cob::object::collaboration::Evaluate; use crate::cob::op::{Op as CobOp, OpEncodingError}; use crate::cob::store::{Cob, CobAction}; use crate::git::Oid; use crate::storage::ReadRepository; /// This prefix is used to generate the name of the command, /// which is executed by the helper to apply operations. static COB_EXTERNAL_COMMAND_PREFIX: &str = "rad-cob-"; #[derive(PartialEq, Debug, Serialize, Deserialize)] pub struct External(Value); impl Default for External { fn default() -> Self { Self(Value::Object(Map::default())) } } #[derive(Error, Debug)] pub enum Error { #[error("op decoding failed: {0}")] Op(#[from] OpEncodingError), #[error("serde_json: {0}")] Serde(#[from] JsonError), #[error("failed to spawn program '{program}': {source}")] Spawn { program: String, source: IoError }, #[error("io: {0}")] Io(#[from] IoError), } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Action { #[serde(default)] parents: Vec, #[serde(flatten)] map: HashMap, } impl CobAction for Action { fn parents(&self) -> Vec { self.parents.clone() } } impl From for nonempty::NonEmpty { fn from(action: Action) -> Self { Self::new(action) } } pub type Op = CobOp; impl External { fn handle( &mut self, op: Op, concurrent: Vec, _repo: &R, ) -> Result<(), Error> { let command_name = { let prefix = String::from(COB_EXTERNAL_COMMAND_PREFIX); let type_name = op.manifest.type_name.to_string(); let suffix = type_name .rsplit_once('.') .map(|(_, suffix)| suffix) .unwrap_or(type_name.as_str()); prefix + suffix }; let mut child = Command::new(&command_name) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() .map_err(|source| Error::Spawn { program: command_name, source, })?; let stdin = child.stdin.take().expect("handle preset"); #[derive(Serialize)] struct OpMessage { value: Value, op: Op, concurrent: Vec, } to_writer( stdin, &OpMessage { value: self.0.clone(), op, concurrent, }, )?; self.0 = from_slice(&child.wait_with_output()?.stdout)?; Ok(()) } } impl Cob for External { type Action = Action; type Error = Error; fn from_root( op: super::Op, repo: &R, ) -> Result { let mut root = Self::default(); root.handle(op, vec![], repo)?; Ok(root) } fn op<'a, R: ReadRepository, I: IntoIterator>( &mut self, op: super::Op, concurrent: I, repo: &R, ) -> Result<(), ::Error> { let concurrent: Vec = concurrent .into_iter() .map(Op::try_from) .collect::, _>>()?; self.handle(op, concurrent, repo) } } impl Evaluate for External { type Error = Error; fn init(entry: &radicle_cob::Entry, store: &R) -> Result { Self::from_root(Op::try_from(entry)?, store) } fn apply<'a, I: Iterator>( &mut self, entry: &radicle_cob::Entry, concurrent: I, repo: &R, ) -> Result<(), Self::Error> { let concurrent: Vec = concurrent .map(|(_, e)| e) .map(Op::try_from) .collect::, _>>()?; self.handle(Op::try_from(entry)?, concurrent, repo) } }