Skip to content

Move ColdPath and ToolProvisioning into core; complete api's re-exports - #514

Merged
blooop merged 8 commits into
mainfrom
wayfinder/devlaunch-340
Aug 29, 2026
Merged

Move ColdPath and ToolProvisioning into core; complete api's re-exports#514
blooop merged 8 commits into
mainfrom
wayfinder/devlaunch-340

Conversation

@blooop

@blooop blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Implements the #313 decision's second build: the real ColdMachinery and Provision implementations move into devlaunch-core, and api re-exports everything Launch::new asks for.

The problem, stated as a test

api::Launch was reachable and not constructible. Five of Launch::new's seven parameter types lived outside api (Refresh, Host, Notices<LaunchNotice>, and the two implementations), and the two implementations that decide whether a launch can go cold at all lived inside the dl binary, where nothing but dl could name them. A second consumer could name the launcher and had nothing to hand it.

The red test is rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs: it builds a cold-capable Launch with the real ColdPath and the real ToolProvisioning, importing every parameter through devlaunch_core::api and nothing else. Before the change it did not compile:

error[E0432]: unresolved imports `devlaunch_core::api::ColdMachinery`,
  `devlaunch_core::api::ColdPath`, `devlaunch_core::api::Host`,
  `devlaunch_core::api::LaunchNotice`, `devlaunch_core::api::Notices`,
  `devlaunch_core::api::Provision`, `devlaunch_core::api::ProvisionEvent`,
  `devlaunch_core::api::RecordsNotice`, `devlaunch_core::api::Refresh`,
  `devlaunch_core::api::SelfInvocation`, `devlaunch_core::api::ToolProvisioning`

Eleven names, one import list. It also asserts the other half of devlaunch#145 at runtime: building a cold-capable launcher spawns nothing, says nothing, and opens no records, which is what makes ColdPath a way to get the records rather than the records.

What moved

From To
dl/src/cold.rs ColdPath devlaunch_core::flows::launch::ColdPath
dl/src/launch.rs ToolProvisioning devlaunch_core::flows::launch::ToolProvisioning
dl/src/session.rs Records / open_records / StartupError new devlaunch_core::flows::records

The only thing that kept either implementation in the binary was where its events get printed, so each now takes an event sink as a constructor argument:

  • ColdPath::new(runner, &mut dyn Notices<RecordsNotice>). The four things the open used to hand back as separate fields (the config's retired keys, the load's notices, the migration report, the migration's refusal) are one vocabulary now, said in the order Python's factory produced them. dl implements Notices<RecordsNotice> on the printer it already had.
  • ToolProvisioning::from_env(cache, &mut dyn Notices<ProvisionEvent>). The sink sits behind a RefCell because Provision answers through &self; one launch makes one pass at a time, so there is nothing to contend with.

dl keeps the rendering and only the rendering. dl/src/session.rs is down to the two answers only a running process can give: where the cache is, and how to re-run this build.

ColdRefused had to be typed for any of it to work

ColdRefused carried reason: String, filled by dl rendering a StartupError and quoted back into core's own launch refusal. That is the one place the binary's prose travelled back through core, against #251 section 5, and it is why #313 put the typing first and the move behind it: a ColdPath inside core cannot write the words.

So this branch does that typing too, in the shape #313 decided and #339 specifies:

pub enum ColdRefused {
    Startup(StartupError),
    NoColdPath,
}

StartupError is core's now rather than mirrored there, so the arms reuse it instead of restating it. dl's render::cold_refused is the match, and every sentence a user sees is unchanged. #339 is closed by this PR. Its substance was here from the first commit and its named red test is here now, after review pointed out that nothing in the tree constructed ColdRefused::Startup or StartupError::Metadata. See "The typed refusal's own red" below.

One consequence worth naming: domain::config::ConfigError is now Clone + PartialEq + Eq, because a refusal that travels inside another has to be as copyable as the one carrying it. Its OS side is an OsFailure rather than an io::Error, which is the choice MetadataError already documents and made in the same words. OsFailure::message is io::Error::to_string(), so the rendered line is byte-identical.

The snapshots

Regenerated for real, not hand-edited: nightly plus cargo-public-api 0.52.0 installed in a scratch prefix, scripts/public-api-snapshots.sh run, and the output confirmed byte-identical to the checked-in files on main before any code changed.

File Before After
devlaunch-core/public-api.api.txt 37 126
devlaunch-core/public-api.rest.txt 2598 2755
devlaunch-runner/public-api.txt 202 202 (unchanged)

The promise file grows by 89 rows and loses none. All 89 are additions, and they are the parameter types the ticket asked for: ColdMachinery, ColdPath, Cold, ColdRefused, Provision, ToolProvisioning, Host, Refresh, SelfInvocation, Notices, and the three notice vocabularies they are parameterised by (LaunchNotice, ProvisionEvent, RecordsNotice) plus StartupError. The vocabularies bring their variants with them, which is most of the row count: a sink type is not usable by a consumer that cannot match on what it receives.

The rest file's diff is the same move seen from the canonical side, which is the documented one-way limit (#352): the newly promised types' constructors, methods and derived impls render at flows::… and land there. The only deletions in the whole run are eleven rows: ten for the old ColdRefused struct and its impls, and one for ConfigError::Unreadable::source changing type. Nothing else was removed from either file.

Three prose sites carried measured counts from #347 and are corrected in step: docs/development.md, devlaunch-core's crate docs, and the header of scripts/public-api-snapshots.sh. The generator emits 259 rows for the api section now and the filter keeps 126; it was 79 and 37.

Gates

  • cargo test --workspace green, 29 suites, including the two new unit tests over the records report's ordering and the partition invariant over the regenerated snapshots.
  • cargo clippy --locked --all-targets -- -D warnings clean.
  • cargo fmt --check clean.
  • DEVLAUNCH_DL_CMD='cargo run …' pixi run pytest test/ green: 427 passed, 6 skipped.
  • cargo doc --workspace --no-deps emits one fewer warning than the merge base, measured rather than asserted: 126 private-link warnings in devlaunch-core before this branch, 125 after. The first push did introduce one (the new ColdRefused::NoColdPath doc pointing at the pub(crate) struct); review caught it and it is gone. A broken-link warning left behind by the deleted dl::cold module is fixed too.
  • CHANGELOG entry under [Unreleased].

The typed refusal's own red (added after review)

Review found #339's shape satisfied and its named red test absent: nothing in the tree constructed ColdRefused::Startup or StartupError::Metadata, so "the reason travels as a type" and "the sentences are unchanged" were both inspection-only. Three tests now hold them, one per place the claim lives.

flows::launch::tests::a_metadata_refused_cold_open_surfaces_as_the_typed_arm drives name_default_branch with a cold path that refuses the way the real one does, and asserts the whole refusal value: BranchNotNamed::Cold(ColdRefused::Startup(StartupError::Metadata(..))), with the MetadataError the store produced still inside it. A sibling pins the same for prepare's NotPrepared::Cold, because those are two separate map_errs over one open, and a third pins that NoColdPath refuses with an arm rather than the English literal it used to carry.

Proven red rather than assumed: flattening the arm back to Startup(String) and filling it with format!("{refused:?}") in ColdPath::open breaks all three at compile time.

error[E0308]: mismatched types
    --> devlaunch-core/src/flows/launch.rs:6363:17
6362 |             Err(BranchNotNamed::Cold(ColdRefused::Startup(
     |                                      -------------------- arguments to this enum variant are incorrect
6363 |                 StartupError::Metadata(metadata_refusal())
     |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `String`, found `StartupError`

render::tests takes the byte-identity claim off inspection. Every ColdRefused arm is asserted against its whole sentence, not a substring, since a contains would pass while the line a user reads quietly changed. Two more cover the composition into Repository 'owner/repo': <reason> and the io::Error wording that had to survive ConfigError::Unreadable swapping io::Error for OsFailure.

test/unit/test_cold_path_refusal.py is the only thing that can run ColdPath::open's Err arm at all. The open resolves its paths from the process environment, so nothing inside either crate reaches it without mutating an environment every other test in the binary shares. A real dl with a file where its cache directory belongs does reach it, and the suite already scopes XDG_CACHE_HOME per test.

The four doc markers review caught

All four were made wrong by this PR's first commit rather than pre-existing, and all four are the kind that misleads a maintainer reading a snapshot diff:

  • flows/lifecycle.rs:11 said everything there is binary surface "except the three §7 names". This PR put Refresh and SelfInvocation in api. It now points at api's re-export list as the authority instead of restating it.
  • flows/records.rs marked StartupError and RecordsNotice binary surface in the same commit that filed both in public-api.api.txt. Both now say they are promised, and why.
  • Records keeps a note of its own, corrected the other way: not re-exported from api, reachable through api::ColdPath::records all the same, which is The api promise file misses Launch::new and Launch::run #352's classifier gap rather than a second tier.
  • tests/public_api_snapshots.rs:19 was a fourth prose site still at "42 of the 79 rows". Now 133 of 259, with the other three.

Also taken from review: the self-sufficiency test asserted two of the three sinks were empty while its own docstring claimed three. The provisioner is dropped so the third can be read.

Merged with main after 0.25.0 shipped

main moved and #500 cut the release, so [Unreleased] came back empty with ## [0.25.0] - 2026-08-28 directly under it. That is the merge this branch's CHANGELOG entry could have been lost in: a textual resolution files it inside the shipped release, reads cleanly, and is wrong. Resolved by hand and checked two ways, git diff origin/main -- CHANGELOG.md shows zero deletion lines, and the entry sits between ## [Unreleased] and ## [0.25.0].

All three snapshots were regenerated from the merged tree rather than carried across it, and came out byte-identical to what the merge produced: main's changes to flows::repo_manager and flows::workspace_clone add no rows. Counts unchanged at 126 / 2755 / 202. Gates re-run on the merge, all green.

Closes #340
Closes #339

🤖 Generated with Claude Code

Summary by Sourcery

Move cold-launch machinery and records management into core, complete the launcher API re-exports, and carry refusal reasons as typed values while preserving user-facing behavior.

New Features:

  • Make the core API self-sufficient for constructing cold-capable launches by re-exporting all Launch::new parameter types and providing the real cold-path and tool-provisioning implementations.
  • Add typed records-opening and provisioning notice vocabularies that let consumers supply their own event sinks.

Bug Fixes:

  • Preserve cold-start refusal reasons as typed values instead of rendering binary-specific messages inside core.
  • Keep existing cold-path, startup, migration, and OS-error messages unchanged while moving rendering to dl.

Enhancements:

  • Move records management, cold-path handling, and tool provisioning from dl into devlaunch-core, leaving the binary responsible for rendering and process-specific behavior.
  • Consolidate records-opening diagnostics into an ordered, typed report and maintain lazy, side-effect-free launcher construction.

Documentation:

  • Update public API documentation, snapshots, and development guidance to reflect the expanded promised API surface.

Tests:

  • Add coverage proving a cold-capable launcher can be built solely through devlaunch_core::api without opening records or spawning processes.
  • Add unit and end-to-end tests for typed cold refusals, notice ordering, exact rendered messages, and blocked-record startup failures.

Chores:

  • Record the changes in the Unreleased changelog and regenerate the core public API snapshots.

`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.

@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 4 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

Moves the real cold-path record handling and tool provisioning into devlaunch-core, passes typed notice events to dl for rendering, replaces stringly cold refusals with typed errors, and completes api re-exports so external consumers can construct a lazy, cold-capable Launch using only the promised API surface.

Sequence diagram for lazy cold-path record opening

sequenceDiagram
    participant Consumer
    participant Launch
    participant ColdPath
    participant Records
    participant Sink

    Consumer->>Launch: Launch::new(...)
    Note over Launch,ColdPath: Construction performs no record I/O
    Consumer->>Launch: Launch::run(...)
    Launch->>ColdPath: open()
    ColdPath->>Records: open_records(runner)
    Records-->>ColdPath: Records or StartupError
    ColdPath->>Sink: say_all(RecordsNotice events)
    ColdPath-->>Launch: Cold or ColdRefused::Startup
Loading

Sequence diagram for typed event rendering during tool provisioning

sequenceDiagram
    participant Launch
    participant ToolProvisioning
    participant ProvisionFlow
    participant Sink
    participant DL

    Launch->>ToolProvisioning: from_env(cache, notices)
    Launch->>ToolProvisioning: provision_tools(runner, workspace_id, occasion, title)
    ToolProvisioning->>ProvisionFlow: provision_tools(...)
    ProvisionFlow->>Sink: Notices<ProvisionEvent>
    Sink->>DL: render provisioning event
    ProvisionFlow-->>ToolProvisioning: provisioning result
    ToolProvisioning-->>Launch: ClaudeConfig or DevpodMissing
Loading

File-Level Changes

Change Details Files
Move cold-launch record management and tool provisioning from the binary into core, with typed event sinks preserving lazy behavior and CLI rendering.
  • Add core-owned ColdPath, ToolProvisioning, Records, open_records, and StartupError implementations.
  • Inject Notices<RecordsNotice> and Notices<ProvisionEvent> sinks so core emits typed events while dl retains formatting and output.
  • Preserve lazy record opening and consolidate record, metadata, migration, and refusal reporting into an ordered notice stream.
  • Update dl command, launch, target, rendering, and session wiring to use the core implementations.
rust/devlaunch-core/src/flows/launch.rs
rust/devlaunch-core/src/flows/records.rs
rust/devlaunch-core/src/flows/mod.rs
rust/dl/src/cold.rs
rust/dl/src/commands.rs
rust/dl/src/launch.rs
rust/dl/src/render.rs
rust/dl/src/session.rs
rust/dl/src/target.rs
Replace rendered cold-start refusal text with a typed error model and move presentation to the binary.
  • Change ColdRefused from a string-bearing struct to Startup and NoColdPath variants.
  • Make configuration errors cloneable/comparable and represent OS failures with OsFailure while preserving rendered messages.
  • Add exhaustive CLI rendering for the typed refusal variants.
rust/devlaunch-core/src/flows/launch.rs
rust/devlaunch-core/src/flows/records.rs
rust/devlaunch-core/src/domain/config.rs
rust/dl/src/render.rs
Make the core API self-sufficient for constructing real launchers and update its public API contract.
  • Re-export all Launch::new parameter types, traits, notice vocabularies, and real implementations through devlaunch_core::api.
  • Add an integration test that constructs a cold-capable launcher using only the API module and verifies construction performs no I/O, spawning, or notices.
  • Regenerate public API snapshots and update documented declaration counts.
rust/devlaunch-core/src/lib.rs
rust/devlaunch-core/tests/api_launch_is_self_sufficient.rs
rust/devlaunch-core/public-api.api.txt
rust/devlaunch-core/public-api.rest.txt
docs/development.md
scripts/public-api-snapshots.sh
Document the architectural and user-visible contract changes.
  • Add an Unreleased changelog entry covering the move, typed refusals, and preserved output behavior.
  • Update crate and module documentation to reflect the new core/binary boundary.
CHANGELOG.md
rust/devlaunch-core/src/lib.rs
rust/dl/src/lib.rs
rust/dl/src/launch.rs
rust/dl/src/session.rs

Assessment against linked issues

Issue Objective Addressed Explanation
#340 Move the real ColdPath/ColdMachinery implementation, record-opening and migration plumbing, and ToolProvisioning/Provision implementation from dl into devlaunch-core, with rendering remaining in dl and event sinks supplied as typed injectable values.
#340 Make ColdRefused carry typed refusal reasons rather than rendered strings, allowing core to remain independent of dl's user-facing prose.
#340 Complete devlaunch_core::api re-exports so every Launch::new parameter type, including Refresh, Host, Notices, the traits, and the concrete cold/provision implementations and event types, is reachable through api alone, with a test proving a cold-capable Launch can be constructed that way without opening records.

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

@codecov

codecov Bot commented Aug 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.52555% with 15 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.77%. Comparing base (6df02bc) to head (c3b49e2).

Files with missing lines Patch % Lines
rust/devlaunch-core/src/flows/records.rs 87.17% 10 Missing ⚠️
rust/dl/src/render.rs 93.65% 4 Missing ⚠️
rust/devlaunch-core/src/flows/launch.rs 99.05% 1 Missing ⚠️
Additional details and impacted files
Flag Coverage Δ
python 42.98% <ø> (ø)
rust 96.07% <94.52%> (+0.09%) ⬆️

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

Components Coverage Δ
shipped code (rust) 96.07% <94.52%> (+0.09%) ⬆️
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 merge-base 669df03...9925291, fresh context, two independent axes. Every claim in the description was re-derived rather than taken on trust; where I could refute a finding I dropped it and said so.

Standards

Core renders no output: holds, and this PR is the thing that fixes it. Every string literal added anywhere under devlaunch-core/ in this diff is a test fixture, a path, or the expect("the records were just opened") panic text moved verbatim from the deleted cold.rs. No print!/println!/eprintln!, no sentence-shaped format string, no reach into dl::render. The reverse channel is genuinely closed: ColdRefused.reason: String was the only place dl prose travelled back through core, and "the cold path is not available to this caller" now lives at dl/src/render.rs:2580 rather than inside flows/launch.rs. Worth knowing that nothing in test/ enforces this rule; it is review-enforced only.

ConfigError remodel: no information lost. OsFailure (devlaunch-core/src/domain/metadata.rs:107) keeps kind: io::ErrorKind alongside message, and From<io::Error> sets both. Nothing in the tree branched on that arm's kind before or after. Eq is over kind+message, used only in tests, in no HashSet or dedup. Clone is load-bearing, not speculative: ColdRefused derives it. The claim that MetadataError already made this choice in the same words checks out verbatim.

minor — devlaunch-core/src/flows/lifecycle.rs:11. The module doc says "Everything reachable here is binary surface, not part of the frozen wf API (#251 §7), except the three §7 names (list, remove, up)." That sentence was true at the merge base and this PR makes it false: it adds Refresh and SelfInvocation to api, and pub struct devlaunch_core::api::Refresh<'a> is now a declaration in public-api.api.txt.

minor — devlaunch-core/src/flows/records.rs:41 and :78. StartupError and RecordsNotice each carry binary surface, not part of the frozen wf API (#251 §7) in the same commit that re-exports both at api:: and files them in the promise file (public-api.api.txt:76-89). :41's hedge, "on its own; it reaches the promised tier as the payload of ColdRefused", is wrong for the same reason: it is directly at api::StartupError now, not only as a payload. :110 on Records is correct and should stay, which is what makes the other two look like an oversight rather than a convention.

This matters more than a comment usually would. docs/development.md makes the promise/tripwire split the entire basis on which a snapshot diff gets read, and a maintainer who opens flows::records is told these types are free to change.

minor — devlaunch-core/tests/public_api_snapshots.rs:19. Still reads "42 of the 79 rows the generator emits for the api section". Three prose sites were corrected to 133/259 (src/lib.rs:56, scripts/public-api-snapshots.sh:29, docs/development.md:61); this is a fourth carrying the same measured figures from #347, and it is now the only one that disagrees with the files it is documenting. test/test_public_api_snapshots_doc.py asserts nothing about the counts, so there is no tripwire under any of the four.

minor — devlaunch-core/src/flows/launch.rs:2463. The description's gate list says cargo doc --workspace --no-deps "introduces no new broken intra-doc link". It introduces one: public documentation for 'NoColdPath' links to private item 'NoColdPath', from the +-added doc line on the public ColdRefused::NoColdPath variant pointing at the pub(crate) struct. Cosmetic, since ~136 such warnings pre-exist repo-wide, but the gate statement as written is false.

nit — devlaunch-core/src/flows/records.rs:76. "because a sink is what lets the words be said while the work is still happening" is not what this flow does. open_records collects into Records.reported and ColdPath::records() drains it with say_all after the whole open, migration included, has returned. Not a regression, the old session.rs deferred the same way, but notices.rs's module doc opens by naming a Vec as precisely the thing that decides when the saying happens, so the sentence claims the property this one flow gave up.

Refuted and dropped. The RefCell around the provision sink: borrow_mut is scoped inside the provision_tools call and provision::provision_tools is a free function that cannot re-enter the value exclusively borrowing it, so the panic path is unreachable. ColdPath<'r, '_>'s second lifetime: eight elided call sites, mechanical. flows::records as a Middle Man: it owns the ordering function, the notice vocabulary and the single construction point. render::cold_refused and startup_reason are wildcard-free, so a future arm is a compile error. And an earlier draft finding that an external ColdMachinery cannot be written from api alone was wrong for the case the lib.rs comment actually names: a NoColdPath-shaped impl only ever returns Err, which needs nothing beyond api::{ColdMachinery, Cold, ColdRefused}. (Only an impl returning Ok would need WorkspaceCloneManager and MetadataStorage, which api does not re-export. The comment does not claim that case.)

Pre-existing, not this PR. rust/dl/tests/read_side.rs:748 the_json_listing_migrates_the_cache_and_the_table_does_not fails roughly one full-suite run in five and passes in isolation. --ls is in CACHE_READING_COMMANDS (flows/completion_cache.rs:209), so it spawns a background dl --update-cache, that child calls open_records, and the migration it runs races the assertion that the document is still at version 1. Untouched by this diff and worth its own issue.

Spec

Spec is #340, with #313's decision comment as the parent and #339's substance carried as a prerequisite.

Re-export completeness: satisfied, proven rather than read. #340: "complete api's re-exports so every Launch::new parameter type is reachable from api alone (Refresh, Host, Notices<LaunchNotice>, the impls), today 5 of 7 live outside it." Launch::new (flows/launch.rs:3000-3008) takes &mut CommandContext, &mut Refresh, &mut dyn ColdMachinery, &dyn Provision, &Host, &mut dyn FnMut(&str) and &mut dyn Notices<LaunchNotice>. A throwaway external crate with a path dependency on devlaunch-core compiles all seven as aliases through devlaunch_core::api:: (the sixth being std), plus ColdPath and ToolProvisioning coerced to their traits, an exhaustive match on every ColdRefused arm, Vec<T> as each of the three sinks, and a third-party impl ColdMachinery. Nothing required a flows:: path.

The red test is real. Dropped into a merge-base worktree, devlaunch-core/tests/api_launch_is_self_sufficient.rs fails with exactly the claimed E0432 over exactly the eleven claimed names. It imports only through devlaunch_core::api:: plus devlaunch_test_support::FakeRunner, and it builds the real ColdPath::new and ToolProvisioning::from_env, not stubs.

nit — devlaunch-core/tests/api_launch_is_self_sufficient.rs:73. The module doc says "the sinks are still empty when the launcher exists", and the test asserts that of records_said and launch_said but not provision_said, which is borrowed by the live ToolProvisioning. Harmless, since from_env emits nothing, but the assertion list is one short of the sentence above it.

Snapshot honesty: satisfied. public-api.api.txt 37 to 126 with zero deletion lines. public-api.rest.txt 2598 to 2755 with exactly eleven deletions: ten rows of the old ColdRefused struct and its impls, and ConfigError::Unreadable::source changing type. wc -l at both revisions matches every figure in the table. Independently corroborated by CI: the public-api job regenerates the files with the pinned cargo-public-api and diffs them against what is checked in, and it is green, so these were regenerated and not hand-edited. (Trivially, the body reads "the eleven rows of the old ColdRefused ... and ConfigError::Unreadable::source", which parses as twelve; it is ten plus one.)

Byte-identical rendering: satisfied. The base rendered format!("could not read {} ({source})", path.display()), pure Display on io::Error; head renders source.message, and OsFailure::from sets that to error.to_string(). No {:?}, no .kind(), and ConfigError::Unreadable has exactly one render site. NoColdPath's literal moved across byte-for-byte.

Behavioral no-op: satisfied, strongly. git diff --stat over rust/dl/tests/ and test/ is empty. The exact-call-sequence tests in rust/dl/tests/launch.rs were not touched at all, let alone loosened, and no assert or #[test] line is removed anywhere in the diff. commands::report keeps its same four call sites, each paired with a direct open_records, so nothing is said twice. cargo test --workspace is green locally.

Coverage gap, and it is the same gap as #339's. ColdPath::open's Err arm, render::cold_refused and render::startup_reason have zero test coverage, and nothing in the tree constructs StartupError::Metadata, StartupError::Config or ColdRefused::Startup. ConfigError::Unreadable's rendering is untested too. Both "the sentences are unchanged" claims are therefore inspection-only. They hold, I checked them by hand, but nothing would catch it if they stopped holding.

CHANGELOG: satisfied. Two entries under [Unreleased] / Changed, covering the move and the typing, and accurate about both.

On #339

Shape satisfied. The red test #339 names does not exist on this branch.

The shape is a faithful reading and arguably better than the letter. #339 asks for "a sum over the startup reasons, NoHomeDirectory / Config(ConfigError) / Metadata(MetadataError) (payloads already core types, mirroring dl's private StartupError), plus a no-cold-path arm". The branch promotes the real StartupError into core (flows/records.rs:45-49) instead of restating it, so ColdRefused::Startup(StartupError) | NoColdPath matches exhaustively over the same four outcomes. The second clause is met as well: render::startup_reason (dl/src/render.rs:2742) is the match on the typed arms, and NoColdPath's English literal moved out of core.

But #339 also names its own red: "a test pinning that a metadata-refused cold open surfaces as the typed arm, not prose." Searching every .rs under rust/, nothing constructs ColdRefused::Startup or StartupError::Metadata at all. The two closest things are neither of it: flows/records.rs:191 the_four_sources_are_reported_in_the_order_python_produced_them carries a MetadataError, but as a RecordsNotice::MigrationRefused in the notice report rather than as a refusal out of a cold open; and tests/api_launch_is_self_sufficient.rs:30 builds the real ColdPath and deliberately never opens it.

So the description's "a maintainer who reads its spec as satisfied can close it" is accurate only if the maintainer waives the test. Recommendation: keep #339 open on the test alone, and it is cheap to write now that ColdRefused is comparable.

Siblings

No overlapping hunks. #504 also edits dl/src/render.rs and #511 also edits domain/config.rs and flows/launch.rs, but all three are comment-only changes in distant regions. Beyond those, every sibling shares only the [Unreleased] CHANGELOG block. The branch is five commits behind main; main's flows/repo_manager.rs and flows/workspace_clone.rs changes added no rows to the snapshots and its own public-api job is green with them unchanged, so the regenerated rest.txt here stays valid after the merge.

Verdict

Comment. No blocking findings. All fifteen gates in the description that I could check independently hold, including the three the description is easiest to be wrong about: the promise file really does grow by 89 rows and lose none, the rendered sentences really are byte-identical, and no exact-call-sequence test was touched. The layering fix is the substance here and it lands.

What I would fix before merge, none of it structural:

  1. flows/lifecycle.rs:11 and flows/records.rs:41/:78, three doc markers that now contradict the tier the same commit put those types in.
  2. tests/public_api_snapshots.rs:19, the fourth prose site still at 42/79.
  3. The description's cargo doc gate line, which is false as written.

And separately from this PR: keep #339 open for its red test, and file the read_side.rs:748 flake.

blooop added 2 commits August 29, 2026 20:36
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.
@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Heads up from the stacked build on top of this branch (#520, #410): 0c5dea0 has not had a CI run of its own, and prek is red on it. ruff format reformats the new test/unit/test_cold_path_refusal.py:

--- test/unit/test_cold_path_refusal.py
@@ -70,9 +70,7 @@
-    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):

pixi run ruff format fixes it. Left here rather than patched in #520, since the commit is this branch's; #520 inherits the red check until it lands.

blooop added 3 commits August 29, 2026 20:53
Three things needed a hand rather than a textual resolution.

CHANGELOG: #500 cut 0.25.0 while this branch was open, so `[Unreleased]` came
back empty with the release directly under it, and a clean-reading textual
merge files this branch's entry inside the shipped release. Resolved so the
entry sits between `[Unreleased]` and `[0.25.0]`, with main's own `Added` and
`Fixed` entries kept beside it and nothing deleted.

`dl/src/session.rs`: main added `open_storage` to the very block #340 deleted
from the binary. The block stays deleted, and `open_storage` is ported to
`devlaunch_core::flows::records` beside `open_records`, which is where the rest
of that plumbing went and which it is a shorter path through. `dl` now names it
from there. One new row in the rest snapshot, none in the promise file.

Snapshots regenerated from the merged tree rather than carried across it, and
the counts the prose names still hold: 126 promised rows, 259 in the section
the filter reads.
Two hand resolutions, both the same shape as the last merge.

CHANGELOG: main's `--purge` entry and this branch's are both bullets of the one
`Changed` section under `[Unreleased]`. Kept side by side, nothing deleted.

`dl/src/commands.rs`: main's new `say_retired_keys` calls
`session::worktree_config`, which #340 deleted from the binary along with the
rest of the records plumbing. It reads `domain::config::worktree_config`
directly now, which is what dl's wrapper forwarded to.

Snapshots regenerated from the merged tree: no change, and the promise file is
still 126 rows.
@blooop

blooop commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Thanks. Every finding taken; the review's own recommendation on #339 is the one thing I have gone the other way on, and only because the test it asked for now exists. Pushed as 0c5dea0, then merged with main twice more as it moved under me.

The review found no inline threads to reply on (it is one review body), so this answers all five plus #339 in order.

1. flows/lifecycle.rs:11, "except the three §7 names"

Correct, and made false by my own commit. Rewritten to point at api's re-export list as the authority rather than restate it, since the list is what the snapshot guards and a restatement is a second copy that can drift again:

Everything reachable here is binary surface, not part of the frozen wf API (#251 §7) unless api re-exports it: ... 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.

2. flows/records.rs:41 and :78, binary-surface markers on promised types

Taken, and the hedge on :41 too, which you are right to read as wrong rather than merely imprecise: StartupError is at api::StartupError now, not only inside a payload. Both say what is true, with why.

Your point about :110 on Records being correct is what made me look at it again, and it was not quite correct either, in the opposite direction: Records is what api::ColdPath::records returns, so it is reachable from the promised tier without being declared there. That is exactly #352's gap rather than a second tier, and the note now says so, and to treat a change there as a change to the promise.

3. tests/public_api_snapshots.rs:19, the fourth prose site

Fixed, 133 of 259 with the other three. You are right that nothing guards any of the four; I have not added a guard here, because a count assertion over a generated file is the fifth copy of the same fact rather than a check on it, and #352 is where the classifier and its arithmetic both change.

4. The cargo doc gate line

The finding is right and the gate line was false: my check grepped for unresolved link, and NoColdPath produced a private item warning, which that grep does not see. The link is gone (the variant doc names the struct without linking to it), and the gate line now carries a measurement instead of a claim: 126 private-link warnings in devlaunch-core at the merge base, 125 on this branch. One fewer, not none, which is the honest number.

5. flows/records.rs:76, "while the work is still happening"

Agreed, and it is worse than a loose sentence: notices.rs opens by naming a Vec as precisely the thing that decides when the saying happens, so the doc claimed the one property this flow gives up. Reworded to say what the sink does buy here and what it does not:

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 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.

6. Also taken

api_launch_is_self_sufficient.rs asserted two of three sinks were empty while its docstring claimed three. The provisioner is dropped so provision_said can be read. And the description's "eleven rows ... and ConfigError::Unreadable::source", which parsed as twelve, now reads "eleven rows: ten for ... and one for ...".

On #339: closing it rather than keeping it open

You are right that the test did not exist, and that is the one recommendation I have not followed, because the cheapness you noted turned out to be real. Three tests now, one per place the claim lives:

  • flows::launch::tests::a_metadata_refused_cold_open_surfaces_as_the_typed_arm drives name_default_branch with a cold path that refuses the way the real one does and asserts the whole refusal value, BranchNotNamed::Cold(ColdRefused::Startup(StartupError::Metadata(..))), with the MetadataError still inside it. A sibling pins prepare's NotPrepared::Cold, since those are two separate map_errs over one open and a refusal stringified by one of them would be the old bug back in half the launches. A third pins NoColdPath, the arm that replaced an English literal.

  • Proven red rather than assumed. Flattening the arm to Startup(String) and filling it with format!("{refused:?}") in ColdPath::open breaks all three at compile time:

    error[E0308]: mismatched types
        --> devlaunch-core/src/flows/launch.rs:6363:17
    6362 |             Err(BranchNotNamed::Cold(ColdRefused::Startup(
         |                                      -------------------- arguments to this enum variant are incorrect
    6363 |                 StartupError::Metadata(metadata_refusal())
         |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `String`, found `StartupError`
    
  • render::tests takes the byte-identity claim off inspection, where you correctly left it. Whole sentences, not substrings, because a contains passes while the line a user reads changes. Plus the composition into Repository 'owner/repo': <reason> and the io::Error wording that had to survive ConfigError::Unreadable swapping io::Error for OsFailure.

  • test/unit/test_cold_path_refusal.py is the only thing that can run ColdPath::open's Err arm at all, which is the half of your coverage finding neither crate can reach: the open resolves its paths from the process environment, and domain::xdg's own tests say why nothing in core mutates that. A real dl with a file where its cache directory belongs does reach it, and the suite already scopes XDG_CACHE_HOME per test.

So Closes #339 is on the description alongside Closes #340. If you would still rather #339 stayed open as its own ticket, say so and I will drop the keyword; the tests stay either way.

Not taken, and why

The read_side.rs:748 flake is real and yours is the better diagnosis, but it is pre-existing and unrelated to this diff, so it stays out of this PR. Filed as #526 with your mechanism.

Since the review: three merges with main

main moved four times while this was in review, and one of them was the hazard worth naming. #500 cut 0.25.0, so [Unreleased] came back empty with the release directly beneath it, and a textual merge files this branch's entry inside the shipped release, reads cleanly, and is wrong. Resolved by hand each time and checked two ways: git diff origin/main -- CHANGELOG.md shows zero deletion lines, and the entry sits between ## [Unreleased] and ## [0.25.0].

Two code conflicts came with them, both where main edited the block #340 deleted from dl/src/session.rs:

All three snapshots were regenerated from each merged tree rather than carried across it. The promise file is still 126 rows and the section the filter reads is still 259, so the corrected prose is still correct after the merges. Gates re-run on the final merge: cargo test --workspace 30 suites green, clippy -D warnings clean, fmt --check clean, Python guard suite 612 passed.

blooop added 2 commits August 29, 2026 21:07
Line length, caught by prek rather than by me: the suite's ruff config fits
that signature on one line.
@blooop
blooop merged commit 2686879 into main Aug 29, 2026
15 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-340 branch August 29, 2026 20:21
blooop added a commit that referenced this pull request Aug 29, 2026
Snapshots regenerated from the merged tree rather than hand-merged: #514 and
main's own changes both moved the surface. CHANGELOG resolved as the union of
main's [Unreleased] and this branch's entry, filed under the same ### Changed
heading it had here, so no released section grows a bullet (#527).
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.

Move ColdPath and ToolProvisioning into core; complete api's re-exports Type ColdRefused as a sum over the startup reasons

1 participant