Skip to main content

max / makenotwork

Give RecipeCtx accessors for its four run-state fields Preparation for splitting engine.rs, and worth stating on its own. `current`, `gatekeeper_ok`, `failed_steps`, `all_green_required` and `artifact_hashes` are private and mostly Mutex-wrapped, and today the code that reads them is in the same file, so it reaches straight into the field. Once the file becomes a directory those readers are siblings rather than descendants, and a sibling cannot see a private field. Six accessors, `pub(super)`, each handing out a value and never the guard: a returned guard invites a lock held across a sibling module's await. The seven touch sites go through them. `artifact_hash(name)` is a targeted read beside the existing `artifact_hashes()`, which clones the whole map; publish wants one entry. 198 tests pass, clippy clean.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-05 03:23 UTC
Signed with PGP, not checked
Commit: a1410d7419358fada3dc8f3072056fce481689e0
Parent: a3e985b
1 file changed, +51 insertions, -7 deletions
@@ -722,6 +722,50 @@
722 722 );
723 723 resolve_artifact_match(&tail, glob, required)
724 724 }
725 +
726 + // --- Run-state accessors.
727 + //
728 + // The four fields below are private and `Mutex`-wrapped, and the modules
729 + // that read them are siblings of this one rather than descendants, so a
730 + // sibling cannot reach the field. Each accessor hands out a value and never
731 + // the guard: a returned guard invites a lock held across a sibling's await.
732 +
733 + /// The steps finalized as `Failed` so far, as a snapshot.
734 + ///
735 + /// Cloned rather than borrowed because `PublishAuthority::prove` wants a
736 + /// slice and the caller slices this, which is the shape that keeps the lock
737 + /// out of the caller's scope.
738 + pub(super) fn failed_steps_snapshot(&self) -> Vec<Step> {
739 + self.failed_steps.lock().unwrap().clone()
740 + }
741 +
742 + /// Record the digest `collect` computed for one artifact, by file name.
743 + pub(super) fn record_artifact_hash(&self, name: String, digest: String) {
744 + self.artifact_hashes.lock().unwrap().insert(name, digest);
745 + }
746 +
747 + /// One artifact's recorded digest, if `collect` hashed it.
748 + ///
749 + /// The targeted read beside [`RecipeCtx::artifact_hashes`], which clones the
750 + /// whole map; `publish` wants exactly one entry.
751 + pub(super) fn artifact_hash(&self, name: &str) -> Option<String> {
752 + self.artifact_hashes.lock().unwrap().get(name).cloned()
753 + }
754 +
755 + /// The all-targets-green publish gate, if this app declares one.
756 + pub(super) fn all_green_required(&self) -> Option<Vec<Target>> {
757 + self.all_green_required.clone()
758 + }
759 +
760 + /// The gatekeeper verdict: `None` = never run, `Some(false)` = rejected.
761 + pub(super) fn gatekeeper_ok(&self) -> Option<bool> {
762 + *self.gatekeeper_ok.lock().unwrap()
763 + }
764 +
765 + /// Record what the gatekeeper said about this artifact.
766 + pub(super) fn set_gatekeeper_ok(&self, accepted: bool) {
767 + *self.gatekeeper_ok.lock().unwrap() = Some(accepted);
768 + }
725 769 }
726 770
727 771 // ----- error bridging: anyhow -> Rhai runtime error -----
@@ -1972,7 +2016,7 @@
1972 2016 // A failed earlier step bars a deploy exactly as it bars a publish. An
1973 2017 // artifact that failed its gates must not reach a production host just
1974 2018 // because the recipe kept running.
1975 - let failed = self.failed_steps.lock().unwrap().clone();
2019 + let failed = self.failed_steps_snapshot();
1976 2020 anyhow::ensure!(
1977 2021 failed.is_empty(),
1978 2022 "refusing to deploy {} {}: {} failed earlier in this run",
@@ -2132,7 +2176,7 @@
2132 2176 .map_or_else(|| rel.clone(), |n| n.to_string_lossy().into_owned());
2133 2177 assert_artifact_version(&name, &self.version)?;
2134 2178 let digest = sha256_file(&path)?;
2135 - self.artifact_hashes.lock().unwrap().insert(rel, digest);
2179 + self.record_artifact_hash(rel, digest);
2136 2180 }
2137 2181 // Deposit at the archive path, so this target's bytes have one address
2138 2182 // whichever host produced them. A no-op when no archive is configured.
@@ -2230,7 +2274,7 @@
2230 2274 // or still building. The publishing target itself is the last mile (it
2231 2275 // reached publish, so its steps passed) and is not required to be green
2232 2276 // in the ledger yet.
2233 - if let Some(declared) = self.all_green_required.clone() {
2277 + if let Some(declared) = self.all_green_required() {
2234 2278 self.assert_siblings_green(&declared)?;
2235 2279 }
2236 2280 let backend = self
@@ -2286,8 +2330,8 @@
2286 2330 // called without one, so the unverified/post-failure ship path is sealed
2287 2331 // at the type level rather than guarded by a separate runtime check.
2288 2332 let authority = {
2289 - let failed = self.failed_steps.lock().unwrap();
2290 - let gatekeeper = *self.gatekeeper_ok.lock().unwrap();
2333 + let failed = self.failed_steps_snapshot();
2334 + let gatekeeper = self.gatekeeper_ok();
2291 2335 PublishAuthority::prove(target, failed.as_slice(), gatekeeper)?
2292 2336 };
2293 2337 let notes = meta
@@ -2328,7 +2372,7 @@
2328 2372 let artifact_hash: Option<String> = artifact_path
2329 2373 .file_name()
2330 2374 .and_then(|n| n.to_str())
2331 - .and_then(|n| self.artifact_hashes.lock().unwrap().get(n).cloned())
2375 + .and_then(|n| self.artifact_hash(n))
2332 2376 .or_else(|| sha256_file(&artifact_path).ok());
2333 2377 let me = self.clone();
2334 2378 let (app_s, target_s, ver_s, chan_s) = (
@@ -2408,7 +2452,7 @@
2408 2452 // Record the verdict for the publish gate. A rejection also
2409 2453 // fails the step, so the matrix shows red and `publish` is barred
2410 2454 // even if the recipe ignores the returned bool.
2411 - *ctx.gatekeeper_ok.lock().unwrap() = Some(accepted);
2455 + ctx.set_gatekeeper_ok(accepted);
2412 2456 if !accepted {
2413 2457 ctx.fail_current_step();
2414 2458 }