1use std::path::{Path, PathBuf};
34
35use serde::{Deserialize, Serialize};
36
37use crate::error::{self, Error, Result};
38use crate::session::{Lifecycle, SessionRequest, SessionToken};
39use crate::store::{self, Resolution};
40use crate::workspace::{self, ProcessStart, Record, Ref};
41use crate::workspaces::{self, Bound, Overrides, Resolved};
42use crate::{git, guidance, ids, lock, processes};
43
44mod maintain;
45
46pub use maintain::{
47 pool_maintain, IdentityMaintenance, IdentityOutcome, MaintainReport, SlotDisk, SlotMaintenance,
48 SlotOutcome,
49};
50
51pub const SLOT_VERSION: u32 = 1;
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(try_from = "String", into = "String")]
63pub(crate) struct Timestamp(String);
64
65impl TryFrom<String> for Timestamp {
66 type Error = String;
67
68 fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
69 match ids::is_timestamp(&value) {
70 true => Ok(Timestamp(value)),
71 false => Err(format!(
72 "{value:?} is not a timestamp this build can order by (RFC3339, millisecond, \
73 UTC)"
74 )),
75 }
76 }
77}
78
79impl From<Timestamp> for String {
80 fn from(stamp: Timestamp) -> Self {
81 stamp.0
82 }
83}
84
85impl Timestamp {
86 fn now() -> Self {
88 Timestamp(ids::timestamp())
89 }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
98pub(crate) struct SlotRecord {
99 pub version: u32,
101 pub number: u32,
103 pub identity: String,
105 pub execution_checkout: PathBuf,
108 pub created: Timestamp,
110 pub maintaining: Option<Claim>,
112 pub last_maintained: Option<Timestamp>,
114 pub last_outcome: Option<MaintenanceOutcome>,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub(crate) struct Claim {
124 pub pid: u32,
126 pub started: ProcessStart,
128 pub since: Timestamp,
130}
131
132impl Claim {
133 fn is_live(&self) -> bool {
135 workspace::process_started(self.pid) == Some(self.started)
136 }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(rename_all = "kebab-case")]
142pub enum MaintenanceOutcome {
143 Succeeded,
145 Failed {
147 exit: Option<i32>,
149 },
150 TimedOut,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(tag = "state", rename_all = "kebab-case")]
157pub enum SlotState {
158 Idle,
161 InUse {
163 session: SessionToken,
165 },
166 Maintaining {
168 pid: u32,
170 since: String,
177 },
178 Broken {
181 reason: String,
183 },
184}
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct SlotStatus {
189 pub number: u32,
191 pub path: PathBuf,
193 pub execution_checkout: PathBuf,
196 pub state: SlotState,
198 pub last_maintained: Option<String>,
204 pub last_outcome: Option<MaintenanceOutcome>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216pub struct WorkspaceCapacity {
217 pub identity: String,
219 pub pool: u32,
221 pub slots: u32,
223 pub idle: u32,
227 pub in_use: u32,
229 pub maintaining: u32,
231 pub overflow: Bound,
233 pub overflow_in_use: u32,
236 pub admits: Bound,
238 pub admitted: bool,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct PoolStatus {
245 pub maintenance: crate::repository_config::MaintenanceSource,
247 pub capacity: WorkspaceCapacity,
249 pub slots: Vec<SlotStatus>,
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255pub struct PruneReport {
256 pub removed: Vec<u32>,
258 pub kept: Vec<(u32, String)>,
260}
261
262pub(crate) fn pool_dir(identity_root: &Path) -> PathBuf {
264 identity_root.join("pool")
265}
266
267pub(crate) fn placement_identity(pool: &Path) -> String {
273 format!("pool:{}", pool.display())
274}
275
276fn record_path(slot: &Path) -> PathBuf {
278 slot.join("slot.json")
279}
280
281fn read_record(
290 slot: &Path,
291 number: u32,
292 identity: &str,
293) -> std::result::Result<SlotRecord, String> {
294 let path = record_path(slot);
295 let raw = std::fs::read_to_string(&path)
296 .map_err(|e| format!("its record at {} cannot be read: {e}", path.display()))?;
297 let record: SlotRecord = serde_json::from_str(&raw)
298 .map_err(|e| format!("its record at {} is malformed: {e}", path.display()))?;
299 if record.version != SLOT_VERSION {
300 return Err(format!(
301 "its record at {} declares version {}; this build reads version {SLOT_VERSION}",
302 path.display(),
303 record.version
304 ));
305 }
306 if record.number != number {
307 return Err(format!(
308 "its record at {} is for slot {}, not for slot {number}",
309 path.display(),
310 record.number
311 ));
312 }
313 if record.identity != identity {
314 return Err(format!(
315 "its record at {} belongs to {:?}, not to {identity:?}",
316 path.display(),
317 record.identity
318 ));
319 }
320 if !record.execution_checkout.is_absolute() {
321 return Err(format!(
322 "its record at {} names an execution checkout at {}, which is not an absolute path",
323 path.display(),
324 record.execution_checkout.display()
325 ));
326 }
327 Ok(record)
328}
329
330fn write_record(slot: &Path, record: &SlotRecord) -> Result<()> {
332 let path = record_path(slot);
333 let json = serde_json::to_string_pretty(record).map_err(error::at("serialize", &path))?;
334 crate::home::atomic_write(&path, &format!("{json}\n"))
335}
336
337#[derive(Debug, Clone)]
339struct Surveyed {
340 number: u32,
341 dir: PathBuf,
342 record: Option<SlotRecord>,
344 state: SlotState,
345}
346
347impl Surveyed {
348 fn clone_dir(&self) -> PathBuf {
349 self.dir.join("clone")
350 }
351
352 fn worktree(&self) -> PathBuf {
353 self.dir.join("worktree")
354 }
355
356 fn lender(&self) -> Option<&Path> {
357 self.record
358 .as_ref()
359 .map(|record| record.execution_checkout.as_path())
360 }
361
362 fn takeable(&self) -> bool {
363 matches!(self.state, SlotState::Idle | SlotState::Broken { .. })
364 }
365
366 fn status(&self) -> SlotStatus {
367 SlotStatus {
368 number: self.number,
369 path: self.dir.clone(),
370 execution_checkout: self.lender().map(Path::to_path_buf).unwrap_or_default(),
371 state: self.state.clone(),
372 last_maintained: self
373 .record
374 .as_ref()
375 .and_then(|record| record.last_maintained.clone())
376 .map(String::from),
377 last_outcome: self.record.as_ref().and_then(|record| record.last_outcome),
378 }
379 }
380}
381
382struct Survey {
384 pool: PathBuf,
385 slots: Vec<Surveyed>,
386 open: Vec<Record>,
388}
389
390impl Survey {
391 fn overflow(&self) -> Vec<&Record> {
395 self.open
396 .iter()
397 .filter(|record| record.slot.is_none())
398 .collect()
399 }
400
401 fn count(&self, wanted: impl Fn(&SlotState) -> bool) -> u32 {
402 u32::try_from(self.slots.iter().filter(|slot| wanted(&slot.state)).count())
403 .unwrap_or(u32::MAX)
404 }
405}
406
407fn survey(identity: &str, pool: &Path) -> Result<Survey> {
412 let open: Vec<Record> = workspace::all()?
413 .into_iter()
414 .filter(|record| record.identity == identity && record.state == Lifecycle::Open)
415 .collect();
416 let mut slots = Vec::new();
417 let entries = match std::fs::read_dir(pool) {
421 Ok(entries) => entries,
422 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
423 return Ok(Survey {
424 pool: pool.to_path_buf(),
425 slots,
426 open,
427 })
428 }
429 Err(e) => return Err(error::at("read the pool at", pool)(e)),
430 };
431 for entry in entries {
432 let entry = entry.map_err(error::at("read the pool at", pool))?;
438 let dir = entry.path();
439 let Some(number) = entry
442 .file_name()
443 .to_str()
444 .and_then(|name| name.parse::<u32>().ok())
445 .filter(|number| *number > 0)
446 else {
447 continue;
448 };
449 if !dir.is_dir() {
450 continue;
451 }
452 let (record, state) = state_of(&dir, number, identity, &open);
453 slots.push(Surveyed {
454 number,
455 dir,
456 record,
457 state,
458 });
459 }
460 slots.sort_by_key(|slot| slot.number);
461 Ok(Survey {
462 pool: pool.to_path_buf(),
463 slots,
464 open,
465 })
466}
467
468fn state_of(
471 dir: &Path,
472 number: u32,
473 identity: &str,
474 open: &[Record],
475) -> (Option<SlotRecord>, SlotState) {
476 let record = match read_record(dir, number, identity) {
477 Ok(record) => record,
478 Err(reason) => return (None, SlotState::Broken { reason }),
479 };
480 let clone = dir.join("clone");
481 let worktree = dir.join("worktree");
482 if !git::is_repo(&clone) {
483 return (
484 Some(record),
485 SlotState::Broken {
486 reason: format!(
487 "its clone at {} is missing or not a repository",
488 clone.display()
489 ),
490 },
491 );
492 }
493 if !git::is_repo(&worktree) {
494 return (
495 Some(record),
496 SlotState::Broken {
497 reason: format!(
498 "its worktree at {} is missing or not a repository",
499 worktree.display()
500 ),
501 },
502 );
503 }
504 if let Some(holder) = open.iter().find(|held| held.run_root == dir) {
505 return (
506 Some(record),
507 SlotState::InUse {
508 session: SessionToken(holder.token.to_string()),
509 },
510 );
511 }
512 if let Some(claim) = record.maintaining.clone() {
513 if claim.is_live() {
514 return (
515 Some(record),
516 SlotState::Maintaining {
517 pid: claim.pid,
518 since: claim.since.into(),
519 },
520 );
521 }
522 let mut cleared = record.clone();
525 cleared.maintaining = None;
526 let _ = write_record(dir, &cleared);
527 return (Some(cleared), SlotState::Idle);
528 }
529 (Some(record), SlotState::Idle)
530}
531
532fn retained_branches(
540 clone: &Path,
541 lender: Option<&Path>,
542) -> std::result::Result<Vec<String>, String> {
543 if !git::is_repo(clone) {
544 return Ok(Vec::new());
545 }
546 let branches = git::branches(clone).map_err(|e| e.to_string())?;
547 Ok(branches
548 .into_iter()
549 .filter(|branch| {
550 let reached = match (git::tip(clone, &format!("refs/heads/{branch}")), lender) {
551 (Some(tip), Some(lender)) => git::refs_reach(lender, &tip),
552 _ => false,
553 };
554 !reached
555 })
556 .collect())
557}
558
559fn keeps(slot: &Surveyed, judge: &Path) -> Option<String> {
566 match &slot.state {
567 SlotState::InUse { session } => {
568 return Some(format!("session {} is working in it", session.0));
569 }
570 SlotState::Maintaining { pid, since } => {
571 return Some(format!(
572 "a maintenance run (pid {pid}) has claimed it since {since}"
573 ));
574 }
575 SlotState::Idle | SlotState::Broken { .. } => {}
576 }
577 let lender = slot.lender().unwrap_or(judge);
582 let retained = match retained_branches(&slot.clone_dir(), Some(lender)) {
583 Ok(retained) => retained,
584 Err(reason) => {
585 return Some(format!(
586 "its clone could not be asked what it retains, and a count nobody got is not \
587 a count of none: {reason}"
588 ))
589 }
590 };
591 if retained.is_empty() {
592 return None;
593 }
594 let names: Vec<&str> = retained.iter().map(String::as_str).collect();
595 Some(format!(
596 "its clone retains {} — {} its lender {} would not take, and removing the slot is \
597 what would make {} unreachable",
598 guidance::listed(&names),
599 match retained.len() {
600 1 => "a branch",
601 _ => "branches",
602 },
603 lender.display(),
604 match retained.len() {
605 1 => "it",
606 _ => "them",
607 },
608 ))
609}
610
611fn remove(slot: &Surveyed) -> std::result::Result<(), String> {
614 let Some(_exclusive) = lock::try_exclusive(&workspace::occupancy_identity(&slot.dir))
615 .map_err(|e| e.to_string())?
616 else {
617 return Err("a command is working in it right now".to_owned());
618 };
619 std::fs::remove_dir_all(&slot.dir).map_err(|e| format!("it could not be removed: {e}"))
620}
621
622fn shed(survey: &mut Survey, file_pool: u32, judge: &Path) {
630 let mut removed = Vec::new();
631 for slot in sheddable(survey, file_pool, judge) {
632 if remove(slot).is_ok() {
633 removed.push(slot.number);
634 }
635 }
636 survey.slots.retain(|slot| !removed.contains(&slot.number));
637}
638
639fn sheddable<'a>(survey: &'a Survey, file_pool: u32, judge: &Path) -> Vec<&'a Surveyed> {
646 let mut surplus = u32::try_from(survey.slots.len())
647 .unwrap_or(u32::MAX)
648 .saturating_sub(file_pool);
649 let mut shed = Vec::new();
650 for slot in survey.slots.iter().rev() {
651 if surplus == 0 {
652 break;
653 }
654 if !slot.takeable() || keeps(slot, judge).is_some() {
655 continue;
656 }
657 shed.push(slot);
658 surplus -= 1;
659 }
660 shed
661}
662
663#[derive(Debug)]
665pub(crate) enum Placement {
666 Slot {
668 number: u32,
670 dir: PathBuf,
672 cut: Cut,
674 _held: lock::Guard,
677 _serial: lock::Guard,
679 },
680 RunRoot {
682 _serial: Option<lock::Guard>,
686 },
687}
688
689pub(crate) struct Ask<'a> {
691 pub resolution: &'a Resolution,
692 pub execution: &'a Path,
693 pub resolved: &'a Resolved,
694 pub pinned: Option<&'a Ref>,
695 pub identity_root: &'a Path,
696}
697
698pub(crate) fn place(ask: &Ask<'_>) -> Result<Placement> {
707 let pool = pool_dir(ask.identity_root);
708 crate::home::ensure_dir(&pool)?;
709 let serial = lock::exclusive(&placement_identity(&pool))?;
710 let mut survey = survey(&ask.resolution.key, &pool)?;
711 shed(&mut survey, ask.resolved.file_pool, ask.execution);
712 let wanted = ask.resolved.pool.value;
713 if wanted > 0 {
714 let taken = match take_idle(&survey, ask)? {
715 Some(taken) => Some(taken),
716 None => match recreate_broken(&survey, ask)? {
717 Some(recreated) => Some(recreated),
718 None if u32::try_from(survey.slots.len()).unwrap_or(u32::MAX) < wanted => {
719 Some(cut_new(&survey, ask)?)
720 }
721 None => None,
722 },
723 };
724 if let Some(taken) = taken {
725 return Ok(Placement::Slot {
726 number: taken.number,
727 dir: taken.dir.clone(),
728 cut: taken.cut,
729 _held: taken.held,
730 _serial: serial,
731 });
732 }
733 }
734 let overflow_in_use = u32::try_from(survey.overflow().len()).unwrap_or(u32::MAX);
735 if ask.resolved.overflow.value.admits(overflow_in_use) {
736 return Ok(Placement::RunRoot {
737 _serial: Some(serial),
738 });
739 }
740 Err(exhausted(ask, &survey))
741}
742
743#[derive(Debug, Clone, Copy, PartialEq, Eq)]
746pub(crate) enum Cut {
747 Fresh,
749 KeptClone,
752 Warm,
754}
755
756impl Cut {
757 pub(crate) fn created(self) -> bool {
760 self != Cut::Warm
761 }
762}
763
764struct Taken {
767 number: u32,
768 dir: PathBuf,
769 cut: Cut,
770 held: lock::Guard,
771}
772
773fn take_idle(survey: &Survey, ask: &Ask<'_>) -> Result<Option<Taken>> {
775 let preferred = match ask.pinned {
776 Some(branch) => last_slot_of(&ask.resolution.key, branch)?,
777 None => None,
778 };
779 let mut candidates: Vec<&Surveyed> = survey
780 .slots
781 .iter()
782 .filter(|slot| slot.state == SlotState::Idle && slot.lender() == Some(ask.execution))
783 .collect();
784 candidates.sort_by_key(|slot| (Some(slot.number) != preferred, slot.number));
785 for slot in candidates {
786 let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&slot.dir))? else {
789 continue;
790 };
791 if !returned(&slot.worktree()) && !processes::holding(&slot.dir).is_empty() {
795 continue;
796 }
797 return Ok(Some(Taken {
798 number: slot.number,
799 dir: slot.dir.clone(),
800 cut: Cut::Warm,
801 held,
802 }));
803 }
804 Ok(None)
805}
806
807fn recreate_broken(survey: &Survey, ask: &Ask<'_>) -> Result<Option<Taken>> {
817 for slot in &survey.slots {
818 if !matches!(slot.state, SlotState::Broken { .. }) {
819 continue;
820 }
821 let clone = slot.clone_dir();
822 let worktree = slot.worktree();
823 let lender = slot
827 .lender()
828 .map(Path::to_path_buf)
829 .or_else(|| lender_of(&clone));
830 if lender.is_some_and(|lender| lender != ask.execution) {
831 continue;
832 }
833 let Some(held) = lock::try_exclusive(&workspace::occupancy_identity(&slot.dir))? else {
834 continue;
835 };
836 let cut = if git::is_repo(&clone) {
837 if git::is_repo(&worktree) {
838 Cut::Warm
839 } else {
840 if worktree.exists() {
841 std::fs::remove_dir_all(&worktree)
842 .map_err(error::at("remove the broken worktree at", &worktree))?;
843 }
844 git::worktree_prune(&clone)?;
847 Cut::KeptClone
848 }
849 } else {
850 std::fs::remove_dir_all(&slot.dir)
851 .map_err(error::at("remove the broken slot at", &slot.dir))?;
852 std::fs::create_dir(&slot.dir).map_err(error::at("create", &slot.dir))?;
853 Cut::Fresh
854 };
855 write_record(&slot.dir, &fresh_record(slot.number, ask))?;
856 return Ok(Some(Taken {
857 number: slot.number,
858 dir: slot.dir.clone(),
859 cut,
860 held,
861 }));
862 }
863 Ok(None)
864}
865
866fn lender_of(clone: &Path) -> Option<PathBuf> {
873 let alternates = git::objects_dir(clone).ok()?.join("info/alternates");
874 let recorded = std::fs::read_to_string(alternates).ok()?;
875 let objects = PathBuf::from(recorded.lines().next()?.trim());
876 let git_dir = objects
877 .file_name()
878 .is_some_and(|name| name == "objects")
879 .then(|| objects.parent())
880 .flatten()?;
881 git_dir
882 .file_name()
883 .is_some_and(|name| name == ".git")
884 .then(|| git_dir.parent().map(Path::to_path_buf))
885 .flatten()
886}
887
888fn cut_new(survey: &Survey, ask: &Ask<'_>) -> Result<Taken> {
893 let mut number: u32 = 1;
894 loop {
895 if survey.slots.iter().any(|slot| slot.number == number) {
896 number += 1;
897 continue;
898 }
899 let dir = survey.pool.join(number.to_string());
900 match std::fs::create_dir(&dir) {
901 Ok(()) => {}
902 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
903 number += 1;
904 continue;
905 }
906 Err(e) => return Err(error::at("create", &dir)(e)),
907 }
908 let held = lock::try_exclusive(&workspace::occupancy_identity(&dir))?.ok_or_else(|| {
909 error::invalid(format!(
910 "the new slot {} is already occupied before it was cut",
911 dir.display()
912 ))
913 })?;
914 write_record(&dir, &fresh_record(number, ask))?;
915 return Ok(Taken {
916 number,
917 dir,
918 cut: Cut::Fresh,
919 held,
920 });
921 }
922}
923
924fn fresh_record(number: u32, ask: &Ask<'_>) -> SlotRecord {
925 SlotRecord {
926 version: SLOT_VERSION,
927 number,
928 identity: ask.resolution.key.clone(),
929 execution_checkout: ask.execution.to_path_buf(),
930 created: Timestamp::now(),
931 maintaining: None,
932 last_maintained: None,
933 last_outcome: None,
934 }
935}
936
937fn last_slot_of(identity: &str, branch: &Ref) -> Result<Option<u32>> {
941 let mut newest: Option<(std::time::SystemTime, u32)> = None;
942 for record in workspace::all()? {
943 let Some(number) = record.slot else {
944 continue;
945 };
946 if record.identity != identity
947 || record.branch != *branch
948 || record.state != Lifecycle::Closed
949 {
950 continue;
951 }
952 let written = workspace::record_path(&record.token)
953 .ok()
954 .and_then(|path| std::fs::metadata(path).ok())
955 .and_then(|meta| meta.modified().ok())
956 .unwrap_or(std::time::UNIX_EPOCH);
957 if newest.is_none_or(|(when, _)| written > when) {
958 newest = Some((written, number));
959 }
960 }
961 Ok(newest.map(|(_, number)| number))
962}
963
964pub(crate) fn is_idle(identity: &str, slot: &Path) -> Result<bool> {
967 let Some(pool) = slot.parent() else {
968 return Ok(false);
969 };
970 Ok(survey(identity, pool)?
971 .slots
972 .iter()
973 .any(|surveyed| surveyed.dir == slot && surveyed.state == SlotState::Idle))
974}
975
976pub(crate) fn returned(worktree: &Path) -> bool {
978 git::current_branch(worktree).is_ok_and(|branch| branch == workspace::DETACHED_HEAD)
979 && git::is_dirty(worktree).is_ok_and(|dirty| !dirty)
980}
981
982fn exhausted(ask: &Ask<'_>, survey: &Survey) -> Error {
985 let resolved = ask.resolved;
986 let slots = survey.slots.len();
987 let idle = survey.count(|state| *state == SlotState::Idle);
988 let in_use = survey.count(|state| matches!(state, SlotState::InUse { .. }));
989 let maintaining = survey.count(|state| matches!(state, SlotState::Maintaining { .. }));
990 let overflow = survey.overflow();
991 let mut holders: Vec<String> = Vec::new();
992 for slot in &survey.slots {
993 match &slot.state {
994 SlotState::InUse { session } => {
995 let held = survey
996 .open
997 .iter()
998 .find(|record| *record.token == *session.0);
999 holders.push(match held {
1000 Some(record) => format!("slot {}: {}", slot.number, describe(record)),
1001 None => format!("slot {}: session {}", slot.number, session.0),
1002 });
1003 }
1004 SlotState::Maintaining { pid, since } => holders.push(format!(
1005 "slot {}: a maintenance run, pid {pid}, since {since}",
1006 slot.number
1007 )),
1008 SlotState::Idle => holders.push(format!(
1009 "slot {}: idle, bound to {}",
1010 slot.number,
1011 slot.lender()
1012 .map(|lender| lender.display().to_string())
1013 .unwrap_or_else(|| "(unknown)".to_owned())
1014 )),
1015 SlotState::Broken { reason } => {
1016 holders.push(format!("slot {}: broken — {reason}", slot.number));
1017 }
1018 }
1019 }
1020 for record in &overflow {
1021 holders.push(format!("under runs/: {}", describe(record)));
1022 }
1023 let file = workspaces::default_path()
1024 .map(|path| path.display().to_string())
1025 .unwrap_or_else(|_| "$ONEVCS_HOME/workspaces.yml".to_owned());
1026 Error::PoolExhausted {
1027 reason: format!(
1028 "no session of {identity} can be placed now: its pool is {pool} (from {pool_from}) — \
1029 {slots} created, {idle} idle, {in_use} in use, {maintaining} maintaining — and its \
1030 overflow is {overflow} (from {overflow_from}) with {overflow_in_use} in use. \
1031 {holding}. Close a session, raise pool or overflow for this identity in {file}, \
1032 or open this one with `--overflow unlimited`",
1033 identity = ask.resolution.key,
1034 pool = resolved.pool.value,
1035 pool_from = resolved.pool.from,
1036 overflow = resolved.overflow.value,
1037 overflow_from = resolved.overflow.from,
1038 overflow_in_use = overflow.len(),
1039 holding = match holders.is_empty() {
1040 true => "Nothing holds it".to_owned(),
1041 false => format!("Held by: {}", holders.join("; ")),
1042 },
1043 ),
1044 }
1045}
1046
1047fn describe(record: &Record) -> String {
1049 format!(
1050 "session {} on {:?} in {}, opened by pid {} ({})",
1051 record.token,
1052 record.branch,
1053 record.worktree.display(),
1054 record.owner_pid,
1055 record.liveness().as_str(),
1056 )
1057}
1058
1059struct Asked {
1062 resolution: Resolution,
1063 execution: PathBuf,
1064 resolved: Resolved,
1065 identity_root: PathBuf,
1066}
1067
1068fn asked(request: &SessionRequest) -> Result<Asked> {
1069 let registry = store::load()?;
1070 let resolution = store::resolve(®istry, &request.repo)?;
1071 let execution = workspace::execution_checkout(
1072 ®istry,
1073 &resolution,
1074 request.execution_checkout.as_deref(),
1075 )?;
1076 let resolved = workspaces::resolve_for(
1077 &resolution,
1078 Overrides {
1079 pool: request.pool,
1080 overflow: request.overflow,
1081 },
1082 )?;
1083 let identity_root = workspace::identity_dir(&resolution.key)?;
1084 Ok(Asked {
1085 resolution,
1086 execution,
1087 resolved,
1088 identity_root,
1089 })
1090}
1091
1092fn capacity_of(asked: &Asked, survey: &Survey, request: &SessionRequest) -> WorkspaceCapacity {
1094 let resolved = &asked.resolved;
1095 let slots = u32::try_from(survey.slots.len()).unwrap_or(u32::MAX);
1096 let idle = survey.count(|state| *state == SlotState::Idle);
1097 let takeable =
1098 survey.count(|state| matches!(state, SlotState::Idle | SlotState::Broken { .. }));
1099 let in_use = survey.count(|state| matches!(state, SlotState::InUse { .. }));
1100 let maintaining = survey.count(|state| matches!(state, SlotState::Maintaining { .. }));
1101 let overflow_in_use = u32::try_from(survey.overflow().len()).unwrap_or(u32::MAX);
1102 let shed = u32::try_from(sheddable(survey, resolved.file_pool, &asked.execution).len())
1106 .unwrap_or(u32::MAX);
1107 let takeable_after = takeable.saturating_sub(shed);
1108 let slots_after = slots.saturating_sub(shed);
1109 let can_create = resolved.pool.value.saturating_sub(slots_after);
1110 let admits = match resolved.pool.value {
1111 0 => resolved.overflow.value.headroom(overflow_in_use),
1112 _ => match resolved.overflow.value.headroom(overflow_in_use) {
1113 Bound::Unlimited => Bound::Unlimited,
1114 Bound::Bounded(room) => Bound::Bounded(
1115 takeable_after
1116 .saturating_add(can_create)
1117 .saturating_add(room),
1118 ),
1119 },
1120 };
1121 let resumes = request.branch.as_ref().is_some_and(|pinned| {
1122 survey.open.iter().any(|record| {
1123 *record.branch == **pinned
1124 && record.execution_checkout == asked.execution
1125 && record.run_root.is_dir()
1126 && record.clone.is_dir()
1127 })
1128 });
1129 let mine = survey.slots.iter().any(|slot| {
1130 slot.takeable() && slot.lender().is_none_or(|lender| lender == asked.execution)
1131 });
1132 let admitted = resumes
1133 || (resolved.pool.value > 0 && (mine || can_create > 0))
1134 || resolved.overflow.value.admits(overflow_in_use);
1135 WorkspaceCapacity {
1136 identity: asked.resolution.key.clone(),
1137 pool: resolved.pool.value,
1138 slots,
1139 idle,
1140 in_use,
1141 maintaining,
1142 overflow: resolved.overflow.value,
1143 overflow_in_use,
1144 admits,
1145 admitted,
1146 }
1147}
1148
1149pub fn workspace_capacity(request: &SessionRequest) -> Result<WorkspaceCapacity> {
1152 let asked = asked(request)?;
1153 let survey = survey(&asked.resolution.key, &pool_dir(&asked.identity_root))?;
1154 Ok(capacity_of(&asked, &survey, request))
1155}
1156
1157pub fn pool_status(repo: &str) -> Result<PoolStatus> {
1159 let request = SessionRequest {
1160 repo: repo.to_owned(),
1161 branch: None,
1162 branch_name: None,
1163 branch_prefix: None,
1164 base: None,
1165 execution_checkout: None,
1166 pool: None,
1167 overflow: None,
1168 labels: Default::default(),
1169 refuse_conflicts: false,
1170 caller_merges_base: false,
1171 };
1172 let asked = asked(&request)?;
1173 let survey = survey(&asked.resolution.key, &pool_dir(&asked.identity_root))?;
1174 Ok(PoolStatus {
1175 maintenance: crate::repository_config::resolve(&asked.resolution, &asked.resolved),
1176 capacity: capacity_of(&asked, &survey, &request),
1177 slots: survey.slots.iter().map(Surveyed::status).collect(),
1178 })
1179}
1180
1181pub fn pool_prune(repo: &str) -> Result<PruneReport> {
1188 let request = SessionRequest {
1189 repo: repo.to_owned(),
1190 branch: None,
1191 branch_name: None,
1192 branch_prefix: None,
1193 base: None,
1194 execution_checkout: None,
1195 pool: None,
1196 overflow: None,
1197 labels: Default::default(),
1198 refuse_conflicts: false,
1199 caller_merges_base: false,
1200 };
1201 let asked = asked(&request)?;
1202 let pool = pool_dir(&asked.identity_root);
1203 let mut report = PruneReport {
1204 removed: Vec::new(),
1205 kept: Vec::new(),
1206 };
1207 if !pool.is_dir() {
1208 return Ok(report);
1209 }
1210 let _serial = lock::exclusive(&placement_identity(&pool))?;
1213 let survey = survey(&asked.resolution.key, &pool)?;
1214 for slot in &survey.slots {
1215 if let Some(why) = keeps(slot, &asked.resolution.publication) {
1216 report.kept.push((slot.number, why));
1217 continue;
1218 }
1219 match remove(slot) {
1220 Ok(()) => report.removed.push(slot.number),
1221 Err(why) => report.kept.push((slot.number, why)),
1222 }
1223 }
1224 Ok(report)
1225}