Skip to content

Initial cross-module actions - #227

Open
robknight wants to merge 5 commits into
mainfrom
cross-module-actions
Open

robknight wants to merge 5 commits into
mainfrom
cross-module-actions

Conversation

@robknight

@robknight robknight commented Aug 24, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #113

Cross-module actions. Pexe plugins can now declare dependencies on other pexe plugins.

A dependency declaration has two parts: a module hash, and an alias. The alias is the name which is used locally to refer to the dependency - it is a purely local alias which is used in the Rhai script to refer to the dependency's classes/actions by name, and in the generated Podlang. Aliases must be locally unique (a plugin cannot use the same alias for two different imports) but does not need to be globally unique (two different plugins might use the same alias to refer to different dependencies, including different "versions" of the "same" plugin).

In the future we might want to introduce a package registry, which would give us globally unique names, and semantic version numbers for plugins. However, a content-based approach works for now.

@robknight
robknight marked this pull request as ready for review August 24, 2026 12:29
@robknight
robknight requested a review from dhvanipa August 24, 2026 12:29
name = "craft-basics"
module_hash = "d7edcb9150d12af76a54fbbac7b00b8bb24fab61bc1395b65da47547ad5d1b42"

[[classes]]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does this PR also add the ability to create pexe's without declaring a new class?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That concept doesn't make any sense to me. Declaring new classes is what pexes are for!

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in PAC, agents negotiate a pexe on the fly which is "throwaway", its just meant to represent a swap of 2 objects

so all it has is

fn Swap(){
rekeyA()
rekeyB()
}

# The pin is stamped by `pexe build`, which resolves the import among
# already-built archives (build craft-basics first).
[[imports]]
name = "craft-basics"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

without a path to the code how is the code resolved? does it loop in the directory? curious why you went for this approach vs making the user specify the path

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ahh I see there is --deps so this is kind of like specifying a path but not at the pexe level

.collect();
entries.sort();
thread_local! {
/// Per-thread module cache, keyed by validated manifest hash.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this get refreshed on new installs? ie when reload_catalog is called in driver

@ed255 ed255 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall LGTM! I've left a few comments with various suggestions

# The pin is stamped by `pexe build`, which resolves the import among
# already-built archives (build craft-basics first).
[[imports]]
name = "craft-basics"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is the alias right? I would suggest naming this field alias. I see this value stored in a variable named alias in the implementation. Otherwise one may think that the name must match the original module name.

//! The compiled [`sdk::SdkModule`] is not kept — it holds a `Rc<Engine>` and is
//! therefore `!Send`. `execute_action` re-loads the script from its stored bytes
//! on demand, matching the per-call pattern used before.
//! [`sdk::SdkModule`] is not `Send`, so execution uses a thread-local cache of

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rhai has a feature to make its types thread-safe (Send). If we need it we could enable it. Doing that would require replacing a bunch of Rcs for Arcs (I'm not sure about the performance impact of that change).
That would probably allow making the SdkModule Send.

}
let sdk = Sdk::default();
let mut resolver = plugin_resolver(&sdk, &self.plugins);
let module = resolver

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function caches the requested module, but it doesn't cache the imported modules right?
So if I have these two module graphs:

  • A <- B (A imports B)
  • C <- B (C imports B)

And I call compiled_module(A), B and A are compiled and then COMPILED_MODULES = {A}
Then I call compiled_module(C), B and C are compiled (so B is compiled again) and then COMPILED_MODULES = {A, C}.

I wonder if a small change could allow us to store B in the thread-local cache, and have the plugin resolver also use it, so that in the scenario I described, B is not compiled twice.

Comment thread libs/pexe/README.md

# Resolve [[imports]] against archives in an extra directory, searched
# before target/pexe and the install dir
cargo run -p pexe --release -- build --deps /path/to/archives examples/craft-totem

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add multiple --deps flags in the cli call?
I believe the answer is yes, as deps is a vector in the cli struct.

Comment thread libs/pexe/src/inspect.rs
}
}
map
module.module_aliases()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe build_alias_map can be deleted? Writing module.module_aliases() is similarly short.

Comment thread libs/sdk/src/lib.rs
impl fmt::Display for ActionObjectRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.defining {
Some(defining) => write!(f, "{defining:?}::{}", self.class),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefiningModule contains SdkModule. Is it a good idea to do a Debug display here? Won't it be too crowded with data?

Comment thread libs/sdk/src/lib.rs
meta.total_outputs.extend(
sub.total_outputs
.iter()
.map(|object_ref| object_ref.qualified_by(defining.as_ref())),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized that ActionObjectRef has defining: Option<DefiningModule>, and because it's optional we need to handle both cases in several places.
What if the action when called from the module that defines it also contains DefiningModule? Internally it has an Rc<SdkModule> so having copies is cheap. And then we don't need to handle 2 different cases.
To be consistent, we could have a special alias to refer to the local module, like root? I've seen the mention of root as the module name of the module where actions are defined.

Comment thread libs/sdk/src/lib.rs
Comment on lines +2498 to +2520
if alias.is_empty()
|| alias.starts_with(|c: char| c.is_ascii_digit())
|| alias
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '_'))
{
return Err(anyhow!(
"import alias {:?} is not a valid podlang module alias",
import.alias
));
}
if alias == "tx" {
return Err(anyhow!(
"import alias {:?} collides with the reserved tx module alias",
import.alias
));
}
if PODLANG_RESERVED_WORDS.contains(&alias.as_str()) {
return Err(anyhow!(
"import alias {:?} maps to {alias:?}, which podlang reserves",
import.alias
));
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice to move all this code to a validate_alias function

Comment thread libs/sdk/src/lib.rs
}
fn collect_module_aliases(&self, aliases: &mut HashMap<Hash, String>) {
// Traverse children first so direct aliases take precedence.
for import in &self.imports {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for myself:
Imports are declared in the manifest.
Where are dependencies declared?

Comment thread libs/sdk/src/lib.rs
for declared in &manifest.imports {
let import = imports
.iter()
.find(|import| import.alias == declared.name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From an earlier comment, I think declared.alias would make this more clear.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ability to refer an action from one pexe in another

3 participants