Skip to content
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`dl --ls` says which workspaces run an older image than their reference names** (#673).
A pull moves an image reference and leaves every running container on the image it was
created from, so long-lived workspaces drifted days behind and nothing said so. `dl --ls`
now prints one line under the table for each such workspace, naming the reference and
`dl <ws> recreate`, and `dl --ls --json` appends a `staleImage` key to its row. The check
reads devpod's `workspace_result.json` and asks docker for the container's image and the
reference's current one; where devpod built a derived image on top of the declared one,
it compares layers. Every doubt reads as current.

## [0.60.1] - 2026-10-16

### Changed
Expand Down
8 changes: 8 additions & 0 deletions docs/agents-using-dl.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ included, is a reason not to destroy anything without a person in the loop, and
caller that tests for `wouldLose` alone will force-delete exactly the rows `rm` itself
would have stopped at.

Two keys are appended only when they have something to say, so a caller must not
expect them. `lastSweep` is described in [cleanup.md](cleanup.md). `staleImage` is a
string, the image reference the workspace's container is behind: the container runs an
older image than that reference now names, because a pull moved the reference and only a
new container picks it up. `dl <ws> recreate` moves it. The key is absent on a current
row and also on a row `dl` could not read about (no docker, no devpod result, a
reference this machine never pulled), so absence is not proof that a row is current.

`--json` is currently `--ls` only. Other commands report in English, so a caller that
creates a workspace and then needs to address it should derive the id from a subsequent
`--ls --json` rather than parsing the launch.
Expand Down
30 changes: 30 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2559,6 +2559,8 @@ pub devlaunch_core::flows::listing::DlView::cache_dir: &'a std::path::Path
pub devlaunch_core::flows::listing::DlView::clones: &'a dyn devlaunch_core::flows::listing::ClonePathResolver
pub devlaunch_core::flows::listing::DlView::storage: &'a devlaunch_core::domain::metadata::MetadataStorage
pub struct devlaunch_core::flows::listing::ListedWorkspace
impl devlaunch_core::flows::listing::ListedWorkspace
pub fn devlaunch_core::flows::listing::ListedWorkspace::id(&self) -> &str
impl core::clone::Clone for devlaunch_core::flows::listing::ListedWorkspace
pub fn devlaunch_core::flows::listing::ListedWorkspace::clone(&self) -> devlaunch_core::flows::listing::ListedWorkspace
impl core::cmp::Eq for devlaunch_core::flows::listing::ListedWorkspace
Expand Down Expand Up @@ -2652,6 +2654,7 @@ pub fn devlaunch_core::flows::listing::describe_source(&devlaunch_core::clients:
pub fn devlaunch_core::flows::listing::discover_repos_from_workspaces(&devlaunch_core::clients::git::Git<'_>, &[devlaunch_core::clients::devpod::Workspace]) -> devlaunch_core::flows::listing::RepoDiscovery
pub fn devlaunch_core::flows::listing::flatten_repos(&indexmap::map::IndexMap<alloc::string::String, alloc::vec::Vec<alloc::string::String>>) -> alloc::vec::Vec<alloc::string::String>
pub fn devlaunch_core::flows::listing::head_branch_of(&devlaunch_core::clients::devpod::Workspace, &std::path::Path) -> core::option::Option<alloc::string::String>
pub fn devlaunch_core::flows::listing::mark_stale_images(&mut [devlaunch_core::flows::listing::ListedWorkspace], &devlaunch_core::flows::stale_images::StaleImages)
pub fn devlaunch_core::flows::listing::outstanding_sweep_notes(&devlaunch_core::domain::metadata::MetadataStorage) -> alloc::vec::Vec<devlaunch_core::flows::listing::SweptRepoNote>
pub fn devlaunch_core::flows::listing::owner_of(&devlaunch_core::clients::devpod::Workspace, &std::path::Path) -> core::option::Option<alloc::string::String>
pub fn devlaunch_core::flows::listing::repo_of(&devlaunch_core::clients::devpod::Workspace, &std::path::Path) -> core::option::Option<alloc::string::String>
Expand Down Expand Up @@ -3189,6 +3192,33 @@ impl core::fmt::Debug for devlaunch_core::flows::session_manager::PaneDestinatio
pub fn devlaunch_core::flows::session_manager::PaneDestination::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::session_manager::PaneDestination
pub fn devlaunch_core::flows::session_manager::pane_destination(&dyn devlaunch_runner::Runner) -> devlaunch_core::flows::session_manager::PaneDestination
pub mod devlaunch_core::flows::stale_images
pub struct devlaunch_core::flows::stale_images::StaleImage
impl devlaunch_core::flows::stale_images::StaleImage
pub fn devlaunch_core::flows::stale_images::StaleImage::reference(&self) -> &str
impl core::clone::Clone for devlaunch_core::flows::stale_images::StaleImage
pub fn devlaunch_core::flows::stale_images::StaleImage::clone(&self) -> devlaunch_core::flows::stale_images::StaleImage
impl core::cmp::Eq for devlaunch_core::flows::stale_images::StaleImage
impl core::cmp::PartialEq for devlaunch_core::flows::stale_images::StaleImage
pub fn devlaunch_core::flows::stale_images::StaleImage::eq(&self, &devlaunch_core::flows::stale_images::StaleImage) -> bool
impl core::fmt::Debug for devlaunch_core::flows::stale_images::StaleImage
pub fn devlaunch_core::flows::stale_images::StaleImage::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::stale_images::StaleImage
pub struct devlaunch_core::flows::stale_images::StaleImages
impl devlaunch_core::flows::stale_images::StaleImages
pub fn devlaunch_core::flows::stale_images::StaleImages::is_empty(&self) -> bool
pub fn devlaunch_core::flows::stale_images::StaleImages::of(&self, &str) -> core::option::Option<&devlaunch_core::flows::stale_images::StaleImage>
impl core::clone::Clone for devlaunch_core::flows::stale_images::StaleImages
pub fn devlaunch_core::flows::stale_images::StaleImages::clone(&self) -> devlaunch_core::flows::stale_images::StaleImages
impl core::cmp::Eq for devlaunch_core::flows::stale_images::StaleImages
impl core::cmp::PartialEq for devlaunch_core::flows::stale_images::StaleImages
pub fn devlaunch_core::flows::stale_images::StaleImages::eq(&self, &devlaunch_core::flows::stale_images::StaleImages) -> bool
impl core::default::Default for devlaunch_core::flows::stale_images::StaleImages
pub fn devlaunch_core::flows::stale_images::StaleImages::default() -> devlaunch_core::flows::stale_images::StaleImages
impl core::fmt::Debug for devlaunch_core::flows::stale_images::StaleImages
pub fn devlaunch_core::flows::stale_images::StaleImages::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::stale_images::StaleImages
pub fn devlaunch_core::flows::stale_images::stale_images<'w>(&dyn devlaunch_runner::Runner, core::option::Option<&devlaunch_core::clients::devpod_home::DevpodHome>, impl core::iter::traits::collect::IntoIterator<Item = &'w str>) -> devlaunch_core::flows::stale_images::StaleImages
pub mod devlaunch_core::flows::workspace_clone
pub enum devlaunch_core::flows::workspace_clone::EnsureBranchError
pub devlaunch_core::flows::workspace_clone::EnsureBranchError::Branch(devlaunch_core::flows::branch_manager::BranchError)
Expand Down
27 changes: 27 additions & 0 deletions rust/devlaunch-core/src/clients/docker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,33 @@ pub(crate) fn exec_as_root(
read(runner, &args, READ_INSIDE)
}

/// A `--format`ted `docker inspect` over these names: what it printed, whatever
/// it exited.
///
/// Whatever it exited, because `docker inspect` over several names prints a line
/// for every name it found and then exits 1 if any one of them was missing. A
/// container removed by hand, or an image reference this machine never pulled,
/// would otherwise cost every other name its answer. So each format the callers
/// pass puts the name's id in the line, and the caller matches on that id and
/// never on the line's position.
///
/// `None` is a docker that did not run at all, which every caller reads as "could
/// not tell".
pub(crate) fn inspect_formatted(
runner: &dyn Runner,
kind: &str,
format: &str,
names: &NonEmpty<String>,
) -> Option<String> {
let mut args = vec!["inspect", "--type", kind, "--format", format];
args.extend(names.iter().map(String::as_str));
let spec = SpawnSpec::from(Invocation::new(PROGRAM).with_args(args.iter().copied()))
.with_timeout(INSPECT_THEM);
ran(runner.capture(&spec))
.ok()
.map(|(_, CapturedText { stdout, .. })| stdout)
}

/// One bounded docker read: its stdout when it exited 0, and otherwise words
/// for why not.
fn read(runner: &dyn Runner, args: &[&str], timeout: Duration) -> Result<String, String> {
Expand Down
63 changes: 63 additions & 0 deletions rust/devlaunch-core/src/flows/listing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ use crate::domain::model::{SweepNote, WorktreeInfo};
use crate::domain::workspace_state::{BareCache, CouldNotTell, NonEmpty};
use crate::flows::agent_worktrees::{self, Verdict};
use crate::flows::disk_usage::{self, DiskUsage};
use crate::flows::stale_images::{StaleImage, StaleImages};
use crate::runner::Runner;
use crate::timing;

Expand Down Expand Up @@ -1002,6 +1003,28 @@ pub struct ListedWorkspace {
/// to carry it at all. [`outstanding_sweep_notes`] is the reading that misses
/// none of them, and is what `dl --ls` prints under the table.
pub(crate) sweep: Option<SweepNote>,
/// The reference this workspace's container is behind, where it is behind
/// one (devlaunch#673). Filled by [`mark_stale_images`] after the rows are
/// built, because the answer comes from one batched docker read over every
/// row and not from a question each row asks alone.
pub(crate) stale_image: Option<StaleImage>,
}

impl ListedWorkspace {
/// devpod's id for the workspace this row is about.
pub fn id(&self) -> &str {
&self.id
}
}

/// Fill in which rows run an older image than their reference now names.
///
/// Apart from [`enriched_listing`] so that its signature, which `wf` calls, does
/// not move. A row this reads nothing about keeps `None`.
pub fn mark_stale_images(rows: &mut [ListedWorkspace], stale: &StaleImages) {
for row in rows {
row.stale_image = stale.of(&row.id).cloned();
}
}

/// The enriched listing: one row per workspace devpod lists, in devpod's order.
Expand Down Expand Up @@ -1206,6 +1229,7 @@ fn enriched_row(
clone,
disk: DiskField::of(sizes, measurable.as_deref()),
sweep,
stale_image: None,
}
}

Expand Down Expand Up @@ -1371,6 +1395,16 @@ fn json_row(row: &ListedWorkspace) -> serde_json::Value {
if let Some(note) = &row.sweep {
insert(&mut value, "lastSweep", sweep_as_json(note));
}
// Appended, and absent unless stale, for the reason `lastSweep` is: a
// machine with nothing stale prints the document it printed before this key
// existed.
if let Some(stale) = &row.stale_image {
insert(
&mut value,
"staleImage",
serde_json::Value::String(stale.reference().to_owned()),
);
}
value
}

Expand Down Expand Up @@ -2712,6 +2746,35 @@ mod tests {
assert!(outstanding_sweep_notes(&scene.storage).is_empty());
}

#[test]
fn a_stale_row_names_its_reference_last_and_the_others_say_nothing() {
// devlaunch#673. Appended after every key `wf` already reads, and absent
// on a current row, so a machine with nothing stale prints the golden.
let scene = Scene::build();
let mut rows = scene.rows(Sizes::Skip);
let stale = StaleImages::of_pairs([("someone-elses", "ghcr.io/o/img:latest")]);
let clean = rows
.iter_mut()
.find(|row| row.id == scene.id("clean"))
.expect("a clean row");
clean.stale_image = StaleImages::of_pairs([("clean", "ghcr.io/o/img:old")])
.of("clean")
.cloned();

mark_stale_images(&mut rows, &stale);
let document = json_document(&rows);
let rows = rows_by_id(&document);

let row = rows["someone-elses"].as_object().expect("an object");
assert_eq!(
row.iter()
.next_back()
.map(|(key, value)| (key.as_str(), value)),
Some(("staleImage", &serde_json::json!("ghcr.io/o/img:latest")))
);
assert!(rows[scene.id("clean")].get("staleImage").is_none());
}

#[test]
fn a_note_with_nothing_quoted_still_says_which_trouble_it_was() {
// `said: null` rather than an absent key or an empty string: a fetch killed
Expand Down
2 changes: 2 additions & 0 deletions rust/devlaunch-core/src/flows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ pub mod repo_manager;
// it is one decision and no socket.
pub mod session_manager;
// binary surface — not part of the frozen wf API (#251 §7)
pub mod stale_images;
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
// binary surface — not part of the frozen wf API (#251 §7)
pub mod workspace_clone;

// Binary surface, outside the frozen workspace API.
Expand Down
Loading
Loading