Skip to main content

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;