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
change rkpuqmkxkryvysxkynsktuokvkwvnnnk
commit 87549a125025b3d677759de043243ec53fb8b402
author Alpha Chen <alpha@kejadlen.dev>
date
parent vplortst
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);