Skip to content
Merged
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`dl rmt` deletes the workspace a herdr tab is named after.** A launch names its herdr tab
`repo@branch`, and the name stays after the session closes. Typed in that tab, `dl rmt` reads
the name with `herdr tab get`, finds the one workspace devpod lists with that name, prints its
id, deletes it with the guard `rm` has (`--force` as for `rm`), and then closes the tab by
hanging up its shell, as `rme` does. No workspace name and no selector. A tab name that
matches no workspace, or two, is refused, and the tab stays open.

## [0.61.0] - 2026-10-16

### Added
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ there to answer. [docs/cli.md](docs/cli.md) has the rest.
| `dl <ws> kill` | Kill whatever is holding a workspace that will not answer, then delete it |
| `dl <ws> rm` | Delete it. Refuses if the clone holds work that is nowhere else |
| `dl <ws> rme` | The same delete, and then the shell: it closes the terminal it was typed in |
| `dl rmt` | In a [herdr](https://herdr.dev) tab: delete the workspace the tab is named after, then close the tab as `rme` does. No selector |
| `dl <ws> code` | Open it in VS Code |
| `dl <ws> restart` | Stop and start, no rebuild |
| `dl <ws> recreate` | Recreate the container |
Expand All @@ -273,6 +274,12 @@ now; `dl <ws> --rm` deletes once the session ends, the way `docker run --rm` doe
work that exists nowhere else: a clone with uncommitted or unpushed changes is kept and named,
and `dl <ws> rm --force` is how you override that.

`rmt` is for the herdr tab a session ran in. A launch names the tab after its workspace
(`devlaunch@main`), and the name stays after the session closes. Type `dl rmt` in that tab and
it deletes that workspace and then closes the tab, as `rme` does, with the same guard and the
same `--force`. It prints the workspace id before it deletes anything, and it refuses a tab
name that no workspace or two workspaces carry. See [docs/cli.md](docs/cli.md#rmt-the-workspace-this-herdr-tab-is-named-after).

`rme` is neither of those. It deletes the workspace now, as `rm` does, and then hangs up the
shell that asked, so the terminal tab you opened for that one workspace closes on its own
instead of waiting out the delete and then wanting an `exit`. A delete that refused leaves the
Expand Down
30 changes: 30 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,36 @@ background jobs with it, because that is what a shell does on SIGHUP. Use `rme`
the tab that has nothing left in it, which is the tab it was written for, and `rm` in
the one you are still working in.

### `rmt`: the workspace this herdr tab is named after

`dl rmt` deletes a workspace without a name on the line and without the selector. The
workspace is the one the herdr tab you type it in is named after.

A launch in a herdr pane renames its tab after the workspace's record, `repo@branch`, and
herdr keeps that name after the session ends. So when you close an `aid` session, the shell
left in the tab still knows which workspace the tab was for. `dl rmt` asks herdr for the
tab's name (`herdr tab get $HERDR_TAB_ID`) and deletes that workspace:

```
$ dl rmt
This tab is named 'devlaunch@main', which is workspace devlaunch-main-3j1t.
Removing workspace devlaunch-main-3j1t...
...
Hanging up the shell dl was called from (pid 4242).
```

The name becomes an id in one of two ways. A name that is a workspace id devpod lists is
that workspace, because a launch of a workspace `dl` holds no record for names the tab with
the id. Any other name is looked up in `metadata.json`, and only workspaces devpod still lists can answer, so the record
of a workspace deleted long ago cannot. A tab nobody renamed carries its number, which names
no workspace. No match and two matches are both refused, and nothing is deleted.

After that it is `rme`. The guard stops at work that is nowhere else, and `dl rmt --force`
is the way past it. Once the workspace has gone, `dl` hangs up the shell it was typed in, so
the tab closes. A refusal leaves the shell open with the reason on screen.
[`rme`](#rme-the-delete-and-then-the-shell) has what the hangup does and when it is skipped.
`rmt` takes no workspace name, no `--rm` and no command. Outside a herdr tab it refuses and asks herdr nothing.

### `--stop` and `--autorm` are retired

Both moved because `--rm` changed meaning, and both are still recognised so that a
Expand Down
17 changes: 17 additions & 0 deletions rust/devlaunch-core/public-api.rest.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2079,6 +2079,7 @@ pub fn devlaunch_core::flows::launch::UnquotableCommand::fmt(&self, &mut core::f
impl core::marker::StructuralPartialEq for devlaunch_core::flows::launch::UnquotableCommand
pub fn devlaunch_core::flows::launch::names_for(&devlaunch_core::flows::launch::Host, &str) -> (devlaunch_core::flows::launch::TerminalTitle, devlaunch_core::flows::launch::HerdrTabRename)
pub fn devlaunch_core::flows::launch::plan(&str) -> core::result::Result<devlaunch_core::flows::launch::Plan, devlaunch_core::domain::workspace_id::UnsafeName>
pub fn devlaunch_core::flows::launch::recorded_ids_labelled(&devlaunch_core::domain::metadata::MetadataStorage, &str, impl core::ops::function::Fn(&str) -> bool) -> alloc::vec::Vec<alloc::string::String>
pub fn devlaunch_core::flows::launch::recorded_label_in(&std::path::Path, &str) -> core::option::Option<alloc::string::String>
pub fn devlaunch_core::flows::launch::resolve_triple(&mut devlaunch_core::flows::listing::CommandContext<'_>, &mut dyn devlaunch_core::flows::launch::ColdMachinery<'_>, &devlaunch_core::domain::workspace_id::WorkspaceId, &mut dyn devlaunch_core::notices::Notices<devlaunch_core::flows::launch::LaunchNotice>, devlaunch_core::clients::devpod::Patience) -> core::result::Result<devlaunch_core::flows::launch::Resolution, devlaunch_core::clients::devpod::NotRun>
pub mod devlaunch_core::flows::launch_locks
Expand Down Expand Up @@ -3365,7 +3366,23 @@ pub fn devlaunch_core::flows::session_manager::PaneDestination::eq(&self, &devla
impl core::fmt::Debug for devlaunch_core::flows::session_manager::PaneDestination
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 enum devlaunch_core::flows::session_manager::ThisTab
pub devlaunch_core::flows::session_manager::ThisTab::Named
pub devlaunch_core::flows::session_manager::ThisTab::Named::label: alloc::string::String
pub devlaunch_core::flows::session_manager::ThisTab::Named::tab_id: alloc::string::String
pub devlaunch_core::flows::session_manager::ThisTab::NotInHerdr
pub devlaunch_core::flows::session_manager::ThisTab::Unanswered
pub devlaunch_core::flows::session_manager::ThisTab::Unanswered::tab_id: alloc::string::String
impl core::clone::Clone for devlaunch_core::flows::session_manager::ThisTab
pub fn devlaunch_core::flows::session_manager::ThisTab::clone(&self) -> devlaunch_core::flows::session_manager::ThisTab
impl core::cmp::Eq for devlaunch_core::flows::session_manager::ThisTab
impl core::cmp::PartialEq for devlaunch_core::flows::session_manager::ThisTab
pub fn devlaunch_core::flows::session_manager::ThisTab::eq(&self, &devlaunch_core::flows::session_manager::ThisTab) -> bool
impl core::fmt::Debug for devlaunch_core::flows::session_manager::ThisTab
pub fn devlaunch_core::flows::session_manager::ThisTab::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::marker::StructuralPartialEq for devlaunch_core::flows::session_manager::ThisTab
pub fn devlaunch_core::flows::session_manager::pane_destination(&dyn devlaunch_runner::Runner) -> devlaunch_core::flows::session_manager::PaneDestination
pub fn devlaunch_core::flows::session_manager::this_tab(&dyn devlaunch_runner::Runner) -> devlaunch_core::flows::session_manager::ThisTab
pub mod devlaunch_core::flows::stale_images
pub struct devlaunch_core::flows::stale_images::StaleImage
impl devlaunch_core::flows::stale_images::StaleImage
Expand Down
37 changes: 37 additions & 0 deletions rust/devlaunch-core/src/clients/herdr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,43 @@ pub(crate) fn process_info_in(answer: &str) -> Option<PaneProcessInfo> {
.map(|envelope| envelope.result.process_info)
}

/// The argv that asks herdr about one tab.
///
/// `herdr tab get <TAB_ID>`, the `tab.get` method of herdr's socket API, taken from
/// its published schema (`docs/next/api/herdr-api.schema.json`) and its tab CLI
/// bindings rather than measured here.
pub(crate) fn tab_get_argv(tab_id: &str) -> Vec<String> {
vec!["tab".to_owned(), "get".to_owned(), tab_id.to_owned()]
}

/// The label in `herdr tab get`'s answer: `{"result":{"type":"tab_info","tab":{…}}}`.
///
/// The label is what `herdr tab rename` wrote, which is the name a launch gave the
/// tab. A tab nobody renamed answers its number instead, and that is still a
/// label: it names no workspace, and the caller finds that out by looking.
/// A label of only whitespace is no label.
pub(crate) fn tab_label_in(answer: &str) -> Option<String> {
#[derive(Deserialize)]
struct Envelope {
result: Info,
}
#[derive(Deserialize)]
struct Info {
tab: Tab,
}
#[derive(Deserialize)]
struct Tab {
label: String,
}
let label = serde_json::from_str::<Envelope>(answer)
.ok()?
.result
.tab
.label;
let label = label.trim();
(!label.is_empty()).then(|| label.to_owned())
}

/// The argv that has herdr type `command` into one pane's shell and run it.
///
/// One string, as herdr takes it: herdr types it as written, so `command` is
Expand Down
84 changes: 84 additions & 0 deletions rust/devlaunch-core/src/flows/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4471,6 +4471,52 @@ fn label_in(records: &MetadataStorage, workspace_id: &str) -> Option<String> {
)
}

/// The workspaces a tab name was given to, read backwards out of the records.
///
/// [`label_in`] the other way round. A launch names the herdr tab and the pane
/// after the record (`devlaunch@main`), and that name outlives the session: the
/// tab is closed, the container is still running, and the name is the one thing
/// the person still has in their head. `dl rmt` typed in that tab reads the name,
/// and this is how it finds the workspace behind it.
///
/// **Only ids `listed` admits come back**, and which of a record's two ids is
/// asked first is [`stored_as`]'s order reversed on purpose: `devpod_workspace_id`
/// is the name devpod was actually given, so it is the one a listing holds. A
/// record nothing lists is a workspace deleted months ago -- `metadata.json` is
/// append-mostly -- and must not turn a tab name into an id devpod has never
/// heard of.
///
/// **More than one answer is an answer.** Names need not be unique: two owners'
/// forks of one repo on one branch carry the same label, and so do two records of
/// one triple under different id schemes. The caller refuses rather than picks,
/// because the verb this serves, `rmt`, deletes.
#[must_use]
pub fn recorded_ids_labelled(
records: &MetadataStorage,
label: &str,
listed: impl Fn(&str) -> bool,
) -> Vec<String> {
let mut ids: Vec<String> = records
.list_worktrees(crate::domain::metadata::WorktreeFilter::All)
.into_iter()
.filter(|record| {
WorkspaceId::new(&record.owner, &record.repo, &record.branch)
.is_ok_and(|workspace| workspace.label() == label)
})
.filter_map(|record| {
record
.devpod_workspace_id
.iter()
.chain(std::iter::once(&record.workspace_id))
.find(|id| listed(id))
.cloned()
})
.collect();
ids.sort();
ids.dedup();
ids
}

/// The devpod workspace id `metadata.json` holds for a triple, if any.
///
/// A store that cannot be opened answers `None`, which is Python's reading: a
Expand Down Expand Up @@ -12916,6 +12962,44 @@ mod tests {
);
}

#[test]
fn a_tab_name_reads_back_to_the_listed_workspaces_that_carry_it() {
// The tab says `devlaunch@main` long after the session that named it has
// gone, and that name is what `dl rmt` reads from the tab. The
// record that names the tab is the record that answers, and only for ids
// devpod still lists: a stale record must not become an id to act on.
let scene = Scene::new();
record_worktree(
scene.cache_dir(),
"blooop",
"devlaunch",
"main",
"devlaunch-main-legacy",
);
record_worktree(
scene.cache_dir(),
"someone",
"devlaunch",
"main",
"devlaunch-main-fork",
);
let records = MetadataStorage::look(MetadataStorage::path_in(scene.cache_dir()));

assert_eq!(
recorded_ids_labelled(&records, "devlaunch@main", |id| id
== "devlaunch-main-legacy"),
["devlaunch-main-legacy"],
);
// Two forks on one branch are one tab name, and both come back: which one
// the person meant is theirs to say.
assert_eq!(
recorded_ids_labelled(&records, "devlaunch@main", |_| true),
["devlaunch-main-fork", "devlaunch-main-legacy"],
);
assert!(recorded_ids_labelled(&records, "devlaunch@main", |_| false).is_empty());
assert!(recorded_ids_labelled(&records, "devlaunch@other", |_| true).is_empty());
}

#[test]
fn a_record_whose_triple_will_not_parse_is_walked_past_rather_than_answered_with() {
// `is_safe_name` refuses an empty ref, and nothing stops such a record
Expand Down
125 changes: 125 additions & 0 deletions rust/devlaunch-core/src/flows/session_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,65 @@ pub fn pane_destination(runner: &dyn Runner) -> PaneDestination {
)
}

/// What herdr calls the tab this process runs in.
///
/// Three arms, because `dl rmt` says something different for each: a shell outside
/// herdr has no tab to ask about, a herdr that did not answer is not a tab with no
/// name, and a name is what the removal looks the workspace up by.
#[derive(Clone, Debug, PartialEq, Eq)]
// binary surface -- not part of the frozen wf API (#251 §7)
pub enum ThisTab {
/// [`launch::HERDR_TAB_VAR`] is not set: this is not a herdr pane.
NotInHerdr,
/// herdr was asked about the tab and gave no label: it refused, timed out, was
/// not found, or answered something that is not a `tab_info`.
Unanswered { tab_id: String },
/// The tab's label, as `herdr tab get` reports it.
Named { tab_id: String, label: String },
}

/// The label of the herdr tab this process runs in, asked of herdr.
///
/// The same environment [`pane_destination`] reads -- the tab id from
/// [`launch::HERDR_TAB_VAR`], the binary through [`herdr::binary_from_process`] --
/// and the same bound on herdr's answer, so a herdr that has gone away costs
/// [`herdr::ANSWER_WITHIN`] and not a hang.
pub fn this_tab(runner: &dyn Runner) -> ThisTab {
let tab_id = crate::osext::env_str(launch::HERDR_TAB_VAR);
let binary = herdr::binary_from_process();
tab_for(
runner,
PaneEnv {
tab_id: tab_id.as_deref(),
binary: binary.as_deref(),
},
)
}

/// [`this_tab`], against a stated environment rather than this process's.
fn tab_for(runner: &dyn Runner, host: PaneEnv<'_>) -> ThisTab {
let Some(tab_id) = host.tab_id.filter(|id| !id.is_empty()) else {
return ThisTab::NotInHerdr;
};
let binary = host
.binary
.filter(|path| !path.is_empty())
.unwrap_or(launch::HERDR_BIN_FALLBACK);
let mut budget = Budget::of(herdr::ANSWER_WITHIN);
match ask(runner, binary, &herdr::tab_get_argv(tab_id), &mut budget)
.as_deref()
.and_then(herdr::tab_label_in)
{
Some(label) => ThisTab::Named {
tab_id: tab_id.to_owned(),
label,
},
None => ThisTab::Unanswered {
tab_id: tab_id.to_owned(),
},
}
}

/// What herdr exported into this pane, as the pane shell reads it.
///
/// A struct rather than two `Option<&str>` parameters, which is what this was:
Expand Down Expand Up @@ -1831,6 +1890,72 @@ mod tests {
assert_eq!(runner.call_count(), 0);
}

/// A `tab get` answer in herdr's envelope, with this label. The fields this
/// does not read are written out for the reason [`pane_list`] gives.
fn tab_info(label: &str) -> String {
format!(
r#"{{"id":"cli:tab:get","result":{{"type":"tab_info","tab":{{"tab_id":"w1:t1","workspace_id":"w1","number":4,"label":"{label}","focused":true,"pane_count":1,"agent_status":"none"}}}}}}"#
)
}

#[test]
fn this_tab_reads_the_label_a_launch_renamed_it_to() {
let runner = ScriptedRunner::new();
runner.on_unscripted(Unscripted::Panic);
runner.script(
[LENT_HERDR, "tab", "get", "w1:t1"],
Response::stdout(tab_info("devlaunch@main")),
);
assert_eq!(
tab_for(&runner, in_pane_of("w1:t1")),
ThisTab::Named {
tab_id: "w1:t1".to_owned(),
label: "devlaunch@main".to_owned(),
}
);
}

#[test]
fn this_tab_outside_herdr_asks_nothing() {
let runner = ScriptedRunner::new();
runner.on_unscripted(Unscripted::Panic);
for tab_id in [None, Some("")] {
assert_eq!(
tab_for(
&runner,
PaneEnv {
tab_id,
..PaneEnv::default()
}
),
ThisTab::NotInHerdr
);
}
assert_eq!(runner.call_count(), 0);
}

#[test]
fn a_herdr_that_refuses_or_answers_no_label_is_unanswered() {
for response in [
Response::failed(
1,
r#"{"id":"cli:tab:get","error":{"code":"tab_not_found"}}"#,
),
Response::stdout(tab_info(" ")),
Response::stdout("not json"),
] {
let runner = ScriptedRunner::new();
runner.on_unscripted(Unscripted::Panic);
runner.script([LENT_HERDR, "tab", "get", "w1:t1"], response);
assert_eq!(
tab_for(&runner, in_pane_of("w1:t1")),
ThisTab::Unanswered {
tab_id: "w1:t1".to_owned()
}
);
}
}

#[test]
fn a_tab_holding_a_session_puts_the_pane_in_its_workspace() {
let runner = ScriptedRunner::new();
Expand Down
Loading
Loading