Skip to main content

runner_manager_github/
demand.rs

1// owner: c4-demand-and-jit-gateway
2
3//! How much work is waiting for a runner that does not exist yet.
4//!
5//! Demand is the number `e1` feeds to `clamp(demand, min_capacity,
6//! max_capacity)`, so it is the number that decides how many runner processes
7//! this host starts. Everything in this module exists to make that number
8//! honest — and, where it cannot be, to make it *say* so rather than look
9//! precise.
10//!
11//! ```text
12//! GET /repos/{owner}/{repo}/actions/runs?status=queued&per_page=100
13//! GET /repos/{owner}/{repo}/actions/runs?status=in_progress&per_page=100
14//!   -> 200 { "total_count": N, "workflow_runs": [ { "id": … }, … ] }
15//! GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs?filter=latest&per_page=100
16//!   -> 200 { "total_count": M, "jobs": [ { "status": …, "labels": […] }, … ] }
17//! ```
18//!
19//! # The counting unit is a **job**, and this reverses an earlier decision
20//!
21//! This is the single most important thing to know about this module, and it is
22//! an owner decision that **replaced the opposite owner decision**. The history
23//! is kept here rather than deleted, because the reasoning that produced the old
24//! answer is still sound in the abstract and a later reader who only sees the
25//! new code will otherwise re-derive it and revert this.
26//!
27//! **What the previous decision said.** `d18b-run-count-filtering.md` probed the
28//! runs endpoint against live GitHub and established that its `total_count`
29//! counts **workflow runs matching the query**. Counting runs costs exactly one
30//! request per repository, a fixed figure the budget model
31//! ([`crate::rest::TargetCost`], [`crate::rest::BudgetProjection`]) could price
32//! and `f2`'s `add` refusals could be computed from. Resolving each queued run's
33//! jobs costs one extra request *per run*, a variable cost scaling with queue
34//! depth. The owner chose the fixed cost, accepted the resulting under-count,
35//! and this module said at length: *do not "fix" this, and do not add a per-run
36//! job listing*.
37//!
38//! **Why it was reversed.** A workflow run holds many jobs — a matrix, or a
39//! `jobs:` map with several independent entries — and **each job needs its own
40//! runner**. Under the run count, a single queued run holding eight jobs read as
41//! demand `1`, `e1` started **one** runner, that runner took one job, and the
42//! remaining seven queued behind it. The next poll saw the same run still
43//! queued, still read `1`, and started one more. So a host configured for ten
44//! concurrent runners served an eight-job matrix **serially, roughly one at a
45//! time**, with the queue depth on GitHub growing while the machine sat idle.
46//! The under-count was not a rounding error in the demand signal; on the
47//! workflow shape people actually write, it was the difference between the
48//! product's headline feature working and not working. That is a worse outcome
49//! than a variable request cost, so the trade was re-made the other way.
50//!
51//! **What that means for a later reader.** The per-run job listing below is
52//! deliberate and load-bearing. Removing it to restore a fixed per-repository
53//! cost re-creates the serial-matrix defect described above. If a future owner
54//! decision reverses this again, it belongs in this documentation and in
55//! `tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it` before it
56//! belongs in the code.
57//!
58//! # Both run statuses are polled, and the second one is not redundant
59//!
60//! `status=queued` alone is the obvious query and it is not sufficient. A run's
61//! status is a property of the *run*, and a run holding both a running job and a
62//! queued one has to report one value for both. Live observation on a repository
63//! using this product caught a run of five jobs — two completed, one
64//! `in_progress`, two `queued` — reporting `status: "queued"`, so `queued` does
65//! take precedence over `in_progress` while any job is still waiting.
66//!
67//! That observation is what makes `status=queued` the *primary* signal, and it
68//! is not what makes it sufficient. The case it does not cover is a job that
69//! becomes queued **later**: `needs:` holds a job back until its dependency
70//! finishes, and whether GitHub flips the run's status back to `queued` at that
71//! moment is a claim about a state machine this project has not observed. A
72//! `needs:`-gated job is an ordinary workflow shape, and missing one entirely
73//! would be the same class of defect this module was just rewritten to fix.
74//!
75//! So `status=in_progress` is polled too, as a **safety net rather than a second
76//! primary signal**, and it is budgeted like one: it is read after the queued
77//! runs and gets the smaller of the two run caps
78//! ([`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] against
79//! [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`]). A run appears in at most one of
80//! the two lists — the queries are disjoint by construction — and only jobs
81//! whose own `status` is `queued` are counted, so an in-progress run whose jobs
82//! have all been dispatched contributes nothing but the request that discovered
83//! that.
84//!
85//! `completed` and `waiting` runs are not polled. A completed run has no
86//! dispatchable job left, and a `waiting` run is held by a deployment gate or a
87//! concurrency group rather than by the absence of a runner — starting one for
88//! it would produce a runner that idles until its timeout.
89//!
90//! # Routing labels ARE applied now, and the predicate is still `b1`'s
91//!
92//! The previous decision's second accepted cost was that **no routing-label
93//! filtering happened at all**: a run carries no `runs-on`, labels live on jobs,
94//! and this module fetched no jobs. Every queued run in a watched repository
95//! counted, including runs whose jobs targeted `ubuntu-latest` or another host's
96//! `rm-<host>-…` label, and each runner started that way idled until it timed
97//! out.
98//!
99//! Fetching the jobs supplies the input that was missing, so that cost is paid
100//! back by the same change. **This module still owns no predicate.** It reads
101//! each job's `labels` array, builds a [`RunsOn`] from it, and hands that to the
102//! caller. `b1` owns the matching
103//! ([`runner_manager_domain::policy::RoutingLabels::matches`] and its `tally`),
104//! `e1` owns applying it per policy, and
105//! `tests::the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it` scans
106//! this file's own source to pin that no second implementation grows here.
107//!
108//! The filtering is deliberately **not** done in this module even though it now
109//! has the input, and the reason is `e1`'s: one target can be watched by more
110//! than one policy, each with its own routing labels, and `e1` polls a target
111//! once for all of them. A gateway that filtered would have to be told whose
112//! labels to filter by, which would make the poll per-policy and multiply its
113//! request cost by the number of policies sharing the target. So the gateway
114//! returns the jobs and each policy tallies them.
115//!
116//! # What is still approximate, stated plainly rather than left to be discovered
117//!
118//! * **A job listing is a snapshot.** A job that leaves the queue between the
119//!   run list and the job list is counted; one that arrives after is not. The
120//!   next poll corrects both, and `e1`'s per-policy `active_owned` term stops a
121//!   job already being served from being served twice.
122//! * **The run caps make a large queue a floor.** Past
123//!   [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] the count is a *floor* rather
124//!   than a total, reported through [`QueuedDemand::is_truncated`]. Scaling up
125//!   from a floor is safe and successive polls converge; concluding "idle" from
126//!   one is not, which is why the floor is expressible at all.
127//! * **`runs-on` is not always resolvable.** `runs-on: ${{ matrix.runner }}` can
128//!   only be evaluated by GitHub. `b1` reports those as
129//!   [`runner_manager_domain::policy::UnresolvableRunsOn`] and they are neither
130//!   counted as demand nor silently dropped.
131//!
132//! # There is no job reservation, and nothing here may pretend otherwise
133//!
134//! The scale-set model's `AcquireJobs` has no REST equivalent
135//! (`d17-user-to-server-scale-set-chain.md`), so demand is **advisory**: another
136//! host may take a job this host has already started a runner for
137//! (`01-current-architecture.md`, edge case 6).
138//!
139//! **Do not add a claim, a lease, a local reservation table, or an
140//! acknowledgement call to compensate.** No such call exists in this crate, and
141//! `b1`'s, `e1`'s and this task's specifications all say so independently
142//! because implementers keep reaching for one. The bounding controls are the
143//! host-scoped labels in `b1` and the two capacity ceilings in `e1`; a local
144//! lease would coordinate this host with itself and with nothing else, which is
145//! the one thing the problem does not need.
146
147use std::{
148    collections::{BTreeMap, BTreeSet},
149    fmt,
150    sync::{
151        Arc,
152        atomic::{AtomicU64, Ordering},
153    },
154};
155
156use runner_manager_domain::{
157    model::{Clock, OwnerRepo, TargetScope, Timestamp},
158    policy::RunsOn,
159};
160use serde::Deserialize;
161
162use crate::{
163    ApiRequest, ApiResponse, AuthenticatedClient, GithubError,
164    rest::{
165        ActivityScope, CancelToken, InventoryError, PER_PAGE, RateLimited, TargetCost,
166        UnavailableRepository,
167    },
168};
169
170// ---------------------------------------------------------------------------
171// Constants
172// ---------------------------------------------------------------------------
173
174/// The `status` filter that selects runs with a job that may still be waiting.
175///
176/// Stated as a constant rather than inlined because it is the whole difference
177/// between this module and `c3`'s activity count, and a one-word typo here
178/// produces a plausible-looking number rather than an error.
179pub const QUEUED_RUN_STATUS: &str = "queued";
180
181/// The `status` filter for the safety-net pass over runs already under way.
182///
183/// Not a second primary signal. The module documentation states what it covers
184/// that [`QUEUED_RUN_STATUS`] does not — a `needs:`-gated job entering the queue
185/// after its run has already started — and why the difference is not something
186/// this project has observed its way out of needing.
187pub const IN_PROGRESS_RUN_STATUS: &str = "in_progress";
188
189/// The job `status` that means "waiting for a runner that does not exist yet".
190///
191/// This is the value the whole module reduces to. A job in any other state
192/// either has a runner or has finished with one, and counting it would start a
193/// runner for work already being done — the same mistake as reading `c3`'s
194/// in-progress count as demand, one level down.
195pub const QUEUED_JOB_STATUS: &str = "queued";
196
197/// The `filter` for the jobs endpoint: the latest attempt of each job only.
198///
199/// The default is `latest`, and it is sent explicitly because the alternative,
200/// `all`, returns every attempt of every re-run. Under `all` a job re-run three
201/// times contributes three entries, and the two that are historical would be
202/// counted as present demand.
203pub const LATEST_JOBS_FILTER: &str = "latest";
204
205/// Requests one demand poll costs, **per repository**, in steady state.
206///
207/// Four: the two run listings ([`QUEUED_RUN_STATUS`] and
208/// [`IN_PROGRESS_RUN_STATUS`]), plus a job listing for each of the couple of
209/// runs a repository has under way at any moment.
210///
211/// # Why this is a projection and not a measurement, and what bounds it
212///
213/// Under the previous owner decision this constant was `1` and it was exact: one
214/// request per repository, always, because `total_count` on the runs query
215/// answered the whole question. Counting jobs makes the cost depend on how many
216/// runs are active, which is a number no constant can know. So this is the
217/// steady-state figure the budget model prices, in the same spirit as
218/// [`crate::rest::ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH`], which is also
219/// a best case with a documented worse one.
220///
221/// The **worst** case is `2 + MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL +
222/// MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`, which is what
223/// [`max_demand_requests_per_repository_per_poll`] returns, and it is bounded by
224/// construction rather than by hope: the two caps are hard, and a repository
225/// that reaches them says so through [`QueuedDemand::is_truncated`] rather than
226/// spending more. [`crate::rest::BUDGET_SHARE_DIVISOR`] absorbs the gap between
227/// the two figures — the projection is compared against half the documented
228/// ceiling precisely so that the half nobody models has somewhere to go.
229///
230/// A repository is only at the worst case while it genuinely has that many runs
231/// in flight, which is also exactly when spending requests to scale correctly is
232/// worth more than saving them.
233pub const DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL: u32 = 4;
234
235/// The most `status=queued` runs one repository's job listing may resolve per
236/// poll.
237///
238/// The primary signal gets the larger cap. Past it the reported count is a
239/// **floor** rather than a total, which is safe in the direction that matters:
240/// `e1` clamps demand to `max_capacity` and the host ceiling anyway, so a
241/// repository with more than this many queued runs is one whose real demand
242/// exceeds any realistic host's capacity — the allocation is already pinned at
243/// the ceiling and a larger number would not change it. Successive polls resolve
244/// the rest as the earlier runs drain.
245pub const MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL: usize = 6;
246
247/// The most `status=in_progress` runs one repository's job listing may resolve
248/// per poll.
249///
250/// Smaller than [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] on purpose: this
251/// pass exists to catch a `needs:`-gated job whose run has already started, and
252/// on the overwhelmingly common shape it finds nothing and costs one request per
253/// run to discover that. Giving the safety net the same budget as the primary
254/// signal would double the worst case to buy a rarer case.
255pub const MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL: usize = 4;
256
257/// The most pages of jobs one run's listing may walk.
258///
259/// A run's job count is bounded by GitHub's own matrix limit of 256, so two
260/// pages at [`PER_PAGE`] cover every legal run with room to spare. The third is
261/// there because a `Link: rel="next"` chain that does not terminate is a
262/// runaway, and this walk is charged against a budget.
263pub const MAX_JOB_PAGES_PER_RUN: usize = 3;
264
265/// The ceiling on what one repository's demand poll may spend.
266///
267/// Stated as a function rather than a constant because it is the sum of three
268/// constants and a reader checking the budget arithmetic should not have to
269/// re-add them — and because `f1` and `f2` render the worst case beside the
270/// projection.
271#[must_use]
272pub const fn max_demand_requests_per_repository_per_poll() -> u32 {
273    // The two run listings, then one job listing per run each cap admits.
274    2 + (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u32)
275        + (MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL as u32)
276}
277
278// A poll that costs nothing is a poll that issued no request, and a budget line
279// of zero would let `f2` admit an unbounded number of targets.
280const _: () = assert!(
281    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL > 0,
282    "a demand poll costs at least the requests that fetched the run lists"
283);
284
285// The projection has to sit inside the bound, or the bound is not a bound. This
286// is a compile-time check rather than a test because the two numbers drifting
287// apart is the defect itself rather than a symptom of one: a projection above
288// the ceiling would have `f2` refuse configurations that cannot occur, and the
289// arithmetic that produced it would look deliberate.
290const _: () = assert!(
291    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL <= max_demand_requests_per_repository_per_poll(),
292    "the projected demand cost must fit inside the worst case the caps allow"
293);
294
295// The projection must also cover the two run listings, which every poll issues
296// unconditionally. A projection below that floor would under-price even a
297// completely idle repository, which is the one case the model must get exactly
298// right: it is what `f1`'s printed ceiling and `f2`'s `add` refusals are
299// computed from.
300const _: () = assert!(
301    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL >= 2,
302    "every poll issues both run listings, so the projection cannot be below two"
303);
304
305// The safety net must not outgrow the signal it is backing up. If these ever
306// invert, the cheaper `in_progress` pass would be resolving more runs than the
307// `queued` pass that actually carries the demand.
308const _: () = assert!(
309    MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL <= MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
310    "the in-progress safety net may not be given a larger budget than the primary signal"
311);
312
313/// How far GitHub's `total_count` may exceed the single page it arrived with
314/// before the disagreement stops being a race and starts being evidence.
315///
316/// `c3` states the reasoning in full at its own `MAX_BENIGN_TOTAL_COUNT_SKEW`,
317/// which is private to `crates/github/src/rest.rs`; the value is repeated rather
318/// than shared because this task does not own that file. The short form:
319///
320/// * A run leaving the queue between GitHub computing `total_count` and
321///   serialising the page makes `total` exceed `listed` by a handful. That race
322///   is documented and legitimate, and a debug build pointed at live GitHub is
323///   the build most likely to meet it.
324/// * The defect being hunted — `total_count` carrying the repository's
325///   *unfiltered* lifetime total — is gross: thousands over a page of three.
326///
327/// # This is now a consistency check rather than a load-bearing one
328///
329/// Under the previous owner decision `total_count` **was** the demand number,
330/// and being wrong about it started the wrong number of runners. It no longer
331/// reaches `clamp()`: demand is counted from the jobs, and `total_count` is read
332/// only to notice that a repository has more runs than the caps will resolve.
333/// So the check stays — it is free, and `c3` reads the same envelope for a
334/// dashboard number — but it is a `warn!` and no longer a `debug_assert!`.
335/// Panicking a development build over a field the decision no longer depends on
336/// would be a tripwire that cries wolf, and those get deleted.
337const MAX_BENIGN_TOTAL_COUNT_SKEW: u64 = 16;
338
339// A zero skew is `total == listed` again, which fires on a run leaving the queue
340// mid-serialisation. At compile time rather than in a test because a test
341// deriving its fixture from this constant moves with it and stays green at zero.
342const _: () = assert!(
343    MAX_BENIGN_TOTAL_COUNT_SKEW > 0,
344    "a zero skew re-creates the check that trips on a run leaving the queue mid-serialisation"
345);
346
347// ---------------------------------------------------------------------------
348// The demand reading
349// ---------------------------------------------------------------------------
350
351/// Queued **jobs**, per repository, each carrying the `runs-on` it requires.
352///
353/// **This is a count of jobs, not of runs, and it is not filtered by any
354/// policy's routing labels — but it carries everything needed to filter it.**
355/// Both facts are the module documentation's subject and are repeated on the
356/// type because this is what a caller holds.
357///
358/// The unfiltered totals ([`QueuedDemand::total`],
359/// [`QueuedDemand::for_repository`]) are the raw queue depth, which is what `g2`
360/// renders for an operator: "what is waiting in this repository", independent of
361/// which host could serve it. The number `e1` clamps is **not** either of those.
362/// It comes from tallying [`QueuedDemand::jobs_for`] against one policy's
363/// routing labels, and the difference between the two is exactly the jobs this
364/// host cannot serve.
365///
366/// # A count can be short in two different ways, and both have to say so
367///
368/// A repository can fail to answer at all ([`QueuedDemand::unavailable`]), and a
369/// repository can answer with a number that is only a **floor**
370/// ([`QueuedDemand::truncated`]) — more runs were active than
371/// [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] and
372/// [`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] allow resolving, or one
373/// run's job listing walked to [`MAX_JOB_PAGES_PER_RUN`].
374/// [`QueuedDemand::is_complete`] is `false` for either.
375///
376/// The shape mirrors [`crate::rest::ActivityCount`] deliberately, down to the
377/// method names, because `g2` renders the two side by side and a caller that has
378/// learned one should not have to learn the other. They stay separate types for
379/// the reason `c3` keeps the busy-runner count and the in-progress count apart:
380/// a type that can hold either number is a type that will eventually add them.
381#[derive(Debug, Clone, PartialEq, Eq, Default)]
382pub struct QueuedDemand {
383    per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>,
384    unavailable: Vec<UnavailableRepository>,
385    /// Repositories whose count is a floor rather than a total.
386    truncated: BTreeSet<OwnerRepo>,
387}
388
389impl QueuedDemand {
390    #[must_use]
391    pub fn new(per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>) -> Self {
392        Self {
393            per_repository,
394            unavailable: Vec::new(),
395            truncated: BTreeSet::new(),
396        }
397    }
398
399    /// One repository's queued jobs, for the repository-target case and for
400    /// tests.
401    #[must_use]
402    pub fn of(repository: OwnerRepo, jobs: impl IntoIterator<Item = RunsOn>) -> Self {
403        Self::new(BTreeMap::from([(
404            repository,
405            jobs.into_iter().collect::<Vec<_>>(),
406        )]))
407    }
408
409    /// Record that `repository`'s count is a floor rather than a total.
410    #[must_use]
411    pub fn with_truncated(mut self, repository: OwnerRepo) -> Self {
412        self.truncated.insert(repository);
413        self
414    }
415
416    /// Record that `repository` could not be read at all, and why.
417    ///
418    /// Deliberately **not** the same as a count of zero: nothing was learned
419    /// about it, and a zero would render a possibly-busy repository as idle.
420    #[must_use]
421    pub fn with_unavailable(mut self, repository: OwnerRepo, reason: impl Into<String>) -> Self {
422        self.unavailable.push(UnavailableRepository {
423            repository,
424            reason: reason.into(),
425        });
426        self
427    }
428
429    /// Queued jobs across every repository that answered, **unfiltered**.
430    ///
431    /// The raw queue depth, not the demand any one policy should serve. See the
432    /// type documentation for the distinction, which is the whole reason
433    /// [`QueuedDemand::jobs`] exists beside this.
434    ///
435    /// Saturating rather than wrapping: a total wider than a `u32` is not a
436    /// number to wrap around zero, and `u32::MAX` runners is refused by the
437    /// capacity ceilings long before it means anything.
438    #[must_use]
439    pub fn total(&self) -> u32 {
440        self.per_repository.values().fold(0_u32, |sum, jobs| {
441            sum.saturating_add(u32::try_from(jobs.len()).unwrap_or(u32::MAX))
442        })
443    }
444
445    #[must_use]
446    pub fn per_repository(&self) -> &BTreeMap<OwnerRepo, Vec<RunsOn>> {
447        &self.per_repository
448    }
449
450    /// One repository's unfiltered count, or `None` when it did not answer.
451    #[must_use]
452    pub fn for_repository(&self, repository: &OwnerRepo) -> Option<u32> {
453        self.per_repository
454            .get(repository)
455            .map(|jobs| u32::try_from(jobs.len()).unwrap_or(u32::MAX))
456    }
457
458    /// One repository's queued jobs, as the `runs-on` each requires.
459    ///
460    /// This is the input `b1`'s predicate was written for and never had. An
461    /// empty slice for a repository that answered means it really is idle; a
462    /// repository that did not answer is in [`QueuedDemand::unavailable`]
463    /// instead, and the two must not be conflated.
464    #[must_use]
465    pub fn jobs_for(&self, repository: &OwnerRepo) -> &[RunsOn] {
466        self.per_repository
467            .get(repository)
468            .map_or(&[], Vec::as_slice)
469    }
470
471    /// Every queued job across every repository that answered.
472    ///
473    /// What an organization target tallies, for the reason a repository target
474    /// tallies [`QueuedDemand::jobs_for`]: one policy watching an organization
475    /// serves any repository in it, so its demand is the whole scope's.
476    pub fn jobs(&self) -> impl Iterator<Item = &RunsOn> {
477        self.per_repository.values().flat_map(Vec::as_slice)
478    }
479
480    #[must_use]
481    pub fn unavailable(&self) -> &[UnavailableRepository] {
482        &self.unavailable
483    }
484
485    #[must_use]
486    pub fn truncated(&self) -> &BTreeSet<OwnerRepo> {
487        &self.truncated
488    }
489
490    #[must_use]
491    pub fn is_truncated(&self, repository: &OwnerRepo) -> bool {
492        self.truncated.contains(repository)
493    }
494
495    /// Whether every repository in scope answered with an exact count.
496    ///
497    /// `false` means the total is a **floor**. Scaling *up* from a floor is
498    /// safe; concluding "idle" from one is not, which is the mistake this exists
499    /// to make expressible.
500    #[must_use]
501    pub fn is_complete(&self) -> bool {
502        self.unavailable.is_empty() && self.truncated.is_empty()
503    }
504}
505
506/// Requests one demand poll over `scope` costs, in steady state.
507///
508/// Grows with the repository count, because there is no organization-wide
509/// workflow-runs endpoint and an organization therefore pays per repository the
510/// App is installed on. That growth *is* the product constraint after D4: every
511/// added repository multiplies this policy's share of the shared hourly ceiling.
512#[must_use]
513pub fn demand_requests_per_poll(scope: &ActivityScope) -> u32 {
514    u32::try_from(scope.repositories().len())
515        .unwrap_or(u32::MAX)
516        .saturating_mul(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
517}
518
519/// The most requests one demand poll over `scope` may spend.
520///
521/// The companion to [`demand_requests_per_poll`], and the number a reader should
522/// be shown when they ask why a busy hour cost more than the projection. See
523/// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for why the model prices the
524/// steady state and bounds the peak rather than trying to price the peak.
525#[must_use]
526pub fn max_demand_requests_per_poll(scope: &ActivityScope) -> u32 {
527    u32::try_from(scope.repositories().len())
528        .unwrap_or(u32::MAX)
529        .saturating_mul(max_demand_requests_per_repository_per_poll())
530}
531
532/// `scope`'s budget cost with this module's demand figure substituted for
533/// `c3`'s estimate.
534///
535/// This is the reporting seam `c4`'s specification requires — "report the
536/// per-poll request count to `c3`'s budget model rather than estimating it
537/// there" — and [`TargetCost::with_demand_requests_per_repository`] is where
538/// `c3` left it open. Callers that project a budget (`f1`'s `host show`, `f2`'s
539/// `repo add` and `org add`, `g3`'s settings) should build their
540/// [`TargetCost`] through this function rather than through
541/// [`TargetCost::from_activity_scope`] directly.
542#[must_use]
543pub fn target_cost(scope: &ActivityScope) -> TargetCost {
544    TargetCost::from_activity_scope(scope)
545        .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
546}
547
548// ---------------------------------------------------------------------------
549// The gateway
550// ---------------------------------------------------------------------------
551
552/// The demand read model.
553///
554/// A trait for [`crate::rest::InventoryGateway`]'s reason: `e1` and `g2` are
555/// tested against `runner_manager_testkit::github::FakeGithub` with no network
556/// and no `wiremock` in their dependency graphs. [`RestDemand`] is the one
557/// implementation that talks to GitHub.
558///
559/// # Why the scope type is `c3`'s `ActivityScope` and not a new one
560///
561/// The name says "activity" and this is demand, so the reuse is worth
562/// justifying rather than leaving to look like an oversight.
563///
564/// [`ActivityScope`] is not a description of the in-progress count; it is the
565/// answer to "which repositories does one per-repository workflow-runs
566/// aggregate cover", and demand asks that question with exactly the same
567/// answer. Both hit `/repos/{o}/{r}/actions/runs`, both have no
568/// organization-wide form, and both take the repository list from the same
569/// caller-held set — `c3` documents that the list "has to come from the caller,
570/// \[because\] `f1` and `e1` already hold it, from
571/// [`crate::AuthenticatedClient::discover_installations`], and re-discovering it
572/// on every refresh would cost more requests than the count itself".
573///
574/// A `DemandScope` beside it would be that type with a different name, and the
575/// cost of the duplicate is concrete rather than aesthetic: `e1` polls both read
576/// models in one refresh, and two scope types are two chances for them to
577/// disagree about which repositories are in scope — which would make the demand
578/// total and the activity total describe different sets while being rendered
579/// side by side. What is *not* shared is the cost function:
580/// [`ActivityScope::requests_per_refresh`] prices the activity count, and
581/// [`demand_requests_per_poll`] prices this one, because those really are two
582/// different numbers.
583#[async_trait::async_trait]
584pub trait DemandGateway: fmt::Debug + Send + Sync {
585    /// Queued workflow runs across `scope`.
586    ///
587    /// # Errors
588    /// Every variant of [`InventoryError`].
589    async fn queued_demand(
590        &self,
591        scope: &ActivityScope,
592        cancel: &CancelToken,
593    ) -> Result<QueuedDemand, InventoryError>;
594
595    /// The instant a reading is stamped with.
596    fn now(&self) -> Timestamp;
597}
598
599/// [`DemandGateway`] over `api.github.com`.
600///
601/// Holds no credential of its own: authentication is entirely
602/// [`AuthenticatedClient`]'s, and this type only ever hands it an
603/// [`ApiRequest`].
604///
605/// # Why this is not a method on `c3`'s `RestInventory`
606///
607/// It would be the better shape, and it is not available. `RestInventory::get`
608/// — the one place cancellation, request accounting and the rate-limit gate meet
609/// — is private to `crates/github/src/rest.rs`, and a sibling module cannot
610/// reach a private item. Making it `pub(crate)` is an edit to `c3`'s file, which
611/// this task does not own, so the choice was between duplicating `c3`'s whole
612/// rate-limit *policy* here and consuming what `c3` already exports. This
613/// consumes.
614///
615/// # What "consuming `c3`'s rate-limit policy" means precisely
616///
617/// [`RateLimited::detect`] is `c3`'s decision procedure for whether a failure is
618/// a rate limit and which of GitHub's two it is, including the part that keeps a
619/// permissions `403` landing on an exhausted quota from being misreported as
620/// one. It is public, and this module calls it rather than re-deciding.
621///
622/// What this module deliberately does **not** copy is `c3`'s in-gateway
623/// *back-off latch*, the window during which `RestInventory` opens no socket at
624/// all. A second latch would be a second, quietly divergent copy of a policy
625/// that only works if there is one of it. The scheduling floor is
626/// [`crate::rest::RefreshState::retry_delay`], which `c3` documents as "the
627/// **absolute floor** on when `e1` may try again" — an `e1` honouring it stops
628/// demand polling for the same window, from the layer that owns the schedule.
629///
630/// The residual gap is stated rather than hidden: a rate limit whose *first*
631/// evidence arrives on a demand request does not silence `RestInventory`, and
632/// vice versa. Both report [`InventoryError::RateLimited`] to `e1`, which is the
633/// layer that can act on either.
634pub struct RestDemand {
635    client: Arc<AuthenticatedClient>,
636    clock: Arc<dyn Clock>,
637    requests_issued: AtomicU64,
638}
639
640impl fmt::Debug for RestDemand {
641    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
642        // Hand-written for the reason `AuthenticatedClient`'s own `Debug`
643        // records: nothing here may render a credential, and a derive on a type
644        // holding a client is how that is lost.
645        f.debug_struct("RestDemand")
646            .field(
647                "requests_issued",
648                &self.requests_issued.load(Ordering::Relaxed),
649            )
650            .finish_non_exhaustive()
651    }
652}
653
654impl RestDemand {
655    #[must_use]
656    pub fn new(client: Arc<AuthenticatedClient>, clock: Arc<dyn Clock>) -> Self {
657        Self {
658            client,
659            clock,
660            requests_issued: AtomicU64::new(0),
661        }
662    }
663
664    /// How many HTTP requests this gateway has issued.
665    ///
666    /// [`demand_requests_per_poll`] projects a cost and this measures it. A
667    /// projection nothing measures is a table in a document, which is why `c3`
668    /// exposes the same counter and why the tests below assert one against the
669    /// other.
670    #[must_use]
671    pub fn requests_issued(&self) -> u64 {
672        self.requests_issued.load(Ordering::SeqCst)
673    }
674
675    /// One request, with cancellation applied and a rate limit recognised.
676    ///
677    /// Cancellation is consulted twice for `c3`'s reason, and the two are not
678    /// redundant: [`CancelToken::check`] stops a multi-page walk *between*
679    /// pages, and [`CancelToken::run`] stops one already blocked on a socket.
680    async fn get(
681        &self,
682        request: &ApiRequest,
683        cancel: &CancelToken,
684    ) -> Result<ApiResponse, InventoryError> {
685        cancel.check()?;
686
687        let result = cancel
688            .run(async {
689                // Counted inside the future, so the count is of requests
690                // actually attempted: `run`'s biased `select!` answers
691                // `Cancelled` without polling this block when the token is
692                // already flipped, and no socket is opened.
693                self.requests_issued.fetch_add(1, Ordering::SeqCst);
694                self.client
695                    .send(request)
696                    .await
697                    .map_err(InventoryError::from)
698            })
699            .await;
700
701        match result {
702            Ok(response) => Ok(response),
703            Err(InventoryError::Github(error)) => Err(Self::classify(error)),
704            Err(other) => Err(other),
705        }
706    }
707
708    /// Turn a failure into a rate limit when GitHub's own evidence says it is
709    /// one, using `c3`'s decision procedure rather than a second one.
710    fn classify(error: GithubError) -> InventoryError {
711        let Some(limit) = RateLimited::detect(&error) else {
712            return InventoryError::Github(error);
713        };
714        tracing::warn!(
715            kind = %limit.kind,
716            remaining = limit.remaining,
717            "GitHub is rate limiting this credential; demand for this poll is unknown, not zero"
718        );
719        InventoryError::RateLimited(limit)
720    }
721
722    /// Queued **jobs** for one repository, each with the `runs-on` it requires.
723    ///
724    /// Two run listings, then one job listing per run either cap admits. See
725    /// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for what that costs in steady
726    /// state and [`max_demand_requests_per_repository_per_poll`] for the bound.
727    ///
728    /// # The order of the two passes is load-bearing
729    ///
730    /// Queued runs are resolved first and in-progress runs second, because the
731    /// caps are spent in that order and the first pass carries the signal. A
732    /// repository busy enough to exhaust the queued cap gets no in-progress pass
733    /// at all, which is the right trade: its count is already a floor above any
734    /// realistic host ceiling, and one more `needs:`-gated job cannot change the
735    /// allocation.
736    ///
737    /// # Why a run is resolved at most once, though the two queries are disjoint
738    ///
739    /// They are disjoint *at any one instant*, and these are two requests with
740    /// time between them. A run that is `queued` when the first is answered and
741    /// `in_progress` when the second is — which is precisely what happens when
742    /// its last waiting job gets picked up, so it is the common transition
743    /// rather than an exotic one — appears in **both** lists. Listing its jobs
744    /// twice would count every queued job it still holds twice, and demand that
745    /// double-counts starts runners for work that does not exist.
746    ///
747    /// The seen set is what stops that. It is deliberately not an argument that
748    /// the race is too narrow to matter: the window is one HTTP round trip
749    /// against a poll that repeats every sixty seconds forever, and the failure
750    /// it produces is silent inflation rather than an error.
751    async fn repository_queued(
752        &self,
753        repository: &OwnerRepo,
754        cancel: &CancelToken,
755    ) -> Result<RepositoryDemand, InventoryError> {
756        let mut jobs: Vec<RunsOn> = Vec::new();
757        let mut exact = true;
758        let mut resolved: BTreeSet<u64> = BTreeSet::new();
759
760        for (status, cap) in [
761            (QUEUED_RUN_STATUS, MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL),
762            (
763                IN_PROGRESS_RUN_STATUS,
764                MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
765            ),
766        ] {
767            let listing = self.active_runs(repository, status, cap, cancel).await?;
768            exact &= listing.complete;
769
770            for run_id in listing.run_ids {
771                // A run that changed status between the two listings is in both.
772                // Resolving it twice would double every queued job it holds.
773                if !resolved.insert(run_id) {
774                    continue;
775                }
776                let run = self.queued_jobs_of_run(repository, run_id, cancel).await?;
777                exact &= run.complete;
778                jobs.extend(run.jobs);
779            }
780        }
781
782        Ok(RepositoryDemand { jobs, exact })
783    }
784
785    /// The ids of one repository's runs in `status`, up to `cap`.
786    ///
787    /// **One request, never a page walk.** Both caps are far below [`PER_PAGE`],
788    /// so the first page always carries more run ids than this may use, and a
789    /// second page could only contain runs that are already past the cap. That
790    /// is the whole reason the caps are stated in *runs* rather than in pages:
791    /// it turns the run listing back into the fixed one-request cost the
792    /// previous owner decision valued, and spends the variable cost only where
793    /// it buys the job-level count.
794    async fn active_runs(
795        &self,
796        repository: &OwnerRepo,
797        status: &str,
798        cap: usize,
799        cancel: &CancelToken,
800    ) -> Result<RunListing, InventoryError> {
801        let request = ApiRequest::get(format!(
802            "/repos/{}/{}/actions/runs",
803            repository.owner(),
804            repository.repo()
805        ))
806        .query("status", status)
807        .query("per_page", PER_PAGE);
808
809        let response = self.get(&request, cancel).await?;
810        let has_next_page = response.next_page().is_some();
811        let page: QueuedRunsPage = response.json()?;
812
813        let listed = page.workflow_runs.len();
814        if let Some(total) = page.total_count
815            && !has_next_page
816            && total != listed as u64
817        {
818            // Free, and no longer load-bearing: see `MAX_BENIGN_TOTAL_COUNT_SKEW`
819            // for why this is a log line and not the `debug_assert!` it was
820            // while `total_count` *was* the demand number. The two levels draw
821            // the same distinction that constant does. A handful over is the
822            // documented race of a run leaving the queue mid-serialisation,
823            // which this module's own documentation calls legitimate — warning
824            // on it every poll is how an operator learns to ignore the warning.
825            // Thousands over is the unfiltered lifetime total, which is a real
826            // finding about the API.
827            if total > (listed as u64).saturating_add(MAX_BENIGN_TOTAL_COUNT_SKEW) {
828                tracing::warn!(
829                    repository = %repository,
830                    status,
831                    total_count = total,
832                    listed,
833                    "GitHub's `total_count` is far larger than the single page it sent for \
834                     a filtered query, so it is not the filtered count. Demand is counted \
835                     from the jobs and does not depend on this field, but `c3` reads the \
836                     same envelope for a dashboard number and does"
837                );
838            } else {
839                tracing::debug!(
840                    repository = %repository,
841                    status,
842                    total_count = total,
843                    listed,
844                    "GitHub's `total_count` disagrees slightly with the page it arrived \
845                     with; this is the documented race of a run leaving the queue while \
846                     the response was being built"
847                );
848            }
849        }
850
851        let run_ids: Vec<u64> = page
852            .workflow_runs
853            .iter()
854            .take(cap)
855            .map(|run| run.id)
856            .collect();
857
858        Ok(RunListing {
859            // More runs exist than this pass will resolve, so the job count it
860            // produces is a floor. Both conditions matter: `listed > cap` is the
861            // ordinary case, and `has_next_page` catches a repository whose
862            // first page was itself short of the whole filtered set.
863            complete: listed <= cap && !has_next_page,
864            run_ids,
865        })
866    }
867
868    /// One run's jobs that are still waiting for a runner.
869    ///
870    /// Jobs in any other state are dropped here rather than downstream, because
871    /// "queued" is what makes a job demand and a caller holding a mixed list
872    /// would have to re-derive that. The `runs-on` is rebuilt from the job's
873    /// `labels` array — which is the array form, so [`RunsOn::from_job_labels`]
874    /// is the constructor `b1` provides for exactly this.
875    async fn queued_jobs_of_run(
876        &self,
877        repository: &OwnerRepo,
878        run_id: u64,
879        cancel: &CancelToken,
880    ) -> Result<RunJobs, InventoryError> {
881        let mut request = Some(
882            ApiRequest::get(format!(
883                "/repos/{}/{}/actions/runs/{run_id}/jobs",
884                repository.owner(),
885                repository.repo()
886            ))
887            .query("filter", LATEST_JOBS_FILTER)
888            .query("per_page", PER_PAGE),
889        );
890
891        let mut jobs = Vec::new();
892        let mut pages = 0_usize;
893
894        while let Some(next) = request.take() {
895            if pages >= MAX_JOB_PAGES_PER_RUN {
896                tracing::warn!(
897                    repository = %repository,
898                    run_id,
899                    pages,
900                    "stopped listing one run's jobs at the page budget; the queued-job \
901                     count for this repository is a floor, not a total"
902                );
903                return Ok(RunJobs {
904                    jobs,
905                    complete: false,
906                });
907            }
908
909            let response = self.get(&next, cancel).await?;
910            let following = response
911                .next_page()
912                .map(|url| ApiRequest::get(url.as_str()));
913            let page: RunJobsPage = response.json()?;
914            pages += 1;
915
916            jobs.extend(
917                page.jobs
918                    .into_iter()
919                    .filter(|job| job.status == QUEUED_JOB_STATUS)
920                    .map(|job| RunsOn::from_job_labels(job.labels)),
921            );
922
923            request = following;
924        }
925
926        Ok(RunJobs {
927            jobs,
928            complete: true,
929        })
930    }
931}
932
933/// Whether a per-repository failure should be recorded and stepped over, or
934/// should abort the whole aggregate.
935///
936/// The same line `c3` draws in its private `is_repository_local_failure`, and
937/// repeated here rather than shared because that function lives in a file this
938/// task does not own. The line is between a fact about *that repository* — a
939/// `404` for one deleted or renamed, a plain `403` for one with Actions disabled
940/// — and a fact about the credential or the connection, which stepping over
941/// would turn into a total short by an unknown amount while looking complete.
942///
943/// It applies to an aggregate only. Stepping over the only repository in scope
944/// would turn a permissions failure into demand `0`, and `e1` would then read
945/// "nothing queued" for a target it cannot see at all — so the caller checks
946/// [`TargetScope`] first.
947fn is_repository_local_failure(error: &InventoryError) -> bool {
948    match error {
949        InventoryError::Github(GithubError::Forbidden { .. }) => true,
950        InventoryError::Github(GithubError::Status { status, .. }) => *status == 404,
951        _ => false,
952    }
953}
954
955#[async_trait::async_trait]
956impl DemandGateway for RestDemand {
957    async fn queued_demand(
958        &self,
959        scope: &ActivityScope,
960        cancel: &CancelToken,
961    ) -> Result<QueuedDemand, InventoryError> {
962        let mut demand = QueuedDemand::default();
963        // Only an aggregate steps over a bad repository; see
964        // `is_repository_local_failure`.
965        let aggregating = scope.target().scope() == TargetScope::Organization;
966
967        for repository in scope.repositories() {
968            match self.repository_queued(repository, cancel).await {
969                Ok(reading) => {
970                    demand
971                        .per_repository
972                        .insert(repository.clone(), reading.jobs);
973                    if !reading.exact {
974                        // A floor travels with the aggregate rather than being
975                        // flattened into it: one truncated repository makes the
976                        // total a floor too, and nothing downstream has another
977                        // way to know.
978                        demand.truncated.insert(repository.clone());
979                    }
980                }
981                Err(error) if aggregating && is_repository_local_failure(&error) => {
982                    tracing::warn!(
983                        repository = %repository,
984                        error = %error,
985                        "a repository in this organization could not be polled for demand; \
986                         the aggregate reports it as unavailable rather than as zero"
987                    );
988                    demand.unavailable.push(UnavailableRepository {
989                        repository: repository.clone(),
990                        reason: error.to_string(),
991                    });
992                }
993                Err(error) => return Err(error),
994            }
995        }
996
997        Ok(demand)
998    }
999
1000    fn now(&self) -> Timestamp {
1001        self.clock.now()
1002    }
1003}
1004
1005// ---------------------------------------------------------------------------
1006// Wire shapes
1007// ---------------------------------------------------------------------------
1008
1009/// One repository's queued jobs, and whether that set is the whole truth.
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011struct RepositoryDemand {
1012    /// The `runs-on` of every job still waiting for a runner.
1013    jobs: Vec<RunsOn>,
1014    /// `false` when `jobs` is a **floor**: a run cap or a job page budget
1015    /// stopped the walk before the whole queue had been seen.
1016    exact: bool,
1017}
1018
1019/// One run listing's ids, and whether the cap left any behind.
1020#[derive(Debug, Clone, PartialEq, Eq)]
1021struct RunListing {
1022    run_ids: Vec<u64>,
1023    complete: bool,
1024}
1025
1026/// One run's queued jobs, and whether the page budget saw all of them.
1027#[derive(Debug, Clone, PartialEq, Eq)]
1028struct RunJobs {
1029    jobs: Vec<RunsOn>,
1030    complete: bool,
1031}
1032
1033/// One page of `GET …/actions/runs?status=…`.
1034///
1035/// Only the `id` is read, and it is read because the job listing needs it. That
1036/// is the field the previous owner decision deliberately did not have — the
1037/// module documentation used to point at `jobs_url` and ask nobody to follow it
1038/// — and reversing that decision is what put it here.
1039#[derive(Debug, Deserialize)]
1040struct QueuedRunsPage {
1041    total_count: Option<u64>,
1042    #[serde(default)]
1043    workflow_runs: Vec<QueuedRun>,
1044}
1045
1046/// One workflow run, reduced to the identifier its jobs are fetched by.
1047#[derive(Debug, Deserialize)]
1048struct QueuedRun {
1049    id: u64,
1050}
1051
1052/// One page of `GET …/actions/runs/{run_id}/jobs`.
1053#[derive(Debug, Deserialize)]
1054struct RunJobsPage {
1055    #[serde(default)]
1056    jobs: Vec<RunJob>,
1057}
1058
1059/// One job of a run: whether it is still waiting, and what it needs.
1060///
1061/// `labels` is GitHub's flat array form of `runs-on`, which is why
1062/// [`RunsOn::from_job_labels`] exists on `b1`'s side. It defaults to empty
1063/// rather than being required, because a job with no labels is a real answer —
1064/// `b1` reports it as [`runner_manager_domain::policy::UnresolvableRunsOn`] —
1065/// and a missing field should not fail the whole repository's poll.
1066#[derive(Debug, Deserialize)]
1067struct RunJob {
1068    #[serde(default)]
1069    status: String,
1070    #[serde(default)]
1071    labels: Vec<String>,
1072}
1073
1074// The unit tests below are inline rather than in a `src/demand/tests.rs`, and
1075// that is a constraint rather than a preference: `lib.rs`'s
1076// `the_confidential_credential_scan_covers_every_source_file` walks `src/`
1077// recursively and requires every `.rs` file under it to appear in
1078// `CRATE_SOURCES` — a list in `lib.rs`, which `c2` owns. A second file in this
1079// directory would fail that pin, and the only fix would be editing another
1080// task's file.
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084    use crate::testing::{FIXTURE_TOKEN, TestClock};
1085    use crate::{Endpoints, UserAccessToken};
1086    use runner_manager_domain::{
1087        model::{Arch, HostLabel, Label, Org, Os},
1088        policy::{RoutingLabels, RunsOn, RunsOnMatch, UnresolvableRunsOn},
1089    };
1090    use secrecy::SecretString;
1091    use serde_json::json;
1092    use wiremock::{
1093        Mock, MockServer, ResponseTemplate,
1094        matchers::{method, path, query_param},
1095    };
1096
1097    fn repo() -> OwnerRepo {
1098        OwnerRepo::parse("octo/dashboard").expect("a valid owner/repo")
1099    }
1100
1101    fn other_repo() -> OwnerRepo {
1102        OwnerRepo::parse("octo/api").expect("a valid owner/repo")
1103    }
1104
1105    fn third_repo() -> OwnerRepo {
1106        OwnerRepo::parse("octo/tools").expect("a valid owner/repo")
1107    }
1108
1109    fn org_scope(repositories: impl IntoIterator<Item = OwnerRepo>) -> ActivityScope {
1110        ActivityScope::organization(
1111            Org::new("octo-org").expect("a valid organization login"),
1112            repositories,
1113        )
1114    }
1115
1116    fn gateway(server: &MockServer) -> RestDemand {
1117        let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
1118        let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
1119        let client = AuthenticatedClient::new(endpoints, token, Arc::new(TestClock::default()))
1120            .expect("a client over the test server");
1121        RestDemand::new(Arc::new(client), Arc::new(TestClock::default()))
1122    }
1123
1124    // -- fixtures -----------------------------------------------------------
1125
1126    /// A run list carrying `ids`, and a `total_count` that agrees with it.
1127    fn runs_body(ids: &[u64]) -> serde_json::Value {
1128        json!({
1129            "total_count": ids.len(),
1130            "workflow_runs": ids.iter().map(|id| json!({ "id": id })).collect::<Vec<_>>()
1131        })
1132    }
1133
1134    /// An empty run list, which is what an idle repository answers.
1135    fn no_runs() -> serde_json::Value {
1136        runs_body(&[])
1137    }
1138
1139    /// A jobs page: `queued` jobs carrying `labels`, then `running` that do not
1140    /// count.
1141    fn jobs_body(labels: &[&str], queued: usize, running: usize) -> serde_json::Value {
1142        let mut jobs = Vec::new();
1143        for _ in 0..queued {
1144            jobs.push(json!({ "status": "queued", "labels": labels }));
1145        }
1146        for _ in 0..running {
1147            jobs.push(json!({ "status": "in_progress", "labels": labels }));
1148        }
1149        json!({ "total_count": jobs.len(), "jobs": jobs })
1150    }
1151
1152    fn runs_path(repository: &OwnerRepo) -> String {
1153        format!(
1154            "/repos/{}/{}/actions/runs",
1155            repository.owner(),
1156            repository.repo()
1157        )
1158    }
1159
1160    fn jobs_path(repository: &OwnerRepo, run_id: u64) -> String {
1161        format!("{}/{run_id}/jobs", runs_path(repository))
1162    }
1163
1164    /// Mount one repository's run list for one status filter.
1165    async fn mount_runs(
1166        server: &MockServer,
1167        repository: &OwnerRepo,
1168        status: &str,
1169        body: serde_json::Value,
1170    ) {
1171        Mock::given(method("GET"))
1172            .and(path(runs_path(repository)))
1173            .and(query_param("status", status))
1174            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1175            .mount(server)
1176            .await;
1177    }
1178
1179    /// Mount one run's job list.
1180    async fn mount_jobs(
1181        server: &MockServer,
1182        repository: &OwnerRepo,
1183        run_id: u64,
1184        body: serde_json::Value,
1185    ) {
1186        Mock::given(method("GET"))
1187            .and(path(jobs_path(repository, run_id)))
1188            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1189            .mount(server)
1190            .await;
1191    }
1192
1193    /// The ordinary shape: one queued run of `queued` + `running` jobs, and an
1194    /// empty in-progress list.
1195    async fn mount_one_queued_run(
1196        server: &MockServer,
1197        repository: &OwnerRepo,
1198        labels: &[&str],
1199        queued: usize,
1200        running: usize,
1201    ) {
1202        mount_runs(server, repository, QUEUED_RUN_STATUS, runs_body(&[100])).await;
1203        mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1204        mount_jobs(server, repository, 100, jobs_body(labels, queued, running)).await;
1205    }
1206
1207    /// A repository with nothing waiting: two run listings, no job listings.
1208    async fn mount_idle(server: &MockServer, repository: &OwnerRepo) {
1209        mount_runs(server, repository, QUEUED_RUN_STATUS, no_runs()).await;
1210        mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1211    }
1212
1213    /// The routing labels of a realistic policy on this host.
1214    ///
1215    /// Deliberately **not** a bare [`RoutingLabels::derive`]. That produces the
1216    /// derived host label alone, and a job written `runs-on: [self-hosted,
1217    /// windows]` — the shape people actually write — requires labels a bare
1218    /// derived set does not carry, so it would not match. An operator adds those
1219    /// with `repo add --label`, and a fixture that skipped them would test the
1220    /// filtering against a policy nobody configures.
1221    fn host_labels() -> RoutingLabels {
1222        RoutingLabels::from_parts(
1223            Label::new("rm-home-win-x64").expect("a valid label"),
1224            [
1225                Label::new("self-hosted").expect("a valid label"),
1226                Label::new("windows").expect("a valid label"),
1227            ],
1228        )
1229    }
1230
1231    // -- the count itself ---------------------------------------------------
1232
1233    /// The defect this module was rewritten for, as an executable statement.
1234    ///
1235    /// One queued run holding eight matrix jobs. Under the previous owner
1236    /// decision this repository reported demand `1`, `e1` started one runner,
1237    /// and the other seven jobs waited for a machine that was sitting idle. It
1238    /// must now report `8`.
1239    #[tokio::test]
1240    async fn a_matrix_run_of_eight_jobs_is_eight_units_of_demand_and_not_one() {
1241        let server = MockServer::start().await;
1242        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 8, 0).await;
1243        let gateway = gateway(&server);
1244
1245        let demand = gateway
1246            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1247            .await
1248            .expect("a queued-job count");
1249
1250        assert_eq!(
1251            demand.total(),
1252            8,
1253            "eight jobs in one run are eight runners' worth of work; reading the run \
1254             count here is the defect that forced the owner decision back"
1255        );
1256        assert_eq!(demand.for_repository(&repo()), Some(8));
1257        assert!(demand.is_complete());
1258        assert_eq!(
1259            gateway.requests_issued(),
1260            3,
1261            "two run listings and one job listing for the single active run"
1262        );
1263    }
1264
1265    /// A job that already has a runner is not demand.
1266    ///
1267    /// The run-level mistake one level down: `status=in_progress` work already
1268    /// has a runner, and counting it would start a second for the same job.
1269    #[tokio::test]
1270    async fn only_jobs_still_queued_are_counted() {
1271        let server = MockServer::start().await;
1272        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 5).await;
1273        let gateway = gateway(&server);
1274
1275        let demand = gateway
1276            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1277            .await
1278            .expect("a queued-job count");
1279
1280        assert_eq!(
1281            demand.total(),
1282            3,
1283            "five of the eight jobs already have a runner and are not waiting for one"
1284        );
1285    }
1286
1287    /// The labels travel with the job, which is the input `b1`'s predicate never
1288    /// had.
1289    #[tokio::test]
1290    async fn each_queued_job_carries_the_runs_on_it_requires() {
1291        let server = MockServer::start().await;
1292        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100, 101])).await;
1293        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
1294        mount_jobs(
1295            &server,
1296            &repo(),
1297            100,
1298            jobs_body(&["self-hosted", "windows"], 2, 0),
1299        )
1300        .await;
1301        mount_jobs(&server, &repo(), 101, jobs_body(&["ubuntu-latest"], 4, 0)).await;
1302        let gateway = gateway(&server);
1303
1304        let demand = gateway
1305            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1306            .await
1307            .expect("a queued-job count");
1308
1309        assert_eq!(
1310            demand.total(),
1311            6,
1312            "the unfiltered depth is every queued job"
1313        );
1314
1315        // And the number `e1` clamps, which is the unfiltered depth put through
1316        // `b1`'s predicate. This gateway does not compute it; it supplies it.
1317        let tally = host_labels().tally(demand.jobs_for(&repo()));
1318        assert_eq!(
1319            tally.demand(),
1320            2,
1321            "the four `ubuntu-latest` jobs are somebody else's work; before the job \
1322             listing existed all six would have driven this policy toward max_capacity"
1323        );
1324        assert_eq!(tally.not_matched, 4);
1325    }
1326
1327    /// The two run statuses are both on the wire, and the job filter with them.
1328    ///
1329    /// The queries are one word apart and answer different questions, so a
1330    /// gateway that sent the wrong one would return a plausible number. The
1331    /// mocks match on the exact status, so sending anything else 404s here
1332    /// rather than passing.
1333    #[tokio::test]
1334    async fn both_run_statuses_are_polled_and_the_jobs_request_asks_for_the_latest_attempt() {
1335        let server = MockServer::start().await;
1336        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
1337        let gateway = gateway(&server);
1338
1339        gateway
1340            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1341            .await
1342            .expect("both filters are mounted");
1343
1344        let requests = server.received_requests().await.expect("recorded requests");
1345        assert_eq!(requests.len(), 3);
1346
1347        let queries: Vec<String> = requests
1348            .iter()
1349            .map(|r| r.url.query().unwrap_or_default().to_string())
1350            .collect();
1351
1352        assert!(
1353            queries.iter().any(|q| q.contains("status=queued")),
1354            "the primary signal is the queued run list; sent {queries:?}"
1355        );
1356        assert!(
1357            queries.iter().any(|q| q.contains("status=in_progress")),
1358            "the safety net catches a `needs:`-gated job whose run has already \
1359             started; sent {queries:?}"
1360        );
1361        assert!(
1362            queries
1363                .iter()
1364                .any(|q| q.contains(&format!("filter={LATEST_JOBS_FILTER}"))),
1365            "`filter=all` would count every attempt of a re-run job as present \
1366             demand; sent {queries:?}"
1367        );
1368        assert!(
1369            queries
1370                .iter()
1371                .all(|q| q.contains(&format!("per_page={PER_PAGE}"))),
1372            "asking for fewer than GitHub's maximum multiplies the request count \
1373             against the budget this module projects; sent {queries:?}"
1374        );
1375    }
1376
1377    /// The queued run list is read before the in-progress one.
1378    ///
1379    /// The order is what spends the caps on the signal rather than on the safety
1380    /// net, and it is the kind of thing that is only true until somebody
1381    /// reorders a literal.
1382    #[tokio::test]
1383    async fn the_queued_run_list_is_read_before_the_in_progress_one() {
1384        let server = MockServer::start().await;
1385        mount_idle(&server, &repo()).await;
1386        let gateway = gateway(&server);
1387
1388        gateway
1389            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1390            .await
1391            .expect("an idle repository still answers");
1392
1393        let requests = server.received_requests().await.expect("recorded requests");
1394        let queries: Vec<String> = requests
1395            .iter()
1396            .map(|r| r.url.query().unwrap_or_default().to_string())
1397            .collect();
1398        assert_eq!(queries.len(), 2, "an idle repository lists no run's jobs");
1399        assert!(
1400            queries[0].contains("status=queued"),
1401            "the primary signal is read first; sent {queries:?}"
1402        );
1403        assert!(
1404            queries[1].contains("status=in_progress"),
1405            "the safety net is read second; sent {queries:?}"
1406        );
1407    }
1408
1409    /// An idle repository costs the two listings and nothing more.
1410    ///
1411    /// The steady-state figure the budget model prices, asserted against what
1412    /// the gateway really spends rather than left as a claim in a doc comment.
1413    #[tokio::test]
1414    async fn an_idle_repository_costs_only_the_two_run_listings() {
1415        let server = MockServer::start().await;
1416        mount_idle(&server, &repo()).await;
1417        let gateway = gateway(&server);
1418
1419        let demand = gateway
1420            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1421            .await
1422            .expect("an idle repository answers zero rather than failing");
1423
1424        assert_eq!(demand.total(), 0);
1425        assert!(
1426            demand.is_complete(),
1427            "zero from a repository that answered is a measurement, not a floor"
1428        );
1429        assert_eq!(gateway.requests_issued(), 2);
1430        assert!(
1431            gateway.requests_issued() <= u64::from(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL),
1432            "the idle case must sit inside the projection, which is the number `f2`'s \
1433             refusals are computed from"
1434        );
1435    }
1436
1437    /// A run whose jobs have all been dispatched is found and costs one request.
1438    ///
1439    /// The safety net's ordinary outcome, and the reason it is capped lower than
1440    /// the primary signal: on the common shape it finds nothing.
1441    #[tokio::test]
1442    async fn an_in_progress_run_with_no_queued_job_contributes_only_its_request() {
1443        let server = MockServer::start().await;
1444        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
1445        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
1446        mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 0, 4)).await;
1447        let gateway = gateway(&server);
1448
1449        let demand = gateway
1450            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1451            .await
1452            .expect("a queued-job count");
1453
1454        assert_eq!(demand.total(), 0);
1455        assert!(demand.is_complete());
1456        assert_eq!(gateway.requests_issued(), 3);
1457    }
1458
1459    /// A run caught mid-transition appears in both listings and is counted once.
1460    ///
1461    /// The two GitHub queries are disjoint at any one instant and these are two
1462    /// requests with time between them. A run whose last waiting job is picked
1463    /// up between them is `queued` for the first and `in_progress` for the
1464    /// second — the ordinary transition, not an exotic one — so it comes back in
1465    /// both lists. Counting its jobs twice would inflate demand silently, which
1466    /// is the failure mode with no error to notice.
1467    #[tokio::test]
1468    async fn a_run_in_both_listings_is_resolved_once_and_not_counted_twice() {
1469        let server = MockServer::start().await;
1470        // The same run id in both lists, which is what the race produces.
1471        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
1472        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[100])).await;
1473        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 3, 0)).await;
1474        let gateway = gateway(&server);
1475
1476        let demand = gateway
1477            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1478            .await
1479            .expect("a queued-job count");
1480
1481        assert_eq!(
1482            demand.total(),
1483            3,
1484            "the run holds three queued jobs, and appearing in both listings does not              make it six"
1485        );
1486        assert_eq!(
1487            gateway.requests_issued(),
1488            3,
1489            "and the duplicate costs no second job listing either"
1490        );
1491    }
1492
1493    /// A `needs:`-gated job whose run has already started is still demand.
1494    ///
1495    /// This is the whole reason the in-progress pass exists. Live sampling of a
1496    /// repository using this product never caught GitHub reporting a run as
1497    /// `in_progress` while one of its jobs was queued — 44 samples, 25 of them
1498    /// with a queued job — so the primary signal covers everything that was
1499    /// observed. It does not cover a job that becomes queued *after* its run
1500    /// started, which is what `needs:` produces and what this pins.
1501    #[tokio::test]
1502    async fn a_job_that_enters_the_queue_after_its_run_started_is_still_found() {
1503        let server = MockServer::start().await;
1504        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
1505        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
1506        // One job running, one released by `needs:` and now waiting.
1507        mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 1, 1)).await;
1508        let gateway = gateway(&server);
1509
1510        let demand = gateway
1511            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1512            .await
1513            .expect("a queued-job count");
1514
1515        assert_eq!(
1516            demand.total(),
1517            1,
1518            "polling only `status=queued` runs would report zero here and the \
1519             `needs:`-gated job would wait for a machine that is idle"
1520        );
1521    }
1522
1523    // -- the wire contract, against payloads GitHub really sent -------------
1524
1525    /// The deserializers are pinned against **real** GitHub responses, not only
1526    /// against fixtures written to match them.
1527    ///
1528    /// Every other test here builds its own JSON, so all of them would keep
1529    /// passing if this module's idea of the wire format were wrong in the same
1530    /// way the fixtures are. That is the one failure a mock server cannot catch,
1531    /// and it is the failure this change was most exposed to: the previous owner
1532    /// decision deliberately read *nothing* out of a run and had no job
1533    /// endpoint at all, so `workflow_runs[].id`, `jobs[].status` and
1534    /// `jobs[].labels` are all fields this crate had never parsed before.
1535    ///
1536    /// The payloads below are verbatim excerpts captured from
1537    /// `api.github.com` on 2026-09-05 against a repository using this product,
1538    /// trimmed only by replacing the `steps` array — which is long, which this
1539    /// module does not read, and whose presence is itself part of what is being
1540    /// asserted, since a job object carries twenty-odd fields that must all be
1541    /// ignored without error.
1542    #[test]
1543    fn the_wire_shapes_parse_a_payload_github_really_sent() {
1544        // GET /repos/{o}/{r}/actions/runs/33938794901/jobs?filter=latest&per_page=100
1545        let jobs: RunJobsPage = serde_json::from_value(json!({
1546          "total_count": 5,
1547          "jobs": [
1548            {
1549              "id": 101_231_925_899_i64,
1550              "run_id": 33_938_794_901_i64,
1551              "workflow_name": "tests",
1552              "head_branch": "worktree-p1-lock-and-worktree-set",
1553              "run_url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
1554              "run_attempt": 1,
1555              "node_id": "CR_kwDOS_wnss8AAAAXkeSaiw",
1556              "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
1557              "url": "https://api.github.com/repos/o/r/actions/jobs/101231925899",
1558              "html_url": "https://github.com/o/r/actions/runs/33938794901/job/101231925899",
1559              "status": "completed",
1560              "conclusion": "success",
1561              "created_at": "2026-09-05T02:20:30Z",
1562              "started_at": "2026-09-05T02:26:03Z",
1563              "completed_at": "2026-09-05T02:28:44Z",
1564              "name": "scripts-tests",
1565              "steps": [],
1566              "check_run_url": "https://api.github.com/repos/o/r/check-runs/101231925899",
1567              "labels": ["self-hosted", "windows"],
1568              "runner_id": 725,
1569              "runner_name": "runner-manager-4bd32f05-088f-4b13-a59c-6900b9142aa1",
1570              "runner_group_id": 1,
1571              "runner_group_name": "Default"
1572            },
1573            {
1574              "id": 101_231_925_900_i64,
1575              "run_id": 33_938_794_901_i64,
1576              "status": "queued",
1577              "conclusion": serde_json::Value::Null,
1578              "started_at": serde_json::Value::Null,
1579              "completed_at": serde_json::Value::Null,
1580              "name": "pipeline-tests",
1581              "steps": [],
1582              "labels": ["self-hosted", "windows"],
1583              "runner_id": serde_json::Value::Null,
1584              "runner_name": "",
1585              "runner_group_name": ""
1586            },
1587            {
1588              "id": 101_231_925_901_i64,
1589              "status": "queued",
1590              "name": "scripts-tests-macos",
1591              "labels": ["self-hosted", "macOS", "rm-macmini-osx-arm64"]
1592            }
1593          ]
1594        }))
1595        .expect("the jobs page GitHub really sends must deserialize");
1596
1597        assert_eq!(
1598            jobs.jobs.len(),
1599            3,
1600            "every job is read, whatever else it carries"
1601        );
1602
1603        // What the gateway does with it: keep the queued ones, and turn each
1604        // one's `labels` array into the `RunsOn` `b1` matches.
1605        let queued: Vec<RunsOn> = jobs
1606            .jobs
1607            .into_iter()
1608            .filter(|job| job.status == QUEUED_JOB_STATUS)
1609            .map(|job| RunsOn::from_job_labels(job.labels))
1610            .collect();
1611
1612        assert_eq!(
1613            queued,
1614            vec![
1615                RunsOn::Many(vec!["self-hosted".into(), "windows".into()]),
1616                RunsOn::Many(vec![
1617                    "self-hosted".into(),
1618                    "macOS".into(),
1619                    "rm-macmini-osx-arm64".into()
1620                ]),
1621            ],
1622            "the completed job is dropped and the two queued ones keep their labels"
1623        );
1624
1625        // And the demand a Windows policy on that host would clamp: one, not
1626        // two, because the macOS job belongs to another machine.
1627        let tally = host_labels().tally(&queued);
1628        assert_eq!(tally.demand(), 1);
1629        assert_eq!(tally.not_matched, 1);
1630
1631        // GET /repos/{o}/{r}/actions/runs?status=queued&per_page=100, as an idle
1632        // repository answers it. `total_count` and an empty array, which is the
1633        // shape that must read as "nothing waiting" rather than as a failure.
1634        let idle: QueuedRunsPage = serde_json::from_value(json!({
1635            "total_count": 0,
1636            "workflow_runs": []
1637        }))
1638        .expect("an idle run listing must deserialize");
1639        assert_eq!(idle.total_count, Some(0));
1640        assert!(idle.workflow_runs.is_empty());
1641
1642        // And a busy one. A run object carries far more than the id, and the id
1643        // is the only field this module reads out of it.
1644        let busy: QueuedRunsPage = serde_json::from_value(json!({
1645            "total_count": 1,
1646            "workflow_runs": [{
1647                "id": 33_938_794_901_i64,
1648                "name": "tests",
1649                "node_id": "WFR_kwLOS_wnss8AAAAH6oSPFQ",
1650                "head_branch": "main",
1651                "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
1652                "path": ".github/workflows/tests.yml",
1653                "run_number": 412,
1654                "event": "push",
1655                "status": "queued",
1656                "conclusion": serde_json::Value::Null,
1657                "workflow_id": 213_842_591_i64,
1658                "url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
1659                "created_at": "2026-09-05T02:20:30Z",
1660                "updated_at": "2026-09-05T02:20:31Z"
1661            }]
1662        }))
1663        .expect("a busy run listing must deserialize");
1664        assert_eq!(
1665            busy.workflow_runs
1666                .iter()
1667                .map(|run| run.id)
1668                .collect::<Vec<_>>(),
1669            vec![33_938_794_901],
1670            "the run id is what the job listing is fetched by, and it is a u64: \
1671             GitHub's run ids passed 2^32 long ago, so a u32 here would have \
1672             wrapped on every real repository"
1673        );
1674    }
1675
1676    // -- the caps -----------------------------------------------------------
1677
1678    /// More queued runs than the cap resolves makes the answer a floor.
1679    #[tokio::test]
1680    async fn more_queued_runs_than_the_cap_report_a_floor_rather_than_a_total() {
1681        let server = MockServer::start().await;
1682        let ids: Vec<u64> = (0..(MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u64 + 4))
1683            .map(|i| 100 + i)
1684            .collect();
1685        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&ids)).await;
1686        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
1687        for id in &ids {
1688            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
1689        }
1690        let gateway = gateway(&server);
1691
1692        let demand = gateway
1693            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1694            .await
1695            .expect("a bounded poll still answers");
1696
1697        assert_eq!(
1698            demand.total() as usize,
1699            MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
1700            "one job resolved per run the cap admits"
1701        );
1702        assert!(
1703            !demand.is_complete(),
1704            "a count clipped by the run cap is a floor and must say so; concluding \
1705             `idle` from one would be the mistake the flag exists to prevent"
1706        );
1707        assert!(demand.is_truncated(&repo()));
1708    }
1709
1710    /// The caps bound what one repository can spend, whatever GitHub sends.
1711    ///
1712    /// The projection is a steady-state figure and the worst case is what keeps
1713    /// it honest, so the worst case has to be a real ceiling rather than an
1714    /// estimate. A repository with a hundred active runs must not spend a
1715    /// hundred requests.
1716    #[tokio::test]
1717    async fn a_repository_cannot_spend_more_than_the_documented_worst_case() {
1718        let server = MockServer::start().await;
1719        let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
1720        let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
1721        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
1722        mount_runs(
1723            &server,
1724            &repo(),
1725            IN_PROGRESS_RUN_STATUS,
1726            runs_body(&running),
1727        )
1728        .await;
1729        for id in queued.iter().chain(running.iter()) {
1730            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
1731        }
1732        let gateway = gateway(&server);
1733
1734        let demand = gateway
1735            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1736            .await
1737            .expect("a bounded poll still answers");
1738
1739        assert_eq!(
1740            gateway.requests_issued(),
1741            u64::from(max_demand_requests_per_repository_per_poll()),
1742            "the measured ceiling must equal the projected one, or the bound is a \
1743             sentence in a doc comment"
1744        );
1745        assert_eq!(
1746            demand.total(),
1747            2 * (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL
1748                + MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL) as u32
1749        );
1750        assert!(!demand.is_complete());
1751    }
1752
1753    /// The queued pass is served before the in-progress one when both are over
1754    /// their caps.
1755    #[tokio::test]
1756    async fn the_primary_signal_is_resolved_before_the_safety_net() {
1757        let server = MockServer::start().await;
1758        let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
1759        let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
1760        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
1761        mount_runs(
1762            &server,
1763            &repo(),
1764            IN_PROGRESS_RUN_STATUS,
1765            runs_body(&running),
1766        )
1767        .await;
1768        // Queued runs hold this host's work; in-progress runs hold another
1769        // host's. If the caps were spent in the other order the demand would be
1770        // made of the wrong jobs.
1771        for id in &queued {
1772            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
1773        }
1774        for id in &running {
1775            mount_jobs(&server, &repo(), *id, jobs_body(&["ubuntu-latest"], 1, 0)).await;
1776        }
1777        let gateway = gateway(&server);
1778
1779        let demand = gateway
1780            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1781            .await
1782            .expect("a bounded poll still answers");
1783
1784        let tally = host_labels().tally(demand.jobs_for(&repo()));
1785        assert_eq!(
1786            tally.demand() as usize,
1787            MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
1788            "the queued cap is spent in full on the primary signal"
1789        );
1790        assert_eq!(
1791            tally.not_matched as usize, MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
1792            "and the safety net gets its own smaller cap, not a share of the first"
1793        );
1794    }
1795
1796    /// One run's job listing follows pages, and stops at its budget.
1797    #[tokio::test]
1798    async fn a_runs_job_listing_walks_pages_and_stops_at_its_budget() {
1799        let server = MockServer::start().await;
1800        let base = server.uri();
1801        let path_100 = jobs_path(&repo(), 100);
1802
1803        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
1804        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
1805        // Every page points at another, forever.
1806        Mock::given(method("GET"))
1807            .and(path(path_100.clone()))
1808            .respond_with(
1809                ResponseTemplate::new(200)
1810                    .set_body_json(jobs_body(&["rm-home-win-x64"], 100, 0))
1811                    .insert_header(
1812                        "link",
1813                        format!("<{base}{path_100}?page=9>; rel=\"next\"").as_str(),
1814                    ),
1815            )
1816            .mount(&server)
1817            .await;
1818        let gateway = gateway(&server);
1819
1820        let demand = gateway
1821            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1822            .await
1823            .expect("a bounded walk still answers");
1824
1825        assert_eq!(
1826            gateway.requests_issued() as usize,
1827            2 + MAX_JOB_PAGES_PER_RUN,
1828            "an endless `Link` chain must stop at the job page budget rather than \
1829             spending the hourly ceiling on one run"
1830        );
1831        assert_eq!(demand.total() as usize, 100 * MAX_JOB_PAGES_PER_RUN);
1832        assert!(
1833            !demand.is_complete(),
1834            "a count clipped by the page bound is a floor and must say so"
1835        );
1836        assert!(demand.is_truncated(&repo()));
1837    }
1838
1839    // -- the `total_count` tripwire ----------------------------------------
1840
1841    /// The zero-cost check that `total_count` is the *filtered* count.
1842    ///
1843    /// A repository with 3 queued runs out of 5,000 lifetime runs would answer
1844    /// `len() == 3`, no `Link`, and `total_count == 5000`. The demand number no
1845    /// longer comes from that field, so the contradiction is a `warn!` rather
1846    /// than a `debug_assert!` — but it still means GitHub is not answering the
1847    /// question this module asked, and it is free to notice.
1848    #[tokio::test]
1849    async fn a_total_count_that_disagrees_with_its_only_page_does_not_derail_the_count() {
1850        let server = MockServer::start().await;
1851        mount_runs(
1852            &server,
1853            &repo(),
1854            QUEUED_RUN_STATUS,
1855            json!({
1856                "total_count": 5_000,
1857                "workflow_runs": [{ "id": 100 }]
1858            }),
1859        )
1860        .await;
1861        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
1862        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
1863        let gateway = gateway(&server);
1864
1865        let demand = gateway
1866            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1867            .await
1868            .expect("a wrong `total_count` no longer decides anything here");
1869
1870        assert_eq!(
1871            demand.total(),
1872            2,
1873            "the count comes from the jobs of the runs that were actually listed, so a \
1874             `total_count` carrying the unfiltered lifetime total cannot inflate it"
1875        );
1876        assert!(
1877            demand.is_complete(),
1878            "one listed run, no next page, and the cap not reached"
1879        );
1880    }
1881
1882    // -- organization aggregation ------------------------------------------
1883
1884    /// An organization aggregates across its installed repositories, and the
1885    /// request count grows with that repository count.
1886    #[tokio::test]
1887    async fn an_organization_aggregates_its_repositories_and_pays_per_repository() {
1888        let server = MockServer::start().await;
1889        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 2, 0).await;
1890        mount_one_queued_run(&server, &other_repo(), &["rm-home-win-x64"], 5, 0).await;
1891        mount_idle(&server, &third_repo()).await;
1892        let gateway = gateway(&server);
1893
1894        let two = org_scope([repo(), other_repo()]);
1895        let three = org_scope([repo(), other_repo(), third_repo()]);
1896
1897        assert_eq!(
1898            demand_requests_per_poll(&two),
1899            2 * DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
1900            "there is no organization-wide workflow-runs endpoint, so the cost is \
1901             per repository"
1902        );
1903        assert!(
1904            demand_requests_per_poll(&three) > demand_requests_per_poll(&two),
1905            "a projection that did not grow with the repository count would understate \
1906             an organization's real spend by exactly that factor"
1907        );
1908
1909        let demand = gateway
1910            .queued_demand(&three, &CancelToken::new())
1911            .await
1912            .expect("an aggregate");
1913
1914        assert_eq!(demand.total(), 7);
1915        assert_eq!(demand.for_repository(&repo()), Some(2));
1916        assert_eq!(demand.for_repository(&other_repo()), Some(5));
1917        assert_eq!(
1918            demand.for_repository(&third_repo()),
1919            Some(0),
1920            "a repository that answered zero is present as a zero, unlike one that \
1921             could not answer at all"
1922        );
1923        assert_eq!(
1924            host_labels().tally(demand.jobs()).demand(),
1925            7,
1926            "an organization policy serves any repository in its scope, so its demand \
1927             is the whole aggregate's rather than one repository's"
1928        );
1929        assert!(
1930            gateway.requests_issued() <= u64::from(max_demand_requests_per_poll(&three)),
1931            "the measured cost must sit inside the projected ceiling, or the budget \
1932             model is a table in a document"
1933        );
1934    }
1935
1936    /// The measured cost is reported to `c3`'s budget model through the seam
1937    /// `c3` left for it, rather than inheriting the estimate.
1938    #[test]
1939    fn the_measured_demand_cost_is_reported_through_c3s_seam() {
1940        use crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH;
1941
1942        let scope = org_scope([repo(), other_repo(), third_repo()]);
1943        let estimated = TargetCost::from_activity_scope(&scope);
1944        let measured = target_cost(&scope);
1945
1946        assert_eq!(
1947            DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH, 2,
1948            "`c3`'s estimate prices the runs request and one jobs request; this module \
1949             now issues both, plus the in-progress listing and a jobs request per \
1950             additional active run, so the measured figure is higher rather than lower"
1951        );
1952        assert_ne!(
1953            measured, estimated,
1954            "the seam must actually replace the estimate; a `target_cost` that returned \
1955             `from_activity_scope` unchanged would report the estimate as measured"
1956        );
1957        // 1 inventory request + 3 repositories * (1 activity + 4 demand).
1958        assert_eq!(measured.requests_per_refresh(), 16);
1959        // 1 + 3 * (1 + 2), `c3`'s estimate.
1960        assert_eq!(estimated.requests_per_refresh(), 10);
1961        assert!(
1962            measured.requests_per_refresh() > estimated.requests_per_refresh(),
1963            "restoring the per-run job listing added requests; a measured cost that was \
1964             not higher would mean this module is not issuing them"
1965        );
1966    }
1967
1968    /// **A known gap, pinned so it stays visible.** `f2`'s refusal *decision*
1969    /// can consume the measured demand cost; the number it prints alongside the
1970    /// refusal cannot.
1971    ///
1972    /// [`crate::rest::BudgetProjection::admit`] takes the candidate
1973    /// [`TargetCost`] from its caller, so an `f2` that builds it through
1974    /// [`target_cost`] gets an admission computed from the real cost.
1975    /// [`crate::rest::BudgetProjection::max_repository_targets`] builds
1976    /// [`TargetCost::repository`] internally, which cannot see the seam and
1977    /// therefore still prices demand at `c3`'s estimate of two.
1978    ///
1979    /// The two then disagree, and an operator sees both. **The direction of the
1980    /// disagreement inverted when this module started counting jobs**, and that
1981    /// is why this test matters more than it did: the printed ceiling used to be
1982    /// conservative, and is now optimistic. It says a host fits ten repository
1983    /// targets when the cost this module really issues fits six, so an operator
1984    /// planning against the printed number can configure a host that spends more
1985    /// than the projection admitted.
1986    ///
1987    /// What keeps that from being a live budget overrun is
1988    /// `BUDGET_SHARE_DIVISOR`: the projection is compared against half of
1989    /// GitHub's hourly ceiling, so the gap is spent out of the half deliberately
1990    /// left unplanned. It is a reporting defect with a safety margin under it,
1991    /// not a correctness one — and `f1`'s `host show` prints the caveat beside
1992    /// the number.
1993    ///
1994    /// It is not fixable from this file. Both remedies — changing
1995    /// [`crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH`], or giving
1996    /// `max_repository_targets` a [`TargetCost`] argument — are edits to
1997    /// `crates/github/src/rest.rs`, which `c3` owns. This test records the
1998    /// discrepancy with its arithmetic so that whoever holds that file can act on
1999    /// it, and fails if it is ever closed, at which point this test and the note
2000    /// above should go.
2001    #[test]
2002    fn the_printed_target_ceiling_still_projects_c3s_estimate() {
2003        use crate::rest::{BudgetProjection, budget_allowance};
2004        use runner_manager_domain::model::RefreshInterval;
2005
2006        let interval = RefreshInterval::default();
2007        let printed = BudgetProjection::max_repository_targets(interval);
2008        let per_hour_estimated = TargetCost::repository().requests_per_hour(interval);
2009        let per_hour_measured = TargetCost::repository()
2010            .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
2011            .requests_per_hour(interval);
2012
2013        // 4 requests per refresh * 60 refreshes, against 6 * 60.
2014        assert_eq!(per_hour_estimated, 240);
2015        assert_eq!(per_hour_measured, 360);
2016        assert_eq!(
2017            printed, 10,
2018            "the printed ceiling is `04-subsystem-contracts.md`'s figure, computed from \
2019             `c3`'s estimate"
2020        );
2021        assert_eq!(
2022            budget_allowance() / per_hour_measured,
2023            6,
2024            "while the cost this module actually issues allows six"
2025        );
2026        assert!(
2027            printed > budget_allowance() / per_hour_measured,
2028            "the gap now runs in the optimistic direction: the printed ceiling is larger \
2029             than the measured cost supports. `BUDGET_SHARE_DIVISOR` is what absorbs it \
2030             -- see this test's documentation before treating the inequality as harmless"
2031        );
2032    }
2033
2034    /// One archived repository must not take down an organization's whole
2035    /// demand poll — and must not read as zero either.
2036    #[tokio::test]
2037    async fn an_organization_steps_over_a_repository_local_failure_without_reading_it_as_zero() {
2038        let server = MockServer::start().await;
2039        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 4, 0).await;
2040        Mock::given(method("GET"))
2041            .and(path(runs_path(&other_repo())))
2042            .respond_with(
2043                ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
2044            )
2045            .mount(&server)
2046            .await;
2047        let gateway = gateway(&server);
2048
2049        let demand = gateway
2050            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
2051            .await
2052            .expect("an aggregate steps over a repository it cannot read");
2053
2054        assert_eq!(demand.total(), 4);
2055        assert_eq!(demand.unavailable().len(), 1);
2056        assert_eq!(demand.unavailable()[0].repository, other_repo());
2057        assert_eq!(
2058            demand.for_repository(&other_repo()),
2059            None,
2060            "a repository that could not be polled is absent from the map, not present \
2061             as a zero"
2062        );
2063        assert!(
2064            demand.jobs_for(&other_repo()).is_empty(),
2065            "and its job list is empty rather than absent, so a caller tallying it \
2066             cannot accidentally read an unavailable repository as demand"
2067        );
2068        assert!(
2069            !demand.is_complete(),
2070            "an aggregate missing a repository is not a complete reading"
2071        );
2072    }
2073
2074    /// The same `404`, on a repository *target*, propagates instead.
2075    ///
2076    /// Stepping over the only repository in scope would answer demand `0` for a
2077    /// target this host cannot see at all, and `e1` would then correctly start
2078    /// no runners for the wrong reason — with no error anywhere to explain it.
2079    #[tokio::test]
2080    async fn a_repository_target_propagates_the_failure_rather_than_reporting_zero_demand() {
2081        let server = MockServer::start().await;
2082        Mock::given(method("GET"))
2083            .and(path(runs_path(&repo())))
2084            .respond_with(
2085                ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
2086            )
2087            .mount(&server)
2088            .await;
2089        let gateway = gateway(&server);
2090
2091        let error = gateway
2092            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2093            .await
2094            .expect_err("a scope of one has no aggregate to step over into");
2095        assert!(matches!(
2096            error,
2097            InventoryError::Github(GithubError::Status { status: 404, .. })
2098        ));
2099    }
2100
2101    /// A failure on the *job* listing is a failure of the whole poll, not a
2102    /// silently short count.
2103    ///
2104    /// The job listing is where the new requests are, so it is where a new way
2105    /// to under-count could enter: a gateway that swallowed a failed job listing
2106    /// would report the run's jobs as zero, which is indistinguishable from a
2107    /// run whose jobs have all been dispatched.
2108    #[tokio::test]
2109    async fn a_failed_job_listing_is_not_read_as_a_run_with_no_queued_jobs() {
2110        let server = MockServer::start().await;
2111        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2112        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2113        Mock::given(method("GET"))
2114            .and(path(jobs_path(&repo(), 100)))
2115            .respond_with(
2116                ResponseTemplate::new(500).set_body_json(json!({ "message": "Server Error" })),
2117            )
2118            .mount(&server)
2119            .await;
2120        let gateway = gateway(&server);
2121
2122        let error = gateway
2123            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2124            .await
2125            .expect_err("a job listing that failed is not a run with nothing queued");
2126        assert!(matches!(
2127            error,
2128            InventoryError::Github(GithubError::Status { status: 500, .. })
2129        ));
2130    }
2131
2132    /// A credential failure aborts the aggregate rather than being stepped over.
2133    ///
2134    /// A revoked token is a fact about the credential, not about the repository:
2135    /// stepping over it would report every remaining repository as unavailable
2136    /// and the total as a number, when in truth nothing can be read at all.
2137    #[tokio::test]
2138    async fn a_rate_limit_aborts_the_aggregate_rather_than_being_stepped_over() {
2139        let server = MockServer::start().await;
2140        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
2141        Mock::given(method("GET"))
2142            .and(path(runs_path(&other_repo())))
2143            .respond_with(
2144                ResponseTemplate::new(429)
2145                    .insert_header("retry-after", "42")
2146                    .set_body_json(json!({
2147                        "message": "You have exceeded a secondary rate limit"
2148                    })),
2149            )
2150            .mount(&server)
2151            .await;
2152        let gateway = gateway(&server);
2153
2154        let error = gateway
2155            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
2156            .await
2157            .expect_err("a rate limit is a fact about the credential, not the repository");
2158
2159        let limit = error
2160            .rate_limited()
2161            .expect("`c3`'s detector is what decides this, and it decided rate limit");
2162        assert_eq!(limit.retry_after, Some(std::time::Duration::from_secs(42)));
2163    }
2164
2165    // -- cancellation -------------------------------------------------------
2166
2167    /// A token flipped between requests stops the poll before the next one.
2168    #[tokio::test]
2169    async fn cancellation_between_requests_stops_the_poll() {
2170        let server = MockServer::start().await;
2171        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 0).await;
2172
2173        let gateway = gateway(&server);
2174        let cancel = CancelToken::new();
2175
2176        let first = gateway
2177            .repository_queued(&repo(), &cancel)
2178            .await
2179            .is_ok_and(|reading| reading.jobs.len() == 3);
2180        assert!(first, "the uncancelled poll reads both lists and the jobs");
2181        assert_eq!(gateway.requests_issued(), 3);
2182
2183        cancel.cancel();
2184        let error = gateway
2185            .repository_queued(&repo(), &cancel)
2186            .await
2187            .expect_err("a cancelled token opens no socket at all");
2188        assert!(error.is_cancelled());
2189        assert_eq!(
2190            gateway.requests_issued(),
2191            3,
2192            "a cancelled poll must spend nothing; the count is of requests attempted"
2193        );
2194    }
2195
2196    // -- the seam this module does not own ---------------------------------
2197
2198    /// The `runs-on` predicate is `b1`'s. This module builds its **input** and
2199    /// implements no part of the matching.
2200    ///
2201    /// # Read this before concluding the test is in the wrong file
2202    ///
2203    /// `c4`'s specification asks for "a `runs-on` table covering forms that must
2204    /// match, forms that must not, and an unresolvable expression, delegating
2205    /// the predicate to `b1`". For a while that table had no input: an owner
2206    /// decision had removed the per-run job listing, a workflow *run* carries no
2207    /// `runs-on`, and this gateway therefore produced nothing to match. That
2208    /// decision has been reversed — the module documentation says why — so the
2209    /// table has its input back and this test asserts both halves of the seam
2210    /// rather than only the delegation half.
2211    ///
2212    /// The division of labour is: `c4` reads each queued job's `labels` array
2213    /// and builds a [`RunsOn`], `b1` decides what matches, and `e1` applies the
2214    /// decision per policy. Constructing a `RunsOn` here is the correct side of
2215    /// that line; comparing labels here is not.
2216    ///
2217    /// # How the second half of that claim is asserted, and how far it reaches
2218    ///
2219    /// By reading this file's own source, because nothing done to `b1` can prove
2220    /// anything about what *this* module contains: every assertion in the body
2221    /// below would pass unchanged with a full label matcher sitting beside it.
2222    /// The scan takes the production half — everything above `#[cfg(test)]`,
2223    /// with comment lines dropped so that the module documentation may keep
2224    /// explaining the seam — and requires that it names none of `b1`'s
2225    /// *decision* vocabulary.
2226    ///
2227    /// `RunsOn` is no longer in the forbidden set, and could not be: this module
2228    /// constructs one per queued job, which is the whole point of the reversal.
2229    /// What stays forbidden is everything that would mean deciding rather than
2230    /// describing — `RoutingLabels`, whose `matches` and `tally` are the
2231    /// predicate, and `DemandTally`, which is the predicate's result. A `c4`
2232    /// that named either would be filtering, and filtering here would make the
2233    /// poll per-policy rather than per-target; the module documentation explains
2234    /// why that trade is refused.
2235    ///
2236    /// Like the needles in `nothing_in_this_crate_reserves_or_claims_a_job`, it
2237    /// is a tripwire on the obvious shape rather than a proof: a hand-rolled
2238    /// comparison of raw label strings that never names a `policy` type would
2239    /// walk past it. Stated rather than implied, for the same reason it is
2240    /// stated there.
2241    #[test]
2242    fn the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it() {
2243        let labels = RoutingLabels::derive(
2244            &HostLabel::new("home").expect("a valid host label"),
2245            Os::Windows,
2246            Arch::X64,
2247        );
2248        let host = labels.host_label().as_str().to_string();
2249        assert_eq!(host, "rm-home-win-x64");
2250
2251        // Must match: the host label alone, in each documented form.
2252        for form in [
2253            RunsOn::Single(host.clone()),
2254            RunsOn::Many(vec![host.clone()]),
2255            RunsOn::Grouped {
2256                group: Some("Default".into()),
2257                labels: runner_manager_domain::policy::RunsOnLabels::One(host.clone()),
2258            },
2259        ] {
2260            assert!(
2261                labels.matches(&form).is_match(),
2262                "a job requiring only this policy's own label must match: {form:?}"
2263            );
2264        }
2265
2266        // Must not match: GitHub-hosted, and another host's label.
2267        for form in [
2268            RunsOn::Single("ubuntu-latest".into()),
2269            RunsOn::Many(vec![host.clone(), "rm-office-win-x64".into()]),
2270        ] {
2271            assert!(
2272                !labels.matches(&form).is_match(),
2273                "a job requiring a label this policy does not carry must not match: {form:?}"
2274            );
2275        }
2276
2277        // Unresolvable: an expression only GitHub can evaluate. Never demand and
2278        // never discarded.
2279        let expression = RunsOn::Single("${{ matrix.runner }}".into());
2280        assert!(matches!(
2281            labels.matches(&expression),
2282            RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
2283        ));
2284
2285        // And the tally keeps the three apart, which is what `e1` clamps.
2286        let tally = labels.tally(&[
2287            RunsOn::Single(host.clone()),
2288            RunsOn::Single("ubuntu-latest".into()),
2289            expression,
2290        ]);
2291        assert_eq!(tally.demand(), 1);
2292        assert_eq!(tally.not_matched, 1);
2293        assert_eq!(tally.unresolvable.len(), 1);
2294        assert_eq!(tally.total_seen(), 3);
2295
2296        // The form this gateway actually builds is the array one, because that
2297        // is the shape the jobs API returns. Pinned so that a `RunsOn` built
2298        // from a job's `labels` really is a value `b1`'s predicate accepts,
2299        // rather than one that happens to compile.
2300        assert_eq!(
2301            RunsOn::from_job_labels(["self-hosted", host.as_str()]),
2302            RunsOn::Many(vec!["self-hosted".into(), host.clone()])
2303        );
2304        assert!(
2305            labels
2306                .matches(&RunsOn::from_job_labels([host.as_str()]))
2307                .is_match()
2308        );
2309        // And the direction that surprises people: a bare derived set does not
2310        // carry `self-hosted`, so the shape most workflows are written in --
2311        // `runs-on: [self-hosted, windows]` -- does **not** match a policy whose
2312        // operator never added those labels. That is `b1`'s superset rule
2313        // working as specified rather than a gap, and it is asserted here
2314        // because the job listing is what finally made it observable.
2315        assert!(
2316            !labels
2317                .matches(&RunsOn::from_job_labels(["self-hosted", host.as_str()]))
2318                .is_match(),
2319            "a job requiring `self-hosted` needs a policy carrying `self-hosted`"
2320        );
2321
2322        // And the other half of this test's title, which everything above leaves
2323        // untouched: that the predicate exercised here has no second
2324        // implementation in this file. See the documentation on this test for
2325        // what the scan covers, what it does not, and why `RunsOn` is absent
2326        // from the list.
2327        let production = this_file_above_its_tests_without_prose();
2328        for owned_by_b1 in ["RoutingLabels", "DemandTally"] {
2329            assert!(
2330                !production.contains(owned_by_b1),
2331                "the demand gateway names `{owned_by_b1}`, which belongs to `b1`: this \
2332                 module builds the predicate's input and does not apply it. Filtering \
2333                 here would make the poll per-policy rather than per-target and multiply \
2334                 its request cost by the number of policies sharing a target -- if an \
2335                 owner decision changed that, it belongs in this module's documentation \
2336                 and in this test before it belongs in the code"
2337            );
2338        }
2339        assert!(
2340            production.contains("RunsOn"),
2341            "and the gateway must still *build* a `RunsOn` per queued job; a production \
2342             half that named none would mean the job listing had been removed again and \
2343             the serial-matrix defect restored"
2344        );
2345    }
2346
2347    /// This file's source above its test module, with comment lines dropped.
2348    ///
2349    /// Two exclusions, each load-bearing. The **test module** goes because the
2350    /// tests above legitimately drive `b1`'s predicate and would accuse the file
2351    /// of owning what they are proving it delegates. The **comments** go because
2352    /// this module's documentation explains the seam at length and names the
2353    /// types to do it — a scan that forbade the explanation would get the
2354    /// explanation deleted, which is the trade
2355    /// `nothing_in_this_crate_reserves_or_claims_a_job` records making in the
2356    /// other direction.
2357    fn this_file_above_its_tests_without_prose() -> String {
2358        let (production, _) = include_str!("demand.rs")
2359            .split_once("\n#[cfg(test)]")
2360            .expect("this file has a test module, and the scan is meaningless without one");
2361        production
2362            .lines()
2363            .filter(|line| !line.trim_start().starts_with("//"))
2364            .collect::<Vec<_>>()
2365            .join("\n")
2366    }
2367
2368    /// The one normalisation both halves of the reservation scan use.
2369    ///
2370    /// Shared rather than written twice, because the defect it closes was two
2371    /// spellings of "the same" normalisation drifting apart: the haystack was
2372    /// lower-cased and the needle was not, so every needle carrying a capital —
2373    /// which is every type-shaped one — could not match a lower-cased haystack,
2374    /// and three of the seven assertions were vacuously true from the day they
2375    /// were written. One function cannot disagree with itself.
2376    fn normalise_for_scan(text: &str) -> String {
2377        text.to_ascii_lowercase().replace(['_', ' '], "")
2378    }
2379
2380    // The Actions-service call this design has no equivalent of, plus the shapes
2381    // an implementer would invent in its place. Matched case-insensitively with
2382    // `_` and spaces removed, so one needle catches the snake, camel and Pascal
2383    // spellings of an identifier at once — and so that the singular needle
2384    // catches the plural.
2385    //
2386    // # Why every needle carries `fn` or `struct`
2387    //
2388    // A bare `acquirejobs` fires on this crate's own prose, and it was written
2389    // that way first: three modules explain *why* there is no job reservation,
2390    // and each has to name the call that does not exist in order to say so. A
2391    // scan that forbids the explanation is a scan that gets the explanation
2392    // deleted, which costs more than it protects. Requiring the item keyword
2393    // narrows the needle to a *definition*, which is what the rule is actually
2394    // about.
2395    //
2396    // What this therefore does **not** catch is stated rather than implied: a
2397    // reservation reached through a trait method, a closure, or a
2398    // differently-named helper. It is a tripwire on the obvious shape, and
2399    // `c4`'s Definition of Done names review as the primary control.
2400    //
2401    // # And why every needle is spelled in halves
2402    //
2403    // `lib.rs`'s confidential-credential scan solves the same problem the same
2404    // way: a needle written out whole would appear in this file's own source and
2405    // the scan would accuse itself. Normalising
2406    // `concat!("fn ", "acquire", "_job")` leaves the quote-comma-quote between
2407    // the halves, so no needle ever appears whole in the text being scanned.
2408    const FORBIDDEN: &[&str] = &[
2409        concat!("fn ", "acquire", "_job"),
2410        concat!("fn ", "claim", "_job"),
2411        concat!("fn ", "lease", "_job"),
2412        concat!("fn ", "reserve", "_job"),
2413        // The acknowledgement this test's own title names alongside the other
2414        // three, and which the list did not actually carry. Spelled to the verb
2415        // rather than to a `_job` suffix, because the shape an implementer
2416        // reaches for acknowledges a *message* or an *assignment* as readily as
2417        // a job, and a suffixed needle would walk straight past those.
2418        concat!("fn ", "ack", "nowledge"),
2419        concat!("struct ", "Job", "Lease"),
2420        concat!("struct ", "Job", "Claim"),
2421        concat!("struct ", "Job", "Reservation"),
2422    ];
2423
2424    /// Which forbidden shape a source text names, if any.
2425    ///
2426    /// The whole-crate scan and its positive control both go through here, so a
2427    /// normalisation that cannot see a shape fails the control loudly instead of
2428    /// passing the scan silently. That is the entire point of the indirection:
2429    /// the arrangement this replaced had the control re-deriving the needle
2430    /// itself, and a copy that agrees with a buggy original proves nothing — it
2431    /// re-derived the needle the same wrong way and went green.
2432    fn forbidden_shape_in(source: &str) -> Option<&'static str> {
2433        let haystack = normalise_for_scan(source);
2434        FORBIDDEN
2435            .iter()
2436            .copied()
2437            .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
2438    }
2439
2440    /// No reservation, claim, lease, or acknowledgement call exists anywhere in
2441    /// this crate.
2442    ///
2443    /// `AcquireJobs` had no REST replacement, so a well-meaning implementer
2444    /// reaches for a local lease to "fix" the surplus-runner case. Three
2445    /// specifications say not to; this makes the instruction executable, over
2446    /// the whole crate rather than only this file, because the edit would most
2447    /// likely land in a new module rather than here.
2448    #[test]
2449    fn nothing_in_this_crate_reserves_or_claims_a_job() {
2450        const SOURCES: &[(&str, &str)] = &[
2451            ("demand.rs", include_str!("demand.rs")),
2452            ("device_flow.rs", include_str!("device_flow.rs")),
2453            ("jit.rs", include_str!("jit.rs")),
2454            ("lib.rs", include_str!("lib.rs")),
2455            ("rest.rs", include_str!("rest.rs")),
2456        ];
2457        // `SOURCES` is a snapshot, and a snapshot makes "anywhere in the crate"
2458        // false the moment a file is added. The walk below turns the claim back
2459        // into a claim.
2460        //
2461        // It **recurses**, and that is not incidental. `lib.rs` records the
2462        // defect a single `read_dir` produced for the same pin: a module
2463        // directory (`src/rest/mod.rs`) arrives as the entry `rest`, which does
2464        // not end in `.rs`, so a flat filter drops it and takes every file
2465        // underneath with it — leaving the pin passing while the files it exists
2466        // to cover are scanned by nothing at all.
2467        fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
2468            for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
2469                let entry = entry.expect("a readable directory entry");
2470                let name = entry.file_name().to_string_lossy().into_owned();
2471                // `/`-joined, which is what `include_str!` takes on every
2472                // platform, so the two sides compare directly.
2473                let joined = if prefix.is_empty() {
2474                    name.clone()
2475                } else {
2476                    format!("{prefix}/{name}")
2477                };
2478                if entry.path().is_dir() {
2479                    walk(&entry.path(), &joined, found);
2480                } else if name.ends_with(".rs") {
2481                    found.push(joined);
2482                }
2483            }
2484        }
2485
2486        let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
2487        listed.sort_unstable();
2488        let mut on_disk = Vec::new();
2489        walk(
2490            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
2491            "",
2492            &mut on_disk,
2493        );
2494        on_disk.sort_unstable();
2495        assert_eq!(
2496            listed, on_disk,
2497            "a source file was added or removed; this scan claims to cover the whole \
2498             crate and a stale list makes that claim false"
2499        );
2500
2501        for (name, source) in SOURCES {
2502            assert_eq!(
2503                forbidden_shape_in(source),
2504                None,
2505                "{name} names a forbidden shape: there is no job reservation on the REST \
2506                 path, and a local lease coordinates this host with itself and with \
2507                 nothing else"
2508            );
2509        }
2510    }
2511
2512    /// The scan above can actually see the things it forbids.
2513    ///
2514    /// A substring scan that never matches passes for the wrong reason. This
2515    /// plants the exact shapes and runs them through [`forbidden_shape_in`] —
2516    /// the same matcher the scan uses, rather than a second copy of it.
2517    ///
2518    /// # Why one planted shape was not enough
2519    ///
2520    /// It planted only the `fn` form, and that form is the one that could not
2521    /// fail: a `fn` needle is already lower-case, so it survived a
2522    /// normalisation that lower-cased the haystack and not the needle. Every
2523    /// type-shaped needle carries capitals and therefore could never match the
2524    /// lower-cased haystack — all three of them were un-catchable, and their
2525    /// three assertions vacuously true, while this control stayed green. (They
2526    /// are not written out here for the reason the list itself is spelled in
2527    /// halves: a literal would make this file fail its own gate.) Both kinds
2528    /// are planted now, and the case-shape of the plant is the property under
2529    /// test rather than an incidental detail of it.
2530    #[test]
2531    fn the_reservation_scan_catches_an_injected_reservation() {
2532        // Assembled from fragments rather than written out, because the scan
2533        // above reads this very file: a literal here would make the crate fail
2534        // its own gate, which is the trap that forced the needles to carry an
2535        // item keyword in the first place.
2536        let call = format!(
2537            "    async {} {}{}(&self) -> Result<Vec<Job>, InventoryError> {{",
2538            "fn", "acquire", "_jobs"
2539        );
2540        let item = format!("{} {}{} {{ id: u64 }}", "struct", "Job", "Lease");
2541        let acknowledgement = format!(
2542            "    async {} {}{}(&self, id: u64) {{",
2543            "fn", "ack", "nowledge_assignment"
2544        );
2545
2546        for (planted, expected) in [
2547            (&call, concat!("fn ", "acquire", "_job")),
2548            (&item, concat!("struct ", "Job", "Lease")),
2549            (&acknowledgement, concat!("fn ", "ack", "nowledge")),
2550        ] {
2551            assert_eq!(
2552                forbidden_shape_in(planted),
2553                Some(expected),
2554                "the scan's own matcher cannot see {planted:?}, so every negative \
2555                 assertion it makes about that shape is worthless"
2556            );
2557        }
2558
2559        // And the plural really is caught by the singular needle, which is the
2560        // one thing about the list above that is not obvious from reading it.
2561        assert!(
2562            call.contains("acquire_jobs"),
2563            "the planted shape is the plural the Actions-service protocol used"
2564        );
2565        // Likewise the acknowledgement needle stops at the verb, so it catches
2566        // the shapes that acknowledge something other than a job by name.
2567        assert!(
2568            acknowledgement.contains("_assignment"),
2569            "the planted acknowledgement names no job, which is why the needle \
2570             carrying a `_job` suffix would have missed it"
2571        );
2572    }
2573}