Skip to content

Make a workspace's source one value instead of a tag beside a parallel field - #132

Merged
blooop merged 5 commits into
mainfrom
wayfinder/devlaunch-127
Aug 8, 2026
Merged

blooop merged 5 commits into
mainfrom
wayfinder/devlaunch-127

Conversation

@blooop

@blooop blooop commented Aug 8, 2026 •

Copy link
Copy Markdown
Owner

Closes #127.

The shape that was there

Workspace carried a source_type string beside a parallel source string.
Two of the three tags described what source held — a path for "local", a URL
for "git" — and the third did not: "unknown" put str(the raw devpod object)
into the same field. So the field was honest for two values of the tag and a
debug repr for the third, and nothing stopped

Workspace("ws", "git", "/home/dev/project", ...)

from being constructed and believed.

The one reader that branched on the tag was repo discovery: an if/elif with
no else. The "unknown" arm fell off the end and left no trace — the same
outcome as a source it read fine and found no repo in.

The shape now

@dataclass(frozen=True)
class LocalFolder:
    path: str

@dataclass(frozen=True)
class GitRepository:
    url: str

@dataclass(frozen=True)
class UnrecognisedSource:
    payload: Mapping[str, Any]

WorkspaceSource = LocalFolder | GitRepository | UnrecognisedSource

Workspace.source_type and Workspace.source become one field, source: WorkspaceSource. Each arm carries only what that arm actually has, and the arm
devlaunch cannot read holds the object devpod sent rather than a rendering of it.

What that made unrepresentable

  • A kind and a value that disagree. There is no longer a tag to be wrong: an
    arm is its kind, so nothing can say git while holding what the code will
    then treat as a path. (GitRepository("/home/dev/project") is still
    constructible, and deliberately so — devpod up <path-to-bare-repo> really
    does record a path-shaped gitRepository, and a test builds one. What is gone
    is the disagreement, not path-shaped strings.)
  • str(dict) in a field typed as a path or a URL. The unreadable arm has no
    path field and no URL field for one to be smuggled into, and no arm's field can
    be filled with a value of another type: a key has to be non-empty text before
    it counts as a folder or a URL, so {"localFolder": {...}} and
    {"localFolder": ""} are both sources dl cannot read rather than folders.
  • A reader that handles some arms and drops the rest in silence — see below.

Exhaustiveness is enforced, and that was measured rather than assumed

The /constructive-modeling skill warns that Python exhaustiveness is decorative
unless a checker runs in CI, and says to compile the negative case. Run against
this repo's own pinned ty:

case result
exhaustive handler over a three-arm union passes
delete one arm fails — Expected Never, found C & ~A & ~B
keep the handler, add a fourth arm to the union fails, at the handler that used to be complete

That third row is the property worth having: adding a case breaks every consumer.
It holds for isinstance chains and for match.

typing.assert_never is 3.11+ and this package supports 3.10, so the same
enforcement comes from a local helper taking a NoReturn parameter — verified to
produce identical diagnostics. When Workspace changed, ty flagged 35
diagnostics across every consumer
, which is the obligation propagation doing its
job.

The unreadable arm is reachable, not defensive

devpod's own workspace source carries image and container alongside
localFolder and gitRepository — read off the shipped binary's JSON struct
tags, not from documentation. So devpod up ubuntu:24.04 produces exactly the arm
that was being dropped in silence.

Not adding arms for image and container: all five call sites would treat them
identically to "unreadable", so they would be dead arms bought at the cost of two
more cases in every reader.

The five call sites

  • Repo discovery now warns, naming the workspace and the payload, instead of
    dropping it. A git source it read fine and found no GitHub owner/repo in stays
    quiet — that is an ordinary answer, not a defect, and warning about it would
    train users past the warning that matters. A warning rather than a debug line
    because dl pins logging at INFO with no verbosity flag, so a debug line here
    would be unreachable, which is the same silence.
  • The --purge ownership predicate (dl --purge deletes workspaces devlaunch never created #107's, merged in dl --purge deletes only the workspaces devlaunch created #129) keeps its
    behaviour and its refusal of non-local sources. Half of that refusal is now
    structural: the unreadable arm has no path to test for containment, so the
    mistake is no longer expressible. A git source does still carry a string that
    could be a path — devpod up <path-to-bare-repo> records a gitRepository, and
    nothing stops that repo living in the cache — so that arm is refused by name and
    stays under test.
  • dl --ls and the fzf picker were the two call sites that only ever wanted
    the tag. They now ask one function for both columns, so the kind shown and the
    detail shown cannot come from two different readings of the same source.
  • The listing parser refuses a source that is not an object at all, where an
    unreadable listing already has an answer (UnreadableWorkspaceList, from Stop reporting an unreadable workspace list as an empty one #122).
    It used to reach a substring test: "localFolder" in "/srv/localFolder/x" is
    True, and indexing a string with "localFolder" is a TypeError.

Verification

  • Full suite: 1088 passed, ty clean, ruff clean, pylint 10.00/10, and every
    added line of dl.py covered.
  • Mutation battery, 17 mutants, all killed — run in its own git worktree so
    nothing could sweep a live mutation into a commit. Covers every mutant dl --purge deletes workspaces devlaunch never created #107's
    battery covered (git source counts as ours, cache root counts as a clone,
    containment becomes a string prefix, everything is ours, the split puts
    everything in mine) plus eleven new ones for the arms, the parser and the
    display — including the two the review found surviving. Survivors: none.
  • On metal: dl --ls against the host's real devpod list renders unchanged,
    and a listing carrying {"image": "ubuntu:24.04"} was pushed through the real
    parse_workspaces → print_workspaces / describe_source /
    discover_repos_from_workspaces / workspace_ownership paths — the row shows
    up in the listing, the warning names it, discovery still finds the repo beside
    it, and ownership puts it in foreign.

This PR carries the release

A standalone Release 0.0.19 commit bumps pyproject.toml and
conda.recipe/recipe.yaml together and adds one ## [0.0.19] CHANGELOG
section covering four merged PRs, not just this one:

PR in the section as
#128 gate job (Added); CI runs on every pull request, not only those targeting main (Fixed)
#129 --purge deletes only devlaunch's own workspaces, and names what it leaves (Changed ×2); scoping XDG_CACHE_HOME now actually protects it (Fixed)
#130 the dev container mounts only the load-bearing ssh files (Fixed); the image builds from a checkout with a pixi environment in it (Fixed)
#127 a workspace's source is one value (Changed); the unreadable source is reported rather than dropped, and a listing dl cannot make sense of is refused rather than half-read (Fixed)

The 0.0.17 and 0.0.18 sections are untouched.

…ield

`Workspace` carried a `source_type` string beside a `source` string. Two of the
three tags said what `source` held -- a path for "local", a URL for "git" -- and
the third did not: "unknown" put `str(the raw devpod object)` into the same
field. The field was therefore a path for some values of the tag and a debug
repr for others, and nothing stopped `Workspace("ws", "git", "/home/dev/p", ...)`
from being built and believed.

The one reader that branched on the tag was repo discovery, an `if`/`elif` with
no `else`. The "unknown" arm fell off the end and left no trace, which is the
same outcome as a source it read fine and found no repo in.

Replaced with a sum type -- LocalFolder(path) | GitRepository(url) |
UnrecognisedSource(payload) -- so each arm carries only what that arm has, and
the arm devlaunch cannot read holds the object devpod sent rather than a
rendering of it. That arm is reachable rather than defensive: devpod's own
workspace source carries `image` and `container` alongside `localFolder` and
`gitRepository`, read off the shipped binary's JSON struct tags.

Every reader is now total over the arms, enforced by `ty` in CI via a local
NoReturn helper (`typing.assert_never` is 3.11+, this package supports 3.10).
Measured rather than assumed: an exhaustive handler passes, deleting an arm
fails, and adding a fourth arm to the union fails at the handler that used to
be complete.

- Repo discovery now warns, naming the workspace and the payload, instead of
  dropping it. A `git` source it read fine and found no GitHub owner/repo in
  stays quiet -- that is an ordinary answer, not a defect.
- The purge ownership predicate keeps its behaviour and its refusal of non-local
  sources. Half of that refusal is now structural: the unreadable arm has no
  path to test for containment, so the mistake is no longer expressible. A git
  source does still carry a string that could be a path, so that arm is refused
  by name and stays under test.
- `dl --ls` and the fzf picker ask one function for both columns, so the kind
  shown and the detail shown cannot come from two different readings.
- A `source` that is not an object at all is refused by the listing parser,
  where an unreadable listing already has an answer. It used to reach a
  substring test: `"localFolder" in "/srv/localFolder/x"` is True.

@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 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@sourcery-ai

sourcery-ai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Reviewer's Guide

Refactors workspace source representation from a string/tag pair into a typed sum type, updates all consumers to handle the new union exhaustively, and improves handling and reporting of unreadable sources and malformed devpod listings.

Flow diagram for parsing and handling workspace sources

flowchart LR
    A[devpod list JSON] --> B[parse_workspaces]
    B --> C{each workspace entry}
    C --> D[Workspace.from_json]
    D --> E[parse_workspace_source]
    E --> F{WorkspaceSource arm}
    F --> G[LocalFolder]
    F --> H[GitRepository]
    F --> I[UnrecognisedSource]

    G --> J[is_devlaunch_clone]
    H --> J
    I --> J

    G --> K[discover_repos_from_workspaces]
    H --> K
    I --> K

    G --> L[describe_source]
    H --> L
    I --> L

    L --> M[print_workspaces / fuzzy_select_workspace]

    I --> N[logging.warning in discover_repos_from_workspaces]
    B --> O[UnreadableWorkspaceList if source is not an object]
Loading

File-Level Changes

Change Details Files
Introduce a typed WorkspaceSource sum type (LocalFolder, GitRepository, UnrecognisedSource) and central helpers, replacing the previous source_type/source string pairing across the codebase.
  • Add LocalFolder, GitRepository, and UnrecognisedSource dataclasses plus the WorkspaceSource union type and an _unhandled_source helper enforcing exhaustiveness via NoReturn.
  • Implement parse_workspace_source to construct WorkspaceSource from devpod JSON source objects, and describe_source to produce (kind, detail) for display.
  • Change Workspace.source_type/source fields into a single source: WorkspaceSource and update Workspace.from_json to use parse_workspace_source with Mapping inputs.
devlaunch/dl.py
Update workspace consumers (purge ownership, repo discovery, listing/selection UI, and workspace parsing) to operate on WorkspaceSource arms, with explicit handling of unreadable sources and malformed listings.
  • Refactor is_devlaunch_clone to branch on LocalFolder/GitRepository/UnrecognisedSource using isinstance, making local-path purge decisions structural and refusing git/unrecognised arms explicitly.
  • Modify discover_repos_from_workspaces to switch on WorkspaceSource, warn on UnrecognisedSource with workspace id and payload, and keep git/local behaviours for repo discovery.
  • Update parse_workspaces to validate that each workspace source is a dict object, raising UnreadableWorkspaceList for non-object sources.
  • Change print_workspaces and fuzzy_select_workspace to use describe_source for both type and source columns, ensuring consistent representation across listing and fzf picker.
devlaunch/dl.py
Align and expand tests to cover the new WorkspaceSource representation, behaviour of all arms, repo discovery warnings, listing rendering, and listing rejection for non-object sources.
  • Update existing tests to construct Workspace with WorkspaceSource instances instead of source_type/source strings, and adjust assertions accordingly.
  • Refactor purge ownership tests to separately validate git-path-in-cache refusal and handling of UnrecognisedSource, matching the new structural semantics.
  • Add a dedicated test_workspace_source module that exercises parsing of each source arm, behaviour of UnrecognisedSource payload, describe_source outputs, repo discovery warnings/log levels, and parse_workspaces rejection of non-object sources.
test/test_dl.py
test/unit/test_purge_ownership.py
test/unit/test_workspace_source.py

Assessment against linked issues

Issue Objective Addressed Explanation
#127 Replace the Workspace.source_type tag plus parallel source field (including the "unknown" sentinel) with a sum type where each arm carries only its own value, and enforce exhaustive handling of source variants via the type system. ✅
#127 Ensure that workspaces whose source was previously represented by the "unknown" sentinel (e.g., image/container sources) are no longer silently dropped by repo discovery, and add tests that pin this behaviour so such sources are explicitly reported rather than skipped. ✅

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 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.03%. Comparing base (c2cb062) to head (a20d008).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #132      +/-   ##
==========================================
+ Coverage   93.21%   94.03%   +0.82%     
==========================================
  Files          18       18              
  Lines        2018     2046      +28     
==========================================
+ Hits         1881     1924      +43     
+ Misses        137      122      -15     
Files with missing lines Coverage Δ
devlaunch/dl.py 94.17% <100.00%> (+2.04%) ⬆️

Impacted file tree graph

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Austin Gregg-Smith added 3 commits August 8, 2026 15:01
…sentinel

`fuzzy_select_workspace` is one of the five call sites this change touches and
had no tests at all, so the arm this ticket is about could have been dropped
from the picker too and nothing would have said. It now has one: a workspace
devpod describes as an image is offered in the list, and picking that row maps
back to its id.

`_unhandled_source(...)` calls join `raise AssertionError` on coverage's
exclude list, for the same reason it is already there. `ty` runs in CI and
rejects any call to that helper that is reachable, so in a build that
type-checks those lines cannot execute -- counting them as missed coverage asks
for a test that cannot be written without first breaking the type check.
Review finding, measured rather than argued: `git -C "" remote get-url origin`
exits 0, because `-C ""` is a no-op. So a `LocalFolder("")` reaching repo
discovery would be credited with whatever repository the person running `dl`
happened to be standing in. The guard against it was a truthiness test one level
inside the arm -- the same defect this change exists to remove, an empty string
acting as a sentinel inside the arm that is supposed to mean "a folder dl can
read" -- and deleting that guard left the whole suite green, because every
discovery test mocks the remote lookup.

Rejected at the parse boundary instead, where it becomes an unreadable source:
the arm now cannot hold an empty path or an empty URL, and the guard is gone
rather than tested.

Also pinned the parser's key precedence. That is the one link in the chain the
type checker cannot cover: a reader that misses an arm is a build failure, but
this is a producer, and a producer that quietly stops producing an arm
type-checks fine.
Two review findings, both measured on the branch rather than argued.

`{"localFolder": {"str_of_a_dict": "is back"}}` built a `LocalFolder` whose
`path` was a dict -- literally the defect this change exists to remove, one
level in -- and both readers that treat it as a path raised `TypeError` on it.
devpod's listing is JSON dl did not write, so a key now has to be non-empty text
before it counts as a folder or a URL; anything else is a source dl cannot read,
which is an arm, and the object is kept whole in it. Not an unreadable listing:
unlike a `source` that is not an object at all, there is an object here to keep.

The other half of the ticket's red test was vacuous. It asserted the warning
mentions "image" against a workspace called `from-an-image`, so stripping the
payload out of the message left it passing. Both halves now assert on strings
that appear nowhere else -- the id is `ws-42`, and the payload is the image tag.

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

Two axes, run as independent agents in fresh context, each in its own git worktree so that nothing mutating could reach the branch while it was being committed to. Neither axis's findings were merged or reranked into the other's.

Standards

1. LocalFolder("") was representable, and the guard against it was load-bearing and untested. Measured, not argued: git -C "" remote get-url origin exits 0, because -C "" is a no-op — so a workspace with an empty folder would have been credited with whatever repository the person running dl happened to be standing in. The truthiness guard inside the arm was the only thing stopping it, and deleting that guard left 1076 passed, because every discovery test mocks the remote lookup. That is this ticket's own defect class one level down: an empty string acting as a sentinel inside the arm that is supposed to mean "a folder dl can read".

2. Key precedence was documented as load-bearing and was the one link the checker cannot cover. Swapping the two ifs survived the suite. Adding a fourth arm to the union produced 3 diagnostics — at each reader — and none at the parser, because a constructor is covariant. So a future arm would break every reader and then silently never be produced.

3. Repeated Switches — argued, and it is not one. Fowler's smell bites because a missed case is silent; measured here it is a build failure at 3/3 readers. Collapsing to polymorphism would drag display strings, the cache directory, and logging plus ws.id — which a source does not own — onto three-line records: Feature Envy traded for a smell the checker already pays for. Correct shape; left alone.

4. _unhandled_source — justified. It is the documented pre-3.11 assert_never; typing_extensions is not a dependency and py310 is in the CI matrix.

5. describe_source -> Tuple[str, str] — representable-but-harmless. Two values, two call sites, unpacked immediately. Noted and left.

Spec

Re-measured rather than read, per this map's fifth principle.

Satisfied. ty enforcement holds — deleting an arm errors, and adding a fourth arm to WorkspaceSource raises a diagnostic at each of the three readers. #107's --purge guard is carried and its battery stays dead: six purge mutants, all killed. The ticket's ask to check all five call sites first was done before building, and all five are handled. #131 was not touched (shutil.rmtree is untouched, no EACCES handling in the diff) and is marginally easier afterwards, being the same principle-3 shape.

Blocking — 1. The sentinel was still constructible at the parse boundary. Measured:

parse_workspace_source({"localFolder": {"str_of_a_dict": "is back"}})
  -> LocalFolder(path={'str_of_a_dict': 'is back'})

is_devlaunch_clone and discover_repos_from_workspaces then both raise TypeError. Exactly the defect class this PR fixed one level up.

Blocking — 2. Half of the ticket's own red test was vacuous. Stripping the payload out of the warning message survived all 1076 tests: assert "image" in caplog.text passes on the workspace id "from-an-image" alone.

Non-blocking. The PR body overclaimed that "a workspace whose tag says git and whose value is a local path" is unrepresentable — GitRepository("/home/dev/project") is constructible, and the PR's own test deliberately builds one. Body corrected.

What changed in response

All four actionable findings are fixed on the branch.

  • A key must be non-empty text before it counts as a folder or a URL. Anything else — empty, a nested object, a number, a list, null — is a source dl cannot read, and the object is kept whole in that arm. Deliberately not an unreadable listing: unlike a source that is not an object at all, there is an object here to keep.
  • The empty-value truthiness guard inside repo discovery is deleted rather than tested, because the value can no longer arrive.
  • Key precedence is pinned by a test, with the covariance gap stated in the docstring.
  • Both halves of the red test now assert on strings that appear nowhere else: the workspace id is ws-42, and the payload assertion is on the image tag.
  • The fuzzy picker — one of the five call sites, and the one with no tests at all before this — now has one: a workspace dl cannot read is offered in the list and selecting that row maps back to its id.

Mutation battery re-run against the fixed head: 17 mutants, 17 killed, no survivors — including the two the Spec axis found surviving. Run in its own worktree.

Verdict

Approve. No blocking findings outstanding. Both blocking Spec findings and both actionable Standards findings are fixed and pinned by tests that were red first. describe_source returning a bare pair is recorded as a nit and deliberately not changed.

@blooop
blooop merged commit c2892fa into main Aug 8, 2026
11 checks passed
@blooop
blooop deleted the wayfinder/devlaunch-127 branch August 8, 2026 14:22
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.

Workspace.source_type is a tag beside a parallel field, and its sentinel arm is dropped silently

1 participant