Skip to content

Fold the removal guard into one workspace_remove and swap the api row - #520

Merged
blooop merged 4 commits into
mainfrom
wayfinder/devlaunch-410
Aug 29, 2026
Merged

Fold the removal guard into one workspace_remove and swap the api row#520
blooop merged 4 commits into
mainfrom
wayfinder/devlaunch-410

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Stacked on #514 (base wayfinder/devlaunch-340); GitHub retargets this to main when that merges. Read the diff over #514's head, not over main.

Implements #410, decided on #395.

The problem, stated as a test

devlaunch_core::api promised workspace_delete, and that is the delete without the unsaved-work guard. The probe and the guard that make it safe were two further exported functions a caller had to run first, in the right order, with the right arguments, and the sequence lived in the dl binary. So the promise was the unguarded delete, nothing outside dl could reuse the safe sequence, and nothing held dl to it. A second consumer following the promise exactly deletes a clone holding the only copy of somebody's afternoon.

The red is rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs, written with api paths only, over a real cache directory with a real git repository in it:

  • a_guarded_removal_refuses_over_unsaved_work_and_never_asks_devpod — a recorded clone with an uncommitted change, Removal::Guarded, and the answer is RemoveOutcome::Refused(RemovalRefused::WouldLose { losses, .. }). The losses are bound and read, not just matched, and the runner never saw devpod delete. Against the old surface it does not compile: there is no api::workspace_remove, and five of workspace_delete's parameter types were not in api either.
  • an_insisted_removal_deletes_the_same_clone_and_runs_no_proberm --force removes it and runs no git status and no git log. Pins the conditional probe: probing unconditionally is the one-line accident this fold invites, and it is invisible except in the bill. Verified red by mutating Removal::Insisted => Probe::Skip to Probe::Look(..).
  • the_volumes_are_named_before_devpod_is_asked_to_delete — the ordering the delete's own comment claims. The fake devpod removes its own record when asked to delete, the way the real one does, so a sweep that read the names afterwards would have nothing to ask docker about. Verified red by moving the naming below the delete; both orders otherwise remove the workspace and exit zero.

What folded

lifecycle::workspace_remove is the one exported removal:

pub fn workspace_remove(
    context: &mut CommandContext<'_>,
    refresh: &mut Refresh<'_>,
    clones: &WorkspaceCloneManager<'_>,
    storage: &mut MetadataStorage,
    cache_dir: &Path,
    devpod_home: Option<&DevpodHome>,
    workspace_id: &str,
    removal: Removal,
    stalled: &mut dyn FnMut(DeleteStalled),
    notices: &mut dyn Notices<LifecycleNotice>,
) -> Result<RemoveOutcome, NotRun>

Probe, guard, name the volumes, delete, remove the clone, in that order. guard_removal, unsaved_work_in and workspace_delete are pub(crate), and so are Guarded and Persistence; their unit tests run unchanged, because workspace_delete keeps the signature it had. git is not a parameter: inside core it is context.git().

DeleteOutcome is RemoveOutcome, with Refused(RemovalRefused) beside Deleted and DevpodRefused. One sum, not two: a refusal is an end of the removal, and having it beside the delete's answer is what let a caller hold the first and go on to the second.

Two deviations from the ticket's sketch, both about kill

The ticket writes the signature with insistence: Insistence and "the probe stays conditional on Insistence::NotInsisted". Applied literally that silently drops dl <ws> kill's report: kill is Insistence::Insisted, and Removal::Wedged's own doc says the guard still looks and reports rather than stopping, which dl/tests/lifecycle.rs::a_kill_names_the_work_it_is_about_to_destroy_and_destroys_it holds it to. The ticket's list also has no room for persistence or the stalled callback, which kill needs for devpod's --force and the deadline.

So the parameter is Removal, which is dl's own three-way value moved into core: Guarded, Insisted, Wedged. It is a deeper fold than one flag, not a wider one — Insistence, Persistence and the probe are all total functions of it now, so the four flags a caller used to assemble are down to one value that names a command line. Removal::insistence() stays public because a rendering of the answer turns on it.

The name was taken by repo_manager's tree-removal result, which is pub(crate), in no snapshot, and now called TreeSweep after the function that produces it (remove_tree_as_far_as_it_goes). RemoveOutcome still follows the module's <Verb>Outcome convention.

Two LifecycleNotice arms carry what dl used to print between the calls: Removing { workspace_id } and RemovingOverWork { refusal }. Their timing is the whole of their value — one is a warning before a wait rather than a receipt after it — so dl passes the streaming render::Saying sink instead of collecting into a vector. Same lines, same order, same words.

api, and it is a removal

api::workspace_delete is out and api::workspace_remove is in. #251 §7 calls dropping a promised function a breaking change; it is the right weight here, because keeping both leaves the unguarded delete promised, which is the finding. An unguarded delete is now unrepresentable rather than documented.

api also gained what a caller needs to call it and read its answer: DevpodHome, MetadataStorage, WorkspaceCloneManager, Records, DeleteStalled, Insistence, LifecycleNotice, Removal, RemovalRefused, RemoveOutcome. Same defect and same answer as #313's for Launch::new. Records and the two it holds are what #411 collapses into one parameter; the list is left explicit rather than bundled a second time, so that fold has something to build on.

DevpodHome is also #427's finding, arriving as a deletion: that PR re-typed workspace_delete's fifth parameter to Option<&DevpodHome> without re-exporting the type. The row it left uncallable is gone, and the type is in api for the call that replaced it.

Snapshots regenerated with the pinned generator, not hand-edited.

Both of the things the last rebase owed

Merged with main, and neither of these was a text merge.

One thing that is not a merge artefact: repo_manager's import list in
lifecycle.rs is the union of both sides, main's new FetchRepoError beside
this branch's TreeSweep, and one unit test #516 added follows DeleteOutcome to
its new name.

🤖 Generated with Claude Code

Summary by Sourcery

Replace the exported unguarded workspace delete with a single self-contained removal operation that enforces safety checks and cleanup for every removal mode.

New Features:

  • Expose a self-contained workspace_remove API that performs guarded, forced, and wedged workspace removals through a single removal mode.
  • Publish the removal-related types and volume-copy store required for external callers to invoke the new API and interpret its results.

Bug Fixes:

  • Prevent callers from deleting workspaces with unsaved changes without running the guard first.
  • Preserve volume discovery and kept-copy cleanup before workspace deletion removes devpod's records.

Enhancements:

  • Move removal orchestration and lifecycle notices into core while preserving the CLI's output and ordering.
  • Rename the tree-sweep result to TreeSweep and consolidate deletion results under RemoveOutcome.

Documentation:

  • Document the guarded workspace-removal API change and breaking removal of the unguarded workspace_delete surface in the changelog.

Tests:

  • Add API-level integration coverage for guarded refusals, forced removals without probing, volume ordering, and kept-copy cleanup.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @blooop, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 6 days and 2 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR makes workspace removal safe by construction: core now owns one guarded workspace_remove operation whose Removal mode controls probing, reporting, persistence, and deletion, while the public API drops the unguarded workspace_delete, the CLI delegates to the new flow, notices remain streamed in order, and integration tests validate safety and volume-cleanup ordering.

Sequence diagram for guarded workspace removal

sequenceDiagram
    participant Caller
    participant Core as workspace_remove
    participant Git
    participant Guard
    participant Devpod
    participant Clone

    Caller->>Core: workspace_remove(..., removal, ...)
    alt Removal::Insisted
        Core->>Devpod: workspace_delete
    else Removal::Guarded or Removal::Wedged
        Core->>Git: unsaved_work_in
        Git-->>Core: unsaved work finding
        Core->>Guard: guard_removal
        alt Removal::Guarded and work would be lost
            Guard-->>Core: RemoveOutcome::Refused
            Core-->>Caller: Refused(RemovalRefused)
        else Removal::Wedged and work would be lost
            Core-->>Caller: RemovingOverWork notice
            Core->>Devpod: workspace_delete
        else No refusal
            Core->>Devpod: workspace_delete
        end
    end
    Core->>Devpod: name volumes
    Core->>Devpod: devpod delete
    Devpod-->>Core: delete result
    Core->>Clone: remove clone
    Core-->>Caller: RemoveOutcome
Loading

File-Level Changes

Change Details Files
Fold the unsaved-work probe, refusal handling, volume discovery, devpod deletion, and clone cleanup into a single removal operation with command-specific behavior encoded by one enum.
  • Add Removal::{Guarded, Insisted, Wedged} and derive probe, insistence, and persistence behavior from it.
  • Run the probe conditionally, refuse guarded removals before devpod, report-but-continue for kill, and preserve volume naming before deletion.
  • Combine guard and delete results into RemoveOutcome, including Refused(RemovalRefused).
  • Make the raw guard and delete helpers crate-private while exposing only the self-sufficient lifecycle operation.
rust/devlaunch-core/src/flows/lifecycle.rs
Replace the public unguarded deletion API with a callable, self-sufficient removal API and export all required argument and result types.
  • Remove api::workspace_delete and expose workspace_remove instead.
  • Re-export DevpodHome, storage, clone, record, callback, removal, notice, and outcome types needed by API consumers.
  • Regenerate the public API snapshots.
rust/devlaunch-core/src/lib.rs
rust/devlaunch-core/public-api.api.txt
rust/devlaunch-core/public-api.rest.txt
Move removal orchestration and streaming output from the CLI into core while preserving command behavior and notice ordering.
  • Delegate rm, rm --force, and kill to workspace_remove.
  • Stream Removing and RemovingOverWork lifecycle notices through the existing rendering sink.
  • Keep CLI-specific rendering, exit handling, and sweep-state reporting in dl.
rust/dl/src/commands.rs
rust/dl/src/render.rs
Add integration coverage proving the public API cannot bypass safety checks and preserves destructive-operation ordering.
  • Exercise guarded removal against a real git clone and verify the refusal contains losses and never invokes devpod.
  • Verify insisted removal skips probing and deletes the clone.
  • Verify volume names are collected before devpod removes its workspace record.
rust/devlaunch-core/tests/api_removal_is_self_sufficient.rs
Resolve the Removal name collision introduced by moving the lifecycle command type into core.
  • Rename the repository tree cleanup result to TreeSweep and update its consumers and tests.
rust/devlaunch-core/src/flows/repo_manager.rs
rust/devlaunch-core/src/flows/lifecycle.rs
Document the breaking API replacement and the guarantee that removal always includes the guard.
  • Describe the unified removal flow, outcome changes, and command-mode semantics in the changelog.
CHANGELOG.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

`devlaunch_core::api` promised `workspace_delete`, which is the delete
without the unsaved-work guard. The probe and the guard that make it safe
were two further exported functions a caller had to run first, in the right
order, and the sequence lived in the `dl` binary: nothing outside `dl` could
reuse it and nothing held `dl` to it. A consumer following the promise
exactly deletes a clone holding the only copy of somebody's work.

`lifecycle::workspace_remove` is the one exported removal now -- probe,
guard, name the volumes, delete, remove the clone. `guard_removal`,
`unsaved_work_in`, `workspace_delete`, `Guarded` and `Persistence` are
`pub(crate)`; `workspace_delete` keeps its signature, so its unit tests run
unchanged. `DeleteOutcome` is `RemoveOutcome` with a `Refused` arm.

Which of the three removals is being asked for is one `Removal` value moved
out of `dl` rather than four flags a caller assembles: `Insistence`,
`Persistence` and the probe are total functions of it. That is what keeps
`kill`'s report of the work it destroys, which the ticket's `insistence`-only
sketch would have dropped. `repo_manager`'s `pub(crate)` tree-removal result
is `TreeSweep`, after the function that produces it.

Two `LifecycleNotice` arms carry what `dl` printed between the calls, and
`dl` passes the streaming sink so the lines keep their order and timing.

`api::workspace_delete` is out and `api::workspace_remove` is in, with the
parameter and answer types a caller needs to reach either.

Closes #410.
@blooop
blooop force-pushed the wayfinder/devlaunch-410 branch from cd6c3fe to 387c0f8 Compare August 29, 2026 19:40
Base automatically changed from wayfinder/devlaunch-340 to main August 29, 2026 20:21
blooop added 2 commits August 29, 2026 21:31
`workspace_remove` now takes the volume-copy store #516 added to the delete
and passes it straight through, so the fold keeps the reclamation rather
than dropping it: a removal that came back removed still forgets the copy
that named the volumes docker has just taken away. `api` re-exports
`KeptCopies` with it, because a parameter type outside the promise is a
promised call nobody outside can make.

Also: the repo_manager import list is the union of both sides, `main`'s new
`FetchRepoError` beside this branch's `TreeSweep`; one unit test from #516
follows `DeleteOutcome` to its new name; and the CHANGELOG entry sits under
the current `[Unreleased]`, not inside the 0.25.0 section the release cut
renamed underneath it.
@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.81%. Comparing base (adab656) to head (2b51e69).

Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 96.11% <100.00%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Components Coverage Δ
shipped code (rust) 96.11% <100.00%> (+0.03%) ⬆️
harness and tooling (python) 42.98% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

No nightly toolchain on this host, so the generator could not be run here:
CI's public-api job regenerates and diffs, and these are the two rows it
named.

`api::LifecycleNotice::VolumesNotRemoved::occasion` is a merge casualty
rather than a stale file. #516 added the field while this branch was moving
the whole notice vocabulary from the rest file into the promise, so the row
was added to a block that no longer existed and lost on the way across.

The second is the re-export's own cost: promising `KeptCopies` makes the
generator render its inherent and derived impls a second time, at the
canonical path, beside the other promised types' impls.
@blooop
blooop merged commit b6da15d into main Aug 29, 2026
15 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-410 branch August 29, 2026 20:45
blooop added a commit that referenced this pull request Aug 29, 2026
A diff in public-api.api.txt is a change to the tier an external consumer may
depend on, so this is the one to read rather than skim. `RemovalRefused`
stops being a two-arm enum and becomes a struct carrying the whole standing,
which is #446's decision reaching the guard: a clone can hold work *and* have
a question that could not be put, and a refusal that named one arm would be
telling half the truth.

It reaches the promised tier only because main promoted `RemovalRefused` into
`api` in #520 while this branch was in flight. The consequence is that
`agent_worktrees::Standing` is now named at that tier too, through the
`standing` field, and its readers -- would_lose, could_not_tell, describe --
are what a consumer reads the refusal with.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant