From 387c0f89300299f58776eeedcc68ac7d00f81ad3 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 20:38:06 +0100 Subject: [PATCH 1/2] Fold the removal guard into one workspace_remove and swap the api row `devlaunch_core::api` promised `workspace_delete`, which is the delete without the unsaved-work guard. The probe and the guard that make it safe were two further exported functions a caller had to run first, in the right order, and the sequence lived in the `dl` binary: nothing outside `dl` could reuse it and nothing held `dl` to it. A consumer following the promise exactly deletes a clone holding the only copy of somebody's work. `lifecycle::workspace_remove` is the one exported removal now -- probe, guard, name the volumes, delete, remove the clone. `guard_removal`, `unsaved_work_in`, `workspace_delete`, `Guarded` and `Persistence` are `pub(crate)`; `workspace_delete` keeps its signature, so its unit tests run unchanged. `DeleteOutcome` is `RemoveOutcome` with a `Refused` arm. Which of the three removals is being asked for is one `Removal` value moved out of `dl` rather than four flags a caller assembles: `Insistence`, `Persistence` and the probe are total functions of it. That is what keeps `kill`'s report of the work it destroys, which the ticket's `insistence`-only sketch would have dropped. `repo_manager`'s `pub(crate)` tree-removal result is `TreeSweep`, after the function that produces it. Two `LifecycleNotice` arms carry what `dl` printed between the calls, and `dl` passes the streaming sink so the lines keep their order and timing. `api::workspace_delete` is out and `api::workspace_remove` is in, with the parameter and answer types a caller needs to reach either. Closes #410. --- CHANGELOG.md | 21 + rust/devlaunch-core/public-api.api.txt | 55 ++- rust/devlaunch-core/public-api.rest.txt | 152 +++++-- rust/devlaunch-core/src/flows/lifecycle.rs | 308 +++++++++++-- rust/devlaunch-core/src/flows/repo_manager.rs | 18 +- rust/devlaunch-core/src/lib.rs | 22 +- .../tests/api_removal_is_self_sufficient.rs | 430 ++++++++++++++++++ rust/dl/src/commands.rs | 204 ++------- rust/dl/src/render.rs | 17 + 9 files changed, 973 insertions(+), 254 deletions(-) create mode 100644 rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index f34fcce5..e69eac88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **A removal on the promised surface cannot skip the unsaved-work guard, because + there is nothing left to skip it with.** `devlaunch_core::api` promised + `workspace_delete`, which is the delete *without* the guard, and the probe and the + guard that make it safe were two further exported functions the caller had to run + first, in the right order, with the right arguments. The sequence lived in the `dl` + binary, so nothing outside `dl` could reuse it and nothing held `dl` to it: a second + consumer following the promise exactly would delete a clone holding the only copy of + somebody's afternoon. + + The three are one `lifecycle::workspace_remove` now — probe, guard, name the + volumes, delete, remove the clone, in that order — and `guard_removal`, + `unsaved_work_in` and the raw delete are `pub(crate)`. `api::workspace_delete` is + gone rather than kept beside the new call, which is a breaking change to the frozen + surface (#251 §7) and the right weight: an unguarded delete is unrepresentable + instead of documented. `api` also gained what a caller needs to *call* the removal + and read its answer, and `DeleteOutcome` is `RemoveOutcome`, with a `Refused` arm + beside the two it had. Which of the three removals is being asked for — `rm`, + `rm --force`, `kill` — travels as one `Removal` value rather than four flags a + caller assembles; it lived in the binary before. No change to what `dl` prints or + when. + - **`devlaunch_core::api` can now build a launcher, not just name one.** The two implementations that decide whether a launch can go cold at all lived in the `dl` binary: the one that opens devlaunch's records (config, `metadata.json`, the cache diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt index 301b3594..89e050f1 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -2,6 +2,11 @@ pub mod devlaunch_core::api pub enum devlaunch_core::api::ColdRefused pub devlaunch_core::api::ColdRefused::NoColdPath pub devlaunch_core::api::ColdRefused::Startup(devlaunch_core::flows::records::StartupError) +pub enum devlaunch_core::api::DeleteStalled +pub devlaunch_core::api::DeleteStalled::OnTheLock +pub enum devlaunch_core::api::Insistence +pub devlaunch_core::api::Insistence::Insisted +pub devlaunch_core::api::Insistence::NotInsisted pub enum devlaunch_core::api::LaunchNotice pub devlaunch_core::api::LaunchNotice::AlreadyRunning pub devlaunch_core::api::LaunchNotice::AlreadyRunning::workspace_id: alloc::string::String @@ -52,6 +57,29 @@ pub devlaunch_core::api::LaunchVerb::Recreate pub devlaunch_core::api::LaunchVerb::Reset pub devlaunch_core::api::LaunchVerb::Restart pub devlaunch_core::api::LaunchVerb::Up +pub enum devlaunch_core::api::LifecycleNotice +pub devlaunch_core::api::LifecycleNotice::AddressingRecordedWorkspace +pub devlaunch_core::api::LifecycleNotice::AddressingRecordedWorkspace::branch: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::AddressingRecordedWorkspace::derived: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::AddressingRecordedWorkspace::owner: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::AddressingRecordedWorkspace::recorded: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::AddressingRecordedWorkspace::repo: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::Cache(devlaunch_core::flows::repo_manager::CacheNotice) +pub devlaunch_core::api::LifecycleNotice::CloneNotRemoved +pub devlaunch_core::api::LifecycleNotice::CloneNotRemoved::refusal: devlaunch_core::flows::workspace_clone::RemoveWorkspaceError +pub devlaunch_core::api::LifecycleNotice::CloneNotRemoved::workspace_id: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::CloneRemoved +pub devlaunch_core::api::LifecycleNotice::CloneRemoved::workspace_id: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::RecordNotDropped +pub devlaunch_core::api::LifecycleNotice::RecordNotDropped::path: std::path::PathBuf +pub devlaunch_core::api::LifecycleNotice::RecordNotDropped::refusal: devlaunch_core::domain::metadata::MetadataError +pub devlaunch_core::api::LifecycleNotice::Removing +pub devlaunch_core::api::LifecycleNotice::Removing::workspace_id: alloc::string::String +pub devlaunch_core::api::LifecycleNotice::RemovingOverWork +pub devlaunch_core::api::LifecycleNotice::RemovingOverWork::refusal: devlaunch_core::flows::lifecycle::RemovalRefused +pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved +pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved::refusal: devlaunch_core::flows::lifecycle::VolumeRefusal +pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved::workspace_id: alloc::string::String pub enum devlaunch_core::api::ProvisionEvent pub devlaunch_core::api::ProvisionEvent::NotInstalled pub devlaunch_core::api::ProvisionEvent::NotInstalled::exit: devlaunch_runner::Exit @@ -78,6 +106,24 @@ pub devlaunch_core::api::RecordsNotice::Metadata(devlaunch_core::domain::metadat pub devlaunch_core::api::RecordsNotice::Migrated(devlaunch_core::flows::migration::MigrationReport) pub devlaunch_core::api::RecordsNotice::MigrationRefused(devlaunch_core::domain::metadata::MetadataError) pub devlaunch_core::api::RecordsNotice::RetiredKey(devlaunch_core::domain::config::RetiredKey) +pub enum devlaunch_core::api::Removal +pub devlaunch_core::api::Removal::Guarded +pub devlaunch_core::api::Removal::Insisted +pub devlaunch_core::api::Removal::Wedged +pub enum devlaunch_core::api::RemovalRefused +pub devlaunch_core::api::RemovalRefused::CouldNotTell +pub devlaunch_core::api::RemovalRefused::CouldNotTell::cause: devlaunch_core::domain::workspace_state::CouldNotTell +pub devlaunch_core::api::RemovalRefused::CouldNotTell::workspace_id: alloc::string::String +pub devlaunch_core::api::RemovalRefused::WouldLose +pub devlaunch_core::api::RemovalRefused::WouldLose::losses: devlaunch_core::domain::workspace_state::NonEmpty +pub devlaunch_core::api::RemovalRefused::WouldLose::workspace_id: alloc::string::String +pub enum devlaunch_core::api::RemoveOutcome +pub devlaunch_core::api::RemoveOutcome::Deleted +pub devlaunch_core::api::RemoveOutcome::Deleted::clone: core::result::Result +pub devlaunch_core::api::RemoveOutcome::Deleted::volumes: devlaunch_core::flows::lifecycle::VolumeSweep +pub devlaunch_core::api::RemoveOutcome::DevpodRefused +pub devlaunch_core::api::RemoveOutcome::DevpodRefused::exit: devlaunch_runner::Exit +pub devlaunch_core::api::RemoveOutcome::Refused(devlaunch_core::flows::lifecycle::RemovalRefused) pub enum devlaunch_core::api::SpecIdentity<'a> pub devlaunch_core::api::SpecIdentity::ExistingName(&'a str) pub devlaunch_core::api::SpecIdentity::PathLeaf(&'a str) @@ -103,11 +149,18 @@ pub devlaunch_core::api::Cold::storage: &'a mut devlaunch_core::domain::metadata pub struct devlaunch_core::api::ColdPath<'r, 'e> pub struct devlaunch_core::api::CommandContext<'r> pub struct devlaunch_core::api::DevcontainerPath(_) +pub struct devlaunch_core::api::DevpodHome pub struct devlaunch_core::api::Host pub struct devlaunch_core::api::Launch<'a, 'r, 'l> +pub struct devlaunch_core::api::MetadataStorage +pub struct devlaunch_core::api::Records<'r> +pub devlaunch_core::api::Records::clones: devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r> +pub devlaunch_core::api::Records::reported: alloc::vec::Vec +pub devlaunch_core::api::Records::storage: devlaunch_core::domain::metadata::MetadataStorage pub struct devlaunch_core::api::Refresh<'a> pub struct devlaunch_core::api::SelfInvocation pub struct devlaunch_core::api::ToolProvisioning<'e> +pub struct devlaunch_core::api::WorkspaceCloneManager<'r> pub const devlaunch_core::api::HANDOFF_VAR: &str pub const devlaunch_core::api::PREWARM_VAR: &str pub trait devlaunch_core::api::ColdMachinery<'r> @@ -122,5 +175,5 @@ pub fn devlaunch_core::api::identity(&str) -> core::result::Result serde_json::value::Value pub fn devlaunch_core::api::parse(&str) -> devlaunch_core::domain::spec::WorkspaceSpec<'_> pub fn devlaunch_core::api::resolve_devcontainer_ref(&str) -> core::result::Result -pub fn devlaunch_core::api::workspace_delete(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, core::option::Option<&devlaunch_core::clients::devpod_home::DevpodHome>, &str, devlaunch_core::flows::lifecycle::Insistence, devlaunch_core::flows::lifecycle::Persistence, &mut dyn core::ops::function::FnMut(devlaunch_core::flows::lifecycle::DeleteStalled), &mut dyn devlaunch_core::notices::Notices) -> core::result::Result +pub fn devlaunch_core::api::workspace_remove(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &std::path::Path, core::option::Option<&devlaunch_core::clients::devpod_home::DevpodHome>, &str, devlaunch_core::flows::lifecycle::Removal, &mut dyn core::ops::function::FnMut(devlaunch_core::flows::lifecycle::DeleteStalled), &mut dyn devlaunch_core::notices::Notices) -> core::result::Result pub fn devlaunch_core::api::workspace_stop(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &str) -> core::result::Result diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index e8ab10d7..bf8414e1 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -7,6 +7,24 @@ pub fn devlaunch_core::flows::launch::ColdRefused::eq(&self, &devlaunch_core::fl impl core::fmt::Debug for devlaunch_core::flows::launch::ColdRefused pub fn devlaunch_core::flows::launch::ColdRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::ColdRefused +impl core::clone::Clone for devlaunch_core::flows::lifecycle::DeleteStalled +pub fn devlaunch_core::flows::lifecycle::DeleteStalled::clone(&self) -> devlaunch_core::flows::lifecycle::DeleteStalled +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::DeleteStalled +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::DeleteStalled +pub fn devlaunch_core::flows::lifecycle::DeleteStalled::eq(&self, &devlaunch_core::flows::lifecycle::DeleteStalled) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::DeleteStalled +pub fn devlaunch_core::flows::lifecycle::DeleteStalled::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::lifecycle::DeleteStalled +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::DeleteStalled +impl core::clone::Clone for devlaunch_core::flows::lifecycle::Insistence +pub fn devlaunch_core::flows::lifecycle::Insistence::clone(&self) -> devlaunch_core::flows::lifecycle::Insistence +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Insistence +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Insistence +pub fn devlaunch_core::flows::lifecycle::Insistence::eq(&self, &devlaunch_core::flows::lifecycle::Insistence) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Insistence +pub fn devlaunch_core::flows::lifecycle::Insistence::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::lifecycle::Insistence +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Insistence impl core::clone::Clone for devlaunch_core::flows::launch::LaunchNotice pub fn devlaunch_core::flows::launch::LaunchNotice::clone(&self) -> devlaunch_core::flows::launch::LaunchNotice impl core::cmp::Eq for devlaunch_core::flows::launch::LaunchNotice @@ -23,6 +41,14 @@ pub fn devlaunch_core::flows::launch::LaunchVerb::eq(&self, &devlaunch_core::flo impl core::fmt::Debug for devlaunch_core::flows::launch::LaunchVerb pub fn devlaunch_core::flows::launch::LaunchVerb::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::LaunchVerb +impl core::clone::Clone for devlaunch_core::flows::lifecycle::LifecycleNotice +pub fn devlaunch_core::flows::lifecycle::LifecycleNotice::clone(&self) -> devlaunch_core::flows::lifecycle::LifecycleNotice +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::LifecycleNotice +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::LifecycleNotice +pub fn devlaunch_core::flows::lifecycle::LifecycleNotice::eq(&self, &devlaunch_core::flows::lifecycle::LifecycleNotice) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::LifecycleNotice +pub fn devlaunch_core::flows::lifecycle::LifecycleNotice::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::LifecycleNotice impl core::clone::Clone for devlaunch_core::flows::provision::ProvisionEvent pub fn devlaunch_core::flows::provision::ProvisionEvent::clone(&self) -> devlaunch_core::flows::provision::ProvisionEvent impl core::cmp::Eq for devlaunch_core::flows::provision::ProvisionEvent @@ -39,6 +65,33 @@ pub fn devlaunch_core::flows::records::RecordsNotice::eq(&self, &devlaunch_core: impl core::fmt::Debug for devlaunch_core::flows::records::RecordsNotice pub fn devlaunch_core::flows::records::RecordsNotice::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::records::RecordsNotice +impl devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::insistence(self) -> devlaunch_core::flows::lifecycle::Insistence +impl core::clone::Clone for devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::clone(&self) -> devlaunch_core::flows::lifecycle::Removal +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Removal +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::eq(&self, &devlaunch_core::flows::lifecycle::Removal) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::lifecycle::Removal +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Removal +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::clone(&self) -> devlaunch_core::flows::lifecycle::RemovalRefused +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemovalRefused +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::eq(&self, &devlaunch_core::flows::lifecycle::RemovalRefused) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalRefused +pub fn devlaunch_core::flows::lifecycle::RemovalRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalRefused +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemoveOutcome +pub fn devlaunch_core::flows::lifecycle::RemoveOutcome::clone(&self) -> devlaunch_core::flows::lifecycle::RemoveOutcome +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemoveOutcome +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemoveOutcome +pub fn devlaunch_core::flows::lifecycle::RemoveOutcome::eq(&self, &devlaunch_core::flows::lifecycle::RemoveOutcome) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemoveOutcome +pub fn devlaunch_core::flows::lifecycle::RemoveOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemoveOutcome impl<'a> core::clone::Clone for devlaunch_core::domain::spec::SpecIdentity<'a> pub fn devlaunch_core::domain::spec::SpecIdentity<'a>::clone(&self) -> devlaunch_core::domain::spec::SpecIdentity<'a> impl<'a> core::cmp::Eq for devlaunch_core::domain::spec::SpecIdentity<'a> @@ -89,6 +142,18 @@ pub fn devlaunch_core::domain::spec::DevcontainerPath::eq(&self, &devlaunch_core impl core::fmt::Debug for devlaunch_core::domain::spec::DevcontainerPath pub fn devlaunch_core::domain::spec::DevcontainerPath::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::domain::spec::DevcontainerPath +impl devlaunch_core::clients::devpod_home::DevpodHome +pub fn devlaunch_core::clients::devpod_home::DevpodHome::at(impl core::convert::Into) -> Self +pub fn devlaunch_core::clients::devpod_home::DevpodHome::locate() -> core::option::Option +pub fn devlaunch_core::clients::devpod_home::DevpodHome::path(&self) -> &std::path::Path +impl core::clone::Clone for devlaunch_core::clients::devpod_home::DevpodHome +pub fn devlaunch_core::clients::devpod_home::DevpodHome::clone(&self) -> devlaunch_core::clients::devpod_home::DevpodHome +impl core::cmp::Eq for devlaunch_core::clients::devpod_home::DevpodHome +impl core::cmp::PartialEq for devlaunch_core::clients::devpod_home::DevpodHome +pub fn devlaunch_core::clients::devpod_home::DevpodHome::eq(&self, &devlaunch_core::clients::devpod_home::DevpodHome) -> bool +impl core::fmt::Debug for devlaunch_core::clients::devpod_home::DevpodHome +pub fn devlaunch_core::clients::devpod_home::DevpodHome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::clients::devpod_home::DevpodHome impl devlaunch_core::flows::launch::Host pub fn devlaunch_core::flows::launch::Host::from_process(impl core::convert::Into) -> Self impl core::clone::Clone for devlaunch_core::flows::launch::Host @@ -105,6 +170,12 @@ impl<'a, 'r, 'l> devlaunch_core::flows::launch::Launch<'a, 'r, 'l> pub fn devlaunch_core::flows::launch::Launch<'a, 'r, 'l>::new(&'a mut devlaunch_core::flows::listing::CommandContext<'r>, &'a mut devlaunch_core::flows::lifecycle::Refresh<'l>, &'a mut dyn devlaunch_core::flows::launch::ColdMachinery<'r>, &'a dyn devlaunch_core::flows::launch::Provision, &'a devlaunch_core::flows::launch::Host, &'a mut dyn core::ops::function::FnMut(&str), &'a mut dyn devlaunch_core::notices::Notices) -> Self pub fn devlaunch_core::flows::launch::Launch<'a, 'r, 'l>::recognised_as(self, core::option::Option) -> Self pub fn devlaunch_core::flows::launch::Launch<'a, 'r, 'l>::run(&mut self, &str, &devlaunch_core::flows::launch::LaunchVerb, core::option::Option<&devlaunch_core::domain::spec::DevcontainerPath>) -> core::result::Result +impl devlaunch_core::domain::metadata::MetadataStorage +pub fn devlaunch_core::domain::metadata::MetadataStorage::default_path() -> core::result::Result +pub fn devlaunch_core::domain::metadata::MetadataStorage::on_metadata_lock_wait(&mut self, impl core::ops::function::Fn() + 'static) +pub fn devlaunch_core::domain::metadata::MetadataStorage::open(impl core::convert::Into) -> core::result::Result<(Self, alloc::vec::Vec), devlaunch_core::domain::metadata::MetadataError> +impl core::fmt::Debug for devlaunch_core::domain::metadata::MetadataStorage +pub fn devlaunch_core::domain::metadata::MetadataStorage::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl<'a> devlaunch_core::flows::lifecycle::Refresh<'a> pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::ask(&mut self, &dyn devlaunch_runner::Runner, devlaunch_core::flows::lifecycle::RefreshReason) -> devlaunch_core::flows::lifecycle::RefreshSpawn pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::new(&'a devlaunch_core::flows::lifecycle::SelfInvocation, &'a std::path::Path) -> Self @@ -125,6 +196,12 @@ pub fn devlaunch_core::flows::launch::ToolProvisioning<'e>::from_env(&std::path: impl devlaunch_core::flows::launch::Provision for devlaunch_core::flows::launch::ToolProvisioning<'_> pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::provision_tools(&self, &dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, core::option::Option<&str>) -> core::result::Result, devlaunch_core::flows::provision::DevpodMissing> pub fn devlaunch_core::flows::launch::ToolProvisioning<'_>::remembered_claude(&self, &str) -> core::option::Option +impl<'r> devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r> +pub fn devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r>::in_cache(&std::path::Path, &devlaunch_core::domain::config::WorktreeConfig, devlaunch_core::clients::git::Git<'r>) -> Self +pub fn devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r>::on_repo_lock_wait(&mut self, impl core::ops::function::Fn(&str, &str) + 'static) +pub fn devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r>::repo_manager(&self) -> &devlaunch_core::flows::repo_manager::RepositoryManager<'r> +impl core::fmt::Debug for devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_> +pub fn devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl<'r> devlaunch_core::flows::launch::ColdMachinery<'r> for devlaunch_core::flows::launch::ColdPath<'r, '_> pub fn devlaunch_core::flows::launch::ColdPath<'r, '_>::open(&mut self) -> core::result::Result, devlaunch_core::flows::launch::ColdRefused> impl devlaunch_core::notices::Notices for alloc::vec::Vec @@ -1448,20 +1525,6 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::ChildWork pub fn devlaunch_core::flows::lifecycle::ChildWork::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::ChildWork impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::ChildWork -pub enum devlaunch_core::flows::lifecycle::DeleteOutcome -pub devlaunch_core::flows::lifecycle::DeleteOutcome::Deleted -pub devlaunch_core::flows::lifecycle::DeleteOutcome::Deleted::clone: core::result::Result -pub devlaunch_core::flows::lifecycle::DeleteOutcome::Deleted::volumes: devlaunch_core::flows::lifecycle::VolumeSweep -pub devlaunch_core::flows::lifecycle::DeleteOutcome::DevpodRefused -pub devlaunch_core::flows::lifecycle::DeleteOutcome::DevpodRefused::exit: devlaunch_runner::Exit -impl core::clone::Clone for devlaunch_core::flows::lifecycle::DeleteOutcome -pub fn devlaunch_core::flows::lifecycle::DeleteOutcome::clone(&self) -> devlaunch_core::flows::lifecycle::DeleteOutcome -impl core::cmp::Eq for devlaunch_core::flows::lifecycle::DeleteOutcome -impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::DeleteOutcome -pub fn devlaunch_core::flows::lifecycle::DeleteOutcome::eq(&self, &devlaunch_core::flows::lifecycle::DeleteOutcome) -> bool -impl core::fmt::Debug for devlaunch_core::flows::lifecycle::DeleteOutcome -pub fn devlaunch_core::flows::lifecycle::DeleteOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::DeleteOutcome pub enum devlaunch_core::flows::lifecycle::DeleteStalled pub devlaunch_core::flows::lifecycle::DeleteStalled::OnTheLock impl core::clone::Clone for devlaunch_core::flows::lifecycle::DeleteStalled @@ -1473,17 +1536,6 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::DeleteStalled pub fn devlaunch_core::flows::lifecycle::DeleteStalled::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::DeleteStalled impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::DeleteStalled -pub enum devlaunch_core::flows::lifecycle::Guarded -pub devlaunch_core::flows::lifecycle::Guarded::MayRemove -pub devlaunch_core::flows::lifecycle::Guarded::Refused(devlaunch_core::flows::lifecycle::RemovalRefused) -impl core::clone::Clone for devlaunch_core::flows::lifecycle::Guarded -pub fn devlaunch_core::flows::lifecycle::Guarded::clone(&self) -> devlaunch_core::flows::lifecycle::Guarded -impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Guarded -impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Guarded -pub fn devlaunch_core::flows::lifecycle::Guarded::eq(&self, &devlaunch_core::flows::lifecycle::Guarded) -> bool -impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Guarded -pub fn devlaunch_core::flows::lifecycle::Guarded::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Guarded pub enum devlaunch_core::flows::lifecycle::Insistence pub devlaunch_core::flows::lifecycle::Insistence::Insisted pub devlaunch_core::flows::lifecycle::Insistence::NotInsisted @@ -1527,6 +1579,10 @@ pub devlaunch_core::flows::lifecycle::LifecycleNotice::CloneRemoved::workspace_i pub devlaunch_core::flows::lifecycle::LifecycleNotice::RecordNotDropped pub devlaunch_core::flows::lifecycle::LifecycleNotice::RecordNotDropped::path: std::path::PathBuf pub devlaunch_core::flows::lifecycle::LifecycleNotice::RecordNotDropped::refusal: devlaunch_core::domain::metadata::MetadataError +pub devlaunch_core::flows::lifecycle::LifecycleNotice::Removing +pub devlaunch_core::flows::lifecycle::LifecycleNotice::Removing::workspace_id: alloc::string::String +pub devlaunch_core::flows::lifecycle::LifecycleNotice::RemovingOverWork +pub devlaunch_core::flows::lifecycle::LifecycleNotice::RemovingOverWork::refusal: devlaunch_core::flows::lifecycle::RemovalRefused pub devlaunch_core::flows::lifecycle::LifecycleNotice::VolumesNotRemoved pub devlaunch_core::flows::lifecycle::LifecycleNotice::VolumesNotRemoved::refusal: devlaunch_core::flows::lifecycle::VolumeRefusal pub devlaunch_core::flows::lifecycle::LifecycleNotice::VolumesNotRemoved::workspace_id: alloc::string::String @@ -1563,18 +1619,6 @@ pub fn devlaunch_core::flows::lifecycle::Objection::eq(&self, &devlaunch_core::f impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Objection pub fn devlaunch_core::flows::lifecycle::Objection::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Objection -pub enum devlaunch_core::flows::lifecycle::Persistence -pub devlaunch_core::flows::lifecycle::Persistence::Ordinary -pub devlaunch_core::flows::lifecycle::Persistence::Wedged -impl core::clone::Clone for devlaunch_core::flows::lifecycle::Persistence -pub fn devlaunch_core::flows::lifecycle::Persistence::clone(&self) -> devlaunch_core::flows::lifecycle::Persistence -impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Persistence -impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Persistence -pub fn devlaunch_core::flows::lifecycle::Persistence::eq(&self, &devlaunch_core::flows::lifecycle::Persistence) -> bool -impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Persistence -pub fn devlaunch_core::flows::lifecycle::Persistence::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result -impl core::marker::Copy for devlaunch_core::flows::lifecycle::Persistence -impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Persistence pub enum devlaunch_core::flows::lifecycle::Promotion pub devlaunch_core::flows::lifecycle::Promotion::Insisted pub devlaunch_core::flows::lifecycle::Promotion::Insisted::despite: devlaunch_core::flows::lifecycle::Objection @@ -1669,6 +1713,21 @@ impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RefreshSpawn pub fn devlaunch_core::flows::lifecycle::RefreshSpawn::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::flows::lifecycle::RefreshSpawn impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RefreshSpawn +pub enum devlaunch_core::flows::lifecycle::Removal +pub devlaunch_core::flows::lifecycle::Removal::Guarded +pub devlaunch_core::flows::lifecycle::Removal::Insisted +pub devlaunch_core::flows::lifecycle::Removal::Wedged +impl devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::insistence(self) -> devlaunch_core::flows::lifecycle::Insistence +impl core::clone::Clone for devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::clone(&self) -> devlaunch_core::flows::lifecycle::Removal +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::Removal +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::eq(&self, &devlaunch_core::flows::lifecycle::Removal) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::Removal +pub fn devlaunch_core::flows::lifecycle::Removal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::Copy for devlaunch_core::flows::lifecycle::Removal +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Removal pub enum devlaunch_core::flows::lifecycle::RemovalRefused pub devlaunch_core::flows::lifecycle::RemovalRefused::CouldNotTell pub devlaunch_core::flows::lifecycle::RemovalRefused::CouldNotTell::cause: devlaunch_core::domain::workspace_state::CouldNotTell @@ -1684,6 +1743,21 @@ pub fn devlaunch_core::flows::lifecycle::RemovalRefused::eq(&self, &devlaunch_co impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemovalRefused pub fn devlaunch_core::flows::lifecycle::RemovalRefused::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemovalRefused +pub enum devlaunch_core::flows::lifecycle::RemoveOutcome +pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted +pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted::clone: core::result::Result +pub devlaunch_core::flows::lifecycle::RemoveOutcome::Deleted::volumes: devlaunch_core::flows::lifecycle::VolumeSweep +pub devlaunch_core::flows::lifecycle::RemoveOutcome::DevpodRefused +pub devlaunch_core::flows::lifecycle::RemoveOutcome::DevpodRefused::exit: devlaunch_runner::Exit +pub devlaunch_core::flows::lifecycle::RemoveOutcome::Refused(devlaunch_core::flows::lifecycle::RemovalRefused) +impl core::clone::Clone for devlaunch_core::flows::lifecycle::RemoveOutcome +pub fn devlaunch_core::flows::lifecycle::RemoveOutcome::clone(&self) -> devlaunch_core::flows::lifecycle::RemoveOutcome +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::RemoveOutcome +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::RemoveOutcome +pub fn devlaunch_core::flows::lifecycle::RemoveOutcome::eq(&self, &devlaunch_core::flows::lifecycle::RemoveOutcome) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::RemoveOutcome +pub fn devlaunch_core::flows::lifecycle::RemoveOutcome::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::RemoveOutcome pub enum devlaunch_core::flows::lifecycle::SpawnRefused pub devlaunch_core::flows::lifecycle::SpawnRefused::Blocked(devlaunch_runner::OsFailure) pub devlaunch_core::flows::lifecycle::SpawnRefused::ProgramNotFound @@ -1921,7 +1995,6 @@ pub fn devlaunch_core::flows::lifecycle::Withheld::fmt(&self, &mut core::fmt::Fo impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::Withheld pub fn devlaunch_core::flows::lifecycle::apply_reconciliation(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &devlaunch_core::clients::devpod_home::DevpodHome, &devlaunch_core::flows::lifecycle::ReconcilePlan, &mut dyn devlaunch_core::notices::Notices) -> devlaunch_core::flows::lifecycle::ReconcileReport pub fn devlaunch_core::flows::lifecycle::child_work(&std::path::Path, devlaunch_core::flows::lifecycle::RefreshReason) -> devlaunch_core::flows::lifecycle::ChildWork -pub fn devlaunch_core::flows::lifecycle::guard_removal(&str, devlaunch_core::domain::workspace_state::Unsaved, devlaunch_core::flows::lifecycle::Insistence) -> devlaunch_core::flows::lifecycle::Guarded pub fn devlaunch_core::flows::lifecycle::objection(&devlaunch_core::domain::workspace_state::Unsaved) -> core::option::Option pub fn devlaunch_core::flows::lifecycle::prune_clones(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &devlaunch_core::flows::lifecycle::PrunePlan, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::prune_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, devlaunch_core::flows::lifecycle::Insistence, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result @@ -1929,8 +2002,7 @@ pub fn devlaunch_core::flows::lifecycle::purge_all_data(&mut devlaunch_core::flo pub fn devlaunch_core::flows::lifecycle::purge_plan(&mut devlaunch_core::flows::listing::CommandContext<'_>, &std::path::Path) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::reconcile_plan(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &[devlaunch_core::clients::devpod::Workspace], &devlaunch_core::flows::lifecycle::ClonePlacement, &mut dyn devlaunch_core::notices::Notices) -> devlaunch_core::flows::lifecycle::ReconcilePlan pub fn devlaunch_core::flows::lifecycle::sweep_repo_fetches(&devlaunch_core::flows::repo_manager::RepositoryManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage) -> devlaunch_core::flows::lifecycle::SweepReport -pub fn devlaunch_core::flows::lifecycle::unsaved_work_in(&devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &devlaunch_core::domain::metadata::MetadataStorage, &devlaunch_core::clients::git::Git<'_>, &std::path::Path, &str, &mut dyn devlaunch_core::notices::Notices) -> devlaunch_core::domain::workspace_state::Unsaved -pub fn devlaunch_core::flows::lifecycle::workspace_delete(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, core::option::Option<&devlaunch_core::clients::devpod_home::DevpodHome>, &str, devlaunch_core::flows::lifecycle::Insistence, devlaunch_core::flows::lifecycle::Persistence, &mut dyn core::ops::function::FnMut(devlaunch_core::flows::lifecycle::DeleteStalled), &mut dyn devlaunch_core::notices::Notices) -> core::result::Result +pub fn devlaunch_core::flows::lifecycle::workspace_remove(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'_>, &mut devlaunch_core::domain::metadata::MetadataStorage, &std::path::Path, core::option::Option<&devlaunch_core::clients::devpod_home::DevpodHome>, &str, devlaunch_core::flows::lifecycle::Removal, &mut dyn core::ops::function::FnMut(devlaunch_core::flows::lifecycle::DeleteStalled), &mut dyn devlaunch_core::notices::Notices) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::workspace_state(&dyn devlaunch_runner::Runner, &str, devlaunch_core::clients::devpod::Patience) -> core::result::Result pub fn devlaunch_core::flows::lifecycle::workspace_stop(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut devlaunch_core::flows::lifecycle::Refresh<'_>, &str) -> core::result::Result pub mod devlaunch_core::flows::listing diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index 72c72c6b..d85250e1 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -86,8 +86,8 @@ use crate::flows::listing::{ self, ClonePathResolver, CommandContext, WorkspaceOwnership, json_as_python_writes_it, }; use crate::flows::repo_manager::{ - BACKGROUND_FETCH_TIMEOUT, CacheNotice, Fetched, LazyFetchError, Refusal, Removal, - RepositoryManager, present, remove_tree_as_far_as_it_goes, + BACKGROUND_FETCH_TIMEOUT, CacheNotice, Fetched, LazyFetchError, Refusal, RepositoryManager, + TreeSweep, present, remove_tree_as_far_as_it_goes, }; use crate::flows::workspace_clone::{RemoveWorkspaceError, Removed, WorkspaceCloneManager}; use crate::notices::{Notices, Wrapped}; @@ -148,6 +148,24 @@ pub enum LifecycleNotice { repo: String, branch: String, }, + /// Which workspace is being removed, said once the guard has had its say and + /// before devpod is asked. + /// + /// The resolved id, which is the point of saying it at all: a target that was a + /// branch, a path or a row in a picker is not this word, and the line is the + /// only place a reader learns what it actually resolved to. It is a notice + /// rather than something the caller prints around the call because the *timing* + /// is what makes it a warning instead of a receipt — it has to land between the + /// guard and `devpod delete`, and only [`workspace_remove`] knows where that + /// is. + Removing { workspace_id: String }, + /// The removal found work that exists nowhere else and is going ahead anyway. + /// + /// Only [`Removal::Wedged`] produces this: `dl rm` refuses on the same + /// finding and `rm --force` never looks. Carries the refusal it stepped past, + /// so the list of what is about to be destroyed is the same value the refusal + /// would have carried — one guard, one finding, two things to do with it. + RemovingOverWork { refusal: RemovalRefused }, /// Something one of the storage flows reported on the way through. Cache(CacheNotice), } @@ -755,6 +773,106 @@ fn stop_call(workspace_id: &str) -> Call { // the delete guard // =========================================================================== +/// Which removal this is, of the three `dl` performs. +/// +/// One value rather than the four flags it stands for — the unsaved-work guard, +/// devpod's `--ignore-not-found`, devpod's `--force` and a deadline — because those +/// are not independent settings anybody would want to mix. They are one decision +/// about how badly the caller wants the workspace gone, and spelling them +/// separately makes seven combinations writable of which three are meant. The three +/// that are meant are these, and each one names a command line rather than a +/// setting. +/// +/// It lived in the `dl` binary until the removal fold, which is what made the +/// guard skippable: core took the flags one at a time, so the sequence that turns +/// them into a removal was the caller's to get right. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Removal { + /// `dl rm`, the happy path. Stops at work that exists nowhere else, names + /// it, and offers `--force`. devpod is asked with its own defaults and given as + /// long as it needs, because a container that is slow to come down is a + /// container that is coming down. + Guarded, + /// `dl rm --force`. The guard does not even look, and an absent workspace + /// counts as deleted, which is what makes it `rm -f` rather than a louder `rm`. + /// devpod is still asked politely: this is a workspace you are sure about, not + /// one that is stuck. + Insisted, + /// `dl kill`. The verb for a workspace that is wedged and finished with, + /// so nothing here refuses and nothing here waits indefinitely: the guard looks + /// and *reports* rather than stopping, devpod gets `--force` so a workspace it + /// can no longer reach still goes, and the call carries a deadline so it cannot + /// join the five second lock loop the sweep in front of it was reached for. + /// + /// The guard still looks, and that is the difference between this and + /// [`Removal::Insisted`] rather than a leftover: work that exists nowhere else + /// is about to be destroyed, and the person who typed `kill` is owed the list + /// even though they are not being asked to confirm it. + Wedged, +} + +impl Removal { + /// Whether dl will accept an absent workspace as a delete, and what devpod's + /// `--ignore-not-found` rides on. + /// + /// Public because it is also what a *rendering* of the answer turns on: "Removed + /// workspace X" and "Workspace X is gone" are the two things a zero exit + /// established, and only this tells them apart. + pub fn insistence(self) -> Insistence { + match self { + Self::Guarded => Insistence::NotInsisted, + Self::Insisted | Self::Wedged => Insistence::Insisted, + } + } + + /// How hard devpod is pushed, and whether the call carries a deadline. + fn persistence(self) -> Persistence { + match self { + Self::Guarded | Self::Insisted => Persistence::Ordinary, + Self::Wedged => Persistence::Wedged, + } + } + + /// Whether the unsaved-work probe is worth running, and what its answer does. + /// + /// [`Removal::Insisted`] is the one that skips it, and it skips it to save the + /// work rather than to hide the answer: the probe is a `git status` and a + /// `git log` per clone, and `rm --force` has said in advance that it will not + /// act on either. Probing unconditionally is the one-line accident this fold + /// invites, so the skip is a total function of the removal rather than a + /// condition anybody writes twice. + fn probe(self) -> Probe { + match self { + Self::Guarded => Probe::Look(Finding::Refuses), + Self::Insisted => Probe::Skip, + Self::Wedged => Probe::Look(Finding::Says), + } + } +} + +/// Whether the removal looks for work that exists nowhere else. +/// +/// Nested rather than three flat arms so that [`Finding`] is unreachable from the +/// arm that never looks: a removal that skips the probe has no finding to act on, +/// and a flat third arm left every `match` on the answer with a case its author had +/// to invent a body for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Probe { + /// Look, and then do this with what is found. + Look(Finding), + /// Do not look. `rm --force`'s. + Skip, +} + +/// What a removal does with work it found that exists nowhere else. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Finding { + /// Stop, and hand the refusal back for the caller to name. `rm`'s. + Refuses, + /// Say it, and remove it. `kill`'s. + Says, +} + /// Whether the caller typed `--force`. /// /// Named arms rather than a bool, because `--force` means two different things on @@ -789,7 +907,7 @@ pub enum RemovalRefused { /// What the guard decided. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum Guarded { +pub(crate) enum Guarded { /// Nothing dl can establish would be lost, or the caller insisted. MayRemove, Refused(RemovalRefused), @@ -810,7 +928,11 @@ pub enum Guarded { /// `--force` is checked *after* the answer is read, not instead of reading it, so /// the refusal a forced delete carried past is still available to the caller — and /// so a future `--force` that wanted to report what it overrode has it. -pub fn guard_removal(workspace_id: &str, unsaved: Unsaved, insistence: Insistence) -> Guarded { +pub(crate) fn guard_removal( + workspace_id: &str, + unsaved: Unsaved, + insistence: Insistence, +) -> Guarded { let refusal = match unsaved { Unsaved::NothingToLose => return Guarded::MayRemove, Unsaved::WouldLose(losses) => RemovalRefused::WouldLose { @@ -872,7 +994,7 @@ impl ClonePathResolver for CloneDirectories<'_, '_> { /// from a path or a URL that dl never cloned and does not manage, so it has no /// clone of its own to protect and no business inspecting somebody's checkout to /// find one. -pub fn unsaved_work_in( +pub(crate) fn unsaved_work_in( clones: &WorkspaceCloneManager<'_>, storage: &MetadataStorage, git: &Git<'_>, @@ -898,7 +1020,7 @@ pub fn unsaved_work_in( /// What became of the docker volumes a deleted workspace's devcontainer created. /// /// Every arm is an outcome of a delete that **succeeded** — the workspace is gone -/// in all four — which is why this rides inside [`DeleteOutcome::Deleted`] rather +/// in all four — which is why this rides inside [`RemoveOutcome::Deleted`] rather /// than being able to fail it. Reporting a failure here would send the caller /// looking for a workspace that is not there, which is the same reasoning the /// clone arm beside it uses. @@ -941,9 +1063,22 @@ pub enum VolumeRefusal { NotRun { failure: OsFailure }, } -/// How a delete ended. +/// How a removal ended. +/// +/// Three arms and one sum, rather than a guard's answer beside a delete's: the +/// refusal is an *end* of the removal, and separating the two is what let a caller +/// hold the first and go on to the second. #[derive(Debug, Clone, PartialEq, Eq)] -pub enum DeleteOutcome { +pub enum RemoveOutcome { + /// The clone holds work that exists nowhere else, so nothing was deleted: + /// devpod was never asked, the clone is where it was, and the workspace is + /// still there. + /// + /// Only [`Removal::Guarded`] ends here. The refusal carries what would have + /// been lost, so the caller writes the sentence and the way past it without + /// asking again — the words are the caller's for the reason every other refusal + /// in this crate leaves them there (#251 §5). + Refused(RemovalRefused), /// devpod let go of the workspace. `clone` says what became of the local /// clone: `Ok` with which no-op or removal happened, or `Err` when the /// removal was attempted and refused (the workspace is gone regardless, which @@ -965,10 +1100,101 @@ pub enum DeleteOutcome { DevpodRefused { exit: Exit }, } +/// Remove a workspace: the guard, the delete, and the clone with it. +/// +/// **The whole of `dl rm`, `rm --force` and `kill` behind one call**, and the +/// reason it is one call is what the three used to be. The probe, the guard and the +/// delete were three separate exported functions the caller had to run in the right +/// order with the right arguments, and only the last of them was on the promised +/// surface — so the promise was an unguarded delete, and the sequence that makes it +/// safe lived in the `dl` binary where nothing else could reuse it or be held to +/// it. A second consumer following the promise exactly would delete somebody's only +/// copy of an afternoon's work. Folding them removes the way to get that wrong: +/// there is no argument to this function that skips the guard and reaches the +/// delete. +/// +/// The order is the point, and it is fixed here rather than documented for a caller +/// to reproduce: +/// +/// 1. **Probe**, but only for a [`Removal`] that will act on the answer — see +/// [`Removal::probe`]. It is a `git status` and a `git log` per clone. +/// 2. **Guard**, always asked with [`Insistence::NotInsisted`] whatever this +/// removal insists, because what is wanted from it is the *finding* rather than +/// the verdict: [`Removal::Wedged`] acts on the same finding differently, and +/// passing its own insistence would collapse the finding to +/// [`Guarded::MayRemove`] before it could. +/// 3. **Name the volumes, then delete, then remove the clone**, which is +/// [`workspace_delete`] and where the rest of the ordering lives. +/// +/// `git` is not a parameter: inside core it is [`CommandContext::git`], so the +/// probe cannot be pointed at a different git from the one the delete's own clone +/// work uses. +#[allow(clippy::too_many_arguments)] +pub fn workspace_remove( + context: &mut CommandContext<'_>, + refresh: &mut Refresh<'_>, + clones: &WorkspaceCloneManager<'_>, + storage: &mut MetadataStorage, + cache_dir: &Path, + devpod_home: Option<&DevpodHome>, + workspace_id: &str, + removal: Removal, + stalled: &mut dyn FnMut(DeleteStalled), + notices: &mut dyn Notices, +) -> Result { + if let Probe::Look(finding) = removal.probe() { + let unsaved = unsaved_work_in( + clones, + storage, + &context.git(), + cache_dir, + workspace_id, + notices, + ); + if let Guarded::Refused(refusal) = + guard_removal(workspace_id, unsaved, Insistence::NotInsisted) + { + match finding { + // The one thing dl refuses on its own account. Nothing below this + // line has run, so the workspace and its clone are exactly as they + // were. + Finding::Refuses => return Ok(RemoveOutcome::Refused(refusal)), + // Said and stepped past. A workspace reached with `kill` is one + // somebody has already given up on, and stopping here is the failure + // the verb was rebuilt to stop having: a wedged workspace's clone is + // dirty almost by construction, since what wedged it interrupted + // whatever was being done in it. + Finding::Says => notices.say(LifecycleNotice::RemovingOverWork { refusal }), + } + } + } + // Which workspace this is, named after the guard has had its say and before + // devpod is asked. + notices.say(LifecycleNotice::Removing { + workspace_id: workspace_id.to_owned(), + }); + workspace_delete( + context, + refresh, + clones, + storage, + devpod_home, + workspace_id, + removal.insistence(), + removal.persistence(), + stalled, + notices, + ) +} + /// Delete a workspace and its local clone (if any). /// +/// **Not the removal**: this is [`workspace_remove`]'s second half, with no +/// unsaved-work guard in front of it, and it is `pub(crate)` for exactly that +/// reason. It used to be the promised surface's only removal. +/// /// The clone is removed only once devpod has actually let go of the workspace — -/// see [`DeleteOutcome::DevpodRefused`] for why. +/// see [`RemoveOutcome::DevpodRefused`] for why. /// /// [`Insistence::Insisted`] passes devpod's own `--ignore-not-found`, which makes a /// workspace devpod does not have count as deleted, so a forced remove is "ensure @@ -976,7 +1202,7 @@ pub enum DeleteOutcome { /// clone with no workspace is exactly what a half-finished delete leaves, and what /// a cold-bench reset (devlaunch#140) must clear. #[allow(clippy::too_many_arguments)] -pub fn workspace_delete( +pub(crate) fn workspace_delete( context: &mut CommandContext<'_>, refresh: &mut Refresh<'_>, clones: &WorkspaceCloneManager<'_>, @@ -987,7 +1213,7 @@ pub fn workspace_delete( persistence: Persistence, stalled: &mut dyn FnMut(DeleteStalled), notices: &mut dyn Notices, -) -> Result { +) -> Result { // Named *before* the delete, and that ordering is the whole of why this is two // steps: `devpod delete` takes devpod's own record of the workspace away with // the workspace, and that record is the only place the substituted volume @@ -1024,7 +1250,7 @@ pub fn workspace_delete( context.forget_workspaces(); if !exit.is_success() { refresh.ask(context.runner(), RefreshReason::Forced); - return Ok(DeleteOutcome::DevpodRefused { exit }); + return Ok(RemoveOutcome::DevpodRefused { exit }); } // Streamed rather than collected and appended, because the storage flow's own @@ -1067,7 +1293,7 @@ pub fn workspace_delete( }); } refresh.ask(context.runner(), RefreshReason::Forced); - Ok(DeleteOutcome::Deleted { clone, volumes }) + Ok(RemoveOutcome::Deleted { clone, volumes }) } /// Remove the volumes `named`, and say what became of them. @@ -1264,7 +1490,7 @@ pub enum DeleteStalled { /// call site and mean opposite-facing things there. [`Insistence`] is what dl will /// *accept* as a delete, and this is what devpod is *asked* for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Persistence { +pub(crate) enum Persistence { /// `rm`'s delete: devpod's defaults, and devpod's own patience. Ordinary, /// `kill`'s: `--force`, so a workspace devpod can no longer reach goes anyway, @@ -1462,9 +1688,9 @@ pub fn purge_all_data( } let cache_dir = plan.cache_dir.clone(); Ok(match remove_tree_as_far_as_it_goes(&cache_dir) { - Removal::Everything => PurgeOutcome::Removed { cache_dir }, - Removal::WhatItCould(refused) => PurgeOutcome::RemovedWhatItCould { cache_dir, refused }, - Removal::Nothing(refused) => PurgeOutcome::RemovedNothing { cache_dir, refused }, + TreeSweep::Everything => PurgeOutcome::Removed { cache_dir }, + TreeSweep::WhatItCould(refused) => PurgeOutcome::RemovedWhatItCould { cache_dir, refused }, + TreeSweep::Nothing(refused) => PurgeOutcome::RemovedNothing { cache_dir, refused }, }) } @@ -2524,13 +2750,13 @@ pub fn prune_clones( // refusal arms are alike to this caller — a directory half removed is // still a directory somebody has to deal with — so they share one arm. match remove_tree_as_far_as_it_goes(&reclaimable.path) { - Removal::Everything => { + TreeSweep::Everything => { report.removed.push((*reclaimable).clone()); if let Some(record) = record_for.get(&reclaimable.path) { forget.push(record.clone()); } } - Removal::WhatItCould(refused) | Removal::Nothing(refused) => { + TreeSweep::WhatItCould(refused) | TreeSweep::Nothing(refused) => { report.refused.extend(refused.iter().cloned()); } } @@ -3585,10 +3811,10 @@ mod tests { } /// The refusals of a removal, whichever arm carries them. - fn refused_paths(removal: &Removal) -> Vec { + fn refused_paths(removal: &TreeSweep) -> Vec { match removal { - Removal::Everything => Vec::new(), - Removal::WhatItCould(refused) | Removal::Nothing(refused) => { + TreeSweep::Everything => Vec::new(), + TreeSweep::WhatItCould(refused) | TreeSweep::Nothing(refused) => { refused.iter().map(|it| it.path.clone()).collect() } } @@ -3606,7 +3832,7 @@ mod tests { let cache = a_sealable_cache(); assert_eq!( remove_tree_as_far_as_it_goes(&cache.root), - Removal::Everything + TreeSweep::Everything ); assert!(!cache.root.exists()); } @@ -3619,7 +3845,7 @@ mod tests { let dir = temp_dir(); assert_eq!( remove_tree_as_far_as_it_goes(&dir.path().join("never-existed")), - Removal::Everything + TreeSweep::Everything ); } @@ -3649,7 +3875,7 @@ mod tests { to go, and saying so five times buries the one fact" ); assert!( - matches!(removal, Removal::WhatItCould(_)), + matches!(removal, TreeSweep::WhatItCould(_)), "the partial arm has to mean something went: {removal:?}" ); } @@ -3755,7 +3981,7 @@ mod tests { }; let removal = remove_tree_as_far_as_it_goes(&root); assert!( - matches!(removal, Removal::Nothing(_)), + matches!(removal, TreeSweep::Nothing(_)), "nothing came away: {removal:?}" ); assert_eq!(refused_paths(&removal), [root.as_path()]); @@ -3787,7 +4013,7 @@ mod tests { }; let removal = remove_tree_as_far_as_it_goes(&root); assert!( - matches!(removal, Removal::WhatItCould(_)), + matches!(removal, TreeSweep::WhatItCould(_)), "the clones under a sealed root are still removable: {removal:?}" ); assert!(!clone.exists()); @@ -3807,7 +4033,7 @@ mod tests { }; let removal = remove_tree_as_far_as_it_goes(&root); assert!( - matches!(removal, Removal::Nothing(_)), + matches!(removal, TreeSweep::Nothing(_)), "nothing was attempted: {removal:?}" ); assert_eq!(refused_paths(&removal), [root]); @@ -3835,7 +4061,7 @@ mod tests { let removal = remove_tree_as_far_as_it_goes(&link); - let Removal::Nothing(refused) = &removal else { + let TreeSweep::Nothing(refused) = &removal else { panic!("expected a removal that removed nothing, got {removal:?}"); }; assert_eq!(refused.len(), 1); @@ -3879,7 +4105,7 @@ mod tests { assert_eq!( remove_tree_as_far_as_it_goes(&cache.root), - Removal::Everything + TreeSweep::Everything ); assert!(!cache.root.exists()); assert_eq!( @@ -3898,7 +4124,7 @@ mod tests { .expect("a dangling link"); assert_eq!( remove_tree_as_far_as_it_goes(&cache.root), - Removal::Everything + TreeSweep::Everything ); assert!(!cache.root.exists()); } @@ -3938,7 +4164,7 @@ mod tests { }; assert_eq!( remove_tree_as_far_as_it_goes(&cache.root), - Removal::Everything + TreeSweep::Everything ); assert!(!cache.root.exists()); } @@ -4674,7 +4900,7 @@ mod tests { assert_eq!( outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Ok(Removed::NothingRecorded), volumes: VolumeSweep::NothingNamed, } @@ -4970,7 +5196,7 @@ mod tests { assert_eq!( outcome, - DeleteOutcome::DevpodRefused { + RemoveOutcome::DevpodRefused { exit: Exit::Code(1) } ); @@ -5010,7 +5236,7 @@ mod tests { assert_eq!( outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Ok(Removed::Clone), volumes: VolumeSweep::NothingNamed, } @@ -5062,7 +5288,7 @@ mod tests { assert!( matches!( &outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Err(RemoveWorkspaceError::DirectoryLeft( RemoveTreeError::RootIsSymlink { .. } )), @@ -5148,7 +5374,7 @@ mod tests { impl Deleting { /// Delete `r-main-aa`, collecting the notices it produced. - fn delete(&mut self) -> (DeleteOutcome, Vec) { + fn delete(&mut self) -> (RemoveOutcome, Vec) { let clones = clones_for(&self.world.repos_dir, &self.world.devpod); let mut context = CommandContext::new(&self.world.devpod); let mut refresh = Refresh::new(&self.updater, &self.cache_path); @@ -5196,7 +5422,7 @@ mod tests { ); assert_eq!( outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Ok(Removed::Clone), volumes: VolumeSweep::Removed, } @@ -5255,7 +5481,7 @@ mod tests { assert_eq!( outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Ok(Removed::Clone), volumes: VolumeSweep::NoDocker, } @@ -5294,7 +5520,7 @@ mod tests { assert_eq!( outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Ok(Removed::Clone), volumes: VolumeSweep::Refused(refusal.clone()), } @@ -5323,7 +5549,7 @@ mod tests { assert_eq!( outcome, - DeleteOutcome::Deleted { + RemoveOutcome::Deleted { clone: Ok(Removed::Clone), volumes: VolumeSweep::NothingNamed, } diff --git a/rust/devlaunch-core/src/flows/repo_manager.rs b/rust/devlaunch-core/src/flows/repo_manager.rs index 6c300246..db12f334 100644 --- a/rust/devlaunch-core/src/flows/repo_manager.rs +++ b/rust/devlaunch-core/src/flows/repo_manager.rs @@ -617,7 +617,7 @@ pub enum RefusalReason { /// means the tree is gone and nothing else does. Which of the two failures /// happened is in the report, where somebody can act on it. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum Removal { +pub(crate) enum TreeSweep { /// The tree is gone, and so is everything that was under it — including when /// it was never there: a purge run twice is not a failure the second time. Everything, @@ -628,7 +628,7 @@ pub(crate) enum Removal { } /// Remove `tree` and everything under it, **keeping going past a refusal**, and -/// say which of [`Removal`]'s three things happened. +/// say which of [`TreeSweep`]'s three things happened. /// /// [`remove_tree`] is the other half of this pair and is the one the cache's own /// steps use: it stops at the first failure, which is right when the tree is one @@ -673,13 +673,13 @@ pub(crate) enum Removal { /// caller never named, and unlinking just the link reports a clean sweep over /// clones that are still on disk somewhere else. Symlinks *inside* the tree are /// unlinked and never descended. -pub(crate) fn remove_tree_as_far_as_it_goes(tree: &Path) -> Removal { +pub(crate) fn remove_tree_as_far_as_it_goes(tree: &Path) -> TreeSweep { // One lstat, three outcomes, none of them inferred — see `present` for why // this question cannot be asked with an existence check. let stat = match std::fs::symlink_metadata(tree) { Ok(stat) => stat, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Removal::Everything; + return TreeSweep::Everything; } Err(error) => { // Something is there that we are not allowed to look at. Nothing was @@ -735,14 +735,14 @@ pub(crate) fn remove_tree_as_far_as_it_goes(tree: &Path) -> Removal { // Nothing survived that anybody needs to know about, so the tree is gone — // including the case where the walk hit failures the disk then // contradicted. - None => Removal::Everything, - Some(refused) if walk.removed_any => Removal::WhatItCould(refused), - Some(refused) => Removal::Nothing(refused), + None => TreeSweep::Everything, + Some(refused) if walk.removed_any => TreeSweep::WhatItCould(refused), + Some(refused) => TreeSweep::Nothing(refused), } } -fn refused_nothing(refusal: Refusal) -> Removal { - Removal::Nothing(NonEmpty::one(refusal)) +fn refused_nothing(refusal: Refusal) -> TreeSweep { + TreeSweep::Nothing(NonEmpty::one(refusal)) } /// The bottom-up walk's running state: what failed, and whether anything went. diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index 06fa7676..5b48f7a3 100644 --- a/rust/devlaunch-core/src/lib.rs +++ b/rust/devlaunch-core/src/lib.rs @@ -162,7 +162,27 @@ pub mod api { pub use crate::flows::listing::{CommandContext, enriched_listing, json_document}; // remove / stop: the two lifecycle verbs that take a workspace away. - pub use crate::flows::lifecycle::{workspace_delete, workspace_stop}; + // + // `workspace_remove` and not `workspace_delete`: the delete without the + // unsaved-work guard is gone from the promise rather than documented beside the + // guarded one (#410). It was a removal of three exported calls a caller had to + // sequence — probe, guard, delete — of which only the last was promised, so the + // promised surface was the unguarded one. #251 §7 calls dropping a promised + // function a breaking change, and it is the right weight: an unguarded delete + // is now unrepresentable rather than merely discouraged. + pub use crate::flows::lifecycle::{workspace_remove, workspace_stop}; + + // …and everything `workspace_remove` asks for and answers with, for the reason + // `Launch::new`'s parameters are here: a promised function whose parameter types + // live outside the promise is not callable from the promise. `Refresh` and + // `Notices` are already above. + pub use crate::clients::devpod_home::DevpodHome; + pub use crate::domain::metadata::MetadataStorage; + pub use crate::flows::lifecycle::{ + DeleteStalled, Insistence, LifecycleNotice, Removal, RemovalRefused, RemoveOutcome, + }; + pub use crate::flows::records::Records; + pub use crate::flows::workspace_clone::WorkspaceCloneManager; // spec and branch helpers: parsing `owner/repo@branch` and friends, the // identity a safe name derives, and the `--devcontainer` reference. diff --git a/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs b/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs new file mode 100644 index 00000000..5dec635a --- /dev/null +++ b/rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs @@ -0,0 +1,430 @@ +//! A workspace removal driven from `devlaunch_core::api` and nothing else, over a +//! clone holding the only copy of somebody's work. +//! +//! This is devlaunch#410 as a test. The promised tier used to carry +//! `workspace_delete` — the delete **without** the unsaved-work guard — while the +//! probe and the guard that make it safe were two further exported functions the +//! caller had to run first, in the right order, with the right arguments. So a +//! second consumer following the promise exactly deleted the clone. There is one +//! exported removal now, and the guard is inside it. +//! +//! Two things are asserted, and the import list is the first of them. Every name +//! comes through `api`: no `flows::`, `domain::` or `clients::` path appears below, +//! because a promise a caller cannot reach the parameter types of is not a promise. +//! The runner is the exception `api_launch_is_self_sufficient.rs` already makes — +//! it is its own crate and its own promised seam — and it is not a parameter of +//! `workspace_remove` either. +//! +//! The second is behavioural, and it is what "the guard cannot be skipped" means: +//! a recorded clone with an uncommitted change refuses, **devpod is never asked**, +//! and the refusal carries what would have been lost rather than a sentence about +//! it. The world these tests drive is a real cache directory on disk with a real +//! git repository in it, built the way `dl` leaves one, because the point is that +//! the promised call reads the machine and not a fixture handed to it. + +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard}; + +use devlaunch_core::api::{ + ColdPath, CommandContext, DeleteStalled, DevpodHome, LifecycleNotice, Notices, Records, + RecordsNotice, Refresh, Removal, RemovalRefused, RemoveOutcome, SelfInvocation, + workspace_remove, +}; +use devlaunch_core::runner::{ + CapturedText, DetachOutcome, Invocation, Outcome, ProcessRunner, Runner, SpawnSpec, +}; +use devlaunch_test_support::{FakeRunner, WorkspaceState}; + +const OWNER: &str = "blooop"; +const REPO: &str = "devlaunch"; +const BRANCH: &str = "main"; +const WORKSPACE: &str = "devlaunch-main-aa11"; + +// =========================================================================== +// the tests +// =========================================================================== + +/// The finding, at the surface it destroys things from. +/// +/// `Insistence` is not a parameter of this call and there is no argument that +/// reaches the delete without the guard: `Removal::Guarded` is `dl rm`, and a +/// clone holding an uncommitted change ends it before devpod is asked. The refusal +/// is read rather than merely matched — its `losses` field is bound and its words +/// are the ones a caller would print. +#[test] +fn a_guarded_removal_refuses_over_unsaved_work_and_never_asks_devpod() { + let machine = Machine::new(); + machine.a_recorded_clone_holding("an-hour-of-work.md"); + + let outcome = machine.remove(Removal::Guarded); + + let RemoveOutcome::Refused(RemovalRefused::WouldLose { + workspace_id, + losses, + }) = outcome + else { + panic!("expected a refusal that names what would be lost, got {outcome:?}"); + }; + assert_eq!(workspace_id, WORKSPACE); + assert!( + losses.describe().contains("an-hour-of-work.md"), + "the refusal has to carry what would be lost, not a count: {}", + losses.describe() + ); + assert!( + machine.devpod_argvs().is_empty(), + "a refused removal must not have asked devpod for anything: {:?}", + machine.devpod_argvs() + ); + assert!( + machine.clone_dir().exists(), + "the clone is what the refusal was protecting" + ); +} + +/// `rm --force` removes it anyway, and pays for no probe on the way. +/// +/// The conditional probe is a `git status` and a `git log` per clone, and running +/// it unconditionally is the one-line accident the fold invites: it costs nothing +/// visible and is only wrong in the bill. `Removal::Insisted` has said in advance +/// that it will not act on the answer, so it must not ask the question. +#[test] +fn an_insisted_removal_deletes_the_same_clone_and_runs_no_probe() { + let machine = Machine::new(); + machine.a_recorded_clone_holding("an-hour-of-work.md"); + + let outcome = machine.remove(Removal::Insisted); + + assert!( + matches!(outcome, RemoveOutcome::Deleted { .. }), + "expected the workspace to go, got {outcome:?}" + ); + assert_eq!(machine.deleted_workspaces(), [WORKSPACE]); + assert!(!machine.clone_dir().exists(), "the clone goes with it"); + let probed: Vec> = machine + .argvs_to("git") + .into_iter() + .filter(|argv| argv.iter().any(|word| word == "status" || word == "log")) + .collect(); + assert!( + probed.is_empty(), + "`rm --force` acts on no finding, so it should look for none: {probed:?}" + ); +} + +/// The ordering the delete's own comment claims, as an assertion. +/// +/// devpod's record of the workspace is the only place the substituted volume names +/// live, and `devpod delete` takes that record away with the workspace. Named +/// afterwards, the sweep would find nothing every time and look like a working +/// cleanup: the workspace still goes, the exit status is still zero, and the +/// volumes stay on the disk for ever. +/// +/// So the fake devpod here removes its own record when it is asked to delete, the +/// way the real one does, and what the test reads is whether docker was asked about +/// the two names that record held. Read after the delete, there would be nothing to +/// ask about. +#[test] +fn the_volumes_are_named_before_devpod_is_asked_to_delete() { + let machine = Machine::new(); + machine.a_recorded_clone_holding("an-hour-of-work.md"); + machine.a_devcontainer_that_created_a_volume(); + + let outcome = machine.remove(Removal::Insisted); + + assert!( + matches!(outcome, RemoveOutcome::Deleted { .. }), + "expected the workspace to go, got {outcome:?}" + ); + let swept: Vec = machine.argvs_to("docker").into_iter().flatten().collect(); + for volume in [ + format!("{WORKSPACE}-pixi"), + "dind-var-lib-docker-f00d".to_owned(), + ] { + assert!( + swept.contains(&volume), + "{volume} was named in devpod's record and had to be swept: {swept:?}" + ); + } +} + +// =========================================================================== +// the machine the removal reads +// =========================================================================== + +/// The environment these tests point devlaunch at, and the lock that makes that +/// sound. +/// +/// `HOME` and the two XDG variables are process-wide, so every test here takes this +/// for its whole body: the removal resolves its cache directory from the +/// environment while it runs, and a second test rewriting those variables +/// underneath it would be reading a directory this one is deleting. Serialised +/// rather than shared, so each test still gets a cache of its own. +static THE_ENVIRONMENT: Mutex<()> = Mutex::new(()); + +/// A cache directory as `dl` leaves one, with the devpod that answers about it. +/// +/// devpod and docker are faked and everything else is really run — git above all, +/// because what the guard reads is a real repository's real answer about what is in +/// it. The two that are faked are faked for the same reason: a unit test that +/// reached the developer's own devpod or docker daemon would be deleting their +/// workspaces and their volumes. +struct Machine { + fake: FakeRunner, + processes: ProcessRunner, + argvs: Mutex>>, + dir: tempfile::TempDir, + cache: PathBuf, + /// Held for the test's lifetime. Last field, so it is dropped last. + _environment: MutexGuard<'static, ()>, +} + +impl Machine { + fn new() -> Self { + let environment = THE_ENVIRONMENT + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let dir = tempfile::tempdir().expect("a temporary directory"); + let home = dir.path().to_path_buf(); + let cache = home.join("cache").join("devlaunch"); + std::fs::create_dir_all(&cache).expect("the cache directory"); + // Safety: every reader of these variables in this binary is a test body, + // and every test body holds the lock this guard is. Nothing else in the + // process reads the environment while they are being written. + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("XDG_CACHE_HOME", home.join("cache")); + std::env::set_var("XDG_CONFIG_HOME", home.join("config")); + std::env::set_var("DEVPOD_HOME", home.join(".devpod")); + } + let fake = FakeRunner::new(); + fake.add_workspace(WORKSPACE, WorkspaceState::Running); + Self { + fake, + processes: ProcessRunner, + argvs: Mutex::new(Vec::new()), + dir, + cache, + _environment: environment, + } + } + + fn clone_dir(&self) -> PathBuf { + self.cache + .join("repos") + .join(OWNER) + .join(REPO) + .join(WORKSPACE) + } + + /// A real git repository under the cache, holding `file` and nothing that has + /// been committed or pushed, plus the `metadata.json` record naming it. + /// + /// Written as a file rather than through a builder because that is what it is: + /// the record is what a previous `dl` run left on disk, and the removal has to + /// read it from there. + fn a_recorded_clone_holding(&self, file: &str) { + let clone = self.clone_dir(); + std::fs::create_dir_all(&clone).expect("the clone directory"); + git(&clone, &["init", "-b", BRANCH]); + std::fs::write(clone.join(file), "half a plan\n").expect("their work"); + + let record = serde_json::json!({ + "version": 3, + "repositories": {}, + "worktrees": { + format!("{OWNER}/{REPO}/{BRANCH}"): { + "owner": OWNER, + "repo": REPO, + "branch": BRANCH, + "local_path": clone.display().to_string(), + "workspace_id": WORKSPACE, + "created_at": "2026-01-01T00:00:00", + "last_used": "2026-01-01T00:00:00", + "devpod_workspace_id": null, + }, + }, + }); + std::fs::write( + self.cache.join("metadata.json"), + serde_json::to_string(&record).expect("a metadata document"), + ) + .expect("the metadata file"); + } + + /// devpod's record of a finished `up`, naming what it substituted into the + /// devcontainer — which is the only place the volume names live. + fn a_devcontainer_that_created_a_volume(&self) { + let workspace = self.devpod_workspace_dir(WORKSPACE); + std::fs::create_dir_all(&workspace).expect("devpod's workspace directory"); + // Both files, because both are read: the record is what says which context + // holds this id, and the result is what says what was substituted into it. + std::fs::write( + workspace.join("workspace.json"), + serde_json::json!({ "id": WORKSPACE }).to_string(), + ) + .expect("devpod's workspace record"); + let result = serde_json::json!({ + "SubstitutionContext": { + "LocalWorkspaceFolder": format!("/host/clones/{WORKSPACE}"), + "DevContainerID": "f00d", + }, + }); + std::fs::write(workspace.join("workspace_result.json"), result.to_string()) + .expect("devpod's create result"); + } + + /// Open devlaunch's records the way a command does, and remove the workspace + /// through the one call the promise carries. + fn remove(&self, removal: Removal) -> RemoveOutcome { + let mut opening: Vec = Vec::new(); + let mut cold = ColdPath::new(self, &mut opening); + let records = cold.records().expect("devlaunch's records open"); + let Records { + storage, clones, .. + } = records; + + let mut context = CommandContext::new(self); + let updater = SelfInvocation::new("dl".to_owned()); + let completions = self.cache.join("completions.json"); + let mut refresh = Refresh::new(&updater, &completions); + // `Vec` is core's own sink for `T`, so a consumer that only wants to + // collect a removal's notices needs nothing of its own. + let mut collected: Vec = Vec::new(); + let said: &mut dyn Notices = &mut collected; + let devpod_home = DevpodHome::locate(); + + workspace_remove( + &mut context, + &mut refresh, + clones, + storage, + &self.cache, + devpod_home.as_ref(), + WORKSPACE, + removal, + &mut |DeleteStalled::OnTheLock| {}, + said, + ) + .expect("devpod ran") + } + + // ------------------------------------------------------------ what ran + + fn argvs(&self) -> Vec> { + self.argvs.lock().expect("the call log").clone() + } + + fn argvs_to(&self, program: &str) -> Vec> { + self.argvs() + .into_iter() + .filter(|argv| argv[0] == program) + .collect() + } + + fn devpod_argvs(&self) -> Vec> { + self.argvs_to("devpod") + } + + /// Every id `devpod delete` was called about, in order. + fn deleted_workspaces(&self) -> Vec { + self.devpod_argvs() + .into_iter() + .filter(|argv| argv.get(1).map(String::as_str) == Some("delete")) + .filter_map(|argv| argv.get(2).cloned()) + .collect() + } + + fn record(&self, spec: &SpawnSpec) { + self.argvs + .lock() + .expect("the call log") + .push(spec.invocation.argv()); + } + + /// What devpod does to its own records on the way out of a delete, which the + /// fake devpod does not do for itself. + /// + /// Modelled because it is the whole hazard: the substituted volume names live + /// only in this directory, so a sweep that reads them after the delete reads + /// nothing. Without this, both orders pass. + fn devpod_forgets_what_it_deleted(&self, spec: &SpawnSpec) { + let argv = spec.invocation.argv(); + if argv.len() >= 3 && argv[0] == "devpod" && argv[1] == "delete" { + let _ = std::fs::remove_dir_all(self.devpod_workspace_dir(&argv[2])); + } + } + + fn devpod_workspace_dir(&self, workspace_id: &str) -> PathBuf { + self.dir + .path() + .join(".devpod") + .join("contexts") + .join("default") + .join("workspaces") + .join(workspace_id) + } +} + +/// Which programs this machine answers for itself. +fn faked(program: &str) -> bool { + program == "devpod" || program == "docker" +} + +impl Runner for Machine { + fn capture(&self, spec: &SpawnSpec) -> Outcome { + self.record(spec); + if faked(&spec.invocation.program) { + self.devpod_forgets_what_it_deleted(spec); + self.fake.capture(spec) + } else { + self.processes.capture(spec) + } + } + + fn passthrough(&self, spec: &SpawnSpec) -> Outcome { + self.record(spec); + if faked(&spec.invocation.program) { + self.devpod_forgets_what_it_deleted(spec); + self.fake.passthrough(spec) + } else { + self.processes.passthrough(spec) + } + } + + fn session(&self, spec: &SpawnSpec, on_stderr_line: &mut dyn FnMut(&str)) -> Outcome { + self.record(spec); + if faked(&spec.invocation.program) { + self.devpod_forgets_what_it_deleted(spec); + self.fake.session(spec, on_stderr_line) + } else { + self.processes.session(spec, on_stderr_line) + } + } + + /// Recorded and never started: the refresh a removal re-arms is a whole second + /// `dl` run, and a test that really forked one would be running an unrelated + /// program against the developer's own cache. + fn detach(&self, what: &Invocation) -> DetachOutcome { + self.fake.detach(what) + } +} + +/// git, really run, in `at`. +fn git(at: &Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(args) + .current_dir(at) + .env("GIT_AUTHOR_NAME", "dl") + .env("GIT_AUTHOR_EMAIL", "dl@example.invalid") + .env("GIT_COMMITTER_NAME", "dl") + .env("GIT_COMMITTER_EMAIL", "dl@example.invalid") + .output() + .expect("git runs"); + assert!( + out.status.success(), + "git {args:?} in {}: {}", + at.display(), + String::from_utf8_lossy(&out.stderr) + ); +} diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index 4e07bee5..0acee79d 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -19,8 +19,8 @@ use devlaunch_core::flows::completion_cache::{self, Refreshed}; use devlaunch_core::flows::kill; use devlaunch_core::flows::launch::{ColdPath, LaunchNotice}; use devlaunch_core::flows::lifecycle::{ - self, ChildWork, DeleteOutcome, DeleteStalled, Guarded, Insistence, LifecycleNotice, - Persistence, PruneError, PruneOutcome, Refresh, RefreshReason, StopOutcome, + self, ChildWork, DeleteStalled, Insistence, LifecycleNotice, PruneError, PruneOutcome, Refresh, + RefreshReason, Removal, RemoveOutcome, StopOutcome, }; use devlaunch_core::flows::listing::{self, CommandContext, DlView, Sizes}; use devlaunch_core::flows::records::{Records, StartupError, open_records}; @@ -580,105 +580,22 @@ fn render_workspace<'r>( } } -/// Which delete this is, of the three dl performs. +/// Whether a sweep already ran in front of this removal. /// -/// One value rather than the three flags it stands for — the unsaved-work guard, -/// devpod's `--ignore-not-found`, devpod's `--force` and a deadline — because -/// those are not independent settings anybody would want to mix. They are one -/// decision about how badly the caller wants the workspace gone, and spelling them -/// separately makes seven combinations writable of which three are meant. The -/// three that are meant are these. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Removal { - /// `dl rm`, the happy path. Stops at work that exists nowhere else, names - /// it, and offers `--force`. devpod is asked with its own defaults and given - /// as long as it needs, because a container that is slow to come down is a - /// container that is coming down. - Guarded, - /// `dl rm --force`. The guard does not even look, and an absent workspace - /// counts as deleted, which is what makes it `rm -f` rather than a louder `rm`. - /// devpod is still asked politely: this is a workspace you are sure about, not - /// one that is stuck. - Insisted, - /// `dl kill`. The verb for a workspace that is wedged and finished with, - /// so nothing here refuses and nothing here waits indefinitely: the guard looks - /// and *reports* rather than stopping, devpod gets `--force` so a workspace it - /// can no longer reach still goes, and the call carries a deadline so it cannot - /// join the five second lock loop the sweep in front of it was reached for. - /// - /// The guard still looks, and that is the difference between this and - /// [`Removal::Insisted`] rather than a leftover: work that exists nowhere else - /// is about to be destroyed, and the person who typed `kill` is owed the list - /// even though they are not being asked to confirm it. - Wedged, -} - -impl Removal { - /// Whether dl will accept an absent workspace as a delete, and what devpod's - /// `--ignore-not-found` rides on. - fn insistence(self) -> Insistence { - match self { - Self::Guarded => Insistence::NotInsisted, - Self::Insisted | Self::Wedged => Insistence::Insisted, - } - } - - /// How hard devpod is pushed, and whether the call carries a deadline. - fn persistence(self) -> Persistence { - match self { - Self::Guarded | Self::Insisted => Persistence::Ordinary, - Self::Wedged => Persistence::Wedged, - } - } - - /// Whether the unsaved-work probe is worth running, and what its answer does. - /// - /// `Insisted` is the one that skips it, and it skips it to save the work rather - /// than to hide the answer: the probe is a `git status` and a `git log` per - /// clone, and `rm --force` has said in advance that it will not act on either. - fn probe(self) -> Probe { - match self { - Self::Guarded => Probe::Look(Finding::Refuses), - Self::Insisted => Probe::Skip, - Self::Wedged => Probe::Look(Finding::Says), - } - } - - /// Whether a sweep already ran in front of this delete. - /// - /// Read off the removal rather than passed beside it, because it is a total - /// function of one: `Wedged` *is* the removal that stands behind a sweep. As a - /// second argument it was a pair that could be written wrong in four ways, and - /// the wrong one produces a `kill` whose own refusal tells you to run `kill`. - fn swept(self) -> Swept { - match self { - Self::Guarded | Self::Insisted => Swept::NotYet, - Self::Wedged => Swept::Already, - } - } -} - -/// Whether the delete looks for work that exists nowhere else. +/// Read off the removal rather than passed beside it, because it is a total +/// function of one: [`Removal::Wedged`] *is* the removal that stands behind a +/// sweep. As a second argument it was a pair that could be written wrong in four +/// ways, and the wrong one produces a `kill` whose own refusal tells you to run +/// `kill`. /// -/// Nested rather than three flat arms so that [`Finding`] is unreachable from the -/// arm that never looks: a delete that skips the probe has no finding to act on, -/// and a flat third arm left every `match` on the answer with a case its author -/// had to invent a body for. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Probe { - /// Look, and then do this with what is found. - Look(Finding), - /// Do not look. `rm --force`'s. - Skip, -} - -/// What a delete does with work it found that exists nowhere else. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Finding { - /// Stop, name it, and offer the way past. `rm`'s. - Refuses, - /// Name it, and delete it. `kill`'s. - Says, +/// A free function rather than a method, because [`Removal`] is core's now: which +/// of the three removals this is belongs to the removal, and whether a sweep has +/// already printed above belongs to the screen. +fn swept(removal: Removal) -> Swept { + match removal { + Removal::Guarded | Removal::Insisted => Swept::NotYet, + Removal::Wedged => Swept::Already, + } } /// The removal `--force` asks for, for the one verb the flag still reaches. @@ -955,83 +872,41 @@ fn remove_addressed<'r>( removal: Removal, word: &str, ) -> Ending { - let insistence = removal.insistence(); - // The delete needs the records whatever the resolution needed, so a resolution + // The removal needs the records whatever the resolution needed, so a resolution // that did not open them opens them here — through the same `ColdPath`, which is // what keeps one command from holding two views of `metadata.json`. let records = match cold.records() { Err(refused) => return refuse_startup(&refused), Ok(records) => records, }; - let mut notices: Vec = Vec::new(); - - // The one thing dl refuses on its own account, and the one thing `kill` reports - // rather than refusing over. Skipped entirely only for `rm --force`, which has - // said in advance that it will not act on the answer and so should not pay for - // it: the probe is a `git status` and a `git log` per clone. - if let Probe::Look(finding) = removal.probe() { - let unsaved = lifecycle::unsaved_work_in( - &records.clones, - &records.storage, - &context.git(), - cache, - workspace_id, - &mut notices, - ); - say(¬ices); - notices.clear(); - // Asked with `Insistence::NotInsisted` whatever this removal insists, - // because what is wanted from it here is the *finding* rather than the - // verdict: `kill` acts on the finding differently, and passing its own - // insistence would collapse the finding to `MayRemove` before it could. - if let Guarded::Refused(refusal) = - lifecycle::guard_removal(workspace_id, unsaved, Insistence::NotInsisted) - { - match finding { - Finding::Refuses => { - eprintln!("{}", render::removal_refusal(&refusal, target, word)); - return Ending::Refused; - } - // Said and stepped past. A workspace reached with `kill` is one - // somebody has already given up on, and stopping here is the - // failure the verb was rebuilt to stop having: a wedged workspace's - // clone is dirty almost by construction, since what wedged it - // interrupted whatever was being done in it. - Finding::Says => eprintln!("{}", render::removing_over_work(&refusal)), - } - } - } - - // Which workspace this is, named before devpod is asked and after the guard has - // had its say. Everything below names the resolved id, and a target that was a - // branch, a path, or a row in the picker is not that word — see - // [`render::removing`]. - eprintln!("{}", render::removing(workspace_id)); - let Records { storage, clones, .. } = records; - let deleted = lifecycle::workspace_delete( + // Said as they happen rather than collected, because the removal's own lines are + // interleaved with the guard's and the clone's and the order *is* the report: + // what was found, which workspace is going, and what became of its clone. A + // vector here would print all of it after the delete had already returned. + let mut notices = render::Saying; + let removed = lifecycle::workspace_remove( context, refresh, clones, storage, + cache, // Resolved here rather than in core, for the reason every other environment // answer is: the process that knows what its environment says hands the // answer down. `None` is a machine with no home directory, where devpod has // no records to read and so no volume names to derive. DevpodHome::locate().as_ref(), workspace_id, - insistence, - removal.persistence(), - // Printed from inside the call rather than collected with the notices - // below it, because the whole value of the sentence is its timing: the - // delete it is about has not returned and, until somebody acts on this, - // is not going to. + removal, + // Printed from inside the call rather than said through the sink beside it, + // because the whole value of the sentence is its timing: the delete it is + // about has not returned and, until somebody acts on this, is not going to. &mut |DeleteStalled::OnTheLock| eprintln!("{}", render::delete_blocked(workspace_id, word)), &mut notices, ); - match deleted { + match removed { Err(not_run) => { let ending = refuse_devpod("delete", ¬_run); // The one refusal whose own sentence describes nothing: `devpod delete @@ -1044,21 +919,26 @@ fn remove_addressed<'r>( } ending } - Ok(DeleteOutcome::DevpodRefused { exit }) => { + // The one thing dl refuses on its own account. Only `rm` reaches this arm: + // `rm --force` never looks and `kill` says it and goes on, both of which are + // decided inside the removal now rather than sequenced out here. + Ok(RemoveOutcome::Refused(refusal)) => { + eprintln!("{}", render::removal_refusal(&refusal, target, word)); + Ending::Refused + } + Ok(RemoveOutcome::DevpodRefused { exit }) => { // The local clone is kept, so the delete stays retryable: devpod // re-parses the workspace's devcontainer.json to tear the container // down, and removing the clone regardless strands it for good. - eprintln!("{}", render::delete_refused(workspace_id, removal.swept())); - say(¬ices); + eprintln!("{}", render::delete_refused(workspace_id, swept(removal))); Ending::Child(exit) } - Ok(DeleteOutcome::Deleted { .. }) => { - say(¬ices); - // After the clone's own lines, because it closes the delete: what a + Ok(RemoveOutcome::Deleted { .. }) => { + // After the clone's own lines, because it closes the removal: what a // reader wants from the end of one workspace's block is which workspace - // it was. `insistence` is passed because it decides what this exit code + // it was. The insistence is passed because it decides what this exit code // established — see [`render::removed`]. - eprintln!("{}", render::removed(workspace_id, insistence)); + eprintln!("{}", render::removed(workspace_id, removal.insistence())); Ending::Done } } diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 11d83be1..f915ee4a 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -952,6 +952,8 @@ fn lifecycle_notice(notice: &LifecycleNotice) -> Option { "Addressing devpod workspace '{recorded}' from the record for {owner}/{repo}@{branch}; \ this build derives '{derived}'" ), + LifecycleNotice::Removing { workspace_id } => removing(workspace_id), + LifecycleNotice::RemovingOverWork { refusal } => removing_over_work(refusal), LifecycleNotice::Cache(cache) => return cache_notice(cache), }) } @@ -2275,6 +2277,21 @@ impl Notices for Saying { } } +/// A removal's notices through the same sink, at the moment each one happens. +/// +/// Streamed rather than collected because a removal's lines are a sequence and not +/// a summary: what the guard found, which workspace is going, and what became of +/// its clone, each said while the step it describes is the one under way. Collected +/// into a vector they all arrived after `devpod delete` had returned, which put +/// "Removing workspace X..." after the wait it exists to explain. +impl Notices for Saying { + fn say(&mut self, notice: LifecycleNotice) { + if let Some(line) = lifecycle_notice(¬ice) { + eprintln!("{line}"); + } + } +} + impl Notices for Saying { fn say(&mut self, event: ProvisionEvent) { if let Some(line) = provision_event(&event) { From 2b51e6924e2b321416e692720a2709d5b7e01ac9 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 21:41:00 +0100 Subject: [PATCH 2/2] Two snapshot rows the merge owed, taken from CI's regeneration No nightly toolchain on this host, so the generator could not be run here: CI's public-api job regenerates and diffs, and these are the two rows it named. `api::LifecycleNotice::VolumesNotRemoved::occasion` is a merge casualty rather than a stale file. #516 added the field while this branch was moving the whole notice vocabulary from the rest file into the promise, so the row was added to a block that no longer existed and lost on the way across. The second is the re-export's own cost: promising `KeptCopies` makes the generator render its inherent and derived impls a second time, at the canonical path, beside the other promised types' impls. --- rust/devlaunch-core/public-api.api.txt | 1 + rust/devlaunch-core/public-api.rest.txt | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt index 68afb14f..30d488b9 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -78,6 +78,7 @@ pub devlaunch_core::api::LifecycleNotice::Removing::workspace_id: alloc::string: pub devlaunch_core::api::LifecycleNotice::RemovingOverWork pub devlaunch_core::api::LifecycleNotice::RemovingOverWork::refusal: devlaunch_core::flows::lifecycle::RemovalRefused pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved +pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved::occasion: devlaunch_core::flows::lifecycle::SweepOccasion pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved::refusal: devlaunch_core::flows::lifecycle::VolumeRefusal pub devlaunch_core::api::LifecycleNotice::VolumesNotRemoved::workspace_id: alloc::string::String pub enum devlaunch_core::api::ProvisionEvent diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index d7ced270..9dd24c5d 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -166,6 +166,16 @@ pub fn devlaunch_core::flows::launch::Host::default() -> devlaunch_core::flows:: impl core::fmt::Debug for devlaunch_core::flows::launch::Host pub fn devlaunch_core::flows::launch::Host::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::Host +impl devlaunch_core::flows::kept_copies::KeptCopies +pub fn devlaunch_core::flows::kept_copies::KeptCopies::under(&std::path::Path) -> Self +impl core::clone::Clone for devlaunch_core::flows::kept_copies::KeptCopies +pub fn devlaunch_core::flows::kept_copies::KeptCopies::clone(&self) -> devlaunch_core::flows::kept_copies::KeptCopies +impl core::cmp::Eq for devlaunch_core::flows::kept_copies::KeptCopies +impl core::cmp::PartialEq for devlaunch_core::flows::kept_copies::KeptCopies +pub fn devlaunch_core::flows::kept_copies::KeptCopies::eq(&self, &devlaunch_core::flows::kept_copies::KeptCopies) -> bool +impl core::fmt::Debug for devlaunch_core::flows::kept_copies::KeptCopies +pub fn devlaunch_core::flows::kept_copies::KeptCopies::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::kept_copies::KeptCopies impl<'a, 'r, 'l> devlaunch_core::flows::launch::Launch<'a, 'r, 'l> pub fn devlaunch_core::flows::launch::Launch<'a, 'r, 'l>::new(&'a mut devlaunch_core::flows::listing::CommandContext<'r>, &'a mut devlaunch_core::flows::lifecycle::Refresh<'l>, &'a mut dyn devlaunch_core::flows::launch::ColdMachinery<'r>, &'a dyn devlaunch_core::flows::launch::Provision, &'a devlaunch_core::flows::launch::Host, &'a mut dyn core::ops::function::FnMut(&str), &'a mut dyn devlaunch_core::notices::Notices) -> Self pub fn devlaunch_core::flows::launch::Launch<'a, 'r, 'l>::recognised_as(self, core::option::Option) -> Self