git_policy_cli/work_item.rs
1//! What:
2//! The durable work-item record: a small file in the Git directory that says "this landed object needs this
3//! asynchronous work", written by the wrapper and drained by a separate resident consumer.
4//! Why:
5//! A wrapper process exits when its command ends, and it may start no program, so work that must not delay a landing
6//! (the standards review, later auto-push and maintenance) is left as a record instead. The record is generic: a kind,
7//! the landed object and an id derived from both, so a retry cannot duplicate it. The design, its failure cases and
8//! the consumer contract are in `doc/planning/cli-git-standards-review-port.md`. Nothing calls this yet.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! await enqueue(workDir, { kind: 'review', oid, ... }); const item = await claimNext(workDir); await finish(workDir, item);
13//! ```
14
15use super::claim_time::ClaimTime;
16use super::private_storage::{create_private_directory_all, write_private_file_unsynced};
17use super::random_id::random_uuid;
18use super::service_timing::log;
19pub use super::work_item_recovery::{Recovered, next_claim_expiry, requeue_expired, requeue_expired_reporting};
20use serde_json::{Map, Value};
21use std::path::{Path, PathBuf};
22use std::time::{Duration, SystemTime};
23
24/// The record format version.
25pub const VERSION: u64 = 1;
26
27/// Directory under the worktree's own Git directory (`WorkItem::git_dir`) that holds the state directories.
28pub const WORK_DIRECTORY: &str = "cli-git/work";
29
30/// The state directories, in the order an item moves through them.
31pub const PENDING: &str = "pending";
32/// Held by one consumer, with a lease.
33pub const CLAIMED: &str = "claimed";
34/// Held by one instance that is returning an expired claim to `pending`; see `requeue_with`.
35pub const RECOVERING: &str = "recovering";
36/// Finished.
37pub const DONE: &str = "done";
38/// Given up after too many tries.
39pub const FAILED: &str = "failed";
40
41/// What:
42/// The kind of work an item asks for.
43/// Why:
44/// One record format carries every asynchronous job; the kind picks the consumer's handler.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// type WorkKind = 'review' | 'push' | 'maintenance';
49/// ```
50#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub enum WorkKind {
52 /// The standards-review shadow run.
53 Review,
54 /// An auto-push of the branch.
55 Push,
56 /// Repository maintenance.
57 Maintenance,
58}
59
60/// Methods over a kind.
61impl WorkKind {
62 /// The kind's name, which is also the start of an item's id.
63 pub fn name(self) -> &'static str {
64 return match self {
65 WorkKind::Review => "review",
66 WorkKind::Push => "push",
67 WorkKind::Maintenance => "maintenance",
68 };
69 }
70
71 /// The kind a name spells.
72 pub fn from_name(name: &str) -> Option<WorkKind> {
73 return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance].into_iter().find(|kind| return kind.name() == name);
74 }
75}
76
77/// What:
78/// One work item: what to do, to which landed object, and where.
79/// Why:
80/// The consumer needs only the object name and the places to find the repository; it reads the patch itself.
81///
82/// In TS you'd write (pseudocode):
83/// ```ts
84/// type WorkItem = { kind; repository: string; gitDir: string; ref: string; oid: string; parent: string; created: string; tries: number };
85/// ```
86#[derive(Clone, Debug, Eq, PartialEq)]
87pub struct WorkItem {
88 /// What to do.
89 pub kind: WorkKind,
90 /// The worktree root.
91 pub repository: String,
92 /// The Git directory of the worktree that made the commit and whose service instance runs the item: the common directory
93 /// for the main worktree, `<common>/worktrees/<name>` for a linked one. The work records are under it, and
94 /// `is_acceptable` refuses an item whose value is not the directory it is read from.
95 pub git_dir: String,
96 /// The branch reference that received the commit.
97 pub branch_ref: String,
98 /// The landed commit.
99 pub oid: String,
100 /// Its first parent, or empty for a root commit.
101 pub parent: String,
102 /// `toISOString` text of the enqueue time.
103 pub created: String,
104 /// Failed or abandoned attempts so far.
105 pub tries: u32,
106}
107
108/// Methods over an item.
109impl WorkItem {
110 /// The id and file name: the kind and the landed object, so the same landing always yields the same id.
111 pub fn id(&self) -> String {
112 return format!("{}-{}", self.kind.name(), self.oid);
113 }
114
115 /// The record's one JSON line.
116 pub fn encode(&self) -> String {
117 let mut fields: Map<String, Value> = Map::new();
118 fields.insert(String::from("v"), Value::from(VERSION));
119 fields.insert(String::from("tries"), Value::from(self.tries));
120 for (name, text) in [
121 ("kind", self.kind.name()),
122 ("id", self.id().as_str()),
123 ("repository", self.repository.as_str()),
124 ("gitDir", self.git_dir.as_str()),
125 ("ref", self.branch_ref.as_str()),
126 ("oid", self.oid.as_str()),
127 ("parent", self.parent.as_str()),
128 ("created", self.created.as_str()),
129 ] {
130 fields.insert(String::from(name), Value::from(text));
131 }
132 return format!("{}\n", Value::Object(fields));
133 }
134
135 /// Read a record; any other version, an unknown kind, a missing field or an id that is not derived from the kind and
136 /// object is refused with a message.
137 pub fn decode(text: &str) -> Result<WorkItem, String> {
138 let value: Value = serde_json::from_str(text).map_err(|error| return format!("the record is not JSON: {error}"))?;
139 let field = |name: &str| -> Result<String, String> {
140 return value.get(name).and_then(Value::as_str).map(String::from).ok_or_else(|| return format!("the record has no text field {name}"));
141 };
142 if value.get("v").and_then(Value::as_u64) != Some(VERSION) {
143 return Err(String::from("the record has an unsupported version"));
144 }
145 let kind: WorkKind = WorkKind::from_name(field("kind")?.as_str()).ok_or_else(|| return String::from("the record has an unknown kind"))?;
146 let tries: u32 = value.get("tries").and_then(Value::as_u64).and_then(|count| return u32::try_from(count).ok()).ok_or_else(|| return String::from("the record has no tries"))?;
147 let item: WorkItem = WorkItem {
148 kind,
149 repository: field("repository")?,
150 git_dir: field("gitDir")?,
151 branch_ref: field("ref")?,
152 oid: field("oid")?,
153 parent: field("parent")?,
154 created: field("created")?,
155 tries,
156 };
157 if field("id")? != item.id() {
158 return Err(String::from("the record's id is not derived from its kind and object"));
159 }
160 return Ok(item);
161 }
162}
163
164/// What:
165/// Whether an enqueue added the item or found it already known.
166/// Why:
167/// A landing that is replayed, or a daemon that restarts, enqueues again; the second time must change nothing.
168///
169/// In TS you'd write (pseudocode):
170/// ```ts
171/// type Enqueued = 'added' | 'known';
172/// ```
173#[derive(Clone, Copy, Debug, Eq, PartialEq)]
174pub enum Enqueued {
175 /// A new pending item was written.
176 Added,
177 /// The id already exists in some state directory.
178 Known,
179}
180
181/// The directory of one state.
182pub fn state_directory(work_dir: &Path, state: &str) -> PathBuf {
183 return work_dir.join(state);
184}
185
186/// What:
187/// Write the item as a pending record, whole or absent, unless its id is already in any state.
188/// The producer never waits for a consumer and never reads anything back.
189/// Why:
190/// The id is the idempotency key; the exclusive publish makes two racing producers agree on one winner.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// await writeExclusive(join(workDir, 'pending', item.id), item.encode())
195/// ```
196pub fn enqueue(work_dir: &Path, item: &WorkItem) -> Result<Enqueued, String> {
197 for state in [PENDING, CLAIMED, DONE, FAILED] {
198 create_private_directory_all(state_directory(work_dir, state).as_path()).map_err(|error| return format!("creating the {state} directory failed: {error}"))?;
199 }
200 let id: String = item.id();
201 // The states are looked at in the order an item moves through them, so an item that moves on while they are looked at is
202 // found in the next one: looking at `done` first could miss an item that settled after that look and left `claimed`
203 // before the look at it.
204 if [CLAIMED, RECOVERING].iter().any(|state| { return entries_oldest_first(work_dir, state).iter().any(|(name, _)| { return id_of_entry(name.as_str()) == id; }); }) {
205 return Ok(Enqueued::Known);
206 }
207 if state_directory(work_dir, DONE).join(id.as_str()).exists() || state_directory(work_dir, FAILED).join(id.as_str()).exists() {
208 return Ok(Enqueued::Known);
209 }
210 return match write_private_file_unsynced(state_directory(work_dir, PENDING).join(id.as_str()).as_path(), item.encode().as_bytes()) {
211 Ok(()) => Ok(Enqueued::Added),
212 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(Enqueued::Known),
213 Err(error) => Err(format!("writing the work item {id} failed: {error}")),
214 };
215}
216
217/// What:
218/// A held item: the decoded record, its id, and the name of the file that holds it.
219/// Why:
220/// Finishing needs the claim's file name, not the id: the name carries the generation of the claim, so a settlement or a
221/// renewal reaches only the claim it was made for. The consumer needs the item.
222///
223/// In TS you'd write (pseudocode):
224/// ```ts
225/// type Claimed = { id: string; name: string; item: WorkItem };
226/// ```
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct Claimed {
229 /// File name in every state directory but `claimed` and `recovering`, where the file name is `name`.
230 pub id: String,
231 /// File name in `claimed` as of the claim or the last renewal: the id, the claim's generation and the clock reading.
232 pub name: String,
233 /// The decoded record.
234 pub item: WorkItem,
235}
236
237/// Separates the parts of a claim's file name; an id is a kind and a hexadecimal object name and holds none.
238const NAME_SEPARATOR: char = '~';
239
240/// The most characters an object name has: 64 hexadecimal digits of a SHA-256 name. With the longest kind, a generation, a reading that
241/// fills every field and the suffix of a recovery's temporary file, such an id still makes a name of at most 223 bytes, under
242/// the 255 a file name may have; a longer id could make a claim's name fit and the name of its temporary file not.
243pub(crate) const MAX_OBJECT_NAME_LENGTH: usize = 64;
244
245/// Whether `object_name` is what follows the kind in an id: 1 to `MAX_OBJECT_NAME_LENGTH` ASCII letters and digits, so it
246/// holds no separator of a claim's name and no character a file name treats specially.
247fn is_object_name(object_name: &str) -> bool {
248 return !object_name.is_empty() && object_name.len() <= MAX_OBJECT_NAME_LENGTH && object_name.bytes().all(|byte| { return byte.is_ascii_alphanumeric(); });
249}
250
251/// What:
252/// Set the modification time of the file at `path` to now.
253/// Why:
254/// A release that cannot read the reading in a claim's name ages the claim by the file's modification time, which a rename
255/// keeps. Claims and renewals are renames, so without this such a reader sees the time the item was queued, not the last
256/// renewal,
257/// and takes a live holder's claim.
258/// A failure is returned,
259/// because a claim or renewal that went on without the stamp
260/// would be one that such a reader takes (a file another user owns cannot be stamped).
261///
262/// In TS you'd write (pseudocode):
263/// ```ts
264/// await utimes(path, new Date(), new Date())
265/// ```
266fn stamp_modification_time(path: &Path) -> std::io::Result<()> {
267 return std::fs::File::open(path)?.set_modified(SystemTime::now());
268}
269
270/// How a claim or renewal stamps the file it holds.
271type Stamp<'a> = &'a dyn Fn(&Path) -> std::io::Result<()>;
272
273/// Whether `id` is a kind and an object name,
274/// which makes every name built from it fit in a file name.
275pub(crate) fn is_valid_id(id: &str) -> bool {
276 return [WorkKind::Review, WorkKind::Push, WorkKind::Maintenance]
277 .iter()
278 .any(|kind| { return id.strip_prefix(format!("{}-", kind.name()).as_str()).is_some_and(is_object_name); });
279}
280
281/// What:
282/// The id an entry of `claimed` or `recovering` is the claim of: the part of its name before the generation.
283/// Why:
284/// A claim by an earlier release is named by the id alone, so a name with no separator is its own id.
285///
286/// In TS you'd write (pseudocode):
287/// ```ts
288/// name.split('~')[0]
289/// ```
290pub fn id_of_entry(name: &str) -> &str {
291 return name.split_once(NAME_SEPARATOR).map_or(name, |(id, _)| { return id; });
292}
293
294/// What:
295/// The name of the file that holds the claim of `id` of the generation `token` as of the reading `stamp`.
296/// Why:
297/// The generation is unique to one acquisition and the reading says when the holder last proved it was alive, so the name
298/// alone tells every other instance whose claim this is and whether it ran out, and a rename of exactly that name is the only
299/// way to take it: a holder that renews changes the name, so a take made from an older look finds nothing to take.
300///
301/// In TS you'd write (pseudocode):
302/// ```ts
303/// `${id}~${token}~${stamp.encode()}`
304/// ```
305pub fn claim_file_name(id: &str, token: &str, stamp: &ClaimTime) -> String {
306 return format!("{id}{NAME_SEPARATOR}{token}{NAME_SEPARATOR}{}", stamp.encode());
307}
308
309/// The generation and the reading in a claim's file name, or nothing for a name by an earlier release.
310pub(crate) fn parse_claim_name(name: &str) -> Option<(&str, ClaimTime)> {
311 let (_, rest) = name.split_once(NAME_SEPARATOR)?;
312 let (token, stamp) = rest.split_once(NAME_SEPARATOR)?;
313 if token.is_empty() || !token.bytes().all(|byte| { return byte.is_ascii_hexdigit(); }) {
314 return None;
315 }
316 return Some((token, ClaimTime::decode(stamp)?));
317}
318
319/// A fresh generation: 32 random hexadecimal digits.
320pub(crate) fn new_generation() -> Result<String, String> {
321 return random_uuid()
322 .map(|uuid| { return uuid.replace('-', ""); })
323 .map_err(|error| { return format!("making the generation of a claim failed: {}", error.0); });
324}
325
326/// The names in a state directory with their modification times, oldest first.
327pub(crate) fn entries_oldest_first(work_dir: &Path, state: &str) -> Vec<(String, SystemTime)> {
328 let mut found: Vec<(String, SystemTime)> = Vec::new();
329 if let Ok(listing) = std::fs::read_dir(state_directory(work_dir, state)) {
330 for entry in listing.flatten() {
331 let modified: SystemTime = entry.metadata().and_then(|meta| return meta.modified()).unwrap_or(SystemTime::UNIX_EPOCH);
332 found.push((entry.file_name().to_string_lossy().into_owned(), modified));
333 }
334 }
335 found.sort_by(|left, right| return left.1.cmp(&right.1).then_with(|| return left.0.cmp(&right.0)));
336 return found;
337}
338
339/// The path of the file that holds the claim of `id` now, or nothing when it is not claimed or being recovered.
340pub fn find_claim(work_dir: &Path, id: &str) -> Option<PathBuf> {
341 return [CLAIMED, RECOVERING]
342 .into_iter()
343 .flat_map(|state| { return entries_oldest_first(work_dir, state).into_iter().map(move |(name, _)| { return (state, name); }); })
344 .find(|(_, name)| { return id_of_entry(name.as_str()) == id; })
345 .map(|(state, name)| { return state_directory(work_dir, state).join(name); });
346}
347
348/// What:
349/// Claim the oldest pending item by renaming it into `claimed` under a name that carries a fresh generation and the
350/// reading `now`. A record that does not decode is moved to `failed` so it cannot block the queue.
351/// Why:
352/// A rename is atomic: of two consumers racing for one item, the loser sees the file gone and takes the next one. The
353/// generation and the reading are in the name, so the claim is whole from the instant it exists; nothing is written to it.
354///
355/// In TS you'd write (pseudocode):
356/// ```ts
357/// await rename(join(workDir, 'pending', id), join(workDir, 'claimed', claimFileName(id, newGeneration(), now)))
358/// ```
359pub fn claim_next(work_dir: &Path, now: &ClaimTime) -> Result<Option<Claimed>, String> {
360 return claim_next_of(work_dir, now, &[WorkKind::Review, WorkKind::Push, WorkKind::Maintenance]);
361}
362
363/// What:
364/// Claim the oldest pending item of the given kinds, leaving every other kind where it is.
365/// Why:
366/// Consumers of different kinds share one queue; the kind starts the file name, so a consumer never opens, moves or
367/// stamps an item that belongs to another.
368///
369/// In TS you'd write (pseudocode):
370/// ```ts
371/// await rename(join(workDir, 'pending', id), join(workDir, 'claimed', name)) // only ids that start with a handled kind
372/// ```
373pub fn claim_next_of(work_dir: &Path, now: &ClaimTime, kinds: &[WorkKind]) -> Result<Option<Claimed>, String> {
374 return claim_next_stamped(work_dir, now, kinds, &stamp_modification_time);
375}
376
377/// What:
378/// `claim_next_of` with the stamp of the claim's modification time given,
379/// so a test can make the stamp fail.
380/// Why:
381/// The entry is taken into `recovering` under its claim name first,
382/// where no reader of `claimed` looks and where the
383/// entry is this instance's alone;
384/// only there is it stamped,
385/// and only then published into `claimed`.
386/// A claim that cannot be
387/// stamped or published goes back to `pending` with the modification time it had,
388/// so a queue that cannot be drained keeps
389/// its true age and the backlog notice still fires.
390///
391/// In TS you'd write (pseudocode):
392/// ```ts
393/// await rename(pending, staged);
394/// await stamp(staged);
395/// await rename(staged, join(workDir, 'claimed', name))
396/// ```
397fn claim_next_stamped(work_dir: &Path, now: &ClaimTime, kinds: &[WorkKind], stamp: Stamp<'_>) -> Result<Option<Claimed>, String> {
398 for (id, queued) in entries_oldest_first(work_dir, PENDING) {
399 let Some(kind) = kinds.iter().find(|kind| return id.starts_with(format!("{}-", kind.name()).as_str())) else {
400 continue;
401 };
402 // An id that is not a kind and an object name could make names that do not fit; it never becomes a claim.
403 if !is_object_name(&id[kind.name().len() + 1..]) {
404 quarantine_pending(work_dir, id.as_str(), format!("is not a kind and an object name of at most {MAX_OBJECT_NAME_LENGTH} letters and digits").as_str());
405 continue;
406 }
407 let name: String = claim_file_name(id.as_str(), new_generation()?.as_str(), now);
408 let pending_path: PathBuf = state_directory(work_dir, PENDING).join(id.as_str());
409 let staged: PathBuf = state_directory(work_dir, RECOVERING).join(name.as_str());
410 let claimed_path: PathBuf = state_directory(work_dir, CLAIMED).join(name.as_str());
411 if create_private_directory_all(state_directory(work_dir, RECOVERING).as_path()).is_err() || std::fs::rename(pending_path.as_path(), staged.as_path()).is_err() {
412 continue;
413 }
414 let published: std::io::Result<()> = stamp(staged.as_path()).and_then(|()| return std::fs::rename(staged.as_path(), claimed_path.as_path()));
415 if let Err(error) = published {
416 log(format!("the claim of {id} could not be taken, so the item stays queued: {error}").as_str());
417 // The staged file is this instance's alone:
418 // the age it had is put back before it is queued again,
419 // whatever the stamp did.
420 let restored: std::io::Result<()> = std::fs::File::open(staged.as_path()).and_then(|file| return file.set_modified(queued));
421 if let Err(requeue_error) = restored.and_then(|()| return std::fs::rename(staged.as_path(), pending_path.as_path())) {
422 log(format!("the staged claim of {id} could not be queued again; its lease will run out and recover it: {requeue_error}").as_str());
423 }
424 continue;
425 }
426 let text: String = std::fs::read_to_string(claimed_path.as_path()).map_err(|error| return format!("reading the claim of {id} failed: {error}"))?;
427 match WorkItem::decode(text.as_str()) {
428 // The record is the work the file name says;
429 // a record of other work would be settled as done under this id.
430 Ok(item) if item.id() == id => return Ok(Some(Claimed { id, name, item })),
431 _ => {
432 let _ = std::fs::rename(claimed_path.as_path(), state_directory(work_dir, FAILED).join(id.as_str()));
433 }
434 }
435 }
436 return Ok(None);
437}
438
439/// Moves the entry `id` of `pending` to `failed` and says why,
440/// where nothing can be done about a failure of either.
441fn quarantine_pending(work_dir: &Path, id: &str, reason: &str) {
442 let failed: PathBuf = state_directory(work_dir, FAILED);
443 let moved: bool = create_private_directory_all(failed.as_path()).is_ok() && std::fs::rename(state_directory(work_dir, PENDING).join(id), failed.join(id)).is_ok();
444 log(format!("the queued entry {id:?} {reason}, so it was {}", if moved { "moved to failed" } else { "left in pending because moving it failed" }).as_str());
445}
446
447/// What:
448/// Move a claimed item to `done` (or to `failed`), after the consumer's result is durable.
449/// Why:
450/// A crash before this rename leaves the item claimed, and its lease runs out; the work is then done again, so a
451/// handler must tolerate a repeat. The rename names the claim's generation, so a holder whose claim was taken over and
452/// claimed again by another settles nothing of the other's.
453///
454/// In TS you'd write (pseudocode):
455/// ```ts
456/// await rename(join(workDir, 'claimed', claimed.name), join(workDir, 'done', claimed.id))
457/// ```
458pub fn settle(work_dir: &Path, claimed: &Claimed, state: &str) -> Result<(), String> {
459 return settle_named(work_dir, claimed.name.as_str(), state);
460}
461
462/// `settle` for the claim file `name`, whose id is the part of the name before the generation.
463pub fn settle_named(work_dir: &Path, name: &str, state: &str) -> Result<(), String> {
464 let id: &str = id_of_entry(name);
465 return std::fs::rename(state_directory(work_dir, CLAIMED).join(name), state_directory(work_dir, state).join(id))
466 .map_err(|error| return format!("moving {id} to {state} failed: {error}"));
467}
468
469/// What:
470/// Start the lease of the claim file `name` again at `now`, returning its new name; nothing when the claim is no longer in
471/// `claimed` under that name or its modification time could not be set.
472/// Why:
473/// A holder that is still working must not look abandoned: every consumer of a directory that several consumers drain reads
474/// the reading in the name as the proof of life. The renewal is a rename of the exact name the holder last wrote, so a
475/// claim that was taken over, settled, or taken over and claimed again by someone else is not found and is never brought
476/// back or renewed on the other's behalf; and an instance that looked at the old name and means to take it finds nothing.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// await rename(join(workDir, 'claimed', name), join(workDir, 'claimed', claimFileName(id, token, now)))
481/// ```
482pub fn renew_claim(work_dir: &Path, name: &str, now: &ClaimTime) -> Option<String> {
483 return renew_claim_stamped(work_dir, name, now, &stamp_modification_time);
484}
485
486/// `renew_claim` with the stamp of the claim's modification time given,
487/// so a test can make the stamp fail.
488/// A renewal whose
489/// stamp failed is no renewal:
490/// an older reader would still see the old modification time and take the claim.
491fn renew_claim_stamped(work_dir: &Path, name: &str, now: &ClaimTime, stamp: Stamp<'_>) -> Option<String> {
492 let id: &str = id_of_entry(name);
493 // A claim by an earlier release has no generation; its renewal gives it one.
494 let token: String = match parse_claim_name(name) {
495 Some((token, _)) => token.to_owned(),
496 None => new_generation().ok()?,
497 };
498 let renewed: String = claim_file_name(id, token.as_str(), now);
499 let directory: PathBuf = state_directory(work_dir, CLAIMED);
500 if let Err(error) = stamp(directory.join(name).as_path()) {
501 log(format!("the claim {name} could not be renewed: {error}").as_str());
502 return None;
503 }
504 return std::fs::rename(directory.join(name), directory.join(renewed.as_str())).ok().map(|()| { return renewed; });
505}
506
507/// What:
508/// How long the oldest pending item has waited, or `None` when nothing is pending.
509/// Why:
510/// The wrapper's one-line notice that no consumer is draining the queue is this age past a bound; a process id in a
511/// file would be meaningless across process-id namespaces.
512///
513/// In TS you'd write (pseudocode):
514/// ```ts
515/// now - oldestPending.mtime
516/// ```
517pub fn oldest_pending_age(work_dir: &Path, now: SystemTime) -> Option<Duration> {
518 return entries_oldest_first(work_dir, PENDING).first().map(|(_, created)| return now.duration_since(*created).unwrap_or(Duration::ZERO));
519}
520
521/// Controls stay out of the release executable.
522#[cfg(test)]
523#[path = "work_item_tests.rs"]
524mod tests;