Use typed structs and serde for jobs accessor inputs
Inputs are PushOutputs (and an InputOutputs enum keeping the door
open for cron/job outputs), serialized to Lua at lookup time via
serde. The two-map split of values + reachability folds into a single
per-job view of Option<InputOutputs>, with absent keys signalling
non-reachable and None signalling reachable-but-empty.
Assisted-by: Claude Opus 4.7 via Claude Code
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index 43fd403..c993570 100644
--- a/src/ci/lua.rs
+++ b/src/ci/lua.rs
@@ -8,7 +8,7 @@
//! each `run-fn` at execute time.
use std::cell::RefCell;
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
use std::rc::Rc;
use mlua::{IntoLua, Lua, LuaSerdeExt};
@@ -93,10 +93,37 @@ fn register_job(
Ok(())
}
+/// Outputs of the `:quire/push` source, exposed to `(jobs :quire/push)`.
+/// For v1: `:sha`, `:ref`, `:pushed-at`. Branch/tag/etc are deferred.
+#[derive(Clone, Debug, serde::Serialize)]
+pub(super) struct PushOutputs {
+ pub sha: String,
+ #[serde(rename = "ref")]
+ pub r#ref: String,
+ #[serde(rename = "pushed-at")]
+ pub pushed_at: jiff::Timestamp,
+}
+
+/// What `(jobs name)` resolves to. One variant per kind of input;
+/// `untagged` so the on-the-Lua-side shape is the inner struct
+/// directly, not a wrapper table with a tag field.
+#[derive(Clone, Debug, serde::Serialize)]
+#[serde(untagged)]
+pub(super) enum InputOutputs {
+ Push(PushOutputs),
+}
+
/// Per-execution runtime: holds the secrets exposed to the job, the
-/// inputs available via `(jobs name)`, the per-job transitive-input
-/// reachability sets, the current-job cursor, and the per-job
-/// captured `sh` outputs.
+/// per-job `(jobs name)` views, the current-job cursor, and the
+/// per-job captured `sh` outputs.
+///
+/// `inputs` is keyed by the calling job; each inner map covers
+/// exactly the names that job may read. Reachability is implicit in
+/// the structure, so `(jobs name)` is a flat lookup. The inner value
+/// is `None` for reachable names without recorded outputs (future
+/// job-to-job outputs drop in without changing the lookup contract);
+/// names absent from the inner map are unreachable and produce a Lua
+/// error.
///
/// Wrap an `Rc<Runtime>` in [`RuntimeHandle`] and convert it via
/// [`IntoLua`] to install it on the Lua VM (sets app data, returns
@@ -108,34 +135,25 @@ fn register_job(
/// runs the command but doesn't record (the cursor lookup misses).
/// `(secret …)` and `(jobs …)` require a runtime — without one, calls
/// error.
-#[derive(Debug)]
+#[derive(Debug, Default)]
pub(super) struct Runtime {
secrets: HashMap<String, SecretString>,
- /// Inputs readable via `(jobs name)`. Source outputs (e.g.
- /// `quire/push`) are populated by the executor before the loop;
- /// job-to-job outputs are not yet wired up.
- inputs: RefCell<HashMap<String, mlua::Value>>,
- /// For each job, the set of names it may legally read via
- /// `(jobs name)` — its transitive ancestors in the input graph,
- /// including source refs. Self is never present.
- transitive_inputs: HashMap<String, HashSet<String>>,
+ inputs: HashMap<String, HashMap<String, Option<InputOutputs>>>,
current_job: RefCell<Option<String>>,
outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
}
impl Runtime {
- /// Build a fresh runtime. `inputs` is the map of source outputs
- /// already prepared as Lua values; `transitive_inputs` is the
- /// per-job reachability set from [`Pipeline::transitive_inputs`].
+ /// Build a fresh runtime. `inputs` is the precomputed per-job
+ /// `(jobs name)` view from [`Pipeline::transitive_inputs`] and
+ /// the source outputs.
pub(super) fn new(
secrets: HashMap<String, SecretString>,
- inputs: HashMap<String, mlua::Value>,
- transitive_inputs: HashMap<String, HashSet<String>>,
+ inputs: HashMap<String, HashMap<String, Option<InputOutputs>>>,
) -> Self {
Self {
secrets,
- inputs: RefCell::new(inputs),
- transitive_inputs,
+ inputs,
current_job: RefCell::new(None),
outputs: RefCell::new(HashMap::new()),
}
@@ -143,7 +161,7 @@ impl Runtime {
/// Mark `id` as the currently executing job. `(sh …)` invocations
/// from this job's `run_fn` will record output under `id`, and
- /// `(jobs …)` lookups will validate against `id`'s reachability set.
+ /// `(jobs …)` lookups will resolve against `id`'s view.
pub(super) fn enter_job(&self, id: &str) {
*self.current_job.borrow_mut() = Some(id.to_string());
}
@@ -176,10 +194,11 @@ impl IntoLua for RuntimeHandle {
}
}
-/// Body of `(jobs name)`. Returns the outputs registered for `name`
-/// if `name` is a transitive ancestor of the calling job (or a source
-/// ref reachable through one). Errors loudly if `name` isn't reachable
-/// or no runtime is installed.
+/// Body of `(jobs name)`. Returns the typed outputs the calling job's
+/// view has for `name`, serialized to a Lua value via serde. Reachable
+/// names without recorded outputs come back as `Nil`. Errors if `name`
+/// is outside the calling job's view, if the calling job tries to read
+/// its own outputs, or if the runtime isn't installed.
fn lookup_input(lua: &Lua, name: String) -> mlua::Result<mlua::Value> {
let rt = lua
.app_data_ref::<Rc<Runtime>>()
@@ -188,29 +207,19 @@ fn lookup_input(lua: &Lua, name: String) -> mlua::Result<mlua::Value> {
let calling = calling
.as_ref()
.ok_or_else(|| mlua::Error::external("(jobs ...) called outside a job's run-fn"))?;
- let reachable = rt.transitive_inputs.get(calling).ok_or_else(|| {
- mlua::Error::external(format!(
- "no transitive-input set for calling job '{calling}'"
- ))
+ let view = rt.inputs.get(calling).ok_or_else(|| {
+ mlua::Error::external(format!("no inputs view for calling job '{calling}'"))
})?;
- if !reachable.contains(&name) {
- if name == *calling {
- return Err(mlua::Error::external(format!(
- "Job '{calling}' cannot read its own outputs"
- )));
- }
- return Err(mlua::Error::external(format!(
+ match view.get(&name) {
+ Some(Some(value)) => lua.to_value(value),
+ Some(None) => Ok(mlua::Value::Nil),
+ None if name == *calling => Err(mlua::Error::external(format!(
+ "Job '{calling}' cannot read its own outputs"
+ ))),
+ None => Err(mlua::Error::external(format!(
"Job '{calling}' cannot read outputs from '{name}' — not in transitive inputs"
- )));
+ ))),
}
- // Reachable but no outputs recorded yet: nil. Job-to-job outputs
- // aren't wired up, so this is the common case for non-source names.
- Ok(rt
- .inputs
- .borrow()
- .get(&name)
- .cloned()
- .unwrap_or(mlua::Value::Nil))
}
/// Body of `(secret name)`. Errors as a Lua error if the runtime
@@ -372,13 +381,9 @@ mod tests {
/// return the runtime handle. Mirrors what `Run::execute` does so
/// tests can drive a `run_fn` directly.
fn rt(pipeline: &Pipeline, secrets: HashMap<String, SecretString>) -> mlua::Value {
- RuntimeHandle(Rc::new(Runtime::new(
- secrets,
- HashMap::new(),
- HashMap::new(),
- )))
- .into_lua(pipeline.fennel().lua())
- .expect("install runtime")
+ RuntimeHandle(Rc::new(Runtime::new(secrets, HashMap::new())))
+ .into_lua(pipeline.fennel().lua())
+ .expect("install runtime")
}
#[test]
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 4a654d0..fa55ae9 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -12,7 +12,7 @@ use std::rc::Rc;
use jiff::Timestamp;
use mlua::IntoLua;
-use super::lua::{Runtime, RuntimeHandle, ShOutput};
+use super::lua::{InputOutputs, PushOutputs, Runtime, RuntimeHandle, ShOutput};
use super::pipeline::Pipeline;
use crate::secret::SecretString;
use crate::{Error, Result};
@@ -250,7 +250,7 @@ impl Run {
base,
state,
id,
- runtime: Rc::new(Runtime::new(HashMap::new(), HashMap::new(), HashMap::new())),
+ runtime: Rc::new(Runtime::default()),
};
run.read_meta()?;
run.read_times()?;
@@ -278,10 +278,10 @@ impl Run {
secrets: HashMap<String, SecretString>,
) -> Result<()> {
let lua = pipeline.fennel().lua();
- let inputs =
- source_outputs(lua, &self.read_meta()?).expect("build source outputs on Lua VM");
- let transitive_inputs = pipeline.transitive_inputs();
- self.runtime = Rc::new(Runtime::new(secrets, inputs, transitive_inputs));
+ let meta = self.read_meta()?;
+ let sources = source_outputs(&meta);
+ let inputs = build_inputs_views(&pipeline.transitive_inputs(), &sources);
+ self.runtime = Rc::new(Runtime::new(secrets, inputs));
let rt_value = RuntimeHandle(self.runtime.clone())
.into_lua(lua)
.expect("install runtime on Lua VM");
@@ -387,18 +387,42 @@ impl Run {
}
}
-/// Build the source-ref outputs table read by `(jobs name)` for source
+/// Build the source-ref outputs read by `(jobs name)` for source
/// names. For v1, only `:quire/push` is exposed, derived from the run
/// meta — `:sha`, `:ref`, `:pushed-at`. Branch/tag/etc are deferred.
-fn source_outputs(lua: &mlua::Lua, meta: &RunMeta) -> mlua::Result<HashMap<String, mlua::Value>> {
- let push = lua.create_table()?;
- push.set("sha", meta.sha.clone())?;
- push.set("ref", meta.r#ref.clone())?;
- push.set("pushed-at", meta.pushed_at.to_string())?;
-
+/// Pure Rust data; `lookup_input` serializes to Lua via serde.
+fn source_outputs(meta: &RunMeta) -> HashMap<String, InputOutputs> {
+ let push = PushOutputs {
+ sha: meta.sha.clone(),
+ r#ref: meta.r#ref.clone(),
+ pushed_at: meta.pushed_at,
+ };
let mut inputs = HashMap::new();
- inputs.insert("quire/push".to_string(), mlua::Value::Table(push));
- Ok(inputs)
+ inputs.insert("quire/push".to_string(), InputOutputs::Push(push));
+ inputs
+}
+
+/// Materialize the per-job `(jobs name)` views from the pipeline's
+/// transitive-input map and the source outputs.
+///
+/// For each job J, the view holds every name reachable from J as a
+/// key. Source values populate the source entries; everything else
+/// sits as `None` so future job-to-job outputs can drop in without
+/// changing the lookup contract.
+fn build_inputs_views(
+ transitive_inputs: &HashMap<String, std::collections::HashSet<String>>,
+ sources: &HashMap<String, InputOutputs>,
+) -> HashMap<String, HashMap<String, Option<InputOutputs>>> {
+ transitive_inputs
+ .iter()
+ .map(|(job_id, reachable)| {
+ let view = reachable
+ .iter()
+ .map(|name| (name.clone(), sources.get(name).cloned()))
+ .collect();
+ (job_id.clone(), view)
+ })
+ .collect()
}
/// Write a serializable value to a YAML file atomically (temp file + rename).
@@ -583,7 +607,7 @@ mod tests {
base: PathBuf::from("/tmp/quire-test-runs/test.git"),
state: RunState::Pending,
id: uuid::Uuid::now_v7().to_string(),
- runtime: Rc::new(Runtime::new(HashMap::new(), HashMap::new(), HashMap::new())),
+ runtime: Rc::new(Runtime::default()),
};
let result = run.transition(RunState::Active);