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