effect: add ents-effect, the Executor trait and Docker/Sprite run loop
commit 82a7113
effect: add ents-effect, the Executor trait and Docker/Sprite run loop
Closes the loop the gate and receive open: an effect’s outcome now has
somewhere to run and a way back into the repository. Executor is one
trait behind three backends selected only at a composition root
(Docker, Sprite, host-direct unsandboxed) — no effect can pick its own
executor or demand --unsandboxed. run_one/run_effect materialize
a toolchain’s recipe and the tested commit’s tree through the same
gitoxide Find walk (no shelling to git archive), execute, and write
the result back through ents_receive::receive like any other client,
signed by a caller-injected sign closure so this crate never holds
key material (mirrors ents_sync::resolve::merge_heads).
Docker and Sprite readiness probes, the Sprite auth/create/sync
quirks, and the toolchain recipe shape (embedded tree vs. sha256-pinned
downloaded components) are ported from pre-redo, re-verified against
the current spec and rewritten onto this crate’s own materialization
path rather than git archive/a persistent-cache module pre-redo no
longer needs (no effect-level cache in this design).
effect.fanout-index and effect.official are intentionally not
Tracey-annotated here: a fanout-index rebuild is an ordinary effect,
satisfied generically by this same run_one/write_result path with no
index-specific code to test; effect.official is a refname-authorization
rule ents-gate’s own doc already defers to a future Config-driven
worker-key narrowing, outside this crate’s scope.
toolchains: define Recipe (Embedded/Downloaded) and materialize toolchains
docker: port readiness probe and argv assembly from pre-redo
sprite: port auth/create/sync-tree quirks from pre-redo, dropping git-archive and PTY streaming
effect: add write_result as an ordinary receive client
effect: add the run_one/run_effect run loop
effect: add write-time definition validation
Assisted-by: Claude:claude-sonnet-4-6
crates/ents-effect/Cargo.toml
@@ -1,0 +1,36 @@
+[package]
+name = "ents-effect"
+version = "0.0.0"
+edition.workspace = true
+publish.workspace = true
+license.workspace = true
+
+[features]
+# Neither backend needs an extra crate dependency — both shell out to an
+# already-installed CLI (`docker`, `sprite`), the same pattern `pre-redo`
+# used and this phase ports. The features exist so a composition root pulls
+# in only the backend(s) it deploys (`roots.local`: Docker only;
+# `roots.hosted`: Sprite only), never both in one binary by accident.
+docker = []
+sprite = []
+
+[dependencies]
+ents-model = { workspace = true }
+ents-query = { workspace = true }
+ents-receive = { workspace = true }
+facet-git-tree = { workspace = true }
+gix = { workspace = true }
+gix-hash = { workspace = true }
+gix-object = { workspace = true }
+gix-ref-store = { workspace = true }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+ents-gate = { workspace = true }
+ents-testutil = { workspace = true }
+facet = { workspace = true }
+rstest = { workspace = true }
+tempfile = { workspace = true }
+
+[lints]
+workspace = true
crates/ents-effect/src/definition.rs
@@ -1,0 +1,103 @@
+//! Write-time validation of an effect definition (`effect.validation`).
+//!
+//! `ents-receive` cannot call this (`arch.query-effect-split`: no push path
+//! may link executor code), so a future frontend that builds an effect
+//! definition's commit (`git effect add`, `git-ents` bin, phase 6) calls
+//! [`validate`] itself before ever proposing the write — `receive` still
+//! admits or refuses the *push* on its own terms (the gate, `receive.unit`);
+//! this only keeps a frontend from proposing a definition that could never
+//! usefully run.
+
+use ents_model::{Effect, namespace};
+use ents_query::Query;
+
+use crate::error::{Error, Result};
+
+/// Reject `effect` before it is ever proposed to `receive`, per
+/// `effect.validation`: every name in `toolchains` must be a valid
+/// ref-path segment, and `trigger` must parse as a `CommitQuery`
+/// (`query.grammar`) — which already rejects a `rev(expr)` naming a
+/// `refs/meta/*` pattern (`query.rev`) and a `meta(glob)` naming an
+/// effect-written namespace (`query.meta`), since the parser enforces
+/// both.
+///
+/// # Errors
+///
+/// [`Error::Trigger`] if `trigger` does not parse; [`Error::InvalidToolchainName`]
+/// for the first toolchain name that is not a valid ref-path segment.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::definition::validate;
+/// use ents_model::Effect;
+///
+/// let good = Effect {
+/// trigger: "rev(refs/heads/main)".into(),
+/// toolchains: vec!["rust-stable".into()],
+/// run: "cargo test".into(),
+/// };
+/// assert!(validate(&good).is_ok());
+///
+/// let bad_trigger = Effect { trigger: "not a query".into(), ..good.clone() };
+/// assert!(validate(&bad_trigger).is_err());
+///
+/// let bad_toolchain = Effect { toolchains: vec!["../escape".into()], ..good };
+/// assert!(validate(&bad_toolchain).is_err());
+/// ```
+// @relation(effect.validation, scope=function)
+pub fn validate(effect: &Effect) -> Result<()> {
+ effect.trigger.parse::<Query>().map_err(Error::from)?;
+ for name in &effect.toolchains {
+ namespace::toolchain_ref(name)
+ .map_err(|_invalid| Error::InvalidToolchainName(name.clone()))?;
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ fn effect(trigger: &str, toolchains: &[&str]) -> Effect {
+ Effect {
+ trigger: trigger.to_owned(),
+ toolchains: toolchains.iter().map(|s| (*s).to_owned()).collect(),
+ run: "true".to_owned(),
+ }
+ }
+
+ #[rstest]
+ // @relation(effect.validation, scope=function, role=Verifies)
+ fn validate_accepts_a_well_formed_definition() {
+ validate(&effect("rev(refs/heads/main)", &["rust-stable"])).expect("well-formed");
+ }
+
+ #[rstest]
+ // @relation(effect.validation, scope=function, role=Verifies)
+ fn validate_rejects_an_unparsable_trigger() {
+ assert!(validate(&effect("not a query", &[])).is_err());
+ }
+
+ #[rstest]
+ // @relation(effect.validation, query.rev, scope=function, role=Verifies)
+ fn validate_rejects_a_rev_naming_a_meta_pattern() {
+ assert!(validate(&effect("rev(refs/meta/effects/*)", &[])).is_err());
+ }
+
+ #[rstest]
+ // @relation(effect.validation, query.meta, scope=function, role=Verifies)
+ fn validate_rejects_a_meta_glob_naming_an_effect_written_namespace() {
+ assert!(validate(&effect("meta(refs/meta/results/*)", &[])).is_err());
+ }
+
+ #[rstest]
+ // @relation(effect.validation, scope=function, role=Verifies)
+ fn validate_rejects_an_invalid_toolchain_name() {
+ assert!(validate(&effect("rev(refs/heads/main)", &["../escape"])).is_err());
+ }
+}
crates/ents-effect/src/docker.rs
@@ -1,0 +1,180 @@
+//! The Docker [`Executor`] backend (`effect.execution`, `roots.local`):
+//! shells out to the `docker` CLI (no docker API crate — the same
+//! rationale [`crate::sprite`] uses for the `sprite` CLI), running each
+//! effect in a throwaway `--rm` container with the materialized workdir and
+//! toolchains bind-mounted in.
+//!
+//! Ported from `pre-redo`'s `git-effect::docker` module: the readiness
+//! probe ([`ensure_docker`]) and the pure argv assembly ([`run_args`]) carry
+//! over verbatim (minus the cache-directory bind mount — this design has no
+//! effect-level cache, `model.effect-definition`); the rest is rewritten
+//! against this phase's [`crate::Executor`] trait.
+
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+
+use crate::error::{Error, Result};
+use crate::executor::{Executor, RunOutput, RunStatus, SandboxInputs, activate};
+
+/// The minimal base image every effect runs in — no toolchain of its own;
+/// everything the command needs comes from the bind-mounted, host-exported
+/// toolchains.
+pub const IMAGE: &str = "debian:stable-slim";
+
+/// Where the workdir is bind-mounted in the container.
+pub const WORKDIR: &str = "/work";
+
+/// Where a toolchain's `bin/` directory is bind-mounted, per toolchain
+/// name: `{TOOLCHAINS_DIR}/<name>/bin`.
+pub const TOOLCHAINS_DIR: &str = "/toolchains";
+
+/// Confirm `docker` is on `PATH` and the daemon answers, with a clean error
+/// — rather than a raw "os error 2" — when it is not. The one place this
+/// backend can fail before an effect ever runs.
+///
+/// # Errors
+///
+/// [`Error::Spawn`] if `docker` could not be started at all;
+/// [`Error::Process`] if it ran but the daemon did not respond.
+pub fn ensure_docker() -> Result<()> {
+ let status = Command::new("docker")
+ .arg("version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ .map_err(|e| Error::Spawn {
+ program: "docker".to_owned(),
+ detail: e.to_string(),
+ })?;
+ if status.success() {
+ Ok(())
+ } else {
+ Err(Error::Process {
+ program: "docker".to_owned(),
+ detail: "the daemon did not respond to `docker version`; is it running?".to_owned(),
+ })
+ }
+}
+
+/// Assemble `docker run`'s argv for one sandboxed run — pure, so the exact
+/// invocation is unit tested without a daemon. The command runs under
+/// `sh -c`, stderr folded into stdout so the captured recording is one
+/// interleaved stream.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::docker::run_args;
+/// use std::path::Path;
+///
+/// let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test");
+/// assert_eq!(
+/// args,
+/// vec![
+/// "run", "--rm", "-v", "/tmp/s/work:/work", "-w", "/work",
+/// "debian:stable-slim", "sh", "-c", "cargo test 2>&1",
+/// ]
+/// );
+/// ```
+#[must_use]
+pub fn run_args(workdir: &Path, toolchains: &[(String, PathBuf)], command: &str) -> Vec<String> {
+ let mut args = vec![
+ "run".to_owned(),
+ "--rm".to_owned(),
+ "-v".to_owned(),
+ format!("{}:{WORKDIR}", workdir.display()),
+ ];
+ let mut sandbox_dirs = Vec::with_capacity(toolchains.len());
+ for (name, host_dir) in toolchains {
+ let sandbox_dir = format!("{TOOLCHAINS_DIR}/{name}/bin");
+ args.push("-v".to_owned());
+ args.push(format!(
+ "{}:{TOOLCHAINS_DIR}/{name}/bin:ro",
+ host_dir.display()
+ ));
+ sandbox_dirs.push((name.clone(), sandbox_dir));
+ }
+ args.push("-w".to_owned());
+ args.push(WORKDIR.to_owned());
+ args.push(IMAGE.to_owned());
+ args.push("sh".to_owned());
+ args.push("-c".to_owned());
+ args.push(format!("{} 2>&1", activate(command, &sandbox_dirs)));
+ args
+}
+
+/// [`Executor`] running each effect in a throwaway local Docker container
+/// (`roots.local`).
+#[derive(Debug, Clone, Copy, Default)]
+pub struct DockerExecutor;
+
+impl Executor for DockerExecutor {
+ fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
+ ensure_docker()?;
+ let args = run_args(inputs.workdir, inputs.toolchains, inputs.command);
+ let output = Command::new("docker")
+ .args(&args)
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "docker".to_owned(),
+ detail: e.to_string(),
+ })?;
+ let log = String::from_utf8_lossy(&output.stdout).into_owned();
+ let status = if output.status.success() {
+ RunStatus::Pass
+ } else {
+ RunStatus::Fail
+ };
+ Ok(RunOutput { status, log })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn run_args_binds_the_workdir() {
+ let args = run_args(Path::new("/tmp/s/work"), &[], "cargo test");
+ assert_eq!(
+ args,
+ vec![
+ "run",
+ "--rm",
+ "-v",
+ "/tmp/s/work:/work",
+ "-w",
+ "/work",
+ IMAGE,
+ "sh",
+ "-c",
+ "cargo test 2>&1",
+ ]
+ );
+ }
+
+ #[rstest]
+ // @relation(effect.execution, effect.toolchains, scope=function, role=Verifies)
+ fn run_args_binds_each_toolchain_read_only_and_activates_it() {
+ let toolchains = vec![("rust".to_owned(), PathBuf::from("/cache/rust/bin"))];
+ let args = run_args(Path::new("/w"), &toolchains, "cargo test");
+ assert!(args.contains(&"/cache/rust/bin:/toolchains/rust/bin:ro".to_owned()));
+ let last = args.last().expect("has a command");
+ assert!(last.starts_with("export PATH=/toolchains/rust/bin:$PATH; cargo test"));
+ }
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn run_args_uses_the_minimal_base_image() {
+ let args = run_args(Path::new("/w"), &[], "true");
+ assert_eq!(
+ args.get(args.len().saturating_sub(4)).map(String::as_str),
+ Some(IMAGE)
+ );
+ }
+}
crates/ents-effect/src/error.rs
@@ -1,0 +1,168 @@
+//! `ents-effect`'s error type: everything that can prevent a run from
+//! reaching a recorded outcome.
+//!
+//! Mirrors `ents-receive`'s split: an [`Error`] means the run never reached
+//! a judgment at all (a store or object read failed, a sandbox never
+//! started, `curl`/`tar`/`docker`/`sprite` was not on `PATH`) — as opposed
+//! to a completed run reporting `pass` or `fail`
+//! (`effect.result-taxonomy`), which is a reached judgment, not an `Err`.
+//! Per `effect.result-taxonomy`, an [`Error`] here is exactly the "queue
+//! concern with bounded retry" case: this crate never turns one into a
+//! `Status::Error` result itself — only a caller that has exhausted its own
+//! retry bound does that, by calling [`crate::write_result`] with
+//! `Status::Error` explicitly.
+
+use std::path::PathBuf;
+
+use gix_hash::ObjectId;
+
+/// Everything that can prevent an `ents-effect` operation from reaching a
+/// result.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The ref store's read or write half failed.
+ #[error("ref store operation failed: {0}")]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// `receive` (the write-back path, `effect.results-writeback`) could
+ /// not reach an outcome.
+ ///
+ /// Boxed rather than `#[from]`-derived inline (see [`From`] below):
+ /// `ents_receive::Error` embeds `ents_gate::Error`, which is large
+ /// enough on its own to trip `clippy::result_large_err` for every
+ /// fallible function in this crate if stored inline.
+ #[error("receive failed: {0}")]
+ Receive(Box<ents_receive::Error>),
+
+ /// The query evaluator could not compute an effect's work set.
+ #[error("query evaluation failed: {0}")]
+ Eval(#[from] ents_query::EvalError),
+
+ /// An effect's `trigger` failed to parse as a `CommitQuery`
+ /// (`effect.validation`).
+ #[error("trigger does not parse as a CommitQuery: {0}")]
+ Trigger(#[from] ents_query::ParseError),
+
+ /// A typed-tree entity (a [`ents_model::Toolchain`] or
+ /// [`ents_model::Status`]) could not be (de)serialized.
+ #[error("typed-tree operation failed: {0}")]
+ Facet(#[from] facet_git_tree::Error),
+
+ /// An object could not be read or decoded.
+ #[error("object {oid} could not be read: {detail}")]
+ Decode {
+ /// The undecodable object.
+ oid: ObjectId,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// An object referenced by a tree or commit is missing from the object
+ /// store.
+ #[error("object {oid} is missing")]
+ Missing {
+ /// The missing object.
+ oid: ObjectId,
+ },
+
+ /// `refs/meta/toolchains/<name>` does not exist.
+ #[error("no toolchain named {0:?}")]
+ UnknownToolchain(String),
+
+ /// A toolchain's `recipe` field did not parse as a [`crate::Recipe`]
+ /// (`effect.toolchains`: "a manifest's declared components MUST be
+ /// resolved during effect execution").
+ #[error("toolchain {name:?} has an unreadable recipe: {detail}")]
+ InvalidRecipe {
+ /// The toolchain's name.
+ name: String,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// An effect's `toolchains` list named something that is not a valid
+ /// ref-path segment (`effect.validation`).
+ #[error("{0:?} is not a valid toolchain name")]
+ InvalidToolchainName(String),
+
+ /// A materialized tree entry was a git submodule (a commit entry).
+ /// Gitlinks retain nothing in this design (no embedded submodule
+ /// content), so materializing one is refused rather than silently
+ /// skipped.
+ #[error("cannot materialize {path:?}: it is a git submodule (gitlink)")]
+ Submodule {
+ /// The offending path, relative to the materialization root.
+ path: String,
+ },
+
+ /// A tree entry's filename, or a downloaded component's extracted file
+ /// name, was not valid UTF-8.
+ #[error("{0:?} is not valid UTF-8")]
+ NotUtf8(PathBuf),
+
+ /// A path under a materialization destination could not be read or
+ /// written.
+ #[error("could not access {path}: {source}")]
+ Io {
+ /// The path being accessed.
+ path: PathBuf,
+ /// The underlying I/O error.
+ #[source]
+ source: std::io::Error,
+ },
+
+ /// A [`crate::Component`] carried a `dest` unsafe to use as a path
+ /// segment (not empty and not a single safe component), or a `url` or
+ /// `sha256` unsafe to interpolate into a shell command.
+ #[error("invalid toolchain component: {0}")]
+ InvalidComponent(String),
+
+ /// Running an external program (`docker`, `sprite`, `curl`, `tar`,
+ /// `sha256sum`/`shasum`) failed to start at all — the readiness probes
+ /// this phase ports from `pre-redo` exist precisely to turn this into
+ /// an actionable message instead of a raw "os error 2".
+ #[error("could not run `{program}`: {detail}")]
+ Spawn {
+ /// The program that could not be started.
+ program: String,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// An external program ran but reported failure (nonzero exit, or
+ /// output this crate could not parse).
+ #[error("{program} failed: {detail}")]
+ Process {
+ /// The program that failed.
+ program: String,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// A downloaded component's content did not match its recorded
+ /// sha256 — refused rather than extracted anyway.
+ #[error("{url}: expected sha256 {expected}, got {actual}")]
+ HashMismatch {
+ /// The component's source URL.
+ url: String,
+ /// The recorded sha256.
+ expected: String,
+ /// The sha256 actually computed.
+ actual: String,
+ },
+
+ /// The sandbox reported an infrastructure failure rather than a
+ /// completed run — never itself a `Status::Error` result
+ /// (`effect.result-taxonomy`); see this type's own doc.
+ #[error("the sandbox did not complete a run: {0}")]
+ Sandbox(String),
+}
+
+impl From<ents_receive::Error> for Error {
+ fn from(source: ents_receive::Error) -> Self {
+ Self::Receive(Box::new(source))
+ }
+}
+
+/// The `Result` alias every fallible `ents-effect` operation returns.
+pub type Result<T> = std::result::Result<T, Error>;
crates/ents-effect/src/executor.rs
@@ -1,0 +1,151 @@
+//! The `Executor` seam: one trait, multiple sandbox backends
+//! (`effect.execution`).
+//!
+//! No execution logic is duplicated per backend: [`Executor::run`] is
+//! handed a fully materialized workdir and a fully materialized set of
+//! toolchain directories (both produced by `crate::materialize::checkout`
+//! and [`crate::toolchain::materialize`] — the same code the run loop and
+//! `git effect run` share, `effect.local-run`), and does only the
+//! backend-specific part: get those bytes into the sandbox, run the
+//! command, and report what happened.
+
+use std::path::{Path, PathBuf};
+
+use crate::error::Result;
+
+/// The inputs one sandboxed run needs, already materialized on the host —
+/// a backend's only job is to get these into its sandbox and run
+/// [`SandboxInputs::command`].
+#[derive(Debug, Clone)]
+pub struct SandboxInputs<'a> {
+ /// The host directory holding the tested commit's checked-out tree.
+ pub workdir: &'a Path,
+ /// Each declared toolchain's name and the host directory holding its
+ /// activated `bin/` (`crate::toolchain::materialize`'s return value),
+ /// in the effect's declared order — the order [`activate`] honors when
+ /// two toolchains would otherwise collide on `PATH`.
+ pub toolchains: &'a [(String, PathBuf)],
+ /// The run command, exactly as stored on the effect definition
+ /// (`model.effect-definition`).
+ pub command: &'a str,
+}
+
+/// What a completed run reported. Only `Pass` or `Fail`
+/// (`effect.result-taxonomy`: "a completed command's exit status MUST
+/// always be recorded as a result"); an infrastructure failure — the
+/// sandbox never started — is [`crate::Error`], not a variant here.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RunStatus {
+ /// The command exited zero.
+ Pass,
+ /// The command exited nonzero.
+ Fail,
+}
+
+/// The output of one completed sandboxed run.
+#[derive(Debug, Clone)]
+pub struct RunOutput {
+ /// Whether the command passed or failed.
+ pub status: RunStatus,
+ /// The command's combined stdout/stderr.
+ pub log: String,
+}
+
+/// One sandbox backend (`effect.execution`): Docker, Sprite, or
+/// unsandboxed host-direct — selected only at a composition root
+/// (`roots.local`, `roots.hosted`), never by effect data
+/// (`effect.deployment-property`).
+///
+/// # Errors
+///
+/// [`Executor::run`] returns `Err` only for an infrastructure failure —
+/// the sandbox never started, or crashed before the command could report
+/// an exit status. A command that ran to completion and merely exited
+/// nonzero is `Ok(RunOutput { status: RunStatus::Fail, .. })`, never an
+/// `Err` (`effect.result-taxonomy`).
+///
+/// # Examples
+///
+/// A minimal executor for tests: runs the command directly on the host, no
+/// sandbox at all (this is deliberately *not* [`crate::UnsandboxedExecutor`]
+/// — it ignores `toolchains` entirely — so it demonstrates only the trait
+/// shape, not the `--unsandboxed` contract).
+///
+/// ```
+/// use ents_effect::{Executor, RunStatus, SandboxInputs};
+///
+/// struct AlwaysPass;
+/// impl Executor for AlwaysPass {
+/// fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<ents_effect::RunOutput> {
+/// Ok(ents_effect::RunOutput { status: RunStatus::Pass, log: String::new() })
+/// }
+/// }
+///
+/// let dir = tempfile::tempdir().expect("tempdir");
+/// let inputs = SandboxInputs { workdir: dir.path(), toolchains: &[], command: "true" };
+/// let output = AlwaysPass.run(&inputs).expect("infallible");
+/// assert_eq!(output.status, RunStatus::Pass);
+/// ```
+pub trait Executor: Send + Sync {
+ /// Run `inputs.command` in this backend's sandbox, materialized from
+ /// `inputs.workdir` and `inputs.toolchains`.
+ fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput>;
+}
+
+/// Prefix `command` with a `PATH` export activating `dirs`' entries,
+/// declared order first (so the first-listed toolchain's `bin` wins on a
+/// name collision) — ported from `pre-redo`'s `engine::activate`. `dirs`
+/// holds each toolchain's *in-sandbox* path (a backend maps its host
+/// [`SandboxInputs::toolchains`] entries to sandbox paths before calling
+/// this), so it is a plain string, not a [`Path`].
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::executor::activate;
+///
+/// let dirs = vec![("rust".to_owned(), "/toolchains/rust/bin".to_owned())];
+/// assert_eq!(
+/// activate("cargo test", &dirs),
+/// "export PATH=/toolchains/rust/bin:$PATH; cargo test"
+/// );
+/// assert_eq!(activate("true", &[]), "true");
+/// ```
+#[must_use]
+pub fn activate(command: &str, dirs: &[(String, String)]) -> String {
+ if dirs.is_empty() {
+ return command.to_owned();
+ }
+ let path = dirs
+ .iter()
+ .map(|(_, dir)| dir.as_str())
+ .collect::<Vec<_>>()
+ .join(":");
+ format!("export PATH={path}:$PATH; {command}")
+}
+
+#[cfg(test)]
+mod tests {
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn activate_prefixes_path_in_declared_order() {
+ let dirs = vec![
+ ("a".to_owned(), "/t/a/bin".to_owned()),
+ ("b".to_owned(), "/t/b/bin".to_owned()),
+ ];
+ assert_eq!(
+ activate("run", &dirs),
+ "export PATH=/t/a/bin:/t/b/bin:$PATH; run"
+ );
+ }
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn activate_is_identity_with_no_toolchains() {
+ assert_eq!(activate("run", &[]), "run");
+ }
+}
crates/ents-effect/src/lib.rs
@@ -1,0 +1,162 @@
+//! Effect execution, results, and toolchains at run time (`docs/spec/effect.sdoc`):
+//! the `Executor` trait, its Docker and Sprite backends, toolchain
+//! materialization, and the run loop that ties them to
+//! [`ents_receive::receive`] as the sole path a result re-enters the
+//! repository.
+//!
+//! This crate closes the loop `ents-gate` and `ents-receive` open
+//! (`docs/abstractions.adoc`, "The loop"): an effect's trigger is
+//! evaluated by `ents-query` (already linked by `ents-receive` for
+//! footprint matching, never by this crate's own dependents in the other
+//! direction — `arch.query-effect-split`), its run happens behind one
+//! [`Executor`] seam with multiple backends, and its outcome returns as an
+//! ordinary signed commit through [`write_result`], a `receive` client
+//! exactly like the CLI or a web edit.
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/effect.sdoc`:
+//!
+//! - `effect.definition`, `effect.admin-only` — already carried by
+//! `ents-model`'s [`ents_model::Effect`] and `ents-gate`'s default
+//! authorization arm; nothing new here.
+//! - `effect.validation` — [`definition::validate`]. `ents-receive` cannot
+//! call this itself (`arch.query-effect-split`); a future frontend that
+//! builds an effect-definition commit does, before ever proposing the
+//! write.
+//! - `effect.execution`, `effect.deployment-property` — [`Executor`],
+//! [`SandboxInputs`], [`RunOutput`]; [`docker::DockerExecutor`] (feature
+//! `docker`), [`sprite::SpriteExecutor`] (feature `sprite`),
+//! [`UnsandboxedExecutor`]. No executor, sandbox, or retry choice is
+//! readable from an [`ents_model::Effect`] — every backend is
+//! constructed and selected only by a composition root.
+//! - `effect.local-run` — [`run::run_one`] is the single code path
+//! [`run::run_effect`] (the boot-time/on-demand form, no queue) and a
+//! future hosted worker (a queue drain feeding the same [`run::run_one`]
+//! calls) both use.
+//! - `effect.results-writeback`, `effect.result-taxonomy` —
+//! [`write_result`]: an ordinary [`ents_receive::receive`] client,
+//! landing exactly `pass`/`fail`/`error` on
+//! `refs/meta/results/<effect>/<short-oid>`
+//! ([`run::short_oid`]). This crate never writes `Status::Error` itself
+//! — see [`Error`]'s own doc for why an infrastructure failure is always
+//! an `Err`, never a taxonomy value this crate chooses on a caller's
+//! behalf.
+//! - `effect.identity` — [`write_result`] takes a `sign` closure the
+//! composition root injects (mirrors `ents_sync::resolve::merge_heads`);
+//! this crate never holds key material.
+//! - `effect.official` — a refname-authorization rule on canonical
+//! `refs/meta/results/<effect>/*`, owned by `ents-gate`'s future
+//! Config-driven worker-key narrowing (see that crate's own doc); this
+//! crate only chooses *which* refname to target
+//! ([`run::run_one`]'s `results_ref`), never judges authorization.
+//! - `effect.self-run` — [`write_result`] and [`run::run_effect`] accept
+//! any results refname, canonical or
+//! [`ents_model::namespace::self_result_ref`]; adopting a self-run
+//! result onto the canonical ref is `ents-sync`'s adoption merge
+//! (`gate.adoption-merge`, `sync.adoption-machinery`), unchanged by this
+//! crate.
+//! - `effect.toolchains`, `model.toolchain` — [`Recipe`], [`Component`],
+//! [`toolchain::resolve`], [`toolchain::materialize`]; only
+//! [`Executor::run`]'s sandbox ever touches the materialized bytes this
+//! crate hands it.
+//! - `effect.fanout-index` — structurally satisfied, no dedicated code: a
+//! fanout-index rebuild is an ordinary effect (`run`ning `git index
+//! rebuild` or similar, a later, unbuilt command), so it uses exactly
+//! the same [`Executor`] and [`write_result`] path as any other effect.
+//!
+//! # Examples
+//!
+//! An end-to-end local run: enroll a worker, define an effect and a
+//! (trivial, embedded-empty) toolchain, advance a code ref, and run the
+//! effect with a stub executor — the shape `effect.local-run` names, minus
+//! only a real sandbox.
+//!
+//! ```
+//! use ents_effect::run::{run_effect, short_oid};
+//! use ents_effect::{Executor, Recipe, RunOutput, RunStatus, SandboxInputs};
+//! use ents_model::{Effect, Provenance, Toolchain, namespace};
+//! use ents_receive::{Mode, NullEventSink};
+//! use ents_testutil::{
+//! Keypair, MemRefStore, ObjectStore, advance_ref, empty_tree, enroll_member, write_meta_entity,
+//! };
+//! use gix_ref_store::RefStoreRead as _;
+//!
+//! struct AlwaysPass;
+//! impl Executor for AlwaysPass {
+//! fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<RunOutput> {
+//! Ok(RunOutput { status: RunStatus::Pass, log: "ok".into() })
+//! }
+//! }
+//!
+//! let refs = MemRefStore::default();
+//! let objects = ObjectStore::default();
+//! let worker = Keypair::from_seed(1);
+//! enroll_member(&refs, &objects, "worker", &worker, Provenance::AdminRegistered, 100);
+//!
+//! // A toolchain over the empty tree — nothing to extract, but it still
+//! // exercises resolve + materialize.
+//! let toolchain = Toolchain {
+//! name: "none".into(),
+//! recipe: Recipe::Embedded { tree: empty_tree(&objects) }.render(),
+//! };
+//! let toolchain_ref = namespace::toolchain_ref("none").expect("valid");
+//! write_meta_entity(&refs, &objects, toolchain_ref, &toolchain, None, 150);
+//!
+//! let effect = Effect {
+//! trigger: "rev(refs/heads/main)".into(),
+//! toolchains: vec!["none".into()],
+//! run: "true".into(),
+//! };
+//! assert!(ents_effect::definition::validate(&effect).is_ok());
+//!
+//! let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
+//!
+//! let author = gix::actor::Signature {
+//! name: "worker".into(), email: "worker@ents.test".into(),
+//! time: gix::date::Time { seconds: 300, offset: 0 },
+//! };
+//! let scratch = tempfile::tempdir().expect("tempdir");
+//! let cache = tempfile::tempdir().expect("tempdir");
+//!
+//! let outcomes = run_effect(
+//! &refs, &objects, &NullEventSink, &AlwaysPass, scratch.path(), cache.path(),
+//! "unit", &effect, None,
+//! |short| Ok(namespace::result_ref("unit", short).expect("valid")),
+//! &author, &|payload| worker.sign(payload), Mode::Advisory,
+//! ).expect("runs");
+//!
+//! assert_eq!(outcomes.len(), 1);
+//! assert_eq!(outcomes[0].0, commits[0]);
+//! assert_eq!(outcomes[0].1.result, ents_receive::TxResult::Applied);
+//!
+//! // The canonical results ref now carries a pass.
+//! let name = namespace::result_ref("unit", &short_oid(commits[0])).expect("valid");
+//! assert!(refs.get(name.as_ref()).expect("readable").is_some());
+//! ```
+
+pub mod definition;
+mod error;
+pub mod executor;
+mod materialize;
+mod results;
+pub mod run;
+pub mod toolchain;
+mod unsandboxed;
+
+#[cfg(feature = "docker")]
+pub mod docker;
+#[cfg(feature = "sprite")]
+pub mod sprite;
+
+pub use error::{Error, Result};
+pub use executor::{Executor, RunOutput, RunStatus, SandboxInputs};
+pub use results::write_result;
+pub use run::{run_effect, run_one};
+pub use toolchain::{Component, Recipe};
+pub use unsandboxed::UnsandboxedExecutor;
+
+#[cfg(feature = "docker")]
+pub use docker::DockerExecutor;
+#[cfg(feature = "sprite")]
+pub use sprite::SpriteExecutor;
crates/ents-effect/src/materialize.rs
@@ -1,0 +1,258 @@
+//! Checking out a git tree onto disk through gitoxide's `Find` seam alone
+//! (`arch.no-object-store-trait`) — no dependency on a real on-disk `.git`
+//! directory or a `git archive` subprocess, so this works identically
+//! against the in-memory fixture store in tests and a real odb in
+//! production.
+//!
+//! This is the one code path both the run loop's pushed-tree checkout and
+//! [`crate::toolchain::materialize`]'s `Embedded` case share
+//! (`effect.local-run`: "identical code path").
+
+use std::path::Path;
+
+use gix_hash::ObjectId;
+use gix_object::bstr::ByteSlice as _;
+use gix_object::tree::EntryKind;
+use gix_object::{Find, Kind, TreeRef};
+
+use crate::error::{Error, Result};
+
+/// Recursively write `tree`'s entries under `dest`, which must already
+/// exist. Blob entries are written verbatim, with the executable bit set
+/// per the entry's mode; tree entries recurse into a created subdirectory.
+///
+/// # Errors
+///
+/// [`Error::Submodule`] for a gitlink entry (this design embeds no
+/// submodule content, `effect.toolchains`'s neighboring retention rule);
+/// [`Error::NotUtf8`] for a non-UTF-8 filename; [`Error::Missing`] or
+/// [`Error::Decode`] for an unreadable object; [`Error::Io`] for a host
+/// filesystem failure. A symlink entry is written as a real symlink
+/// (`std::os::unix::fs::symlink`) pointing at its recorded target text.
+pub fn checkout(objects: &impl Find, tree: ObjectId, dest: &Path) -> Result<()> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&tree, &mut buf)
+ .map_err(|source| Error::Decode {
+ oid: tree,
+ detail: source.to_string(),
+ })?
+ .ok_or(Error::Missing { oid: tree })?;
+ if data.kind != Kind::Tree {
+ return Err(Error::Decode {
+ oid: tree,
+ detail: "expected a tree".to_owned(),
+ });
+ }
+ let entries: Vec<(String, EntryKind, ObjectId)> = TreeRef::from_bytes(data.data, tree.kind())
+ .map_err(|e| Error::Decode {
+ oid: tree,
+ detail: e.to_string(),
+ })?
+ .entries
+ .iter()
+ .map(|entry| {
+ let name = entry
+ .filename
+ .to_str()
+ .map_err(|_not_utf8| Error::NotUtf8(dest.join(entry.filename.to_string())))?
+ .to_owned();
+ Ok((name, entry.mode.kind(), entry.oid.to_owned()))
+ })
+ .collect::<Result<Vec<_>>>()?;
+
+ for (name, kind, oid) in entries {
+ let path = dest.join(&name);
+ match kind {
+ EntryKind::Tree => {
+ make_dir(&path)?;
+ checkout(objects, oid, &path)?;
+ }
+ EntryKind::Commit => {
+ return Err(Error::Submodule { path: name });
+ }
+ EntryKind::Link => {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::Decode {
+ oid,
+ detail: source.to_string(),
+ })?
+ .ok_or(Error::Missing { oid })?;
+ let target = std::str::from_utf8(data.data)
+ .map_err(|_not_utf8| Error::NotUtf8(path.clone()))?;
+ symlink(target, &path)?;
+ }
+ EntryKind::Blob | EntryKind::BlobExecutable => {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::Decode {
+ oid,
+ detail: source.to_string(),
+ })?
+ .ok_or(Error::Missing { oid })?;
+ std::fs::write(&path, data.data).map_err(|source| Error::Io {
+ path: path.clone(),
+ source,
+ })?;
+ if kind == EntryKind::BlobExecutable {
+ set_executable(&path)?;
+ }
+ }
+ }
+ }
+ Ok(())
+}
+
+fn make_dir(path: &Path) -> Result<()> {
+ std::fs::create_dir_all(path).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+#[cfg(unix)]
+fn set_executable(path: &Path) -> Result<()> {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mut perms = std::fs::metadata(path)
+ .map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })?
+ .permissions();
+ perms.set_mode(0o755);
+ std::fs::set_permissions(path, perms).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+#[cfg(not(unix))]
+fn set_executable(_path: &Path) -> Result<()> {
+ Ok(())
+}
+
+#[cfg(unix)]
+fn symlink(target: &str, path: &Path) -> Result<()> {
+ std::os::unix::fs::symlink(target, path).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+#[cfg(not(unix))]
+fn symlink(target: &str, path: &Path) -> Result<()> {
+ std::fs::write(path, target).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_testutil::ObjectStore;
+ use gix_object::tree::{Entry, EntryMode};
+ use gix_object::{Kind, Tree, Write as _};
+
+ use super::*;
+
+ #[test]
+ // @relation(effect.toolchains, effect.execution, scope=function, role=Verifies)
+ fn checkout_writes_blobs_and_sets_the_executable_bit() {
+ let objects = ObjectStore::default();
+ let script = objects
+ .write_buf(Kind::Blob, b"#!/bin/sh\necho hi\n")
+ .expect("write");
+ let readme = objects.write_buf(Kind::Blob, b"hello\n").expect("write");
+ let tree = Tree {
+ entries: vec![
+ Entry {
+ mode: EntryMode::from(EntryKind::Blob),
+ filename: "README".into(),
+ oid: readme,
+ },
+ Entry {
+ mode: EntryMode::from(EntryKind::BlobExecutable),
+ filename: "run.sh".into(),
+ oid: script,
+ },
+ ],
+ };
+ let tree_oid = objects.write(&tree).expect("write tree");
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ checkout(&objects, tree_oid, dir.path()).expect("checkout");
+
+ let script_path = dir.path().join("run.sh");
+ assert_eq!(
+ std::fs::read_to_string(&script_path).expect("read"),
+ "#!/bin/sh\necho hi\n"
+ );
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt as _;
+ let mode = std::fs::metadata(&script_path)
+ .expect("stat")
+ .permissions()
+ .mode();
+ assert_eq!(mode & 0o111, 0o111, "run.sh must be executable");
+ }
+ assert_eq!(
+ std::fs::read_to_string(dir.path().join("README")).expect("read"),
+ "hello\n"
+ );
+ }
+
+ #[test]
+ // @relation(effect.toolchains, scope=function, role=Verifies)
+ fn checkout_recurses_into_subdirectories() {
+ let objects = ObjectStore::default();
+ let leaf = objects.write_buf(Kind::Blob, b"leaf\n").expect("write");
+ let inner = Tree {
+ entries: vec![Entry {
+ mode: EntryMode::from(EntryKind::Blob),
+ filename: "leaf.txt".into(),
+ oid: leaf,
+ }],
+ };
+ let inner_oid = objects.write(&inner).expect("write inner tree");
+ let outer = Tree {
+ entries: vec![Entry {
+ mode: EntryMode::from(EntryKind::Tree),
+ filename: "sub".into(),
+ oid: inner_oid,
+ }],
+ };
+ let outer_oid = objects.write(&outer).expect("write outer tree");
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ checkout(&objects, outer_oid, dir.path()).expect("checkout");
+
+ assert_eq!(
+ std::fs::read_to_string(dir.path().join("sub").join("leaf.txt")).expect("read"),
+ "leaf\n"
+ );
+ }
+
+ #[test]
+ // @relation(effect.toolchains, scope=function, role=Verifies)
+ fn checkout_refuses_a_submodule_entry() {
+ let objects = ObjectStore::default();
+ let tree = Tree {
+ entries: vec![Entry {
+ mode: EntryMode::from(EntryKind::Commit),
+ filename: "vendor".into(),
+ oid: ObjectId::null(gix_hash::Kind::Sha1),
+ }],
+ };
+ let tree_oid = objects.write(&tree).expect("write tree");
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let err = checkout(&objects, tree_oid, dir.path()).expect_err("must refuse a gitlink");
+ assert!(matches!(err, Error::Submodule { .. }));
+ }
+}
crates/ents-effect/src/results.rs
@@ -1,0 +1,242 @@
+//! Writing a run's outcome back to the repository (`effect.results-writeback`,
+//! `effect.identity`): an ordinary [`ents_receive::receive`] client, never a
+//! privileged write outside the gate.
+//!
+//! [`write_result`] builds the [`ents_model::Status`] typed tree, seals it
+//! into a signed commit exactly the way [`ents_sync::resolve::merge_heads`]
+//! seals a merge tip (`sign` is a caller-injected closure, so this crate
+//! never holds key material — the composition root injects the worker's own
+//! member key, `effect.identity`: "its result commit MUST be signed with
+//! its own member key"), and hands the result to `receive` like any other
+//! frontend.
+
+use ents_model::Status;
+use ents_model::trailer::Trailers;
+use ents_receive::{EventSink, Mode, Outcome, Proposal, RefTransition};
+use gix::refs::FullName;
+use gix_object::{Commit, Find, Kind, Write, WriteTo as _};
+use gix_ref_store::RefStore;
+
+use crate::error::{Error, Result};
+
+/// Build a signed commit recording `status` on `results_ref`, and push it
+/// through [`ents_receive::receive`] — the sole path an effect's outcome
+/// may re-enter the repository (`effect.results-writeback`).
+///
+/// `results_ref` is the caller's choice: the canonical
+/// `refs/meta/results/<effect>/<short-oid>` for a designated worker, or the
+/// self-run `refs/meta/self/<member>/<effect>/<short-oid>` for any other
+/// member running the same effect on their own account
+/// (`effect.self-run`). Which one is "official" is a refname authorization
+/// rule the gate enforces (`effect.official`), not a decision this
+/// function makes.
+///
+/// # Errors
+///
+/// [`Error::Facet`] if `status` cannot be serialized; [`Error::Refs`] if
+/// reading `results_ref`'s current tip fails; [`Error::Receive`] if
+/// `receive` itself could not reach an outcome.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::write_result;
+/// use ents_model::Status;
+/// use ents_receive::{Mode, NullEventSink};
+/// use ents_testutil::{Keypair, MemRefStore, ObjectStore};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let key = Keypair::from_seed(1);
+/// let author = gix::actor::Signature {
+/// name: "worker".into(),
+/// email: "worker@ents.test".into(),
+/// time: gix::date::Time { seconds: 1_000, offset: 0 },
+/// };
+///
+/// let name: gix::refs::FullName =
+/// "refs/meta/results/unit/abc123456789".try_into().expect("valid");
+/// let outcome = write_result(
+/// &refs, &objects, &NullEventSink, name, Status::Pass, &author,
+/// |payload| key.sign(payload), Mode::Advisory,
+/// ).expect("evaluates");
+/// assert_eq!(outcome.result, ents_receive::TxResult::Applied);
+/// ```
+// @relation(effect.results-writeback, effect.identity, effect.result-taxonomy, effect.self-run, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one input per commit-building step, mirrors ents_sync::resolve::merge_heads's shape"
+)]
+pub fn write_result(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn EventSink,
+ results_ref: FullName,
+ status: Status,
+ author: &gix::actor::Signature,
+ sign: impl FnOnce(&[u8]) -> String,
+ mode: Mode,
+) -> Result<Outcome> {
+ let tree = facet_git_tree::serialize_into(&status, objects)?;
+ let old = refs.get(results_ref.as_ref())?;
+ let parents: Vec<_> = old.into_iter().collect();
+
+ let trailers = Trailers {
+ ents_ref: Some(results_ref.clone()),
+ schema_version: None,
+ };
+ let summary = match status {
+ Status::Pass => "Record pass",
+ Status::Fail => "Record fail",
+ Status::Error => "Record error",
+ };
+ let message = format!("{summary}\n\n{}", trailers.render());
+ let mut commit = Commit {
+ tree,
+ parents: parents.clone().into(),
+ author: author.clone(),
+ committer: author.clone(),
+ encoding: None,
+ message: message.into(),
+ extra_headers: Vec::new(),
+ };
+
+ let mut payload = Vec::new();
+ commit.write_to(&mut payload).map_err(|e| Error::Decode {
+ oid: tree,
+ detail: format!("serializing result commit failed: {e}"),
+ })?;
+ let pem = sign(&payload);
+ commit
+ .extra_headers
+ .push(("gpgsig".into(), pem.trim_end().into()));
+
+ let mut raw = Vec::new();
+ commit.write_to(&mut raw).map_err(|e| Error::Decode {
+ oid: tree,
+ detail: format!("serializing signed result commit failed: {e}"),
+ })?;
+ let tip = objects
+ .write_buf(Kind::Commit, &raw)
+ .map_err(|e| Error::Decode {
+ oid: tree,
+ detail: e.to_string(),
+ })?;
+
+ let proposal = Proposal {
+ transitions: vec![RefTransition {
+ name: results_ref,
+ old,
+ new: Some(tip),
+ }],
+ objects: vec![tip],
+ auth: None,
+ };
+ Ok(ents_receive::receive(
+ refs, objects, events, &proposal, mode,
+ )?)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_model::{Provenance, namespace};
+ use ents_receive::{NullEventSink, TxResult};
+ use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member};
+ use gix_ref_store::RefStoreRead as _;
+ use rstest::rstest;
+
+ use super::*;
+
+ fn author() -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 1_000,
+ offset: 0,
+ },
+ }
+ }
+
+ #[rstest]
+ #[case::pass(Status::Pass)]
+ #[case::fail(Status::Fail)]
+ #[case::error(Status::Error)]
+ // @relation(effect.results-writeback, effect.identity, effect.result-taxonomy, scope=function, role=Verifies)
+ fn write_result_lands_a_signed_commit_on_the_canonical_results_ref(#[case] status: Status) {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let worker = Keypair::from_seed(1);
+ enroll_member(
+ &refs,
+ &objects,
+ "worker",
+ &worker,
+ Provenance::AdminRegistered,
+ 100,
+ );
+
+ let name = namespace::result_ref("unit", "deadbeefcafe").expect("valid");
+ let outcome = write_result(
+ &refs,
+ &objects,
+ &NullEventSink,
+ name.clone(),
+ status,
+ &author(),
+ |payload| worker.sign(payload),
+ Mode::Advisory,
+ )
+ .expect("evaluates");
+ assert_eq!(outcome.result, TxResult::Applied);
+ // The gate validated the commit's signature against the worker's
+ // own member key — `effect.identity`: "its result commit MUST be
+ // signed with its own member key".
+ let (_, verdict) = outcome.verdicts.first().expect("one transition proposed");
+ assert!(verdict.is_pass());
+
+ let tip = refs.get(name.as_ref()).expect("readable").expect("landed");
+ let mut buf = Vec::new();
+ let data = gix_object::Find::try_find(&objects, &tip, &mut buf)
+ .expect("readable")
+ .expect("present");
+ let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("decodes");
+ let landed: Status =
+ facet_git_tree::deserialize(&commit.tree(), &objects).expect("deserializes");
+ assert_eq!(landed, status);
+ }
+
+ #[rstest]
+ // @relation(effect.self-run, effect.results-writeback, scope=function, role=Verifies)
+ fn write_result_can_target_the_self_run_namespace() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let member = Keypair::from_seed(2);
+ enroll_member(
+ &refs,
+ &objects,
+ "bob",
+ &member,
+ Provenance::AdminRegistered,
+ 100,
+ );
+
+ let name = namespace::self_result_ref(&ents_model::MemberId::new("bob"), "unit", "abc")
+ .expect("valid");
+ let outcome = write_result(
+ &refs,
+ &objects,
+ &NullEventSink,
+ name.clone(),
+ Status::Fail,
+ &author(),
+ |payload| member.sign(payload),
+ Mode::Advisory,
+ )
+ .expect("evaluates");
+ assert_eq!(outcome.result, TxResult::Applied);
+ assert!(refs.get(name.as_ref()).expect("readable").is_some());
+ }
+}
crates/ents-effect/src/run.rs
@@ -1,0 +1,464 @@
+//! The run loop (`effect.execution`, `effect.local-run`): materialize an
+//! effect's declared toolchains and the tested commit's tree, hand both to
+//! an [`Executor`], and write the outcome back through
+//! [`crate::write_result`].
+//!
+//! [`run_one`] is the one code path a hosted worker and `git effect run`
+//! both call (`effect.local-run`: "the identical code path a hosted worker
+//! uses"); only what surrounds it differs — a durable queue feeding a
+//! worker's loop of [`run_one`] calls, versus [`run_effect`] deriving the
+//! same obligations directly from [`ents_query::Evaluator::outstanding`]
+//! and calling [`run_one`] once per commit, with no queue at all
+//! (`effect.local-run`: "only the durable queue MUST be skipped").
+
+use std::path::Path;
+
+use ents_model::Effect;
+use ents_query::{Evaluator, Query};
+use ents_receive::{EventSink, Mode, Outcome};
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind, Write};
+use gix_ref_store::RefStore;
+
+use crate::error::{Error, Result};
+use crate::executor::{Executor, RunStatus, SandboxInputs};
+use crate::results::write_result;
+use crate::toolchain;
+
+/// The tree of the commit at `oid`.
+fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::Decode {
+ oid,
+ detail: source.to_string(),
+ })?
+ .ok_or(Error::Missing { oid })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::Decode {
+ oid,
+ detail: "expected a commit".to_owned(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ Ok(commit.tree())
+}
+
+/// The short-oid segment convention every results refname uses:
+/// `refs/meta/results/<effect>/<short-oid>` (`effect.results-writeback`) —
+/// the first 12 hex characters, long enough to stay unambiguous within one
+/// effect's results namespace while keeping refnames short.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::run::short_oid;
+///
+/// let oid = gix_hash::ObjectId::null(gix_hash::Kind::Sha1);
+/// assert_eq!(short_oid(oid), "000000000000");
+/// ```
+#[must_use]
+pub fn short_oid(oid: ObjectId) -> String {
+ let hex = oid.to_string();
+ hex.get(..12).unwrap_or(&hex).to_owned()
+}
+
+/// Materialize `effect`'s declared toolchains against `toolchain_cache`,
+/// returning each name paired with its host `bin/` directory, in the
+/// effect's declared order (`crate::executor::activate`'s PATH-collision
+/// tiebreak depends on this order surviving).
+///
+/// # Errors
+///
+/// [`Error::UnknownToolchain`] or [`Error::InvalidRecipe`] for a name the
+/// effect declares but that does not resolve; see
+/// [`crate::toolchain::materialize`] for extraction failures.
+fn resolve_toolchains(
+ refs: &dyn gix_ref_store::RefStoreRead,
+ objects: &impl Find,
+ effect: &Effect,
+ toolchain_cache: &Path,
+) -> Result<Vec<(String, std::path::PathBuf)>> {
+ let mut out = Vec::with_capacity(effect.toolchains.len());
+ for name in &effect.toolchains {
+ let (_, recipe) = toolchain::resolve(refs, objects, name)?;
+ let bin = toolchain::materialize(&recipe, objects, toolchain_cache)?;
+ out.push((name.clone(), bin));
+ }
+ Ok(out)
+}
+
+/// Run `effect` against the single commit `oid`: materialize its
+/// toolchains and `oid`'s tree, execute via `executor`, and write the
+/// outcome to `results_ref` — the one code path `effect.local-run` names.
+///
+/// `results_ref` is the caller's choice (`effect.self-run`,
+/// `effect.official`): the canonical results ref for a designated worker,
+/// or a self-run mirror for any other member. `scratch` holds the
+/// per-run, never-cached tree checkout (a Docker container is thrown away
+/// per run; a Sprite's `sync_dir` re-syncs it every time
+/// too, so nothing here needs it to survive); `toolchain_cache` holds the
+/// extract-once toolchain cache [`crate::toolchain::materialize`] shares
+/// across runs.
+///
+/// # Errors
+///
+/// Any [`Error`] from resolving or materializing a toolchain, checking out
+/// `oid`'s tree, the executor itself, or [`crate::write_result`].
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::run::run_one;
+/// use ents_effect::{Executor, RunOutput, RunStatus, SandboxInputs};
+/// use ents_model::{Effect, Provenance, namespace};
+/// use ents_receive::{Mode, NullEventSink};
+/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, advance_ref, enroll_member};
+///
+/// struct AlwaysPass;
+/// impl Executor for AlwaysPass {
+/// fn run(&self, _inputs: &SandboxInputs<'_>) -> ents_effect::Result<RunOutput> {
+/// Ok(RunOutput { status: RunStatus::Pass, log: String::new() })
+/// }
+/// }
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let worker = Keypair::from_seed(1);
+/// enroll_member(&refs, &objects, "worker", &worker, Provenance::AdminRegistered, 100);
+/// let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
+///
+/// let effect = Effect { trigger: "rev(refs/heads/main)".into(), toolchains: vec![], run: "true".into() };
+/// let results_ref = namespace::result_ref("unit", "abcabcabcabc").expect("valid");
+/// let author = gix::actor::Signature {
+/// name: "worker".into(), email: "worker@ents.test".into(),
+/// time: gix::date::Time { seconds: 300, offset: 0 },
+/// };
+/// let scratch = tempfile::tempdir().expect("tempdir");
+/// let cache = tempfile::tempdir().expect("tempdir");
+///
+/// let outcome = run_one(
+/// &refs, &objects, &NullEventSink, &AlwaysPass, scratch.path(), cache.path(),
+/// commits[0], &effect, results_ref, &author, |p| worker.sign(p), Mode::Advisory,
+/// ).expect("runs");
+/// assert_eq!(outcome.result, ents_receive::TxResult::Applied);
+/// ```
+// @relation(effect.execution, effect.local-run, effect.toolchains, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one input per materialization step, mirrors pre-redo's engine::run shape"
+)]
+pub fn run_one(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn EventSink,
+ executor: &dyn Executor,
+ scratch: &Path,
+ toolchain_cache: &Path,
+ oid: ObjectId,
+ effect: &Effect,
+ results_ref: FullName,
+ author: &gix::actor::Signature,
+ sign: impl FnOnce(&[u8]) -> String,
+ mode: Mode,
+) -> Result<Outcome> {
+ let toolchains = resolve_toolchains(refs, objects, effect, toolchain_cache)?;
+
+ let workdir = scratch.join(oid.to_string());
+ std::fs::create_dir_all(&workdir).map_err(|source| Error::Io {
+ path: workdir.clone(),
+ source,
+ })?;
+ let tree = commit_tree(objects, oid)?;
+ crate::materialize::checkout(objects, tree, &workdir)?;
+
+ let inputs = SandboxInputs {
+ workdir: &workdir,
+ toolchains: &toolchains,
+ command: &effect.run,
+ };
+ let output = executor.run(&inputs)?;
+ let status = match output.status {
+ RunStatus::Pass => ents_model::Status::Pass,
+ RunStatus::Fail => ents_model::Status::Fail,
+ };
+
+ write_result(
+ refs,
+ objects,
+ events,
+ results_ref,
+ status,
+ author,
+ sign,
+ mode,
+ )
+}
+
+/// Run `effect` against every commit currently owed a result
+/// (`ents_query::Evaluator::outstanding`, `query.workset`), or against the
+/// single commit `at` when given — the boot-time/on-demand form
+/// [`run_one`]'s doc names, and the shape `git effect run [--at <commit>]`
+/// (a future frontend) calls.
+///
+/// `results_ref` builds each run's target refname from its short oid
+/// (`crate::run::short_oid`) — pass `ents_model::namespace::result_ref` for
+/// a canonical worker or `ents_model::namespace::self_result_ref` curried
+/// to one member for a self-run (`effect.self-run`); this function makes
+/// no canonical-vs-self decision itself.
+///
+/// # Errors
+///
+/// [`Error::Eval`] if the work set cannot be computed; otherwise anything
+/// [`run_one`] can fail with, for the first commit that fails — later
+/// commits in the set are not attempted once one fails, since a caller
+/// wrapping this in its own retry policy (`effect.deployment-property`)
+/// needs to know exactly which commit stopped the batch.
+// @relation(effect.local-run, query.workset, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one input per materialization step plus the target-ref builder"
+)]
+pub fn run_effect(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn EventSink,
+ executor: &dyn Executor,
+ scratch: &Path,
+ toolchain_cache: &Path,
+ effect_name: &str,
+ effect: &Effect,
+ at: Option<ObjectId>,
+ results_ref: impl Fn(&str) -> Result<FullName>,
+ author: &gix::actor::Signature,
+ sign: &impl Fn(&[u8]) -> String,
+ mode: Mode,
+) -> Result<Vec<(ObjectId, Outcome)>> {
+ let trigger: Query = effect.trigger.parse()?;
+ let oids: Vec<ObjectId> = match at {
+ Some(oid) => vec![oid],
+ None => {
+ // `query.workset`'s dedup marker is always the effect's own
+ // *canonical* results namespace, regardless of which ref this
+ // particular run's outcome ends up targeting
+ // (`results_ref`) — a self-run mirror never discharges the
+ // canonical obligation, by construction.
+ let evaluator = Evaluator::new(refs, objects);
+ evaluator
+ .outstanding(effect_name, &trigger)?
+ .into_iter()
+ .collect()
+ }
+ };
+
+ let mut outcomes = Vec::with_capacity(oids.len());
+ for oid in oids {
+ let target = results_ref(&short_oid(oid))?;
+ let outcome = run_one(
+ refs,
+ objects,
+ events,
+ executor,
+ scratch,
+ toolchain_cache,
+ oid,
+ effect,
+ target,
+ author,
+ sign,
+ mode,
+ )?;
+ outcomes.push((oid, outcome));
+ }
+ Ok(outcomes)
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use ents_model::{Provenance, namespace};
+ use ents_receive::{NullEventSink, TxResult};
+ use ents_testutil::{Keypair, MemRefStore, ObjectStore, advance_ref, enroll_member};
+ use gix_ref_store::RefStoreRead as _;
+ use rstest::rstest;
+
+ use super::*;
+ use crate::executor::{RunOutput, RunStatus, SandboxInputs};
+
+ struct AlwaysPass;
+ impl Executor for AlwaysPass {
+ fn run(&self, _inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
+ Ok(RunOutput {
+ status: RunStatus::Pass,
+ log: String::new(),
+ })
+ }
+ }
+
+ fn author() -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: "worker".into(),
+ email: "worker@ents.test".into(),
+ time: gix::date::Time {
+ seconds: 500,
+ offset: 0,
+ },
+ }
+ }
+
+ #[rstest]
+ // @relation(effect.local-run, query.workset, scope=function, role=Verifies)
+ fn run_effect_derives_the_full_outstanding_set_with_no_queue_at_all() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let worker = Keypair::from_seed(1);
+ enroll_member(
+ &refs,
+ &objects,
+ "worker",
+ &worker,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 2, 200);
+
+ let effect = Effect {
+ trigger: "rev(refs/heads/main)".into(),
+ toolchains: vec![],
+ run: "true".into(),
+ };
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let cache = tempfile::tempdir().expect("tempdir");
+
+ // `NullEventSink`: the only component `effect.local-run` says this
+ // path skips is the durable queue, and this run derives its work
+ // set directly from `query.workset` instead of draining one.
+ let outcomes = run_effect(
+ &refs,
+ &objects,
+ &NullEventSink,
+ &AlwaysPass,
+ scratch.path(),
+ cache.path(),
+ "unit",
+ &effect,
+ None,
+ |short| Ok(namespace::result_ref("unit", short).expect("valid")),
+ &author(),
+ &|payload| worker.sign(payload),
+ Mode::Advisory,
+ )
+ .expect("runs");
+
+ assert_eq!(outcomes.len(), 2);
+ let mut ran: Vec<_> = outcomes.iter().map(|(oid, _)| *oid).collect();
+ ran.sort();
+ let mut expected = commits.clone();
+ expected.sort();
+ assert_eq!(ran, expected);
+ for (_, outcome) in &outcomes {
+ assert_eq!(outcome.result, TxResult::Applied);
+ }
+ }
+
+ #[rstest]
+ // @relation(effect.local-run, scope=function, role=Verifies)
+ fn run_effect_at_a_single_commit_skips_the_work_set_scan() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let worker = Keypair::from_seed(1);
+ enroll_member(
+ &refs,
+ &objects,
+ "worker",
+ &worker,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 3, 200);
+
+ let effect = Effect {
+ trigger: "rev(refs/heads/main)".into(),
+ toolchains: vec![],
+ run: "true".into(),
+ };
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let cache = tempfile::tempdir().expect("tempdir");
+
+ let first = *commits.first().expect("advance_ref produced a commit");
+ let outcomes = run_effect(
+ &refs,
+ &objects,
+ &NullEventSink,
+ &AlwaysPass,
+ scratch.path(),
+ cache.path(),
+ "unit",
+ &effect,
+ Some(first),
+ |short| Ok(namespace::result_ref("unit", short).expect("valid")),
+ &author(),
+ &|payload| worker.sign(payload),
+ Mode::Advisory,
+ )
+ .expect("runs");
+
+ assert_eq!(outcomes.len(), 1);
+ let (oid, _) = outcomes.first().expect("one outcome");
+ assert_eq!(*oid, first);
+ }
+
+ #[rstest]
+ // @relation(effect.self-run, effect.local-run, scope=function, role=Verifies)
+ fn run_effect_can_target_the_self_run_namespace_via_its_results_ref_closure() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let bob = Keypair::from_seed(2);
+ enroll_member(
+ &refs,
+ &objects,
+ "bob",
+ &bob,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let commits = advance_ref(&refs, &objects, "refs/heads/main", 1, 200);
+
+ let effect = Effect {
+ trigger: "rev(refs/heads/main)".into(),
+ toolchains: vec![],
+ run: "true".into(),
+ };
+ let scratch = tempfile::tempdir().expect("tempdir");
+ let cache = tempfile::tempdir().expect("tempdir");
+ let member = ents_model::MemberId::new("bob");
+
+ let outcomes = run_effect(
+ &refs,
+ &objects,
+ &NullEventSink,
+ &AlwaysPass,
+ scratch.path(),
+ cache.path(),
+ "unit",
+ &effect,
+ None,
+ |short| Ok(namespace::self_result_ref(&member, "unit", short).expect("valid")),
+ &author(),
+ &|payload| bob.sign(payload),
+ Mode::Advisory,
+ )
+ .expect("runs");
+
+ assert_eq!(outcomes.len(), 1);
+ let first = *commits.first().expect("advance_ref produced a commit");
+ let name = namespace::self_result_ref(&member, "unit", &short_oid(first)).expect("valid");
+ assert!(refs.get(name.as_ref()).expect("readable").is_some());
+ }
+}
crates/ents-effect/src/sprite.rs
@@ -1,0 +1,345 @@
+//! The Fly.io Sprite [`Executor`] backend (`effect.execution`,
+//! `roots.hosted`): a persistent, hardware-isolated sandbox driven through
+//! the `sprite` CLI, one Sprite kept per deployment so a toolchain's
+//! extracted bytes survive between runs (`crate::toolchain::materialize`'s
+//! host-side cache has a Sprite-side mirror, `sync_dir`'s extract-once
+//! check).
+//!
+//! Ported from `pre-redo`'s `git-effect::engine` Sprite half: the CLI
+//! authentication quirk ([`ensure_auth`]), the idempotent-create quirk
+//! ([`ensure_sprite`]), and the orphan-process-kill quirk in
+//! `unpack_script` all carry over verbatim — these are exactly the
+//! "environment is the risk" gotchas the development plan calls out.
+//! Rewritten against this phase's design: no `git archive` (this crate
+//! never assumes an on-disk `.git`, `arch.no-object-store-trait`) — the
+//! workdir and each toolchain are materialized to a host directory first
+//! (the same `crate::materialize::checkout` and
+//! [`crate::toolchain::materialize`] every backend shares), then `tar`'d
+//! from that host directory into the Sprite over `sprite exec`'s stdin;
+//! and no PTY/asciicast live-streaming (`effect.adoc` names no such
+//! requirement for this phase — deferred to `ents-web`, which owns any
+//! live view).
+
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+use crate::error::{Error, Result};
+use crate::executor::{Executor, RunOutput, RunStatus, SandboxInputs, activate};
+
+/// Where the workdir is unpacked inside the Sprite.
+pub const WORKDIR: &str = "/work";
+
+/// Where a toolchain's `bin/` is extracted inside the Sprite, one directory
+/// per content key (`{TOOLCHAINS_DIR}/<key>/bin`) — never cleared: the
+/// Sprite's persistent filesystem is the cache.
+pub const TOOLCHAINS_DIR: &str = "/toolchains";
+
+/// The env var the hosted worker passes the `sprite` CLI's auth token
+/// through, per [`ensure_auth`].
+pub const SPRITES_TOKEN_VAR: &str = "SPRITES_TOKEN";
+
+/// A Sprite name derived from `seed`, kept to the `[a-z0-9-]` a Sprite name
+/// allows so the same seed (a deployment id, a repository path) always
+/// reuses the same sandbox.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::sprite::sprite_name;
+///
+/// assert_eq!(sprite_name("git-ents.cloud"), "ents-effect-git-ents-cloud");
+/// assert_eq!(sprite_name(""), "ents-effect-sprite");
+/// ```
+#[must_use]
+pub fn sprite_name(seed: &str) -> String {
+ let sanitized: String = seed
+ .chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() {
+ c.to_ascii_lowercase()
+ } else {
+ '-'
+ }
+ })
+ .collect();
+ let trimmed = sanitized.trim_matches('-');
+ format!(
+ "ents-effect-{}",
+ if trimmed.is_empty() {
+ "sprite"
+ } else {
+ trimmed
+ }
+ )
+}
+
+/// Configure the `sprite` CLI from [`SPRITES_TOKEN_VAR`]. The CLI persists
+/// its credentials to a config file rather than reading the token per
+/// call, so without this it reports "no organizations configured" even
+/// with the token in the environment. `auth setup` is idempotent, so a
+/// caller may run this before every batch of runs to keep the steady state
+/// self-healing.
+///
+/// # Errors
+///
+/// [`Error::Process`] if [`SPRITES_TOKEN_VAR`] is unset, or the CLI ran and
+/// refused it; [`Error::Spawn`] if the CLI could not be started.
+pub fn ensure_auth() -> Result<()> {
+ let token = std::env::var(SPRITES_TOKEN_VAR).map_err(|_unset| Error::Process {
+ program: "sprite".to_owned(),
+ detail: format!("{SPRITES_TOKEN_VAR} is not set in the worker's environment"),
+ })?;
+ let output = Command::new("sprite")
+ .args(["auth", "setup", "--token", &token])
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "sprite".to_owned(),
+ detail: e.to_string(),
+ })?;
+ if output.status.success() {
+ Ok(())
+ } else {
+ Err(Error::Process {
+ program: "sprite".to_owned(),
+ detail: format!(
+ "auth setup failed: {}",
+ String::from_utf8_lossy(&output.stderr).trim()
+ ),
+ })
+ }
+}
+
+/// Create the Sprite named `name` if it does not already exist.
+/// `sprite create` fails when the Sprite is already there — the steady
+/// state once the first run has happened — so its failure is tolerated
+/// here and surfaces only later if the Sprite turns out unreachable.
+///
+/// # Errors
+///
+/// [`Error::Spawn`] if the CLI could not be started.
+pub fn ensure_sprite(name: &str) -> Result<()> {
+ let _existing = Command::new("sprite")
+ .args(["create", "--skip-console", name])
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "sprite".to_owned(),
+ detail: e.to_string(),
+ })?;
+ Ok(())
+}
+
+/// The in-Sprite script [`sync_dir`] runs to replace `dest`'s contents with
+/// the tar streamed over stdin, first killing any process still working
+/// under `dest`: a worker killed mid-run (a deploy, a restart) leaves its
+/// in-Sprite build processes alive, since `sprite exec` only tethers the
+/// local CLI process — an orphaned build still writing under `dest` races
+/// the wipe, failing `rm -rf` with "Directory not empty".
+fn unpack_script(dest: &str) -> String {
+ format!(
+ "for cwd in /proc/[0-9]*/cwd; do\n\
+ case \"$(readlink \"$cwd\" 2>/dev/null)\" in\n\
+ {dest}|{dest}/*) kill -9 \"$(basename \"${{cwd%/cwd}}\")\" 2>/dev/null || true ;;\n\
+ esac\n\
+ done\n\
+ rm -rf {dest} && mkdir -p {dest} && tar -x -C {dest}"
+ )
+}
+
+/// Stream `host_dir`'s contents into the Sprite `name` at `dest`, replacing
+/// whatever was there — used both for the workdir (always re-synced: a
+/// fresh checkout per run) and, via [`sync_toolchain`], for a toolchain not
+/// already cached in-Sprite.
+///
+/// # Errors
+///
+/// [`Error::Spawn`] if `tar` or `sprite` could not be started;
+/// [`Error::Process`] if either exited nonzero.
+fn sync_dir(host_dir: &Path, name: &str, dest: &str) -> Result<()> {
+ let mut archive = Command::new("tar")
+ .args(["-c", "-C"])
+ .arg(host_dir)
+ .arg(".")
+ .stdout(Stdio::piped())
+ .spawn()
+ .map_err(|e| Error::Spawn {
+ program: "tar".to_owned(),
+ detail: e.to_string(),
+ })?;
+ let tar_stdout = archive.stdout.take().ok_or_else(|| Error::Process {
+ program: "tar".to_owned(),
+ detail: "no stdout".to_owned(),
+ })?;
+
+ let unpack = Command::new("sprite")
+ .args(["exec", "-s", name, "--", "sh", "-c", &unpack_script(dest)])
+ .stdin(Stdio::from(tar_stdout))
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "sprite".to_owned(),
+ detail: e.to_string(),
+ })?;
+
+ let tar_status = archive.wait().map_err(|e| Error::Process {
+ program: "tar".to_owned(),
+ detail: e.to_string(),
+ })?;
+ if !tar_status.success() {
+ return Err(Error::Process {
+ program: "tar".to_owned(),
+ detail: format!("could not archive {}", host_dir.display()),
+ });
+ }
+ if !unpack.status.success() {
+ return Err(Error::Process {
+ program: "sprite".to_owned(),
+ detail: format!(
+ "could not sync into {dest}: {}",
+ String::from_utf8_lossy(&unpack.stderr).trim()
+ ),
+ });
+ }
+ Ok(())
+}
+
+/// Extract-once sync of one toolchain's `bin/` directory into the Sprite
+/// `name`, at `{TOOLCHAINS_DIR}/<key>/bin` — a directory already present
+/// from an earlier run is left alone rather than re-extracted, since the
+/// Sprite's persistent filesystem is the cache. `key` is the same content
+/// key [`crate::toolchain::materialize`] cached `host_bin`'s parent
+/// directory under, so two runs of the same toolchain content sync it at
+/// most once.
+///
+/// # Errors
+///
+/// See [`sync_dir`].
+fn sync_toolchain(host_bin: &Path, name: &str, key: &str) -> Result<String> {
+ let sandbox_dir = format!("{TOOLCHAINS_DIR}/{key}/bin");
+ let cached = Command::new("sprite")
+ .args([
+ "exec",
+ "-s",
+ name,
+ "--",
+ "sh",
+ "-c",
+ &format!("[ -d {sandbox_dir} ]"),
+ ])
+ .status()
+ .map_err(|e| Error::Spawn {
+ program: "sprite".to_owned(),
+ detail: e.to_string(),
+ })?;
+ if !cached.success() {
+ sync_dir(host_bin, name, &sandbox_dir)?;
+ }
+ Ok(sandbox_dir)
+}
+
+/// The content key [`crate::toolchain::materialize`] cached `host_bin`
+/// under — `host_bin`'s parent directory name, since `materialize` always
+/// returns `<cache_root>/<key>/bin`.
+fn content_key(host_bin: &Path) -> Result<String> {
+ host_bin
+ .parent()
+ .and_then(Path::file_name)
+ .and_then(std::ffi::OsStr::to_str)
+ .map(str::to_owned)
+ .ok_or_else(|| Error::Process {
+ program: "sprite".to_owned(),
+ detail: format!(
+ "{} is not a materialize()-shaped toolchain directory",
+ host_bin.display()
+ ),
+ })
+}
+
+/// [`Executor`] running each effect in a persistent, hardware-isolated Fly
+/// Sprite (`roots.hosted`).
+#[derive(Debug, Clone)]
+pub struct SpriteExecutor {
+ /// The Sprite's name, from [`sprite_name`] or chosen by the
+ /// composition root.
+ pub name: String,
+}
+
+impl SpriteExecutor {
+ /// A Sprite executor targeting the Sprite named `name`.
+ #[must_use]
+ pub fn new(name: impl Into<String>) -> Self {
+ Self { name: name.into() }
+ }
+}
+
+impl Executor for SpriteExecutor {
+ fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
+ ensure_auth()?;
+ ensure_sprite(&self.name)?;
+ sync_dir(inputs.workdir, &self.name, WORKDIR)?;
+
+ let mut sandbox_dirs: Vec<(String, String)> = Vec::with_capacity(inputs.toolchains.len());
+ for (toolchain_name, host_bin) in inputs.toolchains {
+ let key = content_key(host_bin)?;
+ let sandbox_dir = sync_toolchain(host_bin, &self.name, &key)?;
+ sandbox_dirs.push((toolchain_name.clone(), sandbox_dir));
+ }
+
+ let script = format!(
+ "cd {WORKDIR} && {} 2>&1",
+ activate(inputs.command, &sandbox_dirs)
+ );
+ let output = Command::new("sprite")
+ .args(["exec", "-s", &self.name, "--", "sh", "-c", &script])
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "sprite".to_owned(),
+ detail: e.to_string(),
+ })?;
+ let log = String::from_utf8_lossy(&output.stdout).into_owned();
+ let status = if output.status.success() {
+ RunStatus::Pass
+ } else {
+ RunStatus::Fail
+ };
+ Ok(RunOutput { status, log })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::normal("git-ents.cloud", "ents-effect-git-ents-cloud")]
+ #[case::empty("", "ents-effect-sprite")]
+ #[case::only_punctuation("///", "ents-effect-sprite")]
+ #[case::mixed_case("Repo_Name", "ents-effect-repo-name")]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn sprite_name_sanitizes_to_a_valid_shape(#[case] seed: &str, #[case] expected: &str) {
+ assert_eq!(sprite_name(seed), expected);
+ }
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn unpack_script_kills_orphans_before_wiping_the_destination() {
+ let script = unpack_script("/work");
+ assert!(script.contains("kill -9"));
+ assert!(script.contains("rm -rf /work && mkdir -p /work && tar -x -C /work"));
+ }
+
+ #[rstest]
+ // @relation(effect.toolchains, scope=function, role=Verifies)
+ fn content_key_reads_the_materialize_cache_layout() {
+ let bin = Path::new("/cache/deadbeef/bin");
+ assert_eq!(content_key(bin).expect("valid shape"), "deadbeef");
+ }
+
+ #[rstest]
+ // @relation(effect.toolchains, scope=function, role=Verifies)
+ fn content_key_rejects_a_path_with_no_parent() {
+ content_key(Path::new("/")).expect_err("no parent");
+ }
+}
crates/ents-effect/src/toolchain.rs
@@ -1,0 +1,551 @@
+//! Toolchain resolution and materialization (`effect.toolchains`,
+//! `model.toolchain`).
+//!
+//! [`ents_model::Toolchain::recipe`] is deliberately an opaque `String` —
+//! `model.toolchain`'s own doc names this crate as the one that gives it
+//! structure. [`Recipe`] is that structure: a toolchain's `bin` is either
+//! [`Recipe::Embedded`] (a tree already in the object database, captured
+//! whole by whatever wrote the toolchain) or [`Recipe::Downloaded`] (a set
+//! of externally-hosted, sha256-pinned archives), ported from `pre-redo`'s
+//! `git_toolchain::Bin` — the design pre-redo settled on and this phase
+//! carries forward, not a fresh design. [`Recipe::render`]/[`Recipe::parse`]
+//! round-trip it through the plain-text `recipe` field.
+//!
+//! [`materialize`] resolves a toolchain to a host directory containing its
+//! activated `bin/`, extract-once cached under a content key (a tree oid
+//! for `Embedded`, a hash of each component's pin for `Downloaded`) so a
+//! backend that runs the same toolchain repeatedly (a Sprite's persistent
+//! filesystem, a developer's local cache) never re-extracts unchanged
+//! bytes. Fetching a [`Recipe::Downloaded`] component shells to `curl`,
+//! `tar`, and `sha256sum`/`shasum`, the same pattern `pre-redo` used and
+//! this phase ports rather than adding an HTTP or hashing dependency.
+
+use std::io::Read as _;
+use std::path::{Path, PathBuf};
+use std::process::{Command, Stdio};
+
+use ents_model::{Toolchain, namespace};
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+use gix_ref_store::RefStoreRead;
+
+use crate::error::{Error, Result};
+
+/// How a toolchain's `bin` is provisioned — the structure inside
+/// [`ents_model::Toolchain::recipe`] (`effect.toolchains`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Recipe {
+ /// `bin`'s directory tree, already in the object database — the whole
+ /// tree's entries become the toolchain's activated `bin/` contents.
+ Embedded {
+ /// The tree object id.
+ tree: ObjectId,
+ },
+ /// A set of archives fetched, sha256-verified, and merged onto disk at
+ /// materialization time.
+ Downloaded {
+ /// Each archive making up the toolchain.
+ components: Vec<Component>,
+ },
+}
+
+/// One archive making up a [`Recipe::Downloaded`] toolchain: fetched from
+/// `url` and checked against `sha256` before being extracted per
+/// `strip`/`dest` — ported verbatim from `pre-redo`'s
+/// `git_toolchain::Component`.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Component {
+ /// Where to fetch the archive from.
+ pub url: String,
+ /// The archive's expected sha256, hex-encoded.
+ pub sha256: String,
+ /// Leading path segments `tar` strips at extraction.
+ pub strip: u8,
+ /// Subdirectory under the toolchain's `bin/` extraction root to extract
+ /// into: empty for an archive that already carries its own `bin/` top
+ /// level, `bin` for a flat archive whose payload should itself land on
+ /// `PATH`.
+ pub dest: String,
+}
+
+const EMBEDDED: &str = "embedded";
+const DOWNLOADED: &str = "downloaded";
+
+impl Recipe {
+ /// Parse a [`Recipe`] out of a [`ents_model::Toolchain::recipe`] string.
+ ///
+ /// The format is deliberately small rather than a general one (no new
+ /// dependency for two variants and four fields): one line naming the
+ /// kind, then either the embedded tree's hex oid, or one
+ /// `url sha256 strip dest` line per component (`dest` last, so it may
+ /// be empty without ambiguity).
+ ///
+ /// # Errors
+ ///
+ /// [`Error::InvalidRecipe`] if the text does not match this shape.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_effect::Recipe;
+ ///
+ /// let text = "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n";
+ /// let recipe = Recipe::parse(text).expect("parses");
+ /// assert!(matches!(recipe, Recipe::Embedded { .. }));
+ /// ```
+ pub fn parse(text: &str) -> Result<Self> {
+ let mut lines = text.lines().filter(|line| !line.trim().is_empty());
+ let Some(first) = lines.next() else {
+ return Err(invalid("empty recipe"));
+ };
+ let mut words = first.split_whitespace();
+ match words.next() {
+ Some(EMBEDDED) => {
+ let hex = words
+ .next()
+ .ok_or_else(|| invalid("embedded recipe missing a tree oid"))?;
+ let tree = ObjectId::from_hex(hex.as_bytes())
+ .map_err(|e| invalid(format!("invalid tree oid {hex:?}: {e}")))?;
+ Ok(Self::Embedded { tree })
+ }
+ Some(DOWNLOADED) => {
+ let mut components = Vec::new();
+ for line in lines {
+ let mut fields = line.split_whitespace();
+ let url = fields
+ .next()
+ .ok_or_else(|| invalid("component line missing a url"))?
+ .to_owned();
+ let sha256 = fields
+ .next()
+ .ok_or_else(|| invalid("component line missing a sha256"))?
+ .to_owned();
+ let strip = fields
+ .next()
+ .ok_or_else(|| invalid("component line missing a strip count"))?
+ .parse::<u8>()
+ .map_err(|e| invalid(format!("invalid strip count: {e}")))?;
+ let dest = fields.next().unwrap_or("").to_owned();
+ components.push(Component {
+ url,
+ sha256,
+ strip,
+ dest,
+ });
+ }
+ if components.is_empty() {
+ return Err(invalid(
+ "a downloaded toolchain must list at least one component",
+ ));
+ }
+ Ok(Self::Downloaded { components })
+ }
+ Some(other) => Err(invalid(format!("unknown recipe kind {other:?}"))),
+ None => Err(invalid("empty recipe")),
+ }
+ }
+
+ /// Render this [`Recipe`] back into the text stored in
+ /// [`ents_model::Toolchain::recipe`].
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_effect::Recipe;
+ ///
+ /// let recipe = Recipe::Embedded {
+ /// tree: gix_hash::ObjectId::null(gix_hash::Kind::Sha1),
+ /// };
+ /// let text = recipe.render();
+ /// assert_eq!(Recipe::parse(&text).expect("round-trips"), recipe);
+ /// ```
+ #[must_use]
+ pub fn render(&self) -> String {
+ match self {
+ Self::Embedded { tree } => format!("{EMBEDDED} {tree}\n"),
+ Self::Downloaded { components } => {
+ let mut out = format!("{DOWNLOADED}\n");
+ for c in components {
+ out.push_str(&format!("{} {} {} {}\n", c.url, c.sha256, c.strip, c.dest));
+ }
+ out
+ }
+ }
+ }
+}
+
+/// Read the [`Toolchain`] entity named `name` from
+/// `refs/meta/toolchains/<name>`, and parse its [`Toolchain::recipe`] as a
+/// [`Recipe`].
+///
+/// # Errors
+///
+/// [`Error::UnknownToolchain`] when the ref does not exist or does not
+/// resolve to a commit tree; [`Error::InvalidRecipe`] when its `recipe`
+/// field does not parse.
+///
+/// # Examples
+///
+/// ```
+/// use ents_effect::toolchain::resolve;
+/// use ents_model::Toolchain;
+/// use ents_testutil::{MemRefStore, ObjectStore, write_meta_entity};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let toolchain = Toolchain {
+/// name: "rust-stable".into(),
+/// recipe: "embedded 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n".into(),
+/// };
+/// let name: gix::refs::FullName = "refs/meta/toolchains/rust-stable".try_into().expect("valid");
+/// write_meta_entity(&refs, &objects, name, &toolchain, None, 100);
+///
+/// let (entity, recipe) = resolve(&refs, &objects, "rust-stable").expect("resolves");
+/// assert_eq!(entity.name, "rust-stable");
+/// assert!(matches!(recipe, ents_effect::Recipe::Embedded { .. }));
+/// ```
+pub fn resolve(
+ refs: &dyn RefStoreRead,
+ objects: &impl Find,
+ name: &str,
+) -> Result<(Toolchain, Recipe)> {
+ let refname = namespace::toolchain_ref(name)
+ .map_err(|e| Error::UnknownToolchain(format!("{name}: {e}")))?;
+ let Some(tip) = refs.get(refname.as_ref())? else {
+ return Err(Error::UnknownToolchain(name.to_owned()));
+ };
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&tip, &mut buf)
+ .map_err(|source| Error::Decode {
+ oid: tip,
+ detail: source.to_string(),
+ })?
+ .ok_or(Error::Missing { oid: tip })?;
+ if data.kind != Kind::Commit {
+ return Err(Error::Decode {
+ oid: tip,
+ detail: "toolchain ref does not point at a commit".to_owned(),
+ });
+ }
+ let commit = CommitRef::from_bytes(data.data, tip.kind()).map_err(|e| Error::Decode {
+ oid: tip,
+ detail: e.to_string(),
+ })?;
+ let toolchain: Toolchain = facet_git_tree::deserialize(&commit.tree(), objects)?;
+ let recipe = Recipe::parse(&toolchain.recipe).map_err(|e| match e {
+ Error::InvalidRecipe { detail, .. } => Error::InvalidRecipe {
+ name: name.to_owned(),
+ detail,
+ },
+ other => other,
+ })?;
+ Ok((toolchain, recipe))
+}
+
+fn invalid(detail: impl Into<String>) -> Error {
+ Error::InvalidRecipe {
+ name: String::new(),
+ detail: detail.into(),
+ }
+}
+
+/// A stable, filesystem-safe cache key for a [`Recipe`]: the embedded
+/// tree's own hex oid, or each downloaded component's sha256 joined in
+/// extraction order — the same bytes extracted differently (a different
+/// `strip`/`dest`) are a different toolchain on disk, so those fields join
+/// the key too.
+#[must_use]
+pub fn cache_key(recipe: &Recipe) -> String {
+ match recipe {
+ Recipe::Embedded { tree } => tree.to_string(),
+ Recipe::Downloaded { components } => components
+ .iter()
+ .map(|c| format!("{}.{}.{}", c.sha256, c.strip, c.dest))
+ .collect::<Vec<_>>()
+ .join("-"),
+ }
+}
+
+/// Resolve `recipe` to a host directory containing the toolchain's
+/// activated `bin/`, extracted once under `cache_root` and reused on every
+/// later call with the same recipe (`effect.toolchains`: "resolved during
+/// effect execution").
+///
+/// # Errors
+///
+/// [`Error::Submodule`] or [`Error::NotUtf8`] for a tree this crate cannot
+/// materialize; [`Error::Spawn`]/[`Error::Process`]/[`Error::HashMismatch`]
+/// for a downloaded component that could not be fetched, verified, or
+/// extracted; [`Error::Io`] for a host filesystem failure.
+///
+/// # Examples
+///
+/// An embedded recipe over the empty tree materializes an empty `bin/`.
+///
+/// ```
+/// use ents_effect::Recipe;
+/// use ents_effect::toolchain::materialize;
+/// use ents_testutil::ObjectStore;
+/// use gix_object::{Kind, Write as _};
+///
+/// let objects = ObjectStore::default();
+/// let empty = objects.write_buf(Kind::Tree, b"").expect("write");
+/// let recipe = Recipe::Embedded { tree: empty };
+///
+/// let dir = tempfile::tempdir().expect("tempdir");
+/// let bin = materialize(&recipe, &objects, dir.path()).expect("materializes");
+/// assert!(bin.ends_with("bin"));
+/// assert!(bin.is_dir());
+/// ```
+pub fn materialize(recipe: &Recipe, objects: &impl Find, cache_root: &Path) -> Result<PathBuf> {
+ let root = cache_root.join(cache_key(recipe));
+ let bin = root.join("bin");
+ if bin.is_dir() {
+ return Ok(bin);
+ }
+ let tmp = cache_root.join(format!("{}.tmp", cache_key(recipe)));
+ if tmp.exists() {
+ remove_dir(&tmp)?;
+ }
+ make_dir(&tmp)?;
+
+ match recipe {
+ Recipe::Embedded { tree } => {
+ let bin_tmp = tmp.join("bin");
+ make_dir(&bin_tmp)?;
+ crate::materialize::checkout(objects, *tree, &bin_tmp)?;
+ }
+ Recipe::Downloaded { components } => {
+ make_dir(&tmp.join("bin"))?;
+ for component in components {
+ fetch_component(component, &tmp)?;
+ }
+ }
+ }
+
+ // Land atomically: a transient failure partway through must never leave
+ // `bin` existing-but-incomplete, or the next call would trust a half
+ // extraction forever.
+ std::fs::rename(&tmp, &root).map_err(|source| Error::Io {
+ path: root.clone(),
+ source,
+ })?;
+ Ok(bin)
+}
+
+fn fetch_component(component: &Component, root: &Path) -> Result<()> {
+ if component.dest.contains('/') || component.dest.contains("..") {
+ return Err(Error::InvalidComponent(format!(
+ "unsafe dest {:?}",
+ component.dest
+ )));
+ }
+ let dest = if component.dest.is_empty() {
+ root.join("bin")
+ } else {
+ root.join("bin").join(&component.dest)
+ };
+ make_dir(&dest)?;
+
+ let bytes = fetch(&component.url)?;
+ let actual = sha256_hex(&bytes)?;
+ if !actual.eq_ignore_ascii_case(&component.sha256) {
+ return Err(Error::HashMismatch {
+ url: component.url.clone(),
+ expected: component.sha256.clone(),
+ actual,
+ });
+ }
+
+ let mut child = Command::new("tar")
+ .args([
+ "-x",
+ "-C",
+ dest.to_str().ok_or_else(|| Error::NotUtf8(dest.clone()))?,
+ &format!("--strip-components={}", component.strip),
+ ])
+ .stdin(Stdio::piped())
+ .stdout(Stdio::null())
+ .stderr(Stdio::piped())
+ .spawn()
+ .map_err(|e| Error::Spawn {
+ program: "tar".to_owned(),
+ detail: e.to_string(),
+ })?;
+ {
+ use std::io::Write as _;
+ let mut stdin = child.stdin.take().ok_or_else(|| Error::Process {
+ program: "tar".to_owned(),
+ detail: "no stdin".to_owned(),
+ })?;
+ stdin.write_all(&bytes).map_err(|e| Error::Process {
+ program: "tar".to_owned(),
+ detail: e.to_string(),
+ })?;
+ }
+ let output = child.wait_with_output().map_err(|e| Error::Process {
+ program: "tar".to_owned(),
+ detail: e.to_string(),
+ })?;
+ if !output.status.success() {
+ return Err(Error::Process {
+ program: "tar".to_owned(),
+ detail: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
+ });
+ }
+ Ok(())
+}
+
+/// `GET url`, via the system `curl` — shells out rather than adding an HTTP
+/// dependency, the same rationale `pre-redo` used.
+fn fetch(url: &str) -> Result<Vec<u8>> {
+ let output = Command::new("curl")
+ .args(["-sSL", "--fail", url])
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "curl".to_owned(),
+ detail: e.to_string(),
+ })?;
+ if !output.status.success() {
+ return Err(Error::Process {
+ program: "curl".to_owned(),
+ detail: format!("could not fetch {url}"),
+ });
+ }
+ Ok(output.stdout)
+}
+
+/// Hex-encoded sha256 of `bytes`, via the system `shasum` (macOS) or
+/// `sha256sum` (Linux) — shells out rather than adding a hashing
+/// dependency, the same rationale `pre-redo` used.
+fn sha256_hex(bytes: &[u8]) -> Result<String> {
+ let (program, args): (&str, &[&str]) = if Command::new("sha256sum")
+ .arg("--version")
+ .output()
+ .is_ok_and(|o| o.status.success())
+ {
+ ("sha256sum", &[])
+ } else {
+ ("shasum", &["-a", "256"])
+ };
+ let mut child = Command::new(program)
+ .args(args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ .map_err(|e| Error::Spawn {
+ program: program.to_owned(),
+ detail: e.to_string(),
+ })?;
+ {
+ use std::io::Write as _;
+ let mut stdin = child.stdin.take().ok_or_else(|| Error::Process {
+ program: program.to_owned(),
+ detail: "no stdin".to_owned(),
+ })?;
+ stdin.write_all(bytes).map_err(|e| Error::Process {
+ program: program.to_owned(),
+ detail: e.to_string(),
+ })?;
+ }
+ let mut out = String::new();
+ child
+ .stdout
+ .take()
+ .ok_or_else(|| Error::Process {
+ program: program.to_owned(),
+ detail: "no stdout".to_owned(),
+ })?
+ .read_to_string(&mut out)
+ .map_err(|e| Error::Process {
+ program: program.to_owned(),
+ detail: e.to_string(),
+ })?;
+ let status = child.wait().map_err(|e| Error::Process {
+ program: program.to_owned(),
+ detail: e.to_string(),
+ })?;
+ if !status.success() {
+ return Err(Error::Process {
+ program: program.to_owned(),
+ detail: "hashing failed".to_owned(),
+ });
+ }
+ out.split_whitespace()
+ .next()
+ .map(str::to_owned)
+ .ok_or_else(|| Error::Process {
+ program: program.to_owned(),
+ detail: "no hash in output".to_owned(),
+ })
+}
+
+fn make_dir(path: &Path) -> Result<()> {
+ std::fs::create_dir_all(path).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+fn remove_dir(path: &Path) -> Result<()> {
+ std::fs::remove_dir_all(path).map_err(|source| Error::Io {
+ path: path.to_owned(),
+ source,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ #[case::embedded(Recipe::Embedded { tree: ObjectId::null(gix_hash::Kind::Sha1) })]
+ #[case::downloaded(Recipe::Downloaded {
+ components: vec![
+ Component { url: "https://example.test/a.tar.gz".into(), sha256: "a".repeat(64), strip: 2, dest: String::new() },
+ Component { url: "https://example.test/b.tar.gz".into(), sha256: "b".repeat(64), strip: 1, dest: "bin".into() },
+ ],
+ })]
+ // @relation(effect.toolchains, model.toolchain, scope=function, role=Verifies)
+ fn recipe_round_trips_through_text(#[case] recipe: Recipe) {
+ let text = recipe.render();
+ assert_eq!(Recipe::parse(&text).expect("parses"), recipe);
+ }
+
+ #[rstest]
+ #[case::empty("")]
+ #[case::unknown_kind("frobnicated\n")]
+ #[case::embedded_missing_oid("embedded\n")]
+ #[case::downloaded_no_components("downloaded\n")]
+ // @relation(effect.toolchains, scope=function, role=Verifies)
+ fn parse_rejects_malformed_text(#[case] text: &str) {
+ Recipe::parse(text).expect_err("malformed");
+ }
+
+ #[rstest]
+ // @relation(effect.toolchains, scope=function, role=Verifies)
+ fn cache_key_differs_by_extraction_shape() {
+ let a = Recipe::Downloaded {
+ components: vec![Component {
+ url: "u".into(),
+ sha256: "s".repeat(64),
+ strip: 1,
+ dest: String::new(),
+ }],
+ };
+ let b = Recipe::Downloaded {
+ components: vec![Component {
+ url: "u".into(),
+ sha256: "s".repeat(64),
+ strip: 2,
+ dest: String::new(),
+ }],
+ };
+ assert_ne!(cache_key(&a), cache_key(&b));
+ }
+}
crates/ents-effect/src/unsandboxed.rs
@@ -1,0 +1,104 @@
+//! Host-direct execution, with no sandbox at all (`effect.execution`:
+//! "Host-direct execution... MUST require an explicit `--unsandboxed` flag
+//! and MUST be available only locally, never on canonical hosted
+//! infrastructure").
+//!
+//! This module only provides the [`Executor`] implementation; enforcing
+//! that it is reachable solely behind an explicit flag, and never wired at
+//! a hosted composition root, is `roots.local`'s and the future CLI's job
+//! (`roots.config-isolation`: selection happens at the root, never inside
+//! a library).
+
+use std::process::Command;
+
+use crate::error::{Error, Result};
+use crate::executor::{Executor, RunOutput, RunStatus, SandboxInputs, activate};
+
+/// [`Executor`] running a command directly on the host, with the tested
+/// tree's checkout as its working directory and every declared toolchain's
+/// `bin/` activated on `PATH` — no isolation whatsoever.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct UnsandboxedExecutor;
+
+impl Executor for UnsandboxedExecutor {
+ fn run(&self, inputs: &SandboxInputs<'_>) -> Result<RunOutput> {
+ let dirs: Vec<(String, String)> = inputs
+ .toolchains
+ .iter()
+ .map(|(name, dir)| (name.clone(), dir.display().to_string()))
+ .collect();
+ let output = Command::new("sh")
+ .arg("-c")
+ .arg(activate(inputs.command, &dirs))
+ .current_dir(inputs.workdir)
+ .output()
+ .map_err(|e| Error::Spawn {
+ program: "sh".to_owned(),
+ detail: e.to_string(),
+ })?;
+ let mut log = String::from_utf8_lossy(&output.stdout).into_owned();
+ log.push_str(&String::from_utf8_lossy(&output.stderr));
+ let status = if output.status.success() {
+ RunStatus::Pass
+ } else {
+ RunStatus::Fail
+ };
+ Ok(RunOutput { status, log })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use std::path::PathBuf;
+
+ use rstest::rstest;
+
+ use super::*;
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn unsandboxed_reports_pass_on_exit_zero() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let inputs = SandboxInputs {
+ workdir: dir.path(),
+ toolchains: &[],
+ command: "true",
+ };
+ let output = UnsandboxedExecutor.run(&inputs).expect("runs");
+ assert_eq!(output.status, RunStatus::Pass);
+ }
+
+ #[rstest]
+ // @relation(effect.execution, scope=function, role=Verifies)
+ fn unsandboxed_reports_fail_on_nonzero_exit_never_as_an_error() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let inputs = SandboxInputs {
+ workdir: dir.path(),
+ toolchains: &[],
+ command: "false",
+ };
+ let output = UnsandboxedExecutor
+ .run(&inputs)
+ .expect("a completed run is never Err");
+ assert_eq!(output.status, RunStatus::Fail);
+ }
+
+ #[rstest]
+ // @relation(effect.execution, effect.toolchains, scope=function, role=Verifies)
+ fn unsandboxed_activates_declared_toolchains_on_path() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let bin = dir.path().join("bin");
+ std::fs::create_dir_all(&bin).expect("mkdir");
+ let toolchains = vec![("t".to_owned(), bin)];
+ let inputs = SandboxInputs {
+ workdir: dir.path(),
+ toolchains: &toolchains,
+ command: "echo $PATH",
+ };
+ let output = UnsandboxedExecutor.run(&inputs).expect("runs");
+ assert!(output.log.contains("bin"));
+ let _: Vec<(String, PathBuf)> = toolchains;
+ }
+}