Skip to content

Commit

Permalink
feat: recipes (#24)
Browse files Browse the repository at this point in the history
* feat: recipes first pass

* fix: extern doc getter

* feat: recipe constants + test

* chore: minor imports tidy

* feat: recipe + execution validation

* chore: add TS types and test for args

* fix: bad commit
  • Loading branch information
8e8b2c authored Jun 24, 2024
1 parent 1e8e2f8 commit f8da78b
Show file tree
Hide file tree
Showing 27 changed files with 1,018 additions and 23 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/holoom_dna_tests/src/tests/username_registry/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod oracle;
mod recipe;
mod user_metadata;
mod username_attestation;
mod wallet_attestation;
203 changes: 203 additions & 0 deletions crates/holoom_dna_tests/src/tests/username_registry/recipe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
use hdk::prelude::*;

use holochain::conductor::api::error::ConductorApiResult;
use holoom_types::{
recipe::{
ExecuteRecipePayload, JqInstructionArgumentNames, Recipe, RecipeArgument,
RecipeArgumentType, RecipeExecution, RecipeInstruction,
},
ExternalIdAttestation, OracleDocument,
};
use username_registry_utils::deserialize_record_entry;

use crate::TestSetup;

#[tokio::test(flavor = "multi_thread")]
async fn can_execute_basic_recipe() {
let setup = TestSetup::authority_and_alice().await;
setup.conductors.exchange_peer_info().await;

// Materials:

// Doc: foo/1234 -> { value: 1, owner: "1234" }
// Doc: foo/5678 -> { value: 4, owner: "5678" }
// Doc: foo -> [foo/1234, foo/5678]
// ExternalId: authority_agent_pub_key -> { id: 1234, display_name: "some-user-1" }
// ExternalId: alice_agent_pub_key -> { id: 5678, display_name: "some-user-2" }

// Recipe:
// Calculate value share of caller
// {
// "$arguments": [{ name" "greeting", type: "string" }],
// "foo_name_list_name": { inst: "get_doc", var_name: `"foo"` },
// "foo_name_list": { inst: "get_docs", var_name: `"foo"` },
// "foos": { inst: "get_docs", var_name: "foo_name_list" },
// "caller_external_id": { inst: "get_caller_external_id" },
// "$return": {
// inst: "jq",
// input_vars: ["foos", "caller_external_id", "greeting"],
// program: `
// .caller_external_id.external_id as $id |
// .foos as $foos |
// "\(.greeting) \(.caller_external_id.display_name)" as $msg |
// [$foos[].value] | add as $total |
// $foos[] | select(.owner==$id) | .value / $total |
// { share: ., msg: $msg }
// `
// }
// }

// Expected outputs:
// Authority with greeting: 'Hi' -> { share: 0.2, msg: "Hi some-user-1" }
// Alice with greeting: 'Hello' -> { share: 0.8, msg: "Hello some-user-2" }

let _foo1_record: Record = setup
.authority_call(
"username_registry",
"create_oracle_document",
OracleDocument {
name: "foo/1234".into(),
json_data: "{\"value\":1,\"owner\":\"1234\"}".into(),
},
)
.await
.unwrap();

let _foo2_record: Record = setup
.authority_call(
"username_registry",
"create_oracle_document",
OracleDocument {
name: "foo/5678".into(),
json_data: "{\"value\":4,\"owner\":\"5678\"}".into(),
},
)
.await
.unwrap();

let _foo_name_list_record: Record = setup
.authority_call(
"username_registry",
"create_oracle_document",
OracleDocument {
name: "foo".into(),
json_data: "[\"foo/1234\",\"foo/5678\"]".into(),
},
)
.await
.unwrap();

let res: ConductorApiResult<Record> = setup
.authority_call(
"username_registry",
"create_external_id_attestation",
ExternalIdAttestation {
request_id: "".into(),
internal_pubkey: setup.authority_pubkey(),
external_id: "1234".into(),
display_name: "some-user-1".into(),
},
)
.await;
assert!(res.is_ok());

let res: ConductorApiResult<Record> = setup
.authority_call(
"username_registry",
"create_external_id_attestation",
ExternalIdAttestation {
request_id: "".into(),
internal_pubkey: setup.alice_pubkey(),
external_id: "5678".into(),
display_name: "some-user-2".into(),
},
)
.await;
assert!(res.is_ok());

let recipe_record: Record = setup
.authority_call(
"username_registry",
"create_recipe",
Recipe {
trusted_authors: vec![setup.authority_pubkey()],
arguments: vec![("greeting".into(),RecipeArgumentType::String)],
instructions: vec![
(
"foo_name_list_name".into(),
RecipeInstruction::Constant {
value: "\"foo\"".into(),
},
),
(
"foo_name_list".into(),
RecipeInstruction::GetLatestDocWithIdentifier {
var_name: "foo_name_list_name".into(),
},
),
(
"foos".into(),
RecipeInstruction::GetDocsListedByVar {
var_name: "foo_name_list".into(),
},
),
(
"caller_external_id".into(),
RecipeInstruction::GetCallerExternalId,
),
(
"$return".into(),
RecipeInstruction::Jq {
input_var_names: JqInstructionArgumentNames::List{var_names: vec!["foos".into(),"caller_external_id".into(), "greeting".into()]},
program: ".caller_external_id.external_id as $id | .foos as $foos | \"\\(.greeting) \\(.caller_external_id.display_name)\" as $msg | [$foos[].value] | add as $total | $foos[] | select(.owner==$id) | .value / $total | { share: ., msg: $msg }".into()
}
)
],
},
)
.await
.unwrap();

// Make both agents know recipe
setup.consistency().await;

let authority_execution_record: Record = setup
.authority_call(
"username_registry",
"execute_recipe",
ExecuteRecipePayload {
recipe_ah: recipe_record.action_address().clone(),
arguments: vec![RecipeArgument::String { value: "Hi".into() }],
},
)
.await
.unwrap();

let authority_execution: RecipeExecution =
deserialize_record_entry(authority_execution_record).unwrap();
assert_eq!(
authority_execution.output,
String::from("{\"share\":0.2,\"msg\":\"Hi some-user-1\"}")
);

let alice_execution_record: Record = setup
.alice_call(
"username_registry",
"execute_recipe",
ExecuteRecipePayload {
recipe_ah: recipe_record.action_address().clone(),
arguments: vec![RecipeArgument::String {
value: "Hello".into(),
}],
},
)
.await
.unwrap();

let alice_execution: RecipeExecution =
deserialize_record_entry(alice_execution_record).unwrap();
assert_eq!(
alice_execution.output,
String::from("{\"share\":0.8,\"msg\":\"Hello some-user-2\"}")
);
}
10 changes: 1 addition & 9 deletions crates/holoom_types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
pub mod external_id;
pub use external_id::*;
pub mod metadata;
pub mod recipe;
pub use metadata::*;
pub mod wallet;
pub use wallet::*;
Expand Down Expand Up @@ -45,12 +46,3 @@ pub struct SignableBytes(pub Vec<u8>);
pub struct HoloomDnaProperties {
pub authority_agent: String,
}

pub fn get_authority_agent() -> ExternResult<AgentPubKey> {
let dna_props = HoloomDnaProperties::try_from_dna_properties()?;
AgentPubKey::try_from(dna_props.authority_agent).map_err(|_| {
wasm_error!(WasmErrorInner::Guest(
"Failed to deserialize AgentPubKey from dna properties".into()
))
})
}
74 changes: 74 additions & 0 deletions crates/holoom_types/src/recipe.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
use hdi::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum RecipeArgumentType {
String,
}

#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum JqInstructionArgumentNames {
Single { var_name: String },
List { var_names: Vec<String> },
}

#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum RecipeInstruction {
Constant {
value: String,
},
GetLatestDocWithIdentifier {
var_name: String,
},
Jq {
input_var_names: JqInstructionArgumentNames,
program: String,
},
GetDocsListedByVar {
var_name: String,
},
GetCallerExternalId,
GetCallerAgentPublicKey,
}

#[hdk_entry_helper]
#[derive(Clone, PartialEq)]
pub struct Recipe {
pub trusted_authors: Vec<AgentPubKey>,
pub arguments: Vec<(String, RecipeArgumentType)>,
pub instructions: Vec<(String, RecipeInstruction)>,
}

#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
#[serde(tag = "type")]
pub enum RecipeArgument {
String { value: String },
}

#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
pub enum RecipeInstructionExecution {
Constant, // In memory
GetLatestDocWithIdentifier { doc_ah: ActionHash },
Jq, // In memory
GetDocsListedByVar { doc_ahs: Vec<ActionHash> },
GetCallerExternalId { attestation_ah: ActionHash },
GetCallerAgentPublicKey, // In memory
}

#[hdk_entry_helper]
#[derive(Clone, PartialEq)]
pub struct RecipeExecution {
pub recipe_ah: ActionHash,
pub arguments: Vec<RecipeArgument>,
pub instruction_executions: Vec<RecipeInstructionExecution>,
pub output: String,
}

#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
pub struct ExecuteRecipePayload {
pub recipe_ah: ActionHash,
pub arguments: Vec<RecipeArgument>,
}
11 changes: 7 additions & 4 deletions crates/jaq_wrapper/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use hdi::prelude::*;
use jaq_interpret::{Ctx, Filter, FilterT, ParseCtx, RcIter, Val};
use jaq_interpret::{Ctx, Filter, FilterT, ParseCtx, RcIter};
use std::io::{self, BufRead};

// Re-export Val
pub use jaq_interpret::Val;

pub enum JqProgramInput {
Single(String),
Slurp(Vec<String>),
Expand Down Expand Up @@ -29,7 +32,7 @@ impl JqProgramInput {
}
}

fn compile_filter(program_str: &str) -> ExternResult<Filter> {
pub fn compile_filter(program_str: &str) -> ExternResult<Filter> {
let (maybe_main, errs) = jaq_parse::parse(program_str, jaq_parse::main());
let main = maybe_main.ok_or(wasm_error!(format!(
"jq program compilation failed with {} error(s)",
Expand All @@ -42,7 +45,7 @@ fn compile_filter(program_str: &str) -> ExternResult<Filter> {
Ok(filter)
}

fn run_filter(filter: Filter, input: Val) -> ExternResult<Val> {
pub fn run_filter(filter: Filter, input: Val) -> ExternResult<Val> {
// Seems jaq is designed to pipe errors forwards, whilst tracking a global reference - hence
// the iterator gubbins.
let vars = vec![];
Expand Down Expand Up @@ -92,7 +95,7 @@ fn run_filter(filter: Filter, input: Val) -> ExternResult<Val> {
)))
}

fn parse_single_json(json: &str) -> ExternResult<Val> {
pub fn parse_single_json(json: &str) -> ExternResult<Val> {
let mut iter = json_read(json.as_bytes());
let val = iter
.next()
Expand Down
1 change: 1 addition & 0 deletions crates/username_registry_coordinator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@ holoom_types = { workspace = true }
username_registry_validation = { workspace = true }
username_registry_utils = { workspace = true }
jaq_wrapper = { workspace = true }
indexmap = "2.2.5"
serde_json = { workspace = true }
Loading

0 comments on commit f8da78b

Please sign in to comment.