Skip to content

devpod's on-disk layout gets the module it was missing - #427

Merged
blooop merged 3 commits into
mainfrom
refactor/devpod-home-adapter
Aug 25, 2026
Merged

devpod's on-disk layout gets the module it was missing#427
blooop merged 3 commits into
mainfrom
refactor/devpod-home-adapter

Conversation

@blooop

@blooop blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes #394. Map: #406. Unblocks #314flows/lifecycle.rs's :2765-2960 group is not a lifecycle submodule at all, so the split has one less section to place, and the pub(crate) mod tests leak that ticket's re-measurement named is gone.

The red

rust/devlaunch-core/tests/devpod_layout.rs walks the crate's sources and fails if any file but clients/devpod_home.rs holds a string literal starting contexts. Before the adapter existed it named exactly the eight sites the ticket lists:

thread 'only_the_devpod_home_adapter_spells_devpods_on_disk_layout' panicked at
devlaunch-core/tests/devpod_layout.rs:87:5:
devpod's on-disk layout is spelled outside clients/devpod_home.rs:
  src/flows/launch.rs:3153
  src/flows/lifecycle.rs:2779
  src/flows/lifecycle.rs:2799
  src/flows/lifecycle.rs:2859
  src/flows/lifecycle.rs:3159
  src/flows/lifecycle.rs:3253
  src/flows/provision/verdict_cache.rs:322
  src/flows/provision.rs:4767

thread 'the_devpod_home_adapter_does_spell_it' panicked at ...:103:5:
no module spells devpod's on-disk layout, so this guard is guarding nothing

A guard rather than a behaviour test, because the failure a second copy of the layout causes is not a wrong answer — it is a copy that agrees with the first until devpod moves it. The second test is the other half, so the guard cannot pass by the layout having been deleted. Every behaviour test that exercised these functions still runs; they moved with the code and now go through the new interface.

What moved

Into rust/devlaunch-core/src/clients/devpod_home.rs, whole: devpod_home, the record and result path builders, CreateRecord/create_record, the contexts walk, sole_workspace_result, repoint_devpod_source and RepointFailure. Plus the config.yaml join that Host::devpod_config was doing onto the same bare path, which the ticket did not list — the layout fact moved, not the function: Host::devpod_config is still at flows/launch.rs:230 and now delegates to DevpodHome::config.

Decisions, and what was rejected:

  • The home is taken, not resolved. DevpodHome::at takes the directory; the binary resolves once, per the convention dl/src/commands.rs:718 already states. DevpodHome::locate is the one environment read, split into a thin wrapper over a pure located(configured, home_dir) — the shape osext::home_dir uses. osext stays pub(crate). The old test could only assert this machine has a home directory; the pure half now pins that an empty DEVPOD_HOME falls through to ~/.devpod rather than naming the current directory.
  • repoint takes (context, workspace_id, source), not &Adoptable. Rejected the direct move: an adapter under clients/ naming a reconcile-flow type is a seam pointing the wrong way. Its doc — devpod v0.26.1 has no subcommand that changes an existing workspace's source, so the choice is one field of one JSON file or no repair — carries across, and is what makes writing into devpod's own file an adapter's job.
  • system_words moves down to osext, for the same reason. repoint builds its refusals out of it, and the first cut left it in flows::repo_manager — so the adapter carried use crate::flows::repo_manager::system_words, the only use crate::flows:: under src/clients/, which is the module-level version of the objection the bullet above makes about the type. osext is the process boundary ported to Python's os: env_str is os.environ.get, home_dir is posixpath.expanduser("~"), and system_words is OSError.strerror, which exists because std::io::Error's Display appends " (os error {errno})" and Python's does not. It is a leaf that depends on nothing, so both repo_manager and devpod_home may name it.
  • create_record and sole_workspace_result stay free functions taking Option<&DevpodHome>. Rejected making them methods: "this machine has no devpod home" is one of the answers each gives, and methods would push that decision to five call sites that do not all read it the same way (one reads the absence as != NeverCompleted).
  • The pass-through signatures take the type. workspace_delete, purge_all_data and apply_reconciliation took a &Path named devpod_home; a bare path is what anybody can join, which is how there came to be eight copies.

pub(crate) mod tests is gone

flows::lifecycle's mod tests is private again. It was pub(crate) solely so flows/provision.rs:4746 and flows/provision/verdict_cache.rs:313 could import tests::devpod_home_with. The fixture is now a plain #[cfg(test)] item at module scope in the adapter — deliberately not inside the adapter's own mod tests, which would have moved the leak rather than removed it. Checked that nothing outside imported anything else from lifecycle::tests; repo_manager's pub(crate) mod tests is untouched and pre-existing.

Public surface

Both snapshots are hand-edited — cargo-public-api needs a nightly toolchain this container cannot install, so CI's regeneration is authoritative over these rows.

  • public-api.rest.txt: flows::lifecycle::devpod_home removed; flows::lifecycle::RepointFailure and its impls move to clients::devpod_home; new clients::devpod_home module with DevpodHome (at, locate, path) and its derives; purge_all_data, apply_reconciliation, workspace_delete and VerdictCache::under re-typed.
  • public-api.api.txt: one row. api::workspace_delete's fifth parameter is Option<&DevpodHome> where it was Option<&Path>. This is a change to the promised tier and should be read as one; the argument for paying it is that the parameter was always devpod's home and now says so.
  • api does not re-export DevpodHome, and that is left as it is. So api::workspace_delete is no longer callable from api alone — before, the parameter was a std type any caller could name. That runs against what Make api::Launch self-sufficient and type ColdRefused #313 settled for Launch, and takes the count api::workspace_delete is the delete without the unsaved-work guard #395 measured on this function from 5 of 8 to 6 of 8. The alternative is a pub use on the frozen tier, and it is not taken here because Fold the removal guard into one workspace_remove and swap the api row #410 deletes api::workspace_delete outright in favour of workspace_remove: the row is due to be removed rather than fixed, and promoting a type onto the promise file only to remove it again is churn on the one file whose diffs are meant to mean something. Fold the removal guard into one workspace_remove and swap the api row #410 carries the constraint: whatever replaces this function must have every parameter type nameable from api.
  • DevpodHome::at and ::path are pub with no consumer outside the crate, which lib.rs:37 would ordinarily keep pub(crate). Left pub deliberately, because narrowing them decides the question above by default — locate() is no substitute for at() for a caller holding a directory — and in the direction Fold the removal guard into one workspace_remove and swap the api row #410 may want reversed.

Gate

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings, cargo fmt --check all clean locally, plus pixi run prek and pixi run test (410 passed). aid/tests/interactive.rs (#401) did not flake on these runs; dl/tests/picker.rs (#416) did once and passed on rerun.

Local green is not the whole gate. At 5b52fa1 CI's public-api job was red — the hand-edited public-api.rest.txt had the DevpodHome block before RepointFailure where the generator puts it after — and gate was red behind it. f5a2777 reorders that block. tests/public_api_snapshots.rs passes either way: it checks which tier a row belongs to, not row order, so a misordered hand-edit dies in CI and not in the local suite. That gap is worth its own issue, and it is why the note above about CI being authoritative is load-bearing rather than boilerplate.

Concurrency

Branched from origin/main. #410 folds the removal guard around flows/lifecycle.rs:806-1175; this works :2765-2960 and its callers. The regions do overlap: git diff 57955a3...HEAD -U0 -- rust/devlaunch-core/src/flows/lifecycle.rs reports hunks at old lines 977 (workspace_delete's devpod_home parameter) and 1095 (devcontainer_volumes), both inside 806-1175. The conflict is two parameter types and the rebase is trivial, but a #410 author should expect to resolve it rather than expecting a clean merge.

🤖 Generated with Claude Code

Summary by Sourcery

Centralize devpod filesystem access behind a typed adapter and update callers and tests to use it.

New Features:

  • Add a dedicated DevpodHome adapter that centralizes devpod’s on-disk layout, home discovery, record lookup, result tracking, and source repointing.

Bug Fixes:

  • Prevent duplicated or inconsistent devpod layout knowledge by adding a source-level guard that detects layout spellings outside the adapter.
  • Ensure empty DEVPOD_HOME values fall back to the user’s .devpod directory rather than the current directory.

Enhancements:

  • Pass the typed DevpodHome through launch, lifecycle, reconciliation, purge, and verdict-cache APIs instead of bare paths.
  • Move system error formatting into the platform extension module and make lifecycle tests private again by relocating shared fixtures to the adapter.

Documentation:

  • Document the new devpod home adapter and its ownership of devpod filesystem state in the changelog and crate documentation.

Tests:

  • Add coverage for home discovery, ambiguous and missing records, non-UTF-8 contexts, record repair failures, and enforcement of the single-layout-module boundary.

Chores:

  • Regenerate the public API snapshots for the new module and updated parameter types.

`clients::devpod` is the seam for devpod-the-command. devpod-the-filesystem had
no seam at all, so `<devpod home>/contexts/<ctx>/workspaces/<id>/…` was rebuilt
wherever it was wanted: three times in `flows/lifecycle.rs`, once in
`flows/provision/verdict_cache.rs`, and four more in test fixtures that
reproduced the convention literally. The comment at lifecycle.rs:2861 claimed
the paths went through shared helpers "so devpod's on-disk layout is spelled out
in one place and not three". It was spelled in four, and the test fixtures made
it eight.

`devlaunch-core/tests/devpod_layout.rs` is what makes the claim checkable rather
than aspirational: it walks the crate's sources and fails if any file but
`clients/devpod_home.rs` contains a string literal starting `contexts`. Before
this change it named all eight, which is the honest red — the failure a layout
copy causes is not a wrong answer but a second copy that agrees with the first
until devpod moves it, and nothing but a guard catches that. A second test
asserts the adapter *does* spell it, so the guard cannot pass by the layout
having been deleted. Prose is deliberately not matched (a doc comment costs
nothing when devpod moves the layout), nor is devpod `config.yaml`'s top-level
`contexts:` key, which is a different fact about a different file. `dl`'s
end-to-end tests are out of scope on purpose: they stand outside the crate and
check that what dl wrote landed where devpod will look for it, which is exactly
the assertion that must not be routed through the code under test.

Moved into `clients::devpod_home`, whole: `devpod_home`, the record and result
path builders, `CreateRecord`/`create_record`, the contexts walk,
`sole_workspace_result`, `repoint_devpod_source` and `RepointFailure`. Plus
`Host::devpod_config`, which was joining `config.yaml` onto the same bare path.

Four decisions worth the words:

- **The home is taken, not resolved.** `DevpodHome::at` takes the directory and
  the binary resolves it once, which is the convention `dl/src/commands.rs`
  already states at its `workspace_delete` call. `DevpodHome::locate` is the one
  place that reads `DEVPOD_HOME`, and it splits into a thin environment read over
  a pure `located(configured, home_dir)`, the shape `osext::home_dir` uses. The
  old test could only assert that this machine has a home directory; the pure
  half pins that an empty `DEVPOD_HOME` falls through to `~/.devpod` rather than
  naming the current directory, without mutating an environment the whole test
  binary shares.
- **`repoint` takes `(context, workspace_id, source)`, not `&Adoptable`.** An
  adapter under `clients/` that named a reconcile-flow type would be a seam
  pointing the wrong way. Its doc — devpod v0.26.1 has no subcommand that changes
  an existing workspace's source, so the choice is one field of one JSON file or
  no repair at all — carries across, and is what makes writing into devpod's own
  file an adapter's job rather than a leak.
- **`create_record` and `sole_workspace_result` stay free functions taking
  `Option<&DevpodHome>`, rather than becoming methods.** "This machine has no
  devpod home" is one of the answers each gives, and absorbing it in the module
  keeps five call sites from each deciding again what a missing home means —
  they would not all decide it the same way, and one of them reads the absence
  as `!= NeverCompleted`.
- **The pass-through signatures take the type.** `workspace_delete`,
  `purge_all_data` and `apply_reconciliation` took a `&Path` named
  `devpod_home`; a bare path is what anybody can join, which is how there came
  to be eight copies. This changes `api::workspace_delete`'s promised signature
  — the one row of `public-api.api.txt` this touches — and the argument for
  paying that is the same one: the parameter was always devpod's home and now
  says so.

The win this protects: `flows::lifecycle`'s `mod tests` is private again. It was
`pub(crate)` solely so `flows/provision.rs` and `flows/provision/verdict_cache.rs`
could import `tests::devpod_home_with` — a test fixture in the crate's internal
surface because a devpod-home module did not exist. The fixture now sits beside
the layout it builds as a plain `#[cfg(test)]` item at module scope, which is
what keeps it shareable without a second `pub(crate) mod tests` in its new home.
Nothing outside imported anything else from there.

Both snapshots are hand-edited: `cargo-public-api` needs a nightly toolchain this
container cannot install, so CI's regeneration is authoritative over these rows.

@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 have reached your weekly rate limit of 250000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 24, 2026

Copy link
Copy Markdown

Reviewer's Guide

Centralizes devpod's filesystem layout and record manipulation in a new clients::devpod_home::DevpodHome adapter, then threads that type through command and core flows, relocates shared behavior and fixtures, adds source-level duplication guards, and updates API documentation and snapshots.

Sequence diagram for reconciliation source repointing

sequenceDiagram
    participant Command as Reconcile command
    participant Lifecycle as apply_reconciliation
    participant Home as DevpodHome
    participant Record as workspace.json

    Command->>Lifecycle: apply_reconciliation(..., devpod_home, plan)
    Lifecycle->>Home: repoint(context, workspace_id, source)
    Home->>Record: read_to_string(path)
    Home->>Record: update source.localFolder
    Home->>Record: write temporary JSON
    Home->>Record: rename temporary file over original
    Home-->>Lifecycle: Result<(), RepointFailure>
    Lifecycle-->>Command: ReconcileReport
Loading

File-Level Changes

Change Details Files
Introduces a dedicated typed adapter for devpod's on-disk state and consolidates all layout-dependent behavior into it.
  • Adds DevpodHome construction, environment-based discovery, path builders, context lookup, create-result inspection, config-path handling, and source-record repointing.
  • Moves CreateRecord, RepointFailure, and related operations out of lifecycle code.
  • Uses atomic temporary-file replacement while preserving unrelated devpod JSON fields during reconciliation.
rust/devlaunch-core/src/clients/devpod_home.rs
rust/devlaunch-core/src/clients/mod.rs
rust/devlaunch-core/src/clients/devpod.rs
rust/devlaunch-core/src/flows/lifecycle.rs
Propagates DevpodHome through launch, lifecycle, reconciliation, deletion, purge, and verdict-cache flows instead of passing untyped paths.
  • Resolves the devpod home at command boundaries and passes the typed value through core APIs.
  • Re-types workspace deletion, purge, reconciliation, verdict-cache construction, and host configuration accessors.
  • Updates rendering and command-layer references to moved errors and helper functions.
rust/devlaunch-core/src/flows/launch.rs
rust/devlaunch-core/src/flows/lifecycle.rs
rust/devlaunch-core/src/flows/provision.rs
rust/devlaunch-core/src/flows/provision/verdict_cache.rs
rust/dl/src/commands.rs
rust/dl/src/launch.rs
rust/dl/src/render.rs
rust/dl/tests/launch.rs
Adds regression protection against reintroducing duplicate devpod layout knowledge and removes the lifecycle test-module leak.
  • Scans crate source files for layout string literals and requires them to occur only in the adapter.
  • Adds the complementary assertion that the adapter still spells the layout.
  • Moves shared fixtures beside the adapter and restores flows::lifecycle::tests to private visibility.
rust/devlaunch-core/tests/devpod_layout.rs
rust/devlaunch-core/src/flows/lifecycle.rs
rust/devlaunch-core/src/flows/provision.rs
rust/devlaunch-core/src/flows/provision/verdict_cache.rs
Updates the documented and frozen API surfaces to reflect the new module and typed parameters.
  • Regenerates hand-edited public API snapshots for moved symbols and changed signatures.
  • Documents the new filesystem adapter and the removal of the lifecycle home helper and test surface.
rust/devlaunch-core/public-api.api.txt
rust/devlaunch-core/public-api.rest.txt
CHANGELOG.md

Assessment against linked issues

Issue Objective Addressed Explanation
#394 Introduce a dedicated DevpodHome adapter under clients that owns devpod's on-disk layout, including workspace/result path construction, context walking, home resolution, and repointing workspace sources.
#394 Remove duplicated devpod filesystem layout knowledge from lifecycle, provisioning, launch, and test code, routing those operations through DevpodHome instead.
#394 Eliminate the lifecycle test-module surface leak by moving the shared devpod-home fixture into the new adapter module, restore lifecycle::tests to private visibility, and add protection against future layout duplication.

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

@blooop

blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

The api.txt row makes the promised function uncallable from api alone.

api::workspace_delete's fifth parameter moves from Option<&std::path::Path> to Option<&devlaunch_core::clients::devpod_home::DevpodHome>, and src/lib.rs is not in this diff — so mod api does not re-export DevpodHome. Before, that parameter was a std type any caller could name. After, a wf importing only from api — which the crate docs say is the whole of what it may depend on — cannot construct an argument for it.

That is a breaking change to the frozen tier, and it is a different break from the one the row shows: not "the signature moved" but "the promise stopped being self-sufficient". It runs against what #313 settled for Launch — that api re-exports every parameter type — which #395 noted workspace_delete already half-fails today, at 5 of 8. This takes it to 6.

Two ways out, and the second is probably right:

  1. Re-export DevpodHome from api here.
  2. Leave it, because #410 removes api::workspace_delete outright and replaces it with workspace_remove — at which point this row is deleted rather than fixed, and re-exporting a type onto the frozen tier only to remove it again is churn on the one file whose diffs are meant to mean something.

If (2), this PR should say so in its body and #410 should carry the constraint explicitly. Flagging rather than deciding: the ordering between this and #410 is the reviewer's call, and both are open at once.

Separately, worth a reviewer's eye: this is a hand-edited api.txt, and the promise file is the one place a wrong hand-edit is least visible. CI's regeneration is authoritative.

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.60440% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.21%. Comparing base (095429d) to head (cd103e4).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/clients/devpod_home.rs 94.05% 11 Missing ⚠️
rust/devlaunch-core/src/osext.rs 88.88% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 95.57% <95.60%> (-0.01%) ⬇️

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

Components Coverage Δ
shipped code (rust) 95.57% <95.60%> (-0.01%) ⬇️
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.

@blooop blooop left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was generated by AI during review.

Reviewed at head 5b52fa1, merge-base 57955a3, in a fresh context that did not write the code. Two axes below, not merged or reranked. Everything marked [ran] was executed in a scratch worktree; [read] was reasoned from source or CI logs.


Standards

1. A client names a flow, and lib.rs says it must not — BLOCKING [ran]

src/lib.rs:15 states the crate's layering rule in as many words:

a flow or a domain type may name a tool client (workspace_state reads clients::git); a client never names a flow.

src/clients/devpod_home.rs:36 is use crate::flows::repo_manager::system_words;. It is the only use crate::flows:: anywhere under src/clients/git grep -rn "use crate::flows" rust/devlaunch-core/src/clients/ returns exactly that one line, and none existed at the merge-base. The move created it: system_words was in-layer while repoint_devpod_source lived in flows/lifecycle.rs, and became an upward dependency the moment the function crossed into clients/.

Sharper still, system_words now has no other consumer outside its own module: flows/lifecycle.rs dropped it from its import list in this very diff, so a flows:: helper's only external caller is a clients:: module. It is std::io::Error prose-trimming — nothing about it is a flow — so osext or domain is where it belongs.

This is the finding worth the most, because it is exactly the shape of defect #394 was filed to remove, introduced by the fix. It is also inconsistent with the PR's own stated reasoning: the body rejects repoint(&Adoptable) on the grounds that "an adapter under clients/ naming a reconcile-flow type is a seam pointing the wrong way", and then names a reconcile-flow module instead.

2. DevpodHome::at and ::path are pub with no consumer outside the crate [ran]

src/lib.rs:36-37: "everything the binaries do not need stays pub(crate)."

Every DevpodHome use in dl/ and aid/ is DevpodHome::locate()dl/src/commands.rs:723,798,1017 and dl/src/launch.rs:144. Neither binary calls at() or path(); every caller of both is inside devlaunch-core, and of path() specifically, every caller is a test (verdict_cache.rs:354,382,423,434,445,464,480,507,564,578, provision.rs:4759). So two rows land on public-api.rest.txt — the file docs/development.md says to read "for the accidental pub" — that the stated rule would keep crate-private.

path() is the more interesting one: it hands out the raw root, which is precisely the bare path the module exists to stop anyone joining onto. The guard test only catches a .join("contexts") on it inside devlaunch-core/src.

Not a flat "make them pub(crate)", because it collides with §3 of the Spec axis: if api::workspace_delete is meant to stay callable from api alone, a consumer needs a constructor. Which way this goes depends on #410. But it should be a decision on the record, not a default.

Twelve test sites also read DevpodHome::at(home.path()) where home is a ScratchHome that already derefs to a DevpodHome — a round trip through a path to rebuild a value already in hand. Some((*home).clone()) says it directly.

3. The guard test is sound, and narrower than its own doc implies [ran]

Verified non-vacuous the only way that settles it — I copied tests/devpod_layout.rs onto merge-base 57955a3 and ran it:

test the_devpod_home_adapter_does_spell_it ... FAILED
test only_the_devpod_home_adapter_spells_devpods_on_disk_layout ... FAILED

---- the_devpod_home_adapter_does_spell_it stdout ----
no module spells devpod's on-disk layout, so this guard is guarding nothing

---- only_the_devpod_home_adapter_spells_devpods_on_disk_layout stdout ----
devpod's on-disk layout is spelled outside clients/devpod_home.rs:
  src/flows/launch.rs:3153
  src/flows/lifecycle.rs:2779
  src/flows/lifecycle.rs:2799
  src/flows/lifecycle.rs:2859
  src/flows/lifecycle.rs:3159
  src/flows/lifecycle.rs:3253
  src/flows/provision/verdict_cache.rs:322
  src/flows/provision.rs:4767

test result: FAILED. 0 passed; 2 failed

Byte-identical to the PR body's claim, on all eight sites. On the branch both pass, and cargo test -p devlaunch-core --lib is 1253 passed, 0 failed. The red is real and the second test does close the delete-it-all hole. Credit where it is due: flows/launch.rs:4192's "contexts: {}\n" — devpod's config.yaml key — is a genuine near-miss the two patterns correctly exclude.

Three holes, none fatal, all worth knowing:

  • Only the contexts head is guarded. "workspaces", "workspace.json" and "workspace_result.json" are not, so a module holding a record path and joining parent().join("workspace_result.json") passes. Narrow, since you cannot reach a record without going through contexts — but the filename half of the layout is unprotected.
  • Scope is devlaunch-core/src only. The module doc argues for excluding dl's end-to-end tests, which is right; it does not argue for excluding dl/src, which imports DevpodHome and can reach path(). Nothing there spells it today.
  • Non-vacuity rests on one line. Exactly one line in the adapter (devpod_home.rs:94) satisfies the_devpod_home_adapter_does_spell_it, and the check is line.contains("\"contexts\"") with no code/comment distinction — so a future doc comment quoting the literal would let the layout be deleted with the guard still green.

4. repoint's hazards are inherited, not introduced [read]

Diffed the moved body against 57955a3:src/flows/lifecycle.rs. source.display().to_string() (lossy for a non-UTF-8 clone path), the fixed path.with_extension("dl-tmp") (two concurrent repoints of one record race for the same temp name), and the un-fsync'd rename all moved byte-for-byte. Nothing introduced here. Recording them so the next reader does not re-derive them, not charging them to this PR.

DevpodHome::locate() / located() is behaviour-identical to the removed lifecycle::devpod_home(), and the pure split buys a real assertion the old test could not make (empty DEVPOD_HOME~/.devpod, not the cwd). Clean.

5. Smaller things [read]

  • src/lib.rs:8 still lists the tool clients as "devpod, git, gh, ssh". A fifth was just added; lib.rs is untouched by the diff.
  • Two new rustdoc warnings in CI's public-api log, both from the new module's docs linking pub(crate) items from a pub mod: devpod_home.rs:20 (DevpodHome::repoint) and devpod_home.rs:59 (Self::located). Warnings, not errors, among 113 pre-existing — but these two are new.
  • #[cfg(test)] pub(crate) fn devpod_home_with at module scope is not the old leak relocated: it compiles into no shipped build and appears in no snapshot, where pub(crate) mod tests put a whole test module into the crate's internal surface. Genuine improvement. ScratchHome's Deref is a test-only RAII wrapper, not inheritance-by-deref.
  • The module has real depth — the contexts walk with its four ambiguities, the tri-state CreateRecord, the atomic rewrite. It is an adapter, not a path-joiner.

Spec

Spec is #394, on map #406.

Satisfied, literally, both halves of the ticket's stated red [ran]

"The red is the duplication itself: after the move, grep -rn '"contexts"' rust/devlaunch-core/src should match one module. And pub(crate) mod tests in lifecycle.rs should go back to mod tests, with nothing outside importing from it."

  • One module: git grep returns exactly one hit, src/clients/devpod_home.rs:94.
  • lifecycle.rs:2867 is mod tests. No lifecycle::tests import survives anywhere — the only surviving mention is a doc comment at devpod_home.rs:321. repo_manager.rs:1717's pub(crate) mod tests is pre-existing and its file has an empty diff, as the PR body says.
  • The eight sites replay exactly at merge-base (see the guard output above).

The extraction landed the win the ticket was filed for, and the fixture did not move somewhere equally leaky.

6. public-api.rest.txt is wrong, and CI already says so — BLOCKING [ran/read]

The public-api job is FAILURE on this head (run 32762105921, job 97543106418), and gate fails behind it with results: success success success success failure success failure.

The error is ordering, and only ordering. CI's regeneration moves the thirteen DevpodHome rows from rest.txt:151-163 (before RepointFailure) to after rest.txt:183, immediately preceding pub mod devlaunch_core::clients::gh. The convention is visible one module up: clients::devpod lists its six enums alphabetically and then the Workspace struct, and flows::lifecycle does the same. Enums before structs. RepointFailure is an enum; DevpodHome is a struct.

Two things make this worth stating precisely rather than "regenerate the snapshots":

  • public-api.api.txt's single hand-edited row is correct. CI's diff touches only rest.txt. The re-typed workspace_delete row renders exactly as the code declares, and the impl core::convert::Into<std::path::PathBuf> rendering on at() matches the four neighbouring impl Trait params in the file. So is every other hand-edited row — Adoption::Refused::failure, apply_reconciliation, purge_all_data, VerdictCache::under, the devpod_home() removal, every RepointFailure row. One block, in the wrong place, is the whole of it.
  • The in-repo guard did not catch it, and docs/development.md says it should have. That file claims tests/public_api_snapshots.rs holds the files "to the split itself … so a hand-edited snapshot fails in the Rust suite rather than in review." I ran it on this head: 4 passed; 0 failed. It checks which tier each row belongs to, not row order, so a hand-edit that misorders rows sails through the local suite and dies in CI. That claim is falsified by this PR and is worth its own issue.

7. api::workspace_delete stops being callable from api alone — the existing comment holds [ran]

Verified independently, and the standing finding is right. src/lib.rs:125-146 is pub mod api; it re-exports workspace_delete and does not re-export DevpodHome, and lib.rs is not in this diff. The fifth parameter's type has no path through api. Before, it was Option<&std::path::Path> — nameable by anyone. Nothing else in the diff compensates: no pub use, no re-export, no alias.

That is against what #313 settled for Launch and takes the count #395 measured from 5 of 8 to 6 of 8. The finding is correct as posted. I am not overriding its own conclusion — that whether to re-export or to let #410 delete the row is the reviewer's call, and both are open — but the PR body should carry that decision explicitly, because as written it reads as though the re-typing is the whole of the cost.

8. "The regions do not overlap" is false [ran]

"#410 folds the removal guard around flows/lifecycle.rs:806-1175; this works :2765-2960 and its callers, so a rebase is expected but the regions do not overlap."

git diff 57955a3...HEAD -U0 -- .../lifecycle.rs reports hunks at old lines 977 (workspace_delete's devpod_home parameter) and 1095 (devcontainer_volumes). Both sit inside 806-1175. The rebase is trivial — two parameter types — but the claim as written is wrong, and it is the sentence a #410 author would read before deciding how carefully to merge.

9. Two smaller overstatements in the body [ran]

  • Under "What moved … whole": "Plus Host::devpod_config, which was joining config.yaml onto the same bare path". Host::devpod_config did not move — it is still at flows/launch.rs:230, now delegating to DevpodHome::config. The layout fact moved; the function did not.
  • The Gate section lists cargo test --workspace, clippy, fmt, prek and pixi run test "all clean" and does not mention that public-api is red. The PR does pre-warn that the snapshots are hand-edited and CI is authoritative — but a reader of the Gate section alone would conclude the run was green.

10. Unargued shape deviations from the ticket [read] — suspicion, not a defect

#394 proposed .workspace(id) / .result(id) / .contexts(). What was built is record(context, id) / result(context, id) — an extra parameter and a rename — with contexts() private. The PR body argues repoint's arity, locate's environment read, and the two free functions, at length and convincingly. It does not argue these. They are defensible (ids are unique per context, so id alone cannot address a record), just not on the record in a PR that put everything else on it.

Nothing in the diff is outside #394's scope.


Verdict

Request changes. (Posted as a comment: GitHub refuses --request-changes on a PR from the same account. The written verdict is the one that counts.)

The refactor is good and the ticket is genuinely discharged — eight spellings to one, the pub(crate) mod tests leak gone with nothing importing from it, a guard that I confirmed goes red on the merge-base with byte-identical output to the claim, and 1253 lib tests green. Two blocking findings and one open decision:

  1. clients/devpod_home.rs:36 imports crate::flows::repo_manager::system_words, against lib.rs:15's "a client never names a flow" — the only such import in the crate, introduced by this PR, in the module whose whole point is a correctly-pointed seam. Move system_words down a layer.
  2. public-api.rest.txt block ordering. Move rest.txt:151-163 to after :183. api.txt is correct as hand-edited; nothing else in either snapshot is wrong. Re-run public-api and gate.
  3. api::workspace_delete's parameter type is unnameable from api. Not blocking on its own — but the body should record which way it went and why, and #410 should carry the constraint if the answer is "let #410 delete the row."

Non-blocking, in order: at()/path() are pub with no consumer outside the crate; the guard covers only the contexts head and only devlaunch-core/src; lib.rs:8's client roster is stale; two new private-intra-doc-link warnings; the "regions do not overlap" and "Host::devpod_config moved" sentences are wrong; the Gate section omits a red job.

Worth its own issue regardless of this PR: tests/public_api_snapshots.rs passed on a snapshot CI rejects, which contradicts docs/development.md's claim that a hand-edited snapshot "fails in the Rust suite rather than in review". Ordering is not checked. Since cargo-public-api needs a nightly this container cannot install, that local guard is the only thing standing between a hand-edit and CI.

Could not verify: the snapshots themselves cannot be regenerated here (nightly + pinned cargo-public-api, neither installable in this devcontainer), so every snapshot statement above is read against CI's regeneration diff and the file's own neighbouring conventions rather than against a fresh generation. CI's public-api job remains authoritative. I did not run the dl/aid integration suites; CI's rust and e2e jobs are green on this head and the known flakes (#401/#416) did not need to be ruled out.

`clients/devpod_home.rs` was importing `crate::flows::repo_manager::system_words`
— the only `use crate::flows::` under `src/clients/` in the crate, and none
existed before this branch. `lib.rs:15` says the layers run strictly downward and
"a client never names a flow", so the import is exactly the seam-pointing-the-
wrong-way this branch's own body rejected `repoint(&Adoptable)` for. Naming a
reconcile-flow *module* is the same objection as naming a reconcile-flow *type*.

It moves to `osext`, which is where it belonged before it had two callers.
`osext` is the process boundary ported to Python's `os` semantics: `env_str` is
`os.environ.get`, `strip` is `str.strip()`, `home_dir` is
`posixpath.expanduser("~")`, `temp_dir` is `tempfile.gettempdir()` — and
`system_words` is `OSError.strerror`, a reading of the host that exists because
`std::io::Error`'s `Display` appends `" (os error {errno})"` where Python's does
not. Nothing about it was ever a flow; it sat in `repo_manager` because that is
where its first caller was. `osext` is also a leaf that depends on nothing, which
is the only kind of module both a flow and a client may name, so the shared
helper stops being an upward edge for either of them.

`public-api.rest.txt` gets the `DevpodHome` block moved after `RepointFailure`,
which is where the generator puts it and where CI's `public-api` job said it
should be. The convention is one module up: `clients::devpod` lists its six enums
first and `struct Workspace` last, and `flows::lifecycle` does the same. Enums
before structs; `RepointFailure` is an enum and `DevpodHome` is a struct. Nothing
else in either snapshot changes — sorting both revisions of the file shows an
identical multiset of rows, so this is ordering and only ordering, and
`public-api.api.txt`'s one hand-edited row was already right.

Two smaller things while here. `lib.rs:8`'s roster of tool clients still read
"`devpod`, `git`, `gh`, `ssh`" after a fifth was added. And the new module's docs
linked `DevpodHome::repoint` and `Self::located`, both of which are private to
the crate, from a `pub mod` — two fresh `private_intra_doc_links` warnings in
CI's rustdoc output. They become plain code spans; the sentences meant them as
names rather than as destinations.

`DevpodHome::at` and `::path` stay `pub`. They have no consumer outside the crate
and `lib.rs:37` would keep them `pub(crate)` for that, but `locate()` is not a
substitute for `at()` for anyone holding a directory, and whether an outside
caller needs to construct one at all is the `api::workspace_delete` question that
belongs to #410. Narrowing them here would settle that question by default and in
the direction #410 may want reversed.
@blooop

blooop commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

Both blockers fixed in f5a2777, and the body corrections are in. Replying to the review point by point.

Fixed

§1 — a client named a flow. system_words moved to osext, and clients/devpod_home.rs now imports crate::osext::system_words. osext rather than domain because of what the function actually is: osext is the process boundary ported to Python's os semantics, and its four existing members are os.environ.get, str.strip(), posixpath.expanduser("~") and tempfile.gettempdir(). system_words is OSError.strerror — it exists because std::io::Error's Display appends " (os error {errno})" and Python's does not, which is the same shape of divergence the other four are there for. It is now documented as the fifth bullet in that list. domain would have been a worse fit: nothing here is part of the data model, and domain is a layer above osext rather than the leaf both callers need.

The layering point generalises, so I checked it: git grep "use crate::flows" rust/devlaunch-core/src/clients/ rust/devlaunch-core/src/domain/ rust/devlaunch-core/src/osext.rs now returns nothing at all. osext is a leaf that depends on nothing, which is the only kind of module a flow and a client can share without one of them pointing upward.

§6 — public-api.rest.txt ordering. The DevpodHome block moved to after RepointFailure. I checked the convention rather than the line numbers: clients::devpod emits six enums alphabetically and then struct Workspace, so enums-before-structs is the rule and RepointFailure is the enum. Verified the edit is ordering and only ordering by sorting both revisions of the file — identical multisets of 2314 rows. public-api.api.txt untouched, as you found it correct. CI is green on f5a2777 — every check, public-api and gate included.

§5 and §9 — the smaller things. lib.rs:8's roster names devpod_home now. The two new private_intra_doc_links warnings are gone (both were sentences using a name, not pointing at a destination, so they became code spans); cargo doc -p devlaunch-core reports nothing from that file. The body no longer says Host::devpod_config moved — the config.yaml join moved, the function is still at flows/launch.rs:230 delegating to DevpodHome::config.

§8 — "the regions do not overlap". Corrected to say they do, with the two hunk offsets, and to tell a #410 author to expect a resolution rather than a clean merge.

§9's second half — the Gate section. It now says local green is not the whole gate, names the red public-api job at 5b52fa1 and what was wrong with it, and records that tests/public_api_snapshots.rs passes either way because it checks tier membership and not row order.

Deliberately left

§7 / the api.txt finding — not acted on, recorded instead. api still does not re-export DevpodHome. Taking option (2) from the standing comment: #410 deletes api::workspace_delete in favour of workspace_remove, so this row is due for removal rather than repair, and promoting a type onto the promise file only to remove it again is churn on the one file whose diffs are meant to mean something. The body now carries that decision explicitly and states the constraint #410 inherits — whatever replaces the function must have every parameter type nameable from api.

§2 — at() and path() stay pub. You are right that lib.rs:37 would keep them pub(crate), and right that it is a decision rather than a default. Narrowing them settles the §7 question by default and in the direction #410 may want reversed: locate() is no substitute for at() for a caller holding a directory. The body records the reasoning. I did not do the twelve-site DevpodHome::at(home.path())Some((*home).clone()) cleanup either — it is right, but it is test churn that would land in the same diff as two blocking fixes.

§3's three guard holes — the unguarded filename half, the dl/src scope gap, and the one-line non-vacuity. Real, and none of them is a defect in what this PR ships; widening the guard is a change to the guard's contract and wants its own argument.

§4repoint's lossy display(), the fixed .dl-tmp name and the missing fsync all moved byte-for-byte, as you established. Not charged here.

§10record(context, id) / result(context, id) over the ticket's .workspace(id): a workspace id is unique within a context and not across them, so id alone cannot address a record. Fair that it was unargued; noting it here rather than reopening the body for it.

The local-snapshot-test gap you flagged as worth its own issue is #431.

Gate on this head

cargo test --workspace, cargo clippy --locked --all-targets -- -D warnings and cargo fmt --check all clean. dl/tests/picker.rs::what_is_typed_appears_on_the_top_line_and_narrows_the_list_below_it failed once and passed on rerun — #416, and CI's rust job is green.

Three resolutions, only one of which git flagged.

CHANGELOG.md: textual conflict, kept both sides.

lifecycle.rs:6986: no textual conflict, but the test main gained in #428
(a_record_removed_while_the_plan_sat_there_is_not_reported_as_re_pointed)
still bound devpod_home as a bare PathBuf where the signatures this branch
rewrites want &DevpodHome. Two E0308s in a tree git called clean. Now
DevpodHome::at(..), matching its eight siblings.

osext.rs: no textual conflict, but system_words was written pub while the
module was pub(crate); #413 has since made osext a pub mod, so the merge
would have added a public-surface row absent from both snapshots and
reddened the public-api job. Demoted to pub(crate) alongside strip,
home_dir and temp_dir, and its doc bullet unbracketed to match. It has no
consumer outside the crate.
@blooop
blooop merged commit 2aa9602 into main Aug 25, 2026
15 checks passed
@blooop
blooop deleted the refactor/devpod-home-adapter branch August 25, 2026 12:52
blooop added a commit that referenced this pull request Aug 29, 2026
As pushed, `public-api.api.txt` named `agent_worktrees::Standing` in
`RemovalRefused` and did not promise it: the type has no struct or impl rows in
that file at all. A consumer holding only `api` got a struct with a field whose
type it could not name, which is devlaunch#531's gap and would have been its
third instance after #427 and #516.

Promoting it honestly was the other option and it is not small. `Standing`
reaches `StandingSite`, `Reason`, `Place`, `Blank`, `Subject` and
`NonEmpty<Loss>`, and `agent_worktrees` has over three hundred rows in the
binary-surface snapshot. That is most of a module's internal vocabulary
arriving in the one tier whose worth is being small and stable. So the promoted
shape was either incomplete or far too wide, and rendering at the seam is the
only option that is both complete and narrow. It is the move the `--ls --json`
payload already makes for the wire, at the same boundary and for the same
reason.

`RemovalRefused` now carries a `RemovalGrounds`, which is made of `String`:
`WouldLose`, `CouldNotTell`, or `BothAtOnce`. Three arms rather than two
options, because a standing is non-empty and every reason in it answers one of
the two, so "neither" cannot happen -- and both render sites carried a fourth
arm apologising for being unreachable, which this deletes rather than comments.
`BothAtOnce` is what keeps #446 true across the seam: a refusal still never
picks one of two true things to say.

Nothing inside `flows` changed. `Standing` is exactly as it was, the domain
type still carries the whole standing, and the conversion is a private free
function at the boundary rather than a method -- a public constructor taking a
`Standing` would put it straight back into the promised tier's signatures.

Also removes `Standing::any_unproved`, which this branch added and nothing ever
called. `Standing` is in the residual, so an uncalled reader there is rows a
consumer can bind to for nothing.
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.

devpod's on-disk layout is spelled in four places, and its module is missing

1 participant