Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.59.4] - 2026-10-05

### Fixed
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