Add the jobs accessor to the runtime handle
Job run-fns can now read upstream outputs via (jobs name) — direct or
transitive ancestors only, and only :quire/push has actual outputs
(sha, ref, pushed-at) for now. Names outside the calling job's
transitive-input set raise a Lua error so typos and undeclared
dependencies fail loudly.

Assisted-by: Claude Opus 4.7 via Claude Code
change vplortstqpuzltvrwuqzokwynnmsmwuq
commit c0d32adf0d5f5cd7541b22ba5dfd97b3c6522b83
author Alpha Chen <alpha@kejadlen.dev>
date
parent lztymqqw
diff --git a/docs/CI-FENNEL.md b/docs/CI-FENNEL.md
index 4876d53..5ea0036 100644
--- a/docs/CI-FENNEL.md
+++ b/docs/CI-FENNEL.md
@@ -47,7 +47,7 @@ The dependency graph is *derived* from the inputs list. No separate `:needs` fie
 
 ### Accessing inputs
 
-The function receives the **runtime handle** as its sole argument — a table with `sh`, `secret`, and (planned) `jobs` bound on it. Destructure the primitives the body needs:
+The function receives the **runtime handle** as its sole argument — a table with `sh`, `secret`, and `jobs` bound on it. Destructure the primitives the body needs:
 
 ```
 (fn [{: sh : jobs}]
@@ -59,7 +59,7 @@ The function receives the **runtime handle** as its sole argument — a table wi
 
 The function-call form sidesteps the awkward dot-access on `/`-containing keys. The `:as` rebinding sugar planned for cron and cherry-picked outputs (see "Future: input args" below) layers on top of the same accessor.
 
-> **v0 status:** the runtime handle currently carries `sh` and `secret`. The `jobs` accessor is the next addition (tracked separately); until it lands, jobs cannot read upstream outputs and `:quire/push` data is not exposed to the body.
+> **v0 status:** `(jobs :quire/push)` is wired. Job-to-job outputs (where `(jobs :build)` returns a job's `run-fn` return value) are not — there's no writer API yet, and a reachable name with no recorded outputs returns `nil`.
 
 ### Sources
 
@@ -192,7 +192,7 @@ Bound on the runtime handle passed into each `run` function. Destructure what yo
 
 Each of these blocks the Fennel function until it returns. Multi-container parallelism inside one job is a v2 want; the v1 model is "the function runs sequentially, calling primitives that block."
 
-> **v0 status:** `sh` and `secret` are bound today. `jobs`, `container`, `read-file`/`read-json`/`write-file`, `log`, and `env` are planned and tracked separately.
+> **v0 status:** `sh`, `secret`, and `jobs` are bound today. `container`, `read-file`/`read-json`/`write-file`, `log`, and `env` are planned and tracked separately.
 
 Eval is unsandboxed by default (see CI.md). A `run` function that loops forever or allocates without bound will hang or OOM `quire serve`. The mitigation is the same as for any Fennel hang: write `ci.fnl` thoughtfully. The bwrap opt-in (also see CI.md) covers eval and primitive calls together when it lands.
 
diff --git a/src/ci/lua.rs b/src/ci/lua.rs
index 9cf4659..43fd403 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;
+use std::collections::{HashMap, HashSet};
 use std::rc::Rc;
 
 use mlua::{IntoLua, Lua, LuaSerdeExt};
@@ -94,7 +94,9 @@ fn register_job(
 }
 
 /// Per-execution runtime: holds the secrets exposed to the job, the
-/// current-job cursor, and the per-job captured `sh` outputs.
+/// inputs available via `(jobs name)`, the per-job transitive-input
+/// reachability sets, the current-job cursor, and the per-job
+/// captured `sh` outputs.
 ///
 /// Wrap an `Rc<Runtime>` in [`RuntimeHandle`] and convert it via
 /// [`IntoLua`] to install it on the Lua VM (sets app data, returns
@@ -104,27 +106,44 @@ fn register_job(
 ///
 /// Outside a run, no runtime is installed; in that case `(sh …)`
 /// runs the command but doesn't record (the cursor lookup misses).
-/// `(secret …)` requires a runtime — without one, calls error.
+/// `(secret …)` and `(jobs …)` require a runtime — without one, calls
+/// error.
 #[derive(Debug)]
 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>>,
     current_job: RefCell<Option<String>>,
     outputs: RefCell<HashMap<String, Vec<ShOutput>>>,
 }
 
 impl Runtime {
-    /// Build a fresh runtime with the given secrets. Cursor and
-    /// outputs start empty.
-    pub(super) fn new(secrets: HashMap<String, SecretString>) -> Self {
+    /// 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`].
+    pub(super) fn new(
+        secrets: HashMap<String, SecretString>,
+        inputs: HashMap<String, mlua::Value>,
+        transitive_inputs: HashMap<String, HashSet<String>>,
+    ) -> Self {
         Self {
             secrets,
+            inputs: RefCell::new(inputs),
+            transitive_inputs,
             current_job: RefCell::new(None),
             outputs: RefCell::new(HashMap::new()),
         }
     }
 
     /// Mark `id` as the currently executing job. `(sh …)` invocations
-    /// from this job's `run_fn` will record output under `id`.
+    /// from this job's `run_fn` will record output under `id`, and
+    /// `(jobs …)` lookups will validate against `id`'s reachability set.
     pub(super) fn enter_job(&self, id: &str) {
         *self.current_job.borrow_mut() = Some(id.to_string());
     }
@@ -143,7 +162,7 @@ impl Runtime {
 }
 
 /// `IntoLua` carrier for an `Rc<Runtime>`. Stows the Rc on the VM as
-/// app data and returns the handle table — `{sh, secret}` for now.
+/// app data and returns the handle table — `{sh, secret, jobs}`.
 pub(super) struct RuntimeHandle(pub Rc<Runtime>);
 
 impl IntoLua for RuntimeHandle {
@@ -152,10 +171,48 @@ impl IntoLua for RuntimeHandle {
         let table = lua.create_table()?;
         table.set("sh", lua.create_function(run_sh)?)?;
         table.set("secret", lua.create_function(lookup_secret)?)?;
+        table.set("jobs", lua.create_function(lookup_input)?)?;
         table.into_lua(lua)
     }
 }
 
+/// 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.
+fn lookup_input(lua: &Lua, name: String) -> mlua::Result<mlua::Value> {
+    let rt = lua
+        .app_data_ref::<Rc<Runtime>>()
+        .ok_or_else(|| mlua::Error::external("runtime not installed on Lua VM"))?;
+    let calling = rt.current_job.borrow();
+    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}'"
+        ))
+    })?;
+    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!(
+            "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
 /// isn't installed, the name is undeclared, or the file form fails to
 /// read.
@@ -315,9 +372,13 @@ 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)))
-            .into_lua(pipeline.fennel().lua())
-            .expect("install runtime")
+        RuntimeHandle(Rc::new(Runtime::new(
+            secrets,
+            HashMap::new(),
+            HashMap::new(),
+        )))
+        .into_lua(pipeline.fennel().lua())
+        .expect("install runtime")
     }
 
     #[test]
diff --git a/src/ci/pipeline.rs b/src/ci/pipeline.rs
index df01835..e8b77d0 100644
--- a/src/ci/pipeline.rs
+++ b/src/ci/pipeline.rs
@@ -4,11 +4,12 @@
 //! Lua/Fennel evaluation lives in the sibling [`super::lua`] module;
 //! this module owns the domain types and the structural rules.
 
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
 
 use miette::{NamedSource, SourceSpan};
 use petgraph::Graph;
 use petgraph::graph::NodeIndex;
+use petgraph::visit::{Bfs, Reversed};
 
 use super::lua;
 use crate::Result;
@@ -117,6 +118,40 @@ impl Pipeline {
             .collect()
     }
 
+    /// For each job, the set of input names — direct and transitive,
+    /// including source refs — reachable through the input graph. The
+    /// job's own id is not included.
+    ///
+    /// Used by the executor to validate `(jobs name)` lookups: the
+    /// calling job may only read outputs from names in its set.
+    ///
+    /// Walks the existing dependency graph in reverse (ancestors of
+    /// the job) via petgraph's BFS. Source refs aren't graph nodes,
+    /// so they're scooped up from the inputs lists of every visited
+    /// job.
+    pub(crate) fn transitive_inputs(&self) -> HashMap<String, HashSet<String>> {
+        let reversed = Reversed(&self.graph);
+        let mut result: HashMap<String, HashSet<String>> = HashMap::new();
+        for job in &self.jobs {
+            let start = self.node_index[&job.id];
+            let mut reachable = HashSet::new();
+            let mut bfs = Bfs::new(reversed, start);
+            while let Some(idx) = bfs.next(reversed) {
+                let visited = &self.jobs[self.graph[idx]];
+                if idx != start {
+                    reachable.insert(visited.id.clone());
+                }
+                for input in &visited.inputs {
+                    if !self.node_index.contains_key(input) {
+                        reachable.insert(input.clone());
+                    }
+                }
+            }
+            result.insert(job.id.clone(), reachable);
+        }
+        result
+    }
+
     /// Parse and validate a ci.fnl source string into a `Pipeline`.
     ///
     /// Delegates evaluation to [`lua::parse`] for the Fennel-side work,
@@ -549,4 +584,50 @@ mod tests {
             "should report unreachable job 'orphan': {errs:?}"
         );
     }
+
+    #[test]
+    fn transitive_inputs_collects_direct_and_indirect() {
+        let pipeline = Pipeline::load(
+            r#"(local ci (require :quire.ci))
+(ci.job :setup [:quire/push] (fn [_] nil))
+(ci.job :build [:setup] (fn [_] nil))
+(ci.job :test [:build :setup] (fn [_] nil))"#,
+            "ci.fnl",
+        )
+        .expect("load should succeed");
+
+        let map = pipeline.transitive_inputs();
+
+        assert_eq!(
+            map["setup"],
+            ["quire/push"].iter().map(|s| s.to_string()).collect()
+        );
+        assert_eq!(
+            map["build"],
+            ["setup", "quire/push"]
+                .iter()
+                .map(|s| s.to_string())
+                .collect()
+        );
+        assert_eq!(
+            map["test"],
+            ["build", "setup", "quire/push"]
+                .iter()
+                .map(|s| s.to_string())
+                .collect()
+        );
+    }
+
+    #[test]
+    fn transitive_inputs_excludes_self() {
+        let pipeline = Pipeline::load(
+            r#"(local ci (require :quire.ci))
+(ci.job :only [:quire/push] (fn [_] nil))"#,
+            "ci.fnl",
+        )
+        .expect("load should succeed");
+
+        let map = pipeline.transitive_inputs();
+        assert!(!map["only"].contains("only"), "self should not be in set");
+    }
 }
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 8c8d6d7..4a654d0 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -250,7 +250,7 @@ impl Run {
             base,
             state,
             id,
-            runtime: Rc::new(Runtime::new(HashMap::new())),
+            runtime: Rc::new(Runtime::new(HashMap::new(), HashMap::new(), HashMap::new())),
         };
         run.read_meta()?;
         run.read_times()?;
@@ -259,14 +259,16 @@ impl Run {
 
     /// Drive `pipeline` to completion through this run.
     ///
-    /// Constructs a fresh [`Runtime`] with `secrets`, installs it on
-    /// the pipeline's Lua VM, topo-sorts the jobs, transitions
-    /// Pending → Active, then invokes each `run_fn` in dependency
-    /// order with the runtime handle as its sole argument. `(sh …)`
-    /// calls record their captured output under the current job —
-    /// readable via [`Run::outputs`] after `execute` returns. The
-    /// run finishes in `Complete` if every job's `run_fn` returned
-    /// without error, otherwise `Failed`.
+    /// Constructs a fresh [`Runtime`] with `secrets`, the source
+    /// outputs (`:quire/push` from `meta.yml`), and the per-job
+    /// transitive-input sets; installs it on the pipeline's Lua VM,
+    /// topo-sorts the jobs, transitions Pending → Active, then
+    /// invokes each `run_fn` in dependency order with the runtime
+    /// handle as its sole argument. `(sh …)` calls record their
+    /// captured output under the current job — readable via
+    /// [`Run::outputs`] after `execute` returns. The run finishes
+    /// in `Complete` if every job's `run_fn` returned without error,
+    /// otherwise `Failed`.
     ///
     /// Source-ref filtering (e.g. running only `quire/push`-reachable
     /// jobs) is not yet implemented; for now every validated job runs.
@@ -275,9 +277,13 @@ impl Run {
         pipeline: &Pipeline,
         secrets: HashMap<String, SecretString>,
     ) -> Result<()> {
-        self.runtime = Rc::new(Runtime::new(secrets));
+        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 rt_value = RuntimeHandle(self.runtime.clone())
-            .into_lua(pipeline.fennel().lua())
+            .into_lua(lua)
             .expect("install runtime on Lua VM");
 
         let order: Vec<String> = pipeline
@@ -381,6 +387,20 @@ impl Run {
     }
 }
 
+/// Build the source-ref outputs table 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())?;
+
+    let mut inputs = HashMap::new();
+    inputs.insert("quire/push".to_string(), mlua::Value::Table(push));
+    Ok(inputs)
+}
+
 /// Write a serializable value to a YAML file atomically (temp file + rename).
 pub(crate) fn write_yaml<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
     let tmp_path = path.with_extension("yml.tmp");
@@ -563,7 +583,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())),
+            runtime: Rc::new(Runtime::new(HashMap::new(), HashMap::new(), HashMap::new())),
         };
 
         let result = run.transition(RunState::Active);
@@ -728,4 +748,130 @@ mod tests {
             "b should not have run after a failed"
         );
     }
+
+    #[test]
+    fn jobs_returns_quire_push_outputs_for_direct_input() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let mut run = runs.create(&test_meta()).expect("create");
+
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :grab [:quire/push]
+  (fn [{: sh : jobs}]
+    (let [push (jobs :quire/push)]
+      (sh ["echo" push.sha push.ref]))))"#,
+        );
+
+        run.execute(&pipeline, HashMap::new()).expect("execute");
+
+        let outputs = run.outputs("grab");
+        assert_eq!(outputs.len(), 1);
+        assert_eq!(outputs[0].stdout, "abc123 refs/heads/main\n");
+    }
+
+    #[test]
+    fn jobs_returns_quire_push_outputs_through_transitive_input() {
+        // `b` depends on `a` which depends on `:quire/push`; `b` reads
+        // `:quire/push` directly even though it's not a direct input.
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let mut run = runs.create(&test_meta()).expect("create");
+
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :a [:quire/push] (fn [_] nil))
+(ci.job :b [:a]
+  (fn [{: sh : jobs}]
+    (let [push (jobs :quire/push)]
+      (sh ["echo" push.sha]))))"#,
+        );
+
+        run.execute(&pipeline, HashMap::new()).expect("execute");
+
+        let outputs = run.outputs("b");
+        assert_eq!(outputs.len(), 1);
+        assert_eq!(outputs[0].stdout, "abc123\n");
+    }
+
+    #[test]
+    fn jobs_errors_on_unknown_name() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let mut run = runs.create(&test_meta()).expect("create");
+
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :grab [:quire/push] (fn [{: jobs}] (jobs :nope)))"#,
+        );
+
+        let err = run
+            .execute(&pipeline, HashMap::new())
+            .expect_err("expected failure");
+        match err {
+            Error::JobFailed { job, source } => {
+                assert_eq!(job, "grab");
+                let msg = source.to_string();
+                assert!(
+                    msg.contains("not in transitive inputs") && msg.contains("nope"),
+                    "expected 'not in transitive inputs' error, got: {msg}"
+                );
+            }
+            other => panic!("expected JobFailed, got: {other:?}"),
+        }
+    }
+
+    #[test]
+    fn jobs_errors_on_non_ancestor_job() {
+        // `peer` exists as a job but isn't an ancestor of `grab`.
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let mut run = runs.create(&test_meta()).expect("create");
+
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :peer [:quire/push] (fn [_] nil))
+(ci.job :grab [:quire/push] (fn [{: jobs}] (jobs :peer)))"#,
+        );
+
+        let err = run
+            .execute(&pipeline, HashMap::new())
+            .expect_err("expected failure");
+        match err {
+            Error::JobFailed { source, .. } => {
+                let msg = source.to_string();
+                assert!(
+                    msg.contains("not in transitive inputs") && msg.contains("peer"),
+                    "expected non-ancestor error, got: {msg}"
+                );
+            }
+            other => panic!("expected JobFailed, got: {other:?}"),
+        }
+    }
+
+    #[test]
+    fn jobs_errors_on_self_lookup() {
+        let (_dir, quire) = tmp_quire();
+        let runs = test_runs(&quire);
+        let mut run = runs.create(&test_meta()).expect("create");
+
+        let pipeline = load(
+            r#"(local ci (require :quire.ci))
+(ci.job :grab [:quire/push] (fn [{: jobs}] (jobs :grab)))"#,
+        );
+
+        let err = run
+            .execute(&pipeline, HashMap::new())
+            .expect_err("expected failure");
+        match err {
+            Error::JobFailed { source, .. } => {
+                let msg = source.to_string();
+                assert!(
+                    msg.contains("cannot read its own outputs"),
+                    "expected self-lookup error, got: {msg}"
+                );
+            }
+            other => panic!("expected JobFailed, got: {other:?}"),
+        }
+    }
 }