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, Mutex,
152        atomic::{AtomicU64, Ordering},
153    },
154    time::Duration,
155};
156
157use runner_manager_domain::{
158    model::{Clock, OwnerRepo, TargetScope, Timestamp},
159    policy::RunsOn,
160};
161use serde::Deserialize;
162
163use crate::{
164    ApiRequest, AuthenticatedClient, GithubError,
165    conditional::{ConditionalCache, Fetched},
166    rest::{
167        ActivityScope, CancelToken, DEFAULT_RATE_LIMIT_BACKOFF, InventoryError,
168        MAX_RATE_LIMIT_BACKOFF, PER_PAGE, RateLimitKind, RateLimited, TargetCost,
169        UnavailableRepository,
170    },
171    traffic::RateLimitSnapshot,
172};
173
174// ---------------------------------------------------------------------------
175// Constants
176// ---------------------------------------------------------------------------
177
178/// The `status` filter that selects runs with a job that may still be waiting.
179///
180/// Stated as a constant rather than inlined because it is the whole difference
181/// between this module and `c3`'s activity count, and a one-word typo here
182/// produces a plausible-looking number rather than an error.
183pub const QUEUED_RUN_STATUS: &str = "queued";
184
185/// The `status` filter for the safety-net pass over runs already under way.
186///
187/// Not a second primary signal. The module documentation states what it covers
188/// that [`QUEUED_RUN_STATUS`] does not — a `needs:`-gated job entering the queue
189/// after its run has already started — and why the difference is not something
190/// this project has observed its way out of needing.
191pub const IN_PROGRESS_RUN_STATUS: &str = "in_progress";
192
193/// The job `status` that means "waiting for a runner that does not exist yet".
194///
195/// This is the value the whole module reduces to. A job in any other state
196/// either has a runner or has finished with one, and counting it would start a
197/// runner for work already being done — the same mistake as reading `c3`'s
198/// in-progress count as demand, one level down.
199pub const QUEUED_JOB_STATUS: &str = "queued";
200
201/// The `filter` for the jobs endpoint: the latest attempt of each job only.
202///
203/// The default is `latest`, and it is sent explicitly because the alternative,
204/// `all`, returns every attempt of every re-run. Under `all` a job re-run three
205/// times contributes three entries, and the two that are historical would be
206/// counted as present demand.
207pub const LATEST_JOBS_FILTER: &str = "latest";
208
209/// Requests one demand poll costs, **per repository**, in steady state.
210///
211/// Four: the two run listings ([`QUEUED_RUN_STATUS`] and
212/// [`IN_PROGRESS_RUN_STATUS`]), plus a job listing for each of the couple of
213/// runs a repository has under way at any moment.
214///
215/// # Why this is a projection and not a measurement, and what bounds it
216///
217/// Under the previous owner decision this constant was `1` and it was exact: one
218/// request per repository, always, because `total_count` on the runs query
219/// answered the whole question. Counting jobs makes the cost depend on how many
220/// runs are active, which is a number no constant can know. So this is the
221/// steady-state figure the budget model prices, in the same spirit as
222/// [`crate::rest::ACTIVITY_REQUESTS_PER_REPOSITORY_PER_REFRESH`], which is also
223/// a best case with a documented worse one.
224///
225/// The **worst** case is `2 + MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL +
226/// MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`, which is what
227/// [`max_demand_requests_per_repository_per_poll`] returns, and it is bounded by
228/// construction rather than by hope: the two caps are hard, and a repository
229/// that reaches them says so through [`QueuedDemand::is_truncated`] rather than
230/// spending more. [`crate::rest::BUDGET_SHARE_DIVISOR`] absorbs the gap between
231/// the two figures — the projection is compared against half the documented
232/// ceiling precisely so that the half nobody models has somewhere to go.
233///
234/// A repository is only at the worst case while it genuinely has that many runs
235/// in flight, which is also exactly when spending requests to scale correctly is
236/// worth more than saving them.
237pub const DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL: u32 = 4;
238
239/// The most `status=queued` runs one repository's job listing may resolve per
240/// poll.
241///
242/// The primary signal gets the larger cap. Past it the reported count is a
243/// **floor** rather than a total, which is safe in the direction that matters:
244/// `e1` clamps demand to `max_capacity` and the host ceiling anyway, so a
245/// repository with more than this many queued runs is one whose real demand
246/// exceeds any realistic host's capacity — the allocation is already pinned at
247/// the ceiling and a larger number would not change it. Successive polls resolve
248/// the rest as the earlier runs drain.
249pub const MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL: usize = 6;
250
251/// The most `status=in_progress` runs one repository's job listing may resolve
252/// per poll.
253///
254/// Smaller than [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] on purpose: this
255/// pass exists to catch a `needs:`-gated job whose run has already started, and
256/// on the overwhelmingly common shape it finds nothing and costs one request per
257/// run to discover that. Giving the safety net the same budget as the primary
258/// signal would double the worst case to buy a rarer case.
259pub const MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL: usize = 4;
260
261/// The most pages of jobs one run's listing may walk.
262///
263/// A run's job count is bounded by GitHub's own matrix limit of 256, so two
264/// pages at [`PER_PAGE`] cover every legal run with room to spare. The third is
265/// there because a `Link: rel="next"` chain that does not terminate is a
266/// runaway, and this walk is charged against a budget.
267pub const MAX_JOB_PAGES_PER_RUN: usize = 3;
268
269/// The ceiling on what one repository's demand poll may spend.
270///
271/// Stated as a function rather than a constant because it is the sum of three
272/// constants and a reader checking the budget arithmetic should not have to
273/// re-add them — and because `f1` and `f2` render the worst case beside the
274/// projection.
275#[must_use]
276pub const fn max_demand_requests_per_repository_per_poll() -> u32 {
277    // The two run listings, then one job listing per run each cap admits.
278    2 + (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u32)
279        + (MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL as u32)
280}
281
282// A poll that costs nothing is a poll that issued no request, and a budget line
283// of zero would let `f2` admit an unbounded number of targets.
284const _: () = assert!(
285    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL > 0,
286    "a demand poll costs at least the requests that fetched the run lists"
287);
288
289// The projection has to sit inside the bound, or the bound is not a bound. This
290// is a compile-time check rather than a test because the two numbers drifting
291// apart is the defect itself rather than a symptom of one: a projection above
292// the ceiling would have `f2` refuse configurations that cannot occur, and the
293// arithmetic that produced it would look deliberate.
294const _: () = assert!(
295    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL <= max_demand_requests_per_repository_per_poll(),
296    "the projected demand cost must fit inside the worst case the caps allow"
297);
298
299// The projection must also cover the two run listings, which every poll issues
300// unconditionally. A projection below that floor would under-price even a
301// completely idle repository, which is the one case the model must get exactly
302// right: it is what `f1`'s printed ceiling and `f2`'s `add` refusals are
303// computed from.
304const _: () = assert!(
305    DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL >= 2,
306    "every poll issues both run listings, so the projection cannot be below two"
307);
308
309// The safety net must not outgrow the signal it is backing up. If these ever
310// invert, the cheaper `in_progress` pass would be resolving more runs than the
311// `queued` pass that actually carries the demand.
312const _: () = assert!(
313    MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL <= MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
314    "the in-progress safety net may not be given a larger budget than the primary signal"
315);
316
317/// How far GitHub's `total_count` may exceed the single page it arrived with
318/// before the disagreement stops being a race and starts being evidence.
319///
320/// `c3` states the reasoning in full at its own `MAX_BENIGN_TOTAL_COUNT_SKEW`,
321/// which is private to `crates/github/src/rest.rs`; the value is repeated rather
322/// than shared because this task does not own that file. The short form:
323///
324/// * A run leaving the queue between GitHub computing `total_count` and
325///   serialising the page makes `total` exceed `listed` by a handful. That race
326///   is documented and legitimate, and a debug build pointed at live GitHub is
327///   the build most likely to meet it.
328/// * The defect being hunted — `total_count` carrying the repository's
329///   *unfiltered* lifetime total — is gross: thousands over a page of three.
330///
331/// # This is now a consistency check rather than a load-bearing one
332///
333/// Under the previous owner decision `total_count` **was** the demand number,
334/// and being wrong about it started the wrong number of runners. It no longer
335/// reaches `clamp()`: demand is counted from the jobs, and `total_count` is read
336/// only to notice that a repository has more runs than the caps will resolve.
337/// So the check stays — it is free, and `c3` reads the same envelope for a
338/// dashboard number — but it is a `warn!` and no longer a `debug_assert!`.
339/// Panicking a development build over a field the decision no longer depends on
340/// would be a tripwire that cries wolf, and those get deleted.
341const MAX_BENIGN_TOTAL_COUNT_SKEW: u64 = 16;
342
343// A zero skew is `total == listed` again, which fires on a run leaving the queue
344// mid-serialisation. At compile time rather than in a test because a test
345// deriving its fixture from this constant moves with it and stays green at zero.
346const _: () = assert!(
347    MAX_BENIGN_TOTAL_COUNT_SKEW > 0,
348    "a zero skew re-creates the check that trips on a run leaving the queue mid-serialisation"
349);
350
351// ---------------------------------------------------------------------------
352// The demand reading
353// ---------------------------------------------------------------------------
354
355/// Queued **jobs**, per repository, each carrying the `runs-on` it requires.
356///
357/// **This is a count of jobs, not of runs, and it is not filtered by any
358/// policy's routing labels — but it carries everything needed to filter it.**
359/// Both facts are the module documentation's subject and are repeated on the
360/// type because this is what a caller holds.
361///
362/// The unfiltered totals ([`QueuedDemand::total`],
363/// [`QueuedDemand::for_repository`]) are the raw queue depth, which is what `g2`
364/// renders for an operator: "what is waiting in this repository", independent of
365/// which host could serve it. The number `e1` clamps is **not** either of those.
366/// It comes from tallying [`QueuedDemand::jobs_for`] against one policy's
367/// routing labels, and the difference between the two is exactly the jobs this
368/// host cannot serve.
369///
370/// # A count can be short in two different ways, and both have to say so
371///
372/// A repository can fail to answer at all ([`QueuedDemand::unavailable`]), and a
373/// repository can answer with a number that is only a **floor**
374/// ([`QueuedDemand::truncated`]) — more runs were active than
375/// [`MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL`] and
376/// [`MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL`] allow resolving, or one
377/// run's job listing walked to [`MAX_JOB_PAGES_PER_RUN`].
378/// [`QueuedDemand::is_complete`] is `false` for either.
379///
380/// The shape mirrors [`crate::rest::ActivityCount`] deliberately, down to the
381/// method names, because `g2` renders the two side by side and a caller that has
382/// learned one should not have to learn the other. They stay separate types for
383/// the reason `c3` keeps the busy-runner count and the in-progress count apart:
384/// a type that can hold either number is a type that will eventually add them.
385#[derive(Debug, Clone, PartialEq, Eq, Default)]
386pub struct QueuedDemand {
387    per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>,
388    /// Which job each entry of `per_repository` is, in the same order, where
389    /// the reading came from GitHub's job listing.
390    identities: BTreeMap<OwnerRepo, Vec<QueuedJobIdentity>>,
391    unavailable: Vec<UnavailableRepository>,
392    /// Repositories whose count is a floor rather than a total.
393    truncated: BTreeSet<OwnerRepo>,
394    /// The workflow file of each run the reading listed, by repository.
395    run_workflows: BTreeMap<OwnerRepo, BTreeMap<u64, String>>,
396    /// Repositories with runs under way whose listings **changed** since the
397    /// previous poll. See [`QueuedDemand::is_quiet`].
398    busy: BTreeSet<OwnerRepo>,
399    /// The account's hourly quota as GitHub reported it during this poll.
400    rate_limit: Option<RateLimitSnapshot>,
401}
402
403impl QueuedDemand {
404    #[must_use]
405    pub fn new(per_repository: BTreeMap<OwnerRepo, Vec<RunsOn>>) -> Self {
406        Self {
407            per_repository,
408            identities: BTreeMap::new(),
409            unavailable: Vec::new(),
410            truncated: BTreeSet::new(),
411            run_workflows: BTreeMap::new(),
412            busy: BTreeSet::new(),
413            rate_limit: None,
414        }
415    }
416
417    /// The workflow file of each of `repository`'s active runs the reading saw,
418    /// by run id.
419    #[must_use]
420    pub fn run_workflows_for(&self, repository: &OwnerRepo) -> Option<&BTreeMap<u64, String>> {
421        self.run_workflows.get(repository)
422    }
423
424    /// `repository`'s queued jobs with their identities, where the reading
425    /// has them.
426    pub fn identified_jobs_for(
427        &self,
428        repository: &OwnerRepo,
429    ) -> impl Iterator<Item = (&RunsOn, Option<&QueuedJobIdentity>)> {
430        let identities = self
431            .identities
432            .get(repository)
433            .map_or(&[][..], Vec::as_slice);
434        self.jobs_for(repository)
435            .iter()
436            .enumerate()
437            .map(move |(index, job)| (job, identities.get(index)))
438    }
439
440    /// Says which job each of `repository`'s entries is, in the same order.
441    #[must_use]
442    pub fn with_identities(
443        mut self,
444        repository: OwnerRepo,
445        identities: Vec<QueuedJobIdentity>,
446    ) -> Self {
447        self.identities.insert(repository, identities);
448        self
449    }
450
451    /// One repository's queued jobs, for the repository-target case and for
452    /// tests.
453    #[must_use]
454    pub fn of(repository: OwnerRepo, jobs: impl IntoIterator<Item = RunsOn>) -> Self {
455        Self::new(BTreeMap::from([(
456            repository,
457            jobs.into_iter().collect::<Vec<_>>(),
458        )]))
459    }
460
461    /// Record that `repository`'s count is a floor rather than a total.
462    #[must_use]
463    pub fn with_truncated(mut self, repository: OwnerRepo) -> Self {
464        self.truncated.insert(repository);
465        self
466    }
467
468    /// Record that `repository` has runs under way whose listings changed.
469    #[must_use]
470    pub fn with_busy(mut self, repository: OwnerRepo) -> Self {
471        self.busy.insert(repository);
472        self
473    }
474
475    /// Attach the hourly-quota reading GitHub sent during this poll.
476    #[must_use]
477    pub const fn with_rate_limit(mut self, rate_limit: Option<RateLimitSnapshot>) -> Self {
478        self.rate_limit = rate_limit;
479        self
480    }
481
482    /// Whether nothing is happening in any repository this poll read.
483    ///
484    /// A repository is quiet when it had **no run under way**, or when every
485    /// listing it took to find out was answered `304 Not Modified`. The second
486    /// half matters: a run stuck in the queue for a label no host serves stays
487    /// in the listing for hours, and treating that as activity would hold the
488    /// whole target at the slower active interval for no reason, when its
489    /// listings cost nothing while they do not change.
490    ///
491    /// `e1` polls a quiet target at the host's *idle* interval. That is the
492    /// moment latency matters most, because work arrives after idle, and the
493    /// moment polling is free, because every answer is a `304`.
494    #[must_use]
495    pub fn is_quiet(&self) -> bool {
496        // An unavailable repository is not quiet either: its failing listing
497        // is not cached, so every poll of it is a charged request, and an
498        // idle interval would multiply that by six.
499        self.busy.is_empty() && self.unavailable.is_empty()
500    }
501
502    /// The account's hourly quota as GitHub reported it during this poll.
503    ///
504    /// Every response carries it, a `304` included, and it describes every
505    /// client signed in as the same user, so it is what `e1` slows down on.
506    #[must_use]
507    pub const fn rate_limit(&self) -> Option<&RateLimitSnapshot> {
508        self.rate_limit.as_ref()
509    }
510
511    /// Record that `repository` could not be read at all, and why.
512    ///
513    /// Deliberately **not** the same as a count of zero: nothing was learned
514    /// about it, and a zero would render a possibly-busy repository as idle.
515    #[must_use]
516    pub fn with_unavailable(mut self, repository: OwnerRepo, reason: impl Into<String>) -> Self {
517        self.unavailable.push(UnavailableRepository {
518            repository,
519            reason: reason.into(),
520        });
521        self
522    }
523
524    /// Queued jobs across every repository that answered, **unfiltered**.
525    ///
526    /// The raw queue depth, not the demand any one policy should serve. See the
527    /// type documentation for the distinction, which is the whole reason
528    /// [`QueuedDemand::jobs`] exists beside this.
529    ///
530    /// Saturating rather than wrapping: a total wider than a `u32` is not a
531    /// number to wrap around zero, and `u32::MAX` runners is refused by the
532    /// capacity ceilings long before it means anything.
533    #[must_use]
534    pub fn total(&self) -> u32 {
535        self.per_repository.values().fold(0_u32, |sum, jobs| {
536            sum.saturating_add(u32::try_from(jobs.len()).unwrap_or(u32::MAX))
537        })
538    }
539
540    #[must_use]
541    pub fn per_repository(&self) -> &BTreeMap<OwnerRepo, Vec<RunsOn>> {
542        &self.per_repository
543    }
544
545    /// One repository's unfiltered count, or `None` when it did not answer.
546    #[must_use]
547    pub fn for_repository(&self, repository: &OwnerRepo) -> Option<u32> {
548        self.per_repository
549            .get(repository)
550            .map(|jobs| u32::try_from(jobs.len()).unwrap_or(u32::MAX))
551    }
552
553    /// One repository's queued jobs, as the `runs-on` each requires.
554    ///
555    /// This is the input `b1`'s predicate was written for and never had. An
556    /// empty slice for a repository that answered means it really is idle; a
557    /// repository that did not answer is in [`QueuedDemand::unavailable`]
558    /// instead, and the two must not be conflated.
559    #[must_use]
560    pub fn jobs_for(&self, repository: &OwnerRepo) -> &[RunsOn] {
561        self.per_repository
562            .get(repository)
563            .map_or(&[], Vec::as_slice)
564    }
565
566    /// Every queued job across every repository that answered.
567    ///
568    /// What an organization target tallies, for the reason a repository target
569    /// tallies [`QueuedDemand::jobs_for`]: one policy watching an organization
570    /// serves any repository in it, so its demand is the whole scope's.
571    pub fn jobs(&self) -> impl Iterator<Item = &RunsOn> {
572        self.per_repository.values().flat_map(Vec::as_slice)
573    }
574
575    #[must_use]
576    pub fn unavailable(&self) -> &[UnavailableRepository] {
577        &self.unavailable
578    }
579
580    #[must_use]
581    pub fn truncated(&self) -> &BTreeSet<OwnerRepo> {
582        &self.truncated
583    }
584
585    #[must_use]
586    pub fn is_truncated(&self, repository: &OwnerRepo) -> bool {
587        self.truncated.contains(repository)
588    }
589
590    /// Whether every repository in scope answered with an exact count.
591    ///
592    /// `false` means the total is a **floor**. Scaling *up* from a floor is
593    /// safe; concluding "idle" from one is not, which is the mistake this exists
594    /// to make expressible.
595    #[must_use]
596    pub fn is_complete(&self) -> bool {
597        self.unavailable.is_empty() && self.truncated.is_empty()
598    }
599}
600
601/// Requests one demand poll over `scope` costs, in steady state.
602///
603/// Grows with the repository count, because there is no organization-wide
604/// workflow-runs endpoint and an organization therefore pays per repository the
605/// App is installed on. That growth *is* the product constraint after D4: every
606/// added repository multiplies this policy's share of the shared hourly ceiling.
607#[must_use]
608pub fn demand_requests_per_poll(scope: &ActivityScope) -> u32 {
609    u32::try_from(scope.repositories().len())
610        .unwrap_or(u32::MAX)
611        .saturating_mul(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
612}
613
614/// The most requests one demand poll over `scope` may spend.
615///
616/// The companion to [`demand_requests_per_poll`], and the number a reader should
617/// be shown when they ask why a busy hour cost more than the projection. See
618/// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for why the model prices the
619/// steady state and bounds the peak rather than trying to price the peak.
620#[must_use]
621pub fn max_demand_requests_per_poll(scope: &ActivityScope) -> u32 {
622    u32::try_from(scope.repositories().len())
623        .unwrap_or(u32::MAX)
624        .saturating_mul(max_demand_requests_per_repository_per_poll())
625}
626
627/// `scope`'s budget cost with this module's demand figure substituted for
628/// `c3`'s estimate.
629///
630/// This is the reporting seam `c4`'s specification requires — "report the
631/// per-poll request count to `c3`'s budget model rather than estimating it
632/// there" — and [`TargetCost::with_demand_requests_per_repository`] is where
633/// `c3` left it open. Callers that project a budget (`f1`'s `host show`, `f2`'s
634/// `repo add` and `org add`, `g3`'s settings) should build their
635/// [`TargetCost`] through this function rather than through
636/// [`TargetCost::from_activity_scope`] directly.
637#[must_use]
638pub fn target_cost(scope: &ActivityScope) -> TargetCost {
639    TargetCost::from_activity_scope(scope)
640        .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
641}
642
643// ---------------------------------------------------------------------------
644// The gateway
645// ---------------------------------------------------------------------------
646
647/// The demand read model.
648///
649/// A trait for [`crate::rest::InventoryGateway`]'s reason: `e1` and `g2` are
650/// tested against `runner_manager_testkit::github::FakeGithub` with no network
651/// and no `wiremock` in their dependency graphs. [`RestDemand`] is the one
652/// implementation that talks to GitHub.
653///
654/// # Why the scope type is `c3`'s `ActivityScope` and not a new one
655///
656/// The name says "activity" and this is demand, so the reuse is worth
657/// justifying rather than leaving to look like an oversight.
658///
659/// [`ActivityScope`] is not a description of the in-progress count; it is the
660/// answer to "which repositories does one per-repository workflow-runs
661/// aggregate cover", and demand asks that question with exactly the same
662/// answer. Both hit `/repos/{o}/{r}/actions/runs`, both have no
663/// organization-wide form, and both take the repository list from the same
664/// caller-held set — `c3` documents that the list "has to come from the caller,
665/// \[because\] `f1` and `e1` already hold it, from
666/// [`crate::AuthenticatedClient::discover_installations`], and re-discovering it
667/// on every refresh would cost more requests than the count itself".
668///
669/// A `DemandScope` beside it would be that type with a different name, and the
670/// cost of the duplicate is concrete rather than aesthetic: `e1` polls both read
671/// models in one refresh, and two scope types are two chances for them to
672/// disagree about which repositories are in scope — which would make the demand
673/// total and the activity total describe different sets while being rendered
674/// side by side. What is *not* shared is the cost function:
675/// [`ActivityScope::requests_per_refresh`] prices the activity count, and
676/// [`demand_requests_per_poll`] prices this one, because those really are two
677/// different numbers.
678#[async_trait::async_trait]
679pub trait DemandGateway: fmt::Debug + Send + Sync {
680    /// Queued workflow runs across `scope`.
681    ///
682    /// # Errors
683    /// Every variant of [`InventoryError`].
684    async fn queued_demand(
685        &self,
686        scope: &ActivityScope,
687        cancel: &CancelToken,
688    ) -> Result<QueuedDemand, InventoryError>;
689
690    /// The workflow file (`.github/workflows/ci.yml`) of one run. Nothing else
691    /// on a run names its workflow stably: its `name` and a job's `workflow_name`
692    /// can be a title that changes at every dispatch.
693    ///
694    /// # Errors
695    /// Every variant of [`InventoryError`].
696    async fn run_workflow(
697        &self,
698        _repository: &OwnerRepo,
699        _run_id: u64,
700        _cancel: &CancelToken,
701    ) -> Result<Option<String>, InventoryError> {
702        Ok(None)
703    }
704
705    /// The instant a reading is stamped with.
706    fn now(&self) -> Timestamp;
707}
708
709/// One gateway shared by every target loop on a host. Sharing is what lets
710/// [`RestDemand`] read a repository once for every target that covers it, and
711/// silence every loop at once inside a rate-limit quiet period.
712#[async_trait::async_trait]
713impl<T: DemandGateway + ?Sized> DemandGateway for Arc<T> {
714    async fn queued_demand(
715        &self,
716        scope: &ActivityScope,
717        cancel: &CancelToken,
718    ) -> Result<QueuedDemand, InventoryError> {
719        (**self).queued_demand(scope, cancel).await
720    }
721
722    async fn run_workflow(
723        &self,
724        repository: &OwnerRepo,
725        run_id: u64,
726        cancel: &CancelToken,
727    ) -> Result<Option<String>, InventoryError> {
728        (**self).run_workflow(repository, run_id, cancel).await
729    }
730
731    fn now(&self) -> Timestamp {
732        (**self).now()
733    }
734}
735
736/// [`DemandGateway`] over `api.github.com`.
737///
738/// Holds no credential of its own: authentication is entirely
739/// [`AuthenticatedClient`]'s, and this type only ever hands it an
740/// [`ApiRequest`].
741///
742/// # Why this is not a method on `c3`'s `RestInventory`
743///
744/// It would be the better shape, and it is not available. `RestInventory::get`
745/// — the one place cancellation, request accounting and the rate-limit gate meet
746/// — is private to `crates/github/src/rest.rs`, and a sibling module cannot
747/// reach a private item. Making it `pub(crate)` is an edit to `c3`'s file, which
748/// this task does not own, so the choice was between duplicating `c3`'s whole
749/// rate-limit *policy* here and consuming what `c3` already exports. This
750/// consumes.
751///
752/// # What "consuming `c3`'s rate-limit policy" means precisely
753///
754/// [`RateLimited::detect`] is `c3`'s decision procedure for whether a failure is
755/// a rate limit and which of GitHub's two it is, including the part that keeps a
756/// permissions `403` landing on an exhausted quota from being misreported as
757/// one. It is public, and this module calls it rather than re-deciding.
758///
759/// What this module deliberately does **not** copy is `c3`'s in-gateway
760/// *back-off latch*, the window during which `RestInventory` opens no socket at
761/// all. A second latch would be a second, quietly divergent copy of a policy
762/// that only works if there is one of it. The scheduling floor is
763/// [`crate::rest::RefreshState::retry_delay`], which `c3` documents as "the
764/// **absolute floor** on when `e1` may try again" — an `e1` honouring it stops
765/// demand polling for the same window, from the layer that owns the schedule.
766///
767/// The residual gap is stated rather than hidden: a rate limit whose *first*
768/// evidence arrives on a demand request does not silence `RestInventory`, and
769/// vice versa. Both report [`InventoryError::RateLimited`] to `e1`, which is the
770/// layer that can act on either.
771pub struct RestDemand {
772    client: Arc<AuthenticatedClient>,
773    clock: Arc<dyn Clock>,
774    requests_issued: AtomicU64,
775    /// Every listing's `ETag` and body, so a repeat poll can be answered `304`.
776    conditional: ConditionalCache,
777    /// One repository's reading, shared between every target that covers it.
778    shared: SharedReadings,
779    /// The host-wide quiet period after GitHub said "rate limited".
780    hold: Mutex<Option<RateLimitHold>>,
781    stats: PollCounters,
782}
783
784/// A repository's reading, handed to every target that asks for it within a
785/// window.
786///
787/// # Why: one poll per repository, not one per target
788///
789/// The daemon runs one loop per target, and two targets can cover the same
790/// repository: an organization target and a repository target inside it.
791/// Each loop used to poll for itself. With one `RestDemand` shared by every
792/// loop on the host and a window here, a repository is read **once** per
793/// window however many targets, policies and profiles want it, and the others
794/// are handed that reading.
795///
796/// A zero window, the default, shares nothing: every call reads GitHub. That
797/// is what a single-target caller and the tests of the reading itself want.
798struct SharedReadings {
799    /// The window, in milliseconds. Atomic so the daemon can change it when
800    /// the host's idle interval changes, without stopping a loop.
801    window_ms: AtomicU64,
802    /// Pruned of readings older than [`HOLD_MEMORY`] on every insert.
803    readings: Mutex<BTreeMap<OwnerRepo, (Timestamp, RepositoryDemand)>>,
804    /// One gate per repository, so two loops asking at once make one request
805    /// between them.
806    gates: Mutex<BTreeMap<OwnerRepo, Arc<tokio::sync::Mutex<()>>>>,
807}
808
809/// How long an ended rate-limit hold is still remembered as part of the same
810/// episode, and how long an unused shared reading is kept.
811const HOLD_MEMORY: Duration = Duration::from_secs(60 * 60);
812
813/// The rate-limit quiet period, host-wide.
814#[derive(Debug, Clone, Copy)]
815struct RateLimitHold {
816    until: Timestamp,
817    limit: RateLimited,
818    /// Secondary limits in a row. GitHub: "If your request continues to fail
819    /// due to a secondary rate limit, wait for an exponentially increasing
820    /// amount of time between retries."
821    consecutive_secondary: u32,
822}
823
824#[derive(Debug, Default)]
825struct PollCounters {
826    reads: AtomicU64,
827    unchanged_reads: AtomicU64,
828    shared_reads: AtomicU64,
829    suppressed: AtomicU64,
830}
831
832/// What [`RestDemand`] has done, for `status` and `doctor`.
833#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
834pub struct DemandStats {
835    /// Repository readings fetched from GitHub.
836    pub reads: u64,
837    /// Of those, readings where every listing came back `304`.
838    pub unchanged_reads: u64,
839    /// Readings handed to another target instead of being fetched again.
840    pub shared_reads: u64,
841    /// Polls answered "rate limited" without a request, inside the host-wide
842    /// quiet period.
843    pub suppressed: u64,
844}
845
846impl fmt::Debug for RestDemand {
847    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
848        // Hand-written for the reason `AuthenticatedClient`'s own `Debug`
849        // records: nothing here may render a credential, and a derive on a type
850        // holding a client is how that is lost.
851        f.debug_struct("RestDemand")
852            .field(
853                "requests_issued",
854                &self.requests_issued.load(Ordering::Relaxed),
855            )
856            .finish_non_exhaustive()
857    }
858}
859
860impl RestDemand {
861    #[must_use]
862    pub fn new(client: Arc<AuthenticatedClient>, clock: Arc<dyn Clock>) -> Self {
863        Self {
864            conditional: ConditionalCache::new(Arc::clone(&clock)),
865            client,
866            clock,
867            requests_issued: AtomicU64::new(0),
868            shared: SharedReadings {
869                window_ms: AtomicU64::new(0),
870                readings: Mutex::new(BTreeMap::new()),
871                gates: Mutex::new(BTreeMap::new()),
872            },
873            hold: Mutex::new(None),
874            stats: PollCounters::default(),
875        }
876    }
877
878    /// Hand a repository's reading to every target that asks within `window`
879    /// of it being fetched. See [`SharedReadings`].
880    ///
881    /// The daemon passes its idle interval: a repository is then read at most
882    /// once per idle interval, and a target sharing it sees a reading no older
883    /// than that.
884    #[must_use]
885    pub fn with_shared_readings(self, window: Duration) -> Self {
886        self.set_shared_window(window);
887        self
888    }
889
890    /// Change the sharing window while running.
891    pub fn set_shared_window(&self, window: Duration) {
892        self.shared.window_ms.store(
893            u64::try_from(window.as_millis()).unwrap_or(u64::MAX),
894            Ordering::SeqCst,
895        );
896    }
897
898    /// Drops `repository`'s shared reading, so the next target to ask for it
899    /// reads GitHub instead of being handed a reading from before.
900    ///
901    /// For a coordinator's wake: GitHub just said this repository's queue
902    /// changed, so a reading shared from a few seconds ago is known stale.
903    /// Nothing else is bypassed — the conditional cache still makes an
904    /// unchanged listing a free `304`, and the rate-limit quiet period still
905    /// withholds every request inside it.
906    pub fn forget_shared_reading(&self, repository: &OwnerRepo) {
907        self.shared
908            .readings
909            .lock()
910            .unwrap_or_else(std::sync::PoisonError::into_inner)
911            .remove(repository);
912    }
913
914    /// The window readings are shared for.
915    fn shared_window(&self) -> Duration {
916        Duration::from_millis(self.shared.window_ms.load(Ordering::SeqCst))
917    }
918
919    /// What this gateway has done.
920    #[must_use]
921    pub fn stats(&self) -> DemandStats {
922        DemandStats {
923            reads: self.stats.reads.load(Ordering::SeqCst),
924            unchanged_reads: self.stats.unchanged_reads.load(Ordering::SeqCst),
925            shared_reads: self.stats.shared_reads.load(Ordering::SeqCst),
926            suppressed: self.stats.suppressed.load(Ordering::SeqCst),
927        }
928    }
929
930    /// The client every request goes through, for its traffic counters.
931    #[must_use]
932    pub const fn client(&self) -> &Arc<AuthenticatedClient> {
933        &self.client
934    }
935
936    /// The rate limit still being waited out, with the time left on it.
937    fn held(&self, now: Timestamp) -> Option<RateLimited> {
938        let hold = (*self
939            .hold
940            .lock()
941            .unwrap_or_else(std::sync::PoisonError::into_inner))?;
942        let left = hold
943            .until
944            .signed_duration_since(now)
945            .to_std()
946            .ok()?
947            // A clock that jumped backwards must not stretch the hold past
948            // the longest one this gateway ever sets.
949            .min(MAX_RATE_LIMIT_BACKOFF);
950        if left.is_zero() {
951            return None;
952        }
953        Some(RateLimited {
954            retry_after: Some(left),
955            ..hold.limit
956        })
957    }
958
959    /// Start, or lengthen, the host-wide quiet period for `limit`, and say
960    /// how long it is.
961    ///
962    /// # Why a quiet period here, when `e1` already waits
963    ///
964    /// `e1`'s schedule waits per loop, and the daemon runs one loop per
965    /// target. A secondary limit hit by one target's poll used to leave every
966    /// other target's loop polling on its own schedule, which is exactly the
967    /// "continuing to make requests while you are rate limited" GitHub warns
968    /// "may result in the banning of your integration"
969    /// (<https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api#handle-rate-limit-errors-appropriately>).
970    /// Every loop shares this gateway, so a quiet period here silences them
971    /// all.
972    ///
973    /// The wait is GitHub's own: `retry-after` when sent, else until
974    /// `x-ratelimit-reset` for the primary limit, else one minute. A secondary
975    /// limit that recurs doubles it each time, up to
976    /// [`MAX_RATE_LIMIT_BACKOFF`], which is GitHub's "exponentially increasing
977    /// amount of time between retries".
978    fn hold_for(&self, limit: RateLimited, now: Timestamp) -> RateLimited {
979        let mut hold = self
980            .hold
981            .lock()
982            .unwrap_or_else(std::sync::PoisonError::into_inner);
983        // A hold that ended long ago is not part of this episode: a secondary
984        // limit days after the last one starts from one minute again, even if
985        // nothing but errors came back in between.
986        let previous = (*hold).filter(|previous| {
987            now.signed_duration_since(previous.until)
988                < chrono::TimeDelta::from_std(HOLD_MEMORY).unwrap_or(chrono::TimeDelta::MAX)
989        });
990        // The doubling counts EPISODES, not answers. Every target loop shares
991        // this gateway, so one secondary limit can come back on several
992        // requests that were in flight together; each of those is the same
993        // limit, and only an answer arriving after the previous hold ended is
994        // GitHub saying "still limited".
995        let live = previous.filter(|previous| previous.until > now);
996        let consecutive_secondary = match (limit.kind, previous, live) {
997            (RateLimitKind::Primary, _, _) => 0,
998            (RateLimitKind::Secondary, _, Some(live)) => live.consecutive_secondary.max(1),
999            (RateLimitKind::Secondary, Some(previous), None) => {
1000                previous.consecutive_secondary.saturating_add(1)
1001            }
1002            (RateLimitKind::Secondary, None, None) => 1,
1003        };
1004        let mut wait = limit.delay_from(now);
1005        if consecutive_secondary > 1 {
1006            let doubled = DEFAULT_RATE_LIMIT_BACKOFF
1007                .saturating_mul(1_u32 << (consecutive_secondary - 1).min(10));
1008            wait = wait.max(doubled).min(MAX_RATE_LIMIT_BACKOFF);
1009        }
1010        let mut until = now
1011            .checked_add_signed(chrono::TimeDelta::from_std(wait).unwrap_or(chrono::TimeDelta::MAX))
1012            .unwrap_or(now);
1013        // A later answer never SHORTENS a hold in force: a primary limit with
1014        // a near reset must not reopen traffic a secondary limit closed.
1015        if let Some(live) = live
1016            && live.until > until
1017        {
1018            until = live.until;
1019            wait = until.signed_duration_since(now).to_std().unwrap_or(wait);
1020        }
1021        *hold = Some(RateLimitHold {
1022            until,
1023            limit,
1024            consecutive_secondary: consecutive_secondary
1025                .max(live.map_or(0, |live| live.consecutive_secondary)),
1026        });
1027        RateLimited {
1028            retry_after: Some(wait),
1029            ..limit
1030        }
1031    }
1032
1033    /// A request went through: the next secondary limit starts the doubling
1034    /// again from one minute.
1035    fn clear_hold(&self) {
1036        let mut hold = self
1037            .hold
1038            .lock()
1039            .unwrap_or_else(std::sync::PoisonError::into_inner);
1040        if hold.is_some_and(|h| h.until <= self.clock.now()) {
1041            *hold = None;
1042        }
1043    }
1044
1045    /// How many HTTP requests this gateway has issued.
1046    ///
1047    /// [`demand_requests_per_poll`] projects a cost and this measures it. A
1048    /// projection nothing measures is a table in a document, which is why `c3`
1049    /// exposes the same counter and why the tests below assert one against the
1050    /// other.
1051    #[must_use]
1052    pub fn requests_issued(&self) -> u64 {
1053        self.requests_issued.load(Ordering::SeqCst)
1054    }
1055
1056    /// One request, with cancellation applied and a rate limit recognised.
1057    ///
1058    /// Cancellation is consulted twice for `c3`'s reason, and the two are not
1059    /// redundant: [`CancelToken::check`] stops a multi-page walk *between*
1060    /// pages, and [`CancelToken::run`] stops one already blocked on a socket.
1061    ///
1062    /// Every request is **conditional** once its listing has been seen: see
1063    /// [`crate::conditional`]. And none is sent at all inside the host-wide
1064    /// rate-limit quiet period; see [`Self::hold_for`].
1065    async fn get(
1066        &self,
1067        request: &ApiRequest,
1068        cancel: &CancelToken,
1069    ) -> Result<Fetched, InventoryError> {
1070        cancel.check()?;
1071        if let Some(limit) = self.held(self.clock.now()) {
1072            self.stats.suppressed.fetch_add(1, Ordering::SeqCst);
1073            return Err(InventoryError::RateLimited(limit));
1074        }
1075
1076        let result = cancel
1077            .run(async {
1078                // Counted inside the future, so the count is of requests
1079                // actually attempted: `run`'s biased `select!` answers
1080                // `Cancelled` without polling this block when the token is
1081                // already flipped, and no socket is opened.
1082                self.requests_issued.fetch_add(1, Ordering::SeqCst);
1083                self.conditional
1084                    .send(&self.client, request)
1085                    .await
1086                    .map_err(InventoryError::from)
1087            })
1088            .await;
1089        match result {
1090            Ok(fetched) => {
1091                self.clear_hold();
1092                Ok(fetched)
1093            }
1094            Err(InventoryError::Github(error)) => Err(self.classify(error)),
1095            Err(other) => Err(other),
1096        }
1097    }
1098
1099    /// Turn a failure into a rate limit when GitHub's own evidence says it is
1100    /// one, using `c3`'s decision procedure rather than a second one, and
1101    /// start the host-wide quiet period for it.
1102    fn classify(&self, error: GithubError) -> InventoryError {
1103        let Some(limit) = RateLimited::detect(&error) else {
1104            return InventoryError::Github(error);
1105        };
1106        let held = self.hold_for(limit, self.clock.now());
1107        tracing::warn!(
1108            kind = %limit.kind,
1109            remaining = limit.remaining,
1110            quiet_secs = held.retry_after.map_or(0, |wait| wait.as_secs()),
1111            "GitHub is rate limiting this credential; demand for this poll is unknown, not zero, \
1112             and no demand request leaves this host until the quiet period ends"
1113        );
1114        InventoryError::RateLimited(held)
1115    }
1116
1117    /// Queued **jobs** for one repository, each with the `runs-on` it requires.
1118    ///
1119    /// Two run listings, then one job listing per run either cap admits. See
1120    /// [`DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL`] for what that costs in steady
1121    /// state and [`max_demand_requests_per_repository_per_poll`] for the bound.
1122    ///
1123    /// # The order of the two passes is load-bearing
1124    ///
1125    /// Queued runs are resolved first and in-progress runs second, because the
1126    /// caps are spent in that order and the first pass carries the signal. A
1127    /// repository busy enough to exhaust the queued cap gets no in-progress pass
1128    /// at all, which is the right trade: its count is already a floor above any
1129    /// realistic host ceiling, and one more `needs:`-gated job cannot change the
1130    /// allocation.
1131    ///
1132    /// # Why a run is resolved at most once, though the two queries are disjoint
1133    ///
1134    /// They are disjoint *at any one instant*, and these are two requests with
1135    /// time between them. A run that is `queued` when the first is answered and
1136    /// `in_progress` when the second is — which is precisely what happens when
1137    /// its last waiting job gets picked up, so it is the common transition
1138    /// rather than an exotic one — appears in **both** lists. Listing its jobs
1139    /// twice would count every queued job it still holds twice, and demand that
1140    /// double-counts starts runners for work that does not exist.
1141    ///
1142    /// The seen set is what stops that. It is deliberately not an argument that
1143    /// the race is too narrow to matter: the window is one HTTP round trip
1144    /// against a poll that repeats every sixty seconds forever, and the failure
1145    /// it produces is silent inflation rather than an error.
1146    async fn repository_queued(
1147        &self,
1148        repository: &OwnerRepo,
1149        cancel: &CancelToken,
1150    ) -> Result<RepositoryDemand, InventoryError> {
1151        let mut jobs: Vec<(RunsOn, QueuedJobIdentity)> = Vec::new();
1152        let mut exact = true;
1153        let mut changed = false;
1154        let mut resolved: BTreeSet<u64> = BTreeSet::new();
1155        let mut run_workflows = BTreeMap::new();
1156
1157        for (status, cap) in [
1158            (QUEUED_RUN_STATUS, MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL),
1159            (
1160                IN_PROGRESS_RUN_STATUS,
1161                MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
1162            ),
1163        ] {
1164            let listing = self.active_runs(repository, status, cap, cancel).await?;
1165            exact &= listing.complete;
1166            changed |= listing.changed;
1167            run_workflows.extend(listing.workflows);
1168
1169            for run_id in listing.run_ids {
1170                // A run that changed status between the two listings is in both.
1171                // Resolving it twice would double every queued job it holds.
1172                if !resolved.insert(run_id) {
1173                    continue;
1174                }
1175                let run = self.queued_jobs_of_run(repository, run_id, cancel).await?;
1176                exact &= run.complete;
1177                changed |= run.changed;
1178                let workflow = run_workflows.get(&run_id);
1179                jobs.extend(run.jobs.into_iter().map(|(job, mut identity)| {
1180                    identity.workflow = workflow.cloned();
1181                    (job, identity)
1182                }));
1183            }
1184        }
1185
1186        self.stats.reads.fetch_add(1, Ordering::SeqCst);
1187        if !changed {
1188            self.stats.unchanged_reads.fetch_add(1, Ordering::SeqCst);
1189        }
1190        let (jobs, identities) = jobs.into_iter().unzip();
1191        Ok(RepositoryDemand {
1192            jobs,
1193            identities,
1194            run_workflows,
1195            exact,
1196            busy: changed && !resolved.is_empty(),
1197        })
1198    }
1199
1200    /// One repository's reading, from the shared window when another target
1201    /// fetched it recently enough, from GitHub otherwise.
1202    async fn repository_reading(
1203        &self,
1204        repository: &OwnerRepo,
1205        cancel: &CancelToken,
1206    ) -> Result<RepositoryDemand, InventoryError> {
1207        let window = self.shared_window();
1208        if window.is_zero() {
1209            return self.repository_queued(repository, cancel).await;
1210        }
1211        let gate = Arc::clone(
1212            self.shared
1213                .gates
1214                .lock()
1215                .unwrap_or_else(std::sync::PoisonError::into_inner)
1216                .entry(repository.clone())
1217                .or_default(),
1218        );
1219        // Held across the fetch, so a second loop asking for this repository
1220        // meanwhile waits for this reading instead of making its own request.
1221        let _gate = cancel.run(async { Ok(gate.lock().await) }).await?;
1222        let now = self.clock.now();
1223        let fresh = self
1224            .shared
1225            .readings
1226            .lock()
1227            .unwrap_or_else(std::sync::PoisonError::into_inner)
1228            .get(repository)
1229            .filter(|(at, _)| {
1230                now.signed_duration_since(*at)
1231                    .to_std()
1232                    .is_ok_and(|age| age < window)
1233            })
1234            .map(|(_, reading)| reading.clone());
1235        if let Some(reading) = fresh {
1236            self.stats.shared_reads.fetch_add(1, Ordering::SeqCst);
1237            return Ok(reading);
1238        }
1239        let reading = self.repository_queued(repository, cancel).await?;
1240        let mut readings = self
1241            .shared
1242            .readings
1243            .lock()
1244            .unwrap_or_else(std::sync::PoisonError::into_inner);
1245        readings.insert(repository.clone(), (now, reading.clone()));
1246        // A repository that left an organization's list stops being asked
1247        // for; its reading goes once nobody could use it.
1248        readings.retain(|_, (at, _)| {
1249            now.signed_duration_since(*at)
1250                .to_std()
1251                .is_ok_and(|age| age < HOLD_MEMORY)
1252        });
1253        Ok(reading)
1254    }
1255
1256    /// The ids of one repository's runs in `status`, up to `cap`.
1257    ///
1258    /// **One request, never a page walk.** Both caps are far below [`PER_PAGE`],
1259    /// so the first page always carries more run ids than this may use, and a
1260    /// second page could only contain runs that are already past the cap. That
1261    /// is the whole reason the caps are stated in *runs* rather than in pages:
1262    /// it turns the run listing back into the fixed one-request cost the
1263    /// previous owner decision valued, and spends the variable cost only where
1264    /// it buys the job-level count.
1265    async fn active_runs(
1266        &self,
1267        repository: &OwnerRepo,
1268        status: &str,
1269        cap: usize,
1270        cancel: &CancelToken,
1271    ) -> Result<RunListing, InventoryError> {
1272        let request = ApiRequest::get(format!(
1273            "/repos/{}/{}/actions/runs",
1274            repository.owner(),
1275            repository.repo()
1276        ))
1277        .query("status", status)
1278        .query("per_page", PER_PAGE);
1279
1280        let Fetched {
1281            response,
1282            not_modified,
1283        } = self.get(&request, cancel).await?;
1284        let has_next_page = response.next_page().is_some();
1285        let page: QueuedRunsPage = response.json()?;
1286
1287        let listed = page.workflow_runs.len();
1288        if let Some(total) = page.total_count
1289            && !has_next_page
1290            && total != listed as u64
1291        {
1292            // Free, and no longer load-bearing: see `MAX_BENIGN_TOTAL_COUNT_SKEW`
1293            // for why this is a log line and not the `debug_assert!` it was
1294            // while `total_count` *was* the demand number. The two levels draw
1295            // the same distinction that constant does. A handful over is the
1296            // documented race of a run leaving the queue mid-serialisation,
1297            // which this module's own documentation calls legitimate — warning
1298            // on it every poll is how an operator learns to ignore the warning.
1299            // Thousands over is the unfiltered lifetime total, which is a real
1300            // finding about the API.
1301            if total > (listed as u64).saturating_add(MAX_BENIGN_TOTAL_COUNT_SKEW) {
1302                tracing::warn!(
1303                    repository = %repository,
1304                    status,
1305                    total_count = total,
1306                    listed,
1307                    "GitHub's `total_count` is far larger than the single page it sent for \
1308                     a filtered query, so it is not the filtered count. Demand is counted \
1309                     from the jobs and does not depend on this field, but `c3` reads the \
1310                     same envelope for a dashboard number and does"
1311                );
1312            } else {
1313                tracing::debug!(
1314                    repository = %repository,
1315                    status,
1316                    total_count = total,
1317                    listed,
1318                    "GitHub's `total_count` disagrees slightly with the page it arrived \
1319                     with; this is the documented race of a run leaving the queue while \
1320                     the response was being built"
1321                );
1322            }
1323        }
1324
1325        let run_ids: Vec<u64> = page
1326            .workflow_runs
1327            .iter()
1328            .take(cap)
1329            .map(|run| run.id)
1330            .collect();
1331        let workflows = page
1332            .workflow_runs
1333            .into_iter()
1334            .filter_map(|run| Some((run.id, run.path?)))
1335            .collect();
1336
1337        Ok(RunListing {
1338            // More runs exist than this pass will resolve, so the job count it
1339            // produces is a floor. Both conditions matter: `listed > cap` is the
1340            // ordinary case, and `has_next_page` catches a repository whose
1341            // first page was itself short of the whole filtered set.
1342            complete: listed <= cap && !has_next_page,
1343            run_ids,
1344            workflows,
1345            changed: !not_modified,
1346        })
1347    }
1348
1349    /// One run's jobs that are still waiting for a runner.
1350    ///
1351    /// Jobs in any other state are dropped here rather than downstream, because
1352    /// "queued" is what makes a job demand and a caller holding a mixed list
1353    /// would have to re-derive that. The `runs-on` is rebuilt from the job's
1354    /// `labels` array — which is the array form, so [`RunsOn::from_job_labels`]
1355    /// is the constructor `b1` provides for exactly this.
1356    async fn queued_jobs_of_run(
1357        &self,
1358        repository: &OwnerRepo,
1359        run_id: u64,
1360        cancel: &CancelToken,
1361    ) -> Result<RunJobs, InventoryError> {
1362        let mut request = Some(
1363            ApiRequest::get(format!(
1364                "/repos/{}/{}/actions/runs/{run_id}/jobs",
1365                repository.owner(),
1366                repository.repo()
1367            ))
1368            .query("filter", LATEST_JOBS_FILTER)
1369            .query("per_page", PER_PAGE),
1370        );
1371
1372        let mut jobs = Vec::new();
1373        let mut pages = 0_usize;
1374        let mut changed = false;
1375
1376        while let Some(next) = request.take() {
1377            if pages >= MAX_JOB_PAGES_PER_RUN {
1378                tracing::warn!(
1379                    repository = %repository,
1380                    run_id,
1381                    pages,
1382                    "stopped listing one run's jobs at the page budget; the queued-job \
1383                     count for this repository is a floor, not a total"
1384                );
1385                return Ok(RunJobs {
1386                    jobs,
1387                    complete: false,
1388                    changed,
1389                });
1390            }
1391
1392            let Fetched {
1393                response,
1394                not_modified,
1395            } = self.get(&next, cancel).await?;
1396            changed |= !not_modified;
1397            let following = response
1398                .next_page()
1399                .map(|url| ApiRequest::get(url.as_str()));
1400            let page: RunJobsPage = response.json()?;
1401            pages += 1;
1402
1403            jobs.extend(
1404                page.jobs
1405                    .into_iter()
1406                    .filter(|job| job.status == QUEUED_JOB_STATUS)
1407                    .map(|job| {
1408                        (
1409                            RunsOn::from_job_labels(job.labels),
1410                            QueuedJobIdentity {
1411                                id: job.id,
1412                                name: job.name,
1413                                workflow: None,
1414                            },
1415                        )
1416                    }),
1417            );
1418
1419            request = following;
1420        }
1421
1422        Ok(RunJobs {
1423            jobs,
1424            complete: true,
1425            changed,
1426        })
1427    }
1428}
1429
1430/// Whether a per-repository failure should be recorded and stepped over, or
1431/// should abort the whole aggregate.
1432///
1433/// The same line `c3` draws in its private `is_repository_local_failure`, and
1434/// repeated here rather than shared because that function lives in a file this
1435/// task does not own. The line is between a fact about *that repository* — a
1436/// `404` for one deleted or renamed, a plain `403` for one with Actions disabled
1437/// — and a fact about the credential or the connection, which stepping over
1438/// would turn into a total short by an unknown amount while looking complete.
1439///
1440/// It applies to an aggregate only. Stepping over the only repository in scope
1441/// would turn a permissions failure into demand `0`, and `e1` would then read
1442/// "nothing queued" for a target it cannot see at all — so the caller checks
1443/// [`TargetScope`] first.
1444fn is_repository_local_failure(error: &InventoryError) -> bool {
1445    match error {
1446        InventoryError::Github(GithubError::Forbidden { .. }) => true,
1447        InventoryError::Github(GithubError::Status { status, .. }) => *status == 404,
1448        _ => false,
1449    }
1450}
1451
1452#[async_trait::async_trait]
1453impl DemandGateway for RestDemand {
1454    async fn queued_demand(
1455        &self,
1456        scope: &ActivityScope,
1457        cancel: &CancelToken,
1458    ) -> Result<QueuedDemand, InventoryError> {
1459        let mut demand = QueuedDemand::default();
1460        // Only an aggregate steps over a bad repository; see
1461        // `is_repository_local_failure`.
1462        let aggregating = scope.target().scope() == TargetScope::Organization;
1463        self.conditional.evict_idle();
1464
1465        for repository in scope.repositories() {
1466            match self.repository_reading(repository, cancel).await {
1467                Ok(reading) => {
1468                    if reading.busy {
1469                        demand.busy.insert(repository.clone());
1470                    }
1471                    demand
1472                        .per_repository
1473                        .insert(repository.clone(), reading.jobs);
1474                    demand
1475                        .identities
1476                        .insert(repository.clone(), reading.identities);
1477                    demand
1478                        .run_workflows
1479                        .insert(repository.clone(), reading.run_workflows);
1480                    if !reading.exact {
1481                        // A floor travels with the aggregate rather than being
1482                        // flattened into it: one truncated repository makes the
1483                        // total a floor too, and nothing downstream has another
1484                        // way to know.
1485                        demand.truncated.insert(repository.clone());
1486                    }
1487                }
1488                Err(error) if aggregating && is_repository_local_failure(&error) => {
1489                    tracing::warn!(
1490                        repository = %repository,
1491                        error = %error,
1492                        "a repository in this organization could not be polled for demand; \
1493                         the aggregate reports it as unavailable rather than as zero"
1494                    );
1495                    demand.unavailable.push(UnavailableRepository {
1496                        repository: repository.clone(),
1497                        reason: error.to_string(),
1498                    });
1499                }
1500                Err(error) => return Err(error),
1501            }
1502        }
1503
1504        demand.rate_limit = self.client.traffic().latest_rate_limit();
1505        Ok(demand)
1506    }
1507
1508    async fn run_workflow(
1509        &self,
1510        repository: &OwnerRepo,
1511        run_id: u64,
1512        cancel: &CancelToken,
1513    ) -> Result<Option<String>, InventoryError> {
1514        let request = ApiRequest::get(format!(
1515            "/repos/{}/{}/actions/runs/{run_id}",
1516            repository.owner(),
1517            repository.repo()
1518        ));
1519        let Fetched { response, .. } = self.get(&request, cancel).await?;
1520        let run: QueuedRun = response.json()?;
1521        Ok(run.path)
1522    }
1523
1524    fn now(&self) -> Timestamp {
1525        self.clock.now()
1526    }
1527}
1528
1529// ---------------------------------------------------------------------------
1530// Wire shapes
1531// ---------------------------------------------------------------------------
1532
1533/// Which job a queued entry is, as GitHub's job listing names it: the same id
1534/// and name a `workflow_job` webhook carries. Not its `workflow_name`, which is
1535/// the run's title and changes at every dispatch of a workflow with a `run-name`.
1536#[derive(Debug, Clone, PartialEq, Eq)]
1537pub struct QueuedJobIdentity {
1538    pub id: Option<u64>,
1539    /// The job's name, matrix values expanded.
1540    pub name: Option<String>,
1541    /// Its run's workflow file (`.github/workflows/ci.yml`), which, unlike the
1542    /// title, does not change between dispatches.
1543    pub workflow: Option<String>,
1544}
1545
1546/// One repository's queued jobs, and whether that set is the whole truth.
1547#[derive(Debug, Clone, PartialEq, Eq)]
1548struct RepositoryDemand {
1549    /// The `runs-on` of every job still waiting for a runner.
1550    jobs: Vec<RunsOn>,
1551    /// Which job each of `jobs` is, in the same order.
1552    identities: Vec<QueuedJobIdentity>,
1553    /// The workflow file of each run listed.
1554    run_workflows: BTreeMap<u64, String>,
1555    /// `false` when `jobs` is a **floor**: a run cap or a job page budget
1556    /// stopped the walk before the whole queue had been seen.
1557    exact: bool,
1558    /// Runs were under way and at least one listing changed since the last
1559    /// poll. See [`QueuedDemand::is_quiet`].
1560    busy: bool,
1561}
1562
1563/// One run listing's ids, and whether the cap left any behind.
1564#[derive(Debug, Clone, PartialEq, Eq)]
1565struct RunListing {
1566    run_ids: Vec<u64>,
1567    workflows: BTreeMap<u64, String>,
1568    complete: bool,
1569    /// `false` when GitHub answered `304`.
1570    changed: bool,
1571}
1572
1573/// One run's queued jobs, and whether the page budget saw all of them.
1574#[derive(Debug, Clone, PartialEq, Eq)]
1575struct RunJobs {
1576    jobs: Vec<(RunsOn, QueuedJobIdentity)>,
1577    complete: bool,
1578    /// `false` when every page was answered `304`.
1579    changed: bool,
1580}
1581
1582/// One page of `GET …/actions/runs?status=…`.
1583///
1584/// Only the `id` is read, and it is read because the job listing needs it. That
1585/// is the field the previous owner decision deliberately did not have — the
1586/// module documentation used to point at `jobs_url` and ask nobody to follow it
1587/// — and reversing that decision is what put it here.
1588#[derive(Debug, Deserialize)]
1589struct QueuedRunsPage {
1590    total_count: Option<u64>,
1591    #[serde(default)]
1592    workflow_runs: Vec<QueuedRun>,
1593}
1594
1595/// One workflow run, reduced to the identifier its jobs are fetched by and its
1596/// workflow file.
1597#[derive(Debug, Deserialize)]
1598struct QueuedRun {
1599    id: u64,
1600    #[serde(default)]
1601    path: Option<String>,
1602}
1603
1604/// One page of `GET …/actions/runs/{run_id}/jobs`.
1605#[derive(Debug, Deserialize)]
1606struct RunJobsPage {
1607    #[serde(default)]
1608    jobs: Vec<RunJob>,
1609}
1610
1611/// One job of a run: whether it is still waiting, and what it needs.
1612///
1613/// `labels` is GitHub's flat array form of `runs-on`, which is why
1614/// [`RunsOn::from_job_labels`] exists on `b1`'s side. It defaults to empty
1615/// rather than being required, because a job with no labels is a real answer —
1616/// `b1` reports it as [`runner_manager_domain::policy::UnresolvableRunsOn`] —
1617/// and a missing field should not fail the whole repository's poll.
1618#[derive(Debug, Deserialize)]
1619struct RunJob {
1620    #[serde(default)]
1621    id: Option<u64>,
1622    #[serde(default)]
1623    status: String,
1624    #[serde(default)]
1625    labels: Vec<String>,
1626    #[serde(default)]
1627    name: Option<String>,
1628}
1629
1630// The unit tests below are inline rather than in a `src/demand/tests.rs`, and
1631// that is a constraint rather than a preference: `lib.rs`'s
1632// `the_confidential_credential_scan_covers_every_source_file` walks `src/`
1633// recursively and requires every `.rs` file under it to appear in
1634// `CRATE_SOURCES` — a list in `lib.rs`, which `c2` owns. A second file in this
1635// directory would fail that pin, and the only fix would be editing another
1636// task's file.
1637#[cfg(test)]
1638mod tests {
1639    use super::*;
1640    use crate::testing::{FIXTURE_TOKEN, TestClock};
1641    use crate::{Endpoints, UserAccessToken};
1642    use runner_manager_domain::{
1643        model::{Arch, HostLabel, Label, Org, Os},
1644        policy::{RoutingLabels, RunsOn, RunsOnMatch, UnresolvableRunsOn},
1645    };
1646    use secrecy::SecretString;
1647    use serde_json::json;
1648    use wiremock::{
1649        Mock, MockServer, ResponseTemplate,
1650        matchers::{method, path, query_param},
1651    };
1652
1653    fn repo() -> OwnerRepo {
1654        OwnerRepo::parse("octo/dashboard").expect("a valid owner/repo")
1655    }
1656
1657    fn other_repo() -> OwnerRepo {
1658        OwnerRepo::parse("octo/api").expect("a valid owner/repo")
1659    }
1660
1661    fn third_repo() -> OwnerRepo {
1662        OwnerRepo::parse("octo/tools").expect("a valid owner/repo")
1663    }
1664
1665    fn org_scope(repositories: impl IntoIterator<Item = OwnerRepo>) -> ActivityScope {
1666        ActivityScope::organization(
1667            Org::new("octo-org").expect("a valid organization login"),
1668            repositories,
1669        )
1670    }
1671
1672    fn gateway(server: &MockServer) -> RestDemand {
1673        let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
1674        let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
1675        let client = AuthenticatedClient::new(endpoints, token, Arc::new(TestClock::default()))
1676            .expect("a client over the test server");
1677        RestDemand::new(Arc::new(client), Arc::new(TestClock::default()))
1678    }
1679
1680    // -- fixtures -----------------------------------------------------------
1681
1682    /// A run list carrying `ids`, and a `total_count` that agrees with it.
1683    fn runs_body(ids: &[u64]) -> serde_json::Value {
1684        json!({
1685            "total_count": ids.len(),
1686            "workflow_runs": ids.iter().map(|id| json!({ "id": id })).collect::<Vec<_>>()
1687        })
1688    }
1689
1690    /// An empty run list, which is what an idle repository answers.
1691    fn no_runs() -> serde_json::Value {
1692        runs_body(&[])
1693    }
1694
1695    /// A jobs page: `queued` jobs carrying `labels`, then `running` that do not
1696    /// count.
1697    fn jobs_body(labels: &[&str], queued: usize, running: usize) -> serde_json::Value {
1698        let mut jobs = Vec::new();
1699        for _ in 0..queued {
1700            jobs.push(json!({ "status": "queued", "labels": labels }));
1701        }
1702        for _ in 0..running {
1703            jobs.push(json!({ "status": "in_progress", "labels": labels }));
1704        }
1705        json!({ "total_count": jobs.len(), "jobs": jobs })
1706    }
1707
1708    fn runs_path(repository: &OwnerRepo) -> String {
1709        format!(
1710            "/repos/{}/{}/actions/runs",
1711            repository.owner(),
1712            repository.repo()
1713        )
1714    }
1715
1716    fn jobs_path(repository: &OwnerRepo, run_id: u64) -> String {
1717        format!("{}/{run_id}/jobs", runs_path(repository))
1718    }
1719
1720    /// Mount one repository's run list for one status filter.
1721    async fn mount_runs(
1722        server: &MockServer,
1723        repository: &OwnerRepo,
1724        status: &str,
1725        body: serde_json::Value,
1726    ) {
1727        Mock::given(method("GET"))
1728            .and(path(runs_path(repository)))
1729            .and(query_param("status", status))
1730            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1731            .mount(server)
1732            .await;
1733    }
1734
1735    /// Mount one run's job list.
1736    async fn mount_jobs(
1737        server: &MockServer,
1738        repository: &OwnerRepo,
1739        run_id: u64,
1740        body: serde_json::Value,
1741    ) {
1742        Mock::given(method("GET"))
1743            .and(path(jobs_path(repository, run_id)))
1744            .respond_with(ResponseTemplate::new(200).set_body_json(body))
1745            .mount(server)
1746            .await;
1747    }
1748
1749    /// The ordinary shape: one queued run of `queued` + `running` jobs, and an
1750    /// empty in-progress list.
1751    async fn mount_one_queued_run(
1752        server: &MockServer,
1753        repository: &OwnerRepo,
1754        labels: &[&str],
1755        queued: usize,
1756        running: usize,
1757    ) {
1758        mount_runs(server, repository, QUEUED_RUN_STATUS, runs_body(&[100])).await;
1759        mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1760        mount_jobs(server, repository, 100, jobs_body(labels, queued, running)).await;
1761    }
1762
1763    /// A repository with nothing waiting: two run listings, no job listings.
1764    async fn mount_idle(server: &MockServer, repository: &OwnerRepo) {
1765        mount_runs(server, repository, QUEUED_RUN_STATUS, no_runs()).await;
1766        mount_runs(server, repository, IN_PROGRESS_RUN_STATUS, no_runs()).await;
1767    }
1768
1769    /// The routing labels of a realistic policy on this host.
1770    ///
1771    /// Deliberately **not** a bare [`RoutingLabels::derive`]. That produces the
1772    /// derived host label alone, and a job written `runs-on: [self-hosted,
1773    /// windows]` — the shape people actually write — requires labels a bare
1774    /// derived set does not carry, so it would not match. An operator adds those
1775    /// with `repo add --label`, and a fixture that skipped them would test the
1776    /// filtering against a policy nobody configures.
1777    fn host_labels() -> RoutingLabels {
1778        RoutingLabels::from_parts(
1779            Label::new("rm-home-win-x64").expect("a valid label"),
1780            [
1781                Label::new("self-hosted").expect("a valid label"),
1782                Label::new("windows").expect("a valid label"),
1783            ],
1784        )
1785    }
1786
1787    // -- the count itself ---------------------------------------------------
1788
1789    /// The defect this module was rewritten for, as an executable statement.
1790    ///
1791    /// One queued run holding eight matrix jobs. Under the previous owner
1792    /// decision this repository reported demand `1`, `e1` started one runner,
1793    /// and the other seven jobs waited for a machine that was sitting idle. It
1794    /// must now report `8`.
1795    #[tokio::test]
1796    async fn a_matrix_run_of_eight_jobs_is_eight_units_of_demand_and_not_one() {
1797        let server = MockServer::start().await;
1798        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 8, 0).await;
1799        let gateway = gateway(&server);
1800
1801        let demand = gateway
1802            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1803            .await
1804            .expect("a queued-job count");
1805
1806        assert_eq!(
1807            demand.total(),
1808            8,
1809            "eight jobs in one run are eight runners' worth of work; reading the run \
1810             count here is the defect that forced the owner decision back"
1811        );
1812        assert_eq!(demand.for_repository(&repo()), Some(8));
1813        assert!(demand.is_complete());
1814        assert_eq!(
1815            gateway.requests_issued(),
1816            3,
1817            "two run listings and one job listing for the single active run"
1818        );
1819    }
1820
1821    /// A job that already has a runner is not demand.
1822    ///
1823    /// The run-level mistake one level down: `status=in_progress` work already
1824    /// has a runner, and counting it would start a second for the same job.
1825    #[tokio::test]
1826    async fn only_jobs_still_queued_are_counted() {
1827        let server = MockServer::start().await;
1828        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 5).await;
1829        let gateway = gateway(&server);
1830
1831        let demand = gateway
1832            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1833            .await
1834            .expect("a queued-job count");
1835
1836        assert_eq!(
1837            demand.total(),
1838            3,
1839            "five of the eight jobs already have a runner and are not waiting for one"
1840        );
1841    }
1842
1843    /// The labels travel with the job, which is the input `b1`'s predicate never
1844    /// had.
1845    #[tokio::test]
1846    async fn each_queued_job_carries_the_runs_on_it_requires() {
1847        let server = MockServer::start().await;
1848        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100, 101])).await;
1849        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
1850        mount_jobs(
1851            &server,
1852            &repo(),
1853            100,
1854            jobs_body(&["rm-home-win-x64", "self-hosted", "windows"], 2, 0),
1855        )
1856        .await;
1857        mount_jobs(&server, &repo(), 101, jobs_body(&["ubuntu-latest"], 4, 0)).await;
1858        let gateway = gateway(&server);
1859
1860        let demand = gateway
1861            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1862            .await
1863            .expect("a queued-job count");
1864
1865        assert_eq!(
1866            demand.total(),
1867            6,
1868            "the unfiltered depth is every queued job"
1869        );
1870
1871        // And the number `e1` clamps, which is the unfiltered depth put through
1872        // `b1`'s predicate. This gateway does not compute it; it supplies it.
1873        let tally = host_labels().tally(demand.jobs_for(&repo()));
1874        assert_eq!(
1875            tally.demand(),
1876            2,
1877            "the four `ubuntu-latest` jobs are somebody else's work; before the job \
1878             listing existed all six would have driven this policy toward max_capacity"
1879        );
1880        assert_eq!(tally.not_matched, 4);
1881    }
1882
1883    /// The two run statuses are both on the wire, and the job filter with them.
1884    ///
1885    /// The queries are one word apart and answer different questions, so a
1886    /// gateway that sent the wrong one would return a plausible number. The
1887    /// mocks match on the exact status, so sending anything else 404s here
1888    /// rather than passing.
1889    #[tokio::test]
1890    async fn both_run_statuses_are_polled_and_the_jobs_request_asks_for_the_latest_attempt() {
1891        let server = MockServer::start().await;
1892        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
1893        let gateway = gateway(&server);
1894
1895        gateway
1896            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1897            .await
1898            .expect("both filters are mounted");
1899
1900        let requests = server.received_requests().await.expect("recorded requests");
1901        assert_eq!(requests.len(), 3);
1902
1903        let queries: Vec<String> = requests
1904            .iter()
1905            .map(|r| r.url.query().unwrap_or_default().to_string())
1906            .collect();
1907
1908        assert!(
1909            queries.iter().any(|q| q.contains("status=queued")),
1910            "the primary signal is the queued run list; sent {queries:?}"
1911        );
1912        assert!(
1913            queries.iter().any(|q| q.contains("status=in_progress")),
1914            "the safety net catches a `needs:`-gated job whose run has already \
1915             started; sent {queries:?}"
1916        );
1917        assert!(
1918            queries
1919                .iter()
1920                .any(|q| q.contains(&format!("filter={LATEST_JOBS_FILTER}"))),
1921            "`filter=all` would count every attempt of a re-run job as present \
1922             demand; sent {queries:?}"
1923        );
1924        assert!(
1925            queries
1926                .iter()
1927                .all(|q| q.contains(&format!("per_page={PER_PAGE}"))),
1928            "asking for fewer than GitHub's maximum multiplies the request count \
1929             against the budget this module projects; sent {queries:?}"
1930        );
1931    }
1932
1933    /// The queued run list is read before the in-progress one.
1934    ///
1935    /// The order is what spends the caps on the signal rather than on the safety
1936    /// net, and it is the kind of thing that is only true until somebody
1937    /// reorders a literal.
1938    #[tokio::test]
1939    async fn the_queued_run_list_is_read_before_the_in_progress_one() {
1940        let server = MockServer::start().await;
1941        mount_idle(&server, &repo()).await;
1942        let gateway = gateway(&server);
1943
1944        gateway
1945            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1946            .await
1947            .expect("an idle repository still answers");
1948
1949        let requests = server.received_requests().await.expect("recorded requests");
1950        let queries: Vec<String> = requests
1951            .iter()
1952            .map(|r| r.url.query().unwrap_or_default().to_string())
1953            .collect();
1954        assert_eq!(queries.len(), 2, "an idle repository lists no run's jobs");
1955        assert!(
1956            queries[0].contains("status=queued"),
1957            "the primary signal is read first; sent {queries:?}"
1958        );
1959        assert!(
1960            queries[1].contains("status=in_progress"),
1961            "the safety net is read second; sent {queries:?}"
1962        );
1963    }
1964
1965    /// An idle repository costs the two listings and nothing more.
1966    ///
1967    /// The steady-state figure the budget model prices, asserted against what
1968    /// the gateway really spends rather than left as a claim in a doc comment.
1969    #[tokio::test]
1970    async fn an_idle_repository_costs_only_the_two_run_listings() {
1971        let server = MockServer::start().await;
1972        mount_idle(&server, &repo()).await;
1973        let gateway = gateway(&server);
1974
1975        let demand = gateway
1976            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
1977            .await
1978            .expect("an idle repository answers zero rather than failing");
1979
1980        assert_eq!(demand.total(), 0);
1981        assert!(
1982            demand.is_complete(),
1983            "zero from a repository that answered is a measurement, not a floor"
1984        );
1985        assert_eq!(gateway.requests_issued(), 2);
1986        assert!(
1987            gateway.requests_issued() <= u64::from(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL),
1988            "the idle case must sit inside the projection, which is the number `f2`'s \
1989             refusals are computed from"
1990        );
1991    }
1992
1993    /// A run whose jobs have all been dispatched is found and costs one request.
1994    ///
1995    /// The safety net's ordinary outcome, and the reason it is capped lower than
1996    /// the primary signal: on the common shape it finds nothing.
1997    #[tokio::test]
1998    async fn an_in_progress_run_with_no_queued_job_contributes_only_its_request() {
1999        let server = MockServer::start().await;
2000        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
2001        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
2002        mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 0, 4)).await;
2003        let gateway = gateway(&server);
2004
2005        let demand = gateway
2006            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2007            .await
2008            .expect("a queued-job count");
2009
2010        assert_eq!(demand.total(), 0);
2011        assert!(demand.is_complete());
2012        assert_eq!(gateway.requests_issued(), 3);
2013    }
2014
2015    /// A run caught mid-transition appears in both listings and is counted once.
2016    ///
2017    /// The two GitHub queries are disjoint at any one instant and these are two
2018    /// requests with time between them. A run whose last waiting job is picked
2019    /// up between them is `queued` for the first and `in_progress` for the
2020    /// second — the ordinary transition, not an exotic one — so it comes back in
2021    /// both lists. Counting its jobs twice would inflate demand silently, which
2022    /// is the failure mode with no error to notice.
2023    #[tokio::test]
2024    async fn a_run_in_both_listings_is_resolved_once_and_not_counted_twice() {
2025        let server = MockServer::start().await;
2026        // The same run id in both lists, which is what the race produces.
2027        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2028        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[100])).await;
2029        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 3, 0)).await;
2030        let gateway = gateway(&server);
2031
2032        let demand = gateway
2033            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2034            .await
2035            .expect("a queued-job count");
2036
2037        assert_eq!(
2038            demand.total(),
2039            3,
2040            "the run holds three queued jobs, and appearing in both listings does not              make it six"
2041        );
2042        assert_eq!(
2043            gateway.requests_issued(),
2044            3,
2045            "and the duplicate costs no second job listing either"
2046        );
2047    }
2048
2049    /// The job listing's id and names travel with each queued job: they are
2050    /// what a `workflow_job` webhook calls the same job.
2051    #[tokio::test]
2052    async fn each_queued_job_keeps_its_id_and_names() {
2053        let server = MockServer::start().await;
2054        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[200])).await;
2055        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2056        mount_jobs(
2057            &server,
2058            &repo(),
2059            200,
2060            json!({"total_count": 3, "jobs": [
2061                {"id": 7, "status": "in_progress", "name": "running",
2062                 "workflow_name": "CI", "labels": ["rm-pool-linux"]},
2063                {"id": 8, "status": "queued", "name": "build (linux, 3.12)",
2064                 "workflow_name": "CI", "labels": ["rm-pool-linux"]},
2065                {"id": 9, "status": "queued", "name": "older", "workflow_name": null,
2066                 "labels": ["rm-pool-linux"]},
2067            ]}),
2068        )
2069        .await;
2070        let demand = gateway(&server)
2071            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2072            .await
2073            .expect("a reading");
2074        let identities: Vec<QueuedJobIdentity> = demand
2075            .identified_jobs_for(&repo())
2076            .filter_map(|(_, identity)| identity.cloned())
2077            .collect();
2078        assert_eq!(
2079            identities,
2080            vec![
2081                QueuedJobIdentity {
2082                    id: Some(8),
2083                    name: Some("build (linux, 3.12)".into()),
2084                    workflow: None,
2085                },
2086                QueuedJobIdentity {
2087                    id: Some(9),
2088                    name: Some("older".into()),
2089                    workflow: None,
2090                },
2091            ]
2092        );
2093        assert_eq!(demand.total(), 2);
2094    }
2095
2096    /// A job's workflow comes from its run's `path`, which unlike its title does
2097    /// not change between dispatches.
2098    #[tokio::test]
2099    async fn a_queued_job_is_named_with_its_runs_workflow_file() {
2100        let server = MockServer::start().await;
2101        mount_runs(
2102            &server,
2103            &repo(),
2104            QUEUED_RUN_STATUS,
2105            json!({
2106                "total_count": 1,
2107                "workflow_runs": [{
2108                    "id": 100,
2109                    "name": "load light [1,2] (a title)",
2110                    "path": ".github/workflows/load.yml"
2111                }]
2112            }),
2113        )
2114        .await;
2115        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2116        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2117        let gateway = gateway(&server);
2118
2119        let demand = gateway
2120            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2121            .await
2122            .expect("read");
2123
2124        let (_, identity) = demand.identified_jobs_for(&repo()).next().expect("a job");
2125        assert_eq!(
2126            identity.and_then(|identity| identity.workflow.as_deref()),
2127            Some(".github/workflows/load.yml")
2128        );
2129        assert_eq!(
2130            demand
2131                .run_workflows_for(&repo())
2132                .and_then(|runs| runs.get(&100))
2133                .map(String::as_str),
2134            Some(".github/workflows/load.yml")
2135        );
2136    }
2137
2138    #[tokio::test]
2139    async fn one_runs_workflow_file_can_be_asked_for() {
2140        let server = MockServer::start().await;
2141        Mock::given(method("GET"))
2142            .and(path(format!(
2143                "/repos/{}/{}/actions/runs/77",
2144                repo().owner(),
2145                repo().repo()
2146            )))
2147            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
2148                "id": 77,
2149                "path": ".github/workflows/ci.yml"
2150            })))
2151            .mount(&server)
2152            .await;
2153
2154        let answer = gateway(&server)
2155            .run_workflow(&repo(), 77, &CancelToken::new())
2156            .await
2157            .expect("read");
2158
2159        assert_eq!(answer.as_deref(), Some(".github/workflows/ci.yml"));
2160    }
2161
2162    /// A `needs:`-gated job whose run has already started is still demand.
2163    ///
2164    /// This is the whole reason the in-progress pass exists. Live sampling of a
2165    /// repository using this product never caught GitHub reporting a run as
2166    /// `in_progress` while one of its jobs was queued — 44 samples, 25 of them
2167    /// with a queued job — so the primary signal covers everything that was
2168    /// observed. It does not cover a job that becomes queued *after* its run
2169    /// started, which is what `needs:` produces and what this pins.
2170    #[tokio::test]
2171    async fn a_job_that_enters_the_queue_after_its_run_started_is_still_found() {
2172        let server = MockServer::start().await;
2173        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, no_runs()).await;
2174        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, runs_body(&[200])).await;
2175        // One job running, one released by `needs:` and now waiting.
2176        mount_jobs(&server, &repo(), 200, jobs_body(&["rm-home-win-x64"], 1, 1)).await;
2177        let gateway = gateway(&server);
2178
2179        let demand = gateway
2180            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2181            .await
2182            .expect("a queued-job count");
2183
2184        assert_eq!(
2185            demand.total(),
2186            1,
2187            "polling only `status=queued` runs would report zero here and the \
2188             `needs:`-gated job would wait for a machine that is idle"
2189        );
2190    }
2191
2192    // -- the wire contract, against payloads GitHub really sent -------------
2193
2194    /// The deserializers are pinned against **real** GitHub responses, not only
2195    /// against fixtures written to match them.
2196    ///
2197    /// Every other test here builds its own JSON, so all of them would keep
2198    /// passing if this module's idea of the wire format were wrong in the same
2199    /// way the fixtures are. That is the one failure a mock server cannot catch,
2200    /// and it is the failure this change was most exposed to: the previous owner
2201    /// decision deliberately read *nothing* out of a run and had no job
2202    /// endpoint at all, so `workflow_runs[].id`, `jobs[].status` and
2203    /// `jobs[].labels` are all fields this crate had never parsed before.
2204    ///
2205    /// The payloads below are verbatim excerpts captured from
2206    /// `api.github.com` on 2026-09-05 against a repository using this product,
2207    /// trimmed only by replacing the `steps` array — which is long, which this
2208    /// module does not read, and whose presence is itself part of what is being
2209    /// asserted, since a job object carries twenty-odd fields that must all be
2210    /// ignored without error.
2211    #[test]
2212    fn the_wire_shapes_parse_a_payload_github_really_sent() {
2213        // GET /repos/{o}/{r}/actions/runs/33938794901/jobs?filter=latest&per_page=100
2214        let jobs: RunJobsPage = serde_json::from_value(json!({
2215          "total_count": 5,
2216          "jobs": [
2217            {
2218              "id": 101_231_925_899_i64,
2219              "run_id": 33_938_794_901_i64,
2220              "workflow_name": "tests",
2221              "head_branch": "worktree-p1-lock-and-worktree-set",
2222              "run_url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
2223              "run_attempt": 1,
2224              "node_id": "CR_kwDOS_wnss8AAAAXkeSaiw",
2225              "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
2226              "url": "https://api.github.com/repos/o/r/actions/jobs/101231925899",
2227              "html_url": "https://github.com/o/r/actions/runs/33938794901/job/101231925899",
2228              "status": "completed",
2229              "conclusion": "success",
2230              "created_at": "2026-09-05T02:20:30Z",
2231              "started_at": "2026-09-05T02:26:03Z",
2232              "completed_at": "2026-09-05T02:28:44Z",
2233              "name": "scripts-tests",
2234              "steps": [],
2235              "check_run_url": "https://api.github.com/repos/o/r/check-runs/101231925899",
2236              "labels": ["self-hosted", "windows"],
2237              "runner_id": 725,
2238              "runner_name": "runner-manager-4bd32f05-088f-4b13-a59c-6900b9142aa1",
2239              "runner_group_id": 1,
2240              "runner_group_name": "Default"
2241            },
2242            {
2243              "id": 101_231_925_900_i64,
2244              "run_id": 33_938_794_901_i64,
2245              "status": "queued",
2246              "conclusion": serde_json::Value::Null,
2247              "started_at": serde_json::Value::Null,
2248              "completed_at": serde_json::Value::Null,
2249              "name": "pipeline-tests",
2250              "steps": [],
2251              "labels": ["self-hosted", "windows"],
2252              "runner_id": serde_json::Value::Null,
2253              "runner_name": "",
2254              "runner_group_name": ""
2255            },
2256            {
2257              "id": 101_231_925_901_i64,
2258              "status": "queued",
2259              "name": "scripts-tests-macos",
2260              "labels": ["self-hosted", "macOS", "rm-macmini-osx-arm64"]
2261            }
2262          ]
2263        }))
2264        .expect("the jobs page GitHub really sends must deserialize");
2265
2266        assert_eq!(
2267            jobs.jobs.len(),
2268            3,
2269            "every job is read, whatever else it carries"
2270        );
2271
2272        // What the gateway does with it: keep the queued ones, and turn each
2273        // one's `labels` array into the `RunsOn` `b1` matches.
2274        let queued: Vec<RunsOn> = jobs
2275            .jobs
2276            .into_iter()
2277            .filter(|job| job.status == QUEUED_JOB_STATUS)
2278            .map(|job| RunsOn::from_job_labels(job.labels))
2279            .collect();
2280
2281        assert_eq!(
2282            queued,
2283            vec![
2284                RunsOn::Many(vec!["self-hosted".into(), "windows".into()]),
2285                RunsOn::Many(vec![
2286                    "self-hosted".into(),
2287                    "macOS".into(),
2288                    "rm-macmini-osx-arm64".into()
2289                ]),
2290            ],
2291            "the completed job is dropped and the two queued ones keep their labels"
2292        );
2293
2294        // The Windows job requires a subset of this policy's labels, even
2295        // without its selector. The macOS job still requires missing labels.
2296        let tally = host_labels().tally(&queued);
2297        assert_eq!(tally.demand(), 1);
2298        assert_eq!(tally.not_matched, 1);
2299
2300        // GET /repos/{o}/{r}/actions/runs?status=queued&per_page=100, as an idle
2301        // repository answers it. `total_count` and an empty array, which is the
2302        // shape that must read as "nothing waiting" rather than as a failure.
2303        let idle: QueuedRunsPage = serde_json::from_value(json!({
2304            "total_count": 0,
2305            "workflow_runs": []
2306        }))
2307        .expect("an idle run listing must deserialize");
2308        assert_eq!(idle.total_count, Some(0));
2309        assert!(idle.workflow_runs.is_empty());
2310
2311        // And a busy one. A run object carries far more than the id, and the id
2312        // is the only field this module reads out of it.
2313        let busy: QueuedRunsPage = serde_json::from_value(json!({
2314            "total_count": 1,
2315            "workflow_runs": [{
2316                "id": 33_938_794_901_i64,
2317                "name": "tests",
2318                "node_id": "WFR_kwLOS_wnss8AAAAH6oSPFQ",
2319                "head_branch": "main",
2320                "head_sha": "e78bc5d1865693aba030d83a2c627dd5515edf45",
2321                "path": ".github/workflows/tests.yml",
2322                "run_number": 412,
2323                "event": "push",
2324                "status": "queued",
2325                "conclusion": serde_json::Value::Null,
2326                "workflow_id": 213_842_591_i64,
2327                "url": "https://api.github.com/repos/o/r/actions/runs/33938794901",
2328                "created_at": "2026-09-05T02:20:30Z",
2329                "updated_at": "2026-09-05T02:20:31Z"
2330            }]
2331        }))
2332        .expect("a busy run listing must deserialize");
2333        assert_eq!(
2334            busy.workflow_runs
2335                .iter()
2336                .map(|run| run.id)
2337                .collect::<Vec<_>>(),
2338            vec![33_938_794_901],
2339            "the run id is what the job listing is fetched by, and it is a u64: \
2340             GitHub's run ids passed 2^32 long ago, so a u32 here would have \
2341             wrapped on every real repository"
2342        );
2343    }
2344
2345    // -- the caps -----------------------------------------------------------
2346
2347    /// More queued runs than the cap resolves makes the answer a floor.
2348    #[tokio::test]
2349    async fn more_queued_runs_than_the_cap_report_a_floor_rather_than_a_total() {
2350        let server = MockServer::start().await;
2351        let ids: Vec<u64> = (0..(MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL as u64 + 4))
2352            .map(|i| 100 + i)
2353            .collect();
2354        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&ids)).await;
2355        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2356        for id in &ids {
2357            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2358        }
2359        let gateway = gateway(&server);
2360
2361        let demand = gateway
2362            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2363            .await
2364            .expect("a bounded poll still answers");
2365
2366        assert_eq!(
2367            demand.total() as usize,
2368            MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
2369            "one job resolved per run the cap admits"
2370        );
2371        assert!(
2372            !demand.is_complete(),
2373            "a count clipped by the run cap is a floor and must say so; concluding \
2374             `idle` from one would be the mistake the flag exists to prevent"
2375        );
2376        assert!(demand.is_truncated(&repo()));
2377    }
2378
2379    /// The caps bound what one repository can spend, whatever GitHub sends.
2380    ///
2381    /// The projection is a steady-state figure and the worst case is what keeps
2382    /// it honest, so the worst case has to be a real ceiling rather than an
2383    /// estimate. A repository with a hundred active runs must not spend a
2384    /// hundred requests.
2385    #[tokio::test]
2386    async fn a_repository_cannot_spend_more_than_the_documented_worst_case() {
2387        let server = MockServer::start().await;
2388        let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
2389        let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
2390        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
2391        mount_runs(
2392            &server,
2393            &repo(),
2394            IN_PROGRESS_RUN_STATUS,
2395            runs_body(&running),
2396        )
2397        .await;
2398        for id in queued.iter().chain(running.iter()) {
2399            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2400        }
2401        let gateway = gateway(&server);
2402
2403        let demand = gateway
2404            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2405            .await
2406            .expect("a bounded poll still answers");
2407
2408        assert_eq!(
2409            gateway.requests_issued(),
2410            u64::from(max_demand_requests_per_repository_per_poll()),
2411            "the measured ceiling must equal the projected one, or the bound is a \
2412             sentence in a doc comment"
2413        );
2414        assert_eq!(
2415            demand.total(),
2416            2 * (MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL
2417                + MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL) as u32
2418        );
2419        assert!(!demand.is_complete());
2420    }
2421
2422    /// The queued pass is served before the in-progress one when both are over
2423    /// their caps.
2424    #[tokio::test]
2425    async fn the_primary_signal_is_resolved_before_the_safety_net() {
2426        let server = MockServer::start().await;
2427        let queued: Vec<u64> = (0..40).map(|i| 100 + i).collect();
2428        let running: Vec<u64> = (0..40).map(|i| 500 + i).collect();
2429        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&queued)).await;
2430        mount_runs(
2431            &server,
2432            &repo(),
2433            IN_PROGRESS_RUN_STATUS,
2434            runs_body(&running),
2435        )
2436        .await;
2437        // Queued runs hold this host's work; in-progress runs hold another
2438        // host's. If the caps were spent in the other order the demand would be
2439        // made of the wrong jobs.
2440        for id in &queued {
2441            mount_jobs(&server, &repo(), *id, jobs_body(&["rm-home-win-x64"], 1, 0)).await;
2442        }
2443        for id in &running {
2444            mount_jobs(&server, &repo(), *id, jobs_body(&["ubuntu-latest"], 1, 0)).await;
2445        }
2446        let gateway = gateway(&server);
2447
2448        let demand = gateway
2449            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2450            .await
2451            .expect("a bounded poll still answers");
2452
2453        let tally = host_labels().tally(demand.jobs_for(&repo()));
2454        assert_eq!(
2455            tally.demand() as usize,
2456            MAX_QUEUED_RUNS_PER_REPOSITORY_PER_POLL,
2457            "the queued cap is spent in full on the primary signal"
2458        );
2459        assert_eq!(
2460            tally.not_matched as usize, MAX_IN_PROGRESS_RUNS_PER_REPOSITORY_PER_POLL,
2461            "and the safety net gets its own smaller cap, not a share of the first"
2462        );
2463    }
2464
2465    /// One run's job listing follows pages, and stops at its budget.
2466    #[tokio::test]
2467    async fn a_runs_job_listing_walks_pages_and_stops_at_its_budget() {
2468        let server = MockServer::start().await;
2469        let base = server.uri();
2470        let path_100 = jobs_path(&repo(), 100);
2471
2472        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2473        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2474        // Every page points at another, forever.
2475        Mock::given(method("GET"))
2476            .and(path(path_100.clone()))
2477            .respond_with(
2478                ResponseTemplate::new(200)
2479                    .set_body_json(jobs_body(&["rm-home-win-x64"], 100, 0))
2480                    .insert_header(
2481                        "link",
2482                        format!("<{base}{path_100}?page=9>; rel=\"next\"").as_str(),
2483                    ),
2484            )
2485            .mount(&server)
2486            .await;
2487        let gateway = gateway(&server);
2488
2489        let demand = gateway
2490            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2491            .await
2492            .expect("a bounded walk still answers");
2493
2494        assert_eq!(
2495            gateway.requests_issued() as usize,
2496            2 + MAX_JOB_PAGES_PER_RUN,
2497            "an endless `Link` chain must stop at the job page budget rather than \
2498             spending the hourly ceiling on one run"
2499        );
2500        assert_eq!(demand.total() as usize, 100 * MAX_JOB_PAGES_PER_RUN);
2501        assert!(
2502            !demand.is_complete(),
2503            "a count clipped by the page bound is a floor and must say so"
2504        );
2505        assert!(demand.is_truncated(&repo()));
2506    }
2507
2508    // -- the `total_count` tripwire ----------------------------------------
2509
2510    /// The zero-cost check that `total_count` is the *filtered* count.
2511    ///
2512    /// A repository with 3 queued runs out of 5,000 lifetime runs would answer
2513    /// `len() == 3`, no `Link`, and `total_count == 5000`. The demand number no
2514    /// longer comes from that field, so the contradiction is a `warn!` rather
2515    /// than a `debug_assert!` — but it still means GitHub is not answering the
2516    /// question this module asked, and it is free to notice.
2517    #[tokio::test]
2518    async fn a_total_count_that_disagrees_with_its_only_page_does_not_derail_the_count() {
2519        let server = MockServer::start().await;
2520        mount_runs(
2521            &server,
2522            &repo(),
2523            QUEUED_RUN_STATUS,
2524            json!({
2525                "total_count": 5_000,
2526                "workflow_runs": [{ "id": 100 }]
2527            }),
2528        )
2529        .await;
2530        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2531        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2532        let gateway = gateway(&server);
2533
2534        let demand = gateway
2535            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2536            .await
2537            .expect("a wrong `total_count` no longer decides anything here");
2538
2539        assert_eq!(
2540            demand.total(),
2541            2,
2542            "the count comes from the jobs of the runs that were actually listed, so a \
2543             `total_count` carrying the unfiltered lifetime total cannot inflate it"
2544        );
2545        assert!(
2546            demand.is_complete(),
2547            "one listed run, no next page, and the cap not reached"
2548        );
2549    }
2550
2551    // -- organization aggregation ------------------------------------------
2552
2553    /// An organization aggregates across its installed repositories, and the
2554    /// request count grows with that repository count.
2555    #[tokio::test]
2556    async fn an_organization_aggregates_its_repositories_and_pays_per_repository() {
2557        let server = MockServer::start().await;
2558        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 2, 0).await;
2559        mount_one_queued_run(&server, &other_repo(), &["rm-home-win-x64"], 5, 0).await;
2560        mount_idle(&server, &third_repo()).await;
2561        let gateway = gateway(&server);
2562
2563        let two = org_scope([repo(), other_repo()]);
2564        let three = org_scope([repo(), other_repo(), third_repo()]);
2565
2566        assert_eq!(
2567            demand_requests_per_poll(&two),
2568            2 * DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL,
2569            "there is no organization-wide workflow-runs endpoint, so the cost is \
2570             per repository"
2571        );
2572        assert!(
2573            demand_requests_per_poll(&three) > demand_requests_per_poll(&two),
2574            "a projection that did not grow with the repository count would understate \
2575             an organization's real spend by exactly that factor"
2576        );
2577
2578        let demand = gateway
2579            .queued_demand(&three, &CancelToken::new())
2580            .await
2581            .expect("an aggregate");
2582
2583        assert_eq!(demand.total(), 7);
2584        assert_eq!(demand.for_repository(&repo()), Some(2));
2585        assert_eq!(demand.for_repository(&other_repo()), Some(5));
2586        assert_eq!(
2587            demand.for_repository(&third_repo()),
2588            Some(0),
2589            "a repository that answered zero is present as a zero, unlike one that \
2590             could not answer at all"
2591        );
2592        assert_eq!(
2593            host_labels().tally(demand.jobs()).demand(),
2594            7,
2595            "an organization policy serves any repository in its scope, so its demand \
2596             is the whole aggregate's rather than one repository's"
2597        );
2598        assert!(
2599            gateway.requests_issued() <= u64::from(max_demand_requests_per_poll(&three)),
2600            "the measured cost must sit inside the projected ceiling, or the budget \
2601             model is a table in a document"
2602        );
2603    }
2604
2605    /// The measured cost is reported to `c3`'s budget model through the seam
2606    /// `c3` left for it, rather than inheriting the estimate.
2607    #[test]
2608    fn the_measured_demand_cost_is_reported_through_c3s_seam() {
2609        use crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH;
2610
2611        let scope = org_scope([repo(), other_repo(), third_repo()]);
2612        let estimated = TargetCost::from_activity_scope(&scope);
2613        let measured = target_cost(&scope);
2614
2615        assert_eq!(
2616            DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH, 2,
2617            "`c3`'s estimate prices the runs request and one jobs request; this module \
2618             now issues both, plus the in-progress listing and a jobs request per \
2619             additional active run, so the measured figure is higher rather than lower"
2620        );
2621        assert_ne!(
2622            measured, estimated,
2623            "the seam must actually replace the estimate; a `target_cost` that returned \
2624             `from_activity_scope` unchanged would report the estimate as measured"
2625        );
2626        // 1 inventory request + 3 repositories * (1 activity + 4 demand).
2627        assert_eq!(measured.requests_per_refresh(), 16);
2628        // 1 + 3 * (1 + 2), `c3`'s estimate.
2629        assert_eq!(estimated.requests_per_refresh(), 10);
2630        assert!(
2631            measured.requests_per_refresh() > estimated.requests_per_refresh(),
2632            "restoring the per-run job listing added requests; a measured cost that was \
2633             not higher would mean this module is not issuing them"
2634        );
2635    }
2636
2637    /// **A known gap, pinned so it stays visible.** `f2`'s refusal *decision*
2638    /// can consume the measured demand cost; the number it prints alongside the
2639    /// refusal cannot.
2640    ///
2641    /// [`crate::rest::BudgetProjection::admit`] takes the candidate
2642    /// [`TargetCost`] from its caller, so an `f2` that builds it through
2643    /// [`target_cost`] gets an admission computed from the real cost.
2644    /// [`crate::rest::BudgetProjection::max_repository_targets`] builds
2645    /// [`TargetCost::repository`] internally, which cannot see the seam and
2646    /// therefore still prices demand at `c3`'s estimate of two.
2647    ///
2648    /// The two then disagree, and an operator sees both. **The direction of the
2649    /// disagreement inverted when this module started counting jobs**, and that
2650    /// is why this test matters more than it did: the printed ceiling used to be
2651    /// conservative, and is now optimistic. It says a host fits ten repository
2652    /// targets when the cost this module really issues fits six, so an operator
2653    /// planning against the printed number can configure a host that spends more
2654    /// than the projection admitted.
2655    ///
2656    /// What keeps that from being a live budget overrun is
2657    /// `BUDGET_SHARE_DIVISOR`: the projection is compared against half of
2658    /// GitHub's hourly ceiling, so the gap is spent out of the half deliberately
2659    /// left unplanned. It is a reporting defect with a safety margin under it,
2660    /// not a correctness one — and `f1`'s `host show` prints the caveat beside
2661    /// the number.
2662    ///
2663    /// It is not fixable from this file. Both remedies — changing
2664    /// [`crate::rest::DEMAND_REQUESTS_PER_REPOSITORY_PER_REFRESH`], or giving
2665    /// `max_repository_targets` a [`TargetCost`] argument — are edits to
2666    /// `crates/github/src/rest.rs`, which `c3` owns. This test records the
2667    /// discrepancy with its arithmetic so that whoever holds that file can act on
2668    /// it, and fails if it is ever closed, at which point this test and the note
2669    /// above should go.
2670    #[test]
2671    fn the_printed_target_ceiling_still_projects_c3s_estimate() {
2672        use crate::rest::{BudgetProjection, budget_allowance};
2673        use runner_manager_domain::model::RefreshInterval;
2674
2675        let interval = RefreshInterval::default();
2676        let printed = BudgetProjection::max_repository_targets(interval);
2677        let per_hour_estimated = TargetCost::repository().requests_per_hour(interval);
2678        let per_hour_measured = TargetCost::repository()
2679            .with_demand_requests_per_repository(DEMAND_REQUESTS_PER_REPOSITORY_PER_POLL)
2680            .requests_per_hour(interval);
2681
2682        // 4 requests per refresh * 60 refreshes, against 6 * 60.
2683        assert_eq!(per_hour_estimated, 240);
2684        assert_eq!(per_hour_measured, 360);
2685        assert_eq!(
2686            printed, 10,
2687            "the printed ceiling is `04-subsystem-contracts.md`'s figure, computed from \
2688             `c3`'s estimate"
2689        );
2690        assert_eq!(
2691            budget_allowance() / per_hour_measured,
2692            6,
2693            "while the cost this module actually issues allows six"
2694        );
2695        assert!(
2696            printed > budget_allowance() / per_hour_measured,
2697            "the gap now runs in the optimistic direction: the printed ceiling is larger \
2698             than the measured cost supports. `BUDGET_SHARE_DIVISOR` is what absorbs it \
2699             -- see this test's documentation before treating the inequality as harmless"
2700        );
2701    }
2702
2703    /// One archived repository must not take down an organization's whole
2704    /// demand poll — and must not read as zero either.
2705    #[tokio::test]
2706    async fn an_organization_steps_over_a_repository_local_failure_without_reading_it_as_zero() {
2707        let server = MockServer::start().await;
2708        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 4, 0).await;
2709        Mock::given(method("GET"))
2710            .and(path(runs_path(&other_repo())))
2711            .respond_with(
2712                ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
2713            )
2714            .mount(&server)
2715            .await;
2716        let gateway = gateway(&server);
2717
2718        let demand = gateway
2719            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
2720            .await
2721            .expect("an aggregate steps over a repository it cannot read");
2722
2723        assert_eq!(demand.total(), 4);
2724        assert_eq!(demand.unavailable().len(), 1);
2725        assert_eq!(demand.unavailable()[0].repository, other_repo());
2726        assert_eq!(
2727            demand.for_repository(&other_repo()),
2728            None,
2729            "a repository that could not be polled is absent from the map, not present \
2730             as a zero"
2731        );
2732        assert!(
2733            demand.jobs_for(&other_repo()).is_empty(),
2734            "and its job list is empty rather than absent, so a caller tallying it \
2735             cannot accidentally read an unavailable repository as demand"
2736        );
2737        assert!(
2738            !demand.is_complete(),
2739            "an aggregate missing a repository is not a complete reading"
2740        );
2741    }
2742
2743    /// The same `404`, on a repository *target*, propagates instead.
2744    ///
2745    /// Stepping over the only repository in scope would answer demand `0` for a
2746    /// target this host cannot see at all, and `e1` would then correctly start
2747    /// no runners for the wrong reason — with no error anywhere to explain it.
2748    #[tokio::test]
2749    async fn a_repository_target_propagates_the_failure_rather_than_reporting_zero_demand() {
2750        let server = MockServer::start().await;
2751        Mock::given(method("GET"))
2752            .and(path(runs_path(&repo())))
2753            .respond_with(
2754                ResponseTemplate::new(404).set_body_json(json!({ "message": "Not Found" })),
2755            )
2756            .mount(&server)
2757            .await;
2758        let gateway = gateway(&server);
2759
2760        let error = gateway
2761            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2762            .await
2763            .expect_err("a scope of one has no aggregate to step over into");
2764        assert!(matches!(
2765            error,
2766            InventoryError::Github(GithubError::Status { status: 404, .. })
2767        ));
2768    }
2769
2770    /// A failure on the *job* listing is a failure of the whole poll, not a
2771    /// silently short count.
2772    ///
2773    /// The job listing is where the new requests are, so it is where a new way
2774    /// to under-count could enter: a gateway that swallowed a failed job listing
2775    /// would report the run's jobs as zero, which is indistinguishable from a
2776    /// run whose jobs have all been dispatched.
2777    #[tokio::test]
2778    async fn a_failed_job_listing_is_not_read_as_a_run_with_no_queued_jobs() {
2779        let server = MockServer::start().await;
2780        mount_runs(&server, &repo(), QUEUED_RUN_STATUS, runs_body(&[100])).await;
2781        mount_runs(&server, &repo(), IN_PROGRESS_RUN_STATUS, no_runs()).await;
2782        Mock::given(method("GET"))
2783            .and(path(jobs_path(&repo(), 100)))
2784            .respond_with(
2785                ResponseTemplate::new(500).set_body_json(json!({ "message": "Server Error" })),
2786            )
2787            .mount(&server)
2788            .await;
2789        let gateway = gateway(&server);
2790
2791        let error = gateway
2792            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
2793            .await
2794            .expect_err("a job listing that failed is not a run with nothing queued");
2795        assert!(matches!(
2796            error,
2797            InventoryError::Github(GithubError::Status { status: 500, .. })
2798        ));
2799    }
2800
2801    /// A credential failure aborts the aggregate rather than being stepped over.
2802    ///
2803    /// A revoked token is a fact about the credential, not about the repository:
2804    /// stepping over it would report every remaining repository as unavailable
2805    /// and the total as a number, when in truth nothing can be read at all.
2806    #[tokio::test]
2807    async fn a_rate_limit_aborts_the_aggregate_rather_than_being_stepped_over() {
2808        let server = MockServer::start().await;
2809        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 1, 0).await;
2810        Mock::given(method("GET"))
2811            .and(path(runs_path(&other_repo())))
2812            .respond_with(
2813                ResponseTemplate::new(429)
2814                    .insert_header("retry-after", "42")
2815                    .set_body_json(json!({
2816                        "message": "You have exceeded a secondary rate limit"
2817                    })),
2818            )
2819            .mount(&server)
2820            .await;
2821        let gateway = gateway(&server);
2822
2823        let error = gateway
2824            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
2825            .await
2826            .expect_err("a rate limit is a fact about the credential, not the repository");
2827
2828        let limit = error
2829            .rate_limited()
2830            .expect("`c3`'s detector is what decides this, and it decided rate limit");
2831        assert_eq!(limit.retry_after, Some(std::time::Duration::from_secs(42)));
2832    }
2833
2834    // -- conditional polling, the shared window, and the quiet period -------
2835
2836    fn gateway_at(server: &MockServer, clock: &Arc<TestClock>) -> RestDemand {
2837        let endpoints = Endpoints::for_test_server(&server.uri()).expect("a test server base");
2838        let token = UserAccessToken::from_stored(SecretString::from(FIXTURE_TOKEN));
2839        let client =
2840            AuthenticatedClient::new(endpoints, token, Arc::clone(clock) as Arc<dyn Clock>)
2841                .expect("a client over the test server");
2842        RestDemand::new(Arc::new(client), Arc::clone(clock) as Arc<dyn Clock>)
2843    }
2844
2845    /// The account-wide quota headers GitHub puts on every response, `304`s
2846    /// included.
2847    fn with_quota(template: ResponseTemplate, remaining: u64) -> ResponseTemplate {
2848        template
2849            .insert_header("x-ratelimit-limit", "5000")
2850            .insert_header("x-ratelimit-remaining", remaining.to_string().as_str())
2851            .insert_header("x-ratelimit-used", (5000 - remaining).to_string().as_str())
2852            .insert_header("x-ratelimit-reset", "1787274000")
2853            .insert_header("x-ratelimit-resource", "core")
2854    }
2855
2856    /// One listing that answers `304` to its own tag and `200` + tag
2857    /// otherwise. Mounted conditional-first, because wiremock answers with the
2858    /// first mock whose matchers all pass.
2859    async fn mount_tagged(
2860        server: &MockServer,
2861        listing: &str,
2862        status: Option<&str>,
2863        etag: &str,
2864        body: serde_json::Value,
2865    ) {
2866        let mut not_modified = Mock::given(method("GET"))
2867            .and(path(listing.to_string()))
2868            .and(wiremock::matchers::header("if-none-match", etag));
2869        if let Some(status) = status {
2870            not_modified = not_modified.and(query_param("status", status));
2871        }
2872        not_modified
2873            .respond_with(with_quota(
2874                ResponseTemplate::new(304).insert_header("etag", etag),
2875                4_000,
2876            ))
2877            .mount(server)
2878            .await;
2879        let mut full = Mock::given(method("GET")).and(path(listing.to_string()));
2880        if let Some(status) = status {
2881            full = full.and(query_param("status", status));
2882        }
2883        full.respond_with(with_quota(
2884            ResponseTemplate::new(200)
2885                .insert_header("etag", etag)
2886                .set_body_json(body),
2887            4_000,
2888        ))
2889        .mount(server)
2890        .await;
2891    }
2892
2893    async fn mount_tagged_idle(server: &MockServer, repository: &OwnerRepo) {
2894        let runs = runs_path(repository);
2895        mount_tagged(
2896            server,
2897            &runs,
2898            Some(QUEUED_RUN_STATUS),
2899            r#"W/"queued""#,
2900            no_runs(),
2901        )
2902        .await;
2903        mount_tagged(
2904            server,
2905            &runs,
2906            Some(IN_PROGRESS_RUN_STATUS),
2907            r#"W/"in-progress""#,
2908            no_runs(),
2909        )
2910        .await;
2911    }
2912
2913    fn conditional_requests(received: &[wiremock::Request]) -> usize {
2914        received
2915            .iter()
2916            .filter(|request| request.headers.contains_key("if-none-match"))
2917            .count()
2918    }
2919
2920    /// The owner's requirement in one test: an idle repository polled again
2921    /// sends its tags back, GitHub answers `304`, and the reading is the same.
2922    #[tokio::test]
2923    async fn an_unchanged_repository_is_polled_with_its_etags_and_answered_304() {
2924        let server = MockServer::start().await;
2925        mount_tagged_idle(&server, &repo()).await;
2926        let clock = Arc::new(TestClock::default());
2927        let gateway = gateway_at(&server, &clock);
2928        let scope = ActivityScope::repository(repo());
2929
2930        let first = gateway
2931            .queued_demand(&scope, &CancelToken::new())
2932            .await
2933            .expect("a first reading");
2934        let second = gateway
2935            .queued_demand(&scope, &CancelToken::new())
2936            .await
2937            .expect("a second reading");
2938
2939        assert_eq!(first.per_repository(), second.per_repository());
2940        let received = server.received_requests().await.expect("recorded requests");
2941        assert_eq!(received.len(), 4, "two listings, twice");
2942        assert_eq!(
2943            conditional_requests(&received),
2944            2,
2945            "the second poll sends both tags back"
2946        );
2947        let traffic = gateway.client().traffic().summary(clock.now());
2948        assert_eq!((traffic.full, traffic.not_modified), (2, 2));
2949        let stats = gateway.stats();
2950        assert_eq!((stats.reads, stats.unchanged_reads), (2, 1));
2951        assert!(second.is_quiet());
2952    }
2953
2954    /// A conditional request must never hide a change: a new run on the very
2955    /// next poll is counted on that poll.
2956    #[tokio::test]
2957    async fn a_run_that_arrives_between_polls_is_counted_on_the_next_one() {
2958        let server = MockServer::start().await;
2959        let runs = runs_path(&repo());
2960        // Asked with the first tag, the queue has moved on: a full answer with
2961        // a new tag.
2962        Mock::given(method("GET"))
2963            .and(path(runs.clone()))
2964            .and(query_param("status", QUEUED_RUN_STATUS))
2965            .and(wiremock::matchers::header("if-none-match", r#"W/"empty""#))
2966            .respond_with(
2967                ResponseTemplate::new(200)
2968                    .insert_header("etag", r#"W/"one-run""#)
2969                    .set_body_json(runs_body(&[100])),
2970            )
2971            .mount(&server)
2972            .await;
2973        Mock::given(method("GET"))
2974            .and(path(runs.clone()))
2975            .and(query_param("status", QUEUED_RUN_STATUS))
2976            .respond_with(
2977                ResponseTemplate::new(200)
2978                    .insert_header("etag", r#"W/"empty""#)
2979                    .set_body_json(no_runs()),
2980            )
2981            .mount(&server)
2982            .await;
2983        mount_tagged(
2984            &server,
2985            &runs,
2986            Some(IN_PROGRESS_RUN_STATUS),
2987            r#"W/"in-progress""#,
2988            no_runs(),
2989        )
2990        .await;
2991        mount_jobs(&server, &repo(), 100, jobs_body(&["rm-home-win-x64"], 2, 0)).await;
2992        let clock = Arc::new(TestClock::default());
2993        let gateway = gateway_at(&server, &clock);
2994        let scope = ActivityScope::repository(repo());
2995
2996        let before = gateway
2997            .queued_demand(&scope, &CancelToken::new())
2998            .await
2999            .expect("an idle reading");
3000        let after = gateway
3001            .queued_demand(&scope, &CancelToken::new())
3002            .await
3003            .expect("a reading after the run arrived");
3004
3005        assert_eq!(before.total(), 0);
3006        assert_eq!(after.total(), 2, "the change is seen on the very next poll");
3007        assert!(
3008            !after.is_quiet(),
3009            "a run under way whose listing changed is activity"
3010        );
3011    }
3012
3013    /// A run stuck in the queue for a label nobody serves is still in the
3014    /// listing hours later. While its listings answer `304` it costs nothing,
3015    /// and it must not hold the target at the slower active interval.
3016    #[tokio::test]
3017    async fn a_queued_run_whose_listings_are_unchanged_is_quiet() {
3018        let server = MockServer::start().await;
3019        let runs = runs_path(&repo());
3020        mount_tagged(
3021            &server,
3022            &runs,
3023            Some(QUEUED_RUN_STATUS),
3024            r#"W/"stuck""#,
3025            runs_body(&[100]),
3026        )
3027        .await;
3028        mount_tagged(
3029            &server,
3030            &runs,
3031            Some(IN_PROGRESS_RUN_STATUS),
3032            r#"W/"in-progress""#,
3033            no_runs(),
3034        )
3035        .await;
3036        mount_tagged(
3037            &server,
3038            &jobs_path(&repo(), 100),
3039            None,
3040            r#"W/"jobs""#,
3041            jobs_body(&["ubuntu-latest"], 1, 0),
3042        )
3043        .await;
3044        let clock = Arc::new(TestClock::default());
3045        let gateway = gateway_at(&server, &clock);
3046        let scope = ActivityScope::repository(repo());
3047
3048        let first = gateway
3049            .queued_demand(&scope, &CancelToken::new())
3050            .await
3051            .expect("a first reading");
3052        let second = gateway
3053            .queued_demand(&scope, &CancelToken::new())
3054            .await
3055            .expect("a second reading");
3056
3057        assert!(
3058            !first.is_quiet(),
3059            "the first sight of a run is a change, and the target must look at it"
3060        );
3061        assert!(second.is_quiet(), "nothing changed; three 304s");
3062        assert_eq!(second.total(), 1, "the job is still counted from the cache");
3063        let received = server.received_requests().await.expect("recorded requests");
3064        assert_eq!(conditional_requests(&received), 3);
3065    }
3066
3067    /// The quota GitHub reports during the poll travels with the reading, so
3068    /// `e1` can slow down on it.
3069    #[tokio::test]
3070    async fn the_accounts_quota_travels_with_the_reading() {
3071        let server = MockServer::start().await;
3072        mount_tagged_idle(&server, &repo()).await;
3073        let clock = Arc::new(TestClock::default());
3074        let gateway = gateway_at(&server, &clock);
3075
3076        let reading = gateway
3077            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3078            .await
3079            .expect("a reading");
3080
3081        let quota = reading
3082            .rate_limit()
3083            .expect("every response carries the quota");
3084        assert_eq!(
3085            (quota.limit, quota.remaining, quota.used),
3086            (5000, 4000, Some(1000))
3087        );
3088    }
3089
3090    /// Two targets covering one repository make one read between them.
3091    #[tokio::test]
3092    async fn a_repository_is_read_once_for_every_target_that_covers_it() {
3093        let server = MockServer::start().await;
3094        mount_tagged_idle(&server, &repo()).await;
3095        mount_tagged_idle(&server, &other_repo()).await;
3096        let clock = Arc::new(TestClock::default());
3097        let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3098
3099        // A repository target and an organization target that contains it.
3100        gateway
3101            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3102            .await
3103            .expect("the repository target's reading");
3104        let organization = gateway
3105            .queued_demand(&org_scope([repo(), other_repo()]), &CancelToken::new())
3106            .await
3107            .expect("the organization target's reading");
3108
3109        assert_eq!(organization.per_repository().len(), 2);
3110        assert_eq!(
3111            gateway.requests_issued(),
3112            4,
3113            "dashboard once (2 listings) and api once (2): the organization target is \
3114             handed the repository target's reading of dashboard"
3115        );
3116        assert_eq!(gateway.stats().shared_reads, 1);
3117
3118        // Past the window the repository is read again.
3119        clock.advance_secs(10);
3120        gateway
3121            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3122            .await
3123            .expect("a later reading");
3124        assert_eq!(gateway.requests_issued(), 6);
3125    }
3126
3127    /// A coordinator's wake drops the shared reading, so the woken poll reads
3128    /// GitHub inside the window instead of being handed the stale one.
3129    #[tokio::test]
3130    async fn a_forgotten_reading_is_read_again_inside_the_window() {
3131        let server = MockServer::start().await;
3132        mount_tagged_idle(&server, &repo()).await;
3133        let clock = Arc::new(TestClock::default());
3134        let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3135        let scope = ActivityScope::repository(repo());
3136
3137        gateway
3138            .queued_demand(&scope, &CancelToken::new())
3139            .await
3140            .expect("a first reading");
3141        gateway
3142            .queued_demand(&scope, &CancelToken::new())
3143            .await
3144            .expect("shared");
3145        assert_eq!(gateway.requests_issued(), 2, "the second was shared");
3146
3147        gateway.forget_shared_reading(&repo());
3148        gateway
3149            .queued_demand(&scope, &CancelToken::new())
3150            .await
3151            .expect("read again");
3152        assert_eq!(gateway.requests_issued(), 4);
3153    }
3154
3155    /// Two loops asking at the same moment make one read between them.
3156    #[tokio::test]
3157    async fn concurrent_targets_wait_for_one_read_instead_of_making_two() {
3158        let server = MockServer::start().await;
3159        mount_tagged_idle(&server, &repo()).await;
3160        let clock = Arc::new(TestClock::default());
3161        let gateway =
3162            Arc::new(gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10)));
3163
3164        let scope = ActivityScope::repository(repo());
3165        let cancel = CancelToken::new();
3166        let (left, right) = tokio::join!(
3167            gateway.queued_demand(&scope, &cancel),
3168            gateway.queued_demand(&scope, &cancel),
3169        );
3170        left.expect("one reading");
3171        right.expect("the other reading");
3172        assert_eq!(gateway.requests_issued(), 2, "one read: two listings");
3173    }
3174
3175    fn secondary_limit(retry_after: Option<&str>) -> ResponseTemplate {
3176        let template = ResponseTemplate::new(429).set_body_json(json!({
3177            "message": "You have exceeded a secondary rate limit"
3178        }));
3179        match retry_after {
3180            Some(seconds) => template.insert_header("retry-after", seconds),
3181            None => template,
3182        }
3183    }
3184
3185    /// GitHub: "If the `retry-after` response header is present, you should
3186    /// not retry your request until after that many seconds has elapsed." Not
3187    /// this poll, and not any other target's poll on this host either.
3188    #[tokio::test]
3189    async fn a_secondary_limit_stops_every_demand_request_until_retry_after() {
3190        let server = MockServer::start().await;
3191        Mock::given(method("GET"))
3192            .respond_with(secondary_limit(Some("42")))
3193            .mount(&server)
3194            .await;
3195        let clock = Arc::new(TestClock::default());
3196        let gateway = gateway_at(&server, &clock);
3197
3198        let first = gateway
3199            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3200            .await
3201            .expect_err("a rate limit");
3202        assert_eq!(
3203            first.rate_limited().and_then(|limit| limit.retry_after),
3204            Some(Duration::from_secs(42))
3205        );
3206
3207        // Another target, half-way through the window: no request at all.
3208        clock.advance_secs(20);
3209        let held = gateway
3210            .queued_demand(
3211                &ActivityScope::repository(other_repo()),
3212                &CancelToken::new(),
3213            )
3214            .await
3215            .expect_err("still inside the quiet period");
3216        assert_eq!(
3217            held.rate_limited().and_then(|limit| limit.retry_after),
3218            Some(Duration::from_secs(22)),
3219            "the wait left, so the loop's schedule sleeps exactly that"
3220        );
3221        assert_eq!(
3222            server.received_requests().await.expect("recorded").len(),
3223            1,
3224            "a request inside the quiet period is the one GitHub said not to send"
3225        );
3226        assert_eq!(gateway.stats().suppressed, 1);
3227
3228        // At the end of the window, the next poll asks again.
3229        clock.advance_secs(22);
3230        let _ = gateway
3231            .queued_demand(&ActivityScope::repository(repo()), &CancelToken::new())
3232            .await;
3233        assert_eq!(server.received_requests().await.expect("recorded").len(), 2);
3234    }
3235
3236    /// GitHub: "Otherwise, wait for at least one minute before retrying. If
3237    /// your request continues to fail due to a secondary rate limit, wait for
3238    /// an exponentially increasing amount of time between retries."
3239    #[tokio::test]
3240    async fn a_recurring_secondary_limit_doubles_the_quiet_period() {
3241        let server = MockServer::start().await;
3242        Mock::given(method("GET"))
3243            .respond_with(secondary_limit(None))
3244            .mount(&server)
3245            .await;
3246        let clock = Arc::new(TestClock::default());
3247        let gateway = gateway_at(&server, &clock);
3248        let scope = ActivityScope::repository(repo());
3249
3250        let mut waits = Vec::new();
3251        for _ in 0..4 {
3252            let error = gateway
3253                .queued_demand(&scope, &CancelToken::new())
3254                .await
3255                .expect_err("a rate limit");
3256            let wait = error
3257                .rate_limited()
3258                .and_then(|limit| limit.retry_after)
3259                .expect("a wait");
3260            waits.push(wait.as_secs());
3261            clock.advance_secs(i64::try_from(wait.as_secs()).expect("fits"));
3262        }
3263        assert_eq!(waits, [60, 120, 240, 480]);
3264    }
3265
3266    fn secondary(retry_after: Option<u64>) -> RateLimited {
3267        RateLimited {
3268            kind: RateLimitKind::Secondary,
3269            retry_after: retry_after.map(Duration::from_secs),
3270            remaining: Some(4_000),
3271            reset_unix_secs: None,
3272        }
3273    }
3274
3275    /// Every loop shares the gateway, so one secondary limit can come back on
3276    /// several requests that were in flight together. That is one episode.
3277    #[tokio::test]
3278    async fn answers_to_one_secondary_limit_in_flight_together_are_one_episode() {
3279        let server = MockServer::start().await;
3280        let clock = Arc::new(TestClock::default());
3281        let gateway = gateway_at(&server, &clock);
3282        let now = clock.now();
3283
3284        let waits: Vec<u64> = (0..4)
3285            .map(|_| {
3286                gateway
3287                    .hold_for(secondary(None), now)
3288                    .retry_after
3289                    .expect("a wait")
3290                    .as_secs()
3291            })
3292            .collect();
3293        assert_eq!(waits, [60, 60, 60, 60], "four answers, one limit");
3294
3295        clock.advance_secs(60);
3296        let again = gateway.hold_for(secondary(None), clock.now());
3297        assert_eq!(
3298            again.retry_after,
3299            Some(Duration::from_secs(120)),
3300            "an answer after the hold ended is GitHub saying \"still limited\""
3301        );
3302    }
3303
3304    /// A primary limit with a near reset must not reopen what a secondary
3305    /// limit closed.
3306    #[tokio::test]
3307    async fn a_later_answer_never_shortens_a_hold_in_force() {
3308        let server = MockServer::start().await;
3309        let clock = Arc::new(TestClock::default());
3310        let gateway = gateway_at(&server, &clock);
3311
3312        let _ = gateway.hold_for(secondary(Some(600)), clock.now());
3313        let primary = RateLimited {
3314            kind: RateLimitKind::Primary,
3315            retry_after: None,
3316            remaining: Some(0),
3317            reset_unix_secs: Some(u64::try_from(clock.now().timestamp() + 30).expect("positive")),
3318        };
3319        let after = gateway.hold_for(primary, clock.now());
3320        assert_eq!(after.retry_after, Some(Duration::from_secs(600)));
3321        clock.advance_secs(300);
3322        assert_eq!(
3323            gateway
3324                .held(clock.now())
3325                .and_then(|limit| limit.retry_after),
3326            Some(Duration::from_secs(300))
3327        );
3328    }
3329
3330    #[test]
3331    fn an_unavailable_repository_is_not_quiet() {
3332        assert!(QueuedDemand::default().is_quiet());
3333        assert!(
3334            !QueuedDemand::default()
3335                .with_unavailable(repo(), "404")
3336                .is_quiet(),
3337            "its failing listing is charged on every poll; polling it every few seconds \
3338             would multiply that"
3339        );
3340    }
3341
3342    /// A draining loop must not wait behind another loop's read.
3343    #[tokio::test]
3344    async fn a_cancelled_loop_does_not_wait_for_another_loops_read() {
3345        let server = MockServer::start().await;
3346        let clock = Arc::new(TestClock::default());
3347        let gateway = gateway_at(&server, &clock).with_shared_readings(Duration::from_secs(10));
3348        let gate = Arc::clone(
3349            gateway
3350                .shared
3351                .gates
3352                .lock()
3353                .expect("gates")
3354                .entry(repo())
3355                .or_default(),
3356        );
3357        let _another_loop = gate.lock().await;
3358        let cancel = CancelToken::new();
3359        cancel.cancel();
3360
3361        let answer = tokio::time::timeout(
3362            Duration::from_secs(5),
3363            gateway.repository_reading(&repo(), &cancel),
3364        )
3365        .await
3366        .expect("a cancelled loop returns at once instead of queueing on the gate");
3367        assert!(answer.is_err_and(|error| error.is_cancelled()));
3368    }
3369
3370    /// A success in between resets the doubling.
3371    #[tokio::test]
3372    async fn a_success_resets_the_doubling() {
3373        let server = MockServer::start().await;
3374        Mock::given(method("GET"))
3375            .respond_with(secondary_limit(None))
3376            .up_to_n_times(1)
3377            .mount(&server)
3378            .await;
3379        mount_idle(&server, &repo()).await;
3380        let clock = Arc::new(TestClock::default());
3381        let gateway = gateway_at(&server, &clock);
3382        let scope = ActivityScope::repository(repo());
3383
3384        let _ = gateway.queued_demand(&scope, &CancelToken::new()).await;
3385        clock.advance_secs(60);
3386        gateway
3387            .queued_demand(&scope, &CancelToken::new())
3388            .await
3389            .expect("GitHub answered");
3390        server.reset().await;
3391        Mock::given(method("GET"))
3392            .respond_with(secondary_limit(None))
3393            .mount(&server)
3394            .await;
3395        let again = gateway
3396            .queued_demand(&scope, &CancelToken::new())
3397            .await
3398            .expect_err("a fresh rate limit");
3399        assert_eq!(
3400            again.rate_limited().and_then(|limit| limit.retry_after),
3401            Some(Duration::from_secs(60)),
3402            "a limit after a success is a first limit again"
3403        );
3404    }
3405
3406    // -- cancellation -------------------------------------------------------
3407
3408    /// A token flipped between requests stops the poll before the next one.
3409    #[tokio::test]
3410    async fn cancellation_between_requests_stops_the_poll() {
3411        let server = MockServer::start().await;
3412        mount_one_queued_run(&server, &repo(), &["rm-home-win-x64"], 3, 0).await;
3413
3414        let gateway = gateway(&server);
3415        let cancel = CancelToken::new();
3416
3417        let first = gateway
3418            .repository_queued(&repo(), &cancel)
3419            .await
3420            .is_ok_and(|reading| reading.jobs.len() == 3);
3421        assert!(first, "the uncancelled poll reads both lists and the jobs");
3422        assert_eq!(gateway.requests_issued(), 3);
3423
3424        cancel.cancel();
3425        let error = gateway
3426            .repository_queued(&repo(), &cancel)
3427            .await
3428            .expect_err("a cancelled token opens no socket at all");
3429        assert!(error.is_cancelled());
3430        assert_eq!(
3431            gateway.requests_issued(),
3432            3,
3433            "a cancelled poll must spend nothing; the count is of requests attempted"
3434        );
3435    }
3436
3437    // -- the seam this module does not own ---------------------------------
3438
3439    /// The `runs-on` predicate is `b1`'s. This module builds its **input** and
3440    /// implements no part of the matching.
3441    ///
3442    /// # Read this before concluding the test is in the wrong file
3443    ///
3444    /// `c4`'s specification asks for "a `runs-on` table covering forms that must
3445    /// match, forms that must not, and an unresolvable expression, delegating
3446    /// the predicate to `b1`". For a while that table had no input: an owner
3447    /// decision had removed the per-run job listing, a workflow *run* carries no
3448    /// `runs-on`, and this gateway therefore produced nothing to match. That
3449    /// decision has been reversed — the module documentation says why — so the
3450    /// table has its input back and this test asserts both halves of the seam
3451    /// rather than only the delegation half.
3452    ///
3453    /// The division of labour is: `c4` reads each queued job's `labels` array
3454    /// and builds a [`RunsOn`], `b1` decides what matches, and `e1` applies the
3455    /// decision per policy. Constructing a `RunsOn` here is the correct side of
3456    /// that line; comparing labels here is not.
3457    ///
3458    /// # How the second half of that claim is asserted, and how far it reaches
3459    ///
3460    /// By reading this file's own source, because nothing done to `b1` can prove
3461    /// anything about what *this* module contains: every assertion in the body
3462    /// below would pass unchanged with a full label matcher sitting beside it.
3463    /// The scan takes the production half — everything above `#[cfg(test)]`,
3464    /// with comment lines dropped so that the module documentation may keep
3465    /// explaining the seam — and requires that it names none of `b1`'s
3466    /// *decision* vocabulary.
3467    ///
3468    /// `RunsOn` is no longer in the forbidden set, and could not be: this module
3469    /// constructs one per queued job, which is the whole point of the reversal.
3470    /// What stays forbidden is everything that would mean deciding rather than
3471    /// describing — `RoutingLabels`, whose `matches` and `tally` are the
3472    /// predicate, and `DemandTally`, which is the predicate's result. A `c4`
3473    /// that named either would be filtering, and filtering here would make the
3474    /// poll per-policy rather than per-target; the module documentation explains
3475    /// why that trade is refused.
3476    ///
3477    /// Like the needles in `nothing_in_this_crate_reserves_or_claims_a_job`, it
3478    /// is a tripwire on the obvious shape rather than a proof: a hand-rolled
3479    /// comparison of raw label strings that never names a `policy` type would
3480    /// walk past it. Stated rather than implied, for the same reason it is
3481    /// stated there.
3482    #[test]
3483    fn the_runs_on_predicate_is_b1s_and_this_module_only_feeds_it() {
3484        let labels = RoutingLabels::derive(
3485            &HostLabel::new("home").expect("a valid host label"),
3486            Os::Windows,
3487            Arch::X64,
3488        );
3489        let host = labels.host_label().as_str().to_string();
3490        assert_eq!(host, "rm-home-win-x64");
3491
3492        // Must match: the host label alone, in each documented form.
3493        for form in [
3494            RunsOn::Single(host.clone()),
3495            RunsOn::Many(vec![host.clone()]),
3496            RunsOn::Grouped {
3497                group: Some("Default".into()),
3498                labels: runner_manager_domain::policy::RunsOnLabels::One(host.clone()),
3499            },
3500        ] {
3501            assert!(
3502                labels.matches(&form).is_match(),
3503                "a job requiring only this policy's own label must match: {form:?}"
3504            );
3505        }
3506
3507        // Must not match: GitHub-hosted, and another host's label.
3508        for form in [
3509            RunsOn::Single("ubuntu-latest".into()),
3510            RunsOn::Many(vec![host.clone(), "rm-office-win-x64".into()]),
3511        ] {
3512            assert!(
3513                !labels.matches(&form).is_match(),
3514                "a job requiring a label this policy does not carry must not match: {form:?}"
3515            );
3516        }
3517
3518        // Unresolvable: an expression only GitHub can evaluate. Never demand and
3519        // never discarded.
3520        let expression = RunsOn::Single("${{ matrix.runner }}".into());
3521        assert!(matches!(
3522            labels.matches(&expression),
3523            RunsOnMatch::Unresolvable(UnresolvableRunsOn::Expression { .. })
3524        ));
3525
3526        // And the tally keeps the three apart, which is what `e1` clamps.
3527        let tally = labels.tally(&[
3528            RunsOn::Single(host.clone()),
3529            RunsOn::Single("ubuntu-latest".into()),
3530            expression,
3531        ]);
3532        assert_eq!(tally.demand(), 1);
3533        assert_eq!(tally.not_matched, 1);
3534        assert_eq!(tally.unresolvable.len(), 1);
3535        assert_eq!(tally.total_seen(), 3);
3536
3537        // The form this gateway actually builds is the array one, because that
3538        // is the shape the jobs API returns. Pinned so that a `RunsOn` built
3539        // from a job's `labels` really is a value `b1`'s predicate accepts,
3540        // rather than one that happens to compile.
3541        assert_eq!(
3542            RunsOn::from_job_labels(["self-hosted", host.as_str()]),
3543            RunsOn::Many(vec!["self-hosted".into(), host.clone()])
3544        );
3545        assert!(
3546            labels
3547                .matches(&RunsOn::from_job_labels([host.as_str()]))
3548                .is_match()
3549        );
3550        // And the direction that surprises people: a bare derived set does not
3551        // carry `self-hosted`, so the shape most workflows are written in --
3552        // `runs-on: [self-hosted, windows]` -- does **not** match a policy whose
3553        // operator never added those labels. That is `b1`'s superset rule
3554        // working as specified rather than a gap, and it is asserted here
3555        // because the job listing is what finally made it observable.
3556        assert!(
3557            !labels
3558                .matches(&RunsOn::from_job_labels(["self-hosted", host.as_str()]))
3559                .is_match(),
3560            "a job requiring `self-hosted` needs a policy carrying `self-hosted`"
3561        );
3562
3563        // And the other half of this test's title, which everything above leaves
3564        // untouched: that the predicate exercised here has no second
3565        // implementation in this file. See the documentation on this test for
3566        // what the scan covers, what it does not, and why `RunsOn` is absent
3567        // from the list.
3568        let production = this_file_above_its_tests_without_prose();
3569        for owned_by_b1 in ["RoutingLabels", "DemandTally"] {
3570            assert!(
3571                !production.contains(owned_by_b1),
3572                "the demand gateway names `{owned_by_b1}`, which belongs to `b1`: this \
3573                 module builds the predicate's input and does not apply it. Filtering \
3574                 here would make the poll per-policy rather than per-target and multiply \
3575                 its request cost by the number of policies sharing a target -- if an \
3576                 owner decision changed that, it belongs in this module's documentation \
3577                 and in this test before it belongs in the code"
3578            );
3579        }
3580        assert!(
3581            production.contains("RunsOn"),
3582            "and the gateway must still *build* a `RunsOn` per queued job; a production \
3583             half that named none would mean the job listing had been removed again and \
3584             the serial-matrix defect restored"
3585        );
3586    }
3587
3588    /// This file's source above its test module, with comment lines dropped.
3589    ///
3590    /// Two exclusions, each load-bearing. The **test module** goes because the
3591    /// tests above legitimately drive `b1`'s predicate and would accuse the file
3592    /// of owning what they are proving it delegates. The **comments** go because
3593    /// this module's documentation explains the seam at length and names the
3594    /// types to do it — a scan that forbade the explanation would get the
3595    /// explanation deleted, which is the trade
3596    /// `nothing_in_this_crate_reserves_or_claims_a_job` records making in the
3597    /// other direction.
3598    fn this_file_above_its_tests_without_prose() -> String {
3599        let (production, _) = include_str!("demand.rs")
3600            .split_once("\n#[cfg(test)]")
3601            .expect("this file has a test module, and the scan is meaningless without one");
3602        production
3603            .lines()
3604            .filter(|line| !line.trim_start().starts_with("//"))
3605            .collect::<Vec<_>>()
3606            .join("\n")
3607    }
3608
3609    /// The one normalisation both halves of the reservation scan use.
3610    ///
3611    /// Shared rather than written twice, because the defect it closes was two
3612    /// spellings of "the same" normalisation drifting apart: the haystack was
3613    /// lower-cased and the needle was not, so every needle carrying a capital —
3614    /// which is every type-shaped one — could not match a lower-cased haystack,
3615    /// and three of the seven assertions were vacuously true from the day they
3616    /// were written. One function cannot disagree with itself.
3617    fn normalise_for_scan(text: &str) -> String {
3618        text.to_ascii_lowercase().replace(['_', ' '], "")
3619    }
3620
3621    // The Actions-service call this design has no equivalent of, plus the shapes
3622    // an implementer would invent in its place. Matched case-insensitively with
3623    // `_` and spaces removed, so one needle catches the snake, camel and Pascal
3624    // spellings of an identifier at once — and so that the singular needle
3625    // catches the plural.
3626    //
3627    // # Why every needle carries `fn` or `struct`
3628    //
3629    // A bare `acquirejobs` fires on this crate's own prose, and it was written
3630    // that way first: three modules explain *why* there is no job reservation,
3631    // and each has to name the call that does not exist in order to say so. A
3632    // scan that forbids the explanation is a scan that gets the explanation
3633    // deleted, which costs more than it protects. Requiring the item keyword
3634    // narrows the needle to a *definition*, which is what the rule is actually
3635    // about.
3636    //
3637    // What this therefore does **not** catch is stated rather than implied: a
3638    // reservation reached through a trait method, a closure, or a
3639    // differently-named helper. It is a tripwire on the obvious shape, and
3640    // `c4`'s Definition of Done names review as the primary control.
3641    //
3642    // # And why every needle is spelled in halves
3643    //
3644    // `lib.rs`'s confidential-credential scan solves the same problem the same
3645    // way: a needle written out whole would appear in this file's own source and
3646    // the scan would accuse itself. Normalising
3647    // `concat!("fn ", "acquire", "_job")` leaves the quote-comma-quote between
3648    // the halves, so no needle ever appears whole in the text being scanned.
3649    const FORBIDDEN: &[&str] = &[
3650        concat!("fn ", "acquire", "_job"),
3651        concat!("fn ", "claim", "_job"),
3652        concat!("fn ", "lease", "_job"),
3653        concat!("fn ", "reserve", "_job"),
3654        // The acknowledgement this test's own title names alongside the other
3655        // three, and which the list did not actually carry. Spelled to the verb
3656        // rather than to a `_job` suffix, because the shape an implementer
3657        // reaches for acknowledges a *message* or an *assignment* as readily as
3658        // a job, and a suffixed needle would walk straight past those.
3659        concat!("fn ", "ack", "nowledge"),
3660        concat!("struct ", "Job", "Lease"),
3661        concat!("struct ", "Job", "Claim"),
3662        concat!("struct ", "Job", "Reservation"),
3663    ];
3664
3665    /// Which forbidden shape a source text names, if any.
3666    ///
3667    /// The whole-crate scan and its positive control both go through here, so a
3668    /// normalisation that cannot see a shape fails the control loudly instead of
3669    /// passing the scan silently. That is the entire point of the indirection:
3670    /// the arrangement this replaced had the control re-deriving the needle
3671    /// itself, and a copy that agrees with a buggy original proves nothing — it
3672    /// re-derived the needle the same wrong way and went green.
3673    fn forbidden_shape_in(source: &str) -> Option<&'static str> {
3674        let haystack = normalise_for_scan(source);
3675        FORBIDDEN
3676            .iter()
3677            .copied()
3678            .find(|forbidden| haystack.contains(&normalise_for_scan(forbidden)))
3679    }
3680
3681    /// No reservation, claim, lease, or acknowledgement call exists anywhere in
3682    /// this crate.
3683    ///
3684    /// `AcquireJobs` had no REST replacement, so a well-meaning implementer
3685    /// reaches for a local lease to "fix" the surplus-runner case. Three
3686    /// specifications say not to; this makes the instruction executable, over
3687    /// the whole crate rather than only this file, because the edit would most
3688    /// likely land in a new module rather than here.
3689    #[test]
3690    fn nothing_in_this_crate_reserves_or_claims_a_job() {
3691        const SOURCES: &[(&str, &str)] = &[
3692            ("conditional.rs", include_str!("conditional.rs")),
3693            ("demand.rs", include_str!("demand.rs")),
3694            ("device_flow.rs", include_str!("device_flow.rs")),
3695            ("jit.rs", include_str!("jit.rs")),
3696            ("lib.rs", include_str!("lib.rs")),
3697            ("rest.rs", include_str!("rest.rs")),
3698            ("traffic.rs", include_str!("traffic.rs")),
3699        ];
3700        // `SOURCES` is a snapshot, and a snapshot makes "anywhere in the crate"
3701        // false the moment a file is added. The walk below turns the claim back
3702        // into a claim.
3703        //
3704        // It **recurses**, and that is not incidental. `lib.rs` records the
3705        // defect a single `read_dir` produced for the same pin: a module
3706        // directory (`src/rest/mod.rs`) arrives as the entry `rest`, which does
3707        // not end in `.rs`, so a flat filter drops it and takes every file
3708        // underneath with it — leaving the pin passing while the files it exists
3709        // to cover are scanned by nothing at all.
3710        fn walk(directory: &std::path::Path, prefix: &str, found: &mut Vec<String>) {
3711            for entry in std::fs::read_dir(directory).expect("the crate's own src/ is readable") {
3712                let entry = entry.expect("a readable directory entry");
3713                let name = entry.file_name().to_string_lossy().into_owned();
3714                // `/`-joined, which is what `include_str!` takes on every
3715                // platform, so the two sides compare directly.
3716                let joined = if prefix.is_empty() {
3717                    name.clone()
3718                } else {
3719                    format!("{prefix}/{name}")
3720                };
3721                if entry.path().is_dir() {
3722                    walk(&entry.path(), &joined, found);
3723                } else if name.ends_with(".rs") {
3724                    found.push(joined);
3725                }
3726            }
3727        }
3728
3729        let mut listed: Vec<&str> = SOURCES.iter().map(|(name, _)| *name).collect();
3730        listed.sort_unstable();
3731        let mut on_disk = Vec::new();
3732        walk(
3733            std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/src")),
3734            "",
3735            &mut on_disk,
3736        );
3737        on_disk.sort_unstable();
3738        assert_eq!(
3739            listed, on_disk,
3740            "a source file was added or removed; this scan claims to cover the whole \
3741             crate and a stale list makes that claim false"
3742        );
3743
3744        for (name, source) in SOURCES {
3745            assert_eq!(
3746                forbidden_shape_in(source),
3747                None,
3748                "{name} names a forbidden shape: there is no job reservation on the REST \
3749                 path, and a local lease coordinates this host with itself and with \
3750                 nothing else"
3751            );
3752        }
3753    }
3754
3755    /// The scan above can actually see the things it forbids.
3756    ///
3757    /// A substring scan that never matches passes for the wrong reason. This
3758    /// plants the exact shapes and runs them through [`forbidden_shape_in`] —
3759    /// the same matcher the scan uses, rather than a second copy of it.
3760    ///
3761    /// # Why one planted shape was not enough
3762    ///
3763    /// It planted only the `fn` form, and that form is the one that could not
3764    /// fail: a `fn` needle is already lower-case, so it survived a
3765    /// normalisation that lower-cased the haystack and not the needle. Every
3766    /// type-shaped needle carries capitals and therefore could never match the
3767    /// lower-cased haystack — all three of them were un-catchable, and their
3768    /// three assertions vacuously true, while this control stayed green. (They
3769    /// are not written out here for the reason the list itself is spelled in
3770    /// halves: a literal would make this file fail its own gate.) Both kinds
3771    /// are planted now, and the case-shape of the plant is the property under
3772    /// test rather than an incidental detail of it.
3773    #[test]
3774    fn the_reservation_scan_catches_an_injected_reservation() {
3775        // Assembled from fragments rather than written out, because the scan
3776        // above reads this very file: a literal here would make the crate fail
3777        // its own gate, which is the trap that forced the needles to carry an
3778        // item keyword in the first place.
3779        let call = format!(
3780            "    async {} {}{}(&self) -> Result<Vec<Job>, InventoryError> {{",
3781            "fn", "acquire", "_jobs"
3782        );
3783        let item = format!("{} {}{} {{ id: u64 }}", "struct", "Job", "Lease");
3784        let acknowledgement = format!(
3785            "    async {} {}{}(&self, id: u64) {{",
3786            "fn", "ack", "nowledge_assignment"
3787        );
3788
3789        for (planted, expected) in [
3790            (&call, concat!("fn ", "acquire", "_job")),
3791            (&item, concat!("struct ", "Job", "Lease")),
3792            (&acknowledgement, concat!("fn ", "ack", "nowledge")),
3793        ] {
3794            assert_eq!(
3795                forbidden_shape_in(planted),
3796                Some(expected),
3797                "the scan's own matcher cannot see {planted:?}, so every negative \
3798                 assertion it makes about that shape is worthless"
3799            );
3800        }
3801
3802        // And the plural really is caught by the singular needle, which is the
3803        // one thing about the list above that is not obvious from reading it.
3804        assert!(
3805            call.contains("acquire_jobs"),
3806            "the planted shape is the plural the Actions-service protocol used"
3807        );
3808        // Likewise the acknowledgement needle stops at the verb, so it catches
3809        // the shapes that acknowledge something other than a job by name.
3810        assert!(
3811            acknowledgement.contains("_assignment"),
3812            "the planted acknowledgement names no job, which is why the needle \
3813             carrying a `_job` suffix would have missed it"
3814        );
3815    }
3816}