From 99252911df19f94fb5e389148a58c75452c61570 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 18:03:43 +0100 Subject: [PATCH 1/3] Move ColdPath and ToolProvisioning into core; complete api's re-exports `devlaunch_core::api` named a launcher nobody outside `dl` could build. The two implementations that decide whether a launch can go cold at all lived in the binary: `ColdPath`, which opens devlaunch's records, and `ToolProvisioning`, which lends the host's tools in. Both are core types plumbed together; what kept them in `dl` was where their events get printed. So both move, with the event sinks injectable as typed values, and `dl` keeps the printer and the words. The records move with them, into a new `flows::records`. `api` now re-exports every one of `Launch::new`'s parameter types; five of the seven used to live outside it. `ColdRefused` had to be typed for the move to be possible at all: it carried `reason: String`, which was the one place dl's prose travelled back through core, and core cannot write the words. It is now a sum over the startup reasons plus a no-cold-path arm, the shape #313 decided and #339 specifies. `ConfigError` became clonable and comparable to travel inside it, its OS side spelled as `OsFailure` the way `MetadataError`'s already was. Red first: a test constructing a cold-capable `Launch` from `api` paths alone, which failed to compile on eleven unresolved imports. Closes #340. --- CHANGELOG.md | 25 ++ docs/development.md | 6 +- rust/devlaunch-core/public-api.api.txt | 89 +++++++ rust/devlaunch-core/public-api.rest.txt | 179 +++++++++++++- rust/devlaunch-core/src/domain/config.rs | 14 +- rust/devlaunch-core/src/flows/launch.rs | 178 +++++++++++++- rust/devlaunch-core/src/flows/mod.rs | 2 + rust/devlaunch-core/src/flows/records.rs | 224 ++++++++++++++++++ rust/devlaunch-core/src/lib.rs | 27 ++- .../tests/api_launch_is_self_sufficient.rs | 75 ++++++ rust/dl/src/cold.rs | 68 ------ rust/dl/src/commands.rs | 57 ++--- rust/dl/src/launch.rs | 123 ++-------- rust/dl/src/lib.rs | 10 +- rust/dl/src/render.rs | 55 ++++- rust/dl/src/session.rs | 134 +---------- rust/dl/src/target.rs | 19 +- scripts/public-api-snapshots.sh | 4 +- 18 files changed, 913 insertions(+), 376 deletions(-) create mode 100644 rust/devlaunch-core/src/flows/records.rs create mode 100644 rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs delete mode 100644 rust/dl/src/cold.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b5114754..0ba0f899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`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 + migration, the clone manager) and the one that lends the host's tools into a + container. Both are core types plumbed together, and both are now in core, as + `flows::launch::ColdPath` and `flows::launch::ToolProvisioning`. What kept them in + the binary was where their events were *printed*, so each now takes an event sink + as a constructor argument and `dl` supplies the printer and the words. The records + themselves moved with them, to a new `flows::records`. + + `api` re-exports every one of `Launch::new`'s parameter types as a result. Five of + the seven used to live outside it, so a second consumer could name the launcher and + had nothing to hand it. No behaviour changes: the same notices are said, in the same + order, in the same words. + +- **`ColdRefused` is a sum over the reasons rather than a rendered sentence.** It + carried `reason: String`, which was the one place `dl`'s own prose travelled back + *through* core, and the move above made that untenable: core would have had to write + the words. It is now `Startup(StartupError)` or `NoColdPath`, and `dl` renders each + arm. `domain::config::ConfigError` became clonable and comparable for the same + reason, its OS side spelled as `OsFailure` the way `MetadataError`'s already was. + The sentences a user sees are unchanged. + ### Fixed - **A session that dies badly no longer takes your terminal with it.** A terminal diff --git a/docs/development.md b/docs/development.md index 12dc1349..c66f9823 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,7 +49,7 @@ because they are not one promise: | File | What a diff means | | --- | --- | -| `devlaunch-core/public-api.api.txt` | **A change to the promised contract.** A removal or a changed signature breaks a consumer, an addition is a deliberate widening. Holds the 37 declarations written *at* the `devlaunch_core::api` path, and only those. | +| `devlaunch-core/public-api.api.txt` | **A change to the promised contract.** A removal or a changed signature breaks a consumer, an addition is a deliberate widening. Holds the 126 declarations written *at* the `devlaunch_core::api` path, and only those. | | `devlaunch-core/public-api.rest.txt` | Mostly routine. The binary API (`flows::`, `domain::`, `clients::`) is reachable but never promised, so read it for the accidental `pub`. **But** the promised types' methods and impls are in here too (see below), and a diff touching one of those is a contract change. | | `devlaunch-runner/public-api.txt` | The process seam an external `Runner` implementer writes against. | @@ -57,8 +57,8 @@ because they are not one promise: and trait impls only at a type's *canonical* path, never at the path it is re-exported under, so the classifier cannot see them. `api::Launch`'s only constructor and only method are rendered `flows::launch::Launch::{new, run}` and land in the rest file, along with `CommandContext::new`, -`DevcontainerPath::as_str` and every derived `Clone`/`Debug`/`PartialEq` on the promised types: 42 -of the 79 rows the generator emits for the `api` section. Measured consequence: renaming +`DevcontainerPath::as_str` and every derived `Clone`/`Debug`/`PartialEq` on the promised types: 133 +of the 259 rows the generator emits for the `api` section. Measured consequence: renaming `api::Launch::run` leaves `public-api.api.txt` byte-identical. The guard is therefore one-way. A diff in the promise file is a change to the promise, but not every change to the promise diffs it. Widening the classifier is [#352](https://github.com/blooop/devlaunch/issues/352). diff --git a/rust/devlaunch-core/public-api.api.txt b/rust/devlaunch-core/public-api.api.txt index 3986e334..301b3594 100644 --- a/rust/devlaunch-core/public-api.api.txt +++ b/rust/devlaunch-core/public-api.api.txt @@ -1,4 +1,48 @@ 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::LaunchNotice +pub devlaunch_core::api::LaunchNotice::AlreadyRunning +pub devlaunch_core::api::LaunchNotice::AlreadyRunning::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::AlreadyRunningAttaching +pub devlaunch_core::api::LaunchNotice::AlreadyRunningAttaching::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::BroughtUpBySibling +pub devlaunch_core::api::LaunchNotice::BroughtUpBySibling::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::Cache(devlaunch_core::flows::repo_manager::CacheNotice) +pub devlaunch_core::api::LaunchNotice::CreateNeverFinished +pub devlaunch_core::api::LaunchNotice::CreateNeverFinished::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::DevcontainerIgnoredRunning +pub devlaunch_core::api::LaunchNotice::DevcontainerIgnoredRunning::spec: alloc::string::String +pub devlaunch_core::api::LaunchNotice::DevcontainerIgnoredRunning::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::DevpodSessionFailed +pub devlaunch_core::api::LaunchNotice::DevpodSessionFailed::exit: devlaunch_runner::Exit +pub devlaunch_core::api::LaunchNotice::LaunchLockUnavailable +pub devlaunch_core::api::LaunchNotice::LaunchLockUnavailable::reason: alloc::string::String +pub devlaunch_core::api::LaunchNotice::LaunchLockUnavailable::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::Lifecycle(devlaunch_core::flows::lifecycle::LifecycleNotice) +pub devlaunch_core::api::LaunchNotice::NoDevpodSshConfig +pub devlaunch_core::api::LaunchNotice::NoDevpodSshConfig::looked_in: std::path::PathBuf +pub devlaunch_core::api::LaunchNotice::NoDevpodSshConfig::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::NoGitHubToken(devlaunch_core::clients::gh::GhEvent) +pub devlaunch_core::api::LaunchNotice::NoTerminalAlias +pub devlaunch_core::api::LaunchNotice::NoTerminalAlias::config: std::path::PathBuf +pub devlaunch_core::api::LaunchNotice::NoTerminalAlias::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::PixiCacheNotADirectory +pub devlaunch_core::api::LaunchNotice::PixiCacheNotADirectory::source: std::path::PathBuf +pub devlaunch_core::api::LaunchNotice::PixiCacheNotCreated +pub devlaunch_core::api::LaunchNotice::PixiCacheNotCreated::reason: alloc::string::String +pub devlaunch_core::api::LaunchNotice::PixiCacheNotCreated::source: std::path::PathBuf +pub devlaunch_core::api::LaunchNotice::SshCommand +pub devlaunch_core::api::LaunchNotice::SshCommand::argv: alloc::vec::Vec +pub devlaunch_core::api::LaunchNotice::SshConfigUnlocatable +pub devlaunch_core::api::LaunchNotice::StartingForDotfiles +pub devlaunch_core::api::LaunchNotice::StartingForDotfiles::workspace_id: alloc::string::String +pub devlaunch_core::api::LaunchNotice::TerminalTitle(devlaunch_core::flows::launch::TerminalTitle) +pub devlaunch_core::api::LaunchNotice::TokenNotStaged +pub devlaunch_core::api::LaunchNotice::TokenNotStaged::reason: alloc::string::String +pub devlaunch_core::api::LaunchNotice::WaitingForSiblingLaunch +pub devlaunch_core::api::LaunchNotice::WaitingForSiblingLaunch::workspace_id: alloc::string::String pub enum devlaunch_core::api::LaunchVerb pub devlaunch_core::api::LaunchVerb::Attach pub devlaunch_core::api::LaunchVerb::Attach::command: core::option::Option @@ -8,11 +52,41 @@ 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::ProvisionEvent +pub devlaunch_core::api::ProvisionEvent::NotInstalled +pub devlaunch_core::api::ProvisionEvent::NotInstalled::exit: devlaunch_runner::Exit +pub devlaunch_core::api::ProvisionEvent::NotInstalled::tools: alloc::vec::Vec<&'static str> +pub devlaunch_core::api::ProvisionEvent::NotInstalled::workspace: alloc::string::String +pub devlaunch_core::api::ProvisionEvent::PayloadNotBundled +pub devlaunch_core::api::ProvisionEvent::PayloadNotBundled::failure: devlaunch_core::flows::provision::BundleFailed +pub devlaunch_core::api::ProvisionEvent::ProvisioningDisabled +pub devlaunch_core::api::ProvisionEvent::ProvisioningDisabled::workspace: alloc::string::String +pub devlaunch_core::api::ProvisionEvent::StageFailed +pub devlaunch_core::api::ProvisionEvent::StageFailed::loudness: devlaunch_core::flows::provision::FailureLevel +pub devlaunch_core::api::ProvisionEvent::StageFailed::stage: &'static str +pub devlaunch_core::api::ProvisionEvent::StageFailed::status: i32 +pub devlaunch_core::api::ProvisionEvent::StageFailed::workspace: alloc::string::String +pub devlaunch_core::api::ProvisionEvent::StageNotReported +pub devlaunch_core::api::ProvisionEvent::StageNotReported::loudness: devlaunch_core::flows::provision::FailureLevel +pub devlaunch_core::api::ProvisionEvent::StageNotReported::stage: &'static str +pub devlaunch_core::api::ProvisionEvent::StageNotReported::workspace: alloc::string::String +pub devlaunch_core::api::ProvisionEvent::TripRefused +pub devlaunch_core::api::ProvisionEvent::TripRefused::refusal: devlaunch_core::clients::devpod::NotRun +pub devlaunch_core::api::ProvisionEvent::TripRefused::workspace: alloc::string::String +pub enum devlaunch_core::api::RecordsNotice +pub devlaunch_core::api::RecordsNotice::Metadata(devlaunch_core::domain::metadata::Notice) +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::SpecIdentity<'a> pub devlaunch_core::api::SpecIdentity::ExistingName(&'a str) pub devlaunch_core::api::SpecIdentity::PathLeaf(&'a str) pub devlaunch_core::api::SpecIdentity::RepoLabel(alloc::string::String) pub devlaunch_core::api::SpecIdentity::Workspace(alloc::string::String) +pub enum devlaunch_core::api::StartupError +pub devlaunch_core::api::StartupError::Config(devlaunch_core::domain::config::ConfigError) +pub devlaunch_core::api::StartupError::Metadata(devlaunch_core::domain::metadata::MetadataError) +pub devlaunch_core::api::StartupError::NoHomeDirectory pub enum devlaunch_core::api::WorkspaceSpec<'a> pub devlaunch_core::api::WorkspaceSpec::ExistingIdOrName(&'a str) pub devlaunch_core::api::WorkspaceSpec::HostPath(&'a str) @@ -23,11 +97,26 @@ pub devlaunch_core::api::WorkspaceSpec::OwnerRepo::repo: &'a str pub devlaunch_core::api::WorkspaceSpec::Path(&'a str) pub devlaunch_core::api::WorkspaceSpec::SshUrl(&'a str) pub devlaunch_core::api::WorkspaceSpec::Url(&'a str) +pub struct devlaunch_core::api::Cold<'a, 'r> +pub devlaunch_core::api::Cold::clones: &'a devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r> +pub devlaunch_core::api::Cold::storage: &'a mut devlaunch_core::domain::metadata::MetadataStorage +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::Host pub struct devlaunch_core::api::Launch<'a, 'r, 'l> +pub struct devlaunch_core::api::Refresh<'a> +pub struct devlaunch_core::api::SelfInvocation +pub struct devlaunch_core::api::ToolProvisioning<'e> pub const devlaunch_core::api::HANDOFF_VAR: &str pub const devlaunch_core::api::PREWARM_VAR: &str +pub trait devlaunch_core::api::ColdMachinery<'r> +pub fn devlaunch_core::api::ColdMachinery::open(&mut self) -> core::result::Result, devlaunch_core::flows::launch::ColdRefused> +pub trait devlaunch_core::api::Notices +pub fn devlaunch_core::api::Notices::say(&mut self, T) +pub trait devlaunch_core::api::Provision +pub fn devlaunch_core::api::Provision::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::api::Provision::remembered_claude(&self, &str) -> core::option::Option pub fn devlaunch_core::api::enriched_listing(&mut devlaunch_core::flows::listing::CommandContext<'_>, &devlaunch_core::flows::listing::DlView<'_>, devlaunch_core::flows::listing::Sizes) -> core::result::Result, devlaunch_core::clients::devpod::ListingUnreadable> pub fn devlaunch_core::api::identity(&str) -> core::result::Result, devlaunch_core::domain::workspace_id::UnsafeName> pub fn devlaunch_core::api::json_document(&[devlaunch_core::flows::listing::ListedWorkspace]) -> serde_json::value::Value diff --git a/rust/devlaunch-core/public-api.rest.txt b/rust/devlaunch-core/public-api.rest.txt index 5a480c85..e8ab10d7 100644 --- a/rust/devlaunch-core/public-api.rest.txt +++ b/rust/devlaunch-core/public-api.rest.txt @@ -1,4 +1,20 @@ pub mod devlaunch_core +impl core::clone::Clone for devlaunch_core::flows::launch::ColdRefused +pub fn devlaunch_core::flows::launch::ColdRefused::clone(&self) -> devlaunch_core::flows::launch::ColdRefused +impl core::cmp::Eq for devlaunch_core::flows::launch::ColdRefused +impl core::cmp::PartialEq for devlaunch_core::flows::launch::ColdRefused +pub fn devlaunch_core::flows::launch::ColdRefused::eq(&self, &devlaunch_core::flows::launch::ColdRefused) -> bool +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::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 +impl core::cmp::PartialEq for devlaunch_core::flows::launch::LaunchNotice +pub fn devlaunch_core::flows::launch::LaunchNotice::eq(&self, &devlaunch_core::flows::launch::LaunchNotice) -> bool +impl core::fmt::Debug for devlaunch_core::flows::launch::LaunchNotice +pub fn devlaunch_core::flows::launch::LaunchNotice::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::LaunchNotice impl core::clone::Clone for devlaunch_core::flows::launch::LaunchVerb pub fn devlaunch_core::flows::launch::LaunchVerb::clone(&self) -> devlaunch_core::flows::launch::LaunchVerb impl core::cmp::Eq for devlaunch_core::flows::launch::LaunchVerb @@ -7,6 +23,22 @@ 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::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 +impl core::cmp::PartialEq for devlaunch_core::flows::provision::ProvisionEvent +pub fn devlaunch_core::flows::provision::ProvisionEvent::eq(&self, &devlaunch_core::flows::provision::ProvisionEvent) -> bool +impl core::fmt::Debug for devlaunch_core::flows::provision::ProvisionEvent +pub fn devlaunch_core::flows::provision::ProvisionEvent::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::provision::ProvisionEvent +impl core::clone::Clone for devlaunch_core::flows::records::RecordsNotice +pub fn devlaunch_core::flows::records::RecordsNotice::clone(&self) -> devlaunch_core::flows::records::RecordsNotice +impl core::cmp::Eq for devlaunch_core::flows::records::RecordsNotice +impl core::cmp::PartialEq for devlaunch_core::flows::records::RecordsNotice +pub fn devlaunch_core::flows::records::RecordsNotice::eq(&self, &devlaunch_core::flows::records::RecordsNotice) -> bool +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<'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> @@ -15,6 +47,20 @@ pub fn devlaunch_core::domain::spec::SpecIdentity<'a>::eq(&self, &devlaunch_core impl<'a> core::fmt::Debug for devlaunch_core::domain::spec::SpecIdentity<'a> pub fn devlaunch_core::domain::spec::SpecIdentity<'a>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl<'a> core::marker::StructuralPartialEq for devlaunch_core::domain::spec::SpecIdentity<'a> +impl core::clone::Clone for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::clone(&self) -> devlaunch_core::flows::records::StartupError +impl core::cmp::Eq for devlaunch_core::flows::records::StartupError +impl core::cmp::PartialEq for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::eq(&self, &devlaunch_core::flows::records::StartupError) -> bool +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::config::ConfigError) -> Self +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::metadata::MetadataError) -> Self +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::xdg::NoHomeDirectory) -> Self +impl core::fmt::Debug for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::records::StartupError impl<'a> core::clone::Clone for devlaunch_core::domain::spec::WorkspaceSpec<'a> pub fn devlaunch_core::domain::spec::WorkspaceSpec<'a>::clone(&self) -> devlaunch_core::domain::spec::WorkspaceSpec<'a> impl<'a> core::cmp::Eq for devlaunch_core::domain::spec::WorkspaceSpec<'a> @@ -24,6 +70,11 @@ impl<'a> core::fmt::Debug for devlaunch_core::domain::spec::WorkspaceSpec<'a> pub fn devlaunch_core::domain::spec::WorkspaceSpec<'a>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl<'a> core::marker::Copy for devlaunch_core::domain::spec::WorkspaceSpec<'a> impl<'a> core::marker::StructuralPartialEq for devlaunch_core::domain::spec::WorkspaceSpec<'a> +impl<'r, 'e> devlaunch_core::flows::launch::ColdPath<'r, 'e> +pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::new(&'r dyn devlaunch_runner::Runner, &'e mut dyn devlaunch_core::notices::Notices) -> Self +pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::records(&mut self) -> core::result::Result<&mut devlaunch_core::flows::records::Records<'r>, devlaunch_core::flows::records::StartupError> +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<'r> devlaunch_core::flows::listing::CommandContext<'r> pub fn devlaunch_core::flows::listing::CommandContext<'r>::git(&self) -> devlaunch_core::clients::git::Git<'r> pub fn devlaunch_core::flows::listing::CommandContext<'r>::new(&'r dyn devlaunch_runner::Runner) -> Self @@ -38,10 +89,49 @@ 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::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 +pub fn devlaunch_core::flows::launch::Host::clone(&self) -> devlaunch_core::flows::launch::Host +impl core::cmp::Eq for devlaunch_core::flows::launch::Host +impl core::cmp::PartialEq for devlaunch_core::flows::launch::Host +pub fn devlaunch_core::flows::launch::Host::eq(&self, &devlaunch_core::flows::launch::Host) -> bool +impl core::default::Default for devlaunch_core::flows::launch::Host +pub fn devlaunch_core::flows::launch::Host::default() -> devlaunch_core::flows::launch::Host +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<'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<'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 +pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::rearm(&mut self) +pub fn devlaunch_core::flows::lifecycle::Refresh<'a>::spawned(&self) -> bool +impl devlaunch_core::flows::lifecycle::SelfInvocation +pub fn devlaunch_core::flows::lifecycle::SelfInvocation::new(impl core::convert::Into) -> Self +impl core::clone::Clone for devlaunch_core::flows::lifecycle::SelfInvocation +pub fn devlaunch_core::flows::lifecycle::SelfInvocation::clone(&self) -> devlaunch_core::flows::lifecycle::SelfInvocation +impl core::cmp::Eq for devlaunch_core::flows::lifecycle::SelfInvocation +impl core::cmp::PartialEq for devlaunch_core::flows::lifecycle::SelfInvocation +pub fn devlaunch_core::flows::lifecycle::SelfInvocation::eq(&self, &devlaunch_core::flows::lifecycle::SelfInvocation) -> bool +impl core::fmt::Debug for devlaunch_core::flows::lifecycle::SelfInvocation +pub fn devlaunch_core::flows::lifecycle::SelfInvocation::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::lifecycle::SelfInvocation +impl<'e> devlaunch_core::flows::launch::ToolProvisioning<'e> +pub fn devlaunch_core::flows::launch::ToolProvisioning<'e>::from_env(&std::path::Path, &'e mut dyn devlaunch_core::notices::Notices) -> Self +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::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 +pub fn alloc::vec::Vec::say(&mut self, T) +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 pub mod devlaunch_core::clients pub mod devlaunch_core::clients::devpod pub enum devlaunch_core::clients::devpod::ContainerState @@ -294,14 +384,22 @@ pub devlaunch_core::domain::config::ConfigError::NotToml::path: std::path::PathB pub devlaunch_core::domain::config::ConfigError::NotToml::reason: alloc::string::String pub devlaunch_core::domain::config::ConfigError::Unreadable pub devlaunch_core::domain::config::ConfigError::Unreadable::path: std::path::PathBuf -pub devlaunch_core::domain::config::ConfigError::Unreadable::source: core::io::error::Error +pub devlaunch_core::domain::config::ConfigError::Unreadable::source: devlaunch_core::domain::metadata::OsFailure pub devlaunch_core::domain::config::ConfigError::WrongType pub devlaunch_core::domain::config::ConfigError::WrongType::path: std::path::PathBuf pub devlaunch_core::domain::config::ConfigError::WrongType::reason: alloc::string::String +impl core::clone::Clone for devlaunch_core::domain::config::ConfigError +pub fn devlaunch_core::domain::config::ConfigError::clone(&self) -> devlaunch_core::domain::config::ConfigError +impl core::cmp::Eq for devlaunch_core::domain::config::ConfigError +impl core::cmp::PartialEq for devlaunch_core::domain::config::ConfigError +pub fn devlaunch_core::domain::config::ConfigError::eq(&self, &devlaunch_core::domain::config::ConfigError) -> bool +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::config::ConfigError) -> Self impl core::convert::From for devlaunch_core::domain::config::ConfigError pub fn devlaunch_core::domain::config::ConfigError::from(devlaunch_core::domain::xdg::NoHomeDirectory) -> Self impl core::fmt::Debug for devlaunch_core::domain::config::ConfigError pub fn devlaunch_core::domain::config::ConfigError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::domain::config::ConfigError pub enum devlaunch_core::domain::config::RetiredKey pub devlaunch_core::domain::config::RetiredKey::ReposDir pub devlaunch_core::domain::config::RetiredKey::ReposDir::named: alloc::string::String @@ -415,6 +513,8 @@ pub fn devlaunch_core::domain::metadata::MetadataError::clone(&self) -> devlaunc impl core::cmp::Eq for devlaunch_core::domain::metadata::MetadataError impl core::cmp::PartialEq for devlaunch_core::domain::metadata::MetadataError pub fn devlaunch_core::domain::metadata::MetadataError::eq(&self, &devlaunch_core::domain::metadata::MetadataError) -> bool +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::metadata::MetadataError) -> Self impl core::fmt::Debug for devlaunch_core::domain::metadata::MetadataError pub fn devlaunch_core::domain::metadata::MetadataError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::domain::metadata::MetadataError @@ -694,6 +794,8 @@ impl core::convert::From for devla pub fn devlaunch_core::domain::config::ConfigError::from(devlaunch_core::domain::xdg::NoHomeDirectory) -> Self impl core::convert::From for devlaunch_core::flows::completion::InstallError pub fn devlaunch_core::flows::completion::InstallError::from(devlaunch_core::domain::xdg::NoHomeDirectory) -> Self +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::xdg::NoHomeDirectory) -> Self impl core::fmt::Debug for devlaunch_core::domain::xdg::NoHomeDirectory pub fn devlaunch_core::domain::xdg::NoHomeDirectory::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::Copy for devlaunch_core::domain::xdg::NoHomeDirectory @@ -1024,6 +1126,17 @@ pub fn devlaunch_core::flows::launch::BranchNotNamed::eq(&self, &devlaunch_core: impl core::fmt::Debug for devlaunch_core::flows::launch::BranchNotNamed pub fn devlaunch_core::flows::launch::BranchNotNamed::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::BranchNotNamed +pub enum devlaunch_core::flows::launch::ColdRefused +pub devlaunch_core::flows::launch::ColdRefused::NoColdPath +pub devlaunch_core::flows::launch::ColdRefused::Startup(devlaunch_core::flows::records::StartupError) +impl core::clone::Clone for devlaunch_core::flows::launch::ColdRefused +pub fn devlaunch_core::flows::launch::ColdRefused::clone(&self) -> devlaunch_core::flows::launch::ColdRefused +impl core::cmp::Eq for devlaunch_core::flows::launch::ColdRefused +impl core::cmp::PartialEq for devlaunch_core::flows::launch::ColdRefused +pub fn devlaunch_core::flows::launch::ColdRefused::eq(&self, &devlaunch_core::flows::launch::ColdRefused) -> bool +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 pub enum devlaunch_core::flows::launch::LaunchAborted pub devlaunch_core::flows::launch::LaunchAborted::DevpodNotRun(devlaunch_core::clients::devpod::NotRun) pub devlaunch_core::flows::launch::LaunchAborted::ListingUnreadable(devlaunch_core::clients::devpod::ListingUnreadable) @@ -1255,16 +1368,12 @@ impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::Termin pub struct devlaunch_core::flows::launch::Cold<'a, 'r> pub devlaunch_core::flows::launch::Cold::clones: &'a devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r> pub devlaunch_core::flows::launch::Cold::storage: &'a mut devlaunch_core::domain::metadata::MetadataStorage -pub struct devlaunch_core::flows::launch::ColdRefused -pub devlaunch_core::flows::launch::ColdRefused::reason: alloc::string::String -impl core::clone::Clone for devlaunch_core::flows::launch::ColdRefused -pub fn devlaunch_core::flows::launch::ColdRefused::clone(&self) -> devlaunch_core::flows::launch::ColdRefused -impl core::cmp::Eq for devlaunch_core::flows::launch::ColdRefused -impl core::cmp::PartialEq for devlaunch_core::flows::launch::ColdRefused -pub fn devlaunch_core::flows::launch::ColdRefused::eq(&self, &devlaunch_core::flows::launch::ColdRefused) -> bool -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 +pub struct devlaunch_core::flows::launch::ColdPath<'r, 'e> +impl<'r, 'e> devlaunch_core::flows::launch::ColdPath<'r, 'e> +pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::new(&'r dyn devlaunch_runner::Runner, &'e mut dyn devlaunch_core::notices::Notices) -> Self +pub fn devlaunch_core::flows::launch::ColdPath<'r, 'e>::records(&mut self) -> core::result::Result<&mut devlaunch_core::flows::records::Records<'r>, devlaunch_core::flows::records::StartupError> +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> pub struct devlaunch_core::flows::launch::Host impl devlaunch_core::flows::launch::Host pub fn devlaunch_core::flows::launch::Host::from_process(impl core::convert::Into) -> Self @@ -1283,6 +1392,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 +pub struct devlaunch_core::flows::launch::ToolProvisioning<'e> +impl<'e> devlaunch_core::flows::launch::ToolProvisioning<'e> +pub fn devlaunch_core::flows::launch::ToolProvisioning<'e>::from_env(&std::path::Path, &'e mut dyn devlaunch_core::notices::Notices) -> Self +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 pub struct devlaunch_core::flows::launch::UnquotableCommand pub devlaunch_core::flows::launch::UnquotableCommand::command: alloc::string::String impl core::clone::Clone for devlaunch_core::flows::launch::UnquotableCommand @@ -1295,9 +1410,14 @@ pub fn devlaunch_core::flows::launch::UnquotableCommand::fmt(&self, &mut core::f impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::UnquotableCommand pub trait devlaunch_core::flows::launch::ColdMachinery<'r> pub fn devlaunch_core::flows::launch::ColdMachinery::open(&mut self) -> core::result::Result, devlaunch_core::flows::launch::ColdRefused> +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> pub trait devlaunch_core::flows::launch::Provision pub fn devlaunch_core::flows::launch::Provision::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::Provision::remembered_claude(&self, &str) -> core::option::Option +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 pub fn devlaunch_core::flows::launch::plan(&str) -> core::result::Result pub fn devlaunch_core::flows::launch::resolve_triple(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut dyn devlaunch_core::flows::launch::ColdMachinery<'_>, &devlaunch_core::domain::workspace_id::WorkspaceId, &mut dyn devlaunch_core::notices::Notices, devlaunch_core::clients::devpod::Patience) -> core::result::Result pub mod devlaunch_core::flows::lifecycle @@ -2259,6 +2379,43 @@ pub fn devlaunch_core::flows::provision::Switches::fmt(&self, &mut core::fmt::Fo impl core::marker::Copy for devlaunch_core::flows::provision::Switches impl core::marker::StructuralPartialEq for devlaunch_core::flows::provision::Switches pub fn devlaunch_core::flows::provision::provision_tools(&dyn devlaunch_runner::Runner, &str, devlaunch_core::flows::provision::PassOccasion, devlaunch_core::flows::provision::Switches, core::option::Option<&str>, core::option::Option<&devlaunch_core::flows::provision::HostLayout>, core::option::Option<&devlaunch_core::flows::provision::verdict_cache::VerdictCache>, &mut dyn devlaunch_core::notices::Notices) -> core::result::Result +pub mod devlaunch_core::flows::records +pub enum devlaunch_core::flows::records::RecordsNotice +pub devlaunch_core::flows::records::RecordsNotice::Metadata(devlaunch_core::domain::metadata::Notice) +pub devlaunch_core::flows::records::RecordsNotice::Migrated(devlaunch_core::flows::migration::MigrationReport) +pub devlaunch_core::flows::records::RecordsNotice::MigrationRefused(devlaunch_core::domain::metadata::MetadataError) +pub devlaunch_core::flows::records::RecordsNotice::RetiredKey(devlaunch_core::domain::config::RetiredKey) +impl core::clone::Clone for devlaunch_core::flows::records::RecordsNotice +pub fn devlaunch_core::flows::records::RecordsNotice::clone(&self) -> devlaunch_core::flows::records::RecordsNotice +impl core::cmp::Eq for devlaunch_core::flows::records::RecordsNotice +impl core::cmp::PartialEq for devlaunch_core::flows::records::RecordsNotice +pub fn devlaunch_core::flows::records::RecordsNotice::eq(&self, &devlaunch_core::flows::records::RecordsNotice) -> bool +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 +pub enum devlaunch_core::flows::records::StartupError +pub devlaunch_core::flows::records::StartupError::Config(devlaunch_core::domain::config::ConfigError) +pub devlaunch_core::flows::records::StartupError::Metadata(devlaunch_core::domain::metadata::MetadataError) +pub devlaunch_core::flows::records::StartupError::NoHomeDirectory +impl core::clone::Clone for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::clone(&self) -> devlaunch_core::flows::records::StartupError +impl core::cmp::Eq for devlaunch_core::flows::records::StartupError +impl core::cmp::PartialEq for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::eq(&self, &devlaunch_core::flows::records::StartupError) -> bool +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::config::ConfigError) -> Self +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::metadata::MetadataError) -> Self +impl core::convert::From for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::from(devlaunch_core::domain::xdg::NoHomeDirectory) -> Self +impl core::fmt::Debug for devlaunch_core::flows::records::StartupError +pub fn devlaunch_core::flows::records::StartupError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +impl core::marker::StructuralPartialEq for devlaunch_core::flows::records::StartupError +pub struct devlaunch_core::flows::records::Records<'r> +pub devlaunch_core::flows::records::Records::clones: devlaunch_core::flows::workspace_clone::WorkspaceCloneManager<'r> +pub devlaunch_core::flows::records::Records::reported: alloc::vec::Vec +pub devlaunch_core::flows::records::Records::storage: devlaunch_core::domain::metadata::MetadataStorage +pub fn devlaunch_core::flows::records::open_records<'r>(&'r dyn devlaunch_runner::Runner) -> core::result::Result, devlaunch_core::flows::records::StartupError> pub mod devlaunch_core::flows::repo_manager pub enum devlaunch_core::flows::repo_manager::CacheNotice pub devlaunch_core::flows::repo_manager::CacheNotice::AdoptedBareClone diff --git a/rust/devlaunch-core/src/domain/config.rs b/rust/devlaunch-core/src/domain/config.rs index 55526796..8ae9baa6 100644 --- a/rust/devlaunch-core/src/domain/config.rs +++ b/rust/devlaunch-core/src/domain/config.rs @@ -29,6 +29,7 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use super::metadata::OsFailure; use super::xdg::{self, NoHomeDirectory}; /// Seconds between background fetches, when the file does not say. @@ -58,12 +59,19 @@ pub struct WorktreeConfig { /// the granularity divergence row 8 claims for the refusal: a file that is not /// TOML at all and a TOML file whose one value has the wrong type are different /// things to fix, and a caller holding one string could not tell which it had. -#[derive(Debug)] +/// +/// `Clone` and comparable, for [`metadata::MetadataError`](super::metadata::MetadataError)'s +/// reason: a refusal that travels inside another refusal has to be as copyable as +/// the one carrying it, and since #340 this one travels inside +/// [`ColdRefused`](crate::flows::launch::ColdRefused). That is what the OS side +/// being an [`OsFailure`] rather than an `io::Error` buys — the same words, from a +/// value that can be cloned and compared. +#[derive(Debug, Clone, PartialEq, Eq)] pub enum ConfigError { /// This machine names no home directory, so no config path can be built. NoHomeDirectory, /// The file exists but could not be read. - Unreadable { path: PathBuf, source: io::Error }, + Unreadable { path: PathBuf, source: OsFailure }, /// The file is not TOML at all. `reason` is the parser's own words, quoted /// as data. NotToml { path: PathBuf, reason: String }, @@ -146,7 +154,7 @@ pub(crate) fn worktree_config_at( Err(error) => { return Err(ConfigError::Unreadable { path: path.to_path_buf(), - source: error, + source: error.into(), }); } }; diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index 6b9eda04..a84d6297 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -57,7 +57,7 @@ //! *is* a string here is a remote payload — `bash -lc ` — because those //! bytes are a contract with a shell rather than prose for a person. -use std::cell::{Cell, OnceCell}; +use std::cell::{Cell, OnceCell, RefCell}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; @@ -75,7 +75,12 @@ use crate::flows::lifecycle::{ self, KnownWorkspace, LifecycleNotice, Refresh, RefreshReason, StopOutcome, }; use crate::flows::listing::CommandContext; -use crate::flows::provision::{ClaudeConfig, DevpodMissing, PassOccasion, ZellijSwitch}; +use crate::flows::provision::verdict_cache::VerdictCache; +use crate::flows::provision::{ + self, ClaudeConfig, DevpodMissing, HostLayout, PassOccasion, ProvisionEvent, Provisioning, + Switches, ZellijSwitch, +}; +use crate::flows::records::{self, Records, RecordsNotice, StartupError}; use crate::flows::repo_manager::CacheNotice; use crate::flows::repo_manager::EnsureRepoError; use crate::flows::workspace_clone::{PrepareColdError, WorkspaceCloneManager}; @@ -1144,6 +1149,97 @@ impl Provision for NoProvisioning { } } +/// Lending the host's tools into every workspace devlaunch opens — the real +/// [`Provision`], moved out of the `dl` binary by #340. +/// +/// The host facts are read once, when the value is built, rather than per pass: +/// a launch can provision twice (a sibling's `up` won the race, then this one's +/// `up` ran) and a switch that changed between them would make one launch two +/// different launches. The verdict cache is built here for the same reason and one +/// more — it is two paths, and resolving either of them a second time is how the +/// pass that *writes* a marker and the pass that *reads* one come to disagree about +/// where markers live. +/// +/// `events` is the caller's sink, and it is the only thing that used to keep this +/// type in the binary: the pass streams [`ProvisionEvent`]s *while it runs*, because +/// a cold install moves hundreds of megabytes and a warning about it is worth +/// nothing an hour later. Held behind a [`RefCell`] because [`Provision`] answers +/// through `&self` and a sink is written to; that is a borrow the launch cannot +/// contend with, since one launch makes one pass at a time. +pub struct ToolProvisioning<'e> { + switches: Switches, + host: Option, + verdicts: VerdictCache, + events: RefCell<&'e mut dyn Notices>, +} + +impl<'e> ToolProvisioning<'e> { + /// What this host will lend, whether it may, what it remembers, and where its + /// events go. + /// + /// `cache` is the caller's for the reason [`Host::from_process`] takes it: the + /// caller has already resolved devlaunch's cache directory for everything else, + /// and a second answer here could disagree with the first. + pub fn from_env(cache: &Path, events: &'e mut dyn Notices) -> Self { + Self { + switches: Switches::from_env(), + // `None` is a machine with no home directory to look in: nothing to + // lend, rather than nothing to do — the setup pass still runs, because + // the stages it carries are not tools work. + host: HostLayout::from_env(), + // A `None` devpod home here means something else again: no file to + // check a remembered verdict against, so nothing is ever trusted and + // every pass travels, exactly as it did before the cache existed. + verdicts: VerdictCache::under(cache, DevpodHome::locate()), + events: RefCell::new(events), + } + } +} + +impl Provision for ToolProvisioning<'_> { + fn provision_tools( + &self, + runner: &dyn Runner, + workspace_id: &str, + occasion: PassOccasion, + title: Option<&str>, + ) -> Result, DevpodMissing> { + let provisioned = { + let mut events = self.events.borrow_mut(); + provision::provision_tools( + runner, + workspace_id, + occasion, + self.switches, + title, + self.host.as_ref(), + Some(&self.verdicts), + &mut **events, + ) + }; + // Every way of coming up empty is an arm of `Provisioning`, and none of them + // is worth an event beyond the ones above: the workspace is up and the user + // asked for a session, not for an install. A devpod that has gone missing is + // the one answer that travels — the launch cannot go on without it, and the + // launch ends with it. + // + // `CachedProvisioned` is silent for the same reason, and deliberately so: it + // is the arm where a launch did *less* than it used to, and a word about it + // would put a sentence on the terminal of every prewarm to announce that + // nothing happened. `DEVLAUNCH_TIMING=1` is where a missing round trip is + // worth reading, and it shows there as the trip that is not in the list. + // The Claude fact travels; every arm of `Provisioning` still says nothing. + provisioned.map(|pass| { + let _: Provisioning = pass.provisioning; + pass.claude() + }) + } + + fn remembered_claude(&self, workspace_id: &str) -> Option { + self.verdicts.remembered_claude(workspace_id) + } +} + // =========================================================================== // `devpod up` // =========================================================================== @@ -2352,9 +2448,21 @@ pub struct Cold<'a, 'r> { } /// Why the cold path's machinery could not be opened. +/// +/// A sum over the reasons and not a rendered sentence. It used to be +/// `reason: String`, which was the one place the binary's own prose travelled back +/// *through* core — `dl` rendered the words and core quoted them into the launch's +/// refusal — against the crate's own rule that no user-facing English lives here +/// (#251 §5). The arms carry what the reason is; the sentences are the caller's, +/// as they are for [`crate::flows::repo_manager::NotRefreshed`]. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct ColdRefused { - pub reason: String, +pub enum ColdRefused { + /// The records could not be opened: no home directory, an unreadable + /// `config.toml`, or a `metadata.json` that would not open. + Startup(StartupError), + /// This launcher was built with no cold path at all — see [`NoColdPath`]. + /// Nothing was attempted and nothing is wrong with the machine. + NoColdPath, } /// A way to build the cold path's machinery, called only when it is needed. @@ -2384,9 +2492,65 @@ pub(crate) struct NoColdPath; impl<'r> ColdMachinery<'r> for NoColdPath { fn open(&mut self) -> Result, ColdRefused> { - Err(ColdRefused { - reason: "the cold path is not available to this caller".to_owned(), - }) + Err(ColdRefused::NoColdPath) + } +} + +/// devlaunch's records, opened on the first ask and kept for the rest of the +/// command. +/// +/// **The real implementation, and the reason this type exists at all.** Core states +/// the requirement in [`ColdMachinery`] — *a way to get* a clone manager and a +/// metadata store, never the things themselves — so that a warm launch can be shown +/// never to have read `metadata.json`. Something has to hold the other end of that, +/// and until #340 it was a private type inside the `dl` binary: a second consumer +/// could name [`Launch`] and had nothing to hand it that could go cold. +/// +/// The open reports once, here, rather than at each call site: a caller that forgot +/// would silently drop what a damaged `metadata.json` has to say, and two callers +/// that both remembered would say it twice. What it reports is +/// [`RecordsNotice`]s into the sink the caller supplied — typed events, said at the +/// moment the open happens. The sentences stay with whoever wrote the sink. +pub struct ColdPath<'r, 'e> { + runner: &'r dyn Runner, + said: &'e mut dyn Notices, + records: Option>, +} + +impl<'r, 'e> ColdPath<'r, 'e> { + /// A cold path that has not been opened, and will not be until something asks. + /// + /// Nothing is read here: no config, no `metadata.json`, no migration. That is + /// devlaunch#145's whole promise, and it is kept by this constructor doing + /// nothing. + pub fn new(runner: &'r dyn Runner, said: &'e mut dyn Notices) -> Self { + Self { + runner, + said, + records: None, + } + } + + /// The records, opening them the first time and reporting what that had to say. + pub fn records(&mut self) -> Result<&mut Records<'r>, StartupError> { + if self.records.is_none() { + let records = records::open_records(self.runner)?; + self.said.say_all(records.reported.iter().cloned()); + self.records = Some(records); + } + Ok(self.records.as_mut().expect("the records were just opened")) + } +} + +impl<'r> ColdMachinery<'r> for ColdPath<'r, '_> { + fn open(&mut self) -> Result, ColdRefused> { + match self.records() { + Ok(records) => Ok(Cold { + clones: &records.clones, + storage: &mut records.storage, + }), + Err(refused) => Err(ColdRefused::Startup(refused)), + } } } diff --git a/rust/devlaunch-core/src/flows/mod.rs b/rust/devlaunch-core/src/flows/mod.rs index 11009a3b..cc4f69ce 100644 --- a/rust/devlaunch-core/src/flows/mod.rs +++ b/rust/devlaunch-core/src/flows/mod.rs @@ -20,6 +20,8 @@ pub mod migration; // binary surface — not part of the frozen wf API (#251 §7) pub mod provision; // binary surface — not part of the frozen wf API (#251 §7) +pub mod records; +// binary surface — not part of the frozen wf API (#251 §7) pub mod repo_manager; // binary surface — not part of the frozen wf API (#251 §7) pub mod workspace_clone; diff --git a/rust/devlaunch-core/src/flows/records.rs b/rust/devlaunch-core/src/flows/records.rs new file mode 100644 index 00000000..c390e263 --- /dev/null +++ b/rust/devlaunch-core/src/flows/records.rs @@ -0,0 +1,224 @@ +//! devlaunch's own records: the config, the metadata store and the clone manager, +//! opened together and once. +//! +//! # Built when they are needed, and once +//! +//! Python memoized the clone manager in a module-level dict and ran the one-shot +//! id-scheme migration on the way through the factory, so that `--help`, +//! `--version`, `--ls`, the completion commands and a warm launch never paid for +//! any of it (#58, then #145). That laziness is behaviour and not an +//! optimisation: it decides which commands run the migration at all. +//! +//! Here it is a value a caller builds when it needs one, rather than a memo to +//! reset: [`open_records`] is the single construction point, it runs the migration +//! exactly once because it is called at most once per command, and a caller that +//! never calls it has provably not touched `metadata.json`. The type that holds +//! the other end of that promise is +//! [`flows::launch::ColdPath`](crate::flows::launch::ColdPath). +//! +//! # No sentences here +//! +//! The load, the retired keys and the migration all have things to report, and +//! none of them is a sentence: they travel as [`RecordsNotice`] and whoever holds +//! the sink writes the words (#251 §5). This module was the `dl` binary's +//! `session.rs` until #340, and the reason it moved is that the binary was the +//! only program that could open devlaunch's records at all. + +use crate::clients::git::Git; +use crate::domain::config::{self, ConfigError, RetiredKey}; +use crate::domain::metadata::{self, MetadataError, MetadataStorage}; +use crate::domain::xdg::{self, NoHomeDirectory}; +use crate::flows::migration::{self, MigrationReport}; +use crate::flows::workspace_clone::WorkspaceCloneManager; +use crate::runner::Runner; + +/// Why a command could not get as far as running. +/// +/// Three separate reasons because they are fixed in three different places: an +/// environment with no home directory, a `config.toml` that cannot be read, and a +/// `metadata.json` that cannot be opened. +/// +/// binary surface — not part of the frozen wf API (#251 §7) on its own; it reaches +/// the promised tier as the payload of +/// [`ColdRefused`](crate::flows::launch::ColdRefused). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StartupError { + NoHomeDirectory, + Config(ConfigError), + Metadata(MetadataError), +} + +impl From for StartupError { + fn from(_: NoHomeDirectory) -> Self { + StartupError::NoHomeDirectory + } +} + +impl From for StartupError { + fn from(error: ConfigError) -> Self { + StartupError::Config(error) + } +} + +impl From for StartupError { + fn from(error: MetadataError) -> Self { + StartupError::Metadata(error) + } +} + +/// Something opening the records had to say, in the order it happened. +/// +/// One vocabulary over what used to be four fields a caller drained in a fixed +/// order: the config's retired keys, the load's notices, the migration's report and +/// the migration's refusal. It is a sink's vocabulary rather than a struct of lists +/// because the order *is* the report — Python's factory read the config, opened the +/// store and then announced the migration from inside it — and because a sink is +/// what lets the words be said while the work is still happening. +/// +/// binary surface — not part of the frozen wf API (#251 §7) +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordsNotice { + /// A key `config.toml` names that this build no longer reads. Only + /// `worktree.repos_dir` today, and it is reported rather than ignored because + /// it used to decide where the clones went: a user who set it has a tree at + /// that path, and this run is the only thing that will ever name it. + RetiredKey(RetiredKey), + /// Something the load of `metadata.json` found and the user should be told. + Metadata(metadata::Notice), + /// What the cache migration did, on the runs where it ran and produced a + /// report. Absent on the common already-current case, which costs a single + /// integer comparison and no scan. + Migrated(MigrationReport), + /// Why the cache could not be migrated. + /// + /// A failed migration must not take the command with it — the renames that did + /// happen are still resumable, because the version header is only written by + /// the final save — so this is reported and the command carries on, as Python's + /// `logging.warning` did. + MigrationRefused(MetadataError), +} + +/// devlaunch's own records and clones, with the cache migration already run. +/// +/// Holds the manager and the store together because the listing reads both and +/// they have to describe the same cache. There is deliberately no second copy of +/// the config here: the clone root is what the commands want, and the manager is +/// what answers for that (see +/// [`lifecycle::ClonePlacement`](crate::flows::lifecycle::ClonePlacement)), so a +/// command cannot scan one tree while locking against another. +/// +/// binary surface — not part of the frozen wf API (#251 §7) +pub struct Records<'r> { + pub storage: MetadataStorage, + /// The clone manager, which is the one thing that names a record's clone + /// directory: the listing, the `dl rm` guard and the delete itself all + /// have to name the *same* directory, and they used to name it separately and + /// could disagree (devlaunch#174). + pub clones: WorkspaceCloneManager<'r>, + /// Everything the load and the migration had to say, in the order it happened. + /// Said by whoever opened the records: these are typed events, and the + /// sentences are the caller's. + pub reported: Vec, +} + +/// Open devlaunch's records, migrating the cache if it has not been migrated yet. +/// +/// The one construction point, so nothing can reach a stale clone path before the +/// rename. On an already-migrated cache the migration costs a single integer +/// comparison: the trigger is the version header the load already parsed. +pub fn open_records<'r>(runner: &'r dyn Runner) -> Result, StartupError> { + let (config, retired_keys) = config::worktree_config()?; + let cache_dir = xdg::devlaunch_cache()?; + let (mut storage, notices) = MetadataStorage::open(MetadataStorage::default_path()?)?; + // The report is carried out and said by the caller. Python's `migrate_cache` + // announces inside itself (migration.py `_announce`); core renders no English + // (#251), so the report travels up and the binary writes the sentences — the + // migration's orphan/unmigrated notices, including the only pointer a user + // gets to `dl --reconcile`/`recreate` for the containers it orphaned. + let (migration, migration_refused) = + match migration::migrate_cache(&mut storage, &xdg::clone_root_in(&cache_dir)) { + Ok(report) => (report, None), + Err(refused) => (None, Some(refused)), + }; + let clones = WorkspaceCloneManager::in_cache(&cache_dir, &config, Git::new(runner)); + Ok(Records { + storage, + clones, + reported: reported(retired_keys, notices, migration, migration_refused), + }) +} + +/// The one order the four sources are reported in, as a function of them. +/// +/// The config is read before the records are opened, so its notices come first; +/// the migration's come after the load's and before any refusal, which is the order +/// Python's factory produced them in. Separated from [`open_records`] so the order +/// is testable without a cache directory to open. +fn reported( + retired_keys: Vec, + notices: Vec, + migration: Option, + migration_refused: Option, +) -> Vec { + retired_keys + .into_iter() + .map(RecordsNotice::RetiredKey) + .chain(notices.into_iter().map(RecordsNotice::Metadata)) + .chain(migration.into_iter().map(RecordsNotice::Migrated)) + .chain( + migration_refused + .into_iter() + .map(RecordsNotice::MigrationRefused), + ) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::metadata::OsFailure; + use std::path::PathBuf; + + fn a_notice() -> metadata::Notice { + metadata::Notice::VersionFromNewerBuild { + path: PathBuf::from("/cache/metadata.json"), + found: 9, + understood: 2, + } + } + + #[test] + fn the_four_sources_are_reported_in_the_order_python_produced_them() { + let refused = MetadataError::CreateDir { + path: PathBuf::from("/nowhere"), + failure: OsFailure { + kind: std::io::ErrorKind::PermissionDenied, + message: "Permission denied (os error 13)".to_owned(), + }, + }; + let key = RetiredKey::ReposDir { + named: "/old".to_owned(), + }; + let said = reported( + vec![key.clone()], + vec![a_notice()], + Some(MigrationReport::default()), + Some(refused.clone()), + ); + + assert_eq!( + said, + [ + RecordsNotice::RetiredKey(key), + RecordsNotice::Metadata(a_notice()), + RecordsNotice::Migrated(MigrationReport::default()), + RecordsNotice::MigrationRefused(refused), + ] + ); + } + + #[test] + fn a_clean_open_has_nothing_to_say() { + assert_eq!(reported(Vec::new(), Vec::new(), None, None), Vec::new()); + } +} diff --git a/rust/devlaunch-core/src/lib.rs b/rust/devlaunch-core/src/lib.rs index a901066d..06fa7676 100644 --- a/rust/devlaunch-core/src/lib.rs +++ b/rust/devlaunch-core/src/lib.rs @@ -10,7 +10,7 @@ //! devpod-the-filesystem where `devpod` is devpod-the-command. //! 3. **domain** — `workspace_id`, `spec`, `model`, `metadata`, `config`, //! `xdg`, `locks`: the data model, written once. -//! 4. **flows** — `launch`, `lifecycle`, `listing`, `provision`, +//! 4. **flows** — `launch`, `lifecycle`, `listing`, `provision`, `records`, //! `completion`, `disk_usage`, `timing`: the operations. Dependencies run //! strictly downward, so a flow or a domain type may name a tool client //! (`workspace_state` reads `clients::git`); a client never names a flow. @@ -45,7 +45,7 @@ //! diff or a red tick. The two tiers get a file each — `public-api.api.txt` //! for declarations at the [`api`] path, `public-api.rest.txt` for the rest — //! so that a change to the promised tier's *declarations* is a diff in a -//! 37-row file rather than one row inside two thousand. +//! 126-row file rather than one row inside two thousand. //! //! **What that file does not cover, and it is not a small gap.** //! `cargo public-api` renders inherent methods and trait impls only at a @@ -53,7 +53,7 @@ //! [`api::Launch`]'s only constructor and only method are rendered //! `flows::launch::Launch::{new, run}` and land in `public-api.rest.txt`, as do //! `CommandContext::new`, `DevcontainerPath::as_str` and every derived -//! `Clone`/`Debug`/`PartialEq` on the promised types: 42 of the 79 rows the +//! `Clone`/`Debug`/`PartialEq` on the promised types: 133 of the 259 rows the //! generator emits for the `api` section. Renaming `api::Launch::run` — an //! unambiguous break — leaves `public-api.api.txt` byte-identical. So the //! sound direction is one-way: a diff in the promise file *is* a change to the @@ -137,6 +137,27 @@ pub mod api { // up: start or attach a workspace. pub use crate::flows::launch::{Launch, LaunchVerb}; + // …and everything `Launch::new` asks for, so that a caller with this module + // and nothing else can build one that goes cold (#340). Five of the seven + // parameter types used to live outside here, and the two implementations that + // decide whether a launch can go cold at all lived in the `dl` binary — so the + // promised tier named a launcher nobody outside `dl` could construct. + // + // The traits come with them: an external caller may lend its own machinery + // (`NoColdPath`'s shape, for a caller that has established the workspace is + // warm) instead of the implementations here, and it cannot do that without + // naming what it is implementing. + pub use crate::flows::launch::{ + Cold, ColdMachinery, ColdPath, ColdRefused, Host, LaunchNotice, Provision, ToolProvisioning, + }; + pub use crate::flows::lifecycle::{Refresh, SelfInvocation}; + pub use crate::flows::provision::ProvisionEvent; + pub use crate::flows::records::{RecordsNotice, StartupError}; + // The sink itself, which is how every one of those vocabularies is taken: + // `Vec` implements it, so a caller that only wants to collect needs nothing + // of its own. + pub use crate::notices::Notices; + // list: the workspace listing, and the two shapes wf renders it in. pub use crate::flows::listing::{CommandContext, enriched_listing, json_document}; diff --git a/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs b/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs new file mode 100644 index 00000000..835c73d2 --- /dev/null +++ b/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs @@ -0,0 +1,75 @@ +//! A cold-capable [`Launch`] built from `devlaunch_core::api` and nothing else. +//! +//! This is devlaunch#340 as a test. `api` is the tier `wf` is entitled to link +//! against (#251 §7), and until this compiled it did not carry a launcher: five of +//! `Launch::new`'s seven parameter types lived outside `api`, and the two that +//! decide whether a launch can go cold at all -- the [`ColdMachinery`] and the +//! [`Provision`] implementations that really do the work -- lived in the `dl` +//! binary, where nothing but `dl` could reach them. A second consumer could name +//! the launcher and could not build one. +//! +//! So the assertion is the import list. Every parameter is named through `api`; +//! the only path from anywhere else is the runner, which is its own crate and its +//! own promised seam (`devlaunch-runner`, snapshot of its own since #338) and is +//! not a parameter of `Launch::new`. +//! +//! What it checks at runtime is the other half of devlaunch#145: **building** a +//! cold-capable launcher reads nothing. The records are not opened, the migration +//! does not run, and the sinks are still empty when the launcher exists -- which is +//! what makes [`ColdPath`] a *way to get* the records rather than the records. + +use std::path::Path; + +use devlaunch_core::api::{ + ColdMachinery, ColdPath, CommandContext, Host, Launch, LaunchNotice, Notices, Provision, + ProvisionEvent, RecordsNotice, Refresh, SelfInvocation, ToolProvisioning, +}; +use devlaunch_test_support::FakeRunner; + +#[test] +fn a_cold_capable_launch_is_built_from_the_api_module_alone() { + let runner = FakeRunner::new(); + // Never written to and never read: a launcher that touched it while being + // built would be the failure this test is here for. + let cache = Path::new("/nonexistent/devlaunch-340"); + + let mut context = CommandContext::new(&runner); + let updater = SelfInvocation::new("dl".to_owned()); + let completions = cache.join("completions.json"); + let mut refresh = Refresh::new(&updater, &completions); + + // The three vocabularies a launch reports in, each collected rather than said: + // `Vec` is core's own sink for `T`, so a consumer needs nothing of its own + // to hold a launch's events. + let mut records_said: Vec = Vec::new(); + let mut provision_said: Vec = Vec::new(); + let mut launch_said: Vec = Vec::new(); + + let mut cold = ColdPath::new(&runner, &mut records_said); + let provision = ToolProvisioning::from_env(cache, &mut provision_said); + let host = Host::from_process(cache); + let mut forward = |_line: &str| {}; + + // The two implementations are the real ones, named through the traits the + // constructor asks for -- not test doubles standing in for them. + let cold_machinery: &mut dyn ColdMachinery<'_> = &mut cold; + let provisioner: &dyn Provision = &provision; + let said: &mut dyn Notices = &mut launch_said; + + let launch = Launch::new( + &mut context, + &mut refresh, + cold_machinery, + provisioner, + &host, + &mut forward, + said, + ); + drop(launch); + + // Nothing was spawned, nothing was said, and -- the point of devlaunch#145 -- + // no records were opened to say it with. + assert_eq!(runner.call_count(), 0); + assert!(records_said.is_empty(), "{records_said:?}"); + assert!(launch_said.is_empty(), "{launch_said:?}"); +} diff --git a/rust/dl/src/cold.rs b/rust/dl/src/cold.rs deleted file mode 100644 index 451c2425..00000000 --- a/rust/dl/src/cold.rs +++ /dev/null @@ -1,68 +0,0 @@ -//! dl's records, opened only if the command turns out to need them. -//! -//! This is the binary's side of devlaunch#145. Core states the requirement in a -//! type — [`ColdMachinery`] is *a way to get* a clone manager and a metadata -//! store, never the things themselves — precisely so that a warm launch can be -//! shown never to have read `metadata.json`. Something has to hold the other end -//! of that, and it is here rather than in core because opening the records is also -//! where the load's notices are *said*, and the sentences are the binary's. -//! -//! One value per command, like [`crate::session::Records`] itself: the records are -//! opened at most once, the load's notices and the migration's refusal are -//! reported at the moment the open happens, and a command that never asks has -//! provably not touched the file. - -use devlaunch_core::flows::launch::{Cold, ColdMachinery, ColdRefused}; -use devlaunch_core::runner::Runner; - -use crate::commands; -use crate::render; -use crate::session::{self, Records, StartupError}; - -/// The records, opened on the first ask and kept for the rest of the command. -pub(crate) struct ColdPath<'r> { - runner: &'r dyn Runner, - records: Option>, -} - -impl<'r> ColdPath<'r> { - pub(crate) fn new(runner: &'r dyn Runner) -> Self { - Self { - runner, - records: None, - } - } - - /// dl's records, opening them the first time and reporting what that had to - /// say. - /// - /// The report happens here, once, rather than at each call site: a caller that - /// forgot it would silently drop the sentences a damaged `metadata.json` - /// prints, and two callers that both remembered would print them twice. - pub(crate) fn records(&mut self) -> Result<&mut Records<'r>, StartupError> { - if self.records.is_none() { - let records = session::open_records(self.runner)?; - commands::report(&records); - self.records = Some(records); - } - Ok(self.records.as_mut().expect("the records were just opened")) - } -} - -impl<'r> ColdMachinery<'r> for ColdPath<'r> { - fn open(&mut self) -> Result, ColdRefused> { - match self.records() { - Ok(records) => Ok(Cold { - clones: &records.clones, - storage: &mut records.storage, - }), - // Rendered into the refusal rather than printed here, because core - // carries this reason into the one sentence the launch refuses with - // (`Repository 'owner/repo': …`) and printing it as well would say it - // twice. - Err(refused) => Err(ColdRefused { - reason: render::startup_reason(&refused), - }), - } - } -} diff --git a/rust/dl/src/commands.rs b/rust/dl/src/commands.rs index c0ddc7f1..4e07bee5 100644 --- a/rust/dl/src/commands.rs +++ b/rust/dl/src/commands.rs @@ -17,23 +17,22 @@ use devlaunch_core::domain::xdg; use devlaunch_core::flows::completion::{self, FileState, InstallError, Installed, RcChange}; use devlaunch_core::flows::completion_cache::{self, Refreshed}; use devlaunch_core::flows::kill; -use devlaunch_core::flows::launch::LaunchNotice; +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, }; use devlaunch_core::flows::listing::{self, CommandContext, DlView, Sizes}; +use devlaunch_core::flows::records::{Records, StartupError, open_records}; use devlaunch_core::flows::repo_manager::CacheNotice; use devlaunch_core::runner::{Exit, Runner}; use crate::cli::{self, Command, ListOutput, RmOnExit, Verb}; -use crate::cold::ColdPath; use crate::hangup; use crate::launch::{self, Family, Reached}; use crate::render; use crate::render::Swept; use crate::select; -use crate::session::{self, Records, StartupError}; use crate::target::{self, Unaddressable, Vetting}; /// How a command ended, as an exit code. @@ -221,7 +220,7 @@ fn render_json( cache: &Path, sizes: Sizes, ) -> Ending { - let records = match session::open_records(runner) { + let records = match open_records(runner) { Err(refused) => return refuse_startup(&refused), Ok(records) => records, }; @@ -339,7 +338,7 @@ fn render_update_cache( // next keystroke reads, while the fetch sweep is for the launch after // that. Both are on the same hour, so a child that gets this far does // both or, when it exits early above, neither. - let mut records = match session::open_records(runner) { + let mut records = match open_records(runner) { Err(refused) => return refuse_startup(&refused), Ok(records) => records, }; @@ -531,7 +530,11 @@ fn render_workspace<'r>( devcontainer: Option<&DevcontainerPath>, recognised: Option, ) -> Ending { - let mut cold = ColdPath::new(runner); + // The open's own notices are said where they happen, by the same printer every + // other notice goes through: a damaged `metadata.json` has something to say + // before the verb it is holding up gets anywhere. + let mut opening = render::Saying; + let mut cold = ColdPath::new(runner, &mut opening); // The word the line used, asked of the verb rather than written out per arm. // Two of the three arms are one word each and could be spelled here, but // `Family::Remove` covers both `rm` and `rme`, and a notice that quotes a word @@ -732,7 +735,7 @@ fn after_the_session<'r>( context: &mut CommandContext<'r>, cache: &Path, refresh: &mut Refresh<'_>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, target: &str, rm: RmOnExit, ran: launch::Ran, @@ -790,7 +793,7 @@ fn render_stop<'r>( runner: &'r dyn Runner, context: &mut CommandContext<'r>, refresh: &mut Refresh<'_>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, target: &str, ) -> Ending { let addressed = match target::resolve(runner, context, cold, target, Vetting::ByDevpod) { @@ -851,7 +854,7 @@ fn render_kill<'r>( context: &mut CommandContext<'r>, cache: &Path, refresh: &mut Refresh<'_>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, target: &str, word: &str, ) -> Ending { @@ -910,7 +913,7 @@ fn render_remove<'r>( context: &mut CommandContext<'r>, cache: &Path, refresh: &mut Refresh<'_>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, target: &str, removal: Removal, word: &str, @@ -946,7 +949,7 @@ fn remove_addressed<'r>( context: &mut CommandContext<'r>, cache: &Path, refresh: &mut Refresh<'_>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, workspace_id: &str, target: &str, removal: Removal, @@ -1188,7 +1191,7 @@ fn prune_clone_directories( yes: bool, force: bool, ) -> Cleanup { - let mut records = match session::open_records(runner) { + let mut records = match open_records(runner) { Err(refused) => return Cleanup::Raised(refuse_startup(&refused)), Ok(records) => records, }; @@ -1292,7 +1295,7 @@ fn render_reconcile( refresh: &mut Refresh<'_>, yes: bool, ) -> Ending { - let mut records = match session::open_records(runner) { + let mut records = match open_records(runner) { Err(refused) => return refuse_startup(&refused), Ok(records) => records, }; @@ -1605,28 +1608,16 @@ fn refuse_startup(refused: &StartupError) -> Ending { /// Everything the config load, the metadata load and the cache migration had to /// say. +/// +/// For the commands that open the records directly rather than through a +/// [`ColdPath`] — which says the same notices through [`render::Saying`] at the +/// moment the open happens. The order is core's, and it is the order Python's +/// factory produced them in: the config's retired keys, then the load's notices, +/// then the migration's, then any refusal of it. pub(crate) fn report(records: &Records<'_>) { - // The config is read before the records are opened, so its notices are said - // first — and here rather than at the load, so that the once-per-command - // guarantee `report` already carries covers them too. - for line in render::retired_keys(&records.retired_keys) { - eprintln!("{line}"); - } - for line in render::metadata_notices(&records.notices) { - eprintln!("{line}"); - } - // The cache migration's notices, said after the load's and before any refusal: - // Python's factory ran the load then `migrate_cache`, which announced inside - // itself. On an already-current cache there is no report and nothing to say. - if let Some(report) = &records.migration { - for line in render::migration_notices(report) { + for notice in &records.reported { + for line in render::records_notice(notice) { eprintln!("{line}"); } } - if let Some(refused) = &records.migration_refused { - eprintln!( - "Could not migrate the workspace cache: {}", - render::metadata_error(refused) - ); - } } diff --git a/rust/dl/src/launch.rs b/rust/dl/src/launch.rs index 60c3d237..9389cc0b 100644 --- a/rust/dl/src/launch.rs +++ b/rust/dl/src/launch.rs @@ -6,17 +6,18 @@ //! core's; what is here is the mapping from [`cli::Verb`] to [`LaunchVerb`], the //! sentences, and the exit codes. //! -//! # Two things this module owns that core deliberately does not +//! # The one thing this module owns that core deliberately does not //! -//! - **Where devpod's session diagnostics go.** `attach_workspace` takes a -//! `forward` closure and calls it *as the session runs*, because a session lives -//! for hours and devpod's warning about it is worth nothing an hour late. Core -//! writes to nobody's stream, so the sink is here. -//! - **Whether the tools get lent in.** [`Provision`] is a trait for the reason -//! its docstring gives, and [`ToolProvisioning`] is the implementation that -//! really provisions: it reads the host's facts once ([`Switches::from_env`], -//! [`HostLayout::from_env`], and the cache directory the verdict cache lives -//! under) and renders each pass's events at the moment the pass makes them. +//! **Where devpod's session diagnostics go.** `attach_workspace` takes a `forward` +//! closure and calls it *as the session runs*, because a session lives for hours +//! and devpod's warning about it is worth nothing an hour late. Core writes to +//! nobody's stream, so the sink is here. +//! +//! It used to own a second thing — [`ToolProvisioning`], the implementation that +//! really lends the tools in — and that moved to core in #340. What kept it here +//! was the events sink, which is now a value the constructor takes: the pass reads +//! the host's facts and streams its events, and this module supplies the stream and +//! the words for it and nothing else. //! //! # When the notices are said //! @@ -32,24 +33,17 @@ use std::path::Path; -use devlaunch_core::clients::devpod_home::DevpodHome; use devlaunch_core::domain::spec::DevcontainerPath; use devlaunch_core::domain::workspace_id::WorkspaceId; use devlaunch_core::flows::completion_cache; use devlaunch_core::flows::launch::{ - self, Host, Launch, LaunchAborted, LaunchRefusal, LaunchVerb, Launched, Plan, Provision, - Session, + self, ColdPath, Host, Launch, LaunchAborted, LaunchRefusal, LaunchVerb, Launched, Plan, + Session, ToolProvisioning, }; use devlaunch_core::flows::lifecycle::Refresh; use devlaunch_core::flows::listing::CommandContext; -use devlaunch_core::flows::provision::verdict_cache::VerdictCache; -use devlaunch_core::flows::provision::{ - self, DevpodMissing, HostLayout, PassOccasion, Provisioning, Switches, -}; -use devlaunch_core::runner::Runner; use crate::cli::{RmOnExit, Verb}; -use crate::cold::ColdPath; use crate::commands::Ending; use crate::render; @@ -133,87 +127,6 @@ pub(crate) fn family(verb: &Verb) -> Family { Family::Launch { verb: launched, rm } } -/// Lending the host's tools into every workspace dl opens. -/// -/// The host facts are read once, when the value is built, rather than per pass: -/// a launch can provision twice (a sibling's `up` won the race, then this one's -/// `up` ran) and a switch that changed between them would make one launch two -/// different launches. The verdict cache is built here for the same reason and one -/// more — it is two paths, and resolving either of them a second time is how the -/// pass that *writes* a marker and the pass that *reads* one come to disagree about -/// where markers live. -pub(crate) struct ToolProvisioning { - switches: Switches, - host: Option, - verdicts: VerdictCache, -} - -impl ToolProvisioning { - /// What this host will lend, whether it may, and what it remembers. - /// - /// `cache` is the caller's for the reason [`Host::from_process`] takes it: the - /// binary has already resolved devlaunch's cache directory for everything else, - /// and a second answer here could disagree with the first. - pub(crate) fn from_env(cache: &Path) -> Self { - Self { - switches: Switches::from_env(), - // `None` is a machine with no home directory to look in: nothing to - // lend, rather than nothing to do — the setup pass still runs, because - // the stages it carries are not tools work. - host: HostLayout::from_env(), - // A `None` devpod home here means something else again: no file to - // check a remembered verdict against, so nothing is ever trusted and - // every pass travels, exactly as it did before the cache existed. - verdicts: VerdictCache::under(cache, DevpodHome::locate()), - } - } -} - -impl Provision for ToolProvisioning { - fn provision_tools( - &self, - runner: &dyn Runner, - workspace_id: &str, - occasion: PassOccasion, - title: Option<&str>, - ) -> Result, DevpodMissing> { - // The events stream through the same sink as the launch's own notices — - // one line on stderr at the moment core says it, which is Python's order: - // a cold install streams hundreds of megabytes, and a warning about it is - // worth something while it is still happening. - let provisioned = provision::provision_tools( - runner, - workspace_id, - occasion, - self.switches, - title, - self.host.as_ref(), - Some(&self.verdicts), - &mut render::Saying, - ); - // Every way of coming up empty is an arm of `Provisioning`, and none of them - // is worth a word beyond the events above: the workspace is up and the user - // asked for a session, not for an install. A devpod that has gone missing is - // the one answer that travels — the launch cannot go on without it, and core - // ends the launch with it. - // - // `CachedProvisioned` is silent for the same reason, and deliberately so: it - // is the arm where a launch did *less* than it used to, and a line about it - // would put a sentence on the terminal of every prewarm to announce that - // nothing happened. `DEVLAUNCH_TIMING=1` is where a missing round trip is - // worth reading, and it shows there as the trip that is not in the list. - // The Claude fact travels; every arm of `Provisioning` still says nothing. - provisioned.map(|pass| { - let _: Provisioning = pass.provisioning; - pass.claude() - }) - } - - fn remembered_claude(&self, workspace_id: &str) -> Option { - self.verdicts.remembered_claude(workspace_id) - } -} - /// One launch, rendered. /// /// `cold` is the caller's so that a lifecycle verb and a launch verb of the same @@ -224,7 +137,7 @@ pub(crate) fn render_launch<'r>( context: &mut CommandContext<'r>, cache: &Path, refresh: &mut Refresh<'_>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, target: &str, verb: &LaunchVerb, devcontainer: Option<&DevcontainerPath>, @@ -252,7 +165,13 @@ pub(crate) fn render_launch<'r>( }; } let host = Host::from_process(cache); - let provision = ToolProvisioning::from_env(cache); + // The pass's events stream through a sink of their own, and it is the same + // printer as the launch's notices: one line on stderr at the moment core makes + // the event, which is Python's order. A cold install streams hundreds of + // megabytes, and a warning about it is worth something while it is still + // happening. + let mut lending = render::Saying; + let provision = ToolProvisioning::from_env(cache, &mut lending); // Verbatim and as it happens: this is devpod's own stderr, minus the line it // buries a remote exit status in, and a session's warnings belong on the // terminal while the session is running. diff --git a/rust/dl/src/lib.rs b/rust/dl/src/lib.rs index 886502f1..cecddeeb 100644 --- a/rust/dl/src/lib.rs +++ b/rust/dl/src/lib.rs @@ -9,13 +9,14 @@ //! summary; an `aid` with launch logic of its own is the drift the Python module was //! written to end. //! -//! Five modules, and the boundary between them is the invariant (#251's invariant 1: +//! Four modules, and the boundary between them is the invariant (#251's invariant 1: //! the binary holds nothing beyond parsing, rendering and interactive selection): //! //! - [`cli`] — the grammar. argv in, one `Command` out, pure. -//! - [`session`] — what one command holds: the runner, the cache directory, and -//! the records when it needs them; [`cold`] is the lazily-opened records -//! themselves, and [`target`] is which workspace a verb's target word names. +//! - [`session`] — the two answers only this process can give: where devlaunch +//! keeps its things, and how to re-run this build. The records it used to open +//! are core's since #340 ([`devlaunch_core::flows::launch::ColdPath`], opened +//! lazily), and [`target`] is which workspace a verb's target word names. //! - [`commands`] — one `render_*` per command, and the exhaustive match; //! [`launch`] is the eight launch verbs' half of it, and [`select`] is the //! embedded picker that supplies the workspace when the command line named none. @@ -23,7 +24,6 @@ //! prints is written in that module or in [`commands`]; core holds none of it. mod cli; -mod cold; mod commands; mod hangup; mod launch; diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index 5db68258..dc72b8af 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -28,7 +28,8 @@ use devlaunch_core::flows::kill::{ TableUnreadable, }; use devlaunch_core::flows::launch::{ - BranchNotNamed, LaunchAborted, LaunchNotice, LaunchRefusal, NotPrepared, SessionRefused, + BranchNotNamed, ColdRefused, LaunchAborted, LaunchNotice, LaunchRefusal, NotPrepared, + SessionRefused, }; use devlaunch_core::flows::lifecycle::{ Insistence, KeptBecause, LifecycleNotice, NotAdopted, Objection, Promotion, PrunePlan, @@ -38,6 +39,7 @@ use devlaunch_core::flows::lifecycle::{ use devlaunch_core::flows::listing::{LastUsed, SizeCell, Sizes, TableRow, WorkspaceTable}; use devlaunch_core::flows::migration::{Listing, MigrationReport}; use devlaunch_core::flows::provision::{BundleFailed, FailureLevel, ProvisionEvent}; +use devlaunch_core::flows::records::{RecordsNotice, StartupError}; use devlaunch_core::flows::repo_manager::{ CacheNotice, Cleanup, CloneError, EnsureRepoError, NotRefreshed, Refusal, RefusalReason, RemoveTreeError, WrongRepoLock, @@ -53,7 +55,6 @@ use serde_json::Value; use serde_json::ser::{Formatter, PrettyFormatter}; use crate::select::Chosen; -use crate::session::StartupError; // --------------------------------------------------------------------------- // the `dl --ls` table @@ -785,7 +786,7 @@ pub(crate) fn config_error(error: &config::ConfigError) -> String { "this machine names no home directory, so dl cannot find its config".to_owned() } config::ConfigError::Unreadable { path, source } => { - format!("could not read {} ({source})", path.display()) + format!("could not read {} ({})", path.display(), source.message) } // One sentence for both parse arms: the reason already says whether the // parser or the typed read refused, and the arms exist for callers. @@ -820,6 +821,24 @@ pub(crate) fn retired_keys(keys: &[config::RetiredKey]) -> Vec { .collect() } +/// One thing the records' open had to say, as the lines it reads as. +/// +/// A list because one arm is many lines: [`RecordsNotice::Migrated`] carries a whole +/// report, and Python's `_announce` printed up to nine separate warnings out of it. +/// Everything else is the one line its own renderer already produced — this is the +/// dispatch, not a new vocabulary. +pub(crate) fn records_notice(notice: &RecordsNotice) -> Vec { + match notice { + RecordsNotice::RetiredKey(key) => retired_keys(std::slice::from_ref(key)), + RecordsNotice::Metadata(notice) => metadata_notices(std::slice::from_ref(notice)), + RecordsNotice::Migrated(report) => migration_notices(report), + RecordsNotice::MigrationRefused(refused) => vec![format!( + "Could not migrate the workspace cache: {}", + metadata_error(refused) + )], + } +} + /// Why a metadata write or open failed, in one line. /// /// A reason phrase and not a sentence: every caller has its own opening — `Could not @@ -2264,6 +2283,19 @@ impl Notices for Saying { } } +/// The records' open reports through the same sink, at the moment it opens them. +/// +/// Which is once per command, because the open is: a `ColdPath` that has already +/// been asked answers from what it holds, so a damaged `metadata.json` is described +/// once however many verbs go looking at it. +impl Notices for Saying { + fn say(&mut self, notice: RecordsNotice) { + for line in records_notice(¬ice) { + eprintln!("{line}"); + } + } +} + /// Why this workspace opens without a GitHub login. /// /// The `Refused` arm names the directory gh read its config from, because that is @@ -2524,18 +2556,31 @@ fn or_list(items: &[String]) -> String { fn branch_not_named(error: &BranchNotNamed) -> String { match error { - BranchNotNamed::Cold(refused) => refused.reason.clone(), + BranchNotNamed::Cold(refused) => cold_refused(refused), BranchNotNamed::Repository(refused) => ensure_repo_failure(refused), } } fn not_prepared(error: &NotPrepared) -> String { match error { - NotPrepared::Cold(refused) => refused.reason.clone(), + NotPrepared::Cold(refused) => cold_refused(refused), NotPrepared::Preparation(refused) => prepare_cold_failure(refused), } } +/// Why the cold path could not be opened, without the `error: ` prefix. +/// +/// Quoted inside core's own refusals — `Repository 'owner/repo': ` — which is +/// why the prefix is the caller's, the way [`startup_reason`] is. The words are here +/// and not in core: `ColdRefused` is a sum over the reasons since #340, and this is +/// the match that turns each arm into the sentence Python printed for it. +fn cold_refused(refused: &ColdRefused) -> String { + match refused { + ColdRefused::Startup(error) => startup_reason(error), + ColdRefused::NoColdPath => "the cold path is not available to this caller".to_owned(), + } +} + /// Why the bare-clone cache could not be brought up. /// /// The words are `worktree/repo_manager.py`'s own exceptions, which is what Python diff --git a/rust/dl/src/session.rs b/rust/dl/src/session.rs index 97545471..b8e11de7 100644 --- a/rust/dl/src/session.rs +++ b/rust/dl/src/session.rs @@ -1,60 +1,18 @@ -//! What one `dl` command holds: the runner it spawns through, the cache -//! directory, and — for the commands that need them — the config, the records and -//! the clone manager. +//! The two answers only *this* process can give: where devlaunch keeps its +//! things, and how to re-run this build. //! -//! # Built when they are needed, and once -//! -//! Python memoized the clone manager in a module-level dict and ran the one-shot -//! id-scheme migration on the way through the factory, so that `--help`, -//! `--version`, `--ls`, the completion commands and a warm launch never paid for -//! any of it (#58, then #145). That laziness is behaviour and not an -//! optimisation: it decides which commands run the migration at all. -//! -//! Here it is a value a command builds when it needs one, rather than a memo to -//! reset: [`open_records`] is the single construction point, it runs the migration -//! exactly once because it is called at most once per command, and a command that -//! never calls it has provably not touched `metadata.json`. +//! The records themselves — the config, `metadata.json`, the cache migration and +//! the clone manager — used to live here too, and moved to +//! [`devlaunch_core::flows::records`] in #340. They were never dl's knowledge: the +//! whole module was core types plumbed together, and keeping the plumbing in the +//! binary meant `devlaunch_core::api` promised a launcher that only `dl` could +//! build. What is left is what genuinely belongs to a running program rather than +//! to the library: `current_exe()`, and the cache path everything else is handed. use std::path::PathBuf; -use devlaunch_core::clients::git::Git; -use devlaunch_core::domain::config::{self, ConfigError, RetiredKey, WorktreeConfig}; -use devlaunch_core::domain::metadata::{MetadataError, MetadataStorage, Notice}; use devlaunch_core::domain::xdg::{self, NoHomeDirectory}; use devlaunch_core::flows::lifecycle::SelfInvocation; -use devlaunch_core::flows::migration::{self, MigrationReport}; -use devlaunch_core::flows::workspace_clone::WorkspaceCloneManager; -use devlaunch_core::runner::Runner; - -/// Why a command could not get as far as running. -/// -/// Three separate reasons because they are fixed in three different places: an -/// environment with no home directory, a `config.toml` that cannot be read, and a -/// `metadata.json` that cannot be opened. -#[derive(Debug)] -pub(crate) enum StartupError { - NoHomeDirectory, - Config(ConfigError), - Metadata(MetadataError), -} - -impl From for StartupError { - fn from(_: NoHomeDirectory) -> Self { - StartupError::NoHomeDirectory - } -} - -impl From for StartupError { - fn from(error: ConfigError) -> Self { - StartupError::Config(error) - } -} - -impl From for StartupError { - fn from(error: MetadataError) -> Self { - StartupError::Metadata(error) - } -} /// Where devlaunch keeps everything: the directory ownership is decided by and /// `--purge` removes. @@ -107,80 +65,6 @@ fn refresh_program(current_exe: Option) -> String { } } -/// The worktree config, and whatever of `config.toml` this build no longer reads. -pub(crate) fn worktree_config() -> Result<(WorktreeConfig, Vec), ConfigError> { - config::worktree_config() -} - -/// dl's own records and clones, with the cache migration already run. -/// -/// Holds the manager and the store together because the listing reads both and -/// they have to describe the same cache. There is deliberately no second copy of -/// the config here: the clone root is what the commands want, and the manager is -/// what answers for that (see [`lifecycle::ClonePlacement`]), so a command cannot -/// scan one tree while locking against another. -pub(crate) struct Records<'r> { - pub(crate) storage: MetadataStorage, - /// The clone manager, which is the one thing that names a record's clone - /// directory: the listing, the `dl rm` guard and the delete itself all - /// have to name the *same* directory, and they used to name it separately and - /// could disagree (devlaunch#174). - pub(crate) clones: WorkspaceCloneManager<'r>, - /// Everything the load and the migration had to say, in the order it happened. - /// Rendered by the caller: these are typed events, and the sentences are the - /// binary's. - pub(crate) notices: Vec, - /// The keys `config.toml` names that this build no longer reads. Only - /// `worktree.repos_dir` today, and it is here rather than ignored because it - /// used to decide where the clones went: a user who set it has a tree at that - /// path, and this run is the only thing that will ever name it. - pub(crate) retired_keys: Vec, - /// What the cache migration did, when it ran and produced a report. `None` - /// covers both the common already-current case (a single integer comparison, - /// no scan) and a migration that a concurrent process had already finished. - /// Rendered by the caller: the report carries the facts, and the sentences — - /// Python's `_announce`, up to nine notice classes and the only pointer to - /// `dl --reconcile` for orphaned containers — are the binary's (#251). - pub(crate) migration: Option, - /// Why the cache could not be migrated, when it could not. - /// - /// A failed migration must not take the command with it — the renames that did - /// happen are still resumable, because the version header is only written by - /// the final save — so this is reported and the command carries on, as Python's - /// `logging.warning` did. - pub(crate) migration_refused: Option, -} - -/// Open dl's records, migrating the cache if it has not been migrated yet. -/// -/// The one construction point, so nothing can reach a stale clone path before the -/// rename. On an already-migrated cache the migration costs a single integer -/// comparison: the trigger is the version header the load already parsed. -pub(crate) fn open_records<'r>(runner: &'r dyn Runner) -> Result, StartupError> { - let (config, retired_keys) = worktree_config()?; - let cache_dir = cache_dir()?; - let (mut storage, notices) = MetadataStorage::open(MetadataStorage::default_path()?)?; - // The report is kept and rendered by the caller. Python's `migrate_cache` - // announces inside itself (migration.py `_announce`); core renders no English - // (#251), so the report travels up and the binary writes the sentences — the - // migration's orphan/unmigrated notices, including the only pointer a user - // gets to `dl --reconcile`/`recreate` for the containers it orphaned. - let (migration, migration_refused) = - match migration::migrate_cache(&mut storage, &xdg::clone_root_in(&cache_dir)) { - Ok(report) => (report, None), - Err(refused) => (None, Some(refused)), - }; - let clones = WorkspaceCloneManager::in_cache(&cache_dir, &config, Git::new(runner)); - Ok(Records { - storage, - clones, - notices, - retired_keys, - migration, - migration_refused, - }) -} - #[cfg(test)] mod tests { use super::*; diff --git a/rust/dl/src/target.rs b/rust/dl/src/target.rs index 65600325..8c1472d9 100644 --- a/rust/dl/src/target.rs +++ b/rust/dl/src/target.rs @@ -35,14 +35,12 @@ use std::time::Duration; use devlaunch_core::clients::devpod::{ListingUnreadable, NotRun, Patience}; use devlaunch_core::domain::workspace_id::{UnsafeName, WorkspaceId}; -use devlaunch_core::flows::launch::{self, LaunchNotice, Plan, Resolution}; +use devlaunch_core::flows::launch::{self, ColdPath, LaunchNotice, Plan, Resolution}; use devlaunch_core::flows::lifecycle; use devlaunch_core::flows::listing::CommandContext; +use devlaunch_core::flows::records::StartupError; use devlaunch_core::runner::Runner; -use crate::cold::ColdPath; -use crate::session::StartupError; - /// Which workspace the target is, and everything the resolution had to say on the /// way. /// @@ -137,7 +135,7 @@ impl Vetting { pub(crate) fn resolve<'r>( runner: &'r dyn Runner, context: &mut CommandContext<'r>, - cold: &mut ColdPath<'r>, + cold: &mut ColdPath<'r, '_>, target: &str, vetting: Vetting, ) -> Result { @@ -165,7 +163,7 @@ pub(crate) fn resolve<'r>( /// devpod does not recognise the hint (devlaunch#88). fn triple( context: &mut CommandContext<'_>, - cold: &mut ColdPath<'_>, + cold: &mut ColdPath<'_, '_>, owner: String, repo: String, branch: Option, @@ -269,7 +267,8 @@ mod tests { fn a_bare_name_the_caller_will_not_vet_costs_no_spawn_at_all() { let fake = FakeRunner::new(); let mut context = CommandContext::new(&fake); - let mut cold = ColdPath::new(&fake); + let mut said = Vec::new(); + let mut cold = ColdPath::new(&fake, &mut said); let addressed = resolve( &fake, @@ -296,7 +295,8 @@ mod tests { fn a_workspace_devpod_denies_is_still_addressable_without_vetting() { let fake = FakeRunner::new(); let mut context = CommandContext::new(&fake); - let mut cold = ColdPath::new(&fake); + let mut said = Vec::new(); + let mut cold = ColdPath::new(&fake, &mut said); let addressed = resolve( &fake, @@ -319,7 +319,8 @@ mod tests { let bound = |vetting| { let fake = FakeRunner::new(); let mut context = CommandContext::new(&fake); - let mut cold = ColdPath::new(&fake); + let mut said = Vec::new(); + let mut cold = ColdPath::new(&fake, &mut said); let _ = resolve( &fake, &mut context, diff --git a/scripts/public-api-snapshots.sh b/scripts/public-api-snapshots.sh index 118c1e24..5fefdae6 100755 --- a/scripts/public-api-snapshots.sh +++ b/scripts/public-api-snapshots.sh @@ -26,8 +26,8 @@ # methods and trait impls only at a type's *canonical* path, never at the path # it is re-exported under. So `api::Launch::run` is rendered # `devlaunch_core::flows::launch::Launch::run` and this classifier cannot see -# it. Of the 79 rows the generator emits for the `api` section, the match keeps -# 37; the other 42 -- `Launch::new`, `Launch::run`, `CommandContext::new`, +# it. Of the 259 rows the generator emits for the `api` section, the match keeps +# 126; the other 133 -- `Launch::new`, `Launch::run`, `CommandContext::new`, # `DevcontainerPath::as_str` and every derived `Clone`/`Debug`/`PartialEq` on # the promised types -- land in the rest file. Renaming `Launch::run` therefore # leaves the promise file byte-identical. Two consequences worth carrying: From 0c5dea09463275b0e104dd4558b42c469b13ead8 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 20:36:16 +0100 Subject: [PATCH 2/3] Give the typed refusal its red test, and fix four stale doc markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found the shape of #339 satisfied and its named red test missing: nothing in the tree constructed `ColdRefused::Startup` or `StartupError::Metadata`, so both "the reason travels as a type" and "the sentences did not move" were inspection-only claims. Three tests now hold them, at the three places the claim lives: - core, `flows::launch`: a metadata-refused cold open arrives at `BranchNotNamed::Cold` and `NotPrepared::Cold` as the reason it is, with the `MetadataError` intact. Proven red by flattening the arm back to a `String`, which stops all three assertions compiling. - `dl::render`: every arm renders the exact line it used to arrive already rendered with, whole strings and not substrings, plus the composition into `Repository 'owner/repo': ...` and the `io::Error` wording that survived `ConfigError` gaining `OsFailure`. - `test/unit/test_cold_path_refusal.py`: a real run whose cache directory is a file, which is the only way to reach `ColdPath::open`'s Err arm. The open resolves its paths from the process environment, so nothing inside either crate can run it. Also the four doc markers the review caught, all made wrong by the previous commit rather than pre-existing: `flows::lifecycle`'s "except the three §7 names", the binary-surface notes on `StartupError` and `RecordsNotice` which the same commit filed in the promise file, and the fourth prose site still at 42-of-79. `Records` gains the note it should have had: reachable through `api::ColdPath::records` without being declared there, which is #352's gap. One rustdoc private-link warning went with them, from the new `ColdRefused::NoColdPath` doc pointing at the `pub(crate)` struct. Measured: 126 such warnings in devlaunch-core before this branch, 125 after. --- CHANGELOG.md | 4 +- rust/devlaunch-core/src/flows/launch.rs | 102 +++++++++++++++++- rust/devlaunch-core/src/flows/lifecycle.rs | 11 +- rust/devlaunch-core/src/flows/records.rs | 30 ++++-- .../tests/api_launch_is_self_sufficient.rs | 5 + .../tests/public_api_snapshots.rs | 2 +- rust/dl/src/render.rs | 89 +++++++++++++++ test/unit/test_cold_path_refusal.py | 85 +++++++++++++++ 8 files changed, 311 insertions(+), 17 deletions(-) create mode 100644 test/unit/test_cold_path_refusal.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ba0f899..f34fcce5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the words. It is now `Startup(StartupError)` or `NoColdPath`, and `dl` renders each arm. `domain::config::ConfigError` became clonable and comparable for the same reason, its OS side spelled as `OsFailure` the way `MetadataError`'s already was. - The sentences a user sees are unchanged. + The sentences a user sees are unchanged, and are held to that rather than inspected: + every arm is asserted against the exact line it used to arrive already rendered + with, and a real run whose records will not open is judged from outside the binary. ### Fixed diff --git a/rust/devlaunch-core/src/flows/launch.rs b/rust/devlaunch-core/src/flows/launch.rs index a84d6297..a765fbdb 100644 --- a/rust/devlaunch-core/src/flows/launch.rs +++ b/rust/devlaunch-core/src/flows/launch.rs @@ -2460,8 +2460,10 @@ pub enum ColdRefused { /// The records could not be opened: no home directory, an unreadable /// `config.toml`, or a `metadata.json` that would not open. Startup(StartupError), - /// This launcher was built with no cold path at all — see [`NoColdPath`]. - /// Nothing was attempted and nothing is wrong with the machine. + /// This launcher was built with no cold path at all: the caller established + /// the workspace was warm and lent machinery that refuses on principle (the + /// crate's own `NoColdPath` is one). Nothing was attempted, and nothing is + /// wrong with the machine. NoColdPath, } @@ -3783,6 +3785,32 @@ mod tests { } } + /// A cold path whose `metadata.json` will not open, refusing exactly as + /// [`ColdPath`] does when [`records::open_records`] hands it a + /// [`StartupError`]. + struct MetadataWillNotOpen; + + /// The refusal a real `MetadataStorage::open` produces when the directory it + /// needs cannot be created, spelled once so the tests below compare against the + /// same value the arm is built from. + fn metadata_refusal() -> crate::domain::metadata::MetadataError { + crate::domain::metadata::MetadataError::CreateDir { + path: PathBuf::from("/cache/devlaunch"), + failure: crate::domain::metadata::OsFailure { + kind: std::io::ErrorKind::NotADirectory, + message: "Not a directory (os error 20)".to_owned(), + }, + } + } + + impl<'r> ColdMachinery<'r> for MetadataWillNotOpen { + fn open(&mut self) -> Result, ColdRefused> { + Err(ColdRefused::Startup(StartupError::Metadata( + metadata_refusal(), + ))) + } + } + /// Records which workspaces had tools lent to them, on which occasion, and under /// what name the container was told to title a terminal. /// @@ -6303,6 +6331,76 @@ mod tests { ); } + // -------------------------------- the cold path's refusal, as a reason + + /// devlaunch#339: a metadata-refused cold open surfaces as the typed arm. + /// + /// [`ColdRefused`] carried a `reason: String` until #340, filled by `dl` + /// rendering a [`StartupError`] and quoted straight back into this refusal. + /// That was the one place the binary's own prose travelled back *through* core, + /// and what it cost is visible from here: a caller holding the refusal could + /// read the sentence and could not ask which of the three things went wrong. + /// + /// So the assertion is the whole value, not a substring of one. The refusal + /// arrives at the launch's own refusal as the reason it *is*, with the + /// [`MetadataError`](crate::domain::metadata::MetadataError) the store produced + /// still inside it and no sentence anywhere along the way. Flatten either level + /// back to a string and this stops compiling, which is the failure it is here + /// for. + #[test] + fn a_metadata_refused_cold_open_surfaces_as_the_typed_arm() { + let refused = name_default_branch( + &mut MetadataWillNotOpen, + "blooop", + "devlaunch", + "git@github.com:blooop/devlaunch.git", + &mut no_notices(), + ); + + assert_eq!( + refused, + Err(BranchNotNamed::Cold(ColdRefused::Startup( + StartupError::Metadata(metadata_refusal()) + ))) + ); + } + + /// The other arm that opens the cold path, carrying the same reason unchanged. + /// + /// Both are worth pinning because they are separate `map_err`s over the same + /// `open`, and a refusal that survived one of them and was stringified by the + /// other would be the old bug back in half the launches. + #[test] + fn the_cold_arm_of_a_host_side_preparation_carries_the_same_typed_reason() { + let workspace = WorkspaceId::new("blooop", "devlaunch", "main").expect("a safe triple"); + + let refused = prepare( + &mut MetadataWillNotOpen, + &workspace, + "git@github.com:blooop/devlaunch.git", + &mut no_notices(), + ); + + assert_eq!( + refused, + Err(NotPrepared::Cold(ColdRefused::Startup( + StartupError::Metadata(metadata_refusal()) + ))) + ); + } + + /// The arm that replaced an English literal. + /// + /// `NoColdPath` used to refuse with the sentence "the cold path is not available + /// to this caller", written in core, which is the rule #251 §5 states. It is a + /// variant now and the sentence is the binary's. + #[test] + fn a_launcher_with_no_cold_path_refuses_with_an_arm_rather_than_a_sentence() { + let mut none = NoColdPath; + + assert_eq!(none.open().err(), Some(ColdRefused::NoColdPath)); + } + // -------------------------------- stage two: which workspace, and is it warm #[test] diff --git a/rust/devlaunch-core/src/flows/lifecycle.rs b/rust/devlaunch-core/src/flows/lifecycle.rs index b9eb9ba2..72c72c6b 100644 --- a/rust/devlaunch-core/src/flows/lifecycle.rs +++ b/rust/devlaunch-core/src/flows/lifecycle.rs @@ -9,10 +9,13 @@ //! why the names are the ones a caller outside dl would reach for. //! //! Everything reachable here is **binary surface — not part of the frozen wf API -//! (#251 §7)**, except the three §7 names (`list`, `remove`, `up`): the `dl` -//! binary is a separate crate and every sentence a user reads is written there, -//! so a rendering layer that could not name these typed results would not be a -//! rendering layer. The distinction is what stays frozen at the end of M6. +//! (#251 §7)** unless [`api`](crate::api) re-exports it: the `dl` binary is a +//! separate crate and every sentence a user reads is written there, so a rendering +//! layer that could not name these typed results would not be a rendering layer. +//! Five names from this module are promised rather than merely reachable — the +//! three §7 verbs (`list`, `remove`, `up`), and, since #340, [`Refresh`] and +//! [`SelfInvocation`], which `api::Launch::new` cannot be called without. The +//! authority is `api`'s re-export list and the snapshot over it, not this comment. //! //! # Three commands remove things, and none of them decides what is finished //! diff --git a/rust/devlaunch-core/src/flows/records.rs b/rust/devlaunch-core/src/flows/records.rs index c390e263..6093bd22 100644 --- a/rust/devlaunch-core/src/flows/records.rs +++ b/rust/devlaunch-core/src/flows/records.rs @@ -38,9 +38,10 @@ use crate::runner::Runner; /// environment with no home directory, a `config.toml` that cannot be read, and a /// `metadata.json` that cannot be opened. /// -/// binary surface — not part of the frozen wf API (#251 §7) on its own; it reaches -/// the promised tier as the payload of -/// [`ColdRefused`](crate::flows::launch::ColdRefused). +/// **Part of the frozen wf API (#251 §7)**, re-exported from +/// [`api`](crate::api) since #340: it is the payload of +/// [`ColdRefused::Startup`](crate::flows::launch::ColdRefused::Startup), and a +/// consumer that cannot match on it is holding a refusal it cannot read. #[derive(Debug, Clone, PartialEq, Eq)] pub enum StartupError { NoHomeDirectory, @@ -70,12 +71,20 @@ impl From for StartupError { /// /// One vocabulary over what used to be four fields a caller drained in a fixed /// order: the config's retired keys, the load's notices, the migration's report and -/// the migration's refusal. It is a sink's vocabulary rather than a struct of lists -/// because the order *is* the report — Python's factory read the config, opened the -/// store and then announced the migration from inside it — and because a sink is -/// what lets the words be said while the work is still happening. +/// the migration's refusal. It is one ordered sequence rather than a struct of four +/// lists because the order *is* the report — Python's factory read the config, +/// opened the store and then announced the migration from inside it — and a caller +/// holding four lists has to know that order to reproduce it. /// -/// binary surface — not part of the frozen wf API (#251 §7) +/// Note what this vocabulary does *not* buy, unlike the launch's: the open is a +/// single act, so [`open_records`] finishes before anything can be said about it and +/// [`ColdPath`](crate::flows::launch::ColdPath) says the whole sequence at once. The +/// sink is what makes the saying the caller's and the ordering core's, not what +/// makes it early. +/// +/// **Part of the frozen wf API (#251 §7)**, re-exported from [`api`](crate::api) +/// since #340: it is what +/// [`ColdPath::new`](crate::flows::launch::ColdPath::new) takes its sink in. #[derive(Debug, Clone, PartialEq, Eq)] pub enum RecordsNotice { /// A key `config.toml` names that this build no longer reads. Only @@ -107,7 +116,10 @@ pub enum RecordsNotice { /// [`lifecycle::ClonePlacement`](crate::flows::lifecycle::ClonePlacement)), so a /// command cannot scan one tree while locking against another. /// -/// binary surface — not part of the frozen wf API (#251 §7) +/// Not re-exported from [`api`](crate::api), and reachable from it all the same: +/// it is what [`ColdPath::records`](crate::flows::launch::ColdPath::records) +/// answers with. That is the classifier gap #352 is about rather than a second +/// tier, so treat a change here as a change to the promise. pub struct Records<'r> { pub storage: MetadataStorage, /// The clone manager, which is the one thing that names a record's clone diff --git a/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs b/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs index 835c73d2..2e13ab5d 100644 --- a/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs +++ b/rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs @@ -66,10 +66,15 @@ fn a_cold_capable_launch_is_built_from_the_api_module_alone() { said, ); drop(launch); + // Dropped so its sink can be read: the provisioner borrows `provision_said` for + // as long as it lives, and all three sinks are worth asserting rather than the + // two that happen to be readable without this line. + drop(provision); // Nothing was spawned, nothing was said, and -- the point of devlaunch#145 -- // no records were opened to say it with. assert_eq!(runner.call_count(), 0); assert!(records_said.is_empty(), "{records_said:?}"); + assert!(provision_said.is_empty(), "{provision_said:?}"); assert!(launch_said.is_empty(), "{launch_said:?}"); } diff --git a/rust/devlaunch-core/tests/public_api_snapshots.rs b/rust/devlaunch-core/tests/public_api_snapshots.rs index 53b7b345..53274317 100644 --- a/rust/devlaunch-core/tests/public_api_snapshots.rs +++ b/rust/devlaunch-core/tests/public_api_snapshots.rs @@ -16,7 +16,7 @@ //! The tests below hold the partition, which is not the same as holding the //! promise. `cargo public-api` renders methods and impls only at a type's //! canonical path, so `api::Launch::{new, run}` and every derived impl on a -//! promised type are in the rest file — 42 of the 79 rows the generator emits +//! promised type are in the rest file — 133 of the 259 rows the generator emits //! for the `api` section — and renaming `Launch::run` diffs neither of these //! two files in the place a reader would look. Deliberately not asserted here: //! widens the classifier, and diff --git a/rust/dl/src/render.rs b/rust/dl/src/render.rs index dc72b8af..11d83be1 100644 --- a/rust/dl/src/render.rs +++ b/rust/dl/src/render.rs @@ -2834,6 +2834,95 @@ mod tests { } } + // ------------------------------------------- the cold path's typed refusal + + /// The other half of devlaunch#339: core carries the reason, and this module + /// is where it becomes a sentence. + /// + /// Every arm is asserted whole rather than by substring, because the claim the + /// typing was made under is that the words did not move: `ColdRefused` used to + /// arrive here already rendered, and these are the exact strings it used to + /// arrive with. A `contains` would pass while a rewrite quietly changed the + /// line a user reads. + #[test] + fn every_arm_of_a_cold_refusal_renders_the_sentence_it_used_to_carry() { + assert_eq!( + cold_refused(&ColdRefused::Startup(StartupError::NoHomeDirectory)), + "this machine names no home directory, so dl cannot find its cache" + ); + assert_eq!( + cold_refused(&ColdRefused::Startup(StartupError::Config( + config::ConfigError::NotToml { + path: PathBuf::from("/cfg/devlaunch/config.toml"), + reason: "expected `.`, `=`".to_owned(), + } + ))), + "/cfg/devlaunch/config.toml is not usable: expected `.`, `=`" + ); + assert_eq!( + cold_refused(&ColdRefused::Startup(StartupError::Metadata( + metadata::MetadataError::CreateDir { + path: PathBuf::from("/cache/devlaunch"), + failure: metadata::OsFailure { + kind: std::io::ErrorKind::NotADirectory, + message: "Not a directory (os error 20)".to_owned(), + }, + } + ))), + "could not create the directory for dl's records at /cache/devlaunch \ + (Not a directory (os error 20))" + ); + // The arm that replaced a literal written in core. Same words, said here. + assert_eq!( + cold_refused(&ColdRefused::NoColdPath), + "the cold path is not available to this caller" + ); + } + + /// And the refusal reaches the user inside the line the launch refuses with. + /// + /// `Repository '{owner}/{repo}': …` is Python's sentence and the reason it is a + /// reason phrase rather than a sentence of its own: the prefix belongs to the + /// caller, so the two must compose exactly here. + #[test] + fn a_cold_refusal_is_quoted_into_the_launch_refusal_that_carries_it() { + let line = launch_refusal(&LaunchRefusal::BranchNotNamed { + owner: "blooop".to_owned(), + repo: "devlaunch".to_owned(), + error: BranchNotNamed::Cold(ColdRefused::Startup(StartupError::NoHomeDirectory)), + }); + + assert_eq!( + line.as_deref(), + Some( + "Repository 'blooop/devlaunch': this machine names no home directory, \ + so dl cannot find its cache" + ) + ); + } + + /// A `config.toml` that could not be read reports the OS's own words. + /// + /// Pinned because #340 changed what carries them: `ConfigError::Unreadable` held + /// an `io::Error` and now holds an `OsFailure`, so that the refusal can be + /// cloned into `ColdRefused`. `OsFailure::message` is `io::Error::to_string()`, + /// and this is what says the line did not move. + #[test] + fn an_unreadable_config_still_reads_as_the_os_error_it_was() { + let refused: config::ConfigError = config::ConfigError::Unreadable { + path: PathBuf::from("/cfg/devlaunch/config.toml"), + source: std::io::Error::from_raw_os_error(13).into(), + }; + + assert_eq!( + config_error(&refused), + format!( + "could not read /cfg/devlaunch/config.toml ({})", + std::io::Error::from_raw_os_error(13) + ) + ); + } + // ------------------------------------------------------- the retired keys #[test] diff --git a/test/unit/test_cold_path_refusal.py b/test/unit/test_cold_path_refusal.py new file mode 100644 index 00000000..c0043fa9 --- /dev/null +++ b/test/unit/test_cold_path_refusal.py @@ -0,0 +1,85 @@ +"""What a run whose records will not open tells the person who typed it. + +The cold path is the config, `metadata.json`, the cache migration and the clone +manager, opened together and only by a command that needs them. When that open +fails there is nothing else dl can do for the launch, so the whole of the +outcome is the sentence it refuses with. + +Judged from outside, through the binary, because that is the only place the two +halves meet: since #340 the reason travels out of `devlaunch-core` as a type +(`ColdRefused::Startup(StartupError::Metadata(..))`, pinned in +`flows::launch`'s own tests) and the words are written in `dl`'s renderer +(pinned in `render`'s). Nothing inside either crate can say that the real +`ColdPath` produces the arm the renderer is given -- the open resolves its paths +from the process environment, so only a real process with a scoped one runs it. + +The refusal is provoked by putting a *file* where dl's cache directory belongs. +`MetadataStorage::open` creates the directory its store lives in before it does +anything else, and it cannot create that one. +""" + +import os +import subprocess +from pathlib import Path + +from fixtures.e2e_helpers import dl_command + + +def cache_dir_is_a_file() -> Path: + """Make the one directory dl keeps everything in impossible to create.""" + path = Path(os.environ["XDG_CACHE_HOME"]) / "devlaunch" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not a directory\n") + return path + + +def run_dl(devpod_shim, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [*dl_command(), *args], + env=devpod_shim.env(), + capture_output=True, + text=True, + check=False, + ) + + +class TestRecordsThatWillNotOpen: + def test_the_launch_says_which_of_the_three_things_went_wrong(self, devpod_shim): + """The reason, not a generic failure: this one is fixed on the filesystem.""" + blocked = cache_dir_is_a_file() + + result = run_dl(devpod_shim, "blooop/devlaunch") + + assert result.returncode != 0 + assert "could not create the directory for dl's records" in result.stderr, result.stderr + assert str(blocked) in result.stderr, result.stderr + + def test_the_reason_is_quoted_into_the_line_the_launch_refuses_with(self, devpod_shim): + """`Repository 'owner/repo': ` is Python's sentence and still is. + + This is what the typed refusal has to compose into. The reason phrase + carries no prefix of its own precisely so this line reads as one + sentence, which is why the renderer is asked for a phrase and the caller + writes the opening. + """ + cache_dir_is_a_file() + + result = run_dl(devpod_shim, "blooop/devlaunch") + + said = [line for line in result.stderr.splitlines() if line.startswith("Repository ")] + assert said, result.stderr + assert said[0].startswith("Repository 'blooop/devlaunch': could not create the directory") + + def test_nothing_was_asked_of_devpod_for_a_workspace_that_cannot_be_named( + self, devpod_shim + ): + """The refusal comes before the round trips, so there is nothing to clean up. + + A `devpod up` here would leave a container behind for a launch that never + got as far as knowing which branch it was for. + """ + cache_dir_is_a_file() + + run_dl(devpod_shim, "blooop/devlaunch") + + assert [call for call in devpod_shim.calls() if "up" in call] == [] From 96a8dc4d743b8efd327b7d2723dba140620adcbe Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Sat, 29 Aug 2026 21:07:02 +0100 Subject: [PATCH 3/3] ruff format the new cold-path refusal test Line length, caught by prek rather than by me: the suite's ruff config fits that signature on one line. --- test/unit/test_cold_path_refusal.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/unit/test_cold_path_refusal.py b/test/unit/test_cold_path_refusal.py index c0043fa9..eea79f79 100644 --- a/test/unit/test_cold_path_refusal.py +++ b/test/unit/test_cold_path_refusal.py @@ -70,9 +70,7 @@ def test_the_reason_is_quoted_into_the_line_the_launch_refuses_with(self, devpod assert said, result.stderr assert said[0].startswith("Repository 'blooop/devlaunch': could not create the directory") - def test_nothing_was_asked_of_devpod_for_a_workspace_that_cannot_be_named( - self, devpod_shim - ): + def test_nothing_was_asked_of_devpod_for_a_workspace_that_cannot_be_named(self, devpod_shim): """The refusal comes before the round trips, so there is nothing to clean up. A `devpod up` here would leave a container behind for a launch that never