Compare commits
163
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
975f3ef0c5 | ||
|
|
c2ac3f7eb6 | ||
|
|
c118020b29 | ||
|
|
7cb14c7aae | ||
|
|
80a9399905 | ||
|
|
9d9a2e723d | ||
|
|
bbafff5a98 | ||
|
|
17e1dc4049 | ||
|
|
436cb194c3 | ||
|
|
6471da2624 | ||
|
|
2a43c76176 | ||
|
|
f689c916bf | ||
|
|
88233cb96d | ||
|
|
54d84e3eaa | ||
|
|
efbccb4e11 | ||
|
|
41c498401b | ||
|
|
6ca347b657 | ||
|
|
1b4981f9ce | ||
|
|
1b289a0450 | ||
|
|
3c5bb51e88 | ||
|
|
f60587a6d6 | ||
|
|
43946c3c46 | ||
|
|
eb605a3d05 | ||
|
|
4c9409a785 | ||
|
|
c24a2133d4 | ||
|
|
75ccfe10d2 | ||
|
|
ea3e18d1f2 | ||
|
|
4f0e887a50 | ||
|
|
cfebfd441b | ||
|
|
81312aa0c5 | ||
|
|
52be48b0a6 | ||
|
|
202b5008b8 | ||
|
|
015f2be5fb | ||
|
|
5a594b2b7f | ||
|
|
8370ddf066 | ||
|
|
5c4e083c2d | ||
|
|
71add0215a | ||
|
|
766d256970 | ||
|
|
78cddec730 | ||
|
|
4ff31a4f79 | ||
|
|
abab7dcb63 | ||
|
|
c7c5b2e0ec | ||
|
|
f6a97c4695 | ||
|
|
323ead5a83 | ||
|
|
5be3e33c94 | ||
|
|
002c128ac8 | ||
|
|
bb28fe1d6e | ||
|
|
d7b15a131f | ||
|
|
581ec29492 | ||
|
|
90b7953b17 | ||
|
|
26e7992c8d | ||
|
|
60515dd8c6 | ||
|
|
b3c73fd2cb | ||
|
|
7d06bec21e | ||
|
|
1a0d1284e1 | ||
|
|
fc6f3469b0 | ||
|
|
b95b5c04c1 | ||
|
|
6af8abccd3 | ||
|
|
ad2e700391 | ||
|
|
2dcef860a6 | ||
|
|
0bb5df7245 | ||
|
|
cb070e4f84 | ||
|
|
8fde211348 | ||
|
|
31cdbd4f0e | ||
|
|
0d06827b69 | ||
|
|
8a6cb81d3a | ||
|
|
52f9d0679e | ||
|
|
e6ef236f89 | ||
|
|
5747c71800 | ||
|
|
5b0a0c2001 | ||
|
|
df9013ffdf | ||
|
|
39341b278c | ||
|
|
b646cc5adf | ||
|
|
cfc125144f | ||
|
|
cd66dbf7d8 | ||
|
|
6bb812f6bd | ||
|
|
d3098a9400 | ||
|
|
5de04af36a | ||
|
|
36cb03961c | ||
|
|
5e34ce7a61 | ||
|
|
1fa9ce798e | ||
|
|
2c89902829 | ||
|
|
8949fdfdf6 | ||
|
|
596e7e2f49 | ||
|
|
16609eff9e | ||
|
|
9b0e9751ec | ||
|
|
d1ed4f1fdc | ||
|
|
2c8758ed14 | ||
|
|
2a2da7f779 | ||
|
|
5eab434647 | ||
|
|
8e9ed580ea | ||
|
|
2d5e2f1718 | ||
|
|
b80d604408 | ||
|
|
56d1afe9bd | ||
|
|
73e9ecfc85 | ||
|
|
095f810176 | ||
|
|
3e809db1bc | ||
|
|
e8bd8a4952 | ||
|
|
a85773cef0 | ||
|
|
9c6e06ec4f | ||
|
|
3029de667e | ||
|
|
a8d693c0a3 | ||
|
|
6c1bcf042c | ||
|
|
fb87a00906 | ||
|
|
d4dcfe7f00 | ||
|
|
6161cabe77 | ||
|
|
524a848fc6 | ||
|
|
187bcb01d6 | ||
|
|
003e3839bc | ||
|
|
d2079cac3a | ||
|
|
2083911baa | ||
|
|
b5450651d1 | ||
|
|
8aa007c73b | ||
|
|
f4d12fa02d | ||
|
|
31702e03e3 | ||
|
|
dde8dd8cfc | ||
|
|
2fc19a886e | ||
|
|
0b95d11aa6 | ||
|
|
a85084d0d9 | ||
|
|
5282ba3411 | ||
|
|
6f698df9f4 | ||
|
|
8e8540460f | ||
|
|
2d796a3556 | ||
|
|
1984bf9472 | ||
|
|
f67803acf5 | ||
|
|
0c135aa065 | ||
|
|
56c8e35d6f | ||
|
|
b96f18c7cb | ||
|
|
0166607064 | ||
|
|
bb2f439302 | ||
|
|
58b4257c92 | ||
|
|
86443d195e | ||
|
|
abaa498b9f | ||
|
|
62581d9f85 | ||
|
|
267f8a6712 | ||
|
|
a575b8bef1 | ||
|
|
e9e0136a89 | ||
|
|
4921f62796 | ||
|
|
898c3cc763 | ||
|
|
44b9745e8c | ||
|
|
cc8af94254 | ||
|
|
50a9d35f62 | ||
|
|
a9ef3ff97b | ||
|
|
0457a61a61 | ||
|
|
132b606830 | ||
|
|
35c75f9238 | ||
|
|
bbbc24a4aa | ||
|
|
e9368bb242 | ||
|
|
918609574e | ||
|
|
bcb365f278 | ||
|
|
586c243056 | ||
|
|
1647e64aed | ||
|
|
5aedaf838a | ||
|
|
d922cdaa32 | ||
|
|
b4e57d1a4f | ||
|
|
74967e45b4 | ||
|
|
7a2cc30bf9 | ||
|
|
8ed44e785d | ||
|
|
63da845614 | ||
|
|
d336bd802d | ||
|
|
67d17f48d0 | ||
|
|
ec45312400 | ||
|
|
264332266c |
@@ -97,3 +97,34 @@ jobs:
|
||||
|
||||
- name: build
|
||||
run: pnpm -C web build
|
||||
|
||||
# The flake pins pnpmDeps.hash against web/pnpm-lock.yaml (#98). A lockfile
|
||||
# change invalidates the pin silently elsewhere, so the packaged build has to
|
||||
# fail here too.
|
||||
flake:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The runner container is root with no `nixbld` group, but the installer
|
||||
# writes `build-users-group = nixbld` anyway and then fails on it, so the
|
||||
# config goes in first. Flakes are off by default and `nix build .#…`
|
||||
# needs them. Written again afterwards in case the installer replaced it.
|
||||
- name: Install nix
|
||||
run: |
|
||||
mkdir -p /etc/nix
|
||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
||||
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||
|
||||
# The installer does not enable flakes, so `nix build .#…` needs the
|
||||
# features passed explicitly — a local machine that has them in
|
||||
# nix.conf will build fine while CI fails without this.
|
||||
- name: build flake packages
|
||||
run: |
|
||||
nix --extra-experimental-features "nix-command flakes" build \
|
||||
.#webFrontend \
|
||||
.#maestro \
|
||||
.#maestro-web \
|
||||
--no-link
|
||||
|
||||
@@ -62,6 +62,14 @@ nix/ # Home Manager module (`services.maestro`)
|
||||
|
||||
See `docs/INSTALL.md` for end-user install instructions.
|
||||
|
||||
`docs/WORKTREES.md` is the contract for session worktrees — placement,
|
||||
collisions, what blocks a kill, what `--force` retains, and what reclaim
|
||||
touches. Read it before changing anything that creates or removes one.
|
||||
|
||||
`docs/SIDEBAR.md` is the contract for session cards in the web and TUI
|
||||
sidebars — line layout, activity colours, duration format, state precedence.
|
||||
Read it before changing sidebar rendering.
|
||||
|
||||
`nix run .#dev` (or `just dev`) brings up the whole local stack under
|
||||
process-compose: a debug daemon on 127.0.0.1:7071, then Vite on :5173 once the
|
||||
daemon answers `/api/info`. The SPA proxies `/api/*` back to the daemon, so only
|
||||
|
||||
Generated
+11
@@ -1065,6 +1065,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"libc",
|
||||
"maestro-config",
|
||||
"maestro-hooks",
|
||||
"maestro-protocol",
|
||||
"maestro-testing",
|
||||
"nix",
|
||||
@@ -1102,6 +1103,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"libc",
|
||||
"maestro-config",
|
||||
"maestro-hooks",
|
||||
"maestro-names",
|
||||
"maestro-protocol",
|
||||
"reqwest",
|
||||
@@ -1137,6 +1139,15 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-hooks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maestro-installer"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -18,6 +18,7 @@ dirs = "5"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
|
||||
libc = "0.2"
|
||||
maestro-config = { path = "crates/maestro-config" }
|
||||
maestro-hooks = { path = "crates/maestro-hooks" }
|
||||
maestro-names = { path = "crates/maestro-names" }
|
||||
maestro-protocol = { path = "crates/maestro-protocol" }
|
||||
maestro-testing = { path = "crates/maestro-testing" }
|
||||
|
||||
@@ -17,6 +17,7 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-hooks.workspace = true
|
||||
maestro-protocol = { workspace = true, features = ["client"] }
|
||||
nix.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
+147
-28
@@ -41,6 +41,53 @@ pub enum Command {
|
||||
Attach(AttachArgs),
|
||||
/// Kill a session by ID.
|
||||
Kill(KillArgs),
|
||||
/// List retained worktrees, or reclaim one by ID (#86).
|
||||
Worktrees(WorktreesArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
Hooks(HooksArgs),
|
||||
}
|
||||
|
||||
/// `maestro hooks <install>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct HooksArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: HooksCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum HooksCommand {
|
||||
/// Write the Claude Code activity hook into ~/.claude/settings.json.
|
||||
/// Idempotent: an older maestro hook is updated in place, every other
|
||||
/// hook is left untouched.
|
||||
Install,
|
||||
}
|
||||
|
||||
/// `maestro worktrees <list|reclaim>`
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct WorktreesArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: WorktreesCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum WorktreesCommand {
|
||||
/// List retained worktrees and what is in the way of reclaiming each.
|
||||
List(ListWorktreesArgs),
|
||||
/// Reclaim one retained worktree: removes the directory, never the branch.
|
||||
Reclaim(ReclaimArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ListWorktreesArgs {
|
||||
/// Output as JSON instead of a table.
|
||||
#[arg(long)]
|
||||
pub json: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
pub struct ReclaimArgs {
|
||||
/// Retained worktree ID to reclaim. Required — there is no reclaim-all.
|
||||
pub id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Args)]
|
||||
@@ -72,11 +119,6 @@ pub struct SpawnArgs {
|
||||
/// there.
|
||||
#[arg(long)]
|
||||
pub existing_branch: bool,
|
||||
/// Remove the worktree when the session is killed. Kept for wire
|
||||
/// compatibility (#56): the daemon now always cleans up on kill, guarded
|
||||
/// against unsaved work.
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// After spawning, attach immediately.
|
||||
#[arg(long, short = 'a')]
|
||||
pub attach: bool,
|
||||
@@ -95,6 +137,11 @@ pub struct AttachArgs {
|
||||
pub struct KillArgs {
|
||||
/// Session ID to kill.
|
||||
pub session_id: Uuid,
|
||||
/// End the session even when its worktree holds unsaved work (#81). The
|
||||
/// worktree is retained — never removed — and enters the retained store;
|
||||
/// the output says where the work went.
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
fn parse_kv(s: &str) -> Result<(String, String), String> {
|
||||
@@ -133,7 +180,6 @@ mod tests {
|
||||
"/repos/foo",
|
||||
"--branch",
|
||||
"feat/x",
|
||||
"--cleanup-on-kill",
|
||||
"-e",
|
||||
"FOO=bar",
|
||||
"-e",
|
||||
@@ -144,7 +190,6 @@ mod tests {
|
||||
Command::Spawn(a) => {
|
||||
assert_eq!(a.command, "bash");
|
||||
assert_eq!(a.branch.as_deref(), Some("feat/x"));
|
||||
assert!(a.cleanup_on_kill);
|
||||
assert_eq!(
|
||||
a.env,
|
||||
vec![("FOO".into(), "bar".into()), ("BAZ".into(), "qux".into()),]
|
||||
@@ -154,27 +199,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_on_kill_defaults_true_for_the_wire() {
|
||||
let cli = Cli::try_parse_from([
|
||||
"maestro",
|
||||
"spawn",
|
||||
"--cwd",
|
||||
"/tmp",
|
||||
"--command",
|
||||
"bash",
|
||||
"--worktree-from",
|
||||
"/repos/foo",
|
||||
"--branch",
|
||||
"feat/x",
|
||||
])
|
||||
.unwrap();
|
||||
match cli.command {
|
||||
Command::Spawn(a) => assert!(a.cleanup_on_kill),
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Review fix: without a flag the CLI always sent `create_branch: true`,
|
||||
/// so the daemon's existing-branch path was unreachable and spawning onto
|
||||
/// a branch that already existed just failed.
|
||||
@@ -226,6 +250,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_kill_with_force() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "kill", "--force", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Kill(a) => {
|
||||
assert!(a.force);
|
||||
assert_eq!(a.session_id.to_string(), id);
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_force_defaults_false() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "kill", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Kill(a) => {
|
||||
assert!(!a.force);
|
||||
assert_eq!(a.session_id.to_string(), id);
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_env_without_equals() {
|
||||
let err = Cli::try_parse_from([
|
||||
@@ -241,4 +291,73 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("KEY=VALUE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hooks_install() {
|
||||
let cli = Cli::try_parse_from(["maestro", "hooks", "install"]).unwrap();
|
||||
match cli.command {
|
||||
Command::Hooks(h) => {
|
||||
assert!(matches!(h.command, HooksCommand::Install));
|
||||
}
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
// No subcommand → help, like `worktrees`.
|
||||
let err = Cli::try_parse_from(["maestro", "hooks"]).unwrap_err();
|
||||
assert!(matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_list() {
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "list", "--json"]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::List(a) => assert!(a.json),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_worktrees_reclaim() {
|
||||
let id = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
let cli = Cli::try_parse_from(["maestro", "worktrees", "reclaim", id]).unwrap();
|
||||
match cli.command {
|
||||
Command::Worktrees(w) => match w.command {
|
||||
WorktreesCommand::Reclaim(a) => assert_eq!(a.id.to_string(), id),
|
||||
_ => panic!("wrong worktrees subcommand"),
|
||||
},
|
||||
_ => panic!("wrong subcommand"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Contract rule: reclaim refuses to run without an explicit target.
|
||||
/// There is no reclaim-everything convenience.
|
||||
#[test]
|
||||
fn reclaim_requires_an_explicit_target() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees", "reclaim"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::InvalidSubcommand
|
||||
| clap::error::ErrorKind::MissingRequiredArgument
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_without_subcommand_shows_help() {
|
||||
let err = Cli::try_parse_from(["maestro", "worktrees"]).unwrap_err();
|
||||
assert!(
|
||||
matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
|
||||
),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@ use std::path::{Path, PathBuf};
|
||||
use maestro_protocol::DaemonClient;
|
||||
|
||||
use crate::attach::{parse_detach_key, run_attach_url};
|
||||
use crate::cli::Command;
|
||||
use crate::cli::{Command, WorktreesCommand};
|
||||
use crate::sessions::{run_kill, run_list, run_spawn};
|
||||
use crate::worktrees::{run_list as run_worktree_list, run_reclaim as run_worktree_reclaim};
|
||||
use maestro_config::{default_config_path, MaestroConfigFile};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -23,6 +24,10 @@ pub enum CliError {
|
||||
#[error("{0}")]
|
||||
Attach(#[from] crate::attach::AttachError),
|
||||
#[error("{0}")]
|
||||
Hooks(#[from] maestro_hooks::HooksError),
|
||||
#[error("could not determine the home directory ($HOME unset?)")]
|
||||
NoHome,
|
||||
#[error("{0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
@@ -39,6 +44,15 @@ pub async fn dispatch(
|
||||
None => default_config_path()?,
|
||||
};
|
||||
|
||||
// `hooks install` is purely local: it must work with the daemon down, so
|
||||
// it never touches the client.
|
||||
if let Command::Hooks(args) = &command {
|
||||
match args.command {
|
||||
crate::cli::HooksCommand::Install => run_hooks_install()?,
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let daemon = client(&config_path, daemon_url_override)?;
|
||||
|
||||
match command {
|
||||
@@ -61,9 +75,61 @@ pub async fn dispatch(
|
||||
run_attach_url(&daemon.attach_url(args.session_id)?, detach).await?;
|
||||
}
|
||||
Command::Kill(args) => {
|
||||
run_kill(&daemon, &args).await?;
|
||||
// #83: warn BEFORE the attempt, so the refusal is not a surprise.
|
||||
// Best-effort: an unreachable list must not block the kill.
|
||||
if !args.force {
|
||||
warn_if_blocked(&daemon, args.session_id).await;
|
||||
}
|
||||
let outcome = run_kill(&daemon, &args).await?;
|
||||
println!("killed {}", args.session_id);
|
||||
if let Some(w) = outcome.retained {
|
||||
println!(
|
||||
"worktree retained at {} (branch {}, force-killed)",
|
||||
w.path.display(),
|
||||
w.branch
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::Hooks(_) => unreachable!("hooks handled before the daemon client exists"),
|
||||
Command::Worktrees(args) => match args.command {
|
||||
WorktreesCommand::List(list_args) => {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
run_worktree_list(&daemon, &list_args, &mut stdout).await?;
|
||||
}
|
||||
WorktreesCommand::Reclaim(reclaim_args) => {
|
||||
// The daemon removes the directory and the store entry; the
|
||||
// branch is never deleted — worth saying out loud (#86).
|
||||
let entry = run_worktree_reclaim(&daemon, &reclaim_args).await?;
|
||||
match entry {
|
||||
Some(wt) => println!(
|
||||
"reclaimed {} ({})\nbranch '{}' kept",
|
||||
wt.id,
|
||||
wt.path.display(),
|
||||
wt.branch
|
||||
),
|
||||
None => println!("reclaimed {}\nbranch kept", reclaim_args.id),
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro hooks install` (#106): write the shipped hook script under the
|
||||
/// user's data dir and register it in their Claude Code settings. Idempotent
|
||||
/// and foreign-hook-preserving — the guarantees live in [`maestro_hooks`].
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn run_hooks_install() -> Result<(), CliError> {
|
||||
let home = dirs::home_dir().ok_or(CliError::NoHome)?;
|
||||
let changed = maestro_hooks::install_for_home(&home)?;
|
||||
if changed {
|
||||
println!(
|
||||
"installed claude-code activity hook: {} → {}",
|
||||
maestro_hooks::script_path(&home).display(),
|
||||
maestro_hooks::settings_path(&home).display()
|
||||
);
|
||||
} else {
|
||||
println!("claude-code activity hook already up to date");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -71,6 +137,25 @@ pub async fn dispatch(
|
||||
/// Default detach byte, matching `maestro attach --detach-key`'s `^q`.
|
||||
const CTRL_Q: u8 = 0x11;
|
||||
|
||||
/// #83: say "this will be refused" before the operator commits to it, in the
|
||||
/// same words [`maestro_protocol::InTheWay`] uses everywhere else.
|
||||
async fn warn_if_blocked(daemon: &DaemonClient, id: uuid::Uuid) {
|
||||
let Ok(sessions) = daemon.list_sessions().await else {
|
||||
return;
|
||||
};
|
||||
if let Some(b) = sessions
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.and_then(|s| s.blocked_work)
|
||||
{
|
||||
if !b.is_clear() {
|
||||
eprintln!(
|
||||
"warning: this kill will be refused — the session's worktree holds unsaved work ({b}); use --force to retain it"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP client pointed at the daemon's API. That surface needs no auth —
|
||||
/// reaching `bind_addr` is the authorization.
|
||||
#[allow(clippy::result_large_err)]
|
||||
|
||||
@@ -6,5 +6,6 @@ pub mod attach;
|
||||
pub mod cli;
|
||||
pub mod dispatch;
|
||||
pub mod sessions;
|
||||
pub mod worktrees;
|
||||
|
||||
pub use dispatch::{dispatch, CliError};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{
|
||||
ActivityState, ClientError, CreateSessionRequest, DaemonClient, DaemonInfo, Session,
|
||||
SessionStatus, WorktreeSpec,
|
||||
@@ -43,7 +44,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
(Some(repo), Some(branch)) => Some(WorktreeSpec {
|
||||
base_repo: repo.clone(),
|
||||
branch: branch.clone(),
|
||||
cleanup_on_kill: args.cleanup_on_kill,
|
||||
create_branch: !args.existing_branch,
|
||||
}),
|
||||
_ => None,
|
||||
@@ -59,9 +59,14 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
|
||||
client.create_session(&body).await
|
||||
}
|
||||
|
||||
/// `maestro kill`
|
||||
pub async fn run_kill(client: &DaemonClient, args: &KillArgs) -> Result<(), ClientError> {
|
||||
client.kill_session(args.session_id).await
|
||||
/// `maestro kill`. A forced kill (#81) retains the guarded worktree instead
|
||||
/// of removing it; the outcome names what was kept so dispatch can say where
|
||||
/// the work went.
|
||||
pub async fn run_kill(
|
||||
client: &DaemonClient,
|
||||
args: &KillArgs,
|
||||
) -> Result<maestro_protocol::KillOutcome, ClientError> {
|
||||
client.kill_session(args.session_id, args.force).await
|
||||
}
|
||||
|
||||
#[allow(clippy::write_literal)]
|
||||
@@ -89,8 +94,8 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND"
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {}",
|
||||
"ID", "STATUS", "ACTIVITY", "DIR", "COMMAND", "BLOCKED"
|
||||
)
|
||||
.ok();
|
||||
for s in sorted {
|
||||
@@ -104,6 +109,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
ActivityState::Active => "active",
|
||||
ActivityState::Idle => "idle",
|
||||
ActivityState::AwaitingInput => "awaiting",
|
||||
ActivityState::Error => "error",
|
||||
};
|
||||
// Show the live foreground command alongside the spawn command when
|
||||
// the daemon has observed one that differs (NAP-340).
|
||||
@@ -111,28 +117,26 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
||||
Some(fg) if fg != &s.command => format!("{} [{}]", s.command, fg),
|
||||
_ => s.command.clone(),
|
||||
};
|
||||
// #83: what an unforced kill would be refused for, in InTheWay's own
|
||||
// words; blank while nothing blocks (or hasn't been probed yet).
|
||||
let blocked = blocked_label(s);
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<8} {:<9} {:<28} {command}",
|
||||
s.id, status, activity, dir
|
||||
" {:<36} {:<8} {:<9} {:<28} {:<40} {blocked}",
|
||||
s.id, status, activity, dir, command
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a path with the daemon's home dir collapsed to `~`. Mirrors the web
|
||||
/// UI's abbreviation so CLI and browser show the same directory shorthand.
|
||||
fn abbreviate_dir(path: &std::path::Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
return "~".into();
|
||||
}
|
||||
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
||||
return format!("~/{rest}");
|
||||
}
|
||||
}
|
||||
p.into_owned()
|
||||
/// The blocked-work indicator (#83), phrased by [`InTheWay`]'s `Display`.
|
||||
/// Empty unless something actually blocks — "no unsaved work" would just be
|
||||
/// noise on every row.
|
||||
pub(crate) fn blocked_label(s: &Session) -> String {
|
||||
s.blocked_work
|
||||
.filter(|b| !b.is_clear())
|
||||
.map(|b| b.to_string())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -153,12 +157,16 @@ mod tests {
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,4 +227,37 @@ mod tests {
|
||||
super::print_session_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no sessions\n");
|
||||
}
|
||||
|
||||
/// #83: a blocked session shows what an unforced kill would be refused
|
||||
/// for, in `InTheWay`'s own words; a clear or unprobed one shows nothing.
|
||||
#[test]
|
||||
fn shows_the_blocked_work_summary_only_when_something_blocks() {
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
let mut blocked = session("/tmp", "claude", 1);
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 3,
|
||||
unpushed_commits: 2,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let mut clear = session("/tmp", "bash", 2);
|
||||
clear.blocked_work = Some(InTheWay::default());
|
||||
let unprobed = session("/tmp", "zsh", 3);
|
||||
|
||||
let mut buf = Vec::new();
|
||||
super::print_session_table(&mut buf, &[blocked, clear, unprobed], &info(""));
|
||||
let out = String::from_utf8(buf).unwrap();
|
||||
assert!(
|
||||
out.contains("3 uncommitted files, 2 unpushed commits"),
|
||||
"{out}"
|
||||
);
|
||||
// Clear and unprobed rows stay blank in the BLOCKED column.
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
let header = lines.iter().position(|l| l.contains("BLOCKED")).unwrap();
|
||||
for line in &lines[header + 1..] {
|
||||
if !line.contains("uncommitted files") {
|
||||
assert!(!line.contains("unsaved work"), "{line}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! `maestro worktrees list / reclaim` — the retained-store surface (#86).
|
||||
//!
|
||||
//! `reclaim` removes one directory; it never deletes the branch, which is
|
||||
//! said out loud in the output because "reclaim" otherwise sounds like it
|
||||
//! takes the commits too.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use maestro_protocol::display::abbreviate_dir;
|
||||
use maestro_protocol::{ClientError, DaemonClient, DaemonInfo, InTheWay, RetainedWorktree};
|
||||
|
||||
use crate::cli::{ListWorktreesArgs, ReclaimArgs};
|
||||
|
||||
/// `maestro worktrees list`
|
||||
pub async fn run_list(
|
||||
client: &DaemonClient,
|
||||
args: &ListWorktreesArgs,
|
||||
out: &mut impl Write,
|
||||
) -> Result<(), ClientError> {
|
||||
let worktrees = client.list_worktrees().await?;
|
||||
let info = client.daemon_info().await.unwrap_or_default();
|
||||
|
||||
if args.json {
|
||||
let body = serde_json::json!({ "worktrees": worktrees });
|
||||
writeln!(out, "{}", serde_json::to_string_pretty(&body)?).ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
print_worktree_table(out, &worktrees, &info);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `maestro worktrees reclaim <id>`
|
||||
///
|
||||
/// Returns the entry as listed just before the reclaim, when it was found in
|
||||
/// the listing, so the confirmation can name what was removed.
|
||||
pub async fn run_reclaim(
|
||||
client: &DaemonClient,
|
||||
args: &ReclaimArgs,
|
||||
) -> Result<Option<RetainedWorktree>, ClientError> {
|
||||
let listed = client
|
||||
.list_worktrees()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|w| w.id == args.id);
|
||||
client.reclaim_worktree(args.id).await?;
|
||||
Ok(listed)
|
||||
}
|
||||
|
||||
fn print_worktree_table(out: &mut impl Write, worktrees: &[RetainedWorktree], info: &DaemonInfo) {
|
||||
if worktrees.is_empty() {
|
||||
writeln!(out, "no retained worktrees").ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&RetainedWorktree> = worktrees.iter().collect();
|
||||
sorted.sort_by_key(|w| w.retained_at);
|
||||
|
||||
if !info.hostname.is_empty() {
|
||||
let warn = if maestro_protocol::is_compatible(&info.version) {
|
||||
""
|
||||
} else {
|
||||
" (protocol mismatch)"
|
||||
};
|
||||
let version = if info.version.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" maestro {}{warn}", info.version)
|
||||
};
|
||||
writeln!(out, "\n{}{version}", info.hostname).ok();
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} IN THE WAY",
|
||||
"ID", "BRANCH", "PATH", "REASON"
|
||||
)
|
||||
.ok();
|
||||
for w in sorted {
|
||||
writeln!(
|
||||
out,
|
||||
" {:<36} {:<24} {:<28} {:<24} {}",
|
||||
w.id,
|
||||
w.branch,
|
||||
abbreviate_dir(&w.path, &info.home_dir),
|
||||
reason_label(w.reason),
|
||||
blocker_label(&w.in_the_way),
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
// The branch survives every reclaim — see docs/WORKTREES.md.
|
||||
writeln!(
|
||||
out,
|
||||
"\nreclaim removes the directory only; branches are never deleted"
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn reason_label(reason: maestro_protocol::RetainedReason) -> &'static str {
|
||||
match reason {
|
||||
maestro_protocol::RetainedReason::ForceKilled => "force-killed",
|
||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||
}
|
||||
}
|
||||
|
||||
/// The same breakdown that blocks an unforced kill, as words: what is still
|
||||
/// in the way, or `clear` when the work has since been committed and pushed.
|
||||
fn blocker_label(in_the_way: &InTheWay) -> String {
|
||||
if in_the_way.is_clear() {
|
||||
return "clear".into();
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
if in_the_way.uncommitted_files > 0 {
|
||||
parts.push(format!("{} uncommitted", in_the_way.uncommitted_files));
|
||||
}
|
||||
if in_the_way.untracked_files > 0 {
|
||||
parts.push(format!("{} untracked", in_the_way.untracked_files));
|
||||
}
|
||||
if in_the_way.unpushed_commits > 0 {
|
||||
parts.push(format!("{} unpushed", in_the_way.unpushed_commits));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{DaemonInfo, InTheWay, RetainedReason, RetainedWorktree};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: Uuid::from_u128(id),
|
||||
path: PathBuf::from(path),
|
||||
base_repo: PathBuf::from("/home/naps/maestro"),
|
||||
branch: branch.into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||
in_the_way: InTheWay::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn info(home: &str) -> DaemonInfo {
|
||||
DaemonInfo {
|
||||
hostname: "naps-laptop".into(),
|
||||
home_dir: home.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_table() {
|
||||
let mut w = worktree("feat/login", "/home/naps/maestro/worktrees/feat/login", 1);
|
||||
w.in_the_way = InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[w], &info("/home/naps"));
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("naps-laptop"), "{s}");
|
||||
assert!(s.contains("feat/login"), "{s}");
|
||||
// Home dir collapses to ~ in the PATH column, like sessions' DIR.
|
||||
assert!(s.contains("~/maestro/worktrees/feat/login"), "{s}");
|
||||
assert!(s.contains("force-killed"), "{s}");
|
||||
assert!(s.contains("2 uncommitted, 1 untracked, 3 unpushed"), "{s}");
|
||||
assert!(s.contains("IN THE WAY"), "{s}");
|
||||
}
|
||||
|
||||
/// Contract rule, visible to the operator: reclaiming a tree must not
|
||||
/// throw away commits on its branch.
|
||||
#[test]
|
||||
fn says_the_branch_survives() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("feat/x", "/repo/worktrees/feat/x", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("never deleted"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_tree_says_clear() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(
|
||||
&mut buf,
|
||||
&[worktree("main-ish", "/repo/worktrees/main-ish", 1)],
|
||||
&info(""),
|
||||
);
|
||||
let s = String::from_utf8(buf).unwrap();
|
||||
assert!(s.contains("clear"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store() {
|
||||
let mut buf = Vec::new();
|
||||
super::print_worktree_table(&mut buf, &[], &info(""));
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "no retained worktrees\n");
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ async fn killing_an_unknown_session_reports_the_daemon_reason() {
|
||||
|
||||
let err = mock
|
||||
.client()
|
||||
.kill_session(Uuid::new_v4())
|
||||
.kill_session(Uuid::new_v4(), false)
|
||||
.await
|
||||
.expect_err("should fail");
|
||||
|
||||
@@ -102,6 +102,9 @@ async fn spawn_round_trips_through_the_daemon() {
|
||||
assert_eq!(created.command, "bash");
|
||||
assert_eq!(mock.session_count(), 1);
|
||||
|
||||
mock.client().kill_session(created.id).await.expect("kill");
|
||||
mock.client()
|
||||
.kill_session(created.id, false)
|
||||
.await
|
||||
.expect("kill");
|
||||
assert_eq!(mock.session_count(), 0);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,24 @@ pub struct MaestroConfigFile {
|
||||
pub tui: TuiSection,
|
||||
#[serde(default)]
|
||||
pub spawn: SpawnSection,
|
||||
#[serde(default)]
|
||||
pub hooks: HooksSection,
|
||||
}
|
||||
|
||||
/// `[hooks]` — agent-hook installation settings (#106).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
pub struct HooksSection {
|
||||
/// Whether the daemon installs the Claude Code activity hook into
|
||||
/// `~/.claude/settings.json` on first start when no maestro hook is
|
||||
/// there yet. Default `true`; set `auto_install = false` to opt out.
|
||||
#[serde(default)]
|
||||
pub auto_install: Option<bool>,
|
||||
}
|
||||
|
||||
impl HooksSection {
|
||||
pub fn auto_install(&self) -> bool {
|
||||
self.auto_install.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
@@ -375,6 +393,26 @@ mod tests {
|
||||
assert_eq!(cfg, MaestroConfigFile::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
// Absent file / absent [hooks] ⇒ auto_install on.
|
||||
assert!(HooksSection::default().auto_install());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
std::fs::write(&path, "").unwrap();
|
||||
assert!(MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[hooks]
|
||||
auto_install = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!MaestroConfigFile::load(&path).unwrap().hooks.auto_install());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_presets_default_to_builtins_plus_custom() {
|
||||
// Absent [spawn].presets → built-ins, with the implicit custom tile last.
|
||||
|
||||
@@ -18,6 +18,7 @@ dirs.workspace = true
|
||||
futures-util.workspace = true
|
||||
libc.workspace = true
|
||||
maestro-config.workspace = true
|
||||
maestro-hooks.workspace = true
|
||||
maestro-names.workspace = true
|
||||
maestro-protocol.workspace = true
|
||||
serde.workspace = true
|
||||
|
||||
@@ -15,6 +15,44 @@ use maestro_protocol::ActivityState;
|
||||
/// Active↔Idle. This window *is* the debounce the issue calls for.
|
||||
pub const ACTIVE_WINDOW: Duration = Duration::from_secs(3);
|
||||
|
||||
/// How long an agent-reported state (#105) stays in force before inference
|
||||
/// resumes. Long enough for a working agent's heartbeat to hold between
|
||||
/// reports, short enough that an agent dying mid-run cannot pin its session
|
||||
/// at `Error` forever.
|
||||
pub const REPORT_TTL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Server-side cap (#105) on a reported `detail`, in characters. The meta
|
||||
/// line renders it inline, so an unbounded string would own the whole card.
|
||||
pub const MAX_REPORT_DETAIL_CHARS: usize = 256;
|
||||
|
||||
/// An agent-reported activity state (#105) still within its TTL. While one
|
||||
/// is [`Self::in_force`], it outranks [`decide`]'s inference; when it
|
||||
/// lapses, inference resumes and any reported detail is cleared with it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportedActivity {
|
||||
pub state: ActivityState,
|
||||
pub expires_at: std::time::Instant,
|
||||
}
|
||||
|
||||
impl ReportedActivity {
|
||||
pub fn new(state: ActivityState, ttl: Duration) -> Self {
|
||||
Self {
|
||||
state,
|
||||
expires_at: std::time::Instant::now() + ttl,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn in_force(&self, now: std::time::Instant) -> bool {
|
||||
now < self.expires_at
|
||||
}
|
||||
}
|
||||
|
||||
/// Cap a reported detail to [`MAX_REPORT_DETAIL_CHARS`] characters,
|
||||
/// char-boundary safe.
|
||||
pub fn truncate_detail(detail: Option<String>) -> Option<String> {
|
||||
detail.map(|d| d.chars().take(MAX_REPORT_DETAIL_CHARS).collect())
|
||||
}
|
||||
|
||||
/// Combine the observed signals into an [`ActivityState`]:
|
||||
/// recent output ⇒ `Active`; else blocked on a terminal read ⇒ `AwaitingInput`;
|
||||
/// else `Idle`.
|
||||
@@ -59,4 +97,25 @@ mod tests {
|
||||
ActivityState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_in_force_until_exactly_its_expiry() {
|
||||
let ttl = Duration::from_secs(300);
|
||||
let report = ReportedActivity::new(ActivityState::Error, ttl);
|
||||
assert!(report.in_force(report.expires_at - Duration::from_millis(1)));
|
||||
assert!(!report.in_force(report.expires_at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_truncates_to_the_cap_without_panic_on_multibyte_input() {
|
||||
let long = "é".repeat(MAX_REPORT_DETAIL_CHARS + 10);
|
||||
let capped = truncate_detail(Some(long)).unwrap();
|
||||
assert_eq!(capped.chars().count(), MAX_REPORT_DETAIL_CHARS);
|
||||
|
||||
assert_eq!(truncate_detail(None), None);
|
||||
assert_eq!(
|
||||
truncate_detail(Some("short".into())).as_deref(),
|
||||
Some("short")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,20 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
let _ = default_state_dir();
|
||||
std::fs::create_dir_all(config.sidecar_dir())?;
|
||||
std::fs::create_dir_all(config.fifo_dir())?;
|
||||
|
||||
// First-start hook auto-install (#106). Best-effort, before serving: a
|
||||
// session spawned right away should already carry the hook.
|
||||
if config.hooks_auto_install {
|
||||
match crate::hooks::auto_install(&config.home_dir) {
|
||||
crate::hooks::AutoInstallOutcome::Installed => {
|
||||
tracing::info!("installed claude-code activity hook (#106)");
|
||||
}
|
||||
crate::hooks::AutoInstallOutcome::Failed => {
|
||||
tracing::warn!("hooks auto-install failed; run `maestro hooks install` manually");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// No worktree dir to pre-create: without an override the worktrees live
|
||||
// under their base repos, created on demand (#56).
|
||||
|
||||
@@ -56,6 +70,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
Some(p) => Tmux::new().with_tmpdir(p),
|
||||
None => Tmux::new(),
|
||||
};
|
||||
let retained = std::sync::Arc::new(crate::retained_store::RetainedStore::new(
|
||||
config.retained_worktree_dir(),
|
||||
));
|
||||
let manager = SessionManager::new_with_replay(
|
||||
std::sync::Arc::new(tmux),
|
||||
sidecar,
|
||||
@@ -66,7 +83,13 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
.with_spawn_history(crate::spawn_history::SpawnHistory::new(
|
||||
config.spawn_history_path(),
|
||||
config.spawn_history_days,
|
||||
));
|
||||
))
|
||||
// One store instance shared with the API handlers: forced kills (#81)
|
||||
// record here, `GET /api/worktrees` (#80) reads back from here.
|
||||
.with_retained_store((*retained).clone());
|
||||
// Spawned sessions learn where to report back (#103). Derived from the
|
||||
// configured bind address; set before any spawn can happen.
|
||||
manager.set_daemon_url(format!("http://{}", config.bind_addr));
|
||||
|
||||
let recovered = manager.recover().await?;
|
||||
tracing::info!(recovered, "adopted existing tmux sessions");
|
||||
@@ -85,6 +108,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
||||
config.home_dir.clone(),
|
||||
config.spawn_presets.clone(),
|
||||
projects,
|
||||
retained,
|
||||
));
|
||||
|
||||
let listener = TcpListener::bind(config.bind_addr).await?;
|
||||
|
||||
@@ -88,6 +88,10 @@ pub struct DaemonConfig {
|
||||
/// Project-discovery depth (#54): `[spawn].project_scan_depth`, default
|
||||
/// [`DEFAULT_PROJECT_SCAN_DEPTH`].
|
||||
pub project_scan_depth: u32,
|
||||
/// Whether the daemon installs the Claude Code activity hook on first
|
||||
/// start when none is present (#106): `[hooks].auto_install`, default
|
||||
/// `true`.
|
||||
pub hooks_auto_install: bool,
|
||||
}
|
||||
|
||||
impl DaemonConfig {
|
||||
@@ -103,6 +107,11 @@ impl DaemonConfig {
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
self.state_dir.join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
/// Retained-worktree store directory (#78): `<state_dir>/retained-worktrees/`.
|
||||
pub fn retained_worktree_dir(&self) -> PathBuf {
|
||||
self.state_dir.join("retained-worktrees")
|
||||
}
|
||||
}
|
||||
|
||||
/// Env-var overrides applied by [`load_config`]. Tests pass explicit
|
||||
@@ -192,6 +201,7 @@ pub fn load_config(path: &Path, env: EnvOverrides) -> Result<DaemonConfig, Confi
|
||||
spawn_presets,
|
||||
project_roots,
|
||||
project_scan_depth,
|
||||
hooks_auto_install: file.hooks.auto_install(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -228,6 +238,27 @@ pub fn system_hostname() -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hooks_auto_install_defaults_true_and_is_opt_out() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("config.toml");
|
||||
|
||||
std::fs::write(&path, "").unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert!(cfg.hooks_auto_install);
|
||||
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[hooks]
|
||||
auto_install = false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config(&path, EnvOverrides::default()).unwrap();
|
||||
assert!(!cfg.hooks_auto_install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_file_defaults_everything() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -111,6 +111,7 @@ fn io_error(dir: &Path, e: &std::io::Error) -> ErrorInfo {
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: format!("{message}: {}", dir.display()),
|
||||
unsaved_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! First-start hook auto-install (#106).
|
||||
//!
|
||||
//! The daemon runs [`auto_install`] once at startup: when the user's
|
||||
//! `~/.claude/settings.json` carries no maestro hook yet (and
|
||||
//! `[hooks].auto_install` hasn't opted out — checked by the caller), the same
|
||||
//! installer `maestro hooks install` uses writes it. Best-effort: a failure is
|
||||
//! warned about and never blocks startup. The same global hook serves every
|
||||
//! Claude Code session on the machine, so nothing here targets repositories
|
||||
//! or worktrees.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Install the Claude Code activity hook for `home_dir` unless one is already
|
||||
/// present or the home dir is unresolvable (`""`). Returns what happened.
|
||||
pub fn auto_install(home_dir: &str) -> AutoInstallOutcome {
|
||||
if home_dir.is_empty() {
|
||||
return AutoInstallOutcome::Skipped;
|
||||
}
|
||||
let settings = maestro_hooks::settings_path(Path::new(home_dir));
|
||||
if maestro_hooks::is_installed(&settings) {
|
||||
return AutoInstallOutcome::AlreadyPresent;
|
||||
}
|
||||
match maestro_hooks::install_for_home(Path::new(home_dir)) {
|
||||
Ok(_) => AutoInstallOutcome::Installed,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "hooks auto-install failed (non-fatal)");
|
||||
AutoInstallOutcome::Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What [`auto_install`] did, so callers can log and tests can assert.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AutoInstallOutcome {
|
||||
Installed,
|
||||
AlreadyPresent,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Everything happens under a temp `$HOME`; the developer's own
|
||||
/// `~/.claude/settings.json` is never read or written.
|
||||
#[test]
|
||||
fn installs_when_settings_carry_no_maestro_hook() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let outcome = auto_install(dir.path().to_str().unwrap());
|
||||
assert_eq!(outcome, AutoInstallOutcome::Installed);
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
assert!(maestro_hooks::is_installed(&settings));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn second_start_is_a_noop() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path().to_str().unwrap();
|
||||
auto_install(home);
|
||||
// A foreign edit between starts survives; presence alone skips.
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
let before = std::fs::read_to_string(&settings).unwrap();
|
||||
assert_eq!(auto_install(home), AutoInstallOutcome::AlreadyPresent);
|
||||
assert_eq!(
|
||||
before,
|
||||
std::fs::read_to_string(&settings).unwrap(),
|
||||
"already-installed settings must not be rewritten"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_home_is_skipped() {
|
||||
assert_eq!(auto_install(""), AutoInstallOutcome::Skipped);
|
||||
}
|
||||
}
|
||||
@@ -14,16 +14,20 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod dirlist;
|
||||
pub mod gitroot;
|
||||
pub mod hooks;
|
||||
pub mod local_api;
|
||||
pub mod panes;
|
||||
pub mod proc;
|
||||
pub mod projects;
|
||||
pub mod pty;
|
||||
pub mod retained_store;
|
||||
pub mod session;
|
||||
pub mod sidecar;
|
||||
pub mod spawn_history;
|
||||
pub mod tmux;
|
||||
pub mod worktree;
|
||||
pub mod worktree_probe;
|
||||
|
||||
pub use activity::MAX_REPORT_DETAIL_CHARS;
|
||||
pub use config::{default_config_path, default_state_dir, load_config, DaemonConfig, EnvOverrides};
|
||||
pub use session::{SessionError, SessionManager};
|
||||
|
||||
@@ -15,12 +15,12 @@ use std::sync::Arc;
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{any, delete, get};
|
||||
use axum::routing::{any, delete, get, post};
|
||||
use axum::{Json, Router};
|
||||
use maestro_config::SpawnPreset;
|
||||
use maestro_protocol::{
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch, Session,
|
||||
PROTOCOL_VERSION,
|
||||
routes, ApiError, CatalogEvent, CreateSessionRequest, DaemonInfo, GitRoot, NameBatch,
|
||||
ReportActivityRequest, Session, PROTOCOL_VERSION,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
@@ -38,11 +38,15 @@ pub struct LocalApiState {
|
||||
pub presets: Arc<Vec<SpawnPreset>>,
|
||||
/// Project discovery (#54) backing `GET /api/projects`.
|
||||
pub projects: Arc<crate::projects::ProjectScanner>,
|
||||
/// Retained worktrees (#78) backing `GET /api/worktrees` and their
|
||||
/// reclaim endpoint (#80).
|
||||
pub retained: Arc<crate::retained_store::RetainedStore>,
|
||||
}
|
||||
|
||||
pub fn router(state: LocalApiState) -> Router {
|
||||
Router::new()
|
||||
.route(routes::SESSIONS, get(list_sessions).post(create_session))
|
||||
.route(routes::patterns::SESSION_ACTIVITY, post(report_activity))
|
||||
.route(routes::patterns::SESSION, delete(kill_session))
|
||||
.route(routes::DIRS, get(list_dir))
|
||||
.route(routes::RECENT_DIRS, get(recent_dirs))
|
||||
@@ -50,6 +54,8 @@ pub fn router(state: LocalApiState) -> Router {
|
||||
.route(routes::GITROOT, get(git_root))
|
||||
.route(routes::BRANCHES, get(crate::branches::handler))
|
||||
.route(routes::NAMES, get(names))
|
||||
.route(routes::WORKTREES, get(list_worktrees))
|
||||
.route(routes::patterns::WORKTREE, delete(reclaim_worktree))
|
||||
.route(routes::INFO, get(daemon_info))
|
||||
.route(routes::EVENTS, any(events_ws))
|
||||
.route(routes::patterns::ATTACH, any(attach_ws))
|
||||
@@ -94,6 +100,10 @@ fn session_error_status(code: &str) -> StatusCode {
|
||||
match code {
|
||||
"not_found" => StatusCode::NOT_FOUND,
|
||||
"invalid_env" => StatusCode::BAD_REQUEST,
|
||||
// A guard refusal is the daemon doing its job, not a failure (#79).
|
||||
"kill_guarded" => StatusCode::CONFLICT,
|
||||
// The retention record failed before anything was torn down (#81).
|
||||
"retain_failed" => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
_ => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
@@ -168,8 +178,158 @@ async fn create_session(
|
||||
/// `kill` already reports a missing session as `SessionError::NotFound`, which
|
||||
/// maps to 404. Checking first would only add a window in which the liveness
|
||||
/// watcher reaps the session between the two awaits, turning a 404 into a 502.
|
||||
async fn kill_session(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
match s.manager.kill(id).await {
|
||||
///
|
||||
/// The full error info is passed through rather than rebuilt from
|
||||
/// code + message: a `kill_guarded` refusal (#79) carries the unsaved-work
|
||||
/// breakdown clients render as "3 uncommitted files, 2 unpushed commits".
|
||||
///
|
||||
/// With `force=true` (#81) the refusal is overridden and the worktree
|
||||
/// retained; the answer is `200` with a [`KillOutcome`] naming what was kept.
|
||||
/// Without it the historical `204` stands.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KillQuery {
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
async fn kill_session(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Query(q): Query<KillQuery>,
|
||||
) -> Response {
|
||||
match s.manager.kill(id, q.force).await {
|
||||
Ok(outcome) => {
|
||||
if q.force {
|
||||
(StatusCode::OK, Json(outcome)).into_response()
|
||||
} else {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
let status = session_error_status(&info.code);
|
||||
(status, Json(ApiError { error: info })).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /api/worktrees` (#80): every retained worktree, with the in-the-way
|
||||
/// breakdown recomputed at read time — never stored, so a tree whose work has
|
||||
/// since been committed or pushed says so. Entries whose directory somebody
|
||||
/// removed by hand are pruned on the way through.
|
||||
async fn list_worktrees(State(s): State<LocalApiState>) -> Response {
|
||||
let retained = s.retained.clone();
|
||||
let listed = tokio::task::spawn_blocking(move || {
|
||||
retained.prune_missing()?;
|
||||
retained
|
||||
.load_all()?
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
// A tree git cannot read (permissions, corrupt checkout)
|
||||
// still gets listed; it just reports nothing in the way.
|
||||
let in_the_way = crate::worktree_probe::in_the_way(&entry.path).unwrap_or_default();
|
||||
Ok(maestro_protocol::RetainedWorktree {
|
||||
id: entry.id,
|
||||
path: entry.path.clone(),
|
||||
base_repo: entry.base_repo.clone(),
|
||||
branch: entry.branch.clone(),
|
||||
reason: entry.reason,
|
||||
retained_at: entry.retained_at,
|
||||
in_the_way,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, crate::retained_store::RetainedStoreError>>()
|
||||
})
|
||||
.await;
|
||||
match listed {
|
||||
Ok(Ok(worktrees)) => Json(worktrees).into_response(),
|
||||
Ok(Err(e)) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"store_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` (#80): reclaim one retained worktree. Removes
|
||||
/// the directory and drops the store entry, without consulting the guard —
|
||||
/// reclaim is the deliberate destructive act, chosen while looking at what is
|
||||
/// in the way. The branch itself is never deleted; commits on it survive.
|
||||
async fn reclaim_worktree(State(s): State<LocalApiState>, Path(id): Path<Uuid>) -> Response {
|
||||
let found = {
|
||||
let retained = s.retained.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<
|
||||
Option<crate::retained_store::RetainedWorktreeRecord>,
|
||||
crate::retained_store::RetainedStoreError,
|
||||
> {
|
||||
Ok(retained.load_all()?.into_iter().find(|e| e.id == id))
|
||||
})
|
||||
.await
|
||||
};
|
||||
let entry = match found {
|
||||
Ok(Ok(Some(entry))) => entry,
|
||||
Ok(Ok(None)) => {
|
||||
return error_response(
|
||||
StatusCode::NOT_FOUND,
|
||||
"not_found",
|
||||
"no such retained worktree",
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"store_failed",
|
||||
&e.to_string(),
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
return error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let outcome = {
|
||||
let retained = s.retained.clone();
|
||||
tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
crate::worktree_probe::reclaim(&entry.base_repo, &entry.path)
|
||||
.map_err(|e| e.to_string())?;
|
||||
retained.remove(id).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
};
|
||||
match outcome {
|
||||
Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(),
|
||||
// The directory could not be removed: leave the entry so the
|
||||
// operator still sees the tree and can retry.
|
||||
Ok(Err(e)) => error_response(StatusCode::BAD_GATEWAY, "reclaim_failed", &e.to_string()),
|
||||
Err(e) => error_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"join_failed",
|
||||
&e.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||
/// The report holds above the daemon's inference for a TTL, then inference
|
||||
/// resumes — an agent that dies mid-run cannot pin its session at `Error`
|
||||
/// forever. Unknown session is 404; an unknown state value is rejected by the
|
||||
/// JSON decoder (422) rather than coerced. Answers `204 No Content`.
|
||||
async fn report_activity(
|
||||
State(s): State<LocalApiState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(body): Json<ReportActivityRequest>,
|
||||
) -> Response {
|
||||
match s.manager.report_activity(id, body.state, body.detail).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => {
|
||||
let info = e.to_error_info();
|
||||
@@ -197,6 +357,7 @@ pub fn state_for(
|
||||
home_dir: impl Into<String>,
|
||||
presets: Vec<SpawnPreset>,
|
||||
projects: Arc<crate::projects::ProjectScanner>,
|
||||
retained: Arc<crate::retained_store::RetainedStore>,
|
||||
) -> LocalApiState {
|
||||
LocalApiState {
|
||||
manager,
|
||||
@@ -204,6 +365,7 @@ pub fn state_for(
|
||||
home_dir: Arc::new(home_dir.into()),
|
||||
presets: Arc::new(presets),
|
||||
projects,
|
||||
retained,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -261,6 +261,8 @@ impl ProjectScanner {
|
||||
}
|
||||
|
||||
/// Cached snapshot, rescanning (off-thread) when the TTL has lapsed.
|
||||
/// Also the background timer's entry point: a tick inside the TTL is a
|
||||
/// no-op (#75).
|
||||
pub async fn repos(&self) -> std::sync::Arc<Vec<DiscoveredRepo>> {
|
||||
let mut cache = self.cache.lock().await;
|
||||
if let Some((at, repos)) = cache.as_ref() {
|
||||
@@ -281,7 +283,9 @@ impl ProjectScanner {
|
||||
repos
|
||||
}
|
||||
|
||||
/// Rescan now, ignoring the TTL. Called by the background timer.
|
||||
/// One background-refresh tick: rescan only if the cached snapshot has
|
||||
/// passed its TTL (#75) — the timer used to bypass it and re-walk the
|
||||
/// roots every [`REFRESH_INTERVAL`].
|
||||
async fn refresh(&self) {
|
||||
self.repos().await;
|
||||
}
|
||||
@@ -536,6 +540,32 @@ mod tests {
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn background_refresh_honours_the_ttl() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut scanner = ProjectScanner::new(
|
||||
vec![tmp.path().to_path_buf()],
|
||||
3,
|
||||
PathBuf::from("/nonexistent"),
|
||||
);
|
||||
|
||||
let empty = scanner.repos().await;
|
||||
assert!(empty.is_empty());
|
||||
|
||||
make_repo(tmp.path(), "late");
|
||||
// A background tick inside the TTL must not re-walk the filesystem:
|
||||
// the new repo stays invisible until the cache expires.
|
||||
scanner.refresh().await;
|
||||
assert!(scanner.repos().await.is_empty());
|
||||
|
||||
// Once the TTL lapses, the next tick picks it up.
|
||||
scanner.ttl = Duration::ZERO;
|
||||
scanner.refresh().await;
|
||||
let fresh = scanner.repos().await;
|
||||
assert_eq!(fresh.len(), 1);
|
||||
assert_eq!(fresh[0].name, "late");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_scanner_always_serves_empty() {
|
||||
let scanner = ProjectScanner::disabled();
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! Store of retained worktrees (#78): worktrees the daemon created whose
|
||||
//! session is gone.
|
||||
//!
|
||||
//! One JSON file per entry under `<state_dir>/retained-worktrees/`, written
|
||||
//! the way `sidecar.rs` writes session records — atomic tmp+rename replace,
|
||||
//! missing or malformed files tolerated on read. The store survives a daemon
|
||||
//! restart; it is the recovery path for everything else in the worktree
|
||||
//! lifecycle.
|
||||
//!
|
||||
//! It is not an ownership record: it must never be consulted to decide whether
|
||||
//! a path may be adopted. Its only job is to remember what the daemon is
|
||||
//! holding so it can be listed and reclaimed.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
/// Why a worktree outlived its session — [`maestro_protocol::RetainedReason`],
|
||||
/// so the store and the wire payload cannot drift apart.
|
||||
pub use maestro_protocol::RetainedReason;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RetainedStoreError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RetainedWorktreeRecord {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
pub reason: RetainedReason,
|
||||
pub retained_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetainedStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
impl RetainedStore {
|
||||
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||
Self { dir: dir.into() }
|
||||
}
|
||||
|
||||
pub fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
fn path_for(&self, id: Uuid) -> PathBuf {
|
||||
self.dir.join(format!("{id}.json"))
|
||||
}
|
||||
|
||||
/// Record a retained worktree and return the entry that was written.
|
||||
pub fn retain(
|
||||
&self,
|
||||
path: &Path,
|
||||
base_repo: &Path,
|
||||
branch: &str,
|
||||
reason: RetainedReason,
|
||||
) -> Result<RetainedWorktreeRecord, RetainedStoreError> {
|
||||
let entry = RetainedWorktreeRecord {
|
||||
id: Uuid::new_v4(),
|
||||
path: path.to_path_buf(),
|
||||
base_repo: base_repo.to_path_buf(),
|
||||
branch: branch.to_string(),
|
||||
reason,
|
||||
retained_at: Utc::now(),
|
||||
};
|
||||
self.overwrite(&entry)?;
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Atomically write `entry`. Creates the directory if it does not exist.
|
||||
pub fn overwrite(&self, entry: &RetainedWorktreeRecord) -> Result<(), RetainedStoreError> {
|
||||
std::fs::create_dir_all(&self.dir)?;
|
||||
let final_path = self.path_for(entry.id);
|
||||
let tmp = self.dir.join(format!("{}.json.tmp", entry.id));
|
||||
let json = serde_json::to_vec_pretty(entry)?;
|
||||
std::fs::write(&tmp, json)?;
|
||||
std::fs::rename(&tmp, &final_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one entry. No-op when the id is unknown — reclaiming an entry the
|
||||
/// store already lost (e.g. pruned) must not fail.
|
||||
pub fn remove(&self, id: Uuid) -> Result<(), RetainedStoreError> {
|
||||
match std::fs::remove_file(self.path_for(id)) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read every entry. Files that are missing or malformed are skipped
|
||||
/// silently: a corrupt record must not hide the others, and an empty or
|
||||
/// absent directory simply means nothing is retained.
|
||||
pub fn load_all(&self) -> Result<Vec<RetainedWorktreeRecord>, RetainedStoreError> {
|
||||
let mut out = Vec::new();
|
||||
let entries = match std::fs::read_dir(&self.dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(worktree) = serde_json::from_slice::<RetainedWorktreeRecord>(&bytes) {
|
||||
out.push(worktree);
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|w| w.retained_at);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop entries whose worktree directory no longer exists — somebody
|
||||
/// removed it by hand. Returns the ids that were pruned.
|
||||
pub fn prune_missing(&self) -> Result<Vec<Uuid>, RetainedStoreError> {
|
||||
let mut pruned = Vec::new();
|
||||
for entry in self.load_all()? {
|
||||
if !entry.path.exists() {
|
||||
self.remove(entry.id)?;
|
||||
pruned.push(entry.id);
|
||||
}
|
||||
}
|
||||
Ok(pruned)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn tmp_dir() -> tempfile::TempDir {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn sample_path(root: &std::path::Path, name: &str) -> PathBuf {
|
||||
let p = root.join(name);
|
||||
std::fs::create_dir_all(&p).expect("mkdir");
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retain_then_load_round_trips_every_field() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
let repo = sample_path(dir.path(), "repo");
|
||||
|
||||
let entry = store
|
||||
.retain(&wt, &repo, "feature/login", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
let all = store.load_all().expect("load_all");
|
||||
assert_eq!(all, vec![entry.clone()]);
|
||||
assert_eq!(entry.path, wt);
|
||||
assert_eq!(entry.base_repo, repo);
|
||||
assert_eq!(entry.branch, "feature/login");
|
||||
assert_eq!(entry.reason, RetainedReason::ForceKilled);
|
||||
let age = Utc::now() - entry.retained_at;
|
||||
assert!(age >= Duration::zero() && age < Duration::minutes(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reason_serializes_kebab_case() {
|
||||
let json =
|
||||
serde_json::to_string(&RetainedReason::ExitedWithUnsavedWork).expect("serialize");
|
||||
assert_eq!(json, r#""exited-with-unsaved-work""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_survive_a_restart() {
|
||||
let dir = tmp_dir();
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
|
||||
let first = RetainedStore::new(dir.path());
|
||||
let entry = first
|
||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
// A fresh store over the same state_dir — what a daemon restart sees.
|
||||
let second = RetainedStore::new(dir.path());
|
||||
assert_eq!(second.load_all().expect("load_all"), vec![entry]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_store_reads_as_empty() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path().join("does-not-exist"));
|
||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_entries_are_skipped_silently() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
store
|
||||
.retain(&wt, dir.path(), "kept", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
std::fs::write(dir.path().join("not-a-uuid.json"), b"{oops").expect("write junk");
|
||||
|
||||
let all = store.load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].branch, "kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_unique() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let a = store
|
||||
.retain(dir.path(), dir.path(), "a", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
let b = store
|
||||
.retain(dir.path(), dir.path(), "b", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_the_entry() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let wt = sample_path(dir.path(), "wt");
|
||||
let entry = store
|
||||
.retain(&wt, dir.path(), "main", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
|
||||
store.remove(entry.id).expect("remove");
|
||||
assert_eq!(store.load_all().expect("load_all"), Vec::new());
|
||||
|
||||
// Removing an unknown id is a no-op, not an error.
|
||||
store.remove(entry.id).expect("remove again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_all_is_sorted_by_time() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let mut first = store
|
||||
.retain(dir.path(), dir.path(), "first", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
first.retained_at -= Duration::hours(1);
|
||||
store.overwrite(&first).expect("rewrite");
|
||||
|
||||
let second = store
|
||||
.retain(
|
||||
dir.path(),
|
||||
dir.path(),
|
||||
"second",
|
||||
RetainedReason::ForceKilled,
|
||||
)
|
||||
.expect("retain");
|
||||
|
||||
let branches: Vec<_> = store
|
||||
.load_all()
|
||||
.expect("load_all")
|
||||
.into_iter()
|
||||
.map(|e| e.branch)
|
||||
.collect();
|
||||
assert_eq!(branches, vec!["first", "second"]);
|
||||
let _ = second;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prune_missing_drops_gone_directories_only() {
|
||||
let dir = tmp_dir();
|
||||
let store = RetainedStore::new(dir.path());
|
||||
let alive = sample_path(dir.path(), "alive");
|
||||
let gone = sample_path(dir.path(), "gone");
|
||||
let keep = store
|
||||
.retain(&alive, dir.path(), "keep", RetainedReason::ForceKilled)
|
||||
.expect("retain");
|
||||
let drop = store
|
||||
.retain(
|
||||
&gone,
|
||||
dir.path(),
|
||||
"drop",
|
||||
RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.expect("retain");
|
||||
std::fs::remove_dir_all(&gone).expect("rm");
|
||||
|
||||
let pruned = store.prune_missing().expect("prune_missing");
|
||||
assert_eq!(pruned, vec![drop.id]);
|
||||
|
||||
let branches: Vec<_> = store
|
||||
.load_all()
|
||||
.expect("load_all")
|
||||
.into_iter()
|
||||
.map(|e| e.branch)
|
||||
.collect();
|
||||
assert_eq!(branches, vec!["keep"]);
|
||||
let _ = keep;
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{
|
||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
|
||||
WorktreeSpec,
|
||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
|
||||
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
|
||||
};
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use uuid::Uuid;
|
||||
@@ -33,18 +33,40 @@ pub const EVENTS_CAPACITY: usize = maestro_config::EVENTS_CAPACITY;
|
||||
/// How often the liveness watcher polls tmux for sessions that disappeared.
|
||||
pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// How often the liveness watcher re-probes worktrees for the blocked-work
|
||||
/// indicator (#83). Each probe costs three git subprocesses per session, so
|
||||
/// the pass rides the same tick as everything else but runs on a slower
|
||||
/// cadence: an indicator a few seconds stale still warns before the kill
|
||||
/// attempt, which is all #83 needs.
|
||||
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Per-session daemon-local bookkeeping that complements the public `Session`.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionRecord {
|
||||
pub session: Session,
|
||||
pub fifo_path: PathBuf,
|
||||
/// Worktree we created at spawn time; removed on kill unless the
|
||||
/// dirty-work guard refuses (#56).
|
||||
/// Worktree we created at spawn time; removed on kill, but only after the
|
||||
/// kill guard (#79) found nothing worth keeping in it.
|
||||
pub worktree_path: Option<PathBuf>,
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
pub worktree_base_repo: Option<PathBuf>,
|
||||
/// Branch the worktree was created for, recorded at spawn time so a
|
||||
/// forced kill (#81) can name it in the retained store without asking
|
||||
/// git. Sessions adopted from a sidecar have no branch on record and
|
||||
/// fall back to a probe.
|
||||
pub worktree_branch: Option<String>,
|
||||
pub bridge: PtyBridge,
|
||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||
/// outranks the activity pass's own inference; when it lapses, inference
|
||||
/// resumes and [`Session::activity_detail`] is cleared with it. This
|
||||
/// field is daemon-local bookkeeping only — it never rides the wire.
|
||||
pub reported_activity: Option<crate::activity::ReportedActivity>,
|
||||
/// Flipped once the natural-exit settle step (#82) has been applied or
|
||||
/// refused for this session. `Dead` is observable before settling ends
|
||||
/// (#115), so this is the observable that says its outcomes are safe to
|
||||
/// assert on. Daemon-local bookkeeping; never rides the wire.
|
||||
pub settled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -61,6 +83,16 @@ pub enum SessionError {
|
||||
NotFound(Uuid),
|
||||
#[error("invalid environment variable name: {0:?}")]
|
||||
InvalidEnv(String),
|
||||
/// The kill was refused (#79): the session's worktree holds unsaved work.
|
||||
/// Nothing happened — the session keeps running and the tree is untouched,
|
||||
/// so it stays the operator's handle on their own unfinished work.
|
||||
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
||||
KillGuarded(maestro_protocol::InTheWay),
|
||||
/// A forced kill (#81) records the retained worktree *before* tearing
|
||||
/// anything down; if that write fails nothing was destroyed, and the
|
||||
/// error says so rather than silently dropping unrecorded work.
|
||||
#[error("could not record the retained worktree: {0}")]
|
||||
RetainFailed(String),
|
||||
}
|
||||
|
||||
impl SessionError {
|
||||
@@ -72,10 +104,17 @@ impl SessionError {
|
||||
SessionError::Io(_) => "io_failed",
|
||||
SessionError::NotFound(_) => "not_found",
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
SessionError::RetainFailed(_) => "retain_failed",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
SessionError::KillGuarded(b) => Some(*b),
|
||||
_ => None,
|
||||
};
|
||||
ErrorInfo {
|
||||
code: code.into(),
|
||||
message: self.to_string(),
|
||||
unsaved_work,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +138,17 @@ struct Inner {
|
||||
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
||||
/// but disabled (`0`-day) history no-ops internally.
|
||||
spawn_history: Option<SpawnHistory>,
|
||||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||||
retained: Option<crate::retained_store::RetainedStore>,
|
||||
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
||||
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
||||
/// the variable. A mutex because the runner sets it after construction,
|
||||
/// possibly while clones are already serving.
|
||||
daemon_url: std::sync::Mutex<Option<String>>,
|
||||
/// TTL an agent-reported activity state (#105) stays in force. A const in
|
||||
/// production; overridable for tests that watch the lapse happen.
|
||||
report_ttl: Duration,
|
||||
sessions: Mutex<HashMap<Uuid, SessionRecord>>,
|
||||
events: broadcast::Sender<CatalogEvent>,
|
||||
}
|
||||
@@ -137,6 +187,9 @@ impl SessionManager {
|
||||
fifo_dir,
|
||||
replay_bytes,
|
||||
spawn_history: None,
|
||||
retained: None,
|
||||
daemon_url: std::sync::Mutex::new(None),
|
||||
report_ttl: crate::activity::REPORT_TTL,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
events,
|
||||
}),
|
||||
@@ -159,6 +212,33 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
|
||||
/// to record what it retained; without one, forcing a guarded kill fails
|
||||
/// instead of destroying unrecorded work.
|
||||
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
|
||||
let inner = Arc::get_mut(&mut self.inner)
|
||||
.expect("with_retained_store must be called before the SessionManager is cloned");
|
||||
inner.retained = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
||||
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
||||
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
||||
let inner = Arc::get_mut(&mut self.inner)
|
||||
.expect("with_activity_report_ttl must be called before the SessionManager is cloned");
|
||||
inner.report_ttl = ttl;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the daemon's own HTTP base URL (#103), exported to every session
|
||||
/// spawned afterwards as `MAESTRO_URL`. Unlike the `with_*` builders this
|
||||
/// is a plain setter: the daemon runner learns its bound address after
|
||||
/// construction, and clones may already exist by then.
|
||||
pub fn set_daemon_url(&self, url: String) {
|
||||
*self.inner.daemon_url.lock().expect("daemon_url mutex") = Some(url);
|
||||
}
|
||||
|
||||
/// Recent working dirs for the spawn form's picker (#24), newest-first and
|
||||
/// deduped by dir. Empty when no history is wired or it's disabled.
|
||||
pub fn recent_dirs(&self) -> Vec<RecentDir> {
|
||||
@@ -219,6 +299,17 @@ impl SessionManager {
|
||||
sessions.get(&id).map(|r| r.session.status)
|
||||
}
|
||||
|
||||
/// Whether the natural-exit settle step (#82) has finished for this
|
||||
/// session: `Some(true)` once applied or refused, `Some(false)` while
|
||||
/// still pending, `None` if the session is unknown. `Dead` is observable
|
||||
/// before settling ends (#115), so anything asserting on a dead
|
||||
/// session's worktree waits on this rather than inferring settle
|
||||
/// completion from its side effects.
|
||||
pub async fn is_settled(&self, id: Uuid) -> Option<bool> {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
sessions.get(&id).map(|r| r.settled)
|
||||
}
|
||||
|
||||
/// Spawn a new session. On success returns the freshly-created `Session`.
|
||||
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
|
||||
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
|
||||
@@ -226,13 +317,20 @@ impl SessionManager {
|
||||
let WorktreeSpec {
|
||||
base_repo, branch, ..
|
||||
} = spec;
|
||||
// A path collision is sidestepped inside `run_add` (#77), and
|
||||
// a branch already checked out elsewhere is git's refusal,
|
||||
// not ours.
|
||||
let path = if spec.create_branch {
|
||||
self.inner.worktree_mgr.add_new(base_repo, branch)
|
||||
} else {
|
||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||
}
|
||||
.map_err(SessionError::Worktree)?;
|
||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
||||
// Record the repository, not whatever the request named: a
|
||||
// spawn from inside a linked worktree passes that worktree as
|
||||
// `base_repo`, and clients group sessions by this path (#99).
|
||||
let repo = crate::worktree::main_checkout_of(base_repo);
|
||||
(path.clone(), Some(path), Some(repo))
|
||||
}
|
||||
None => (req.cwd.clone(), None, None),
|
||||
};
|
||||
@@ -254,7 +352,7 @@ impl SessionManager {
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let session_env = match sanitize_env(&req.env) {
|
||||
let mut session_env = match sanitize_env(&req.env) {
|
||||
Ok(env) => env,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&fifo_path);
|
||||
@@ -262,6 +360,19 @@ impl SessionManager {
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Identity + reporting target for the in-session hook (#106). Ours
|
||||
// win over whatever the request carried, so a client cannot spoof
|
||||
// another session's identity (#103).
|
||||
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
|
||||
if let Some(url) = self
|
||||
.inner
|
||||
.daemon_url
|
||||
.lock()
|
||||
.expect("daemon_url mutex")
|
||||
.clone()
|
||||
{
|
||||
session_env.insert("MAESTRO_URL".into(), url);
|
||||
}
|
||||
let spawned = {
|
||||
let tmux = self.inner.tmux.clone();
|
||||
let cwd = effective_cwd.clone();
|
||||
@@ -290,15 +401,26 @@ impl SessionManager {
|
||||
command: req.command,
|
||||
env: req.env,
|
||||
metadata: req.metadata,
|
||||
worktree: worktree_path.as_ref().zip(worktree_base_repo.as_ref()).map(
|
||||
|(path, base_repo)| maestro_protocol::SessionWorktree {
|
||||
path: path.clone(),
|
||||
base_repo: base_repo.clone(),
|
||||
},
|
||||
),
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
// A freshly-spawned session is producing its initial output; the
|
||||
// activity watcher refines `foreground`/`activity` on its next tick.
|
||||
foreground: None,
|
||||
activity: ActivityState::Active,
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
// The watcher fills this in on its first blocked-work pass; a
|
||||
// fresh tree has nothing in the way anyway.
|
||||
blocked_work: None,
|
||||
};
|
||||
|
||||
if let Err(e) = self.inner.sidecar.write(&session) {
|
||||
@@ -320,7 +442,10 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -361,22 +486,78 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill a session and remove its on-disk artifacts. The worktree is
|
||||
/// always pruned -- unless it holds uncommitted changes or commits not on
|
||||
/// any other ref, in which case the guard leaves it on disk (#56).
|
||||
/// Kill a session and remove its on-disk artifacts.
|
||||
///
|
||||
/// Refused (#79) when the session's worktree holds unsaved work —
|
||||
/// uncommitted tracked changes, untracked non-ignored files, or commits
|
||||
/// reachable from no remote-tracking ref. Nothing is killed and nothing
|
||||
/// is removed; the error names what blocked it and how much of it there
|
||||
/// is. The session stays as the operator's handle on their own unfinished
|
||||
/// work, so ending a session that still matters becomes a deliberate
|
||||
/// `--force`, which retains rather than destroys (WORKTREES.md).
|
||||
///
|
||||
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
||||
/// the session ends and the guarded worktree is retained into the store
|
||||
/// with reason `force-killed`, never removed. The outcome names what was
|
||||
/// kept so a client can say where the work went. A tree whose guard is
|
||||
/// clear never needed the override; force removes it as usual.
|
||||
///
|
||||
/// This is also how an already-dead session is dismissed. tmux is only
|
||||
/// asked to kill something it still has — calling `kill-session` on a
|
||||
/// session tmux already lost fails, and that failure is not interesting.
|
||||
pub async fn kill(&self, id: Uuid) -> Result<(), SessionError> {
|
||||
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> {
|
||||
let (worktree, alive, known_branch) = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
match sessions.get(&id) {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
Some(record) => (
|
||||
record
|
||||
.worktree_base_repo
|
||||
.clone()
|
||||
.zip(record.worktree_path.clone()),
|
||||
record.session.status == SessionStatus::Running,
|
||||
record.worktree_branch.clone(),
|
||||
),
|
||||
}
|
||||
};
|
||||
let probe = match &worktree {
|
||||
Some(wt) => Some(probe_worktree(wt).await?),
|
||||
None => None,
|
||||
};
|
||||
let guarded = probe.as_ref().is_some_and(|p| !p.is_clear());
|
||||
if guarded && !force {
|
||||
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
|
||||
}
|
||||
|
||||
let mut retained = None;
|
||||
if guarded {
|
||||
let entry = record_retention(
|
||||
&self.inner,
|
||||
worktree.as_ref().expect("guarded ⇒ worktree"),
|
||||
known_branch,
|
||||
RetainedReason::ForceKilled,
|
||||
)
|
||||
.await?;
|
||||
retained = Some((entry, probe.expect("guarded ⇒ probed")));
|
||||
}
|
||||
|
||||
let removed = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
sessions.remove(&id)
|
||||
};
|
||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||
let alive = record.session.status == SessionStatus::Running;
|
||||
cleanup_record(&self.inner, id, record, alive).await;
|
||||
Ok(())
|
||||
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
|
||||
Ok(KillOutcome {
|
||||
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
|
||||
id: entry.id,
|
||||
path: entry.path,
|
||||
base_repo: entry.base_repo,
|
||||
branch: entry.branch,
|
||||
reason: entry.reason,
|
||||
retained_at: entry.retained_at,
|
||||
in_the_way,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawn a background task that polls tmux every `interval` and reaps any
|
||||
@@ -387,6 +568,17 @@ impl SessionManager {
|
||||
/// `SessionManager` clone is dropped — call this once from the daemon
|
||||
/// startup path after [`Self::recover`] has populated the map.
|
||||
pub fn start_liveness_watcher(&self, interval: Duration) {
|
||||
self.start_liveness_watcher_with_blocked_work(interval, DEFAULT_BLOCKED_WORK_INTERVAL);
|
||||
}
|
||||
|
||||
/// [`Self::start_liveness_watcher`] with the blocked-work cadence (#83)
|
||||
/// made explicit. Tests pass a small `blocked_work_interval` to watch the
|
||||
/// probe actually happen.
|
||||
pub fn start_liveness_watcher_with_blocked_work(
|
||||
&self,
|
||||
interval: Duration,
|
||||
blocked_work_interval: Duration,
|
||||
) {
|
||||
let weak = Arc::downgrade(&self.inner);
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(interval);
|
||||
@@ -394,6 +586,9 @@ impl SessionManager {
|
||||
// first tick fires immediately — skip it so we don't reap before
|
||||
// the caller has a chance to create anything.
|
||||
ticker.tick().await;
|
||||
// Start "due" so the first live tick probes: a recovered daemon
|
||||
// should not wait a full interval to show what blocks.
|
||||
let mut since_probe = blocked_work_interval;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
@@ -403,6 +598,11 @@ impl SessionManager {
|
||||
// Same tick, no new timer (NAP-340): refresh activity for the
|
||||
// survivors and emit SessionUpdated on any transition.
|
||||
run_activity_pass(&inner).await;
|
||||
since_probe += interval;
|
||||
if since_probe >= blocked_work_interval {
|
||||
since_probe = Duration::ZERO;
|
||||
run_blocked_work_pass(&inner).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -430,14 +630,21 @@ impl SessionManager {
|
||||
command: String::new(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
// Activity is unknown on adoption; the watcher fills it in.
|
||||
// #104: the restart also resets the activity clock to now —
|
||||
// accepted, since the daemon cannot know how long a
|
||||
// recovered session had already been sitting idle.
|
||||
foreground: None,
|
||||
activity: ActivityState::Idle,
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
},
|
||||
};
|
||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||
@@ -457,9 +664,17 @@ impl SessionManager {
|
||||
SessionRecord {
|
||||
session: session.clone(),
|
||||
fifo_path,
|
||||
worktree_path: None,
|
||||
worktree_base_repo: None,
|
||||
// Restored from the sidecar: without it the recovered
|
||||
// session cleans up nothing on kill, and the leftover
|
||||
// directory blocks that branch forever (#54 review).
|
||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||
// The sidecar carries no branch; a forced kill of a
|
||||
// recovered session probes git for it at retain time.
|
||||
worktree_branch: None,
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
},
|
||||
);
|
||||
let _ = self
|
||||
@@ -500,6 +715,47 @@ impl SessionManager {
|
||||
.await
|
||||
.map_err(SessionError::Tmux)
|
||||
}
|
||||
|
||||
/// Accept an agent-reported activity state (#105), the only path that can
|
||||
/// produce `ActivityState::Error`. The report outranks the activity pass's
|
||||
/// own inference until its TTL lapses — so an agent that dies mid-run
|
||||
/// cannot pin its session at `Error` forever. An unknown session is
|
||||
/// [`SessionError::NotFound`]; the reported `detail` is capped
|
||||
/// server-side.
|
||||
///
|
||||
/// A report that changes the effective state restarts `activity_since`
|
||||
/// (#104's clock); re-reporting an unchanged state only extends the TTL
|
||||
/// and stays silent on the event stream at steady state.
|
||||
pub async fn report_activity(
|
||||
&self,
|
||||
id: Uuid,
|
||||
state: ActivityState,
|
||||
detail: Option<String>,
|
||||
) -> Result<(), SessionError> {
|
||||
let updated = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
let rec = sessions.get_mut(&id).ok_or(SessionError::NotFound(id))?;
|
||||
let detail = crate::activity::truncate_detail(detail);
|
||||
let changed = rec.session.activity != state || rec.session.activity_detail != detail;
|
||||
if rec.session.activity != state {
|
||||
rec.session.activity_since = Utc::now();
|
||||
}
|
||||
rec.session.activity = state;
|
||||
rec.session.activity_detail = detail;
|
||||
rec.reported_activity = Some(crate::activity::ReportedActivity::new(
|
||||
state,
|
||||
self.inner.report_ttl,
|
||||
));
|
||||
changed.then(|| rec.session.clone())
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = self
|
||||
.inner
|
||||
.events
|
||||
.send(CatalogEvent::SessionUpdated { session });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a blocking `tmux` subprocess call on the blocking thread pool so it
|
||||
@@ -535,6 +791,49 @@ where
|
||||
.expect("tmux blocking task panicked")
|
||||
}
|
||||
|
||||
/// What would be lost if this session's worktree were removed right now.
|
||||
///
|
||||
/// The single [`crate::worktree_probe::in_the_way`] call site behind both
|
||||
/// endings of a session — the kill guard (#79) and the natural-exit
|
||||
/// classifier (#82) — so neither can grow its own answer. Runs on the
|
||||
/// blocking pool; callers invoke it before anything is torn down, because
|
||||
/// refusing (#79), recording a retention (#81) and classifying an exit (#82)
|
||||
/// must all see the tree as it stands.
|
||||
async fn probe_worktree(
|
||||
(_, path): &(PathBuf, PathBuf),
|
||||
) -> Result<maestro_protocol::InTheWay, SessionError> {
|
||||
let path = path.clone();
|
||||
tmux_blocking(move || crate::worktree_probe::in_the_way(&path))
|
||||
.await
|
||||
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
|
||||
}
|
||||
|
||||
/// Record a guarded worktree into the retained store with `reason`, BEFORE
|
||||
/// the caller tears anything down: a failed write then leaves every artifact
|
||||
/// untouched instead of destroying work nothing records anymore. Shared by
|
||||
/// the forced kill (`ForceKilled`, #81) and the natural exit
|
||||
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
|
||||
async fn record_retention(
|
||||
inner: &Arc<Inner>,
|
||||
(base_repo, path): &(PathBuf, PathBuf),
|
||||
known_branch: Option<String>,
|
||||
reason: RetainedReason,
|
||||
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
|
||||
let store = inner
|
||||
.retained
|
||||
.clone()
|
||||
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
|
||||
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
|
||||
let branch = known_branch
|
||||
.or_else(|| crate::worktree_probe::current_branch(path))
|
||||
.unwrap_or_default();
|
||||
let base_repo = base_repo.clone();
|
||||
let path = path.clone();
|
||||
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
|
||||
.await
|
||||
.map_err(|e| SessionError::RetainFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// One pass of the liveness watcher: diff the in-memory session set against
|
||||
/// the tmux-reported one, and reap anything we still track but tmux has lost.
|
||||
async fn run_liveness_pass(inner: &Arc<Inner>) {
|
||||
@@ -627,8 +926,22 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
let Some(rec) = sessions.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
// #105: an agent report lapses at its TTL — drop it and its
|
||||
// detail so this tick's inference takes over again.
|
||||
if let Some(report) = &rec.reported_activity {
|
||||
if !report.in_force(std::time::Instant::now()) {
|
||||
rec.reported_activity = None;
|
||||
rec.session.activity_detail = None;
|
||||
}
|
||||
}
|
||||
let idle = rec.bridge.idle_for();
|
||||
let activity = crate::activity::decide(idle, awaiting);
|
||||
// #105: while a report is in force it supplies the state and
|
||||
// nothing here may overwrite it; only an unreported session is
|
||||
// decided by inference.
|
||||
let activity = match &rec.reported_activity {
|
||||
Some(report) => report.state,
|
||||
None => crate::activity::decide(idle, awaiting),
|
||||
};
|
||||
let foreground = Some(pane.command.clone());
|
||||
// The pane's live cwd, drives the dir-based session tree; empty means
|
||||
// tmux reported none.
|
||||
@@ -642,6 +955,12 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
{
|
||||
None
|
||||
} else {
|
||||
// #104: stamp only when the state itself changed — never on
|
||||
// foreground/cwd-only diffs, and never every tick, or the
|
||||
// sidebar's duration would reset continuously and read zero.
|
||||
if rec.session.activity != activity {
|
||||
rec.session.activity_since = Utc::now();
|
||||
}
|
||||
rec.session.activity = activity;
|
||||
rec.session.foreground = foreground;
|
||||
rec.session.current_cwd = current_cwd;
|
||||
@@ -657,18 +976,79 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh each running session's blocked-work indicator (#83): what its
|
||||
/// worktree holds right now that would block an unforced kill. Rides the
|
||||
/// liveness tick on its own (slower) cadence — see
|
||||
/// [`DEFAULT_BLOCKED_WORK_INTERVAL`] — because every probe is three git
|
||||
/// subprocesses per session.
|
||||
///
|
||||
/// Sessions without a worktree are filtered out BEFORE any probe: they can
|
||||
/// never block a kill, so their indicator must cost nothing at all. A failed
|
||||
/// probe keeps the previous value rather than clearing it — a transient git
|
||||
/// error must not read as "nothing blocks". Like the activity pass, this only
|
||||
/// emits `SessionUpdated` when the value actually changed.
|
||||
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
||||
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
|
||||
let sessions = inner.sessions.lock().await;
|
||||
sessions
|
||||
.iter()
|
||||
.filter(|(_, r)| r.session.status == SessionStatus::Running)
|
||||
.filter_map(|(id, r)| {
|
||||
let wt = r.worktree_base_repo.clone().zip(r.worktree_path.clone())?;
|
||||
Some((*id, wt))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (id, wt) in targets {
|
||||
// The kill guard's own probe (#79), so the indicator can never
|
||||
// disagree with what a kill attempt would find.
|
||||
let breakdown = match probe_worktree(&wt).await {
|
||||
Ok(breakdown) => breakdown,
|
||||
Err(e) => {
|
||||
tracing::debug!(%id, error = ?e, "blocked-work: probe failed; keeping previous value");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let updated = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
let Some(rec) = sessions.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
if rec.session.blocked_work == Some(breakdown) {
|
||||
None
|
||||
} else {
|
||||
rec.session.blocked_work = Some(breakdown);
|
||||
Some(rec.session.clone())
|
||||
}
|
||||
};
|
||||
if let Some(session) = updated {
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
||||
/// and announce it, keeping everything else intact.
|
||||
/// and settle its worktree per the natural-exit rule (#82).
|
||||
///
|
||||
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
||||
/// still attach and read the replay buffer — the last thing a crashed agent
|
||||
/// printed. Artifacts are only released when the operator dismisses the session
|
||||
/// via [`SessionManager::kill`].
|
||||
/// printed. The worktree, though, is settled right away: a pristine tree is
|
||||
/// removed, anything else is retained into the store with reason
|
||||
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
|
||||
/// so there is nothing to refuse. Once settled, the session drops its
|
||||
/// worktree reference so a later dismissal neither re-probes nor retains
|
||||
/// twice. When settling is impossible (probe or store failure) nothing is
|
||||
/// destroyed: the tree stays attached to the dead record for a later forced
|
||||
/// dismissal (#81).
|
||||
///
|
||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||
/// adopts only what tmux still holds.
|
||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
let updated = {
|
||||
let flipped = {
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
match sessions.get_mut(&id) {
|
||||
Some(record) if record.session.status == SessionStatus::Running => {
|
||||
@@ -676,19 +1056,122 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
// Release anyone attached; the scrollback survives for a later
|
||||
// attach.
|
||||
record.bridge.seal();
|
||||
Some(record.session.clone())
|
||||
let session = record.session.clone();
|
||||
let worktree = record
|
||||
.worktree_base_repo
|
||||
.clone()
|
||||
.zip(record.worktree_path.clone());
|
||||
let branch = record.worktree_branch.clone();
|
||||
Some((session, worktree, branch))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some(session) = updated else {
|
||||
let Some((mut session, worktree, branch)) = flipped else {
|
||||
return;
|
||||
};
|
||||
tracing::info!(%id, "session vanished from tmux — marking dead");
|
||||
|
||||
if let Some(wt) = &worktree {
|
||||
if settle_worktree_on_exit(inner, id, wt, branch).await {
|
||||
// The tree is settled (removed or retained); a stale "in the
|
||||
// way" would describe something the session no longer holds.
|
||||
session.blocked_work = None;
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.session.worktree = None;
|
||||
record.session.blocked_work = None;
|
||||
record.worktree_path = None;
|
||||
record.worktree_base_repo = None;
|
||||
record.worktree_branch = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Settle has now been applied or refused either way (#115): from here on
|
||||
// its outcomes are safe to observe.
|
||||
{
|
||||
let mut sessions = inner.sessions.lock().await;
|
||||
if let Some(record) = sessions.get_mut(&id) {
|
||||
record.settled = true;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = inner.sidecar.write(&session);
|
||||
let _ = inner.events.send(CatalogEvent::SessionUpdated { session });
|
||||
}
|
||||
|
||||
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
||||
/// would be lost through the kill's own predicate, then remove a pristine
|
||||
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
|
||||
/// before anything is torn down, exactly as the forced kill does (#81). A
|
||||
/// removal that fails falls back to retaining, since whatever blocked it is
|
||||
/// by definition something worth keeping.
|
||||
///
|
||||
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
||||
/// nothing was destroyed and the caller keeps the reference on the record.
|
||||
async fn settle_worktree_on_exit(
|
||||
inner: &Arc<Inner>,
|
||||
id: Uuid,
|
||||
(base_repo, path): &(PathBuf, PathBuf),
|
||||
known_branch: Option<String>,
|
||||
) -> bool {
|
||||
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
||||
Ok(probe) => probe,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = ?e,
|
||||
"natural exit: could not probe the worktree; leaving it untouched"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if probe.is_clear() {
|
||||
match inner.worktree_mgr.remove(base_repo, path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = ?e,
|
||||
"natural exit: removal failed; falling back to retention"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
match record_retention(
|
||||
inner,
|
||||
&(base_repo.clone(), path.clone()),
|
||||
known_branch,
|
||||
RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(entry) => {
|
||||
tracing::info!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
entry = %entry.id,
|
||||
"natural exit: worktree retained with unsaved work"
|
||||
);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = ?e,
|
||||
"natural exit: could not record the retained worktree; leaving it untouched"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a session's artifacts after it has already been removed from the
|
||||
/// live map. Shared by [`SessionManager::kill`] and [`reap_dead_session`],
|
||||
/// which previously hand-rolled the same five-step choreography and only
|
||||
@@ -698,7 +1181,17 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
/// gone from the authoritative in-memory map, so a stray FIFO or sidecar can't
|
||||
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
||||
/// liveness reaper (tmux already lost the session).
|
||||
async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kill_tmux: bool) {
|
||||
///
|
||||
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
|
||||
/// worktree instead of removing it: the store entry is already written, so
|
||||
/// only the removal is skipped.
|
||||
async fn cleanup_record(
|
||||
inner: &Arc<Inner>,
|
||||
id: Uuid,
|
||||
record: SessionRecord,
|
||||
kill_tmux: bool,
|
||||
retain_worktree: bool,
|
||||
) {
|
||||
if kill_tmux {
|
||||
let tmux = inner.tmux.clone();
|
||||
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
|
||||
@@ -707,12 +1200,18 @@ async fn cleanup_record(inner: &Arc<Inner>, id: Uuid, record: SessionRecord, kil
|
||||
if let Err(e) = inner.sidecar.delete(id) {
|
||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||
}
|
||||
// Cleanup is unconditional now (#56): `cleanup_on_kill` is no longer a
|
||||
// user-facing choice. The manager's guard is what protects unsaved work --
|
||||
// when it refuses, leave the tree on disk and say where it is so the
|
||||
// operator can find it.
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
if retain_worktree {
|
||||
tracing::info!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
"forced kill: worktree retained in the store"
|
||||
);
|
||||
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
// The kill already passed the unsaved-work guard (#79), so this
|
||||
// removal is unconditional; the manager's own nested-worktree
|
||||
// refusal is all that can still leave a tree on disk, and it
|
||||
// says where.
|
||||
tracing::warn!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
|
||||
@@ -264,6 +264,25 @@ impl Tmux {
|
||||
check(out)
|
||||
}
|
||||
|
||||
/// `tmux show-environment -t maestro-<id>`, parsed into KEY=VALUE pairs.
|
||||
/// Test-facing view of what a spawned session actually inherits.
|
||||
pub fn show_environment(&self, id: Uuid) -> Result<BTreeMap<String, String>, TmuxError> {
|
||||
let name = session_name(id);
|
||||
let out = self
|
||||
.cmd()
|
||||
.args(["show-environment", "-t", &name])
|
||||
.output()?;
|
||||
check(out.clone())?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let mut env = BTreeMap::new();
|
||||
for line in stdout.lines() {
|
||||
if let Some((k, v)) = line.split_once('=') {
|
||||
env.insert(k.to_string(), v.to_string());
|
||||
}
|
||||
}
|
||||
Ok(env)
|
||||
}
|
||||
|
||||
/// `tmux kill-server`. Best-effort cleanup used mostly by tests.
|
||||
pub fn kill_server(&self) -> Result<(), TmuxError> {
|
||||
let out = self.cmd().arg("kill-server").output()?;
|
||||
|
||||
@@ -10,15 +10,24 @@ pub enum WorktreeError {
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
/// A worktree already occupies the target path. The caller decides what
|
||||
/// to do; the daemon never relocates to a different directory (#56).
|
||||
#[error("worktree path already exists: {}", .path.display())]
|
||||
PathExists { path: PathBuf },
|
||||
/// The worktree holds unsaved work; removal was refused (#56).
|
||||
/// A registered worktree lives under the tree about to be removed; git
|
||||
/// hides its contents from `git status`, so removal would delete its
|
||||
/// uncommitted work unseen (#54 review).
|
||||
#[error("left {} on disk: {}", .path.display(), .reason)]
|
||||
Guarded { path: PathBuf, reason: String },
|
||||
}
|
||||
|
||||
impl From<crate::worktree_probe::ProbeError> for WorktreeError {
|
||||
fn from(e: crate::worktree_probe::ProbeError) -> Self {
|
||||
match e {
|
||||
crate::worktree_probe::ProbeError::Spawn(io) => WorktreeError::Spawn(io),
|
||||
crate::worktree_probe::ProbeError::NonZero { code, stderr } => {
|
||||
WorktreeError::NonZero { code, stderr }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `git worktree add` rooted at `base_repo`. By default worktrees land
|
||||
/// inside the repository itself at `<repo>/worktrees/<branch>` (#56) so they
|
||||
/// are findable from the repo; an operator-set root overrides that.
|
||||
@@ -39,9 +48,9 @@ impl WorktreeManager {
|
||||
}
|
||||
|
||||
/// Where a worktree for `branch` will be created: the override root, or
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. The path is
|
||||
/// predictable (no UUID suffix), so an existing directory at the target
|
||||
/// is an error rather than a silently different location.
|
||||
/// `<main checkout>/worktrees/<sanitized branch>` by default. If the path
|
||||
/// is occupied, [`Self::add_new`] / [`Self::add_existing`] sidestep to
|
||||
/// `<path>-2`, `<path>-3`, ... instead.
|
||||
///
|
||||
/// `base_repo` is whatever the client picked, which the project list makes
|
||||
/// easy to point at a *linked worktree* — placing the new tree inside it
|
||||
@@ -51,23 +60,36 @@ impl WorktreeManager {
|
||||
/// uncommitted work (#54 review). Always place against the main checkout.
|
||||
///
|
||||
/// The override root gets the repo's basename too: without it two repos
|
||||
/// spawning the same branch name target one directory and the second
|
||||
/// fails with `PathExists`.
|
||||
/// spawning the same branch name target one directory.
|
||||
fn target_path(&self, base_repo: &Path, branch: &str) -> PathBuf {
|
||||
let slug = sanitize_branch_for_path(branch);
|
||||
// An unusable branch name falls back to a digest rather than escaping
|
||||
// the root; `run_add` surfaces git's own error for it anyway.
|
||||
let slug = branch_relpath(branch).unwrap_or_else(|| PathBuf::from(short_hash(branch)));
|
||||
let main = main_checkout(base_repo);
|
||||
match &self.root_override {
|
||||
Some(root) => {
|
||||
let repo = main
|
||||
// Basename alone collides for two checkouts of the same name
|
||||
// (`~/a/maestro` and `~/b/maestro`), so disambiguate with a
|
||||
// digest of the full path (#54 review).
|
||||
let base = main
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "repo".into());
|
||||
let repo = format!("{base}-{}", short_hash(&main.to_string_lossy()));
|
||||
root.join(repo).join(slug)
|
||||
}
|
||||
None => main.join("worktrees").join(slug),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::target_path`] with `-n` appended to the final component, the
|
||||
/// collision suffix (#77).
|
||||
fn target_path_suffixed(&self, base_repo: &Path, branch: &str, n: u32) -> PathBuf {
|
||||
let mut name = self.target_path(base_repo, branch).into_os_string();
|
||||
name.push(format!("-{n}"));
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree add -b <branch> <path>` — creates the
|
||||
/// branch. Returns the path of the new worktree on success.
|
||||
pub fn add_new(&self, base_repo: &Path, branch: &str) -> Result<PathBuf, WorktreeError> {
|
||||
@@ -86,12 +108,18 @@ impl WorktreeManager {
|
||||
branch: &str,
|
||||
create_branch: bool,
|
||||
) -> Result<PathBuf, WorktreeError> {
|
||||
let path = self.target_path(base_repo, branch);
|
||||
// Clear registrations whose directory is gone before testing the
|
||||
// path, so a previously-leaked entry doesn't block this branch.
|
||||
// Clear registrations whose directory is gone first, so a
|
||||
// previously-leaked entry doesn't push this branch to a suffix.
|
||||
prune(base_repo)?;
|
||||
if path.symlink_metadata().is_ok() {
|
||||
return Err(WorktreeError::PathExists { path });
|
||||
// Anything occupying the target — a tree we left behind, one the
|
||||
// operator made, an unrelated directory — is sidestepped with a
|
||||
// numeric suffix on the directory; the branch stays whatever was
|
||||
// requested (#77). The daemon never adopts what it finds.
|
||||
let mut path = self.target_path(base_repo, branch);
|
||||
let mut n = 1;
|
||||
while path.symlink_metadata().is_ok() {
|
||||
n += 1;
|
||||
path = self.target_path_suffixed(base_repo, branch, n);
|
||||
}
|
||||
// The parent (`<repo>/worktrees` or the override root) may not exist
|
||||
// yet; the target itself must not.
|
||||
@@ -111,12 +139,13 @@ impl WorktreeManager {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree remove <path>` — but only when the
|
||||
/// worktree holds nothing unique (#56). A worktree with uncommitted
|
||||
/// changes, or with commits reachable from no other ref, is refused and
|
||||
/// left on disk so the operator can rescue it; cleanup is unconditional,
|
||||
/// which makes this guard the only thing standing between a stray kill
|
||||
/// and destroyed work.
|
||||
/// `git -C <base_repo> worktree remove <path>`.
|
||||
///
|
||||
/// Unconditional (#79): the guard moved off the worktree and onto the
|
||||
/// kill, so by the time removal runs the caller has already chosen to
|
||||
/// destroy the tree — the kill only gets here after
|
||||
/// [`crate::worktree_probe::in_the_way`] came back clear. Still refused for a tree holding a
|
||||
/// nested registered worktree, which `git status` cannot see.
|
||||
pub fn remove(&self, base_repo: &Path, path: &Path) -> Result<(), WorktreeError> {
|
||||
// Gone from disk already (operator `rm -rf`, or a create that failed
|
||||
// half-way). Probing it with git would fail and be misread as unsaved
|
||||
@@ -132,12 +161,6 @@ impl WorktreeManager {
|
||||
reason: format!("holds a nested worktree at {}", inner.display()),
|
||||
});
|
||||
}
|
||||
if let Some(reason) = unsafe_to_remove(path)? {
|
||||
return Err(WorktreeError::Guarded {
|
||||
path: path.to_path_buf(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(base_repo)
|
||||
@@ -151,6 +174,13 @@ impl WorktreeManager {
|
||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||
/// back to the input when git can't answer.
|
||||
/// The repository a path belongs to: the main checkout, even when `path` is a
|
||||
/// linked worktree. Clients group sessions by this (#99), and placement uses it
|
||||
/// so a worktree is never nested inside another.
|
||||
pub fn main_checkout_of(path: &Path) -> PathBuf {
|
||||
main_checkout(path)
|
||||
}
|
||||
|
||||
fn main_checkout(path: &Path) -> PathBuf {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -180,44 +210,60 @@ fn nested_worktree(path: &Path) -> Option<PathBuf> {
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.output()
|
||||
.ok()?;
|
||||
// `worktree list` prints fully-resolved paths while ours comes from
|
||||
// `--git-common-dir` and may still hold symlinks. Comparing the two forms
|
||||
// directly makes the guard silently pass on any symlinked repo path — and
|
||||
// this guard is what stands between a kill and deleting an inner
|
||||
// worktree's uncommitted work (#54 review).
|
||||
let needle = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
text.lines()
|
||||
.filter_map(|l| l.strip_prefix("worktree "))
|
||||
.map(PathBuf::from)
|
||||
.find(|p| p != path && p.starts_with(path))
|
||||
.find(|p| {
|
||||
let real = p.canonicalize().unwrap_or_else(|_| p.clone());
|
||||
real != needle && real.starts_with(&needle)
|
||||
})
|
||||
}
|
||||
|
||||
fn sanitize_branch_for_path(branch: &str) -> String {
|
||||
let mut out = String::with_capacity(branch.len());
|
||||
for c in branch.chars() {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
out.push(c);
|
||||
} else if c == '/' || c == '.' {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push_str("wt");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `Some(reason)` when deleting `path` would destroy work.
|
||||
/// Short digest used to disambiguate override roots.
|
||||
///
|
||||
/// Only an unclean working tree qualifies. `git worktree remove` deletes the
|
||||
/// checkout but keeps `refs/heads/<branch>`, so committed work — pushed or
|
||||
/// not — stays reachable and is never at risk. Guarding on "commits on no
|
||||
/// other ref" instead would refuse every ordinary unpushed feature branch,
|
||||
/// and because the path is deterministic that branch could then never be
|
||||
/// spawned again (#56 review).
|
||||
fn unsafe_to_remove(path: &Path) -> Result<Option<String>, WorktreeError> {
|
||||
// `--ignored=no` keeps build output out of it; untracked sources count,
|
||||
// since removal would delete them.
|
||||
let status = run_git(path, ["status", "--porcelain", "--ignored=no"])?;
|
||||
if !status.stdout.is_empty() {
|
||||
return Ok(Some("has uncommitted changes".into()));
|
||||
/// Hand-rolled FNV-1a rather than `DefaultHasher`: this value becomes a
|
||||
/// directory name, and `DefaultHasher`'s algorithm is explicitly not
|
||||
/// guaranteed stable across Rust releases — a toolchain bump would remap every
|
||||
/// repo to a new directory and orphan the worktrees under the old one
|
||||
/// (#67 review).
|
||||
fn short_hash(s: &str) -> String {
|
||||
let mut h: u32 = 0x811c_9dc5;
|
||||
for b in s.as_bytes() {
|
||||
h ^= u32::from(*b);
|
||||
h = h.wrapping_mul(0x0100_0193);
|
||||
}
|
||||
Ok(None)
|
||||
format!("{h:08x}")
|
||||
}
|
||||
|
||||
/// The relative directory a branch's worktree lives in.
|
||||
///
|
||||
/// A git branch name is already a valid relative path — git stores it as
|
||||
/// `.git/refs/heads/feat/example` — so it is used verbatim and `feat/example`
|
||||
/// nests under `worktrees/feat/`. That keeps the path readable *and* keeps the
|
||||
/// mapping injective: flattening `/` and `.` to `-` made `feat/x`, `feat.x`
|
||||
/// and `feat-x` share one directory (#54 review).
|
||||
///
|
||||
/// Git already forbids holding both `feat` and `feat/x`, so the directory
|
||||
/// collision that nesting could cause cannot arise from valid refs. Only
|
||||
/// traversal and absolute components are rejected.
|
||||
fn branch_relpath(branch: &str) -> Option<PathBuf> {
|
||||
use std::path::Component;
|
||||
let candidate = Path::new(branch);
|
||||
if branch.is_empty()
|
||||
|| !candidate
|
||||
.components()
|
||||
.all(|c| matches!(c, Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(candidate.to_path_buf())
|
||||
}
|
||||
|
||||
/// `git -C <base_repo> worktree prune` — drops registrations whose directory
|
||||
@@ -231,22 +277,6 @@ fn prune(base_repo: &Path) -> Result<(), WorktreeError> {
|
||||
check(out)
|
||||
}
|
||||
|
||||
fn run_git(
|
||||
dir: &Path,
|
||||
args: impl IntoIterator<Item = &'static str>,
|
||||
) -> Result<Output, WorktreeError> {
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(WorktreeError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check(out: Output) -> Result<(), WorktreeError> {
|
||||
if out.status.success() {
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
//! The one git probe for what a worktree holds that would be lost, plus
|
||||
//! guard-free reclaim (#79, #80).
|
||||
//!
|
||||
//! [`in_the_way`] is the single predicate behind all three paths: the kill
|
||||
//! refusal (#79), the natural-exit classifier (#82), and the retained list
|
||||
//! (#80). It lives here rather than in `worktree.rs` so that none of them can
|
||||
//! answer the question differently — #79 and #80 were built in parallel and
|
||||
//! each grew its own copy, which is exactly the drift this collapses.
|
||||
//!
|
||||
//! Reclaim never consults it: reclaim is the deliberate destructive act, and
|
||||
//! the operator chose it while looking at what was in the way.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use maestro_protocol::InTheWay;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProbeError {
|
||||
#[error("failed to spawn git: {0}")]
|
||||
Spawn(#[from] std::io::Error),
|
||||
#[error("git exited with code {code}: {stderr}")]
|
||||
NonZero { code: i32, stderr: String },
|
||||
}
|
||||
|
||||
/// What a kill refusal would report about the worktree at `path`, right now.
|
||||
///
|
||||
/// Ignored files never count (`--ignored=no`) — every Rust worktree has a
|
||||
/// `target/`, and counting those would put every tree permanently in the way.
|
||||
/// A directory that no longer exists reports nothing in the way: there is
|
||||
/// nothing left to lose, and the store's own pruning drops the entry.
|
||||
pub fn in_the_way(path: &Path) -> Result<InTheWay, ProbeError> {
|
||||
let mut breakdown = InTheWay::default();
|
||||
// `symlink_metadata` rather than `exists`: a broken symlink is gone for
|
||||
// our purposes, and following it would probe the wrong tree.
|
||||
if std::fs::symlink_metadata(path).is_err() {
|
||||
return Ok(breakdown);
|
||||
}
|
||||
let status = run_git(
|
||||
path,
|
||||
[
|
||||
"status",
|
||||
"--porcelain",
|
||||
"--ignored=no",
|
||||
"--untracked-files=normal",
|
||||
],
|
||||
)?;
|
||||
for line in String::from_utf8_lossy(&status.stdout).lines() {
|
||||
if line.starts_with("??") {
|
||||
breakdown.untracked_files += 1;
|
||||
} else if !line.is_empty() {
|
||||
breakdown.uncommitted_files += 1;
|
||||
}
|
||||
}
|
||||
// Resolved inside the worktree so a detached HEAD still has a starting
|
||||
// point; asking the base repo for `refs/heads/<branch>` fails there.
|
||||
let head = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"])?;
|
||||
let branch = String::from_utf8_lossy(&head.stdout).trim().to_string();
|
||||
let tip: &str = if branch == "HEAD" { "HEAD" } else { &branch };
|
||||
// Commits reachable from the tip but from no remote-tracking ref. A branch
|
||||
// pushed anywhere counts as pushed; there is no "which remote" question to
|
||||
// get wrong.
|
||||
let unpushed = run_git(path, ["rev-list", "--count", tip, "--not", "--remotes"])?;
|
||||
breakdown.unpushed_commits = String::from_utf8_lossy(&unpushed.stdout)
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
Ok(breakdown)
|
||||
}
|
||||
|
||||
/// Remove a retained worktree's directory without consulting any guard, per
|
||||
/// the reclaim contract: the operator chose this while looking at what was in
|
||||
/// the way. `git worktree remove` keeps `refs/heads/<branch>`, so commits on
|
||||
/// the branch survive even when they were never pushed.
|
||||
///
|
||||
/// A directory that is already gone only leaves a stale registration behind;
|
||||
/// `worktree prune` clears it so the branch can be spawned again.
|
||||
pub fn reclaim(base_repo: &Path, path: &Path) -> Result<(), ProbeError> {
|
||||
if !path.exists() {
|
||||
return run_git(base_repo, ["worktree", "prune"]).map(|_| ());
|
||||
}
|
||||
run_git(
|
||||
base_repo,
|
||||
[
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
path.to_str().expect("worktree path is valid utf-8"),
|
||||
],
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Branch checked out at `path`. Sessions adopted from a sidecar carry no
|
||||
/// branch on record, so a forced kill (#81) asks the tree itself when it
|
||||
/// needs one for the retained store. `None` when git cannot answer.
|
||||
pub fn current_branch(path: &Path) -> Option<String> {
|
||||
let out = run_git(path, ["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn run_git<I, S>(dir: &Path, args: I) -> Result<std::process::Output, ProbeError>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<std::ffi::OsStr>,
|
||||
{
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg("-C").arg(dir).args(args);
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() {
|
||||
return Err(ProbeError::NonZero {
|
||||
code: out.status.code().unwrap_or(-1),
|
||||
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
struct Repo {
|
||||
root: tempfile::TempDir,
|
||||
repo: PathBuf,
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
/// A repo with a remote, one commit on `main`, and a linked worktree for
|
||||
/// branch `feature` at `<repo>/worktrees/feature`.
|
||||
fn repo_with_worktree() -> Repo {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
let repo = root.path().join("repo");
|
||||
let remote = root.path().join("remote.git");
|
||||
std::fs::create_dir_all(&repo).unwrap();
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
git(&repo, &["init", "-q", "-b", "main"]);
|
||||
git(&repo, &["config", "user.email", "t@t"]);
|
||||
git(&repo, &["config", "user.name", "T"]);
|
||||
std::fs::write(repo.join("README"), b"hello\n").unwrap();
|
||||
git(&repo, &["add", "."]);
|
||||
git(&repo, &["commit", "-q", "-m", "init"]);
|
||||
git(&remote, &["init", "-q", "--bare"]);
|
||||
git(&repo, &["remote", "add", "origin", &path_str(&remote)]);
|
||||
git(&repo, &["push", "-q", "-u", "origin", "main"]);
|
||||
git(
|
||||
&repo,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feature",
|
||||
"worktrees/feature",
|
||||
],
|
||||
);
|
||||
Repo { root, repo }
|
||||
}
|
||||
|
||||
fn path_str(path: &Path) -> String {
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
fn wt(&self) -> PathBuf {
|
||||
self.repo.join("worktrees").join("feature")
|
||||
}
|
||||
|
||||
fn commit_in_wt(&self, msg: &str) {
|
||||
std::fs::write(self.wt().join("README"), msg.as_bytes()).unwrap();
|
||||
git(&self.wt(), &["add", "-A"]);
|
||||
git(&self.wt(), &["commit", "-qm", msg]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_pushed_worktree_has_nothing_in_the_way() {
|
||||
let r = repo_with_worktree();
|
||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown, InTheWay::default());
|
||||
assert!(breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uncommitted_untracked_and_unpushed_are_counted_separately() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||
std::fs::write(r.wt().join("scratch.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(dirty.uncommitted_files, 1);
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
assert_eq!(dirty.unpushed_commits, 0);
|
||||
assert!(!dirty.is_clear());
|
||||
|
||||
// Committing clears exactly one dimension and creates another.
|
||||
git(&r.wt(), &["add", "README"]);
|
||||
git(&r.wt(), &["commit", "-qm", "wip"]);
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert_eq!(breakdown.unpushed_commits, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_edits_count_as_uncommitted() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"edited\n").unwrap();
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pushing_clears_unpushed_but_keeps_other_entries() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("new.txt"), b"x\n").unwrap();
|
||||
r.commit_in_wt("wip");
|
||||
|
||||
let before = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(before.unpushed_commits, 1);
|
||||
|
||||
git(&r.wt(), &["push", "-q", "-u", "origin", "feature"]);
|
||||
// Same stored entry, different tree: the breakdown must move with it.
|
||||
let after = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(after.unpushed_commits, 0);
|
||||
assert_eq!(after.untracked_files, 0);
|
||||
|
||||
std::fs::write(r.wt().join("loose.txt"), b"x\n").unwrap();
|
||||
let dirty = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(dirty.untracked_files, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignored_files_never_count() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join(".gitignore"), b"target/\n").unwrap();
|
||||
std::fs::create_dir_all(r.wt().join("target")).unwrap();
|
||||
std::fs::write(r.wt().join("target/artifact"), b"x").unwrap();
|
||||
|
||||
let breakdown = in_the_way(&r.wt()).expect("probe");
|
||||
assert_eq!(
|
||||
breakdown,
|
||||
InTheWay {
|
||||
untracked_files: 1,
|
||||
..InTheWay::default()
|
||||
},
|
||||
"the .gitignore itself is untracked; target/ is not"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_directory_reports_nothing_in_the_way() {
|
||||
let r = repo_with_worktree();
|
||||
let gone = r.root.path().join("nowhere");
|
||||
let breakdown = in_the_way(&gone).expect("probe");
|
||||
assert!(breakdown.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_removes_the_directory_and_prunes_a_gone_one() {
|
||||
let r = repo_with_worktree();
|
||||
r.commit_in_wt("precious");
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||
assert!(!r.wt().exists());
|
||||
|
||||
// A second reclaim over the now-missing path just prunes.
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_keeps_the_branch_and_its_commits() {
|
||||
let r = repo_with_worktree();
|
||||
r.commit_in_wt("precious");
|
||||
let sha = {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(r.wt())
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
};
|
||||
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim");
|
||||
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&r.repo)
|
||||
.args(["rev-parse", "--verify", "refs/heads/feature"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"branch must survive reclaim: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), sha);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_is_guard_free_on_a_dirty_tree() {
|
||||
let r = repo_with_worktree();
|
||||
std::fs::write(r.wt().join("README"), b"unsaved\n").unwrap();
|
||||
std::fs::write(r.wt().join("notes.txt"), b"unsaved\n").unwrap();
|
||||
reclaim(&r.repo, &r.wt()).expect("reclaim ignores the guard");
|
||||
assert!(!r.wt().exists());
|
||||
}
|
||||
}
|
||||
@@ -189,8 +189,23 @@ fn a_foreground_pipeline_whose_leader_exited_is_still_awaiting_input() {
|
||||
"never observed a foreground group whose leader had exited"
|
||||
);
|
||||
|
||||
assert!(
|
||||
awaiting_input(pane.pane_pid, &pane.tty, &ProcGroups::snapshot()),
|
||||
"a surviving pipeline member still holds the tty"
|
||||
);
|
||||
// The leader can be gone while the surviving stage is still setting up:
|
||||
// `true` exits as soon as both stages are forked, before the reader has
|
||||
// opened the tty and parked. Poll for the answer, but only accept it
|
||||
// while the state this test exists for still holds — otherwise a shell
|
||||
// that regained the terminal would satisfy it vacuously.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
let mut holds = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
let groups = ProcGroups::snapshot();
|
||||
let still_leaderless = pgrp_and_tpgid(pane.pane_pid).is_some_and(|(pgrp, tpgid)| {
|
||||
tpgid > 0 && tpgid != pgrp && pgrp_and_tpgid(tpgid).is_none()
|
||||
});
|
||||
if still_leaderless && awaiting_input(pane.pane_pid, &pane.tty, &groups) {
|
||||
holds = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert!(holds, "a surviving pipeline member still holds the tty");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Agent-reported activity (#105): `POST /api/sessions/:id/activity`
|
||||
//! semantics at the SessionManager level — precedence over the daemon's own
|
||||
//! inference, detail handling, and lapse back to inference at the TTL.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::TmuxFixture;
|
||||
use maestro_daemon::sidecar::SidecarStore;
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::SessionManager;
|
||||
use maestro_protocol::{ActivityState, CreateSessionRequest};
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::sleep;
|
||||
use uuid::Uuid;
|
||||
|
||||
struct Fixture {
|
||||
manager: SessionManager,
|
||||
_tmux: TmuxFixture,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
|
||||
async fn fixture(ttl: Duration) -> Fixture {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let manager = SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
SidecarStore::new(state_dir.path().join("sessions")),
|
||||
WorktreeManager::new(None),
|
||||
state_dir.path().join("fifos"),
|
||||
)
|
||||
.with_activity_report_ttl(ttl);
|
||||
Fixture {
|
||||
manager,
|
||||
_tmux: tmux,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
|
||||
manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
async fn observed(manager: &SessionManager, id: Uuid) -> maestro_protocol::Session {
|
||||
manager
|
||||
.snapshot()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.id == id)
|
||||
.expect("session still tracked")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_on_unknown_session_is_not_found() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let err = fx
|
||||
.manager
|
||||
.report_activity(Uuid::new_v4(), ActivityState::Idle, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, maestro_daemon::SessionError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_sets_error_state_and_detail() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(
|
||||
id,
|
||||
ActivityState::Error,
|
||||
Some("cargo build failed: link error".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.activity, ActivityState::Error);
|
||||
assert_eq!(
|
||||
s.activity_detail.as_deref(),
|
||||
Some("cargo build failed: link error")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_a_report_replaces_both_state_and_detail() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some("first failure".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
fx.manager
|
||||
.report_activity(
|
||||
id,
|
||||
ActivityState::AwaitingInput,
|
||||
Some("needs approval".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.activity, ActivityState::AwaitingInput);
|
||||
assert_eq!(s.activity_detail.as_deref(), Some("needs approval"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detail_is_capped_server_side() {
|
||||
let fx = fixture(Duration::from_secs(300)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
|
||||
let long = "x".repeat(10_000);
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some(long))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let s = observed(&fx.manager, id).await;
|
||||
let detail = s.activity_detail.expect("detail present");
|
||||
assert_eq!(
|
||||
detail.chars().count(),
|
||||
maestro_daemon::MAX_REPORT_DETAIL_CHARS
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reported_state_survives_watcher_ticks_until_the_ttl_then_inference_resumes() {
|
||||
let fx = fixture(Duration::from_millis(150)).await;
|
||||
let id = spawn_sleeping(&fx.manager).await;
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
|
||||
fx.manager
|
||||
.report_activity(id, ActivityState::Error, Some("agent died mid-run".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Several watcher ticks inside the TTL: nothing infers `Error`, so the
|
||||
// report must hold above inference.
|
||||
sleep(Duration::from_millis(80)).await;
|
||||
let s = observed(&fx.manager, id).await;
|
||||
assert_eq!(s.activity, ActivityState::Error);
|
||||
assert_eq!(s.activity_detail.as_deref(), Some("agent died mid-run"));
|
||||
|
||||
// After the TTL lapses, inference resumes and the detail goes with it.
|
||||
common::wait_for_async(Duration::from_secs(5), || async {
|
||||
let s = observed(&fx.manager, id).await;
|
||||
s.activity != ActivityState::Error && s.activity_detail.is_none()
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ async fn spawn_daemon_ws() -> Harness {
|
||||
"/home/test",
|
||||
Vec::new(),
|
||||
std::sync::Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -121,7 +122,7 @@ async fn pty_round_trip_through_real_tmux() {
|
||||
"marker not seen in {} bytes",
|
||||
got.len()
|
||||
);
|
||||
let _ = h.manager.kill(id).await;
|
||||
let _ = h.manager.kill(id, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -169,7 +170,7 @@ async fn multi_attach_broadcasts_to_every_client() {
|
||||
"client B missed marker"
|
||||
);
|
||||
|
||||
let _ = h.manager.kill(id).await;
|
||||
let _ = h.manager.kill(id, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -218,7 +219,7 @@ async fn sends_close_frame_when_session_is_killed() {
|
||||
// give the broadcast subscriber a moment to register before killing.
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
h.manager.kill(id).await.unwrap();
|
||||
h.manager.kill(id, false).await.unwrap();
|
||||
|
||||
let mut saw_close = false;
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||||
@@ -266,7 +267,7 @@ async fn ping_text_frame_gets_pong() {
|
||||
}
|
||||
}
|
||||
assert!(saw_pong, "no Pong frame received");
|
||||
let _ = h.manager.kill(id).await;
|
||||
let _ = h.manager.kill(id, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! The performance contract of #83: the blocked-work pass must cost NO git
|
||||
//! probe at all for a session without a worktree, while a session with one is
|
||||
//! still probed.
|
||||
//!
|
||||
//! `git` itself is shimmed through PATH with a wrapper that logs every
|
||||
//! invocation before exec'ing the real binary, so the assertion counts actual
|
||||
//! probes rather than inferring them from state. This lives in its own test
|
||||
//! binary because it mutates the process environment.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_session_without_a_worktree_costs_no_probe() {
|
||||
let fx = DaemonFixture::new();
|
||||
let log = TempDir::new().unwrap();
|
||||
let log_path = log.path().join("git-calls.log");
|
||||
|
||||
// Session A owns a real (pristine, pushed) worktree; session B has none.
|
||||
let repo_a = TempDir::new().unwrap();
|
||||
init_repo(repo_a.path());
|
||||
init_bare_repo(&repo_a.path().join("origin.git"));
|
||||
push_all_to_origin(repo_a.path(), &repo_a.path().join("origin.git"));
|
||||
fx.manager
|
||||
.create(request(repo_a.path(), repo_a.path(), "feat/probed"))
|
||||
.await
|
||||
.expect("create A");
|
||||
|
||||
let cwd_b = TempDir::new().unwrap();
|
||||
let mut req_b = request(cwd_b.path(), cwd_b.path(), "feat/unused");
|
||||
req_b.worktree = None;
|
||||
fx.manager.create(req_b).await.expect("create B");
|
||||
|
||||
// Shim `git` AFTER setup so only watcher-driven probes get logged.
|
||||
let real_git = String::from_utf8(
|
||||
Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg("command -v git")
|
||||
.output()
|
||||
.expect("locate git")
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
let shim_dir = TempDir::new().unwrap();
|
||||
let script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$*\" >> {}\nexec {} \"$@\"\n",
|
||||
log_path.display(),
|
||||
real_git,
|
||||
);
|
||||
std::fs::write(shim_dir.path().join("git"), script).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(
|
||||
shim_dir.path().join("git"),
|
||||
std::fs::Permissions::from_mode(0o755),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let old_path = std::env::var("PATH").unwrap_or_default();
|
||||
std::env::set_var(
|
||||
"PATH",
|
||||
format!("{}:{}", shim_dir.path().display(), old_path),
|
||||
);
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
// The pass demonstrably runs and probed A's tree...
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager
|
||||
.snapshot()
|
||||
.await
|
||||
.iter()
|
||||
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
|
||||
})
|
||||
.await;
|
||||
|
||||
std::env::set_var("PATH", &old_path);
|
||||
|
||||
let calls = std::fs::read_to_string(&log_path).unwrap_or_default();
|
||||
assert!(
|
||||
!calls.is_empty(),
|
||||
"the pass never probed anything — the shim saw no git call at all"
|
||||
);
|
||||
// ...while B — no worktree, so nothing to probe — was never touched.
|
||||
assert!(
|
||||
!calls.contains(cwd_b.path().to_str().unwrap()),
|
||||
"a worktree-less session was probed:\n{calls}"
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ async fn spawn() -> Harness {
|
||||
"/home/test",
|
||||
presets,
|
||||
Arc::new(maestro_daemon::projects::ProjectScanner::disabled()),
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
|
||||
@@ -93,6 +93,38 @@ fn run(cmd: &mut Command) {
|
||||
);
|
||||
}
|
||||
|
||||
/// An empty bare repository at `dir`, usable as a push target (#79).
|
||||
pub fn init_bare_repo(dir: &Path) {
|
||||
run(Command::new("git")
|
||||
.arg("init")
|
||||
.arg("-q")
|
||||
.arg("--bare")
|
||||
.arg(dir.to_str().expect("non-utf8 bare repo path")));
|
||||
}
|
||||
|
||||
/// Push every branch of `repo` to `origin`, creating remote-tracking refs.
|
||||
///
|
||||
/// The unsaved-work probe (#79) counts commits reachable from no
|
||||
/// remote-tracking ref as unpushed, so tests that want a killable worktree
|
||||
/// must give its branch one. Safe to call again: re-adding an existing
|
||||
/// remote is ignored.
|
||||
pub fn push_all_to_origin(repo: &Path, origin: &Path) {
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args([
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
origin.to_str().expect("non-utf8 origin path"),
|
||||
])
|
||||
.status();
|
||||
run(Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["push", "-q", "--all", "origin"]));
|
||||
}
|
||||
|
||||
/// Async polling sibling of [`wait_for`] for tokio tests.
|
||||
pub async fn wait_for_async<F, Fut>(timeout: Duration, mut cond: F)
|
||||
where
|
||||
@@ -144,6 +176,11 @@ impl DaemonFixture {
|
||||
manager =
|
||||
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
||||
}
|
||||
// Same wiring as production: the manager records retained worktrees
|
||||
// into the same dir the API reads back from (#78/#81).
|
||||
let retained =
|
||||
maestro_daemon::retained_store::RetainedStore::new(Self::retained_path(&state_dir));
|
||||
manager = manager.with_retained_store(retained.clone());
|
||||
Self {
|
||||
tmux,
|
||||
manager,
|
||||
@@ -155,6 +192,10 @@ impl DaemonFixture {
|
||||
state_dir.path().join("spawn-history.jsonl")
|
||||
}
|
||||
|
||||
fn retained_path(state_dir: &TempDir) -> PathBuf {
|
||||
state_dir.path().join("retained-worktrees")
|
||||
}
|
||||
|
||||
pub fn spawn_history_path(&self) -> PathBuf {
|
||||
Self::history_path(&self.state_dir)
|
||||
}
|
||||
@@ -163,6 +204,15 @@ impl DaemonFixture {
|
||||
self.state_dir.path().join("sessions")
|
||||
}
|
||||
|
||||
/// Retained-worktree store rooted at the fixture's own state dir (#78).
|
||||
pub fn retained_store(&self) -> maestro_daemon::retained_store::RetainedStore {
|
||||
maestro_daemon::retained_store::RetainedStore::new(self.retained_dir())
|
||||
}
|
||||
|
||||
pub fn retained_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("retained-worktrees")
|
||||
}
|
||||
|
||||
pub fn fifo_dir(&self) -> PathBuf {
|
||||
self.state_dir.path().join("fifos")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::time::Duration;
|
||||
use common::DaemonFixture;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_daemon::local_api;
|
||||
use maestro_protocol::{CreateSessionRequest, Session};
|
||||
use maestro_protocol::{ApiError, CreateSessionRequest, Session, WorktreeSpec};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
@@ -25,6 +25,13 @@ struct Harness {
|
||||
_fx: DaemonFixture,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// Store over the same dir the manager records into.
|
||||
fn fx_retained(&self) -> maestro_daemon::retained_store::RetainedStore {
|
||||
self._fx.retained_store()
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn(hostname: &str) -> Harness {
|
||||
spawn_with(
|
||||
hostname,
|
||||
@@ -48,6 +55,7 @@ async fn spawn_with(
|
||||
"/home/test",
|
||||
presets,
|
||||
projects,
|
||||
std::sync::Arc::new(fx.retained_store()),
|
||||
));
|
||||
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
|
||||
.await
|
||||
@@ -338,6 +346,210 @@ async fn delete_owned_session_kills_it() {
|
||||
assert!(!h.manager.has(session.id).await);
|
||||
}
|
||||
|
||||
/// Spawn a session through the manager and return its id.
|
||||
async fn spawn_via_manager(h: &Harness) -> Uuid {
|
||||
h.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: "/tmp".into(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
/// The agent activity endpoint on the wire (#105): a report lands in the
|
||||
/// session snapshot with its detail.
|
||||
#[tokio::test]
|
||||
async fn post_activity_reports_error_with_detail() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let id = spawn_via_manager(&h).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||
.json(&serde_json::json!({
|
||||
"state": "Error",
|
||||
"detail": "cargo build failed"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||
assert_eq!(s.activity, maestro_protocol::ActivityState::Error);
|
||||
assert_eq!(s.activity_detail.as_deref(), Some("cargo build failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_activity_to_unknown_session_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url(
|
||||
h.addr,
|
||||
&format!("/api/sessions/{}/activity", Uuid::new_v4()),
|
||||
))
|
||||
.json(&serde_json::json!({ "state": "Idle" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
/// Unknown state values are rejected rather than coerced (#105).
|
||||
#[tokio::test]
|
||||
async fn post_activity_rejects_an_unknown_state_value() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let id = spawn_via_manager(&h).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||
.json(&serde_json::json!({ "state": "Teleported" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(resp.status().is_client_error(), "got {}", resp.status());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_activity_caps_an_overlong_detail_server_side() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let id = spawn_via_manager(&h).await;
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.post(url(h.addr, &format!("/api/sessions/{id}/activity")))
|
||||
.json(&serde_json::json!({
|
||||
"state": "Error",
|
||||
"detail": "x".repeat(10_000)
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
let sessions: Vec<Session> = reqwest::get(url(h.addr, "/api/sessions"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let s = sessions.iter().find(|s| s.id == id).unwrap();
|
||||
assert_eq!(
|
||||
s.activity_detail.as_ref().map(|d| d.chars().count()),
|
||||
Some(maestro_daemon::MAX_REPORT_DETAIL_CHARS)
|
||||
);
|
||||
}
|
||||
|
||||
/// The kill guard on the wire (#79): `DELETE /api/sessions/:id` answers 409
|
||||
/// with the structured breakdown, and the session keeps running.
|
||||
#[tokio::test]
|
||||
async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the_breakdown() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let repo = tempfile::TempDir::new().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
repo.path()
|
||||
.join("worktrees")
|
||||
.join("feat")
|
||||
.join("guarded")
|
||||
.join("README"),
|
||||
b"unsaved edits\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::CONFLICT);
|
||||
let body: ApiError = resp.json().await.unwrap();
|
||||
assert_eq!(body.error.code, "kill_guarded");
|
||||
let breakdown = body.error.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
|
||||
assert!(h.manager.has(session.id).await, "the session keeps running");
|
||||
}
|
||||
|
||||
/// `?force=true` on the wire (#81): the refusal is overridden, the session
|
||||
/// ends, and the answer is 200 with the retained worktree named — path,
|
||||
/// branch, reason — while the directory survives on disk.
|
||||
#[tokio::test]
|
||||
async fn delete_with_force_ends_the_session_and_answers_what_was_retained() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let repo = tempfile::TempDir::new().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
let wt_path = repo
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feat")
|
||||
.join("guarded")
|
||||
.to_path_buf();
|
||||
let session = h
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 60".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/sessions/{}", session.id)))
|
||||
.query(&[("force", "true")])
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::OK);
|
||||
let body: maestro_protocol::KillOutcome = resp.json().await.unwrap();
|
||||
let retained = body.retained.expect("force names what was kept");
|
||||
assert_eq!(retained.path, wt_path);
|
||||
assert_eq!(retained.branch, "feat/guarded");
|
||||
assert_eq!(
|
||||
retained.reason,
|
||||
maestro_protocol::RetainedReason::ForceKilled
|
||||
);
|
||||
|
||||
// The tree would have blocked; force must not have removed it.
|
||||
assert!(wt_path.exists(), "a forced kill never removes the worktree");
|
||||
// And the store entry exists, so reclaim can find it (#80).
|
||||
let all = h.fx_retained().load_all().unwrap();
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].id, retained.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_ws_streams_pty_bytes() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
@@ -428,7 +640,7 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
|
||||
other => panic!("expected SessionAdded, got {other:?}"),
|
||||
}
|
||||
|
||||
h.manager.kill(created.id).await.expect("kill");
|
||||
h.manager.kill(created.id, false).await.expect("kill");
|
||||
|
||||
// Activity ticks may interleave; the removal is what we are waiting for.
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
@@ -492,3 +704,234 @@ async fn next_event(ws: &mut WsClient) -> maestro_protocol::CatalogEvent {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/worktrees + DELETE /api/worktrees/:id (#80)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `git -C dir args`, asserting success — the worktree endpoints are tested
|
||||
/// against real git, like everything else in this crate.
|
||||
fn git(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
struct RetainedFixture {
|
||||
repo: tempfile::TempDir,
|
||||
wt: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// A real repo with a linked worktree for branch `feat/login` whose tree holds
|
||||
/// an uncommitted tracked edit and an unpushed commit.
|
||||
fn retained_fixture() -> RetainedFixture {
|
||||
let repo = tempfile::tempdir().unwrap();
|
||||
common::init_repo(repo.path());
|
||||
// A pushed main, so "unpushed" means commits made on the feature branch,
|
||||
// not the shared history.
|
||||
let remote = repo.path().join("remote.git");
|
||||
std::fs::create_dir_all(&remote).unwrap();
|
||||
let remote_s = remote.to_string_lossy().into_owned();
|
||||
git(&remote, &["init", "-q", "--bare"]);
|
||||
git(repo.path(), &["remote", "add", "origin", &remote_s]);
|
||||
git(repo.path(), &["push", "-q", "-u", "origin", "main"]);
|
||||
git(
|
||||
repo.path(),
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"feat/login",
|
||||
"worktrees/feat/login",
|
||||
],
|
||||
);
|
||||
let wt = repo.path().join("worktrees").join("feat").join("login");
|
||||
RetainedFixture { repo, wt }
|
||||
}
|
||||
|
||||
impl RetainedFixture {
|
||||
fn commit_in_wt(&self, msg: &str) {
|
||||
std::fs::write(self.wt.join("README"), msg.as_bytes()).unwrap();
|
||||
git(&self.wt, &["add", "-A"]);
|
||||
git(&self.wt, &["commit", "-qm", msg]);
|
||||
}
|
||||
|
||||
fn branch_sha(&self, branch: &str) -> String {
|
||||
let out = std::process::Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(self.repo.path())
|
||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "branch {branch} must exist");
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_lists_retained_entries_with_recomputed_breakdown() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
// Uncommitted tracked edit + an untracked file; the branch has no commits
|
||||
// beyond main yet.
|
||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap();
|
||||
std::fs::write(fx.wt.join("scratch.txt"), b"loose\n").unwrap();
|
||||
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1);
|
||||
let w = &listed[0];
|
||||
assert_eq!(w.id, entry.id);
|
||||
assert_eq!(w.path, fx.wt);
|
||||
assert_eq!(w.base_repo, fx.repo.path());
|
||||
assert_eq!(w.branch, "feat/login");
|
||||
assert_eq!(w.reason, maestro_protocol::RetainedReason::ForceKilled);
|
||||
assert_eq!(w.in_the_way.uncommitted_files, 1);
|
||||
assert_eq!(w.in_the_way.untracked_files, 1);
|
||||
assert_eq!(w.in_the_way.unpushed_commits, 0);
|
||||
|
||||
// The operator saves the work themselves. The stored entry is untouched —
|
||||
// the next read must report the tree as it is *now*, not as it was when
|
||||
// it was retained.
|
||||
fx.commit_in_wt("saved");
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(listed.len(), 1, "same entry, still retained");
|
||||
assert_eq!(listed[0].id, entry.id);
|
||||
assert_eq!(listed[0].in_the_way.uncommitted_files, 0);
|
||||
assert_eq!(listed[0].in_the_way.unpushed_commits, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_removes_directory_and_entry_but_never_the_branch() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
fx.commit_in_wt("precious");
|
||||
let sha = fx.branch_sha("feat/login");
|
||||
std::fs::write(fx.wt.join("README"), b"unsaved\n").unwrap(); // guard would refuse
|
||||
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
|
||||
assert!(!fx.wt.exists(), "reclaim removes the directory");
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.is_empty(), "reclaim drops the store entry");
|
||||
assert_eq!(
|
||||
fx.branch_sha("feat/login"),
|
||||
sha,
|
||||
"the branch and its commits survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_of_an_already_gone_directory_still_drops_the_entry() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
let store = h._fx.retained_store();
|
||||
let entry = store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&fx.wt).unwrap(); // operator rm -rf
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", entry.id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reclaim_unknown_id_returns_404() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let resp = reqwest::Client::new()
|
||||
.delete(url(h.addr, &format!("/api/worktrees/{}", Uuid::new_v4())))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), reqwest::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worktrees_prunes_entries_whose_directory_vanished_by_hand() {
|
||||
let h = spawn("naps-laptop").await;
|
||||
let fx = retained_fixture();
|
||||
let store = h._fx.retained_store();
|
||||
store
|
||||
.retain(
|
||||
&fx.wt,
|
||||
fx.repo.path(),
|
||||
"feat/login",
|
||||
maestro_daemon::retained_store::RetainedReason::ForceKilled,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::remove_dir_all(&fx.wt).unwrap();
|
||||
|
||||
let listed: Vec<maestro_protocol::RetainedWorktree> =
|
||||
reqwest::get(url(h.addr, "/api/worktrees"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(listed.is_empty(), "a hand-removed directory is not listed");
|
||||
assert!(
|
||||
store.load_all().unwrap().is_empty(),
|
||||
"and its entry is gone"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,6 +30,24 @@ impl Fx {
|
||||
Self::with_sidecar_dir(|tmp| tmp.join("sidecars"))
|
||||
}
|
||||
|
||||
/// Variant whose manager knows the daemon's own base URL (#103), the way
|
||||
/// the production runner wires it from `[daemon].bind_addr`.
|
||||
fn with_daemon_url(url: &str) -> Self {
|
||||
let fx = Self::new();
|
||||
fx.manager.set_daemon_url(url.to_string());
|
||||
fx
|
||||
}
|
||||
|
||||
/// The env map of the (single, expected) spawn call.
|
||||
fn spawned_env(&self) -> BTreeMap<String, String> {
|
||||
let calls = self.fake.calls();
|
||||
let Some(Call::NewSessionPiped { env, .. }) = calls.first() else {
|
||||
panic!("expected a spawn call, got {calls:?}");
|
||||
};
|
||||
assert_eq!(calls.len(), 1, "expected exactly one spawn: {calls:?}");
|
||||
env.clone()
|
||||
}
|
||||
|
||||
fn with_sidecar_dir(pick: impl Fn(&std::path::Path) -> PathBuf) -> Self {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let fake = Arc::new(FakePanes::new());
|
||||
@@ -211,7 +229,7 @@ async fn killing_a_dead_session_dismisses_it_without_touching_tmux() {
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||
wait_for_status(&fx.manager, s.id, SessionStatus::Dead).await;
|
||||
|
||||
fx.manager.kill(s.id).await.expect("dismiss");
|
||||
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
assert!(
|
||||
@@ -261,16 +279,23 @@ async fn requested_env_reaches_tmux() {
|
||||
|
||||
fx.manager.create(req).await.expect("create");
|
||||
|
||||
let spawned = match fx.fake.calls().first() {
|
||||
Some(Call::NewSessionPiped { env, .. }) => env.clone(),
|
||||
other => panic!("expected a spawn call, got {other:?}"),
|
||||
};
|
||||
let id = spawned
|
||||
.get("MAESTRO_SESSION_ID")
|
||||
.expect("spawn must export MAESTRO_SESSION_ID (#103)")
|
||||
.clone();
|
||||
// The daemon-owned variable rides alongside whatever was requested.
|
||||
let want: BTreeMap<String, String> = [
|
||||
("EMPTY".to_string(), String::new()),
|
||||
("FOO".to_string(), "bar".to_string()),
|
||||
("MAESTRO_SESSION_ID".to_string(), id),
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
match fx.fake.calls().first() {
|
||||
Some(Call::NewSessionPiped { env, .. }) => assert_eq!(env, &want),
|
||||
other => panic!("expected a spawn call, got {other:?}"),
|
||||
}
|
||||
assert_eq!(&spawned, &want);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -338,6 +363,9 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
|
||||
// Still inside `activity::ACTIVE_WINDOW` (3s) since the spawn, so the
|
||||
// change here is `foreground`, not the activity state.
|
||||
assert_eq!(updated.activity, ActivityState::Active);
|
||||
// A foreground/cwd-only change is not a state transition (#104): the
|
||||
// sidebar's duration must keep counting from the original stamp.
|
||||
assert_eq!(updated.activity_since, session.activity_since);
|
||||
|
||||
// Drain anything already queued, then confirm a steady pane stays quiet.
|
||||
// Stays well inside the 3s window, so no Active -> Idle transition either.
|
||||
@@ -349,6 +377,70 @@ async fn activity_pass_emits_updated_only_when_something_changed() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The sidebar's duration (#104) is time in the *current* state, so
|
||||
/// `activity_since` is stamped exactly when the decided state differs from
|
||||
/// the stored one — once per transition. Stamping it every tick would make
|
||||
/// the duration permanently read zero.
|
||||
#[tokio::test]
|
||||
async fn activity_transition_stamps_activity_since_once_per_transition() {
|
||||
let fx = Fx::new();
|
||||
let session = fx
|
||||
.manager
|
||||
.create(fx.request("sleep 300"))
|
||||
.await
|
||||
.expect("create");
|
||||
fx.fake.set_pane_info(
|
||||
session.id,
|
||||
PaneInfo {
|
||||
// Nothing reads from this pid: `awaiting_input` is deterministically
|
||||
// false, so past `ACTIVE_WINDOW` the session decides as Idle.
|
||||
pane_pid: i32::MAX,
|
||||
command: "sleep".into(),
|
||||
tty: "/dev/null".into(),
|
||||
current_path: String::new(),
|
||||
},
|
||||
);
|
||||
let mut events = fx.manager.events();
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(25));
|
||||
|
||||
let idle = wait_for_activity(&fx.manager, session.id, ActivityState::Idle).await;
|
||||
assert!(
|
||||
idle.activity_since >= session.activity_since + chrono::Duration::seconds(2),
|
||||
"the Active -> Idle transition must stamp a fresh activity_since \
|
||||
(spawn stamp {:?}, transition stamp {:?})",
|
||||
session.activity_since,
|
||||
idle.activity_since
|
||||
);
|
||||
|
||||
// Steady Idle across many more ticks must not re-stamp — no further
|
||||
// SessionUpdated at all, or the sidebar clock would keep resetting.
|
||||
while events.try_recv().is_ok() {}
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(
|
||||
matches!(events.try_recv(), Err(TryRecvError::Empty)),
|
||||
"a steady state must not re-emit (and re-stamp) on every tick"
|
||||
);
|
||||
}
|
||||
|
||||
async fn wait_for_activity(
|
||||
manager: &SessionManager,
|
||||
id: uuid::Uuid,
|
||||
want: ActivityState,
|
||||
) -> maestro_protocol::Session {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(6);
|
||||
loop {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
panic!("timed out waiting for {id} to become {want:?}");
|
||||
}
|
||||
if let Some(s) = manager.snapshot().await.into_iter().find(|s| s.id == id) {
|
||||
if s.activity == want {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_updated(
|
||||
events: &mut tokio::sync::broadcast::Receiver<CatalogEvent>,
|
||||
) -> maestro_protocol::Session {
|
||||
@@ -371,7 +463,7 @@ async fn killing_an_unknown_session_is_reported_as_not_found() {
|
||||
let fx = Fx::new();
|
||||
let err = fx
|
||||
.manager
|
||||
.kill(uuid::Uuid::new_v4())
|
||||
.kill(uuid::Uuid::new_v4(), false)
|
||||
.await
|
||||
.expect_err("should fail");
|
||||
assert_eq!(err.to_error_info().code, "not_found");
|
||||
@@ -396,3 +488,70 @@ fn attaching_spawn_history_after_cloning_panics() {
|
||||
7,
|
||||
));
|
||||
}
|
||||
|
||||
/// Every tmux-spawned session learns who it is (#103): its own UUID under
|
||||
/// `MAESTRO_SESSION_ID`, so the reporting hook in #106 can name the session
|
||||
/// it is running inside.
|
||||
#[tokio::test]
|
||||
async fn spawn_exports_maestro_session_id() {
|
||||
let fx = Fx::new();
|
||||
let s = fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_SESSION_ID").map(String::as_str),
|
||||
Some(&s.id.to_string()[..]),
|
||||
"MAESTRO_SESSION_ID must be the session's UUID, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hook also needs to know where to report to: `MAESTRO_URL` carries the
|
||||
/// daemon's base URL derived from `[daemon].bind_addr` (#103).
|
||||
#[tokio::test]
|
||||
async fn spawn_exports_maestro_url_when_the_daemon_is_wired() {
|
||||
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL").map(String::as_str),
|
||||
Some("http://127.0.0.1:7070"),
|
||||
"MAESTRO_URL must be the daemon's base URL, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a wired URL (tests, or a caller that never set one) only the
|
||||
/// session id is injected — an empty or bogus `MAESTRO_URL` would send the
|
||||
/// #106 hook's reports nowhere useful.
|
||||
#[tokio::test]
|
||||
async fn spawn_omits_maestro_url_when_not_wired() {
|
||||
let fx = Fx::new();
|
||||
fx.manager.create(fx.request("bash")).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert!(
|
||||
!env.contains_key("MAESTRO_URL"),
|
||||
"MAESTRO_URL must be absent when no daemon URL is known, got {env:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The daemon-owned variables win over anything the spawner sent: a client
|
||||
/// cannot spoof another session's identity.
|
||||
#[tokio::test]
|
||||
async fn spawn_overrides_client_supplied_maestro_variables() {
|
||||
let fx = Fx::with_daemon_url("http://127.0.0.1:7070");
|
||||
let mut req = fx.request("bash");
|
||||
req.env.insert(
|
||||
"MAESTRO_SESSION_ID".into(),
|
||||
"00000000-0000-0000-0000-000000000000".into(),
|
||||
);
|
||||
req.env.insert("MAESTRO_URL".into(), "http://evil:1".into());
|
||||
let s = fx.manager.create(req).await.expect("create");
|
||||
|
||||
let env = fx.spawned_env();
|
||||
assert_eq!(env.get("MAESTRO_SESSION_ID"), Some(&s.id.to_string()));
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL"),
|
||||
Some(&"http://127.0.0.1:7070".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ mod common;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{init_repo, wait_for_async, DaemonFixture};
|
||||
use common::{
|
||||
init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture, TmuxFixture,
|
||||
};
|
||||
use maestro_daemon::SessionError;
|
||||
use maestro_protocol::CreateSessionRequest;
|
||||
use maestro_protocol::{CatalogEvent, SessionStatus, WorktreeSpec};
|
||||
use tempfile::TempDir;
|
||||
@@ -42,7 +46,7 @@ async fn create_then_kill_round_trip() {
|
||||
// FIFO should also exist
|
||||
assert!(fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
|
||||
|
||||
fx.manager.kill(session.id).await.expect("kill");
|
||||
fx.manager.kill(session.id, false).await.expect("kill");
|
||||
assert!(!fx.manager.has(session.id).await);
|
||||
assert!(!fx
|
||||
.sidecar_dir()
|
||||
@@ -77,7 +81,7 @@ async fn create_appends_to_spawn_history() {
|
||||
assert_eq!(recents[0].cwd, cwd.path());
|
||||
assert_eq!(recents[0].last_command, "sleep 30");
|
||||
|
||||
fx.manager.kill(session.id).await.expect("kill");
|
||||
fx.manager.kill(session.id, false).await.expect("kill");
|
||||
// History is durable — it survives the kill that deletes the sidecar.
|
||||
assert_eq!(fx.manager.recent_dirs().len(), 1);
|
||||
}
|
||||
@@ -183,7 +187,7 @@ async fn create_captures_initial_output_in_replay_buffer() {
|
||||
"first-line marker never reached the replay buffer — pipe-pane raced past the child's initial output",
|
||||
);
|
||||
|
||||
fx.manager.kill(s.id).await.unwrap();
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// Regression for NAP-228: after `recover`, PTY output must reach new
|
||||
@@ -249,7 +253,7 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
|
||||
String::from_utf8_lossy(&buf),
|
||||
);
|
||||
|
||||
revived.kill(s.id).await.unwrap();
|
||||
revived.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -298,7 +302,7 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
|
||||
assert!(buf_a.windows(want.len()).any(|w| w == want), "A missed");
|
||||
assert!(buf_b.windows(want.len()).any(|w| w == want), "B missed");
|
||||
|
||||
fx.manager.kill(s.id).await.unwrap();
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
let _ = (cwd, PathBuf::from("/"));
|
||||
}
|
||||
|
||||
@@ -357,20 +361,112 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
|
||||
);
|
||||
|
||||
// Dismissing it is what finally releases the artifacts.
|
||||
fx.manager.kill(s.id).await.expect("dismiss");
|
||||
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
assert!(!fx.fifo_dir().join(format!("{}.fifo", s.id)).exists());
|
||||
}
|
||||
|
||||
/// Cleanup is unconditional (#56): even a spec that says "don't clean up"
|
||||
/// loses its worktree on kill — the guard, not the flag, protects work.
|
||||
/// #83: the watcher refreshes `Session.blocked_work` on its own cadence —
|
||||
/// first "nothing blocks" for a pristine tree, then the breakdown once the
|
||||
/// tree picks up unsaved work, so clients can warn before the kill attempt.
|
||||
#[tokio::test]
|
||||
async fn watcher_refreshes_the_blocked_work_indicator() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("indicator");
|
||||
|
||||
fx.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/indicator".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Pushed and clean: nothing in the way, and the indicator must say so.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
// Subscribe before the watcher starts so every pass's emission is
|
||||
// captured — otherwise the breakdown event can land between the
|
||||
// snapshot observation below and the drain, and the quiet window
|
||||
// would see it as an illegitimate re-emit.
|
||||
let mut events = fx.manager.events();
|
||||
|
||||
fx.manager.start_liveness_watcher_with_blocked_work(
|
||||
Duration::from_millis(50),
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
fx.manager.snapshot().await[0].blocked_work == Some(maestro_protocol::InTheWay::default())
|
||||
})
|
||||
.await;
|
||||
|
||||
// Unsaved work appears; the next pass must surface it.
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||
wait_for_async(Duration::from_secs(5), || async {
|
||||
matches!(
|
||||
&fx.manager.snapshot().await[0].blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
)
|
||||
})
|
||||
.await;
|
||||
|
||||
// Consume through the emission that carried the breakdown (#83
|
||||
// change-detection): the quiet window below must start after the last
|
||||
// legitimate change, not race it into the channel.
|
||||
loop {
|
||||
match tokio::time::timeout(Duration::from_secs(5), events.recv()).await {
|
||||
Ok(Ok(CatalogEvent::SessionUpdated { session }))
|
||||
if matches!(
|
||||
&session.blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
) =>
|
||||
{
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
_ => panic!("never saw the SessionUpdated carrying the breakdown"),
|
||||
}
|
||||
}
|
||||
while events.try_recv().is_ok() {}
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
// Only a *changed* indicator counts as a re-emit (#83 change-detection).
|
||||
// The activity pass may still legitimately announce e.g. Active→Idle
|
||||
// (its debounce is time-based, not worktree-based), and its payload
|
||||
// carries today's blocked_work unchanged.
|
||||
while let Ok(CatalogEvent::SessionUpdated { session }) = events.try_recv() {
|
||||
assert!(
|
||||
matches!(
|
||||
&session.blocked_work,
|
||||
Some(b) if b.uncommitted_files >= 1 && b.untracked_files >= 1
|
||||
),
|
||||
"an unchanged worktree must not re-emit a changed indicator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cleanup on kill is unconditional once the guard passes (#79): even a spec
|
||||
/// that says "don't clean up" loses its worktree — the branch was pushed, so
|
||||
/// nothing in the tree is unique and removal goes ahead.
|
||||
#[tokio::test]
|
||||
async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-keep");
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("keep");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
@@ -382,7 +478,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/keep".into(),
|
||||
cleanup_on_kill: false,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -390,18 +485,25 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
|
||||
.expect("create");
|
||||
assert!(wt_path.exists());
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
// Push the branch so its commits are reachable from a remote-tracking ref.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager.kill(s.id, false).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "worktree must be removed on kill");
|
||||
}
|
||||
|
||||
/// The guard (#56): a worktree holding uncommitted changes survives the kill,
|
||||
/// left on disk where the operator can find it.
|
||||
/// The kill guard (#79): a worktree holding uncommitted changes refuses the
|
||||
/// kill outright. Nothing happens — the session keeps running as the
|
||||
/// operator's handle on their unfinished work, and the tree is untouched.
|
||||
/// The refusal names what blocked it and how much of it there is.
|
||||
#[tokio::test]
|
||||
async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat-guarded");
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("guarded");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
@@ -413,7 +515,120 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/guarded".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
std::fs::write(wt_path.join("notes.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let err = fx
|
||||
.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
let SessionError::KillGuarded(breakdown) = err else {
|
||||
panic!("expected KillGuarded, got {err:?}");
|
||||
};
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.untracked_files, 1);
|
||||
assert!(
|
||||
breakdown.unpushed_commits >= 1,
|
||||
"a fresh branch has no remote-tracking ref"
|
||||
);
|
||||
let message = breakdown.to_string();
|
||||
assert!(message.contains("1 uncommitted file"), "{message}");
|
||||
assert!(message.contains("1 untracked file"), "{message}");
|
||||
|
||||
// Nothing happened: the session survives and so does the tree.
|
||||
assert!(fx.manager.has(s.id).await, "the session must keep running");
|
||||
assert!(wt_path.exists(), "the worktree must be untouched");
|
||||
|
||||
// A second kill after the work is saved goes through.
|
||||
run_git_in(&wt_path, &["add", "."]);
|
||||
run_git_in(&wt_path, &["commit", "-q", "-m", "saved"]);
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
fx.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect("kill after saving");
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// Unpushed commits alone block the kill (#79): a feature branch almost
|
||||
/// always has them, which is why `--force` (which retains) is the ordinary
|
||||
/// way to end such a session.
|
||||
#[tokio::test]
|
||||
async fn killing_refuses_until_the_branch_is_pushed() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("pushed");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/pushed".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let err = fx
|
||||
.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect_err("unpushed blocks");
|
||||
let SessionError::KillGuarded(breakdown) = err else {
|
||||
panic!("expected KillGuarded, got {err:?}");
|
||||
};
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
assert_eq!(breakdown.untracked_files, 0);
|
||||
assert!(breakdown.unpushed_commits >= 1);
|
||||
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect("pushed branch kills");
|
||||
assert!(!wt_path.exists());
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// `--force` overrides the refusal and nothing else (#81): the session ends,
|
||||
/// but the guarded worktree is RETAINED — never removed — and enters the
|
||||
/// retained store with reason `force-killed`. The outcome names what was
|
||||
/// kept, so a client can say where the work went.
|
||||
#[tokio::test]
|
||||
async fn force_killing_a_guarded_worktree_retains_it_in_the_store() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("forced");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/forced".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
@@ -421,12 +636,347 @@ async fn kill_leaves_worktree_with_unsaved_work_on_disk() {
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
fx.manager.kill(s.id).await.expect("kill");
|
||||
// Without force the same kill is refused (#79).
|
||||
assert!(fx.manager.kill(s.id, false).await.is_err());
|
||||
|
||||
let outcome = fx
|
||||
.manager
|
||||
.kill(s.id, true)
|
||||
.await
|
||||
.expect("force overrides the refusal");
|
||||
|
||||
// The tree would have blocked, so force must NOT have removed it...
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dirty worktree must be left on disk, not destroyed"
|
||||
"a forced kill never removes a tree that would have blocked"
|
||||
);
|
||||
// ...but the session itself still ends.
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
assert!(!fx.sidecar_dir().join(format!("{}.json", s.id)).exists());
|
||||
|
||||
let retained = outcome.retained.expect("the outcome names what was kept");
|
||||
assert_eq!(retained.path, wt_path);
|
||||
assert_eq!(retained.branch, "feat/forced");
|
||||
assert_eq!(
|
||||
retained.reason,
|
||||
maestro_protocol::RetainedReason::ForceKilled
|
||||
);
|
||||
assert!(
|
||||
retained.in_the_way.uncommitted_files >= 1,
|
||||
"the outcome carries what blocked the unforced kill"
|
||||
);
|
||||
|
||||
// The store holds the same entry, so reclaim can find it later (#80).
|
||||
let all = fx.retained_store().load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].id, retained.id);
|
||||
assert_eq!(all[0].path, wt_path);
|
||||
assert_eq!(all[0].branch, "feat/forced");
|
||||
}
|
||||
|
||||
/// Force overrides the *refusal*, not ordinary cleanup (#81): a tree whose
|
||||
/// guard is clear never needed the override, so a forced kill removes it as
|
||||
/// usual and retains nothing.
|
||||
#[tokio::test]
|
||||
async fn force_on_a_clear_worktree_removes_it_as_usual() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("clear");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/clear".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
let outcome = fx.manager.kill(s.id, true).await.expect("kill");
|
||||
assert!(!wt_path.exists(), "a clear tree needs no override");
|
||||
assert_eq!(outcome.retained, None);
|
||||
assert!(fx.retained_store().load_all().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// Ignored files NEVER block (#79): build output like `target/` exists in
|
||||
/// every Rust checkout, and an ignored-only worktree stays killable.
|
||||
#[tokio::test]
|
||||
async fn killing_an_ignored_only_worktree_succeeds() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
// .gitignore must be committed before the worktree branches off, or it
|
||||
// would itself read as an untracked file.
|
||||
std::fs::write(repo.path().join(".gitignore"), b"/target\n").unwrap();
|
||||
run_git_in(repo.path(), &["add", ".gitignore"]);
|
||||
run_git_in(repo.path(), &["commit", "-q", "-m", "ignore target"]);
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("rusty");
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/rusty".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::create_dir_all(wt_path.join("target")).unwrap();
|
||||
std::fs::write(wt_path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||
|
||||
// Branch pushed; the only dirt is ignored output.
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.manager
|
||||
.kill(s.id, false)
|
||||
.await
|
||||
.expect("ignored files never block");
|
||||
assert!(!wt_path.exists());
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
}
|
||||
|
||||
/// Natural exit (#82): a session that ends on its own never reaches the kill
|
||||
/// path, so the watcher applies the kill's rule itself. A worktree holding
|
||||
/// unsaved work is RETAINED into the store with reason
|
||||
/// `exited-with-unsaved-work`; the session stays in the catalog marked dead
|
||||
/// until dismissed — and dismissing it afterwards neither refuses nor
|
||||
/// retains a second time.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_of_a_guarded_worktree_retains_it_in_the_store() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("exited");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/exited".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
// The command finishes / the operator types exit: tmux loses the session.
|
||||
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
// The tree would have blocked a kill, so the exit kept it...
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"a dirty worktree must be retained on natural exit"
|
||||
);
|
||||
// ...in the store, under the exit reason.
|
||||
// (`Dead` is observable a moment before the settle step's store write lands.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.retained_store()
|
||||
.load_all()
|
||||
.map(|all| all.len() == 1)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await;
|
||||
let all = fx.retained_store().load_all().expect("load_all");
|
||||
assert_eq!(all.len(), 1);
|
||||
assert_eq!(all[0].path, wt_path);
|
||||
assert_eq!(all[0].branch, "feat/exited");
|
||||
assert_eq!(
|
||||
all[0].reason,
|
||||
maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
||||
);
|
||||
|
||||
// The session itself stays until dismissed; its worktree reference is
|
||||
// settled, so dismissal is neither refused nor retained twice.
|
||||
// (`Dead` is observable a moment before the settle step finishes.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
let s = &fx.manager.snapshot().await[0];
|
||||
s.worktree.is_none() && s.blocked_work.is_none()
|
||||
})
|
||||
.await;
|
||||
let snap = fx.manager.snapshot().await;
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].worktree, None);
|
||||
|
||||
fx.manager.kill(s.id, false).await.expect("dismiss");
|
||||
assert!(!fx.manager.has(s.id).await);
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"dismissal must not remove what was retained"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.retained_store().load_all().unwrap().len(),
|
||||
1,
|
||||
"dismissal must not record a second entry for the same tree"
|
||||
);
|
||||
}
|
||||
|
||||
/// Natural exit (#82), pristine side of the same rule: nothing in the tree,
|
||||
/// nothing to keep — the worktree is removed and no store entry appears.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_of_a_pristine_worktree_removes_it() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("clean");
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/clean".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
// Push everything so the branch has no unpushed commits: fully pristine.
|
||||
let origin = TempDir::new().unwrap();
|
||||
init_bare_repo(origin.path());
|
||||
push_all_to_origin(repo.path(), origin.path());
|
||||
|
||||
fx.tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
fx.manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
fx.manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
// (`Dead` is observable a moment before the settle step removes the tree.)
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
!wt_path.exists()
|
||||
&& fx
|
||||
.retained_store()
|
||||
.load_all()
|
||||
.map(|all| all.is_empty())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
!wt_path.exists(),
|
||||
"a pristine worktree leaves nothing behind"
|
||||
);
|
||||
assert!(
|
||||
fx.retained_store().load_all().unwrap().is_empty(),
|
||||
"nothing was retained"
|
||||
);
|
||||
assert!(
|
||||
fx.manager.has(s.id).await,
|
||||
"the session stays until dismissed"
|
||||
);
|
||||
}
|
||||
|
||||
/// When nothing can be recorded — here: no retained store wired — the exit
|
||||
/// destroys nothing. The tree stays on disk AND stays attached to the dead
|
||||
/// record, where dismissing it later still goes through #81's forced-kill
|
||||
/// retention. Losing the store must not lose the work.
|
||||
#[tokio::test]
|
||||
async fn natural_exit_without_a_retained_store_destroys_nothing() {
|
||||
let tmux = TmuxFixture::new();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
let manager = maestro_daemon::SessionManager::new(
|
||||
Arc::new(tmux.tmux.clone()),
|
||||
maestro_daemon::sidecar::SidecarStore::new(state_dir.path().join("sessions")),
|
||||
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||
state_dir.path().join("fifos"),
|
||||
);
|
||||
// Deliberately NO with_retained_store.
|
||||
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
let wt_path = repo.path().join("worktrees").join("feat").join("nostore");
|
||||
|
||||
let s = manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/nostore".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
tmux.tmux.kill_session(s.id).expect("kill tmux directly");
|
||||
manager.start_liveness_watcher(Duration::from_millis(50));
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
manager.status(s.id).await == Some(SessionStatus::Dead)
|
||||
})
|
||||
.await;
|
||||
|
||||
// The settle step has been applied or refused either way — here refused,
|
||||
// since there is no store to record the tree into. Only from this point
|
||||
// is "the tree survived" a settled fact rather than a race (#115).
|
||||
wait_for_async(Duration::from_secs(3), || async {
|
||||
manager.is_settled(s.id).await == Some(true)
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"nothing may be destroyed when it cannot be recorded"
|
||||
);
|
||||
let snap = manager.snapshot().await;
|
||||
assert!(
|
||||
snap[0].worktree.is_some(),
|
||||
"the tree must stay attached to the record so a later forced kill can retain it"
|
||||
);
|
||||
}
|
||||
|
||||
fn run_git_in(dir: &std::path::Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(dir)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("git spawn");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"git {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(fx.manager.snapshot().await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -452,5 +1002,103 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
||||
tokio::time::sleep(Duration::from_millis(400)).await;
|
||||
assert!(fx.manager.has(s.id).await, "live session got reaped");
|
||||
|
||||
fx.manager.kill(s.id).await.unwrap();
|
||||
fx.manager.kill(s.id, false).await.unwrap();
|
||||
}
|
||||
|
||||
/// #99: clients group a session under `worktree.base_repo`, so that field has
|
||||
/// to name the repository even when the spawn request pointed at a linked
|
||||
/// worktree. Recording the request verbatim put the session in a folder of its
|
||||
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
|
||||
#[tokio::test]
|
||||
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||
let fx = DaemonFixture::new();
|
||||
let repo = TempDir::new().unwrap();
|
||||
init_repo(repo.path());
|
||||
|
||||
// A linked worktree of that repo, then a spawn whose `base_repo` names it
|
||||
// rather than the main checkout — what happens when the operator spawns
|
||||
// from inside a worktree.
|
||||
let first = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: repo.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: repo.path().to_path_buf(),
|
||||
branch: "feat/one".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create first");
|
||||
let inner_worktree = first.worktree.expect("first has a worktree").path;
|
||||
|
||||
let second = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: inner_worktree.clone(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: inner_worktree.clone(),
|
||||
branch: "feat/two".into(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
.await
|
||||
.expect("create second");
|
||||
|
||||
let wt = second.worktree.expect("second has a worktree");
|
||||
assert_eq!(
|
||||
wt.base_repo,
|
||||
repo.path()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| repo.path().to_path_buf()),
|
||||
"base_repo must be the repository, not the worktree the request named",
|
||||
);
|
||||
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
|
||||
}
|
||||
|
||||
/// #103: every tmux-spawned session carries the daemon's identity variables in
|
||||
/// its environment — its UUID under `MAESTRO_SESSION_ID` and the daemon's base
|
||||
/// URL under `MAESTRO_URL` — so the in-session reporting hook (#106) can find
|
||||
/// both without any out-of-band channel.
|
||||
#[tokio::test]
|
||||
async fn spawned_sessions_export_maestro_session_id_and_url() {
|
||||
let fx = DaemonFixture::new();
|
||||
fx.manager
|
||||
.set_daemon_url("http://127.0.0.1:7071".to_string());
|
||||
let cwd = TempDir::new().unwrap();
|
||||
|
||||
let s = fx
|
||||
.manager
|
||||
.create(CreateSessionRequest {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
})
|
||||
.await
|
||||
.expect("create");
|
||||
|
||||
let env = fx
|
||||
.tmux
|
||||
.tmux
|
||||
.show_environment(s.id)
|
||||
.expect("show-environment");
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_SESSION_ID"),
|
||||
Some(&s.id.to_string()),
|
||||
"session env: {env:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
env.get("MAESTRO_URL"),
|
||||
Some(&"http://127.0.0.1:7071".to_string())
|
||||
);
|
||||
|
||||
fx.manager.kill(s.id, false).await.expect("kill");
|
||||
}
|
||||
|
||||
@@ -19,14 +19,18 @@ fn sample(id: Uuid) -> Session {
|
||||
command: "bash".into(),
|
||||
env: HashMap::from([("FOO".into(), "bar".into())]),
|
||||
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ mod common;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
use common::init_repo;
|
||||
use common::{init_bare_repo, init_repo, push_all_to_origin};
|
||||
use maestro_daemon::worktree::WorktreeManager;
|
||||
use maestro_daemon::worktree_probe::in_the_way;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn worktree_branches(repo: &PathBuf) -> Vec<String> {
|
||||
@@ -53,7 +54,7 @@ fn add_places_worktree_inside_the_base_repo_by_default() {
|
||||
let mgr = WorktreeManager::new(None);
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, repo.join("worktrees").join("feat-example"));
|
||||
assert_eq!(path, repo.join("worktrees").join("feat").join("example"));
|
||||
assert!(path.exists());
|
||||
assert!(path.join("README").exists());
|
||||
|
||||
@@ -74,31 +75,47 @@ fn worktree_root_override_redirects_placement() {
|
||||
// repos spawning the same branch name don't target one directory.
|
||||
let repo_name = repo.file_name().unwrap();
|
||||
let path = mgr.add_new(&repo, "feat/example").expect("add worktree");
|
||||
assert_eq!(path, wt_root.path().join(repo_name).join("feat-example"));
|
||||
// The override root disambiguates same-named checkouts with a digest of
|
||||
// the full repo path, and the branch keeps its own shape.
|
||||
// <root>/<repo basename>-<digest>/feat/example
|
||||
let prefix = format!("{}-", repo_name.to_string_lossy());
|
||||
let repo_dir_name = path
|
||||
.strip_prefix(wt_root.path())
|
||||
.unwrap()
|
||||
.components()
|
||||
.next()
|
||||
.unwrap();
|
||||
assert!(
|
||||
repo_dir_name
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.starts_with(&prefix),
|
||||
"{path:?}"
|
||||
);
|
||||
assert!(path.ends_with("feat/example"), "{path:?}");
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
/// Anything at all occupying the target path — ours, the operator's, or an
|
||||
/// unrelated directory — is sidestepped, not adopted and not reported (#77).
|
||||
#[test]
|
||||
fn existing_directory_at_target_path_is_an_error_carrying_the_path() {
|
||||
fn an_occupied_target_path_gets_a_numeric_suffix() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// A predictable path means collisions are possible; they must be
|
||||
// reported, never silently relocated.
|
||||
std::fs::create_dir_all(repo.join("worktrees").join("feat-collide")).unwrap();
|
||||
// A non-git directory sits exactly where the worktree would go.
|
||||
let squatter = repo.join("worktrees").join("feat").join("collide");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let err = mgr.add_new(&repo, "feat/collide").unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||
assert_eq!(path, repo.join("worktrees").join("feat").join("collide-2"));
|
||||
assert!(path.exists());
|
||||
assert!(
|
||||
msg.contains(
|
||||
repo.join("worktrees")
|
||||
.join("feat-collide")
|
||||
.to_str()
|
||||
.unwrap()
|
||||
),
|
||||
"{msg}"
|
||||
squatter.join("operator-notes.txt").exists(),
|
||||
"the occupant is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +135,123 @@ fn add_twice_generates_distinct_paths() {
|
||||
assert!(p2.exists());
|
||||
}
|
||||
|
||||
/// The unsaved-work probe (#79) counts uncommitted tracked changes.
|
||||
#[test]
|
||||
fn the_probe_counts_uncommitted_tracked_changes() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/dirty").expect("add");
|
||||
std::fs::write(path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1);
|
||||
assert_eq!(w.untracked_files, 0);
|
||||
}
|
||||
|
||||
/// Untracked sources count; a staged new file is also unsaved tracked work.
|
||||
#[test]
|
||||
fn the_probe_counts_untracked_files() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/untracked").expect("add");
|
||||
std::fs::write(path.join("notes.txt"), b"wip\n").unwrap();
|
||||
std::fs::write(path.join("more.txt"), b"also wip\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.untracked_files, 2);
|
||||
assert_eq!(w.uncommitted_files, 0);
|
||||
|
||||
run_git(&path, &["add", "notes.txt"]);
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1, "staged counts as uncommitted");
|
||||
assert_eq!(w.untracked_files, 1);
|
||||
}
|
||||
|
||||
/// Ignored files NEVER block: every Rust checkout has a `target/`, and
|
||||
/// counting ignored output would leave no session killable (#79).
|
||||
#[test]
|
||||
fn the_probe_ignores_ignored_files() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
// .gitignore must be committed, or it would itself read as untracked.
|
||||
std::fs::write(repo.join(".gitignore"), b"/target\n").unwrap();
|
||||
run_git(&repo, &["add", ".gitignore"]);
|
||||
run_git(&repo, &["commit", "-q", "-m", "ignore target"]);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/builds").expect("add");
|
||||
std::fs::create_dir_all(path.join("target")).unwrap();
|
||||
std::fs::write(path.join("target").join("app.o"), b"binary\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 0);
|
||||
assert_eq!(w.untracked_files, 0);
|
||||
|
||||
// With the branch pushed too, an ignored-only tree reads fully clean.
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
init_bare_repo(origin_dir.path());
|
||||
push_all_to_origin(&repo, origin_dir.path());
|
||||
let w = in_the_way(&path).expect("probe after push");
|
||||
assert!(w.is_clear(), "ignored-only worktree must read clean: {w:?}");
|
||||
}
|
||||
|
||||
/// Commits reachable from no remote-tracking ref block; pushing the branch
|
||||
/// clears them. With no remote at all every commit is unreachable from one,
|
||||
/// which is exactly why feature branches almost always refuse (#79).
|
||||
#[test]
|
||||
fn the_probe_counts_unpushed_commits_until_the_branch_is_pushed() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/branch").expect("add");
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert!(
|
||||
w.unpushed_commits >= 1,
|
||||
"a fresh branch has no remote-tracking ref: {w:?}"
|
||||
);
|
||||
|
||||
let origin_dir = TempDir::new().unwrap();
|
||||
init_bare_repo(origin_dir.path());
|
||||
push_all_to_origin(&repo, origin_dir.path());
|
||||
|
||||
let w = in_the_way(&path).expect("probe after push");
|
||||
assert_eq!(w.unpushed_commits, 0, "{w:?}");
|
||||
assert!(w.is_clear(), "pushed and clean must be empty: {w:?}");
|
||||
}
|
||||
|
||||
/// The full breakdown names each blocker with its count.
|
||||
#[test]
|
||||
fn the_probe_reports_every_blocker_at_once() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let path = mgr.add_new(&repo, "feat/mixed").expect("add");
|
||||
std::fs::write(path.join("README"), b"edited\n").unwrap();
|
||||
std::fs::write(path.join("scratch.txt"), b"untracked\n").unwrap();
|
||||
|
||||
let w = in_the_way(&path).expect("probe");
|
||||
assert_eq!(w.uncommitted_files, 1);
|
||||
assert_eq!(w.untracked_files, 1);
|
||||
assert!(w.unpushed_commits >= 1);
|
||||
let summary = w.to_string();
|
||||
assert!(summary.contains("1 uncommitted file"), "{summary}");
|
||||
assert!(summary.contains("1 untracked file"), "{summary}");
|
||||
assert!(summary.contains("unpushed commit"), "{summary}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_prunes_worktree() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
@@ -195,8 +329,14 @@ fn add_existing_checks_out_branch_without_creating_it() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Removal is guarded upstream now (#79): the kill refuses unsaved work
|
||||
/// before anything is torn down, so by the time `remove` runs the tree is
|
||||
/// clean. Git still refuses a dirty tree on its own — this asserts that last
|
||||
/// line of defense — and committed-but-unpushed work does not block removal,
|
||||
/// because `worktree remove` keeps `refs/heads/<branch>` and the commits stay
|
||||
/// reachable.
|
||||
#[test]
|
||||
fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||
fn remove_still_relies_on_git_refusing_a_dirty_tree() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
@@ -209,10 +349,6 @@ fn remove_refuses_worktree_with_uncommitted_changes() {
|
||||
.expect_err("dirty worktree must be refused");
|
||||
assert!(path.exists(), "guarded worktree must stay on disk");
|
||||
|
||||
// Committing it makes removal safe: `worktree remove` keeps
|
||||
// `refs/heads/feat/dirty`, so the work is still reachable. Refusing here
|
||||
// instead would make every unpushed branch a one-shot, since the path is
|
||||
// deterministic and the leftover tree blocks the next spawn (#56 review).
|
||||
run_git(&path, &["add", "."]);
|
||||
run_git(&path, &["commit", "-q", "-m", "saved"]);
|
||||
|
||||
@@ -312,10 +448,9 @@ fn placement_resolves_a_linked_worktree_to_the_main_checkout() {
|
||||
!inner.starts_with(&outer),
|
||||
"must not nest: {inner:?} inside {outer:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
inner.parent().and_then(|p| p.parent()),
|
||||
Some(repo.canonicalize().unwrap().as_path()),
|
||||
"placed against the main checkout"
|
||||
assert!(
|
||||
inner.starts_with(repo.canonicalize().unwrap().join("worktrees")),
|
||||
"placed against the main checkout: {inner:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -363,3 +498,75 @@ fn override_root_keeps_repos_apart() {
|
||||
let b = mgr.add_new(b_dir.path(), "feat/shared").expect("repo b");
|
||||
assert_ne!(a, b, "same branch name in two repos must not collide");
|
||||
}
|
||||
|
||||
/// Flattening `/` and `.` to `-` made these three share one directory, so the
|
||||
/// losers could never get a worktree (#54 review).
|
||||
#[test]
|
||||
fn similar_branch_names_get_distinct_directories() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let a = mgr.add_new(&repo, "feat/x").expect("feat/x");
|
||||
let b = mgr.add_new(&repo, "feat.x").expect("feat.x");
|
||||
let c = mgr.add_new(&repo, "feat-x").expect("feat-x");
|
||||
assert_ne!(a, b);
|
||||
assert_ne!(b, c);
|
||||
assert_ne!(a, c);
|
||||
assert!(a.ends_with("feat/x"), "{a:?}");
|
||||
}
|
||||
|
||||
/// A guarded leftover stays on disk untouched, and is never adopted (#77).
|
||||
/// Re-spawning its branch is refused by git itself — the branch is still
|
||||
/// checked out inside the leftover — which the caller surfaces.
|
||||
#[test]
|
||||
fn a_guarded_leftover_is_never_adopted() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let mgr = WorktreeManager::new(None);
|
||||
let first = mgr.add_new(&repo, "feat/resume").expect("add");
|
||||
std::fs::write(first.join("notes.txt"), b"unsaved\n").unwrap();
|
||||
|
||||
// Removal is refused — git itself refuses a dirty tree — leaving it on
|
||||
// disk; the daemon-side guard lives on the kill (#79).
|
||||
mgr.remove(&repo, &first)
|
||||
.expect_err("dirty tree is guarded");
|
||||
|
||||
let err = mgr
|
||||
.add_existing(&repo, "feat/resume")
|
||||
.expect_err("a branch already checked out cannot be spawned twice");
|
||||
assert!(format!("{err}").contains("git exited"), "{err}");
|
||||
assert!(
|
||||
first.join("notes.txt").exists(),
|
||||
"the leftover's unsaved work is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `worktree_root` override gets the same collision suffix (#77).
|
||||
#[test]
|
||||
fn the_override_root_suffixes_collisions_too() {
|
||||
let repo_dir = TempDir::new().unwrap();
|
||||
let repo = repo_dir.path().to_path_buf();
|
||||
init_repo(&repo);
|
||||
|
||||
let wt_root = TempDir::new().unwrap();
|
||||
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
|
||||
|
||||
// First add settles `<root>/<repo basename>-<digest>/` so the squatter
|
||||
// can be created at the exact target.
|
||||
mgr.add_new(&repo, "feat/first").expect("add first");
|
||||
let repo_dir_in_root = std::fs::read_dir(wt_root.path())
|
||||
.unwrap()
|
||||
.next()
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.path();
|
||||
let squatter = repo_dir_in_root.join("feat").join("collide");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
|
||||
let path = mgr.add_new(&repo, "feat/collide").expect("sidesteps");
|
||||
assert_eq!(path, squatter.with_file_name("collide-2"), "{path:?}");
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use maestro_protocol::{CatalogEvent, CreateSessionRequest, DaemonClient, Session};
|
||||
use maestro_protocol::{
|
||||
BranchInfo, CatalogEvent, CreateSessionRequest, DaemonClient, KillOutcome, Project,
|
||||
RetainedWorktree, Session,
|
||||
};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
|
||||
@@ -59,7 +62,29 @@ impl TestClient {
|
||||
}
|
||||
|
||||
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
Ok(self.daemon.kill_session(id).await?)
|
||||
Ok(self.daemon.kill_session(id, false).await.map(|_| ())?)
|
||||
}
|
||||
|
||||
/// Kill with `force=true`: overrides a kill refusal and retains the
|
||||
/// guarded worktree (#81). Answers with what was kept.
|
||||
pub async fn kill_session_forced(&self, id: Uuid) -> Result<KillOutcome, ClientError> {
|
||||
Ok(self.daemon.kill_session(id, true).await?)
|
||||
}
|
||||
|
||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||
Ok(self.daemon.list_worktrees().await?)
|
||||
}
|
||||
|
||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
Ok(self.daemon.reclaim_worktree(id).await?)
|
||||
}
|
||||
|
||||
pub async fn list_projects(&self) -> Result<Vec<Project>, ClientError> {
|
||||
Ok(self.daemon.list_projects().await?)
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
Ok(self.daemon.list_branches(path).await?)
|
||||
}
|
||||
|
||||
/// Connect to the daemon's per-session WS and return the stream. The
|
||||
|
||||
@@ -29,11 +29,32 @@ impl Daemon {
|
||||
Self::spawn_at(SocketAddr::from((Ipv4Addr::LOCALHOST, free_port()))).await
|
||||
}
|
||||
|
||||
/// Spawn with a config file pre-seeded at the daemon's expected path
|
||||
/// (`$XDG_CONFIG_HOME/maestro/config.toml`).
|
||||
pub async fn spawn_with_config(toml: &str) -> Self {
|
||||
Self::spawn_config_at(
|
||||
SocketAddr::from((Ipv4Addr::LOCALHOST, free_port())),
|
||||
Some(toml),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address.
|
||||
pub async fn spawn_at(bind: SocketAddr) -> Self {
|
||||
Self::spawn_config_at(bind, None).await
|
||||
}
|
||||
|
||||
/// Spawn on a caller-supplied bind address, with an optional config file.
|
||||
async fn spawn_config_at(bind: SocketAddr, config: impl Into<Option<&str>>) -> Self {
|
||||
let home = TempDir::new().expect("home tmpdir");
|
||||
let tmux = TempDir::new().expect("tmux tmpdir");
|
||||
|
||||
if let Some(toml) = config.into() {
|
||||
let dir = home.path().join(".config").join("maestro");
|
||||
std::fs::create_dir_all(&dir).expect("config dir");
|
||||
std::fs::write(dir.join("config.toml"), toml).expect("write config.toml");
|
||||
}
|
||||
|
||||
let child = launch(bind, home.path(), tmux.path());
|
||||
let me = Self {
|
||||
bind_addr: bind,
|
||||
|
||||
@@ -14,5 +14,5 @@ pub mod daemon;
|
||||
pub mod util;
|
||||
|
||||
pub use build::workspace_target_bin;
|
||||
pub use client::TestClient;
|
||||
pub use client::{ClientError, TestClient};
|
||||
pub use daemon::Daemon;
|
||||
|
||||
+873
-33
@@ -35,6 +35,28 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/// A worktree spec built through its wire shape. The daemon and clients ship
|
||||
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
|
||||
/// object carries only the fields the lifecycle actually honours.
|
||||
fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool) -> WorktreeSpec {
|
||||
WorktreeSpec {
|
||||
base_repo: base_repo.to_path_buf(),
|
||||
branch: branch.into(),
|
||||
create_branch,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn request carrying a worktree spec against `base`.
|
||||
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: base.to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(base, branch, create_branch)),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lists_spawned_sessions() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
@@ -125,12 +147,7 @@ async fn kill_removes_clean_worktree() {
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: base.path().to_path_buf(),
|
||||
branch: "feature/e2e".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/e2e", true)),
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
@@ -140,10 +157,11 @@ async fn kill_removes_clean_worktree() {
|
||||
assert!(
|
||||
worktrees
|
||||
.iter()
|
||||
.any(|w| w.contains("worktrees/feature-e2e")),
|
||||
.any(|w| w.contains("worktrees/feature/e2e")),
|
||||
"expected a worktree for feature/e2e under <repo>/worktrees, got: {worktrees:?}"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// Give the daemon a moment to remove the worktree, then verify.
|
||||
@@ -161,49 +179,70 @@ async fn kill_removes_clean_worktree() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The guard (#56): killing a session whose worktree holds uncommitted
|
||||
/// changes must leave the tree on disk — cleanup is unconditional, so this
|
||||
/// guard is what keeps a stray kill from destroying work.
|
||||
/// The kill guard (#79): killing a session whose worktree holds uncommitted
|
||||
/// changes is refused outright. Nothing happens — the session keeps running
|
||||
/// as the operator's handle on their unfinished work, the tree stays on disk,
|
||||
/// and the refusal names what blocked it and how much.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn kill_spares_worktree_with_uncommitted_changes() {
|
||||
async fn kill_refuses_worktree_with_uncommitted_changes() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let wt_path = base.path().join("worktrees").join("feature-unsaved");
|
||||
let wt_path = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("unsaved");
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
cwd: base.path().to_path_buf(),
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: base.path().to_path_buf(),
|
||||
branch: "feature/unsaved".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/unsaved", true)),
|
||||
};
|
||||
|
||||
let s = client.spawn_session(&req).await.expect("spawn worktree");
|
||||
assert!(wt_path.exists());
|
||||
std::fs::write(wt_path.join("README"), b"unsaved edits\n").unwrap();
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// The session is gone but the dirty worktree stays findable on disk.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||
if sessions.is_empty() {
|
||||
break;
|
||||
let err = client
|
||||
.kill_session(s.id)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
match err {
|
||||
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||
let breakdown = info.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||
}
|
||||
|
||||
// The session survives and the dirty worktree stays findable on disk.
|
||||
let sessions = client.list_sessions().await.unwrap_or_default();
|
||||
assert!(
|
||||
sessions.iter().any(|x| x.id == s.id),
|
||||
"a refused kill must keep the session alive"
|
||||
);
|
||||
assert!(
|
||||
wt_path.exists(),
|
||||
"guarded worktree must be left on disk after kill"
|
||||
);
|
||||
// Left findable, not silently half-deleted: git still registers it and
|
||||
// the uncommitted edit survives byte for byte (#66).
|
||||
assert!(
|
||||
git_worktree_list(base.path())
|
||||
.iter()
|
||||
.any(|w| w.contains("feature/unsaved")),
|
||||
"guarded worktree must stay registered in git worktree list"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(wt_path.join("README")).unwrap(),
|
||||
b"unsaved edits\n",
|
||||
"uncommitted changes must survive the guarded kill"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -219,12 +258,7 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
||||
command: "sleep 30".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: base.path().to_path_buf(),
|
||||
branch: "feature/existing".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
}),
|
||||
worktree: Some(worktree_spec(base.path(), "feature/existing", false)),
|
||||
};
|
||||
|
||||
let s = client
|
||||
@@ -237,9 +271,761 @@ async fn spawn_on_existing_branch_checks_it_out() {
|
||||
"expected a worktree checked out at feature/existing, got: {worktrees:?}"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a fresh branch spawns a worktree at `<repo>/worktrees/<branch>` and
|
||||
/// the session actually runs inside it — `current_cwd` comes from tmux's live
|
||||
/// pane state, not from the spawn request.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let wt_path = base.path().join("worktrees").join("feature").join("fresh");
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/fresh", true))
|
||||
.await
|
||||
.expect("spawn new-branch worktree");
|
||||
|
||||
assert_eq!(
|
||||
s.cwd, wt_path,
|
||||
"session must report the worktree as its cwd"
|
||||
);
|
||||
assert!(wt_path.is_dir(), "worktree directory must exist on disk");
|
||||
|
||||
// The activity watcher fills `current_cwd` from tmux's pane_current_path,
|
||||
// so this is the daemon confirming where the process really runs.
|
||||
wait_for_async(
|
||||
"session's live cwd is the worktree",
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let id = s.id;
|
||||
let want = wt_path.clone();
|
||||
async move {
|
||||
client
|
||||
.list_sessions()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|ss| ss.iter().find(|s| s.id == id).cloned())
|
||||
.map(|s| s.current_cwd == Some(want.clone()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: a second spawn onto a branch that already has a worktree is rejected
|
||||
/// with a structured worktree error; the first worktree stays intact.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn second_spawn_on_same_branch_is_rejected() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
let first = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await
|
||||
.expect("first spawn");
|
||||
let dup = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
|
||||
.await;
|
||||
match dup {
|
||||
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
|
||||
assert_eq!(
|
||||
info.code, "worktree_failed",
|
||||
"expected worktree error: {info:?}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected rejection for duplicate branch, got {other:?}"),
|
||||
}
|
||||
|
||||
// Exactly one registration, one directory, still usable.
|
||||
// Count branch records only: the worktree path now contains the branch
|
||||
// name too (`worktrees/feature/dup`), so a substring match over every line
|
||||
// would count the same tree twice.
|
||||
let holders = git_worktree_list(base.path())
|
||||
.iter()
|
||||
.filter(|w| w.as_str() == "branch refs/heads/feature/dup")
|
||||
.count();
|
||||
assert_eq!(holders, 1, "duplicate spawn must not register another tree");
|
||||
assert!(
|
||||
base.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("dup")
|
||||
.is_dir(),
|
||||
"original worktree must be untouched"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(first.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66/#56: when the client picks a *linked worktree* as base_repo, placement
|
||||
/// resolves through `--git-common-dir` to the main checkout, so the new tree
|
||||
/// lands at `<main>/worktrees/<branch>` instead of nesting inside another
|
||||
/// worktree.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn spawn_from_linked_worktree_places_in_main_checkout() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let linked = base.path().join("linked");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"other",
|
||||
&linked.to_string_lossy(),
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(&linked, "feature/nested", true))
|
||||
.await
|
||||
.expect("spawn from linked worktree");
|
||||
|
||||
let want = base.path().join("worktrees").join("feature").join("nested");
|
||||
assert_eq!(
|
||||
s.cwd, want,
|
||||
"worktree must be placed under the main checkout"
|
||||
);
|
||||
assert!(want.is_dir());
|
||||
assert!(
|
||||
!s.cwd.starts_with(&linked),
|
||||
"worktrees must never nest inside another worktree"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Worktree lifecycle: collisions, retention, reclaim (#88)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// #77/#88: the target path `<repo>/worktrees/<branch>` is occupied by a real
|
||||
/// registered worktree the operator made by hand. The daemon sidesteps to the
|
||||
/// suffixed directory and never touches the occupant — and both trees keep
|
||||
/// working: two registered worktrees, two sessions, each running in its own.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn occupied_path_gets_suffixed_directory_and_both_worktrees_work() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
// An operator-made worktree sits exactly where `feature/collide` would go.
|
||||
// Its branch is a different one, so the requested branch stays creatable.
|
||||
let squatter = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("collide");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"operator/manual",
|
||||
&squatter.to_string_lossy(),
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/collide", true))
|
||||
.await
|
||||
.expect("spawn onto occupied path");
|
||||
|
||||
let suffixed = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("collide-2");
|
||||
assert_eq!(
|
||||
s.cwd, suffixed,
|
||||
"the daemon must take the suffixed directory, not the occupied one"
|
||||
);
|
||||
assert!(suffixed.is_dir(), "suffixed worktree must exist on disk");
|
||||
|
||||
// Both trees are registered, distinct, and the squatter is byte-intact.
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
registrations
|
||||
.iter()
|
||||
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
|
||||
"operator's worktree must stay registered: {registrations:?}"
|
||||
);
|
||||
assert!(
|
||||
registrations.iter().any(|l| l.contains("collide-2")),
|
||||
"suffixed worktree must be registered too: {registrations:?}"
|
||||
);
|
||||
|
||||
// Both sessions run inside their own tree at the same time.
|
||||
let sessions = client.list_sessions().await.expect("list");
|
||||
assert!(
|
||||
sessions.iter().any(|x| x.id == s.id && x.cwd == suffixed),
|
||||
"session must keep running in its suffixed tree"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
|
||||
// Only the daemon-made trees go; the operator's manual one survives.
|
||||
let after = git_worktree_list(base.path());
|
||||
assert!(
|
||||
after
|
||||
.iter()
|
||||
.any(|l| l == &format!("worktree {}", squatter.canonicalize().unwrap().display())),
|
||||
"operator's worktree must outlive the sessions: {after:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #77/#88: the target path holds a plain directory the daemon never made and
|
||||
/// git has never heard of. The daemon neither adopts it nor errors — it builds
|
||||
/// beside it and leaves every byte of it alone.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn foreign_directory_is_sidestepped_never_adopted() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
|
||||
let squatter = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("foreign");
|
||||
std::fs::create_dir_all(&squatter).unwrap();
|
||||
std::fs::write(squatter.join("operator-notes.txt"), b"mine\n").unwrap();
|
||||
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/foreign", true))
|
||||
.await
|
||||
.expect("spawn must sidestep, not adopt or fail");
|
||||
|
||||
let suffixed = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("foreign-2");
|
||||
assert_eq!(s.cwd, suffixed, "session must run in the suffixed tree");
|
||||
assert_eq!(
|
||||
std::fs::read(squatter.join("operator-notes.txt")).unwrap(),
|
||||
b"mine\n",
|
||||
"the foreign directory must be untouched"
|
||||
);
|
||||
let registrations = git_worktree_list(base.path());
|
||||
let registered_paths: Vec<&str> = registrations
|
||||
.iter()
|
||||
.filter_map(|l| l.strip_prefix("worktree "))
|
||||
.collect();
|
||||
assert!(
|
||||
!registered_paths
|
||||
.iter()
|
||||
.any(|p| *p == squatter.canonicalize().unwrap().display().to_string()),
|
||||
"a plain directory must never be adopted as a worktree: {registered_paths:?}"
|
||||
);
|
||||
|
||||
let _origin = throwaway_origin(base.path());
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
assert!(squatter.join("operator-notes.txt").exists());
|
||||
}
|
||||
|
||||
/// #79/#88: killing with uncommitted changes is refused; committing and
|
||||
/// pushing that work clears the guard and the very same kill then succeeds
|
||||
/// and removes the tree. The session is the handle on the work: while it is
|
||||
/// unsaved the session stays, once it is saved the tree goes.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn guarded_kill_then_commit_and_push_makes_the_same_kill_succeed() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let _origin = throwaway_origin(base.path());
|
||||
|
||||
let wt = base.path().join("worktrees").join("feature").join("guard");
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/guard", true))
|
||||
.await
|
||||
.expect("spawn");
|
||||
std::fs::write(wt.join("README"), b"work in progress\n").unwrap();
|
||||
|
||||
// Refused: the edit is uncommitted.
|
||||
let err = client
|
||||
.kill_session(s.id)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
match err {
|
||||
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||
let breakdown = info.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.uncommitted_files, 1);
|
||||
assert_eq!(breakdown.unpushed_commits, 0);
|
||||
}
|
||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||
}
|
||||
assert!(
|
||||
client
|
||||
.list_sessions()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|x| x.id == s.id),
|
||||
"a refused kill keeps the session running"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(wt.join("README")).unwrap(),
|
||||
b"work in progress\n",
|
||||
"the worktree is untouched by the refusal"
|
||||
);
|
||||
|
||||
// The operator does exactly what the refusal asked for: save and push.
|
||||
commit_all(&wt, "wip");
|
||||
push_all(base.path());
|
||||
|
||||
client.kill_session(s.id).await.expect("kill after saving");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
if !wt.exists() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
assert!(
|
||||
!wt.exists(),
|
||||
"a cleared worktree must be removed after kill"
|
||||
);
|
||||
let registrations = git_worktree_list(base.path());
|
||||
assert!(
|
||||
!registrations.iter().any(|l| l.contains("feature/guard")),
|
||||
"removal must deregister the tree: {registrations:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #81/#88: commits that no remote has refuse the kill; `--force` ends the
|
||||
/// session anyway but RETAINS the worktree — force never destroys blocked
|
||||
/// work, it parks it in the retained store with reason `force-killed`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn unpushed_commits_block_kill_and_force_retains_the_tree() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let _origin = throwaway_origin(base.path());
|
||||
|
||||
let wt = base.path().join("worktrees").join("feature").join("local");
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/local", true))
|
||||
.await
|
||||
.expect("spawn");
|
||||
std::fs::write(wt.join("README"), b"local only\n").unwrap();
|
||||
commit_all(&wt, "not pushed yet");
|
||||
|
||||
// Refused: the commit is on no remote-tracking ref.
|
||||
let err = client
|
||||
.kill_session(s.id)
|
||||
.await
|
||||
.expect_err("kill must refuse");
|
||||
match err {
|
||||
maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. }) => {
|
||||
assert_eq!(info.code, "kill_guarded", "{info:?}");
|
||||
let breakdown = info.unsaved_work.expect("breakdown on the wire");
|
||||
assert_eq!(breakdown.unpushed_commits, 1);
|
||||
assert_eq!(breakdown.uncommitted_files, 0);
|
||||
}
|
||||
other => panic!("expected a kill_guarded refusal, got {other:?}"),
|
||||
}
|
||||
|
||||
// Force overrides the refusal and nothing else: session gone, tree kept.
|
||||
let outcome = client.kill_session_forced(s.id).await.expect("forced kill");
|
||||
let retained = outcome
|
||||
.retained
|
||||
.expect("force must retain the guarded tree");
|
||||
assert_eq!(
|
||||
retained.reason,
|
||||
maestro_protocol::RetainedReason::ForceKilled
|
||||
);
|
||||
assert_eq!(retained.path, wt);
|
||||
assert_eq!(retained.branch, "feature/local");
|
||||
assert_eq!(retained.in_the_way.unpushed_commits, 1);
|
||||
|
||||
assert!(
|
||||
!client
|
||||
.list_sessions()
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|x| x.id == s.id),
|
||||
"the forced kill ends the session"
|
||||
);
|
||||
assert!(wt.is_dir(), "a force-retained worktree must stay on disk");
|
||||
assert!(
|
||||
git_worktree_list(base.path())
|
||||
.iter()
|
||||
.any(|l| l.contains("feature/local")),
|
||||
"the retained tree stays registered in git"
|
||||
);
|
||||
}
|
||||
|
||||
/// #82/#88: a session that exits on its own never reaches the kill path, but
|
||||
/// its worktree follows the same rule. Dirty → retained with reason
|
||||
/// `exited-with-unsaved-work`; pristine → removed, leaving nothing behind.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
// Two repos so the two cases cannot see each other's state.
|
||||
let dirty_repo = TempDir::new().unwrap();
|
||||
init_repo(dirty_repo.path());
|
||||
let _origin = throwaway_origin(dirty_repo.path());
|
||||
let pristine_repo = TempDir::new().unwrap();
|
||||
init_repo(pristine_repo.path());
|
||||
let _origin_pristine = throwaway_origin(pristine_repo.path());
|
||||
|
||||
let dirty_wt = dirty_repo
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("messy");
|
||||
let pristine_wt = pristine_repo
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("tidy");
|
||||
|
||||
let _dirty = client
|
||||
.spawn_session(&CreateSessionRequest {
|
||||
cwd: dirty_repo.path().to_path_buf(),
|
||||
command: "sh -c 'echo unsaved > scratch.txt'".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(dirty_repo.path(), "feature/messy", true)),
|
||||
})
|
||||
.await
|
||||
.expect("spawn dirty");
|
||||
let _pristine = client
|
||||
.spawn_session(&create_req_cmd(
|
||||
pristine_repo.path(),
|
||||
"true",
|
||||
"feature/tidy",
|
||||
))
|
||||
.await
|
||||
.expect("spawn pristine");
|
||||
|
||||
assert!(dirty_wt.is_dir() && pristine_wt.is_dir());
|
||||
|
||||
// The watcher reaps dead tmux sessions every couple of ticks and settles
|
||||
// each worktree accordingly. Poll for both outcomes.
|
||||
wait_for_async(
|
||||
"dirty exit retains its worktree",
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let path = dirty_wt.clone();
|
||||
async move {
|
||||
client
|
||||
.list_worktrees()
|
||||
.await
|
||||
.ok()
|
||||
.map(|wts| {
|
||||
wts.iter().any(|w| {
|
||||
w.path == path
|
||||
&& w.reason
|
||||
== maestro_protocol::RetainedReason::ExitedWithUnsavedWork
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
wait_for_async(
|
||||
"pristine exit removes its worktree entirely",
|
||||
Duration::from_secs(20),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
let repo = pristine_repo.path().to_path_buf();
|
||||
let path = pristine_wt.clone();
|
||||
async move {
|
||||
if path.exists() {
|
||||
return false;
|
||||
}
|
||||
let listed = client.list_worktrees().await.ok();
|
||||
let listed_clear = listed
|
||||
.map(|wts| !wts.iter().any(|w| w.path == path))
|
||||
.unwrap_or(false);
|
||||
let registered = git_worktree_list(&repo);
|
||||
!registered.iter().any(|l| l.contains("feature/tidy")) && listed_clear
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// The dirty tree is still fully there, file included.
|
||||
assert!(dirty_wt.is_dir());
|
||||
assert_eq!(
|
||||
std::fs::read(dirty_wt.join("scratch.txt")).unwrap(),
|
||||
b"unsaved\n"
|
||||
);
|
||||
}
|
||||
|
||||
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
|
||||
CreateSessionRequest {
|
||||
cwd: cwd.to_path_buf(),
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: Some(worktree_spec(cwd, branch, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// #80/#88: `GET /api/worktrees` lists what the daemon is holding, with the
|
||||
/// reason and a live in-the-way breakdown; reclaim removes the directory and
|
||||
/// the store entry but NEVER the branch — its commits outlive the reclaim.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn worktrees_endpoint_lists_reasons_and_reclaim_keeps_the_branch() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let _origin = throwaway_origin(base.path());
|
||||
|
||||
let wt = base.path().join("worktrees").join("feature").join("parked");
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/parked", true))
|
||||
.await
|
||||
.expect("spawn");
|
||||
std::fs::write(wt.join("README"), b"worth keeping\n").unwrap();
|
||||
commit_all(&wt, "worth keeping");
|
||||
let sha = branch_sha(&wt, "feature/parked");
|
||||
|
||||
let outcome = client.kill_session_forced(s.id).await.expect("force kill");
|
||||
assert!(outcome.retained.is_some());
|
||||
|
||||
let listed = client.list_worktrees().await.expect("list worktrees");
|
||||
assert_eq!(listed.len(), 1, "exactly one retained entry: {listed:?}");
|
||||
let entry = &listed[0];
|
||||
assert_eq!(entry.path, wt);
|
||||
assert_eq!(entry.branch, "feature/parked");
|
||||
assert_eq!(entry.reason, maestro_protocol::RetainedReason::ForceKilled);
|
||||
assert_eq!(entry.in_the_way.unpushed_commits, 1);
|
||||
|
||||
client.reclaim_worktree(entry.id).await.expect("reclaim");
|
||||
assert!(!wt.exists(), "reclaim removes the directory");
|
||||
assert!(
|
||||
client.list_worktrees().await.unwrap().is_empty(),
|
||||
"reclaim drops the store entry"
|
||||
);
|
||||
|
||||
// The whole point of reclaim: the branch and its commits survive.
|
||||
assert_eq!(
|
||||
branch_sha(base.path(), "feature/parked"),
|
||||
sha,
|
||||
"the branch must keep pointing at the same commit after reclaim"
|
||||
);
|
||||
}
|
||||
|
||||
/// The retained store is state-dir backed (#80): a daemon restart must not
|
||||
/// orphan the trees it is holding — they are still on disk with entries
|
||||
/// pointing at them, and still reclaimable afterwards.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn retained_store_survives_a_daemon_restart() {
|
||||
let (mut daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
let _origin = throwaway_origin(base.path());
|
||||
|
||||
let wt = base
|
||||
.path()
|
||||
.join("worktrees")
|
||||
.join("feature")
|
||||
.join("durable");
|
||||
let s = client
|
||||
.spawn_session(&worktree_req(base.path(), "feature/durable", true))
|
||||
.await
|
||||
.expect("spawn");
|
||||
std::fs::write(wt.join("README"), b"survives restarts\n").unwrap();
|
||||
commit_all(&wt, "survives restarts");
|
||||
client.kill_session_forced(s.id).await.expect("force kill");
|
||||
|
||||
daemon.restart().await;
|
||||
|
||||
let listed = client.list_worktrees().await.expect("list after restart");
|
||||
assert_eq!(listed.len(), 1, "store must survive restart: {listed:?}");
|
||||
assert_eq!(listed[0].path, wt);
|
||||
assert_eq!(
|
||||
listed[0].reason,
|
||||
maestro_protocol::RetainedReason::ForceKilled
|
||||
);
|
||||
assert!(wt.is_dir(), "the tree itself must survive the restart too");
|
||||
|
||||
client
|
||||
.reclaim_worktree(listed[0].id)
|
||||
.await
|
||||
.expect("reclaim");
|
||||
assert!(!wt.exists());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/projects + /api/branches (#54/#55/#66)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// #66: two repos under a configured `[spawn].project_roots` root are both
|
||||
/// discovered by `GET /api/projects`; git activity gives every repo a
|
||||
/// `last_activity`, and spawning inside one merges its command into the
|
||||
/// project entry via spawn history.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn projects_endpoint_discovers_roots_and_merges_activity() {
|
||||
let root = TempDir::new().unwrap();
|
||||
let alpha = make_repo(root.path(), "alpha");
|
||||
make_repo(root.path(), "beta");
|
||||
|
||||
let daemon = Daemon::spawn_with_config(&format!(
|
||||
"[spawn]\nproject_roots = [\"{}\"]\nproject_scan_depth = 2\n",
|
||||
root.path().display()
|
||||
))
|
||||
.await;
|
||||
let client = TestClient::new(&daemon.http_base());
|
||||
|
||||
let projects = client.list_projects().await.expect("list projects");
|
||||
let names: Vec<&str> = projects.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(
|
||||
names.contains(&"alpha") && names.contains(&"beta"),
|
||||
"both repos must be discovered, got: {names:?}"
|
||||
);
|
||||
|
||||
let find = |name: &str| {
|
||||
projects
|
||||
.iter()
|
||||
.find(|p| p.name == name)
|
||||
.cloned()
|
||||
.expect("project present")
|
||||
};
|
||||
// Both have commits, so git activity alone populates last_activity…
|
||||
assert!(
|
||||
find("alpha").last_activity.is_some(),
|
||||
"git activity must surface"
|
||||
);
|
||||
assert!(
|
||||
find("beta").last_activity.is_some(),
|
||||
"git activity must surface"
|
||||
);
|
||||
// …and neither has been spawned into yet.
|
||||
assert_eq!(find("alpha").last_command, None);
|
||||
assert_eq!(find("beta").last_command, None);
|
||||
|
||||
// Spawning inside alpha writes durable history; /api/projects folds it in.
|
||||
let s = client
|
||||
.spawn_session(&create_req(&alpha, "sleep 30"))
|
||||
.await
|
||||
.expect("spawn in alpha");
|
||||
|
||||
wait_for_async(
|
||||
"alpha's project entry reflects spawn history",
|
||||
Duration::from_secs(5),
|
||||
|| {
|
||||
let client = client.clone();
|
||||
async move {
|
||||
client
|
||||
.list_projects()
|
||||
.await
|
||||
.ok()
|
||||
.map(|ps| {
|
||||
ps.iter()
|
||||
.find(|p| p.name == "alpha")
|
||||
.map(|p| p.last_command.as_deref() == Some("sleep 30"))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
// beta was never spawned into: no command recorded there.
|
||||
let beta_after = client
|
||||
.list_projects()
|
||||
.await
|
||||
.expect("list after spawn")
|
||||
.into_iter()
|
||||
.find(|p| p.name == "beta")
|
||||
.unwrap();
|
||||
assert_eq!(beta_after.last_command, None);
|
||||
|
||||
client.kill_session(s.id).await.expect("kill");
|
||||
}
|
||||
|
||||
/// #66: `GET /api/branches` lists every local branch, flags the main
|
||||
/// checkout's HEAD with `is_head`, and reports which linked worktree holds an
|
||||
/// occupied branch.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn branches_endpoint_lists_branches_and_flags_occupied() {
|
||||
let (_daemon, client) = bring_up().await;
|
||||
|
||||
let base = TempDir::new().unwrap();
|
||||
init_repo(base.path());
|
||||
run_git(&["branch", "feature/taken"], base.path());
|
||||
run_git(&["branch", "feature/free"], base.path());
|
||||
let holder = base.path().join("holder-wt");
|
||||
run_git(
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
&holder.to_string_lossy(),
|
||||
"feature/taken",
|
||||
],
|
||||
base.path(),
|
||||
);
|
||||
|
||||
let branches = client
|
||||
.list_branches(&base.path().to_string_lossy())
|
||||
.await
|
||||
.expect("list branches");
|
||||
let by_name = |name: &str| {
|
||||
branches
|
||||
.iter()
|
||||
.find(|b| b.name == name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| panic!("branch {name} missing from {branches:?}"))
|
||||
};
|
||||
|
||||
let main = by_name("main");
|
||||
assert!(main.is_head, "main is the main checkout's HEAD");
|
||||
assert_eq!(main.worktree, None);
|
||||
|
||||
let taken = by_name("feature/taken");
|
||||
assert!(!taken.is_head);
|
||||
assert_eq!(
|
||||
taken.worktree.as_deref(),
|
||||
Some(holder.canonicalize().unwrap().as_path()),
|
||||
"occupied branch must report its holding worktree"
|
||||
);
|
||||
|
||||
let free = by_name("feature/free");
|
||||
assert!(!free.is_head);
|
||||
assert_eq!(free.worktree, None, "unoccupied branch must be spawnable");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn daemon_restart_recovers_session() {
|
||||
let (mut daemon, client) = bring_up().await;
|
||||
@@ -428,6 +1214,15 @@ fn init_repo(dir: &std::path::Path) {
|
||||
run_git(&["commit", "-q", "-m", "initial"], dir);
|
||||
}
|
||||
|
||||
/// Real git repo at `<root>/<name>`, canonicalized (the scanner canonicalizes
|
||||
/// discovered paths too, so comparisons must agree on the real path).
|
||||
fn make_repo(root: &std::path::Path, name: &str) -> std::path::PathBuf {
|
||||
let dir = root.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
init_repo(&dir);
|
||||
dir.canonicalize().unwrap()
|
||||
}
|
||||
|
||||
fn run_git(args: &[&str], cwd: &std::path::Path) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -455,6 +1250,51 @@ fn git_worktree_list(dir: &std::path::Path) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Bare remote registered as `origin` on `repo`, seeded with every current
|
||||
/// branch, so existing commits are reachable from remote-tracking refs.
|
||||
/// Without one the kill guard (#79) reads every commit as unpushed, and any
|
||||
/// worktree session refuses to die. The caller must keep the `TempDir` alive
|
||||
/// while the remote is still needed; later pushes go through [`push_all`].
|
||||
fn throwaway_origin(repo: &std::path::Path) -> TempDir {
|
||||
let origin = tempfile::TempDir::new().unwrap();
|
||||
run_git(
|
||||
&["init", "-q", "--bare", origin.path().to_str().unwrap()],
|
||||
repo,
|
||||
);
|
||||
let _ = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["remote", "add", "origin", origin.path().to_str().unwrap()])
|
||||
.output()
|
||||
.expect("git remote add");
|
||||
push_all(repo);
|
||||
origin
|
||||
}
|
||||
|
||||
fn push_all(repo: &std::path::Path) {
|
||||
run_git(&["push", "-q", "--all", "origin"], repo);
|
||||
}
|
||||
|
||||
fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
run_git(&["add", "-A"], dir);
|
||||
run_git(&["commit", "-q", "-m", message], dir);
|
||||
}
|
||||
|
||||
fn branch_sha(repo: &std::path::Path, branch: &str) -> String {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(repo)
|
||||
.args(["rev-parse", "--verify", &format!("refs/heads/{branch}")])
|
||||
.output()
|
||||
.expect("git rev-parse");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"branch {branch} missing: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn daemon_events_ws_streams_snapshot_then_session_added() {
|
||||
// NAP-248: subscribe to the daemon's WS /api/events, expect an initial
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "maestro-hooks"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Agent activity hooks (#106): shipped script + idempotent settings.json installer"
|
||||
|
||||
[dependencies]
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Claude Code activity hook (#106).
|
||||
//!
|
||||
//! Two halves, both shared by the `maestro hooks install` CLI subcommand and
|
||||
//! the daemon's first-start auto-install:
|
||||
//!
|
||||
//! * [`HOOK_SCRIPT`] — the hook itself, written to disk at install time. It
|
||||
//! posts an [`ActivityState`](maestro-protocol) to
|
||||
//! `$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity` (#105). The same
|
||||
//! global hook runs in every Claude Code session on the machine, so it exits
|
||||
//! 0 and silently when either #103 env var is unset, and it never lets a
|
||||
//! daemon failure escape (short curl timeout, status ignored).
|
||||
//! * [`install`] — idempotent registration of that script in
|
||||
//! `~/.claude/settings.json`. Only entries maestro itself wrote (identified
|
||||
//! by [`HOOK_MARKER`]) are touched; every other hook and every other field
|
||||
//! in the file is preserved verbatim.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Marker substring every maestro-installed hook command carries. The
|
||||
/// installer rewrites or removes exactly these entries — nothing else in the
|
||||
/// user's settings file.
|
||||
pub const HOOK_MARKER: &str = "maestro-claude-hook";
|
||||
|
||||
/// File name of the installed script (carries [`HOOK_MARKER`]).
|
||||
pub const SCRIPT_FILE_NAME: &str = "maestro-claude-hook.sh";
|
||||
|
||||
/// Claude Code hook event → `ActivityState` spelling posted to the daemon.
|
||||
///
|
||||
/// `PreToolUse` is what makes a session `Active` *during* a long tool call —
|
||||
/// `PostToolUse` alone only fires once it ends (#114). `StopFailure` is
|
||||
/// Claude Code's own name for "the turn ended on an API error"; there is no
|
||||
/// failure signal in `Stop` or `SessionEnd` payloads, so those never report
|
||||
/// `Error`.
|
||||
pub const HOOK_EVENTS: [(&str, &str); 5] = [
|
||||
// Tool starting: the agent is demonstrably producing work, even if the
|
||||
// tool itself runs for minutes.
|
||||
("PreToolUse", "Active"),
|
||||
// Tool finished successfully.
|
||||
("PostToolUse", "Active"),
|
||||
// Permission / idle notices: Claude is blocked on the user.
|
||||
("Notification", "AwaitingInput"),
|
||||
// Turn ended: waiting on the user again.
|
||||
("Stop", "AwaitingInput"),
|
||||
// Turn ended on an API error; the payload's `error` field is forwarded
|
||||
// as the report's `detail` (#114).
|
||||
("StopFailure", "Error"),
|
||||
];
|
||||
|
||||
/// The shipped hook script. POSIX sh so any `/bin/sh` works.
|
||||
pub const HOOK_SCRIPT: &str = r#"#!/bin/sh
|
||||
# Installed by maestro (#106): report Claude Code activity to its daemon.
|
||||
# Exits silently unless running inside a session maestro spawned (#103 env).
|
||||
# Event -> ActivityState mapping:
|
||||
# PreToolUse -> Active (tool started; agent is working)
|
||||
# PostToolUse -> Active (tool finished)
|
||||
# Notification -> AwaitingInput (permission / idle notice)
|
||||
# Stop -> AwaitingInput (turn ended; waiting on the user)
|
||||
# StopFailure -> Error (turn ended on an API error)
|
||||
state="$1"
|
||||
[ -n "$MAESTRO_SESSION_ID" ] || exit 0
|
||||
[ -n "$MAESTRO_URL" ] || exit 0
|
||||
payload=$(cat 2>/dev/null) || true
|
||||
body="{\"state\":\"$state\"}"
|
||||
# StopFailure payloads carry the outcome in "error". Only a plain enum
|
||||
# spelling is reported as the detail — anything else (quotes, backslashes,
|
||||
# prose) would risk breaking the JSON body, and the spelling already names
|
||||
# the failure.
|
||||
detail=$(printf '%s' "$payload" | sed -n 's/.*"error"[[:space:]]*:[[:space:]]*"\([a-z_]*\)".*/\1/p')
|
||||
if [ -n "$detail" ]; then
|
||||
body="{\"state\":\"$state\",\"detail\":\"$detail\"}"
|
||||
fi
|
||||
curl -fsS --connect-timeout 1 --max-time 2 \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$body" \
|
||||
"$MAESTRO_URL/api/sessions/$MAESTRO_SESSION_ID/activity" \
|
||||
>/dev/null 2>&1 || true
|
||||
exit 0
|
||||
"#;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HooksError {
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Where the script is written for a given home directory:
|
||||
/// `<home>/.local/share/maestro/maestro-claude-hook.sh`.
|
||||
pub fn script_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".local/share/maestro").join(SCRIPT_FILE_NAME)
|
||||
}
|
||||
|
||||
/// The user-level Claude Code settings file for a given home directory.
|
||||
pub fn settings_path(home: &Path) -> std::path::PathBuf {
|
||||
home.join(".claude/settings.json")
|
||||
}
|
||||
|
||||
/// Whether `settings_path` already carries a maestro hook (#106).
|
||||
pub fn is_installed(settings_path: &Path) -> bool {
|
||||
settings_json(settings_path)
|
||||
.map(|root| find_maestro_commands(&root).next().is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Write [`HOOK_SCRIPT`] to `path`, executable. Parent directories are
|
||||
/// created as needed — this is always under the caller's chosen data dir,
|
||||
/// never inside a repository.
|
||||
pub fn write_script(path: &Path) -> Result<(), HooksError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, HOOK_SCRIPT)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install the full hook setup for `home`: write the script under
|
||||
/// `.local/share/maestro/` and register it in `.claude/settings.json`.
|
||||
/// Idempotent; returns whether anything changed on disk.
|
||||
pub fn install_for_home(home: &Path) -> Result<bool, HooksError> {
|
||||
let script = script_path(home);
|
||||
let settings = settings_path(home);
|
||||
let before = std::fs::read_to_string(&settings).unwrap_or_default();
|
||||
write_script(&script)?;
|
||||
install(&settings, &script)?;
|
||||
let after = std::fs::read_to_string(&settings).unwrap_or_default();
|
||||
Ok(before != after)
|
||||
}
|
||||
|
||||
/// Register the hook command for every event in [`HOOK_EVENTS`] in the
|
||||
/// settings file at `settings_path`. Idempotent: existing maestro entries are
|
||||
/// updated in place wherever they sit, stale ones removed, duplicates folded;
|
||||
/// every non-maestro entry and every unrelated top-level field survives.
|
||||
///
|
||||
/// Never inspects or writes anywhere but `settings_path` — callers pass the
|
||||
/// user's real home or a temp one in tests, never a repository.
|
||||
pub fn install(settings_path: &Path, script_path: &Path) -> Result<(), HooksError> {
|
||||
let mut root = settings_json(settings_path)?;
|
||||
|
||||
// Strip every maestro entry wherever it currently sits, so an older
|
||||
// install under different events is replaced rather than duplicated.
|
||||
strip_maestro_entries(&mut root);
|
||||
|
||||
let hooks = ensure_object(&mut root, "hooks");
|
||||
for (event, state) in HOOK_EVENTS {
|
||||
let groups = ensure_array(hooks, event);
|
||||
let mut group = serde_json::Map::new();
|
||||
group.insert(
|
||||
"hooks".into(),
|
||||
serde_json::json!([{
|
||||
"type": "command",
|
||||
"command": format!("{} {state}", script_path.display()),
|
||||
}]),
|
||||
);
|
||||
groups.push(serde_json::Value::Object(group));
|
||||
}
|
||||
|
||||
write_atomic(settings_path, &root)
|
||||
}
|
||||
|
||||
fn settings_json(path: &Path) -> Result<serde_json::Value, HooksError> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(s) => Ok(serde_json::from_str(&s)?),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
Ok(serde_json::Value::Object(Default::default()))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every command string in the file belonging to a maestro hook.
|
||||
fn find_maestro_commands(root: &serde_json::Value) -> impl Iterator<Item = &str> {
|
||||
root.get("hooks")
|
||||
.and_then(|h| h.as_object())
|
||||
.into_iter()
|
||||
.flat_map(|events| events.values())
|
||||
.filter_map(|groups| groups.as_array())
|
||||
.flatten()
|
||||
.filter_map(|group| group.get("hooks"))
|
||||
.filter_map(|inner| inner.as_array())
|
||||
.flatten()
|
||||
.filter_map(|hook| hook.get("command"))
|
||||
.filter_map(|cmd| cmd.as_str())
|
||||
.filter(|cmd| cmd.contains(HOOK_MARKER))
|
||||
}
|
||||
|
||||
/// Remove all maestro inner-hook entries from every event group, dropping
|
||||
/// groups left empty. Non-maestro siblings survive untouched.
|
||||
fn strip_maestro_entries(root: &mut serde_json::Value) {
|
||||
let Some(events) = root.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
|
||||
return;
|
||||
};
|
||||
for (_, groups) in events.iter_mut() {
|
||||
let Some(list) = groups.as_array_mut() else {
|
||||
continue;
|
||||
};
|
||||
for group in list.iter_mut() {
|
||||
let Some(inner) = group.get_mut("hooks").and_then(|h| h.as_array_mut()) else {
|
||||
continue;
|
||||
};
|
||||
inner.retain(|hook| {
|
||||
!hook
|
||||
.get("command")
|
||||
.and_then(|c| c.as_str())
|
||||
.is_some_and(|c| c.contains(HOOK_MARKER))
|
||||
});
|
||||
}
|
||||
list.retain(|group| {
|
||||
group
|
||||
.get("hooks")
|
||||
.and_then(|h| h.as_array())
|
||||
.is_some_and(|h| !h.is_empty())
|
||||
});
|
||||
}
|
||||
// An event left with no groups at all (ours emptied it, or the user's
|
||||
// file carried an empty list) reads better gone.
|
||||
events.retain(|_, groups| groups.as_array().is_some_and(|g| !g.is_empty()));
|
||||
}
|
||||
|
||||
fn ensure_object<'a>(
|
||||
root: &'a mut serde_json::Value,
|
||||
key: &str,
|
||||
) -> &'a mut serde_json::Map<String, serde_json::Value> {
|
||||
if !root.get(key).map(|v| v.is_object()).unwrap_or(false) {
|
||||
root[key] = serde_json::json!({});
|
||||
}
|
||||
root.get_mut(key).unwrap().as_object_mut().unwrap()
|
||||
}
|
||||
|
||||
fn ensure_array<'a>(
|
||||
parent: &'a mut serde_json::Map<String, serde_json::Value>,
|
||||
key: &str,
|
||||
) -> &'a mut Vec<serde_json::Value> {
|
||||
let v = parent
|
||||
.entry(key.to_string())
|
||||
.or_insert_with(|| serde_json::json!([]));
|
||||
if !v.is_array() {
|
||||
*v = serde_json::json!([]);
|
||||
}
|
||||
v.as_array_mut().unwrap()
|
||||
}
|
||||
|
||||
fn write_atomic(path: &Path, value: &serde_json::Value) -> Result<(), HooksError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let tmp = path.with_extension("json.maestro-tmp");
|
||||
std::fs::write(&tmp, serde_json::to_string_pretty(value)? + "\n")?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Tests for the shipped hook script (run as a real `/bin/sh` subprocess)
|
||||
//! and the settings-file installer. Everything happens under a temp `$HOME`;
|
||||
//! the developer's own `~/.claude/settings.json` is never touched.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sh(script: &str, args: &[&str], envs: &[(&str, &str)], payload: &[u8]) -> std::process::Output {
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
// `sh -c CMD NAME ARGS…`: NAME fills $0, so the real arguments start at $1.
|
||||
let mut argv = vec!["-c", script, HOOK_MARKER];
|
||||
argv.extend(args.iter().copied());
|
||||
cmd.args(&argv);
|
||||
// A clean env: only what the test passes, plus PATH so curl can spawn.
|
||||
cmd.env_clear();
|
||||
cmd.env("PATH", std::env::var("PATH").unwrap_or_default());
|
||||
for (k, v) in envs {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn /bin/sh");
|
||||
// Feed a Claude-Code-style payload on stdin; the script must drain it.
|
||||
child.stdin.take().unwrap().write_all(payload).ok();
|
||||
child.wait_with_output().expect("wait")
|
||||
}
|
||||
|
||||
const SESSION: &str = "01234567-89ab-cdef-0123-456789abcdef";
|
||||
|
||||
/// Accept exactly one HTTP request on a throwaway listener, answer 204, and
|
||||
/// hand back the raw request text. Runs `run` with the base URL once the
|
||||
/// acceptor thread is in place.
|
||||
fn capture_request(run: impl FnOnce(String) -> std::process::Output) -> String {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = std::thread::spawn(move || {
|
||||
let (mut sock, _) = listener.accept().unwrap();
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = sock.read(&mut buf).unwrap();
|
||||
sock.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n")
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&buf[..n]).to_string()
|
||||
});
|
||||
let out = run(format!("http://{addr}"));
|
||||
assert!(out.status.success(), "{:?}", out.stderr);
|
||||
handle.join().unwrap()
|
||||
}
|
||||
|
||||
fn session_envs(url: &str) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("MAESTRO_URL", url.to_string()),
|
||||
("MAESTRO_SESSION_ID", SESSION.to_string()),
|
||||
]
|
||||
}
|
||||
|
||||
fn owned_envs<'a>(envs: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
|
||||
envs.iter().map(|(k, v)| (*k, v.as_str())).collect()
|
||||
}
|
||||
|
||||
/// The hook must be invisible outside maestro: no env vars (#103 unset) ⇒
|
||||
/// exit 0, no output, stdin drained.
|
||||
#[test]
|
||||
fn script_exits_silently_without_maestro_env() {
|
||||
for (event, _) in HOOK_EVENTS {
|
||||
let out = sh(HOOK_SCRIPT, &[event], &[], br#"{"session_id":"x"}"#);
|
||||
assert!(out.status.success(), "{event:?}: {:?}", out.stderr);
|
||||
assert!(out.stdout.is_empty(), "{event:?}: {:?}", out.stdout);
|
||||
assert!(out.stderr.is_empty(), "{event:?}: {:?}", out.stderr);
|
||||
}
|
||||
}
|
||||
|
||||
/// With both env vars set the script POSTs the mapped state to the daemon's
|
||||
/// activity endpoint and still exits 0.
|
||||
#[test]
|
||||
fn script_posts_mapped_state_to_the_activity_endpoint() {
|
||||
// The installer bakes the mapped state into the command's argv; the
|
||||
// script relays whatever it is given.
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Active"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"x"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.starts_with("POST "), "{request}");
|
||||
assert!(
|
||||
request.contains(&format!("/api/sessions/{SESSION}/activity")),
|
||||
"{request}"
|
||||
);
|
||||
assert!(request.contains(r#""state":"Active""#), "{request}");
|
||||
}
|
||||
|
||||
/// Every event maps to the state the contract specifies.
|
||||
#[test]
|
||||
fn event_state_mapping_matches_the_contract() {
|
||||
let map: std::collections::HashMap<&str, &str> = HOOK_EVENTS.into_iter().collect();
|
||||
assert_eq!(map["PreToolUse"], "Active");
|
||||
assert_eq!(map["PostToolUse"], "Active");
|
||||
assert_eq!(map["Notification"], "AwaitingInput");
|
||||
assert_eq!(map["Stop"], "AwaitingInput");
|
||||
assert_eq!(map["StopFailure"], "Error");
|
||||
}
|
||||
|
||||
/// A `StopFailure` payload names the API error that ended the turn; the
|
||||
/// script reports its `error` spelling as the report's `detail` so a card
|
||||
/// can show why (#114).
|
||||
#[test]
|
||||
fn script_reports_the_error_spelling_as_detail() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"session_id":"abc","hook_event_name":"StopFailure","error":"rate_limit","error_details":"429 Too Many Requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(request.contains(r#""detail":"rate_limit""#), "{request}");
|
||||
}
|
||||
|
||||
/// An `error` value that is not a plain enum spelling (or a payload without
|
||||
/// one at all) must never produce a broken body — no detail is posted.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_an_unexpected_error_value() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Error"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"StopFailure","error":"429 \"Too Many\" \\ requests"}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"Error""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// Events whose payload carries no error fields post a bare body — never an
|
||||
/// empty or stale detail.
|
||||
#[test]
|
||||
fn script_posts_no_detail_for_events_without_an_error() {
|
||||
let request = capture_request(|url| {
|
||||
sh(
|
||||
HOOK_SCRIPT,
|
||||
&["AwaitingInput"],
|
||||
&owned_envs(&session_envs(&url)),
|
||||
br#"{"hook_event_name":"Stop","stop_hook_active":false}"#,
|
||||
)
|
||||
});
|
||||
assert!(request.contains(r#""state":"AwaitingInput""#), "{request}");
|
||||
assert!(!request.contains("detail"), "{request}");
|
||||
}
|
||||
|
||||
/// A dead or unreachable daemon must never fail — or slow down — a session.
|
||||
#[test]
|
||||
fn script_ignores_daemon_failure() {
|
||||
// Port 1 on localhost is closed in practice; curl fails fast.
|
||||
let out = sh(
|
||||
HOOK_SCRIPT,
|
||||
&["Stop"],
|
||||
&[
|
||||
("MAESTRO_URL", "http://127.0.0.1:1"),
|
||||
("MAESTRO_SESSION_ID", "00000000-0000-0000-0000-000000000000"),
|
||||
],
|
||||
br#"{"session_id":"x"}"#,
|
||||
);
|
||||
assert!(out.status.success());
|
||||
}
|
||||
|
||||
// --- installer -------------------------------------------------------------
|
||||
|
||||
fn installed_commands(settings: &Path) -> Vec<String> {
|
||||
serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(settings).unwrap())
|
||||
.unwrap()
|
||||
.get("hooks")
|
||||
.and_then(|h| h.as_object())
|
||||
.into_iter()
|
||||
.flat_map(|events| events.values())
|
||||
.filter_map(|g| g.as_array())
|
||||
.flatten()
|
||||
.filter_map(|g| g.get("hooks"))
|
||||
.filter_map(|h| h.as_array())
|
||||
.flatten()
|
||||
.filter_map(|h| h.get("command"))
|
||||
.filter_map(|c| c.as_str())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fresh install registers exactly one command per event, all pointing at the
|
||||
/// script with the right state argument.
|
||||
#[test]
|
||||
fn fresh_install_registers_every_event() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
write_script(&script_path(home)).unwrap();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings_path(home));
|
||||
assert_eq!(cmds.len(), HOOK_EVENTS.len());
|
||||
let joined = cmds.join("\n");
|
||||
assert_eq!(joined.matches("Active").count(), 2);
|
||||
assert!(joined.contains("Error"));
|
||||
assert_eq!(joined.matches("AwaitingInput").count(), 2);
|
||||
for cmd in &cmds {
|
||||
assert!(cmd.contains(SCRIPT_FILE_NAME));
|
||||
}
|
||||
}
|
||||
|
||||
/// Running install twice changes nothing further — no duplicates.
|
||||
#[test]
|
||||
fn install_is_idempotent() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
let once = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
|
||||
install(&settings_path(home), &script_path(home)).unwrap();
|
||||
let twice = std::fs::read_to_string(settings_path(home)).unwrap();
|
||||
assert_eq!(once, twice);
|
||||
assert_eq!(
|
||||
installed_commands(&settings_path(home)).len(),
|
||||
HOOK_EVENTS.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// An older maestro entry under the same events is updated in place, not
|
||||
/// duplicated next to the new one.
|
||||
#[test]
|
||||
fn install_updates_an_older_maestro_hook_in_place() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
|
||||
let old_script = "/old/location/maestro-claude-hook.sh Active";
|
||||
std::fs::write(
|
||||
&settings,
|
||||
serde_json::json!({
|
||||
"model": "sonnet",
|
||||
"hooks": {
|
||||
"Stop": [{"hooks": [{"type": "command", "command": old_script}]}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_script = dir
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.len(),
|
||||
HOOK_EVENTS.len(),
|
||||
"old entry replaced, not kept: {cmds:?}"
|
||||
);
|
||||
assert!(!cmds.iter().any(|c| c == old_script));
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
assert_eq!(root["model"], "sonnet", "unrelated fields survive");
|
||||
}
|
||||
|
||||
/// A stale maestro entry under an event we no longer use is cleaned up too;
|
||||
/// the user's own hooks are never touched.
|
||||
#[test]
|
||||
fn install_preserves_foreign_hooks_and_removes_stale_maestro_ones() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = dir.path().join(".claude/settings.json");
|
||||
std::fs::create_dir_all(settings.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&settings,
|
||||
serde_json::json!({
|
||||
"hooks": {
|
||||
"PreToolUse": [{"matcher": "Bash", "hooks": [
|
||||
{"type": "command", "command": "my-own-linter.sh"},
|
||||
]}],
|
||||
"UserPromptSubmit": [{"hooks": [
|
||||
{"type": "command", "command": "/old/maestro-claude-hook.sh Active"},
|
||||
]}],
|
||||
},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let new_script = dir
|
||||
.path()
|
||||
.join(".local/share/maestro")
|
||||
.join(SCRIPT_FILE_NAME);
|
||||
install(&settings, &new_script).unwrap();
|
||||
|
||||
let root: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
|
||||
// Foreign hook untouched.
|
||||
let pre = root["hooks"]["PreToolUse"].as_array().unwrap();
|
||||
assert_eq!(pre[0]["hooks"][0]["command"], "my-own-linter.sh");
|
||||
// Stale maestro event gone.
|
||||
assert!(root["hooks"].get("UserPromptSubmit").is_none());
|
||||
// Our events present, nothing else maestro-related anywhere.
|
||||
let cmds = installed_commands(&settings);
|
||||
assert_eq!(
|
||||
cmds.iter().filter(|c| c.contains(HOOK_MARKER)).count(),
|
||||
HOOK_EVENTS.len(),
|
||||
"{cmds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// `is_installed` flips only once a maestro entry exists.
|
||||
#[test]
|
||||
fn is_installed_tracks_reality() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
assert!(!is_installed(&settings_path(home)));
|
||||
install_for_home(home).unwrap();
|
||||
assert!(is_installed(&settings_path(home)));
|
||||
}
|
||||
|
||||
/// The full home-level install leaves the script executable.
|
||||
#[test]
|
||||
fn install_for_home_writes_an_executable_script() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let home = dir.path();
|
||||
install_for_home(home).unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(script_path(home))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o111, 0o111);
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ use uuid::Uuid;
|
||||
|
||||
use crate::routes::{self, UrlError};
|
||||
use crate::{
|
||||
ApiError, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot, Project,
|
||||
RecentDirs, Session,
|
||||
ApiError, BranchInfo, CreateSessionRequest, DaemonInfo, DirListing, ErrorInfo, GitRoot,
|
||||
KillOutcome, Project, RecentDirs, ReportActivityRequest, RetainedWorktree, Session,
|
||||
};
|
||||
|
||||
/// Time budget for establishing a TCP connection to the daemon.
|
||||
@@ -114,9 +114,36 @@ impl DaemonClient {
|
||||
self.post(routes::SESSIONS, req).await
|
||||
}
|
||||
|
||||
pub async fn kill_session(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
/// Kill a session. With `force`, the daemon overrides the kill refusal
|
||||
/// (#79) and retains the worktree instead of removing it (#81); the
|
||||
/// outcome then names what was kept and where.
|
||||
pub async fn kill_session(&self, id: Uuid, force: bool) -> Result<KillOutcome, ClientError> {
|
||||
let query: &[(&str, &str)] = if force {
|
||||
&[(routes::FORCE_QUERY, "true")]
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let resp = self
|
||||
.send(Method::DELETE, &routes::session(id), &[], None::<&()>)
|
||||
.send(Method::DELETE, &routes::session(id), query, None::<&()>)
|
||||
.await?;
|
||||
let resp = check(resp).await?;
|
||||
// An unforced kill keeps its historical `204 No Content`.
|
||||
if resp.status() == reqwest::StatusCode::NO_CONTENT {
|
||||
return Ok(KillOutcome { retained: None });
|
||||
}
|
||||
json(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): report an agent-observed
|
||||
/// state. The daemon holds it above its own inference for a TTL, then
|
||||
/// resumes inference. Answers `204 No Content`.
|
||||
pub async fn report_activity(
|
||||
&self,
|
||||
id: Uuid,
|
||||
req: &ReportActivityRequest,
|
||||
) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(Method::POST, &routes::session_activity(id), &[], Some(req))
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
@@ -143,6 +170,28 @@ impl DaemonClient {
|
||||
self.get(routes::PROJECTS, &[]).await
|
||||
}
|
||||
|
||||
pub async fn list_branches(&self, path: &str) -> Result<Vec<BranchInfo>, ClientError> {
|
||||
self.get(routes::BRANCHES, &[(routes::PATH_QUERY, path)])
|
||||
.await
|
||||
}
|
||||
|
||||
/// `GET /api/worktrees` (#80): every retained worktree with its in-the-way
|
||||
/// breakdown as of this read.
|
||||
pub async fn list_worktrees(&self) -> Result<Vec<RetainedWorktree>, ClientError> {
|
||||
self.get(routes::WORKTREES, &[]).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` (#86): reclaim one retained worktree. The
|
||||
/// daemon removes the directory and drops the store entry; the branch is
|
||||
/// never deleted.
|
||||
pub async fn reclaim_worktree(&self, id: Uuid) -> Result<(), ClientError> {
|
||||
let resp = self
|
||||
.send(Method::DELETE, &routes::worktree(id), &[], None::<&()>)
|
||||
.await?;
|
||||
check(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -- plumbing -----------------------------------------------------------
|
||||
|
||||
async fn get<T: DeserializeOwned>(
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Path-abbreviation helpers shared by every client that renders a session's
|
||||
//! directory (CLI, TUI, web (#117)). Ports of `web/src/types.ts` and
|
||||
//! `web/src/tree.ts`'s `abbreviateCwd`, `abbreviateFolderName`, `mergeLinear`,
|
||||
//! and `sessionTreePath` — keep them in sync.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Collapse a daemon's home dir to `~`. `home` empty leaves the path
|
||||
/// untouched. Mirrors the web's `abbreviateCwd`.
|
||||
pub fn abbreviate_dir(path: &Path, home: &str) -> String {
|
||||
let p = path.to_string_lossy();
|
||||
if !home.is_empty() {
|
||||
if p == home {
|
||||
return "~".into();
|
||||
}
|
||||
if let Some(rest) = p.strip_prefix(&format!("{home}/")) {
|
||||
return format!("~/{rest}");
|
||||
}
|
||||
}
|
||||
p.into_owned()
|
||||
}
|
||||
|
||||
/// Result of [`abbreviate_folder_name`]: the last path segment kept verbatim,
|
||||
/// and — when there was something to collapse — the shortened lead-in a
|
||||
/// caller can render dimmed (the web's `.folder-stem`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AbbreviatedFolderName {
|
||||
/// The collapsed earlier segments, joined by `/`, with no trailing
|
||||
/// slash. `None` when `name` had two or fewer segments — nothing to
|
||||
/// gain from collapsing, so `last` carries the name unabbreviated.
|
||||
pub stem: Option<String>,
|
||||
pub last: String,
|
||||
}
|
||||
|
||||
/// `~/git.naps.pt/naps62/maestro` → stem `~/g/n`, last `maestro`: keep the
|
||||
/// last segment verbatim, collapse every earlier one to its first character.
|
||||
/// Two-or-fewer segments are left alone. Mirrors the web's
|
||||
/// `abbreviateFolderName`, minus the HTML escaping/wrapping — a ratatui
|
||||
/// caller styles `stem` dim itself rather than parsing markup out of it.
|
||||
pub fn abbreviate_folder_name(name: &str) -> AbbreviatedFolderName {
|
||||
let parts: Vec<&str> = name.split('/').collect();
|
||||
if parts.len() <= 2 {
|
||||
return AbbreviatedFolderName {
|
||||
stem: None,
|
||||
last: name.to_string(),
|
||||
};
|
||||
}
|
||||
let last = parts[parts.len() - 1].to_string();
|
||||
let stem = parts[..parts.len() - 1]
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if *p == "~" {
|
||||
"~".to_string()
|
||||
} else {
|
||||
p.chars().next().map(String::from).unwrap_or_default()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
AbbreviatedFolderName {
|
||||
stem: Some(stem),
|
||||
last,
|
||||
}
|
||||
}
|
||||
|
||||
/// The directory a session lives in: its live `current_cwd` if the daemon
|
||||
/// has observed one, else the spawn `cwd`. Mirrors the web's `sessionCwd`.
|
||||
fn session_cwd(s: &Session) -> Option<PathBuf> {
|
||||
if let Some(cur) = &s.current_cwd {
|
||||
if !cur.as_os_str().is_empty() {
|
||||
return Some(cur.clone());
|
||||
}
|
||||
}
|
||||
if !s.cwd.as_os_str().is_empty() {
|
||||
return Some(s.cwd.clone());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Where a session sits in the directory tree. A worktree session belongs to
|
||||
/// its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||
/// grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||
/// session of the same name, and the repository stops being where you look
|
||||
/// for the work. Mirrors the web's `sessionTreePath`.
|
||||
pub fn session_tree_path(s: &Session) -> Option<PathBuf> {
|
||||
if let Some(wt) = &s.worktree {
|
||||
return Some(wt.base_repo.clone());
|
||||
}
|
||||
session_cwd(s)
|
||||
}
|
||||
|
||||
/// One node of a directory tree grouping sessions by path, generic over the
|
||||
/// leaf payload since building the tree itself (grouping key, sort order,
|
||||
/// filtering) is not this crate's job — only [`merge_linear`] is.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TreeNode<T> {
|
||||
Folder(TreeFolder<T>),
|
||||
Leaf(T),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TreeFolder<T> {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub children: Vec<TreeNode<T>>,
|
||||
}
|
||||
|
||||
/// Fold a folder with a single folder-child into one node (`a` + `b` →
|
||||
/// `a/b`), repeating down the chain, then recurse into the surviving
|
||||
/// branches. The merged node keeps the deepest path so collapse/expand state
|
||||
/// stays stable as it grows. Mirrors the web's `mergeLinear`.
|
||||
pub fn merge_linear<T>(folder: TreeFolder<T>) -> TreeFolder<T> {
|
||||
let mut cur = folder;
|
||||
loop {
|
||||
let single_folder_child =
|
||||
cur.children.len() == 1 && matches!(cur.children[0], TreeNode::Folder(_));
|
||||
if !single_folder_child {
|
||||
break;
|
||||
}
|
||||
let child = match cur.children.pop().expect("checked len == 1 above") {
|
||||
TreeNode::Folder(f) => f,
|
||||
TreeNode::Leaf(_) => unreachable!("checked matches! above"),
|
||||
};
|
||||
cur = TreeFolder {
|
||||
name: format!("{}/{}", cur.name, child.name),
|
||||
path: child.path,
|
||||
children: child.children,
|
||||
};
|
||||
}
|
||||
cur.children = cur
|
||||
.children
|
||||
.into_iter()
|
||||
.map(|c| match c {
|
||||
TreeNode::Folder(f) => TreeNode::Folder(merge_linear(f)),
|
||||
leaf => leaf,
|
||||
})
|
||||
.collect();
|
||||
cur
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{SessionStatus, SessionWorktree};
|
||||
use chrono::{TimeZone, Utc};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn session(cwd: &str) -> Session {
|
||||
Session {
|
||||
id: Uuid::from_u128(1),
|
||||
cwd: PathBuf::from(cwd),
|
||||
command: "bash".into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_dir_collapses_home() {
|
||||
assert_eq!(
|
||||
abbreviate_dir(Path::new("/home/naps/maestro"), "/home/naps"),
|
||||
"~/maestro"
|
||||
);
|
||||
assert_eq!(abbreviate_dir(Path::new("/home/naps"), "/home/naps"), "~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_dir_leaves_paths_outside_home_untouched() {
|
||||
assert_eq!(
|
||||
abbreviate_dir(Path::new("/srv/work/api"), "/home/naps"),
|
||||
"/srv/work/api"
|
||||
);
|
||||
assert_eq!(
|
||||
abbreviate_dir(Path::new("/srv/work/api"), ""),
|
||||
"/srv/work/api"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abbreviate_folder_name_collapses_the_stem() {
|
||||
let got = abbreviate_folder_name("~/git.naps.pt/naps62/maestro");
|
||||
assert_eq!(
|
||||
got,
|
||||
AbbreviatedFolderName {
|
||||
stem: Some("~/g/n".into()),
|
||||
last: "maestro".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// Two-or-fewer segments are left alone — nothing to gain from collapsing.
|
||||
#[test]
|
||||
fn abbreviate_folder_name_two_segment_no_op() {
|
||||
assert_eq!(
|
||||
abbreviate_folder_name("~/work"),
|
||||
AbbreviatedFolderName {
|
||||
stem: None,
|
||||
last: "~/work".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
abbreviate_folder_name("work"),
|
||||
AbbreviatedFolderName {
|
||||
stem: None,
|
||||
last: "work".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tree_path_prefers_worktree_base_repo() {
|
||||
let mut s = session("/home/n/tea/agent-skills/worktrees/largo-clarinet");
|
||||
s.worktree = Some(SessionWorktree {
|
||||
path: PathBuf::from("/home/n/tea/agent-skills/worktrees/largo-clarinet"),
|
||||
base_repo: PathBuf::from("/home/n/tea/agent-skills"),
|
||||
});
|
||||
assert_eq!(
|
||||
session_tree_path(&s),
|
||||
Some(PathBuf::from("/home/n/tea/agent-skills"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tree_path_falls_back_to_cwd_without_a_worktree() {
|
||||
let s = session("/home/n/tea/arr");
|
||||
assert_eq!(
|
||||
session_tree_path(&s),
|
||||
Some(PathBuf::from("/home/n/tea/arr"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tree_path_prefers_live_current_cwd_over_spawn_cwd() {
|
||||
let mut s = session("/home/n/tea/arr");
|
||||
s.current_cwd = Some(PathBuf::from("/home/n/tea/arr/crates"));
|
||||
assert_eq!(
|
||||
session_tree_path(&s),
|
||||
Some(PathBuf::from("/home/n/tea/arr/crates"))
|
||||
);
|
||||
}
|
||||
|
||||
fn leaf(name: &str) -> TreeNode<String> {
|
||||
TreeNode::Leaf(name.into())
|
||||
}
|
||||
|
||||
fn folder(name: &str, path: &str, children: Vec<TreeNode<String>>) -> TreeFolder<String> {
|
||||
TreeFolder {
|
||||
name: name.into(),
|
||||
path: path.into(),
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
/// The web's mergeLinear test: keeps the deepest path so collapse state
|
||||
/// survives growth.
|
||||
#[test]
|
||||
fn merge_linear_folds_a_chain_and_keeps_the_deepest_path() {
|
||||
let tree = folder("a", "a", vec![TreeNode::Folder(folder("b", "a/b", vec![]))]);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.name, "a/b");
|
||||
assert_eq!(merged.path, "a/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_linear_stops_where_a_directory_branches() {
|
||||
let tree = folder(
|
||||
"git",
|
||||
"git",
|
||||
vec![
|
||||
TreeNode::Folder(folder("one", "git/one", vec![])),
|
||||
TreeNode::Folder(folder("two", "git/two", vec![])),
|
||||
],
|
||||
);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.name, "git");
|
||||
assert_eq!(merged.children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_linear_stops_at_a_leaf() {
|
||||
let tree = folder("a", "a", vec![leaf("session")]);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.name, "a");
|
||||
assert_eq!(merged.children, vec![leaf("session")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_linear_recurses_into_surviving_branches() {
|
||||
let tree = folder(
|
||||
"root",
|
||||
"root",
|
||||
vec![
|
||||
TreeNode::Folder(folder(
|
||||
"a",
|
||||
"root/a",
|
||||
vec![TreeNode::Folder(folder("b", "root/a/b", vec![leaf("s")]))],
|
||||
)),
|
||||
leaf("other"),
|
||||
],
|
||||
);
|
||||
let merged = merge_linear(tree);
|
||||
assert_eq!(merged.children.len(), 2);
|
||||
let TreeNode::Folder(nested) = &merged.children[0] else {
|
||||
panic!("expected folder")
|
||||
};
|
||||
assert_eq!(nested.name, "a/b");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
pub mod client;
|
||||
pub mod display;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
@@ -53,6 +54,14 @@ pub struct Session {
|
||||
pub env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub metadata: HashMap<String, Value>,
|
||||
/// The worktree this session owns, when it was spawned with one.
|
||||
///
|
||||
/// Persisted rather than kept in memory only: after a daemon restart the
|
||||
/// recovered session must still know what to clean up, and with
|
||||
/// deterministic worktree paths a forgotten association leaves a directory
|
||||
/// that blocks that branch from ever being spawned again (#54 review).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worktree: Option<SessionWorktree>,
|
||||
pub status: SessionStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Current foreground command in the session's pane (e.g. `claude`,
|
||||
@@ -67,11 +76,24 @@ pub struct Session {
|
||||
/// byte stream.
|
||||
#[serde(default)]
|
||||
pub activity: ActivityState,
|
||||
/// Short human-readable string an agent reported alongside its activity
|
||||
/// (#105) — e.g. why it is in `Error`. Shown on a sidebar card's meta line
|
||||
/// while the report is in force; cleared when the report lapses or is
|
||||
/// replaced. `None` unless an agent reported one.
|
||||
#[serde(default)]
|
||||
pub activity_detail: Option<String>,
|
||||
/// Wall-clock time of the session's most recent observed PTY output.
|
||||
/// Drives "idle for Ns" displays. Defaults to the Unix epoch on payloads
|
||||
/// from daemons that predate the field.
|
||||
#[serde(default = "epoch")]
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
/// Wall-clock time at which the session entered its current
|
||||
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
|
||||
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
|
||||
/// tracks the last observed PTY output. Populated by #104; defaults to the
|
||||
/// Unix epoch on payloads from daemons that predate the field.
|
||||
#[serde(default = "epoch")]
|
||||
pub activity_since: DateTime<Utc>,
|
||||
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
||||
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
||||
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
||||
@@ -79,6 +101,13 @@ pub struct Session {
|
||||
/// from daemons that predate the field.
|
||||
#[serde(default)]
|
||||
pub current_cwd: Option<PathBuf>,
|
||||
/// Whether the session's worktree currently holds anything that would
|
||||
/// block an unforced kill (#83), summarised by [`InTheWay`]'s `Display`.
|
||||
/// Refreshed by the daemon's watcher; `None` for sessions without a
|
||||
/// worktree and until the first probe. Defaults on the wire so payloads
|
||||
/// from daemons that predate the field stay forward-compatible.
|
||||
#[serde(default)]
|
||||
pub blocked_work: Option<InTheWay>,
|
||||
}
|
||||
|
||||
/// Default for [`Session::last_activity_at`] when an older payload omits it.
|
||||
@@ -95,7 +124,12 @@ pub enum SessionStatus {
|
||||
/// Inferred session activity, orthogonal to [`SessionStatus`] (NAP-340). The
|
||||
/// daemon derives this from tmux pane state, PTY quiescence, and `/proc`
|
||||
/// inspection — never by parsing the terminal byte stream.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
///
|
||||
/// Deserializes leniently (#110, SIDEBAR.md Precedence): an unknown variant
|
||||
/// from a newer daemon becomes [`ActivityState::Idle`] instead of failing the
|
||||
/// whole `Session`. Non-string values still error — only variant names are
|
||||
/// forgiven.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Default)]
|
||||
pub enum ActivityState {
|
||||
/// Producing output, or the foreground process is busy.
|
||||
Active,
|
||||
@@ -104,6 +138,64 @@ pub enum ActivityState {
|
||||
Idle,
|
||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||
AwaitingInput,
|
||||
/// The agent running in the session reported a failure (#102). Never
|
||||
/// inferred by the daemon; only set when an agent reports it (#105).
|
||||
Error,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ActivityState {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Ok(match s.as_str() {
|
||||
"Active" => Self::Active,
|
||||
"AwaitingInput" => Self::AwaitingInput,
|
||||
"Error" => Self::Error,
|
||||
// Known or not, any other spelling degrades to the neutral default
|
||||
_ => Self::Idle,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions/:id/activity` body (#105): an agent's report of
|
||||
/// its own state. The daemon holds the reported state above its own inference
|
||||
/// for a fixed TTL, then resumes inference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ReportActivityRequest {
|
||||
/// Parsed strictly, unlike [`ActivityState`]'s own lenient `Deserialize`
|
||||
/// (#110). Leniency exists so a client reading a *newer* daemon's snapshot
|
||||
/// degrades an unrecognised state to `Idle` instead of dropping the whole
|
||||
/// session. A report travelling the other way has no such excuse: an agent
|
||||
/// posting a state this daemon does not know is a bug in the agent, and
|
||||
/// silently recording it as `Idle` would hide that behind a 204.
|
||||
#[serde(deserialize_with = "strict_activity_state")]
|
||||
pub state: ActivityState,
|
||||
/// Short human string shown next to the state (e.g. an error message).
|
||||
/// The daemon caps its length server-side.
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Reject an unknown [`ActivityState`] spelling instead of degrading it to
|
||||
/// `Idle`. Used only by [`ReportActivityRequest`]; see the note there.
|
||||
fn strict_activity_state<'de, D>(deserializer: D) -> Result<ActivityState, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
use serde::de::Error as _;
|
||||
let s = String::deserialize(deserializer)?;
|
||||
match s.as_str() {
|
||||
"Active" => Ok(ActivityState::Active),
|
||||
"Idle" => Ok(ActivityState::Idle),
|
||||
"AwaitingInput" => Ok(ActivityState::AwaitingInput),
|
||||
"Error" => Ok(ActivityState::Error),
|
||||
other => Err(D::Error::unknown_variant(
|
||||
other,
|
||||
&["Active", "Idle", "AwaitingInput", "Error"],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional worktree request attached to a `CreateSessionRequest`.
|
||||
@@ -111,11 +203,6 @@ pub enum ActivityState {
|
||||
pub struct WorktreeSpec {
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
/// Kept on the wire for CLI compatibility (#56) but no longer a
|
||||
/// user-facing choice: the daemon always cleans up on kill, guarded
|
||||
/// against unsaved work. Defaults to `true`.
|
||||
#[serde(default = "default_true")]
|
||||
pub cleanup_on_kill: bool,
|
||||
/// When false, `branch` must already exist and is checked out as-is
|
||||
/// (#56); when true (the default, and what every payload meant before
|
||||
/// the field existed) the daemon creates it with `worktree add -b`.
|
||||
@@ -127,6 +214,108 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The worktree a running session owns, carried on [`Session`] so it survives
|
||||
/// a daemon restart.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionWorktree {
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
}
|
||||
|
||||
/// Why a retained worktree outlived its session (#80). Same values the
|
||||
/// daemon's retained store persists; new variants are additive but clients
|
||||
/// should render unknown reasons generically.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum RetainedReason {
|
||||
/// The session was killed with `--force` while its worktree held unsaved
|
||||
/// work.
|
||||
ForceKilled,
|
||||
/// The session ended by itself and its worktree held unsaved work.
|
||||
ExitedWithUnsavedWork,
|
||||
}
|
||||
|
||||
/// What a kill refusal would report about a worktree right now (#80) — the
|
||||
/// same breakdown that blocks an unforced kill, recomputed on every read of
|
||||
/// [`RetainedWorktree`] so it reflects the tree now rather than when it was
|
||||
/// retained.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InTheWay {
|
||||
/// Tracked files with uncommitted modifications.
|
||||
#[serde(default)]
|
||||
pub uncommitted_files: usize,
|
||||
/// Untracked files that are not ignored (ignored files never block).
|
||||
#[serde(default)]
|
||||
pub untracked_files: usize,
|
||||
/// Commits on the branch reachable from no remote-tracking ref.
|
||||
#[serde(default)]
|
||||
pub unpushed_commits: usize,
|
||||
}
|
||||
|
||||
impl InTheWay {
|
||||
/// Nothing blocks any more — the work has since been committed and pushed.
|
||||
pub fn is_clear(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
||||
/// in-the-way state as of this read. Reclaiming it via
|
||||
/// `DELETE /api/worktrees/:id` removes the directory but **never** the branch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RetainedWorktree {
|
||||
pub id: Uuid,
|
||||
pub path: PathBuf,
|
||||
pub base_repo: PathBuf,
|
||||
pub branch: String,
|
||||
pub reason: RetainedReason,
|
||||
pub retained_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub in_the_way: InTheWay,
|
||||
}
|
||||
|
||||
/// Body of a successful forced `DELETE /api/sessions/:id?force=true` (#81).
|
||||
/// A forced kill ends the session and retains its worktree instead of
|
||||
/// removing it; this names what was kept, where, and why, so a client can say
|
||||
/// where the work went rather than leaving the operator to wonder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KillOutcome {
|
||||
/// Set when the worktree was retained with reason `force-killed`; `None`
|
||||
/// when there was nothing to retain — no worktree at all, or one whose
|
||||
/// guard was clear, which a forced kill removes as usual.
|
||||
#[serde(default)]
|
||||
pub retained: Option<RetainedWorktree>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InTheWay {
|
||||
/// "3 uncommitted files, 2 unpushed commits" — zero parts are omitted;
|
||||
/// all-zero renders "no unsaved work". This is the phrasing every client
|
||||
/// uses for both the kill refusal and the retained list, so the two
|
||||
/// cannot describe the same tree differently.
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let parts = [
|
||||
(self.uncommitted_files, "uncommitted file"),
|
||||
(self.untracked_files, "untracked file"),
|
||||
(self.unpushed_commits, "unpushed commit"),
|
||||
];
|
||||
let said: Vec<String> = parts
|
||||
.into_iter()
|
||||
.filter(|(n, _)| *n > 0)
|
||||
.map(|(n, unit)| {
|
||||
if n == 1 {
|
||||
format!("1 {unit}")
|
||||
} else {
|
||||
format!("{n} {unit}s")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
if said.is_empty() {
|
||||
return write!(f, "no unsaved work");
|
||||
}
|
||||
write!(f, "{}", said.join(", "))
|
||||
}
|
||||
}
|
||||
|
||||
/// HTTP `POST /api/sessions` body.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CreateSessionRequest {
|
||||
@@ -232,6 +421,10 @@ pub struct BranchInfo {
|
||||
pub struct ErrorInfo {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
/// Set only by the kill guard (#79): the unsaved work that blocked
|
||||
/// `DELETE /api/sessions/:id`, so a client can render counts directly.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unsaved_work: Option<InTheWay>,
|
||||
}
|
||||
|
||||
/// The envelope every `/api/*` failure is wrapped in: `{"error": {…}}`.
|
||||
@@ -252,6 +445,7 @@ impl ApiError {
|
||||
error: ErrorInfo {
|
||||
code: code.into(),
|
||||
message: message.into(),
|
||||
unsaved_work: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -410,16 +604,22 @@ mod tests {
|
||||
command: "claude".into(),
|
||||
env: HashMap::from([("FOO".into(), "bar".into())]),
|
||||
metadata: HashMap::from([("name".into(), Value::String("maestro-impl".into()))]),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: DateTime::parse_from_rfc3339("2026-05-24T12:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
foreground: Some("claude".into()),
|
||||
activity: ActivityState::Active,
|
||||
activity_detail: None,
|
||||
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,6 +639,63 @@ mod tests {
|
||||
assert_eq!(s.activity, ActivityState::Idle);
|
||||
assert_eq!(s.last_activity_at, epoch());
|
||||
assert!(s.current_cwd.is_none());
|
||||
assert!(s.blocked_work.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_work_defaults_to_none_on_legacy_payload() {
|
||||
// A pre-#83 daemon omits the indicator; it must deserialize to `None`
|
||||
// rather than failing, and a present value round-trips.
|
||||
let json = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"cwd": "/tmp",
|
||||
"command": "bash",
|
||||
"status": "Running",
|
||||
"created_at": "2026-05-24T12:00:00Z"
|
||||
});
|
||||
let s: Session = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(s.blocked_work, None);
|
||||
|
||||
let mut with_blocker = sample_session();
|
||||
with_blocker.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 3,
|
||||
..InTheWay::default()
|
||||
});
|
||||
assert_eq!(
|
||||
with_blocker.clone().blocked_work,
|
||||
serde_json::from_value::<Session>(serde_json::to_value(&with_blocker).unwrap())
|
||||
.unwrap()
|
||||
.blocked_work
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
||||
// A daemon predating #102 omits `activity_since`; it must deserialize
|
||||
// to the forward-compat default rather than failing, and a present
|
||||
// value round-trips.
|
||||
let json = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"cwd": "/tmp",
|
||||
"command": "bash",
|
||||
"status": "Running",
|
||||
"created_at": "2026-05-24T12:00:00Z"
|
||||
});
|
||||
let s: Session = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(s.activity_since, epoch());
|
||||
|
||||
let full = sample_session();
|
||||
assert_eq!(full, round_trip(&full));
|
||||
}
|
||||
|
||||
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
|
||||
#[test]
|
||||
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(ActivityState::Error).unwrap(),
|
||||
serde_json::json!("Error")
|
||||
);
|
||||
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -450,6 +707,78 @@ mod tests {
|
||||
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||
}
|
||||
|
||||
// #110 / SIDEBAR.md Precedence: a future daemon may send an
|
||||
// `ActivityState` variant this client does not know; it must degrade to
|
||||
// the neutral default instead of failing the whole `Session`.
|
||||
#[test]
|
||||
fn unknown_activity_state_variant_deserializes_to_idle() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ActivityState>(serde_json::json!("Teleported")).unwrap(),
|
||||
ActivityState::Idle
|
||||
);
|
||||
|
||||
let mut future = serde_json::to_value(sample_session()).unwrap();
|
||||
future["activity"] = serde_json::json!("QuantumEntangled");
|
||||
let s: Session = serde_json::from_value(future).unwrap();
|
||||
assert_eq!(s.activity, ActivityState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_string_activity_payload_still_fails_deserialization() {
|
||||
let json = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"cwd": "/tmp",
|
||||
"command": "bash",
|
||||
"status": "Running",
|
||||
"created_at": "2026-05-24T12:00:00Z",
|
||||
"activity": 42,
|
||||
});
|
||||
assert!(serde_json::from_value::<Session>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_detail_defaults_to_none_on_legacy_payload() {
|
||||
// #105: a daemon predating the detail field omits it; it must
|
||||
// deserialize to `None` rather than failing, and a present value
|
||||
// round-trips.
|
||||
let json = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"cwd": "/tmp",
|
||||
"command": "bash",
|
||||
"status": "Running",
|
||||
"created_at": "2026-05-24T12:00:00Z"
|
||||
});
|
||||
let s: Session = serde_json::from_value(json).unwrap();
|
||||
assert_eq!(s.activity_detail, None);
|
||||
|
||||
let mut reported = sample_session();
|
||||
reported.activity = ActivityState::Error;
|
||||
reported.activity_detail = Some("build failed".into());
|
||||
assert_eq!(reported.clone(), round_trip(&reported));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_activity_request_defaults_detail_and_rejects_unknown_state() {
|
||||
let bare: ReportActivityRequest =
|
||||
serde_json::from_value(serde_json::json!({ "state": "Error" })).unwrap();
|
||||
assert_eq!(bare.state, ActivityState::Error);
|
||||
assert_eq!(bare.detail, None);
|
||||
|
||||
let full = ReportActivityRequest {
|
||||
state: ActivityState::AwaitingInput,
|
||||
detail: Some("waiting on permission".into()),
|
||||
};
|
||||
assert_eq!(full, round_trip(&full));
|
||||
|
||||
// Unknown state values are rejected rather than coerced (#105).
|
||||
assert!(
|
||||
serde_json::from_value::<ReportActivityRequest>(serde_json::json!({
|
||||
"state": "Teleported"
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_round_trips() {
|
||||
let s = sample_session();
|
||||
@@ -478,7 +807,6 @@ mod tests {
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: PathBuf::from("/home/naps62/projects/maestro"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
}),
|
||||
};
|
||||
@@ -508,7 +836,6 @@ mod tests {
|
||||
});
|
||||
let spec: WorktreeSpec = serde_json::from_value(json).unwrap();
|
||||
assert!(spec.create_branch);
|
||||
assert!(spec.cleanup_on_kill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -516,12 +843,81 @@ mod tests {
|
||||
let spec = WorktreeSpec {
|
||||
base_repo: PathBuf::from("/work"),
|
||||
branch: "feat/foo".into(),
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
};
|
||||
assert_eq!(spec, round_trip(&spec));
|
||||
}
|
||||
|
||||
fn sample_retained_worktree() -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: Uuid::from_u128(0x0123_4567_89ab_cdef_0123_4567_89ab_cdef),
|
||||
path: PathBuf::from("/work/maestro/worktrees/feat/login"),
|
||||
base_repo: PathBuf::from("/work/maestro"),
|
||||
branch: "feat/login".into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: DateTime::parse_from_rfc3339("2026-08-24T10:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc),
|
||||
in_the_way: InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_worktree_round_trips() {
|
||||
let wt = sample_retained_worktree();
|
||||
assert_eq!(wt, round_trip(&wt));
|
||||
}
|
||||
|
||||
/// A payload from a daemon predating `in_the_way` still decodes; the
|
||||
/// breakdown defaults to "nothing in the way".
|
||||
#[test]
|
||||
fn retained_worktree_defaults_in_the_way_on_legacy_payload() {
|
||||
let legacy = serde_json::json!({
|
||||
"id": Uuid::from_u128(1),
|
||||
"path": "/w/t",
|
||||
"base_repo": "/w",
|
||||
"branch": "feat/login",
|
||||
"reason": "force-killed",
|
||||
"retained_at": "2026-08-24T10:00:00Z",
|
||||
});
|
||||
let wt: RetainedWorktree = serde_json::from_value(legacy).unwrap();
|
||||
assert_eq!(wt.reason, RetainedReason::ForceKilled);
|
||||
assert_eq!(wt.in_the_way, InTheWay::default());
|
||||
assert!(wt.in_the_way.is_clear());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_reason_serializes_kebab_case() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(RetainedReason::ForceKilled).unwrap(),
|
||||
serde_json::json!("force-killed")
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(RetainedReason::ExitedWithUnsavedWork).unwrap(),
|
||||
serde_json::json!("exited-with-unsaved-work")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kill_outcome_round_trips_a_retained_worktree() {
|
||||
let outcome = KillOutcome {
|
||||
retained: Some(sample_retained_worktree()),
|
||||
};
|
||||
assert_eq!(outcome, round_trip(&outcome));
|
||||
}
|
||||
|
||||
/// A kill with nothing to retain — no worktree, or a clean one removed as
|
||||
/// usual — decodes from an absent field.
|
||||
#[test]
|
||||
fn kill_outcome_defaults_to_nothing_retained() {
|
||||
let outcome: KillOutcome = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert_eq!(outcome.retained, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_event_round_trips_and_tags() {
|
||||
let session = sample_session();
|
||||
@@ -561,6 +957,7 @@ mod tests {
|
||||
ClientControl::Error(ErrorInfo {
|
||||
code: "bad_frame".into(),
|
||||
message: "decode failed".into(),
|
||||
unsaved_work: None,
|
||||
}),
|
||||
resize,
|
||||
] {
|
||||
|
||||
@@ -26,6 +26,9 @@ pub const BRANCHES: &str = "/api/branches";
|
||||
/// `GET` a batch of generated session handles (#53). Takes a `count` query
|
||||
/// param (default 1, capped at [`maestro_names`]' batch cap).
|
||||
pub const NAMES: &str = "/api/names";
|
||||
/// `GET` retained worktrees (#80): what the store holds, with the in-the-way
|
||||
/// breakdown recomputed on read.
|
||||
pub const WORKTREES: &str = "/api/worktrees";
|
||||
/// `WS` catalog snapshot + session deltas.
|
||||
pub const EVENTS: &str = "/api/events";
|
||||
|
||||
@@ -35,11 +38,25 @@ pub const PATH_QUERY: &str = "path";
|
||||
/// Name of the query parameter [`NAMES`] takes.
|
||||
pub const COUNT_QUERY: &str = "count";
|
||||
|
||||
/// Name of the boolean query parameter `DELETE /api/sessions/:id` takes (#81).
|
||||
/// `force=true` overrides the kill refusal and retains the worktree.
|
||||
pub const FORCE_QUERY: &str = "force";
|
||||
|
||||
/// `DELETE /api/sessions/:id`.
|
||||
pub fn session(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}")
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/:id/activity` (#105): an agent reports its own state.
|
||||
pub fn session_activity(id: Uuid) -> String {
|
||||
format!("{SESSIONS}/{id}/activity")
|
||||
}
|
||||
|
||||
/// `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80).
|
||||
pub fn worktree(id: Uuid) -> String {
|
||||
format!("{WORKTREES}/{id}")
|
||||
}
|
||||
|
||||
/// `WS /api/attach/:id` — raw PTY bytes.
|
||||
pub fn attach(id: Uuid) -> String {
|
||||
format!("{API_PREFIX}/attach/{id}")
|
||||
@@ -49,7 +66,9 @@ pub fn attach(id: Uuid) -> String {
|
||||
/// registers the same paths [`session`] and [`attach`] build.
|
||||
pub mod patterns {
|
||||
pub const SESSION: &str = "/api/sessions/:id";
|
||||
pub const SESSION_ACTIVITY: &str = "/api/sessions/:id/activity";
|
||||
pub const ATTACH: &str = "/api/attach/:id";
|
||||
pub const WORKTREE: &str = "/api/worktrees/:id";
|
||||
}
|
||||
|
||||
/// A daemon base URL that could not be turned into a WebSocket base.
|
||||
@@ -156,10 +175,18 @@ mod tests {
|
||||
session(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
);
|
||||
assert_eq!(
|
||||
session_activity(id()),
|
||||
"/api/sessions/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50/activity"
|
||||
);
|
||||
assert_eq!(
|
||||
attach(id()),
|
||||
"/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
);
|
||||
assert_eq!(
|
||||
worktree(id()),
|
||||
"/api/worktrees/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
);
|
||||
assert_eq!(
|
||||
attach_url("https://laptop.lan:7070", id()).unwrap(),
|
||||
"wss://laptop.lan:7070/api/attach/6f1a7c0e-0f5e-4a2b-9a3d-6b1c2d3e4f50"
|
||||
@@ -174,10 +201,18 @@ mod tests {
|
||||
patterns::SESSION.trim_end_matches(":id"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::SESSION_ACTIVITY.trim_end_matches(":id/activity"),
|
||||
session(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::ATTACH.trim_end_matches(":id"),
|
||||
attach(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
patterns::WORKTREE.trim_end_matches(":id"),
|
||||
worktree(id()).trim_end_matches(&id().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -191,6 +226,7 @@ mod tests {
|
||||
GITROOT,
|
||||
BRANCHES,
|
||||
NAMES,
|
||||
WORKTREES,
|
||||
EVENTS,
|
||||
] {
|
||||
assert!(path.starts_with(API_PREFIX), "{path} escapes {API_PREFIX}");
|
||||
|
||||
@@ -36,12 +36,16 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
||||
command: command.into(),
|
||||
env: HashMap::new(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,12 @@ pub enum Action {
|
||||
AttachHighlighted,
|
||||
ToggleAttachHighlighted,
|
||||
NewDraftPane,
|
||||
/// Fast-forward spawn (#67): spawn immediately with every default
|
||||
/// resolved; unresolvable defaults open a pre-filled draft instead.
|
||||
FastForwardSpawn,
|
||||
KillHighlighted,
|
||||
/// Open the retained-worktrees view (#85).
|
||||
ShowWorktrees,
|
||||
|
||||
// Tabs
|
||||
NewTab,
|
||||
@@ -79,7 +84,9 @@ impl Action {
|
||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
|
||||
"KillHighlighted" => return Ok(Action::KillHighlighted),
|
||||
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
|
||||
"NewTab" => return Ok(Action::NewTab),
|
||||
"CloseTab" => return Ok(Action::CloseTab),
|
||||
"NextTab" => return Ok(Action::NextTab),
|
||||
@@ -197,6 +204,16 @@ mod tests {
|
||||
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
|
||||
}
|
||||
|
||||
/// The fast-forward pair sits next to the draft-pane key (#67).
|
||||
#[test]
|
||||
fn fast_forward_spawn_round_trips() {
|
||||
assert_eq!(
|
||||
Action::parse("FastForwardSpawn").unwrap(),
|
||||
Action::FastForwardSpawn
|
||||
);
|
||||
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_round_trips() {
|
||||
for a in [
|
||||
|
||||
+853
-53
File diff suppressed because it is too large
Load Diff
@@ -188,12 +188,16 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata: Default::default(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
activity_detail: None,
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_since: chrono::Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,11 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
||||
// Catalog actions
|
||||
("enter", "AttachHighlighted"),
|
||||
("s", "NewDraftPane"),
|
||||
// Fast-forward spawn (#67): the draft-pane key's neighbour does the same
|
||||
// thing with every default resolved and no form.
|
||||
("S", "FastForwardSpawn"),
|
||||
("x", "KillHighlighted"),
|
||||
("w", "ShowWorktrees"),
|
||||
// Tabs
|
||||
("ctrl-t", "NewTab"),
|
||||
("g t", "NextTab"),
|
||||
@@ -159,6 +163,14 @@ mod tests {
|
||||
cfg.keymap.resolve(Mode::Normal, "s"),
|
||||
Some(Action::NewDraftPane),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "S"),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, "w"),
|
||||
Some(Action::ShowWorktrees),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -226,6 +238,26 @@ mod tests {
|
||||
assert!(s.contains("ctrl-glorb"), "diagnostic: {s}");
|
||||
}
|
||||
|
||||
/// Regression for #70: the previous canonical form of a real Shift+S
|
||||
/// keypress was `"shift-S"`, which the default `"S"` binding never
|
||||
/// matched. Drive the actual crossterm event, not the binding string,
|
||||
/// so the dead-key bug can't come back unnoticed.
|
||||
#[test]
|
||||
fn real_shift_s_keypress_resolves_to_fast_forward_spawn() {
|
||||
use crossterm::event::{Event as CtEvent, KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use crate::input::{canonicalise, Region};
|
||||
|
||||
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||
let ev = CtEvent::Key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
|
||||
let key = canonicalise(&ev, |_, _| Region::Unknown).expect("key event canonicalises");
|
||||
|
||||
assert_eq!(
|
||||
cfg.keymap.resolve(Mode::Normal, &key.canonical()),
|
||||
Some(Action::FastForwardSpawn),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_chord_timeout_is_applied() {
|
||||
let mut section = TuiSection::default();
|
||||
|
||||
+1752
-94
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,282 @@
|
||||
//! Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
||||
//!
|
||||
//! The defaults mirror what a draft pane would settle on after the operator
|
||||
//! picked the most recent project and kept their hands off everything else:
|
||||
//! its last command (or the first non-custom preset), a freshly generated
|
||||
//! name, and a worktree with a branch named after that name. Any default
|
||||
//! that cannot be resolved — no projects yet, no command, no generated
|
||||
//! name — downgrades the keystroke to a draft pane pre-filled with whatever
|
||||
//! did resolve ([`FastForwardDefaults::apply_to_draft`]).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use maestro_protocol::{CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||
|
||||
use crate::draft::DraftPane;
|
||||
|
||||
/// What one fast-forward keystroke resolved. Every field optional: `None`
|
||||
/// anywhere means "open a draft instead of spawning".
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FastForwardDefaults {
|
||||
pub project: Option<Project>,
|
||||
pub command: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the fast-forward defaults from the daemon's answers. `name` is
|
||||
/// `None` when `/api/names` yielded nothing; `presets` may be empty (the
|
||||
/// caller falls back to the built-ins it already has).
|
||||
pub fn resolve(
|
||||
projects: &[Project],
|
||||
presets: &[SpawnPreset],
|
||||
name: Option<String>,
|
||||
now: DateTime<Utc>,
|
||||
) -> FastForwardDefaults {
|
||||
let project = crate::projects::sort_projects(projects.to_vec(), now)
|
||||
.into_iter()
|
||||
.next();
|
||||
let command = project
|
||||
.as_ref()
|
||||
.and_then(|p| p.last_command.clone())
|
||||
.filter(|c| !c.trim().is_empty())
|
||||
.or_else(|| {
|
||||
presets
|
||||
.iter()
|
||||
.find(|p| !p.command.is_empty())
|
||||
.map(|p| p.command.clone())
|
||||
});
|
||||
FastForwardDefaults {
|
||||
project,
|
||||
command,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
impl FastForwardDefaults {
|
||||
/// The spawn request when *every* default resolved: worktree on, new
|
||||
/// branch named after the generated session name, base repo from the
|
||||
/// scan (`None` means the project is itself a main checkout).
|
||||
pub fn build_request(&self) -> Option<CreateSessionRequest> {
|
||||
let project = self.project.as_ref()?;
|
||||
let command = self.command.as_deref()?.trim();
|
||||
let name = self.name.as_deref()?;
|
||||
if command.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut metadata = std::collections::HashMap::new();
|
||||
metadata.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
);
|
||||
Some(CreateSessionRequest {
|
||||
cwd: project.path.clone(),
|
||||
command: command.to_string(),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: Some(WorktreeSpec {
|
||||
base_repo: project
|
||||
.base_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.path.clone()),
|
||||
branch: name.to_string(),
|
||||
create_branch: true,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Seed a fresh draft pane with whatever did resolve — the fallback when
|
||||
/// [`Self::build_request`] is `None`. The operator fills in the rest.
|
||||
pub fn apply_to_draft(&self, draft: &mut DraftPane) {
|
||||
if let Some(project) = &self.project {
|
||||
draft.picked_cwd = Some(PathBuf::from(&project.path));
|
||||
// The scan already knows this directory's repo (#61): no probe
|
||||
// round trip needed, exactly like picking the row by hand.
|
||||
draft.base_repo = Some(
|
||||
project
|
||||
.base_repo
|
||||
.clone()
|
||||
.unwrap_or_else(|| project.path.clone()),
|
||||
);
|
||||
draft.probed_cwd = Some(PathBuf::from(&project.path));
|
||||
}
|
||||
if let Some(command) = &self.command {
|
||||
if let Some(idx) = draft.presets.iter().position(|p| &p.command == command) {
|
||||
draft.selected_preset = idx;
|
||||
} else if !command.trim().is_empty() {
|
||||
// A last_command no preset advertises lands in custom free text.
|
||||
draft.selected_preset = draft.presets.len().saturating_sub(1);
|
||||
draft.custom_command = crate::text_input::TextInput::with_value(command);
|
||||
}
|
||||
}
|
||||
if let Some(name) = &self.name {
|
||||
draft.set_name(name.clone());
|
||||
// Seeded, not generated: a later refill must not clobber it.
|
||||
draft.name_touched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::TimeDelta;
|
||||
|
||||
fn project(path: &str, days_ago: i64, cmd: Option<&str>) -> Project {
|
||||
Project {
|
||||
path: PathBuf::from(path),
|
||||
name: path.rsplit('/').next().unwrap_or(path).into(),
|
||||
is_worktree: false,
|
||||
base_repo: None,
|
||||
last_activity: Some(Utc::now() - TimeDelta::days(days_ago)),
|
||||
last_command: cmd.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
fn repo_project(path: &str, base: &str, days_ago: i64, cmd: Option<&str>) -> Project {
|
||||
Project {
|
||||
base_repo: Some(PathBuf::from(base)),
|
||||
..project(path, days_ago, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
fn preset(id: &str, command: &str) -> SpawnPreset {
|
||||
SpawnPreset {
|
||||
id: id.into(),
|
||||
label: id.into(),
|
||||
command: command.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_every_default_from_the_most_recent_project() {
|
||||
let projects = vec![
|
||||
project("/repos/old", 30, Some("zsh")),
|
||||
repo_project("/repos/fresh", "/repos/fresh", 0, Some("claude")),
|
||||
];
|
||||
let presets = vec![preset("aider", "aider"), preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("amber-falcon".into()), Utc::now());
|
||||
assert_eq!(
|
||||
r.project.as_ref().unwrap().path,
|
||||
PathBuf::from("/repos/fresh")
|
||||
);
|
||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||
assert_eq!(r.name.as_deref(), Some("amber-falcon"));
|
||||
|
||||
let req = r.build_request().expect("all defaults resolved");
|
||||
assert_eq!(req.cwd, PathBuf::from("/repos/fresh"));
|
||||
assert_eq!(req.command, "claude");
|
||||
assert_eq!(
|
||||
req.metadata.get("name").and_then(|v| v.as_str()),
|
||||
Some("amber-falcon")
|
||||
);
|
||||
let wt = req.worktree.expect("worktree on");
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/fresh"));
|
||||
assert_eq!(wt.branch, "amber-falcon");
|
||||
assert!(wt.create_branch, "fresh branch named after the session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktree_of_the_most_recent_project_targets_its_base_repo() {
|
||||
let projects = vec![repo_project("/repos/main/wt-x", "/repos/main", 0, None)];
|
||||
let r = resolve(
|
||||
&projects,
|
||||
&[preset("zsh", "zsh")],
|
||||
Some("s".into()),
|
||||
Utc::now(),
|
||||
);
|
||||
let wt = r.build_request().unwrap().worktree.unwrap();
|
||||
assert_eq!(wt.base_repo, PathBuf::from("/repos/main"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_last_command_falls_back_to_the_first_non_custom_preset() {
|
||||
let projects = vec![project("/repos/p", 0, None)];
|
||||
let presets = vec![preset("custom", ""), preset("claude", "claude")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
assert_eq!(r.command.as_deref(), Some("claude"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_last_command_is_no_command_at_all() {
|
||||
let projects = vec![project("/repos/p", 0, Some(""))];
|
||||
let presets = vec![preset("custom", "")];
|
||||
let r = resolve(&projects, &presets, Some("n".into()), Utc::now());
|
||||
assert_eq!(r.command, None);
|
||||
assert!(r.build_request().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_resolves_when_no_projects_are_known_yet() {
|
||||
let r = resolve(
|
||||
&[],
|
||||
&[preset("claude", "claude")],
|
||||
Some("n".into()),
|
||||
Utc::now(),
|
||||
);
|
||||
assert_eq!(r.project, None);
|
||||
// No project → no directory → no request, even with command + name.
|
||||
assert!(r.build_request().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_generated_name_downgrades_to_a_draft() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
assert!(r.build_request().is_none(), "no name → no immediate spawn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolvable_defaults_still_seed_the_draft_with_what_resolved() {
|
||||
let projects = vec![repo_project("/repos/p", "/repos/p", 0, Some("aider"))];
|
||||
let r = resolve(&projects, &[preset("aider", "aider")], None, Utc::now());
|
||||
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
assert_eq!(draft.cwd(), Some(PathBuf::from("/repos/p")));
|
||||
assert_eq!(draft.base_repo, Some(PathBuf::from("/repos/p")));
|
||||
assert_eq!(draft.command(), "aider");
|
||||
assert!(
|
||||
draft.name.value().is_empty(),
|
||||
"nothing to seed the name with"
|
||||
);
|
||||
assert!(draft.worktree_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resolved_name_survives_late_refills_and_tracks_the_branch() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let named = resolve(
|
||||
&projects,
|
||||
&[preset("claude", "claude")],
|
||||
Some("amber-falcon".into()),
|
||||
Utc::now(),
|
||||
);
|
||||
let mut draft = DraftPane::default();
|
||||
named.apply_to_draft(&mut draft);
|
||||
// A later batch must not overwrite the name the spawn was seeded with.
|
||||
draft.seed_names(vec!["late-name".into()]);
|
||||
assert_eq!(draft.name.value(), "amber-falcon");
|
||||
assert_eq!(draft.branch.value(), "amber-falcon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresolved_name_still_gets_generated_by_a_later_batch() {
|
||||
let projects = vec![project("/repos/p", 0, Some("claude"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
draft.seed_names(vec!["late-name".into()]);
|
||||
assert_eq!(draft.name.value(), "late-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_last_command_no_preset_advertises_lands_in_custom_text() {
|
||||
let projects = vec![project("/repos/p", 0, Some("nvim -d"))];
|
||||
let r = resolve(&projects, &[preset("claude", "claude")], None, Utc::now());
|
||||
let mut draft = DraftPane::default();
|
||||
r.apply_to_draft(&mut draft);
|
||||
assert_eq!(draft.command(), "nvim -d");
|
||||
assert!(draft.is_custom());
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,9 @@ impl InputKey {
|
||||
if *alt {
|
||||
out.push_str("alt-");
|
||||
}
|
||||
if *shift {
|
||||
// Uppercase already implies shift; crossterm sets the flag
|
||||
// on every such char, so skip the redundant prefix (#70).
|
||||
if *shift && !c.is_uppercase() {
|
||||
out.push_str("shift-");
|
||||
}
|
||||
out.push(*c);
|
||||
@@ -580,12 +582,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_simple_chars() {
|
||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x", "shift-A"] {
|
||||
for s in ["a", "ctrl-a", "ctrl-w", "alt-x"] {
|
||||
let k = InputKey::parse(s).unwrap();
|
||||
assert_eq!(k.canonical(), s, "round-trip {s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// crossterm 0.28 attaches `KeyModifiers::SHIFT` to any uppercase char,
|
||||
/// so a real Shift+S delivers `Char { c: 'S', shift: true }`. Its
|
||||
/// canonical form must be the bare `"S"` a keymap binds, not `"shift-S"` —
|
||||
/// the uppercase letter already says "shifted"; a redundant prefix would
|
||||
/// make the default `S` binding unreachable from the keyboard (#70).
|
||||
#[test]
|
||||
fn shift_flag_is_redundant_for_an_already_uppercase_char() {
|
||||
let k = InputKey::Char {
|
||||
c: 'S',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: true,
|
||||
};
|
||||
assert_eq!(k.canonical(), "S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_round_trips_specials() {
|
||||
for s in [
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod draft;
|
||||
pub mod emulators;
|
||||
pub mod fast_forward;
|
||||
pub mod input;
|
||||
pub mod keymap;
|
||||
pub mod overlays;
|
||||
@@ -21,6 +22,7 @@ pub mod state;
|
||||
pub mod tabs;
|
||||
pub mod terminal;
|
||||
pub mod text_input;
|
||||
pub mod worktrees;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -14,23 +14,28 @@ use ratatui::Frame;
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KillConfirm {
|
||||
pub label: String,
|
||||
/// #83: what the daemon's watcher last saw in the worktree, phrased by
|
||||
/// [`maestro_protocol::InTheWay`]'s Display — so "this will be refused"
|
||||
/// is visible before the attempt. `None` while nothing blocks.
|
||||
pub blockers: Option<String>,
|
||||
}
|
||||
|
||||
impl KillConfirm {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = centered(parent, 50, 5);
|
||||
let area = centered(parent, 60, if self.blockers.is_some() { 6 } else { 5 });
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" kill ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
f.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(format!("Kill session {}?", self.label)),
|
||||
Line::from(""),
|
||||
Line::from("[y] yes [n] no"),
|
||||
]),
|
||||
inner,
|
||||
);
|
||||
let mut lines = vec![Line::from(format!("Kill session {}?", self.label))];
|
||||
if let Some(blockers) = &self.blockers {
|
||||
// Same phrasing the reclaim confirmation uses for its own guard.
|
||||
lines.push(Line::from(format!("Currently in the way: {blockers}")));
|
||||
lines.push(Line::from("The kill will be refused unless forced."));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("[y] yes [n] no"));
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@ impl Toast {
|
||||
}
|
||||
}
|
||||
|
||||
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
|
||||
pub(crate) fn centered(parent: Rect, w: u16, h: u16) -> Rect {
|
||||
let w = w.min(parent.width);
|
||||
let h = h.min(parent.height);
|
||||
Rect {
|
||||
@@ -121,6 +126,7 @@ mod tests {
|
||||
fn kill_confirm_renders_prompt() {
|
||||
let modal = KillConfirm {
|
||||
label: "session-7".into(),
|
||||
blockers: None,
|
||||
};
|
||||
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
|
||||
assert!(rendered.contains("Kill"));
|
||||
@@ -129,6 +135,22 @@ mod tests {
|
||||
assert!(rendered.contains("no"));
|
||||
}
|
||||
|
||||
/// #83: when the worktree blocks, the confirm names it before the
|
||||
/// operator commits — in `InTheWay`'s own words.
|
||||
#[test]
|
||||
fn kill_confirm_names_what_blocks() {
|
||||
let modal = KillConfirm {
|
||||
label: "session-7".into(),
|
||||
blockers: Some("2 uncommitted files".into()),
|
||||
};
|
||||
let rendered = render_to_string(70, 10, |f| modal.render(f, f.area()));
|
||||
assert!(
|
||||
rendered.contains("Currently in the way: 2 uncommitted files"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("refused"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_overlay_lists_lines() {
|
||||
let help = HelpOverlay {
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::pane::Pane;
|
||||
use crate::sidebar::Sidebar;
|
||||
use crate::state::{AppState, Focus, OverlayKind};
|
||||
use crate::tabs::TabBar;
|
||||
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||
|
||||
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
|
||||
/// coordinate to one of these via [`RegionMap::region_at`].
|
||||
@@ -67,6 +68,8 @@ pub struct RenderInputs<'a> {
|
||||
pub draft: Option<&'a crate::draft::DraftPane>,
|
||||
pub state: &'a AppState,
|
||||
pub help: &'a HelpOverlay,
|
||||
/// Retained-worktree entries for the worktrees view (#85).
|
||||
pub worktrees: Option<&'a WorktreesView>,
|
||||
}
|
||||
|
||||
/// Draw one frame. Returns the `RegionMap` the input router will consult for
|
||||
@@ -143,8 +146,19 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
|
||||
match inputs.state.overlay() {
|
||||
Some(OverlayKind::KillConfirm(id)) => {
|
||||
// #83: surface what the watcher last saw in the worktree, in
|
||||
// InTheWay's own words, before the operator commits to the kill.
|
||||
let blockers = inputs
|
||||
.sidebar
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| &s.id == id)
|
||||
.and_then(|s| s.blocked_work)
|
||||
.filter(|b| !b.is_clear())
|
||||
.map(|b| b.to_string());
|
||||
let kc = KillConfirm {
|
||||
label: id.to_string(),
|
||||
blockers,
|
||||
};
|
||||
kc.render(f, area);
|
||||
map.modal = Some(area);
|
||||
@@ -153,6 +167,22 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
||||
inputs.help.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::Worktrees) => {
|
||||
if let Some(view) = inputs.worktrees {
|
||||
view.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
}
|
||||
Some(OverlayKind::ReclaimConfirm {
|
||||
branch, blockers, ..
|
||||
}) => {
|
||||
let rc = ReclaimConfirm {
|
||||
branch: branch.clone(),
|
||||
blockers: blockers.clone(),
|
||||
};
|
||||
rc.render(f, area);
|
||||
map.modal = Some(area);
|
||||
}
|
||||
Some(OverlayKind::Toast(text)) => {
|
||||
let t = Toast { text: text.clone() };
|
||||
t.render(f, area);
|
||||
@@ -298,6 +328,7 @@ mod tests {
|
||||
draft: Some(draft),
|
||||
state: &state,
|
||||
help: &help,
|
||||
worktrees: None,
|
||||
};
|
||||
|
||||
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
//! Sidebar widget — session list, navigation-only.
|
||||
//! Sidebar widget — session cards per `docs/SIDEBAR.md`.
|
||||
//!
|
||||
//! Status glyphs: ● attached / ○ live / ✗ dead.
|
||||
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||
//! blocked-work and exited fragments when they apply. Absent entirely when
|
||||
//! it has nothing to say.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use maestro_protocol::{Session, SessionStatus};
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
@@ -12,6 +16,32 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
||||
use ratatui::Frame;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Time-in-state alone earns a meta line only once the session has sat in
|
||||
/// its current activity state this long (docs/SIDEBAR.md).
|
||||
const META_LINE_AFTER_SECS: i64 = 30;
|
||||
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
|
||||
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
|
||||
/// renders — so anything implausibly old counts as "never observed" rather
|
||||
/// than a card announcing that the session has been idle for half a century.
|
||||
/// Mirrors `PLAUSIBLE_EPOCH_MS` in `web/src/activity.ts`.
|
||||
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
|
||||
const INDENT: &str = " ";
|
||||
const META_INDENT: &str = " ";
|
||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||
const ACTIONS: &str = "⧉ ✕";
|
||||
|
||||
/// Every card sits on this background so cards read as cards against the
|
||||
/// panel; the highlighted card swaps it for the focus background instead
|
||||
/// (`Modifier::REVERSED` made the card unreadable against a background).
|
||||
const CARD_BG: Color = Color::Black;
|
||||
fn card_base(highlighted: bool) -> Style {
|
||||
if highlighted {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().bg(CARD_BG)
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderable sidebar state.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Sidebar {
|
||||
@@ -41,29 +71,185 @@ impl Sidebar {
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let width = inner.width as usize;
|
||||
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
|
||||
"sessions",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))];
|
||||
for s in &self.sessions {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let label = name_for(s);
|
||||
let style = if self.highlight == Some(s.id) {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("{glyph} {label}"), style),
|
||||
]));
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
lines.push(Line::from(self.name_row(s, width)));
|
||||
if let Some(meta) = self.meta_row(s, width) {
|
||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||
}
|
||||
}
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
|
||||
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||
(SessionStatus::Dead, _) => "✗",
|
||||
(SessionStatus::Running, true) => "●",
|
||||
(SessionStatus::Running, false) => "○",
|
||||
};
|
||||
let highlighted = self.highlight == Some(s.id);
|
||||
let base = card_base(highlighted);
|
||||
// Line-1 actions are keyboard affordances shown on the highlighted
|
||||
// row only; they reserve their width so the name truncates with `…`
|
||||
// before it can ever reach them (docs/SIDEBAR.md).
|
||||
let actions_width = if highlighted {
|
||||
ACTIONS.chars().count()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let usable = width.saturating_sub(actions_width);
|
||||
let prefix = INDENT.chars().count() + 2;
|
||||
// Keep one column of separation between a truncated name and the
|
||||
// actions so the ellipsis never touches them.
|
||||
let sep = if highlighted { 1 } else { 0 };
|
||||
let name_budget = usable.saturating_sub(prefix + sep);
|
||||
|
||||
let mut spans: Vec<Span<'static>> = vec![
|
||||
Span::styled(INDENT, base),
|
||||
Span::styled(format!("{glyph} "), base),
|
||||
];
|
||||
spans.extend(
|
||||
fit_to_width(vec![Span::raw(name_for(s))], name_budget)
|
||||
.into_iter()
|
||||
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
|
||||
);
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
||||
spans.push(Span::styled(
|
||||
" ".repeat(fill),
|
||||
Style::default().bg(base.bg.unwrap_or(Color::Reset)),
|
||||
));
|
||||
}
|
||||
if highlighted {
|
||||
spans.push(Span::styled(ACTIONS, base));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
|
||||
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
|
||||
let now = Utc::now();
|
||||
let held = held_for(s, now);
|
||||
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
||||
// #83: a worktree that blocks a kill is summarised here rather than
|
||||
// on line 1; the kill confirm names the specifics.
|
||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||
let dead = s.status == SessionStatus::Dead;
|
||||
let errored = s.activity == ActivityState::Error;
|
||||
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
|
||||
&& blocked.is_none()
|
||||
&& !dead
|
||||
&& !errored
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let base = card_base(self.highlight == Some(s.id));
|
||||
let dot_style = base.patch(Style::default().fg(activity_color(s.activity)));
|
||||
let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||
let mut spans = vec![
|
||||
Span::styled(META_INDENT, base),
|
||||
Span::styled("●", dot_style),
|
||||
];
|
||||
if let Some(d) = held {
|
||||
spans.push(Span::styled(" ", base));
|
||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||
}
|
||||
// The trailing fragments are measured up front so a long detail
|
||||
// truncates ahead of them instead of pushing them off the line
|
||||
// (docs/SIDEBAR.md).
|
||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(b) = blocked {
|
||||
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||
}
|
||||
if dead {
|
||||
tail.push(Span::styled(" exited", muted));
|
||||
}
|
||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||
let budget = width.saturating_sub(head_w + tail_w);
|
||||
spans.extend(fit_to_width(
|
||||
vec![Span::styled(format!(" {detail}"), muted)],
|
||||
budget,
|
||||
));
|
||||
}
|
||||
spans.extend(tail);
|
||||
Some(fit_to_width(spans, width))
|
||||
}
|
||||
}
|
||||
|
||||
/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state
|
||||
/// word is never spelled out — this colour carries it.
|
||||
fn activity_color(state: ActivityState) -> Color {
|
||||
match state {
|
||||
ActivityState::Active => Color::Green,
|
||||
ActivityState::Idle => Color::DarkGray,
|
||||
ActivityState::AwaitingInput => Color::Yellow,
|
||||
ActivityState::Error => Color::Red,
|
||||
}
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||
/// measured in the current activity state. No days, no rounding up.
|
||||
/// How long the session has held its current activity state, or `None` when
|
||||
/// the daemon never reported one (see [`PLAUSIBLE_SINCE_YEAR`]).
|
||||
fn held_for(s: &Session, now: chrono::DateTime<Utc>) -> Option<chrono::Duration> {
|
||||
use chrono::Datelike;
|
||||
if s.activity_since.year() < PLAUSIBLE_SINCE_YEAR {
|
||||
return None;
|
||||
}
|
||||
Some(now - s.activity_since)
|
||||
}
|
||||
|
||||
fn format_state_duration(elapsed: chrono::Duration) -> String {
|
||||
let secs = elapsed.num_seconds().max(0);
|
||||
match secs {
|
||||
0..=59 => format!("{secs}s"),
|
||||
60..=3599 => format!("{}m", secs / 60),
|
||||
_ => format!("{}h", secs / 3600),
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a span list to `max` columns, ending it with `…` when cut. The
|
||||
/// sidebar is narrow: card lines truncate, they never wrap.
|
||||
fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
||||
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||
let mut used = 0usize;
|
||||
for span in spans {
|
||||
let w = span.width();
|
||||
if used + w <= max {
|
||||
used += w;
|
||||
out.push(span);
|
||||
continue;
|
||||
}
|
||||
let budget = max.saturating_sub(used + 1);
|
||||
let ellipsis = "…";
|
||||
if budget > 0 {
|
||||
let text: String = span.content.chars().take(budget).collect();
|
||||
out.push(Span::styled(format!("{text}{ellipsis}"), span.style));
|
||||
} else if used < max {
|
||||
out.push(Span::styled(ellipsis, span.style));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pad a card row out to the full sidebar width so the card background
|
||||
/// paints edge to edge rather than stopping at the last glyph.
|
||||
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
||||
let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
||||
spans.push(Span::styled(" ".repeat(fill), pad_style));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
/// `metadata.name` if present, else the command line.
|
||||
@@ -78,8 +264,10 @@ fn name_for(s: &Session) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use chrono::{DateTime, Duration};
|
||||
use maestro_protocol::InTheWay;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::Terminal;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -92,28 +280,49 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata,
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String {
|
||||
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
fn rows(buf: &Buffer) -> Vec<String> {
|
||||
(0..buf.area.height)
|
||||
.map(|y| {
|
||||
(0..buf.area.width)
|
||||
.map(|x| buf[(x, y)].symbol())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_to_string(buf: &Buffer) -> String {
|
||||
rows(buf).join("\n")
|
||||
}
|
||||
|
||||
fn row_containing<'a>(rows: &'a [String], needle: &str) -> &'a str {
|
||||
rows.iter()
|
||||
.find(|l| l.contains(needle))
|
||||
.unwrap_or_else(|| panic!("no row containing {needle:?} in {rows:#?}"))
|
||||
}
|
||||
|
||||
/// Row text without the trailing space padding and right border.
|
||||
fn content(row: &str) -> &str {
|
||||
row.trim_end().trim_end_matches('│').trim_end()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -123,7 +332,7 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 12);
|
||||
let rendered = render_to_string(&draw(&sb, 28, 12));
|
||||
assert!(
|
||||
rendered.contains("claude"),
|
||||
"missing session name:\n{rendered}"
|
||||
@@ -142,11 +351,8 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: [sid].into_iter().collect(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("claude row");
|
||||
let all = rows(&draw(&sb, 28, 6));
|
||||
let line = row_containing(&all, "claude");
|
||||
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
|
||||
assert!(
|
||||
!line.contains('○'),
|
||||
@@ -163,14 +369,305 @@ mod tests {
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let line = rendered
|
||||
.lines()
|
||||
.find(|l| l.contains("claude"))
|
||||
.expect("claude row");
|
||||
let buf = draw(&sb, 28, 6);
|
||||
let all = rows(&buf);
|
||||
let line = row_containing(&all, "claude");
|
||||
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
|
||||
}
|
||||
|
||||
/// #83 via docs/SIDEBAR.md: blocked work lives on the meta line now, not
|
||||
/// on the name row; a clear or unprobed worktree never warns.
|
||||
#[test]
|
||||
fn blocked_session_warns_on_its_meta_line() {
|
||||
let mut blocked = session_named(1, "claude");
|
||||
blocked.activity_since = Utc::now() - Duration::seconds(5);
|
||||
blocked.blocked_work = Some(InTheWay {
|
||||
unpushed_commits: 4,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let mut clear = session_named(2, "bash");
|
||||
clear.blocked_work = Some(InTheWay::default());
|
||||
let unprobed = session_named(3, "vim");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![blocked, clear, unprobed],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 10));
|
||||
|
||||
let meta = all
|
||||
.iter()
|
||||
.find(|l| l.contains("⚠"))
|
||||
.expect("blocked card must warn somewhere");
|
||||
assert!(
|
||||
meta.contains("4 unpushed commits"),
|
||||
"warning must carry InTheWay's summary: {meta:?}"
|
||||
);
|
||||
let name_row = row_containing(&all, "claude");
|
||||
assert!(
|
||||
!name_row.contains('⚠'),
|
||||
"warning moved off the name row: {name_row:?}"
|
||||
);
|
||||
for name in ["bash", "vim"] {
|
||||
let idx = all.iter().position(|l| l.contains(name)).unwrap();
|
||||
assert!(
|
||||
!all[idx + 1].contains('⚠'),
|
||||
"{name} must not warn: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A freshly-active session stays one line — never an empty placeholder
|
||||
/// row (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn freshly_active_session_stays_one_line() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity_since = Utc::now() - Duration::seconds(10);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
assert_eq!(content(&all[0]), "sessions");
|
||||
assert!(row_containing(&all, "claude").contains('○'));
|
||||
assert!(
|
||||
content(&all[2]).is_empty(),
|
||||
"line 2 must be absent, got {:?}",
|
||||
all[2]
|
||||
);
|
||||
}
|
||||
|
||||
/// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the
|
||||
/// meta line, with the floored duration next to the dot.
|
||||
#[test]
|
||||
fn long_held_session_gets_a_meta_line_with_duration() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity_since = Utc::now() - Duration::seconds(90);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(meta.contains("1m"), "expected 1m in: {meta:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_format_floors_whole_units() {
|
||||
let fmt = |secs| format_state_duration(Duration::seconds(secs));
|
||||
assert_eq!(fmt(0), "0s");
|
||||
assert_eq!(fmt(45), "45s");
|
||||
assert_eq!(fmt(59), "59s");
|
||||
assert_eq!(fmt(60), "1m");
|
||||
assert_eq!(fmt(240), "4m");
|
||||
assert_eq!(fmt(3599), "59m");
|
||||
assert_eq!(fmt(3600), "1h");
|
||||
assert_eq!(fmt(7200), "2h");
|
||||
}
|
||||
|
||||
/// The dot and duration carry the activity state's colour; the state word
|
||||
/// is never spelled out (docs/SIDEBAR.md colour table).
|
||||
#[test]
|
||||
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||
for (state, expected) in [
|
||||
(ActivityState::Active, Color::Green),
|
||||
(ActivityState::Idle, Color::DarkGray),
|
||||
(ActivityState::AwaitingInput, Color::Yellow),
|
||||
(ActivityState::Error, Color::Red),
|
||||
] {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = state;
|
||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let found = (0..buf.area.height)
|
||||
.any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().fg == Some(expected)));
|
||||
assert!(found, "expected some cell in fg {expected:?} for {state:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
||||
/// even freshly dead, with the muted `exited` fragment.
|
||||
#[test]
|
||||
fn dead_session_shows_exited_on_its_meta_line() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dead;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
assert!(
|
||||
all[idx + 1].contains("exited"),
|
||||
"expected exited fragment in: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
|
||||
/// The sidebar is narrow: the meta line truncates with `…` and never
|
||||
/// wraps onto a third row (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn meta_line_truncates_instead_of_wrapping() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.blocked_work = Some(InTheWay {
|
||||
uncommitted_files: 120,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(
|
||||
meta.trim_end().ends_with('…'),
|
||||
"truncated meta line must end with …: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
"meta line must not wrap: {:?}",
|
||||
all[idx + 2]
|
||||
);
|
||||
}
|
||||
|
||||
/// #112: the agent-reported detail sits right after the duration on the
|
||||
/// meta line, in the muted style (docs/SIDEBAR.md).
|
||||
#[test]
|
||||
fn errored_session_shows_its_reported_detail_on_the_meta_line() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = ActivityState::Error;
|
||||
s.activity_since = Utc::now() - Duration::seconds(90);
|
||||
s.activity_detail = Some("cargo build failed".into());
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = &all[idx + 1];
|
||||
assert!(meta.contains('●'), "meta line carries the dot: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("1m"),
|
||||
"duration precedes the detail: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
meta.contains("cargo build failed"),
|
||||
"reported detail must render: {meta:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A fresh Error report earns line 2 on its own (contract rule 4), with
|
||||
/// the detail even though no duration has accrued.
|
||||
#[test]
|
||||
fn fresh_errored_session_earns_a_meta_line_for_its_detail() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.activity = ActivityState::Error;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.activity_detail = Some("waiting for approval".into());
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
assert!(
|
||||
all[idx + 1].contains("waiting for approval"),
|
||||
"detail must render on a fresh report's own meta line: {:?}",
|
||||
all[idx + 1]
|
||||
);
|
||||
}
|
||||
|
||||
/// #112 via docs/SIDEBAR.md: the detail truncates with `…` ahead of the
|
||||
/// trailing fragments — a long detail never pushes blocked work or
|
||||
/// `exited` off the line, and never wraps.
|
||||
#[test]
|
||||
fn long_detail_yields_to_blocked_work_and_exited() {
|
||||
let mut s = session_named(1, "claude");
|
||||
s.status = SessionStatus::Dead;
|
||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||
s.activity_detail =
|
||||
Some("an absurdly long error message that cannot possibly fit ".repeat(3));
|
||||
s.blocked_work = Some(InTheWay {
|
||||
unpushed_commits: 2,
|
||||
..InTheWay::default()
|
||||
});
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 40, 8));
|
||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let meta = content(&all[idx + 1]);
|
||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||
assert!(
|
||||
meta.contains("2 unpushed commits"),
|
||||
"blocked work stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
meta.contains("exited"),
|
||||
"exited fragment stays on the line: {meta:?}"
|
||||
);
|
||||
assert!(
|
||||
content(&all[idx + 2]).trim().is_empty(),
|
||||
"meta line must not wrap: {:?}",
|
||||
all[idx + 2]
|
||||
);
|
||||
}
|
||||
|
||||
/// docs/SIDEBAR.md: a payload predating #102 carries an epoch
|
||||
/// `activity_since` and so contributes no duration — a card must not claim
|
||||
/// the session has been idle since 1970. The dot still renders when some
|
||||
/// other trigger puts line 2 on screen.
|
||||
#[test]
|
||||
fn epoch_activity_since_renders_no_duration() {
|
||||
let mut legacy = session_named(1, "claude");
|
||||
legacy.activity_since = DateTime::from_timestamp(0, 0).unwrap();
|
||||
legacy.status = SessionStatus::Dead;
|
||||
let sb = Sidebar {
|
||||
sessions: vec![legacy],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&draw(&sb, 40, 8));
|
||||
assert!(
|
||||
rendered.contains("exited"),
|
||||
"dead session must still show line 2:\n{rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains('h') || !rendered.contains("● "),
|
||||
"no duration may follow the dot for an epoch stamp:\n{rendered}"
|
||||
);
|
||||
for line in rendered.lines().filter(|l| l.contains('●')) {
|
||||
assert!(
|
||||
!line
|
||||
.split('●')
|
||||
.nth(1)
|
||||
.unwrap_or("")
|
||||
.trim_start()
|
||||
.starts_with(char::is_numeric),
|
||||
"duration rendered from an epoch stamp: {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_command_when_name_metadata_is_absent() {
|
||||
let s = Session {
|
||||
@@ -179,23 +676,166 @@ mod tests {
|
||||
cwd: std::path::PathBuf::from("/tmp"),
|
||||
env: Default::default(),
|
||||
metadata: HashMap::new(),
|
||||
worktree: None,
|
||||
status: SessionStatus::Running,
|
||||
created_at: Utc::now(),
|
||||
foreground: None,
|
||||
activity: Default::default(),
|
||||
last_activity_at: chrono::Utc::now(),
|
||||
|
||||
activity_detail: None,
|
||||
last_activity_at: Utc::now(),
|
||||
activity_since: Utc::now(),
|
||||
current_cwd: None,
|
||||
blocked_work: None,
|
||||
};
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let rendered = render_to_string(&sb, 28, 6);
|
||||
let rendered = render_to_string(&draw(&sb, 28, 6));
|
||||
assert!(
|
||||
rendered.contains("vim"),
|
||||
"missing fallback command name:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Reversed video makes a card unreadable against a background; cards
|
||||
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
||||
#[test]
|
||||
fn no_cell_is_ever_reversed() {
|
||||
let mut highlighted = session_named(1, "claude");
|
||||
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
||||
let sb = Sidebar {
|
||||
sessions: vec![highlighted],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
assert!(
|
||||
!buf[(x, y)]
|
||||
.style()
|
||||
.add_modifier
|
||||
.contains(Modifier::REVERSED),
|
||||
"reversed video at ({x},{y})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The highlighted card gets the focus background; every other card gets
|
||||
/// a card background distinct from the panel's default.
|
||||
#[test]
|
||||
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
|
||||
let one = session_named(1, "claude");
|
||||
let two = session_named(2, "bash");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let all = rows(&buf);
|
||||
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().bg,
|
||||
Some(Color::Cyan),
|
||||
"focused card must sit on the focus background"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, claude_y as u16)].style().fg,
|
||||
Some(Color::Black),
|
||||
"focused card text must stay readable on cyan"
|
||||
);
|
||||
assert_eq!(
|
||||
buf[(0, bash_y as u16)].style().bg,
|
||||
Some(Color::Black),
|
||||
"unfocused cards must still have a card background"
|
||||
);
|
||||
// The panel itself (header row) stays on the terminal default.
|
||||
assert!(
|
||||
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
|
||||
"panel must keep the terminal default background"
|
||||
);
|
||||
}
|
||||
|
||||
/// The card background paints the full card width, not just the glyphs.
|
||||
#[test]
|
||||
fn card_background_covers_the_full_card_width() {
|
||||
let s = session_named(1, "claude");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: None,
|
||||
attached: Default::default(),
|
||||
};
|
||||
let buf = draw(&sb, 28, 8);
|
||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||
for x in 0..27 {
|
||||
assert_eq!(
|
||||
buf[(x, y)].style().bg,
|
||||
Some(Color::Black),
|
||||
"gap in card background at col {x}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Line 1's actions live on the highlighted row's top-right corner.
|
||||
#[test]
|
||||
fn highlighted_row_anchors_the_actions_top_right() {
|
||||
let sb = Sidebar {
|
||||
sessions: vec![session_named(1, "claude")],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = content(row_containing(&all, "claude"));
|
||||
assert!(
|
||||
line.ends_with("⧉ ✕"),
|
||||
"actions must sit at the right edge: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhighlighted_rows_show_no_actions() {
|
||||
let one = session_named(1, "claude");
|
||||
let two = session_named(2, "bash");
|
||||
let sb = Sidebar {
|
||||
sessions: vec![one, two],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "bash");
|
||||
assert!(
|
||||
!line.contains('⧉') && !line.contains('✕'),
|
||||
"actions are highlight-only: {line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The name truncates with `…` before it can ever reach the reserved
|
||||
/// action width (docs/SIDEBAR.md — overlap is a defect).
|
||||
#[test]
|
||||
fn name_truncates_before_reaching_the_actions() {
|
||||
let mut s = session_named(1, "claude");
|
||||
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
|
||||
*name = "an-unreasonably-long-session-name".into();
|
||||
}
|
||||
let sb = Sidebar {
|
||||
sessions: vec![s],
|
||||
highlight: Some(Uuid::from_u128(1)),
|
||||
attached: Default::default(),
|
||||
};
|
||||
let all = rows(&draw(&sb, 28, 8));
|
||||
let line = row_containing(&all, "an-unreasonably");
|
||||
let name_end = line.find('…').expect("name must truncate with …");
|
||||
let actions_start = line.find('⧉').expect("actions must still render");
|
||||
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
|
||||
assert!(
|
||||
line.split_at(actions_start).0.ends_with(' '),
|
||||
"no gap before the actions: {line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,17 @@ pub enum OverlayKind {
|
||||
KillConfirm(Uuid),
|
||||
Help,
|
||||
Toast(String),
|
||||
/// The retained-worktrees view (#85); its entries live on `App` because
|
||||
/// they are daemon data, not UI state.
|
||||
Worktrees,
|
||||
/// Reclaim confirmation for the worktree with this id (#85), guarding
|
||||
/// the destructive key like [`OverlayKind::KillConfirm`] does.
|
||||
ReclaimConfirm {
|
||||
id: Uuid,
|
||||
branch: String,
|
||||
/// [`maestro_protocol::InTheWay`] as of when the confirm was opened.
|
||||
blockers: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Retained-worktrees view (#85): an overlay listing every retained worktree
|
||||
//! — branch, abbreviated path, reason, what is in the way — with j/k to move,
|
||||
//! `r` to reclaim behind a confirmation, esc to close.
|
||||
//!
|
||||
//! The in-the-way summary comes straight from [`InTheWay`]'s `Display` impl,
|
||||
//! the same source the kill refusal and the web client use, so no two clients
|
||||
//! can describe the same tree differently.
|
||||
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
|
||||
use ratatui::Frame;
|
||||
|
||||
use maestro_protocol::{RetainedReason, RetainedWorktree};
|
||||
|
||||
use crate::draft::abbreviate_path;
|
||||
|
||||
/// The open worktrees overlay: its entries and which one is highlighted.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct WorktreesView {
|
||||
pub items: Vec<RetainedWorktree>,
|
||||
pub highlight: usize,
|
||||
/// Daemon-reported home dir, for `~` abbreviation of paths.
|
||||
pub home_dir: String,
|
||||
}
|
||||
|
||||
impl WorktreesView {
|
||||
/// Replace the listing and keep the highlight pointing at something real.
|
||||
pub fn apply(&mut self, items: Vec<RetainedWorktree>) {
|
||||
self.items = items;
|
||||
if !self.items.is_empty() {
|
||||
self.highlight = self.highlight.min(self.items.len() - 1);
|
||||
} else {
|
||||
self.highlight = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, id: uuid::Uuid) {
|
||||
let idx = self.items.iter().position(|w| w.id == id);
|
||||
self.items.retain(|w| w.id != id);
|
||||
if let Some(i) = idx {
|
||||
self.highlight = self.highlight.min(self.items.len().saturating_sub(1));
|
||||
let _ = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the highlight by `delta` (positive = down). Wraps. No-op on empty.
|
||||
pub fn move_highlight(&mut self, delta: i32) {
|
||||
if self.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let len = self.items.len() as i32;
|
||||
let next = (self.highlight as i32 + delta).rem_euclid(len);
|
||||
self.highlight = next as usize;
|
||||
}
|
||||
|
||||
pub fn selected(&self) -> Option<&RetainedWorktree> {
|
||||
self.items.get(self.highlight)
|
||||
}
|
||||
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let h = (self.items.len() as u16 + 6).clamp(4, parent.height);
|
||||
let area = super::overlays::centered(parent, parent.width.saturating_sub(4), h);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.title(" retained worktrees ")
|
||||
.borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if self.items.is_empty() {
|
||||
// A drained store is the normal state — say so plainly, not like
|
||||
// something went wrong.
|
||||
lines.push(Line::from("no retained worktrees"));
|
||||
} else {
|
||||
lines.push(Line::from(format!(
|
||||
" {:<26} {:<34} {:<24} IN THE WAY",
|
||||
"BRANCH", "PATH", "REASON"
|
||||
)));
|
||||
for (i, w) in self.items.iter().enumerate() {
|
||||
let marker = if i == self.highlight { ">" } else { " " };
|
||||
lines.push(Line::from(format!(
|
||||
"{marker} {:<25} {:<34} {:<24} {}",
|
||||
w.branch,
|
||||
abbreviate_path(&w.path, &self.home_dir),
|
||||
reason_label(w.reason),
|
||||
w.in_the_way,
|
||||
)));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from("j/k: select r: reclaim esc: close"));
|
||||
f.render_widget(Paragraph::new(lines), inner);
|
||||
}
|
||||
}
|
||||
|
||||
/// The confirmation guarding reclaim, mirroring [`super::overlays::KillConfirm`].
|
||||
/// It names the branch and says out loud what reclaim does not take with it —
|
||||
/// docs/WORKTREES.md: "reclaim" otherwise sounds like it takes the commits too.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReclaimConfirm {
|
||||
pub branch: String,
|
||||
/// [`maestro_protocol::InTheWay`] rendered via its `Display` impl.
|
||||
pub blockers: String,
|
||||
}
|
||||
|
||||
impl ReclaimConfirm {
|
||||
pub fn render(&self, f: &mut Frame, parent: Rect) {
|
||||
let area = super::overlays::centered(parent, 70, 7);
|
||||
f.render_widget(Clear, area);
|
||||
let block = Block::default().title(" reclaim ").borders(Borders::ALL);
|
||||
let inner = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
f.render_widget(
|
||||
ratatui::widgets::Paragraph::new(vec![
|
||||
Line::from(format!("Reclaim worktree {}?", self.branch)),
|
||||
Line::from(format!("Currently in the way: {}", self.blockers)),
|
||||
Line::from("The directory is removed; the branch is never deleted."),
|
||||
Line::from(""),
|
||||
Line::from("[y] yes [n] no"),
|
||||
]),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn reason_label(reason: RetainedReason) -> &'static str {
|
||||
match reason {
|
||||
RetainedReason::ForceKilled => "force-killed",
|
||||
RetainedReason::ExitedWithUnsavedWork => "exited-with-unsaved-work",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::{TimeZone, Utc};
|
||||
use maestro_protocol::{InTheWay, RetainedReason};
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
|
||||
where
|
||||
F: FnOnce(&mut Frame),
|
||||
{
|
||||
let backend = TestBackend::new(w, h);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
terminal.draw(draw).unwrap();
|
||||
let buf = terminal.backend().buffer().clone();
|
||||
let mut out = String::new();
|
||||
for y in 0..buf.area.height {
|
||||
for x in 0..buf.area.width {
|
||||
out.push_str(buf[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn worktree(branch: &str, path: &str, id: u128) -> RetainedWorktree {
|
||||
RetainedWorktree {
|
||||
id: uuid::Uuid::from_u128(id),
|
||||
path: path.into(),
|
||||
base_repo: "/home/naps/tea/maestro".into(),
|
||||
branch: branch.into(),
|
||||
reason: RetainedReason::ForceKilled,
|
||||
retained_at: Utc.with_ymd_and_hms(2026, 8, 24, 10, 0, 0).unwrap(),
|
||||
in_the_way: InTheWay::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_lists_branch_path_reason_and_what_is_in_the_way() {
|
||||
let mut view = WorktreesView {
|
||||
home_dir: "/home/naps".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut wt = worktree(
|
||||
"feat/login",
|
||||
"/home/naps/tea/maestro/worktrees/feat/login",
|
||||
1,
|
||||
);
|
||||
wt.in_the_way = InTheWay {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
};
|
||||
view.apply(vec![wt]);
|
||||
|
||||
let rendered = render_to_string(160, 20, |f| view.render(f, f.area()));
|
||||
assert!(rendered.contains("feat/login"), "{rendered}");
|
||||
// Path abbreviated against the daemon's home dir, same helper as the
|
||||
// draft pane's project list.
|
||||
assert!(rendered.contains("~/t/m/w/f/login"), "{rendered}");
|
||||
assert!(rendered.contains("force-killed"), "{rendered}");
|
||||
// Phrasing comes from InTheWay's Display impl — shared with the kill
|
||||
// refusal and the web client.
|
||||
assert!(
|
||||
rendered.contains("2 uncommitted files, 1 untracked file, 3 unpushed commits"),
|
||||
"{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_store_looks_like_a_normal_state_not_an_error() {
|
||||
let view = WorktreesView::default();
|
||||
let rendered = render_to_string(80, 12, |f| view.render(f, f.area()));
|
||||
assert!(rendered.contains("no retained worktrees"), "{rendered}");
|
||||
assert!(!rendered.contains("error"), "{rendered}");
|
||||
assert!(!rendered.contains("failed"), "{rendered}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_moves_and_wraps() {
|
||||
let mut view = WorktreesView::default();
|
||||
view.apply(vec![
|
||||
worktree("a", "/r/a", 1),
|
||||
worktree("b", "/r/b", 2),
|
||||
worktree("c", "/r/c", 3),
|
||||
]);
|
||||
assert_eq!(view.selected().unwrap().branch, "a");
|
||||
view.move_highlight(1);
|
||||
assert_eq!(view.selected().unwrap().branch, "b");
|
||||
view.move_highlight(-1);
|
||||
assert_eq!(view.selected().unwrap().branch, "a");
|
||||
view.move_highlight(-1);
|
||||
// Wraps backward to the last entry.
|
||||
assert_eq!(view.selected().unwrap().branch, "c");
|
||||
view.move_highlight(1);
|
||||
assert_eq!(view.selected().unwrap().branch, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_clamps_a_stale_highlight() {
|
||||
let mut view = WorktreesView::default();
|
||||
view.apply(vec![worktree("a", "/r/a", 1), worktree("b", "/r/b", 2)]);
|
||||
view.move_highlight(1);
|
||||
view.apply(vec![worktree("c", "/r/c", 3)]);
|
||||
assert_eq!(view.selected().unwrap().branch, "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reclaim_confirm_names_the_branch_and_says_it_survives() {
|
||||
let confirm = ReclaimConfirm {
|
||||
branch: "feat/login".into(),
|
||||
blockers: "2 uncommitted files".into(),
|
||||
};
|
||||
let rendered = render_to_string(80, 14, |f| confirm.render(f, f.area()));
|
||||
assert!(
|
||||
rendered.contains("Reclaim worktree feat/login?"),
|
||||
"{rendered}"
|
||||
);
|
||||
assert!(rendered.contains("2 uncommitted files"), "{rendered}");
|
||||
assert!(rendered.contains("never deleted"), "{rendered}");
|
||||
assert!(rendered.contains("yes"), "{rendered}");
|
||||
assert!(rendered.contains("no"), "{rendered}");
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,9 @@ enum Command {
|
||||
/// Kill a session by ID.
|
||||
#[cfg(feature = "cli")]
|
||||
Kill(maestro_cli::cli::KillArgs),
|
||||
/// Install or update the agent activity hook (#106).
|
||||
#[cfg(feature = "cli")]
|
||||
Hooks(maestro_cli::cli::HooksArgs),
|
||||
/// Per-host daemon: tmux wrapper + session API.
|
||||
#[cfg(feature = "daemon")]
|
||||
Daemon {
|
||||
@@ -123,6 +126,15 @@ async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
)
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "cli")]
|
||||
Command::Hooks(args) => {
|
||||
maestro_cli::dispatch(
|
||||
maestro_cli::cli::Command::Hooks(args),
|
||||
cli.config,
|
||||
cli.daemon_url,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
#[cfg(feature = "daemon")]
|
||||
Command::Daemon { cmd } => maestro_daemon::cli::dispatch(cli.config, cmd).await?,
|
||||
#[cfg(feature = "tui")]
|
||||
@@ -157,7 +169,11 @@ fn init_tracing(cli: &Cli) {
|
||||
fn is_cli_subcommand(command: &Command) -> bool {
|
||||
matches!(
|
||||
command,
|
||||
Command::List(_) | Command::Spawn(_) | Command::Attach(_) | Command::Kill(_)
|
||||
Command::List(_)
|
||||
| Command::Spawn(_)
|
||||
| Command::Attach(_)
|
||||
| Command::Kill(_)
|
||||
| Command::Hooks(_)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# SIDEBAR.md — the session card contract
|
||||
|
||||
One rule for how a session is rendered as a card in the sidebar, so the web
|
||||
sidebar (#107) and the TUI sidebar (#108) describe a session the same way.
|
||||
Read this before changing either. Both clients implement exactly what is
|
||||
written here; if it needs to change, change it here first.
|
||||
|
||||
A card is always two lines: **line 1** and **line 2**.
|
||||
|
||||
## Line 1 — name + actions
|
||||
|
||||
- Left: the session name — `metadata.name` if present, else the command line
|
||||
(same fallback both clients already use).
|
||||
- Right: two actions anchored to the top-right corner: **spawn similar**
|
||||
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
|
||||
the highlighted row; in the web they are buttons. The glyphs are shared so
|
||||
muscle memory transfers.
|
||||
- Actions reserve their fixed width; the name truncates with `…` before it can
|
||||
ever reach them. **Overlap between the name and the actions is a defect** —
|
||||
that overlap is what started this milestone.
|
||||
|
||||
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
|
||||
badge move off this row into line 2.
|
||||
|
||||
## Line 2 — meta line (adaptive)
|
||||
|
||||
Line 2 always renders — never an empty placeholder row either, since its
|
||||
unconditional fragments (see below) always have something to show. Its
|
||||
*fragments* are adaptive: some show only when their trigger holds. The line
|
||||
itself is not.
|
||||
|
||||
Line 2 reads left to right:
|
||||
|
||||
```
|
||||
<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>
|
||||
```
|
||||
|
||||
Its minimum content is therefore the dot and the command.
|
||||
|
||||
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
|
||||
state's colour, and `<duration>` follows in the same colour when shown. There
|
||||
is no state word and no second badge: the dot exists so the state is still
|
||||
legible when a row is dimmed or the terminal renders the duration in the
|
||||
default fg. The dot is an unconditional part of line 2; the bracketed
|
||||
fragments appear only when their trigger holds.
|
||||
|
||||
`<duration>` renders only once the session has been held in its current
|
||||
activity state longer than **30 seconds** (`activity_since` from #102) — under
|
||||
that threshold the duration is absent and the dot renders alone. A payload
|
||||
that predates #102 (no usable `activity_since`) likewise contributes no
|
||||
duration — the dot still renders. This rule now governs only the duration
|
||||
fragment; it no longer decides whether line 2 exists.
|
||||
|
||||
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
|
||||
`Error`. It sits right after the duration, in the muted style, and shows only
|
||||
while an agent-reported state is in force (see Precedence): when the report
|
||||
lapses at its TTL or is replaced, the detail goes with it. The detail
|
||||
truncates with `…` ahead of the trailing fragments — a long detail never
|
||||
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
|
||||
client ever lets it wrap.
|
||||
|
||||
`<command>` is unconditional and muted: the session's command line, shown on
|
||||
every card in both clients — no omission in the TUI, no name-vs-command
|
||||
comparison gating it. A session named by its command line then shows the same
|
||||
string on both lines; that is accepted, the price of a row that never changes
|
||||
height.
|
||||
|
||||
## Colour per activity state
|
||||
|
||||
The state word is never spelled out — the colour carries it.
|
||||
|
||||
| Activity state | Web token | ratatui colour |
|
||||
| ---------------- | --------- | ----------------- |
|
||||
| `Active` | `--live` | `Color::Green` |
|
||||
| `Idle` | `--muted` | `Color::DarkGray` |
|
||||
| `AwaitingInput` | `--warn` | `Color::Yellow` |
|
||||
| `Error` | `--danger`| `Color::Red` |
|
||||
|
||||
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
|
||||
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
|
||||
named colours above; do not introduce RGB values or a new palette module for
|
||||
this.
|
||||
|
||||
The duration text inherits its state's colour; the blocked-work, exited and
|
||||
command fragments use the muted colour (`--faint` / default style), since the
|
||||
⚠ glyph already signals severity.
|
||||
|
||||
## Duration format
|
||||
|
||||
Time spent in the **current activity state**, sourced from `activity_since`
|
||||
(#102) — *not* time since spawn, and *not* time since last PTY output
|
||||
(`last_activity_at`). Both clients compute it identically:
|
||||
|
||||
- `< 60s` → whole seconds, floored: `30s`
|
||||
- `< 60m` → whole minutes, floored: `4m`
|
||||
- `≥ 60m` → whole hours, floored: `2h`
|
||||
|
||||
No days unit, no seconds inside minutes, no rounding up. Floor everywhere so
|
||||
the counter never shows a duration the state hasn't actually reached yet.
|
||||
Clock granularity: re-render at least once every 30s while visible so the
|
||||
cutover points land within one tick.
|
||||
|
||||
## Precedence
|
||||
|
||||
An agent-reported `ActivityState` (#105) overrides the daemon's inference for
|
||||
**five minutes** (the TTL is fixed by the daemon; re-reporting extends it).
|
||||
When the TTL expires, inference resumes and the session's reported detail —
|
||||
if any — clears with the report. Clients never blend the two: whichever source
|
||||
is in force supplies both the colour and the clock for `activity_since`.
|
||||
|
||||
Two rulings specific to a reported `Error` (#114):
|
||||
|
||||
- **The five-minute TTL applies to `Error` like to any other state.** A
|
||||
session that failed is still failed after five minutes, but a card pinned
|
||||
red forever lies once the session moves on; when the report lapses the card
|
||||
returns to the daemon's own inference — usually `Idle` for a session that
|
||||
has gone quiet.
|
||||
- **Only a new report clears an `Error` before its TTL.** The next
|
||||
`ActivityState` of any kind replaces it (with #106's hook that is typically
|
||||
`PreToolUse` → `Active` when the user prompts again). PTY output and
|
||||
process changes do not clear it — only the agent itself may withdraw what
|
||||
the agent itself reported. Re-reporting `Error` extends the TTL rather than
|
||||
stacking reports.
|
||||
|
||||
If a client receives an `ActivityState` variant it does not know, it must not
|
||||
fail rendering: treat it as `Idle` (neutral/dim, line-2 rules apply as usual)
|
||||
until the daemon sends a variant it does know. Rust clients deserialize
|
||||
activity leniently for the same reason.
|
||||
|
||||
Leniency runs one way only. It covers a client *reading* a newer daemon, where
|
||||
dropping the whole session would be worse than dimming one card. A report
|
||||
arriving at `POST /api/sessions/:id/activity` is parsed strictly and an unknown
|
||||
state is a 4xx: an agent posting a state the daemon does not know is a bug in
|
||||
the agent, and answering 204 would hide it.
|
||||
@@ -0,0 +1,143 @@
|
||||
# Worktrees
|
||||
|
||||
Maestro can create a git worktree for a session and is responsible for it from
|
||||
that moment until the worktree is removed. This document is the contract for
|
||||
that lifetime: where a worktree goes, what ends a session, what survives, and
|
||||
what the operator has to do by hand.
|
||||
|
||||
It exists because the rules here are not guessable from the code. Every rule
|
||||
below was decided once, re-litigated in review, and decided the same way again.
|
||||
The reasoning is written down alongside each rule so the next reader does not
|
||||
have to reconstruct it.
|
||||
|
||||
## Nothing is ever adopted
|
||||
|
||||
The daemon only ever removes a worktree it created in this session's lifetime or
|
||||
recorded in its retained store. It never takes ownership of a directory it finds
|
||||
on disk, whatever that directory looks like.
|
||||
|
||||
There is no reliable way to ask "is this worktree mine?". A git worktree carries
|
||||
no marker distinguishing one a daemon made from one a human made ten minutes
|
||||
earlier, and any heuristic that answers the question will eventually answer it
|
||||
wrong — in the direction of deleting somebody's work.
|
||||
|
||||
So the daemon does not ask. If the target path is occupied by anything at all, it
|
||||
picks a different path.
|
||||
|
||||
## Placement
|
||||
|
||||
A worktree for branch `<branch>` of repository `<repo>` goes to
|
||||
`<repo>/worktrees/<branch>`, where `<repo>` is the main checkout — not whichever
|
||||
worktree the request happened to come from.
|
||||
|
||||
The branch name is used verbatim as a relative path, so `feature/login` becomes
|
||||
`worktrees/feature/login`. A branch name that cannot be a relative path falls
|
||||
back to a short hash of the branch.
|
||||
|
||||
If that path exists, the daemon appends `-2`, then `-3`, and so on until it finds
|
||||
a free one. The suffix names the **directory only**. The branch is whatever was
|
||||
requested; a second worktree of the same branch is not what happens here, because
|
||||
git refuses to check the same branch out twice and the branch picker already
|
||||
marks branches that are checked out elsewhere.
|
||||
|
||||
### The `worktree_root` override
|
||||
|
||||
`[daemon].worktree_root` moves every worktree out of the repository and under one
|
||||
root. The per-repository directory is the repository's basename plus a short
|
||||
digest of its full path — the digest is what keeps two repositories with the same
|
||||
basename apart. Inside it the layout and the collision suffix are the same.
|
||||
|
||||
## What ends a session
|
||||
|
||||
`DELETE /api/sessions/:id` is **refused** when the session's worktree holds
|
||||
unsaved work, meaning any of:
|
||||
|
||||
- uncommitted changes to tracked files
|
||||
- untracked files that are not ignored
|
||||
- commits on the branch reachable from no remote-tracking ref
|
||||
|
||||
Ignored files never block. Every Rust worktree has a `target/`; if ignored files
|
||||
counted, no session would ever be killable.
|
||||
|
||||
The refusal is a structured error naming what blocked it and how much of it there
|
||||
is, so a client can say "3 uncommitted files, 2 unpushed commits". The session
|
||||
keeps running and the worktree is untouched.
|
||||
|
||||
The point of refusing rather than cleaning up is that the session stays as the
|
||||
operator's handle on their own unfinished work. A session that is killed while
|
||||
its worktree still matters leaves a directory nobody is looking at.
|
||||
|
||||
A consequence, accepted deliberately: a feature branch almost always has unpushed
|
||||
commits, so `--force` is the ordinary way to end a session, not an exception.
|
||||
That is only safe because force retains.
|
||||
|
||||
### `--force`
|
||||
|
||||
`--force` overrides the refusal and nothing else. It ends the session and
|
||||
**retains** the worktree, which enters the retained store with reason
|
||||
`force-killed`. Force never removes a tree that would have blocked. A tree
|
||||
whose guard is clear never needed the override, so a forced kill removes it as
|
||||
usual — force changes what happens to blocked work, not ordinary cleanup.
|
||||
|
||||
Ending a session and destroying its work are two separate destructive acts.
|
||||
Each is chosen on its own, and the second one — reclaim — is chosen while
|
||||
looking at exactly what would be lost.
|
||||
|
||||
A forced kill answers with what was retained and why, so a client can say where
|
||||
the work went rather than leaving the operator to wonder.
|
||||
|
||||
### Exiting on its own
|
||||
|
||||
A session that ends by itself — the command finished, the operator typed `exit` —
|
||||
never reaches the kill path. When the watcher notices the tmux session is gone it
|
||||
applies the same rule: a pristine worktree is removed, anything else is retained
|
||||
with reason `exited-with-unsaved-work`.
|
||||
|
||||
Nobody is waiting for an answer here, so there is nothing to refuse; the choice is
|
||||
only between removing and retaining. It uses the same predicate as the kill, from
|
||||
the same place, so the two paths cannot drift apart.
|
||||
|
||||
The session itself stays in the catalog marked dead until it is dismissed — the
|
||||
replay buffer of a crashed agent outlives it — but its worktree is settled at
|
||||
once. Once settled, the session drops its worktree reference, so dismissing it
|
||||
afterwards neither refuses (there is no guard left to trip) nor records a second
|
||||
store entry.
|
||||
|
||||
If settling is impossible — the probe fails, or the store cannot take the entry —
|
||||
nothing is destroyed. The tree stays on disk *and* stays attached to the dead
|
||||
record, where dismissing it later still goes through `--force`'s
|
||||
retain-before-teardown path. Losing the store must never mean losing the work.
|
||||
|
||||
## The retained store
|
||||
|
||||
A `state_dir`-backed record of worktrees the daemon created that no longer have a
|
||||
session. For each one: the path, the base repository, the branch, why it was
|
||||
retained, and when.
|
||||
|
||||
It survives a daemon restart. It is the recovery path for everything above — a
|
||||
store that is lost on restart is worse than no store, because the worktrees are
|
||||
still on disk with nothing pointing at them.
|
||||
|
||||
Entries leave when the worktree is reclaimed, and when the daemon notices the
|
||||
directory is gone because somebody removed it by hand.
|
||||
|
||||
**It is not an ownership record.** It must never be consulted to decide whether a
|
||||
path may be adopted — see the first rule. Its only job is to remember what the
|
||||
daemon is holding so that it can be listed and reclaimed.
|
||||
|
||||
## Reclaim
|
||||
|
||||
`GET /api/worktrees` lists the store: path, base repository, branch, reason, when,
|
||||
and what is currently in the way. The in-the-way breakdown is recomputed on read,
|
||||
not stored, so a tree whose work has since been pushed says so.
|
||||
|
||||
`DELETE /api/worktrees/<id>` reclaims one. It removes the directory and drops the
|
||||
store entry. It does **not** consult the guard — reclaim is the deliberate
|
||||
destructive act, and the operator chose it while looking at what was in the way.
|
||||
|
||||
It never deletes the branch. `git worktree remove` leaves the ref, and reclaiming
|
||||
a tree must not throw away commits on it. This is worth saying out loud in every
|
||||
client, because "reclaim" otherwise sounds like it takes the commits too.
|
||||
|
||||
Nothing is reclaimed automatically. There is no age-based expiry and no bulk
|
||||
reclaim.
|
||||
@@ -125,7 +125,7 @@
|
||||
pnpmDeps = (pkgs.fetchPnpmDeps.override { pnpm = pkgs.pnpm_10; }) {
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Lhi41AtCK69SM4+7zih19+Njf7o2hyXrAV0W6VO9YxU=";
|
||||
hash = "sha256-D9JdgI92vzAY8en2zGEEPd4hwOLVi3sr1joT4b17Be4=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
activityColorSlug,
|
||||
activityDurationLabel,
|
||||
activityHeldMs,
|
||||
showsMetaLine,
|
||||
} from "./activity.js";
|
||||
import type { Session } from "./types.js";
|
||||
|
||||
describe("activityHeldMs", () => {
|
||||
it("measures time spent in the current state", () => {
|
||||
const now = Date.now();
|
||||
const since = new Date(now - 90_000).toISOString();
|
||||
expect(activityHeldMs(since, now)).toBe(90_000);
|
||||
});
|
||||
|
||||
it("returns null for payloads that predate #102 (epoch default)", () => {
|
||||
expect(activityHeldMs(undefined, Date.now())).toBeNull();
|
||||
expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for unparseable timestamps", () => {
|
||||
expect(activityHeldMs("not-a-time", Date.now())).toBeNull();
|
||||
});
|
||||
|
||||
it("never goes negative", () => {
|
||||
const now = Date.now();
|
||||
expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("activityDurationLabel (#102)", () => {
|
||||
it("floors whole seconds under a minute", () => {
|
||||
expect(activityDurationLabel(30_400)).toBe("30s");
|
||||
expect(activityDurationLabel(59_900)).toBe("59s");
|
||||
});
|
||||
|
||||
it("floors whole minutes under an hour", () => {
|
||||
expect(activityDurationLabel(60_000)).toBe("1m");
|
||||
expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m");
|
||||
});
|
||||
|
||||
it("floors whole hours from an hour up, with no days unit", () => {
|
||||
expect(activityDurationLabel(60 * 60_000)).toBe("1h");
|
||||
expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h");
|
||||
expect(activityDurationLabel(26 * 3_600_000)).toBe("26h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activityColorSlug", () => {
|
||||
it("maps each known state to its contract slug", () => {
|
||||
expect(activityColorSlug("Active")).toBe("active");
|
||||
expect(activityColorSlug("Idle")).toBe("idle");
|
||||
expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input");
|
||||
expect(activityColorSlug("Error")).toBe("error");
|
||||
});
|
||||
|
||||
it("treats an unknown variant as idle so rendering never fails", () => {
|
||||
expect(activityColorSlug("Teleported")).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
activity: "Active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("showsMetaLine", () => {
|
||||
const fresh = new Date(Date.now() - 10_000).toISOString();
|
||||
|
||||
it("stays hidden for a freshly-active session", () => {
|
||||
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
|
||||
});
|
||||
|
||||
it("appears once the state has been held past 30 seconds", () => {
|
||||
const held = new Date(Date.now() - 31_000).toISOString();
|
||||
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
|
||||
});
|
||||
|
||||
it("appears for blocked work, exited sessions and reported errors", () => {
|
||||
expect(
|
||||
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
|
||||
).toBe(true);
|
||||
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
|
||||
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("needs no activity timestamp when another trigger holds", () => {
|
||||
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and
|
||||
// the shared duration format. Both clients compute it identically; the TUI
|
||||
// mirrors this in crates/maestro-tui.
|
||||
|
||||
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
|
||||
import { sessionBlockers } from "./types.js";
|
||||
|
||||
/** A state held shorter than this keeps the card to one line (contract rule 1). */
|
||||
const META_LINE_MIN_HOLD_MS = 30_000;
|
||||
|
||||
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
|
||||
// implausibly old is treated as "never observed" rather than a huge duration.
|
||||
const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1);
|
||||
|
||||
export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null {
|
||||
if (!since) return null;
|
||||
const t = Date.parse(since);
|
||||
if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null;
|
||||
return Math.max(0, now - t);
|
||||
}
|
||||
|
||||
/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored
|
||||
* everywhere so the counter never shows a duration the state hasn't reached. */
|
||||
export function activityDurationLabel(heldMs: number): string {
|
||||
const s = Math.floor(heldMs / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m`;
|
||||
return `${Math.floor(m / 60)}h`;
|
||||
}
|
||||
|
||||
/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An
|
||||
* unknown variant degrades to idle so rendering never fails (Precedence). */
|
||||
export function activityColorSlug(state: ActivityState | string): string {
|
||||
switch (state) {
|
||||
case "Active":
|
||||
return "active";
|
||||
case "AwaitingInput":
|
||||
return "awaiting-input";
|
||||
case "Error":
|
||||
return "error";
|
||||
default:
|
||||
return "idle";
|
||||
}
|
||||
}
|
||||
|
||||
/** Line 2 renders only when at least one contract trigger holds. */
|
||||
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
return (
|
||||
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
|
||||
sessionBlockers(s) !== null ||
|
||||
s.status === "Dead" ||
|
||||
s.activity === "Error"
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
NameBatch,
|
||||
Projects,
|
||||
RecentDirs,
|
||||
RetainedWorktree,
|
||||
Session,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
@@ -97,4 +98,22 @@ export async function fetchBranches(path: string): Promise<Branches> {
|
||||
return jsonOrError(await fetch(url, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** The daemon's retained worktrees (#80): trees kept after a session ended
|
||||
* with unsaved work, with what is currently in the way. Drives the
|
||||
* retained-worktrees view (#84). */
|
||||
export async function listWorktrees(): Promise<RetainedWorktree[]> {
|
||||
return jsonOrError(await fetch(routes.WORKTREES, { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
/** Reclaim a retained worktree: removes its directory and drops the store
|
||||
* entry. Never deletes the branch (docs/WORKTREES.md). */
|
||||
export async function reclaimWorktree(id: Uuid): Promise<void> {
|
||||
await jsonOrError(
|
||||
await fetch(routes.worktree(id), {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export { eventsWsUrl } from "./routes.js";
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
import { renderSidebar } from "./catalog.js";
|
||||
import type { CatalogListener, CatalogStore } from "./events.js";
|
||||
import type { InTheWay, Session } from "./types.js";
|
||||
|
||||
// happy-dom runs without a backing store; renderSidebar reads the persisted
|
||||
// sidebar width on mount.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: { getItem: () => null, setItem: () => {} },
|
||||
});
|
||||
// No daemon here; renderSidebar treats a failed info fetch as "absolute
|
||||
// paths are a fine fallback".
|
||||
window.fetch = () => Promise.reject(new Error("offline"));
|
||||
});
|
||||
|
||||
function session(overrides: Partial<Session>): Session {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
cwd: "/home/naps/tea/maestro",
|
||||
command: "claude",
|
||||
env: {},
|
||||
metadata: {},
|
||||
status: "Running",
|
||||
created_at: "2026-08-24T10:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storeWith(sessions: Session[]): CatalogStore {
|
||||
const state = { sessions: new Map(sessions.map((s) => [s.id, s])) };
|
||||
return {
|
||||
snapshot: () => state,
|
||||
// Like the real store, deliver an immediate snapshot so the sidebar
|
||||
// renders its rows during construction.
|
||||
subscribe: (fn: CatalogListener) => {
|
||||
fn(state);
|
||||
return () => {};
|
||||
},
|
||||
} as unknown as CatalogStore;
|
||||
}
|
||||
|
||||
function rowFor(el: HTMLElement, id: string): HTMLElement | null {
|
||||
return el.querySelector<HTMLElement>(`[data-session-id="${id}"]`);
|
||||
}
|
||||
|
||||
describe("blocked-work indicator (#83)", () => {
|
||||
it("marks the row of a blocked session with the shared phrasing", () => {
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000001",
|
||||
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const badge = rowFor(el, blocked.id)?.querySelector(".leaf-blocked");
|
||||
expect(badge?.textContent).toContain("⚠");
|
||||
expect(badge?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||
});
|
||||
|
||||
it("does not mark clear or unprobed sessions", () => {
|
||||
const clear = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000002",
|
||||
blocked_work: {} as InTheWay,
|
||||
});
|
||||
const unprobed = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000003",
|
||||
});
|
||||
const el = renderSidebar(storeWith([clear, unprobed])).el;
|
||||
expect(rowFor(el, clear.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
expect(rowFor(el, unprobed.id)?.querySelector(".leaf-blocked")).toBeNull();
|
||||
});
|
||||
|
||||
it("restates the warning at the kill confirm", async () => {
|
||||
const confirmations: string[] = [];
|
||||
window.confirm = (msg?: string) => {
|
||||
confirmations.push(msg ?? "");
|
||||
return false;
|
||||
};
|
||||
const blocked = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000004",
|
||||
command: "bash",
|
||||
blocked_work: { untracked_files: 1 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([blocked])).el;
|
||||
const kill = rowFor(el, blocked.id)?.querySelector<HTMLButtonElement>(".leaf-kill");
|
||||
kill?.click();
|
||||
await Promise.resolve();
|
||||
expect(confirmations).toHaveLength(1);
|
||||
expect(confirmations[0]).toContain("1 untracked file");
|
||||
expect(confirmations[0]).toContain("refused unless forced");
|
||||
});
|
||||
|
||||
it("confirms plainly when nothing blocks", async () => {
|
||||
const confirmations: string[] = [];
|
||||
window.confirm = (msg?: string) => {
|
||||
confirmations.push(msg ?? "");
|
||||
return false;
|
||||
};
|
||||
const plain = session({ id: "aaaaaaaa-0000-0000-0000-000000000005" });
|
||||
const el = renderSidebar(storeWith([plain])).el;
|
||||
rowFor(el, plain.id)?.querySelector<HTMLButtonElement>(".leaf-kill")?.click();
|
||||
await Promise.resolve();
|
||||
expect(confirmations).toEqual(['Kill session "claude"?']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent-reported detail (#112)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("renders the reported detail right after the duration on line 2", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000020",
|
||||
status: "Dead",
|
||||
activity: "Error",
|
||||
activity_since: ago(90_000),
|
||||
activity_detail: "cargo build failed",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.querySelector(".activity-detail")?.textContent).toBe("cargo build failed");
|
||||
const order = [...line2!.children].map((c) => c.className);
|
||||
expect(order.indexOf("activity-detail")).toBeGreaterThan(order.indexOf("activity-duration"));
|
||||
expect(order.indexOf("activity-detail")).toBeLessThan(
|
||||
order.findIndex((c) => c.startsWith("card-flag")),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the detail when no report is in force", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000021",
|
||||
activity: "Active",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".activity-detail")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session cards (#107)", () => {
|
||||
const ago = (ms: number): string => new Date(Date.now() - ms).toISOString();
|
||||
|
||||
it("keeps the name and the actions in the same flow line, actions reserving their width", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000010" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line1 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line1");
|
||||
expect(line1).not.toBeNull();
|
||||
// Same flex line ⇒ the name truncates before it can ever reach the buttons.
|
||||
expect(line1?.querySelector(".leaf-name")).not.toBeNull();
|
||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||
});
|
||||
|
||||
it("stays a single line while freshly active", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||
activity: "Active",
|
||||
activity_since: ago(5_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000012",
|
||||
activity: "Active",
|
||||
activity_since: ago(4 * 60_000 + 30_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.dataset.activity).toBe("active");
|
||||
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||
});
|
||||
|
||||
it("maps each activity state to its contract slug", () => {
|
||||
const mk = (id: string, activity: string) =>
|
||||
session({
|
||||
id,
|
||||
activity: activity as Session["activity"],
|
||||
status: "Running",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const idle = mk("aaaaaaaa-0000-0000-0000-000000000013", "Idle");
|
||||
const wait = mk("aaaaaaaa-0000-0000-0000-000000000014", "AwaitingInput");
|
||||
const error = mk("aaaaaaaa-0000-0000-0000-000000000015", "Error");
|
||||
const el = renderSidebar(storeWith([idle, wait, error])).el;
|
||||
expect(rowFor(el, idle.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"idle",
|
||||
);
|
||||
expect(rowFor(el, wait.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"awaiting-input",
|
||||
);
|
||||
expect(rowFor(el, error.id)?.querySelector(".card-line2")?.getAttribute("data-activity")).toBe(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
it("treats an unknown activity variant as idle instead of failing to render", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000016",
|
||||
activity: "Teleported" as Session["activity"],
|
||||
activity_since: ago(120_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector(".card-line2");
|
||||
expect(line2?.getAttribute("data-activity")).toBe("idle");
|
||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("2m");
|
||||
});
|
||||
|
||||
it("moves blocked work onto line 2 in the shared phrasing", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000017",
|
||||
blocked_work: { uncommitted_files: 3, unpushed_commits: 2 },
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("⚠");
|
||||
expect(line2?.textContent).toContain("3 uncommitted files, 2 unpushed commits");
|
||||
// Line 1 carries no badges.
|
||||
expect(rowFor(el, s.id)?.querySelector(".leaf-badge")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows exited on line 2 for dead sessions", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000018",
|
||||
status: "Dead",
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||
expect(line2?.textContent).toContain("exited");
|
||||
});
|
||||
|
||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||
const s = session({
|
||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||
command: "claude",
|
||||
metadata: { name: "worker" },
|
||||
activity: "AwaitingInput",
|
||||
activity_since: ago(90_000),
|
||||
});
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLElement;
|
||||
const dot = row.querySelector<HTMLElement>(".activity-dot");
|
||||
expect(dot?.getAttribute("title")).toContain("Awaiting input");
|
||||
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
|
||||
expect(row.textContent).not.toMatch(/awaiting.?input/i);
|
||||
});
|
||||
|
||||
it("keeps the attach navigation and depth indentation", () => {
|
||||
const s = session({ id: "aaaaaaaa-0000-0000-0000-00000000001a" });
|
||||
const el = renderSidebar(storeWith([s])).el;
|
||||
const row = rowFor(el, s.id) as HTMLAnchorElement;
|
||||
expect(row.getAttribute("href")).toBe("#/attach/aaaaaaaa-0000-0000-0000-00000000001a");
|
||||
expect(row.style.paddingLeft).toMatch(/^\d+(\.\d+)?px$/);
|
||||
});
|
||||
});
|
||||
+84
-13
@@ -3,7 +3,8 @@ import { getDaemonInfo, killSession } from "./api.js";
|
||||
import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateFolderName, sessionName } from "./types.js";
|
||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
|
||||
import type { TreeNode } from "./tree.js";
|
||||
import { buildTree } from "./tree.js";
|
||||
import { createThemeToggle } from "./theme.js";
|
||||
@@ -17,6 +18,44 @@ export function requestOpenSpawn(): void {
|
||||
|
||||
const collapsed = new Set<string>();
|
||||
|
||||
// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries
|
||||
// it. It survives only in tooltips/aria labels so meaning is not colour-only.
|
||||
const STATE_WORDS: Record<string, string> = {
|
||||
active: "Active",
|
||||
idle: "Idle",
|
||||
"awaiting-input": "Awaiting input",
|
||||
error: "Error",
|
||||
};
|
||||
|
||||
function metaLine(s: Session, now: number): string {
|
||||
const name = sessionName(s);
|
||||
const dead = s.status === "Dead";
|
||||
const blockers = sessionBlockers(s);
|
||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||
const held = activityHeldMs(s.activity_since, now);
|
||||
const duration = held !== null ? activityDurationLabel(held) : null;
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
|
||||
const fragments = [
|
||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||
duration ? `<span class="activity-duration">${duration}</span>` : "",
|
||||
s.activity_detail
|
||||
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
|
||||
: "",
|
||||
blockers
|
||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||
: "",
|
||||
dead ? `<span class="card-flag">exited</span>` : "",
|
||||
showCmd
|
||||
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
|
||||
: "",
|
||||
];
|
||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||
}
|
||||
|
||||
function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
if (node.kind === "folder") {
|
||||
const wrap = document.createElement("div");
|
||||
@@ -52,26 +91,34 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
}
|
||||
|
||||
const s = node.session;
|
||||
const now = Date.now();
|
||||
const row = document.createElement("a");
|
||||
row.className = "tree-leaf";
|
||||
row.className = "tree-leaf session-card";
|
||||
row.href = `#/attach/${encodeURIComponent(s.id)}`;
|
||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||
row.dataset.sessionId = s.id;
|
||||
const running = s.status === "Running";
|
||||
row.dataset.status = running ? "running" : "dead";
|
||||
const name = sessionName(s);
|
||||
row.setAttribute("aria-label", `${name}, ${running ? "running" : "exited"}`);
|
||||
// The directory is the enclosing folder, so only surface the command — and
|
||||
// only when it isn't already what the name shows.
|
||||
const showCmd = s.command !== name;
|
||||
const stateWord =
|
||||
STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle;
|
||||
row.setAttribute(
|
||||
"aria-label",
|
||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||
);
|
||||
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
|
||||
// the actions reserve their width, so the name truncates before it can ever
|
||||
// reach them. Line 2 renders only when a contract trigger holds.
|
||||
const showMeta = showsMetaLine(s, now);
|
||||
row.innerHTML = `
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
${showCmd ? `<span class="leaf-meta"><span class="leaf-cmd">${escapeHtml(s.command)}</span></span>` : ""}
|
||||
${running ? "" : `<span class="leaf-badge">exited</span>`}
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${showMeta ? metaLine(s, now) : ""}
|
||||
`;
|
||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||
similarBtn.addEventListener("click", (ev) => {
|
||||
@@ -83,7 +130,12 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
||||
killBtn.addEventListener("click", async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (!confirm(`Kill session "${sessionName(s)}"?`)) return;
|
||||
// #83: the confirm restates the warning at the moment of decision.
|
||||
const blockers = sessionBlockers(s);
|
||||
const question = blockers
|
||||
? `Kill session "${name}"? Its worktree still has ${formatInTheWay(blockers)} — the kill will be refused unless forced.`
|
||||
: `Kill session "${name}"?`;
|
||||
if (!confirm(question)) return;
|
||||
try {
|
||||
await killSession(s.id);
|
||||
} catch (e) {
|
||||
@@ -180,6 +232,15 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
||||
renderStatus();
|
||||
|
||||
const actions = header.querySelector(".app-actions") as HTMLElement;
|
||||
// Retained worktrees (#84): a section reachable from the header, not a
|
||||
// sidebar fixture — PRODUCT.md's scan is the product, so this must not
|
||||
// compete with the session catalog for attention.
|
||||
const worktreesLink = document.createElement("a");
|
||||
worktreesLink.className = "btn btn-ghost";
|
||||
worktreesLink.href = "#/worktrees";
|
||||
worktreesLink.textContent = "worktrees";
|
||||
worktreesLink.title = "Retained worktrees";
|
||||
actions.appendChild(worktreesLink);
|
||||
// DEV-only shortcut to the standalone design kit (?kit). Lets you reach it by
|
||||
// clicking inside a proxied preview (e.g. crit live) that has no address bar.
|
||||
if (import.meta.env.DEV) {
|
||||
@@ -196,6 +257,8 @@ export function renderTopbar(store: CatalogStore, onToggleSidebar?: () => void):
|
||||
spawnBtn.type = "button";
|
||||
spawnBtn.className = "btn btn-primary spawn-btn";
|
||||
spawnBtn.textContent = "+ Spawn";
|
||||
// The keyboard pair sits right here in the tooltip (#67).
|
||||
spawnBtn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawnBtn.addEventListener("click", () => requestOpenSpawn());
|
||||
actions.appendChild(spawnBtn);
|
||||
return header;
|
||||
@@ -375,6 +438,13 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
|
||||
const unsubscribe = store.subscribe(refresh);
|
||||
|
||||
// Contract granularity: re-render at least every 30s while visible so the
|
||||
// duration cutover points land within one tick.
|
||||
const clock = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
refresh();
|
||||
}, 30_000);
|
||||
|
||||
return {
|
||||
el,
|
||||
setActive(id: string | null) {
|
||||
@@ -390,6 +460,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S
|
||||
applyHighlights();
|
||||
},
|
||||
dispose() {
|
||||
clearInterval(clock);
|
||||
document.removeEventListener("catalog-rerender", rerenderHandler);
|
||||
unsubscribe();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFastForwardRequest,
|
||||
fastForwardSeed,
|
||||
resolveFastForward,
|
||||
} from "./fastForward.js";
|
||||
import type { Project, SpawnPreset } from "./types.js";
|
||||
|
||||
const presets: SpawnPreset[] = [
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
];
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
return {
|
||||
path: "/repos/p",
|
||||
name: "p",
|
||||
last_activity: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveFastForward (#67)", () => {
|
||||
it("resolves every default from the most recent project", () => {
|
||||
const projects = [
|
||||
project({ path: "/repos/old", name: "old", last_activity: "2020-01-01T00:00:00Z" }),
|
||||
project({
|
||||
path: "/repos/fresh",
|
||||
name: "fresh",
|
||||
base_repo: "/repos/fresh",
|
||||
last_command: "aider",
|
||||
}),
|
||||
];
|
||||
const res = resolveFastForward(projects, presets, "amber-falcon");
|
||||
expect(res.project?.path).toBe("/repos/fresh");
|
||||
expect(res.command).toBe("aider");
|
||||
expect(res.name).toBe("amber-falcon");
|
||||
|
||||
const req = buildFastForwardRequest(res)!;
|
||||
expect(req.cwd).toBe("/repos/fresh");
|
||||
expect(req.command).toBe("aider");
|
||||
expect(req.metadata).toEqual({ name: "amber-falcon" });
|
||||
expect(req.worktree).toEqual({
|
||||
base_repo: "/repos/fresh",
|
||||
branch: "amber-falcon",
|
||||
create_branch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the first non-custom preset when there is no last command", () => {
|
||||
const res = resolveFastForward([project({ last_command: undefined })], presets, "n");
|
||||
expect(res.command).toBe("claude");
|
||||
});
|
||||
|
||||
it("treats an empty last command as no command", () => {
|
||||
const res = resolveFastForward([project({ last_command: "" })], [presets[0]], "n");
|
||||
expect(res.command).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves nothing when no projects are known yet", () => {
|
||||
const res = resolveFastForward([], presets, "n");
|
||||
expect(res.project).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("downgrades to a draft when the generated name is missing", () => {
|
||||
const res = resolveFastForward([project({ last_command: "claude" })], presets);
|
||||
expect(res.name).toBeUndefined();
|
||||
expect(buildFastForwardRequest(res)).toBeNull();
|
||||
});
|
||||
|
||||
it("seeds the fallback draft with whatever did resolve", () => {
|
||||
const res = resolveFastForward(
|
||||
[project({ path: "/repos/p", base_repo: "/repos/p", last_command: "aider" })],
|
||||
presets,
|
||||
);
|
||||
expect(fastForwardSeed(res)).toEqual({
|
||||
cwd: "/repos/p",
|
||||
command: "aider",
|
||||
name: "",
|
||||
worktreeEnabled: true,
|
||||
worktreeBranch: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the worktree default alone when not even a project resolved", () => {
|
||||
// Not `false`: the form defaults the toggle on and a false would stick,
|
||||
// so the fallback draft would disagree with a normally-opened draft and
|
||||
// with the TUI's fast path.
|
||||
const seed = fastForwardSeed(resolveFastForward([], presets));
|
||||
expect(seed.worktreeEnabled).toBeUndefined();
|
||||
expect(seed.cwd).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
// Fast-forward spawn (#67): one keystroke, every default resolved, no form.
|
||||
// Pure resolution only — the caller (main.ts) owns the fetches and the POST,
|
||||
// and falls back to a seeded draft pane when anything is missing.
|
||||
|
||||
import type { CreateSessionRequest, Project, SpawnPreset } from "./types.js";
|
||||
import { sortProjects } from "./projectList.js";
|
||||
|
||||
/** What one fast-forward keystroke resolved. Anything absent means "open a
|
||||
* draft pre-filled with the rest instead of spawning". */
|
||||
export interface FastForwardResolution {
|
||||
project?: Project;
|
||||
command?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** The defaults: most recent project (fresh-first, same ordering the picker
|
||||
* shows), its last command falling back to the first non-custom preset, and
|
||||
* a freshly generated name when the daemon supplied one. */
|
||||
export function resolveFastForward(
|
||||
projects: Project[],
|
||||
presets: SpawnPreset[],
|
||||
name?: string,
|
||||
): FastForwardResolution {
|
||||
const project = sortProjects(projects, new Date())[0];
|
||||
const lastCommand = project?.last_command?.trim();
|
||||
const command =
|
||||
lastCommand || presets.find((p) => p.command !== "")?.command || undefined;
|
||||
return { project, command, name: name || undefined };
|
||||
}
|
||||
|
||||
/** The immediate spawn request — worktree on, new branch named after the
|
||||
* generated session name — or `null` when any default is missing. */
|
||||
export function buildFastForwardRequest(
|
||||
res: FastForwardResolution,
|
||||
): CreateSessionRequest | null {
|
||||
const { project, command, name } = res;
|
||||
if (!project || !command?.trim() || !name) return null;
|
||||
return {
|
||||
cwd: project.path,
|
||||
command,
|
||||
metadata: { name },
|
||||
worktree: {
|
||||
base_repo: project.base_repo ?? project.path,
|
||||
branch: name,
|
||||
create_branch: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Seed for the fallback draft pane: whatever did resolve goes into the
|
||||
* form; the rest stays with its own defaults. */
|
||||
export function fastForwardSeed(res: FastForwardResolution) {
|
||||
return {
|
||||
cwd: res.project?.path ?? "",
|
||||
command: res.command ?? "",
|
||||
name: res.name ?? "",
|
||||
// Left undefined rather than false when nothing resolved: the form
|
||||
// defaults the toggle on, and `false` would stick, so the fallback draft
|
||||
// would disagree with both a normally-opened draft and the TUI's fast path
|
||||
// (#67 review).
|
||||
worktreeEnabled: res.project ? true : undefined,
|
||||
worktreeBranch:
|
||||
res.project && res.name ? res.name : undefined,
|
||||
};
|
||||
}
|
||||
+66
-18
@@ -59,20 +59,28 @@ function leafRow(opts: {
|
||||
status: "running" | "dead";
|
||||
active?: boolean;
|
||||
indent?: number;
|
||||
activity?: "active" | "idle" | "awaiting-input" | "error";
|
||||
duration?: string;
|
||||
}): HTMLElement {
|
||||
const row = h("a", "tree-leaf");
|
||||
const row = h("a", "tree-leaf session-card");
|
||||
(row as HTMLAnchorElement).href = "#/kit";
|
||||
row.dataset.status = opts.status;
|
||||
if (opts.active) row.classList.add("is-active");
|
||||
row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`;
|
||||
const flags = [
|
||||
opts.status === "dead" ? `<span class="card-flag">exited</span>` : "",
|
||||
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
|
||||
].join("");
|
||||
row.innerHTML = `
|
||||
<span class="leaf-name">${opts.name}</span>
|
||||
${opts.cmd !== opts.name ? `<span class="leaf-meta"><span class="leaf-cmd">${opts.cmd}</span></span>` : ""}
|
||||
${opts.status === "dead" ? `<span class="leaf-badge">exited</span>` : ""}
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${opts.name}">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${opts.name}">✕</button>
|
||||
</span>`;
|
||||
<span class="card-line1">
|
||||
<span class="leaf-name">${opts.name}</span>
|
||||
<span class="leaf-actions">
|
||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar">⧉</button>
|
||||
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
|
||||
</span>
|
||||
</span>
|
||||
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
|
||||
`;
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -190,14 +198,17 @@ export function renderKit(): HTMLElement {
|
||||
// --- chips & badges ---
|
||||
{
|
||||
const { el, body } = section("Chips & badges", "Status-bearing pills and counts.");
|
||||
const activeLine = h(
|
||||
"span",
|
||||
"card-line2",
|
||||
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
|
||||
);
|
||||
activeLine.setAttribute("data-activity", "active");
|
||||
body.append(
|
||||
specimen("session-tally", h("span", "session-tally", "6 sessions · 5 live")),
|
||||
specimen("folder-count", h("span", "folder-count", "4")),
|
||||
specimen("leaf-badge", h("span", "leaf-badge", "exited")),
|
||||
specimen(
|
||||
"attach-state · open",
|
||||
h("span", "attach-state", "live"),
|
||||
),
|
||||
specimen("card-line2 · active", activeLine),
|
||||
specimen("attach-state · open", h("span", "attach-state", "live")),
|
||||
);
|
||||
(body.lastElementChild?.querySelector(".attach-state") as HTMLElement)?.setAttribute(
|
||||
"data-state",
|
||||
@@ -215,19 +226,56 @@ export function renderKit(): HTMLElement {
|
||||
{
|
||||
const { el, body } = section(
|
||||
"Session rows — nested by host, then directory",
|
||||
"The working directory (home collapsed to ~) is the nesting, and single-child chains fold into one node like ~/git/maestro. “Alive” is a faint row wash; exited rows recede with a badge; the open session is highlighted.",
|
||||
"docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.",
|
||||
);
|
||||
const list = h("div", "kit-tree");
|
||||
list.append(
|
||||
folderHeader("~/git/maestro", 3, 0),
|
||||
folderHeader("web", 2, 1),
|
||||
leafRow({ name: "redesign", cmd: "claude", status: "running", indent: 2 }),
|
||||
leafRow({ name: "dev-server", cmd: "vite dev", status: "running", indent: 2, active: true }),
|
||||
leafRow({
|
||||
name: "dev-server",
|
||||
cmd: "vite dev",
|
||||
status: "running",
|
||||
indent: 2,
|
||||
active: true,
|
||||
activity: "active",
|
||||
duration: "4m",
|
||||
}),
|
||||
folderHeader("crates/daemon", 1, 1),
|
||||
leafRow({ name: "daemon-work", cmd: "claude", status: "running", indent: 2 }),
|
||||
leafRow({
|
||||
name: "daemon-work",
|
||||
cmd: "claude",
|
||||
status: "running",
|
||||
indent: 2,
|
||||
activity: "awaiting-input",
|
||||
duration: "12s",
|
||||
}),
|
||||
leafRow({
|
||||
name: "flaky-agent",
|
||||
cmd: "claude",
|
||||
status: "running",
|
||||
indent: 2,
|
||||
activity: "error",
|
||||
duration: "2h",
|
||||
}),
|
||||
folderHeader("~/work/acme", 2, 0),
|
||||
leafRow({ name: "billing-api", cmd: "claude", status: "running", indent: 1 }),
|
||||
leafRow({ name: "scratch", cmd: "bash", status: "dead", indent: 1 }),
|
||||
leafRow({
|
||||
name: "billing-api",
|
||||
cmd: "claude",
|
||||
status: "running",
|
||||
indent: 1,
|
||||
activity: "idle",
|
||||
duration: "26m",
|
||||
}),
|
||||
leafRow({
|
||||
name: "scratch",
|
||||
cmd: "bash",
|
||||
status: "dead",
|
||||
indent: 1,
|
||||
activity: "idle",
|
||||
duration: "5h",
|
||||
}),
|
||||
leafRow({ name: "monitor", cmd: "btop", status: "running", indent: 0 }),
|
||||
);
|
||||
body.classList.add("kit-tree-host");
|
||||
|
||||
+120
-2
@@ -3,13 +3,28 @@ import { ConnectionPool } from "./connections.js";
|
||||
import { installHudKeybind } from "./debug.js";
|
||||
import { CatalogStore } from "./events.js";
|
||||
import { shouldRetryClose } from "./reconnect.js";
|
||||
import {
|
||||
createSession,
|
||||
fetchNames,
|
||||
getDaemonInfo,
|
||||
listWorktrees,
|
||||
projects as fetchProjects,
|
||||
reclaimWorktree,
|
||||
} from "./api.js";
|
||||
import {
|
||||
buildFastForwardRequest,
|
||||
fastForwardSeed,
|
||||
resolveFastForward,
|
||||
} from "./fastForward.js";
|
||||
import { FALLBACK_PRESETS } from "./spawnForm.js";
|
||||
import { mountHud, type Hud } from "./hud.js";
|
||||
import { emptyLayout, type Layout, openSessions } from "./layout.js";
|
||||
import { createMultiView, type MultiView } from "./multiview.js";
|
||||
import { loadLayout, saveLayout } from "./persistence.js";
|
||||
import { OPEN_SPAWN_EVENT } from "./catalog.js";
|
||||
import { initTheme } from "./theme.js";
|
||||
import { sessionName, type Session } from "./types.js";
|
||||
import { sessionName, type Project, type Session, type SpawnPreset } from "./types.js";
|
||||
import { createWorktreesView, type WorktreesViewHandle } from "./worktreesView.js";
|
||||
import { installViewportSizing } from "./viewport.js";
|
||||
import "./styles.css";
|
||||
|
||||
@@ -212,6 +227,7 @@ function renderWelcome(): HTMLElement {
|
||||
spawn.type = "button";
|
||||
spawn.className = "btn btn-primary";
|
||||
spawn.textContent = "+ Spawn session";
|
||||
spawn.title = "Draft pane: n — fast-forward spawn: N";
|
||||
spawn.addEventListener("click", () => requestOpenSpawn());
|
||||
(el.querySelector(".empty-actions") as HTMLElement).appendChild(spawn);
|
||||
return el;
|
||||
@@ -256,7 +272,11 @@ function showMultiView(): void {
|
||||
// cloned session's dir + command.
|
||||
function requestOpenSpawn(): void {
|
||||
showMultiView();
|
||||
mv.openDraft();
|
||||
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
||||
// similar" and the fast-forward key below deliberately keep the old
|
||||
// behaviour: both act on the session in front of you, so a split beside it
|
||||
// is the point.
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
}
|
||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||
@@ -265,19 +285,114 @@ document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||
mv.openDraft({ cwd: s.cwd, command: s.command });
|
||||
});
|
||||
|
||||
// Fast-forward spawn (#67): the draft pane's keystroke neighbour spawns
|
||||
// immediately with every default resolved — most recent project, its last
|
||||
// command (or the first non-custom preset), a fresh name, worktree on with
|
||||
// a new branch named after that name. Anything unresolvable (no projects
|
||||
// known yet, no command, no generated name) opens a draft pre-filled with
|
||||
// what did resolve instead.
|
||||
async function requestFastForward(): Promise<void> {
|
||||
showMultiView();
|
||||
const [discovered, name] = await Promise.all([
|
||||
fetchProjects().catch(() => [] as Project[]),
|
||||
fetchNames(1)
|
||||
.then((batch) => batch.names[0])
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
let presets: SpawnPreset[] = FALLBACK_PRESETS;
|
||||
try {
|
||||
const info = await getDaemonInfo();
|
||||
if (info.presets?.length) presets = info.presets;
|
||||
} catch {
|
||||
/* keep fallbacks */
|
||||
}
|
||||
const resolved = resolveFastForward(discovered, presets, name);
|
||||
const req = buildFastForwardRequest(resolved);
|
||||
if (req) {
|
||||
try {
|
||||
const session = await createSession(req);
|
||||
// Mount it, the way the TUI's fast path attaches. Without this the
|
||||
// keystroke creates a session and leaves the UI untouched, so it reads
|
||||
// as a dead key (#67 review).
|
||||
mv.openSessionBeside(session.id);
|
||||
updateShellRoute();
|
||||
return;
|
||||
} catch {
|
||||
// A failed POST degrades to the same pre-filled draft as an
|
||||
// unresolvable default — never a silent dead keystroke.
|
||||
}
|
||||
}
|
||||
mv.openDraft(fastForwardSeed(resolved));
|
||||
}
|
||||
|
||||
// Keyboard pair (#67): `n` opens a draft pane, `N` fast-forwards a spawn.
|
||||
// Ignored while typing anywhere (form fields, the terminal's hidden
|
||||
// textarea, search) so an attached session's keys are never hijacked.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (
|
||||
target &&
|
||||
(target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (e.key === "n") {
|
||||
e.preventDefault();
|
||||
requestOpenSpawn();
|
||||
} else if (e.key === "N") {
|
||||
e.preventDefault();
|
||||
void requestFastForward();
|
||||
}
|
||||
});
|
||||
|
||||
type Route =
|
||||
| { kind: "catalog" }
|
||||
| { kind: "attach"; sessionId: string }
|
||||
| { kind: "worktrees" }
|
||||
| { kind: "not-found"; raw: string };
|
||||
|
||||
function parseRoute(): Route {
|
||||
const hash = location.hash.replace(/^#/, "");
|
||||
if (hash === "" || hash === "/") return { kind: "catalog" };
|
||||
if (hash === "/worktrees") return { kind: "worktrees" };
|
||||
const m = hash.match(/^\/attach\/([^/?#]+)$/);
|
||||
if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) };
|
||||
return { kind: "not-found", raw: hash };
|
||||
}
|
||||
|
||||
// Retained worktrees (#84): built lazily on first visit, then kept so a
|
||||
// revisit re-enters instantly. The daemon's home dir abbreviates paths once
|
||||
// `/api/info` answers.
|
||||
let worktreesView: WorktreesViewHandle | undefined;
|
||||
let homeDir: string | undefined;
|
||||
void getDaemonInfo()
|
||||
.then((info) => {
|
||||
homeDir = info.home_dir;
|
||||
})
|
||||
.catch(() => {
|
||||
/* absolute paths are a fine fallback */
|
||||
});
|
||||
|
||||
function showWorktrees(): void {
|
||||
pool.setVisible([]);
|
||||
pool.setFocus(null);
|
||||
contentShowsMv = false;
|
||||
if (!worktreesView) {
|
||||
worktreesView = createWorktreesView({
|
||||
load: listWorktrees,
|
||||
reclaim: reclaimWorktree,
|
||||
homeDir: () => homeDir,
|
||||
});
|
||||
}
|
||||
content.replaceChildren(worktreesView.el);
|
||||
shellEl.dataset.route = "worktrees";
|
||||
void worktreesView.refresh();
|
||||
}
|
||||
|
||||
function dispatch(): void {
|
||||
const route = parseRoute();
|
||||
switch (route.kind) {
|
||||
@@ -299,6 +414,9 @@ function dispatch(): void {
|
||||
// tears panes down.
|
||||
showMultiView();
|
||||
return;
|
||||
case "worktrees":
|
||||
showWorktrees();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,6 +147,48 @@ describe("multiview openDraft (#62)", () => {
|
||||
expect((mv.el.querySelector(".mv-draft input[name=name]") as HTMLInputElement).value).toBe("wip");
|
||||
});
|
||||
|
||||
it("reuses an already-synced empty pane instead of splitting (#72)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
// Let the mounted form's loadProjects() settle and auto-sync its picked
|
||||
// defaults into leaf.draft before openDraft runs (#72's dead-branch window).
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === empty)?.draft).not.toBeNull();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
expect(leaves(tab.root)).toHaveLength(2);
|
||||
expect(tab.focusedLeafId).toBe(empty);
|
||||
});
|
||||
|
||||
it("keeps a seed after reusing an already-synced empty pane (#72)", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
const empty = splitLeaf(tab, first, null, "row", true);
|
||||
tab.focusedLeafId = empty;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
mv.openDraft({ cwd: "/home/user/proj", command: "claude" });
|
||||
await flush();
|
||||
|
||||
const leaf = leaves(tab.root).find((l) => l.id === empty)!;
|
||||
expect(leaf.draft?.cwd).toBe("/home/user/proj");
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
const cwdRaw = mv.el.querySelector(".mv-draft .cwd-raw") as HTMLInputElement;
|
||||
expect(cwdRaw.value).toBe("/home/user/proj");
|
||||
});
|
||||
|
||||
it("seeds a clone's cwd and command into the draft", async () => {
|
||||
const layout = emptyLayout();
|
||||
const mv = makeMv(layout);
|
||||
@@ -160,3 +202,134 @@ describe("multiview openDraft (#62)", () => {
|
||||
expect(leaf.draft?.command).toBe("claude");
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview pane-drag swap (#73)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/** Drag `sourcePane`'s title onto `targetPane`'s center, as the real pane
|
||||
* drag handler does — modelled after the pointer sequence in runDrag()/
|
||||
* updateDock() in multiview.ts, since neither is exposed on MultiView. */
|
||||
async function dragPaneOntoCenter(sourcePane: HTMLElement, targetPane: HTMLElement): Promise<void> {
|
||||
// The real drag-start gate requires the pointerdown target to sit inside
|
||||
// an `.attach-header` (attach.ts's session title bar). Draft panes mount
|
||||
// no such element, so stand one in for the gate check itself.
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "attach-header";
|
||||
sourcePane.appendChild(marker);
|
||||
|
||||
vi.spyOn(targetPane, "getBoundingClientRect").mockReturnValue({
|
||||
left: 100,
|
||||
top: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
right: 200,
|
||||
bottom: 100,
|
||||
x: 100,
|
||||
y: 0,
|
||||
toJSON() {},
|
||||
} as DOMRect);
|
||||
(document as unknown as { elementsFromPoint: (x: number, y: number) => Element[] }).elementsFromPoint = () => [
|
||||
targetPane,
|
||||
];
|
||||
|
||||
marker.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { clientX: 0, clientY: 0, bubbles: true, cancelable: true }),
|
||||
);
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }),
|
||||
);
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { clientX: 150, clientY: 50, bubbles: true, cancelable: true }));
|
||||
await flush();
|
||||
}
|
||||
|
||||
it("moves each draft's form along with its data when two draft panes are swapped", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const leaf1 = splitLeaf(tab, "", null, "row", true);
|
||||
const leaf2 = splitLeaf(tab, leaf1, null, "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
await flush();
|
||||
|
||||
const paneOf = (leafId: string) => mv.el.querySelector(`[data-leaf-id="${leafId}"]`) as HTMLElement;
|
||||
const nameInputOf = (leafId: string) => paneOf(leafId).querySelector("input[name=name]") as HTMLInputElement;
|
||||
|
||||
nameInputOf(leaf1).value = "west";
|
||||
nameInputOf(leaf1).dispatchEvent(new Event("input"));
|
||||
nameInputOf(leaf2).value = "east";
|
||||
nameInputOf(leaf2).dispatchEvent(new Event("input"));
|
||||
await flush();
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("west");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("east");
|
||||
|
||||
await dragPaneOntoCenter(paneOf(leaf1), paneOf(leaf2));
|
||||
|
||||
// The leaf data swaps (already correct pre-fix)...
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf1)?.draft?.name).toBe("east");
|
||||
expect(leaves(tab.root).find((l) => l.id === leaf2)?.draft?.name).toBe("west");
|
||||
// ...and the mounted forms must follow, not stay behind showing the old
|
||||
// pane's values (#73).
|
||||
expect(nameInputOf(leaf1).value).toBe("east");
|
||||
expect(nameInputOf(leaf2).value).toBe("west");
|
||||
});
|
||||
});
|
||||
|
||||
describe("openDraft newTab (#100)", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("opens its own tab instead of splitting the focused session", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||
tab.focusedLeafId = first;
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft(undefined, { newTab: true });
|
||||
await flush();
|
||||
|
||||
expect(layout.tabs).toHaveLength(2);
|
||||
// The tab you were reading is untouched: still one pane, still its session.
|
||||
const original = leaves(layout.tabs[0].root);
|
||||
expect(original).toHaveLength(1);
|
||||
expect(original[0].sessionId).toBe("s1");
|
||||
// The draft is alone in the new, now-active tab.
|
||||
const fresh = leaves(layout.tabs[1].root);
|
||||
expect(fresh).toHaveLength(1);
|
||||
expect(fresh[0].sessionId).toBeNull();
|
||||
expect(fresh[0].draft).not.toBeNull();
|
||||
expect(layout.activeTabId).toBe(layout.tabs[1].id);
|
||||
});
|
||||
|
||||
it("still splits when newTab is not asked for", async () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
mv.openDraft();
|
||||
await flush();
|
||||
|
||||
expect(layout.tabs).toHaveLength(1);
|
||||
expect(leaves(tab.root)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
+64
-4
@@ -49,6 +49,7 @@ export interface MultiView {
|
||||
/** Bring a session into the layout: focus it if already open, else fill the
|
||||
* focused pane (or open the first pane in an empty tab). */
|
||||
openSession: (sessionId: string) => void;
|
||||
openSessionBeside: (sessionId: string) => void;
|
||||
/** Drop the pane showing `sessionId` (the session was disconnected/killed). */
|
||||
closeSession: (sessionId: string) => void;
|
||||
/** Every session currently shown in some pane, across all tabs. */
|
||||
@@ -56,7 +57,7 @@ export interface MultiView {
|
||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||
* ("Spawn similar" clones a session's cwd + command). */
|
||||
openDraft: (seed?: Partial<Draft>) => void;
|
||||
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
|
||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||
* drag shows drop zones to place it. */
|
||||
@@ -378,9 +379,51 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
render();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>): void {
|
||||
/// Mount a session without evicting one.
|
||||
///
|
||||
/// `openSession` deliberately replaces the focused pane (VS Code style) for
|
||||
/// a sidebar click, but the fast path must never do that: pressing the key
|
||||
/// while attached would drop the running session out of the layout
|
||||
/// entirely. Follows `openDraft`'s rule instead — reuse an empty pane, else
|
||||
/// split beside the focused one (#67 review).
|
||||
function openSessionBeside(sessionId: string): void {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
if (loc) {
|
||||
layout.activeTabId = loc.tab.id;
|
||||
loc.tab.focusedLeafId = loc.leaf.id;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId) {
|
||||
target.sessionId = sessionId;
|
||||
target.draft = null;
|
||||
} else {
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
|
||||
}
|
||||
render();
|
||||
opts.onOpenSessionsChange?.();
|
||||
}
|
||||
|
||||
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||
if (opts?.newTab) {
|
||||
// Spawn opens its own tab rather than halving whatever you were reading
|
||||
// (#100). The empty-pane reuse below still applies within a tab.
|
||||
const fresh = addTab(layout);
|
||||
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
|
||||
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
|
||||
render();
|
||||
return;
|
||||
}
|
||||
const tab = activeTab(layout);
|
||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||
@@ -389,9 +432,15 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return;
|
||||
}
|
||||
const target = findLeaf(tab.root, tab.focusedLeafId)!;
|
||||
if (!target.sessionId && !target.draft) {
|
||||
// An already-empty pane becomes the draft in place.
|
||||
if (!target.sessionId) {
|
||||
// Empty = no session, not `!draft` (#72): the mounted form auto-syncs
|
||||
// picked defaults into `draft` once ready, so that's never reliably null.
|
||||
target.draft = draft();
|
||||
const stale = draftForms.get(target.id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(target.id);
|
||||
}
|
||||
} else {
|
||||
// Split a fresh draft beside the focused pane.
|
||||
tab.focusedLeafId = splitLeaf(tab, target.id, null, "row", true);
|
||||
@@ -661,6 +710,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
const dragged = findLeaf(tab.root, self)!;
|
||||
[dragged.sessionId, target.sessionId] = [target.sessionId, dragged.sessionId];
|
||||
[dragged.draft, target.draft] = [target.draft, dragged.draft];
|
||||
// The leaf data moved but each pane's mounted form is still keyed to
|
||||
// its old leaf (#73) — tear both down like openDraft's reuse case
|
||||
// (#72) so render() remounts fresh forms seeded from the swapped data.
|
||||
for (const id of [dragged.id, target.id]) {
|
||||
const stale = draftForms.get(id);
|
||||
if (stale) {
|
||||
stale.destroy();
|
||||
draftForms.delete(id);
|
||||
}
|
||||
}
|
||||
tab.focusedLeafId = target.id;
|
||||
} else if (sessionId) {
|
||||
const loc = locateSession(layout, sessionId);
|
||||
@@ -693,6 +752,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
return {
|
||||
el,
|
||||
openSession,
|
||||
openSessionBeside,
|
||||
openDraft,
|
||||
closeSession,
|
||||
openSessions: () => openSessions(layout),
|
||||
|
||||
@@ -34,13 +34,13 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
function rowPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-row:not(.project-other)")].map(
|
||||
(r) => r.dataset.cwd ?? "",
|
||||
function boxPaths(form: HTMLFormElement): string[] {
|
||||
return [...form.querySelectorAll<HTMLButtonElement>(".project-box")].map(
|
||||
(b) => b.dataset.cwd ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm project picker", () => {
|
||||
describe("spawnForm project picker (#95: boxes, full paths, mains only)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +65,7 @@ describe("spawnForm project picker", () => {
|
||||
return handle;
|
||||
}
|
||||
|
||||
it("renders one row per project, fresh before stale", async () => {
|
||||
it("renders one box per project, fresh before stale", async () => {
|
||||
const fresh = project({ name: "fresh", path: "/home/user/fresh", last_activity: NOW });
|
||||
const stale = project({
|
||||
name: "stale",
|
||||
@@ -73,40 +73,56 @@ describe("spawnForm project picker", () => {
|
||||
last_activity: "2026-01-01T00:00:00Z",
|
||||
});
|
||||
const form = (await mount([stale, fresh])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const rows = [...form.querySelectorAll<HTMLButtonElement>(".project-row")];
|
||||
expect(rows[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(rows[0].classList.contains("is-stale")).toBe(false);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/fresh", "/home/user/stale"]);
|
||||
const boxes = [...form.querySelectorAll<HTMLButtonElement>(".project-box")];
|
||||
expect(boxes[1].classList.contains("is-stale")).toBe(true);
|
||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("nests worktrees under their base repository", async () => {
|
||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
});
|
||||
|
||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||
const main = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const wt = project({
|
||||
name: "wt",
|
||||
path: "/elsewhere/wt",
|
||||
path: "/home/user/maestro/worktrees/wt",
|
||||
is_worktree: true,
|
||||
base_repo: main.path,
|
||||
});
|
||||
const form = (await mount([wt, main])).el;
|
||||
expect(rowPaths(form)).toEqual(["/home/user/maestro", "/elsewhere/wt"]);
|
||||
const [mainRow, wtRow] = [
|
||||
...form.querySelectorAll(".project-row:not(.project-other)"),
|
||||
];
|
||||
expect(wtRow.classList.contains("is-worktree")).toBe(true);
|
||||
expect(mainRow.classList.contains("is-worktree")).toBe(false);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||
expect(form.querySelector(".project-box.is-worktree")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters rows as the operator types", async () => {
|
||||
it("filters boxes as the operator types, keeping a repo its worktree matched", async () => {
|
||||
const a = project({ name: "maestro", path: "/home/user/maestro" });
|
||||
const b = project({ name: "tea", path: "/home/user/work/tea" });
|
||||
const form = (await mount([a, b])).el;
|
||||
const wt = project({
|
||||
name: "bulgarians",
|
||||
path: "/home/user/maestro/worktrees/bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: a.path,
|
||||
});
|
||||
const form = (await mount([a, b, wt])).el;
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "tea";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
expect(boxPaths(form)).toEqual(["/home/user/work/tea"]);
|
||||
// A query that only matches a worktree still surfaces its repository box.
|
||||
filter.value = "bulgarians";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(boxPaths(form)).toEqual(["/home/user/maestro"]);
|
||||
filter.value = "zzz";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
expect(rowPaths(form)).toEqual([]);
|
||||
expect(boxPaths(form)).toEqual([]);
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -135,17 +151,17 @@ describe("spawnForm project picker", () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
onChange.mockClear();
|
||||
|
||||
const row = form.querySelector<HTMLButtonElement>(".project-row")!;
|
||||
row.click();
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
box.click();
|
||||
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked row is the source of truth.
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
expect((form.querySelector(".cwd-raw-wrap") as HTMLElement).hidden).toBe(true);
|
||||
expect(row.classList.contains("is-active")).toBe(true);
|
||||
expect(box.classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an 'Other directory…' escape hatch that reveals the raw input", async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const PROJECTS = "/api/projects";
|
||||
export const GITROOT = "/api/gitroot";
|
||||
export const NAMES = "/api/names";
|
||||
export const BRANCHES = "/api/branches";
|
||||
export const WORKTREES = "/api/worktrees";
|
||||
export const EVENTS = "/api/events";
|
||||
|
||||
/** Name of the query parameter `DIRS` and `GITROOT` both take. */
|
||||
@@ -29,6 +30,11 @@ export function attach(id: Uuid): string {
|
||||
return `${API_PREFIX}/attach/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/** `DELETE /api/worktrees/:id` — reclaim one retained worktree (#80). */
|
||||
export function worktree(id: Uuid): string {
|
||||
return `${WORKTREES}/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||
export function withPathQuery(route: string, value: string): string {
|
||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||
|
||||
@@ -9,6 +9,7 @@ const base: SpawnFormValues = {
|
||||
worktreeEnabled: false,
|
||||
worktreeBranch: "",
|
||||
worktreeBranchMode: "new",
|
||||
worktreeBranchEdited: false,
|
||||
};
|
||||
|
||||
describe("buildSpawnRequest", () => {
|
||||
@@ -37,7 +38,6 @@ describe("buildSpawnRequest", () => {
|
||||
worktree: {
|
||||
base_repo: "/home/user/project",
|
||||
branch: "feat/foo",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: true,
|
||||
},
|
||||
},
|
||||
@@ -62,7 +62,6 @@ describe("buildSpawnRequest", () => {
|
||||
worktree: {
|
||||
base_repo: "/home/user/project",
|
||||
branch: "spawn-v2",
|
||||
cleanup_on_kill: true,
|
||||
create_branch: false,
|
||||
},
|
||||
},
|
||||
|
||||
+327
-95
@@ -9,8 +9,8 @@ import {
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
import { abbreviateCwd, abbreviateFolderName } from "./types.js";
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
* from the repository's existing branches (#61). */
|
||||
@@ -25,6 +25,10 @@ export interface SpawnFormSeed {
|
||||
name?: string;
|
||||
worktreeEnabled?: boolean;
|
||||
worktreeBranch?: string;
|
||||
/** Whether the operator typed the branch themselves, rather than it having
|
||||
* been auto-synced from the session name. Carried separately because the
|
||||
* value alone cannot distinguish the two. */
|
||||
worktreeBranchEdited?: boolean;
|
||||
worktreeBranchMode?: WorktreeBranchMode;
|
||||
cwdRawVisible?: boolean;
|
||||
}
|
||||
@@ -37,6 +41,7 @@ export interface SpawnFormValues {
|
||||
name: string;
|
||||
worktreeEnabled: boolean;
|
||||
worktreeBranch: string;
|
||||
worktreeBranchEdited: boolean;
|
||||
/** "new" creates the branch; "existing" checks out a picked one (#61). */
|
||||
worktreeBranchMode: WorktreeBranchMode;
|
||||
/** Whether the cwd lives in the raw text input (vs a picked recent row). */
|
||||
@@ -70,9 +75,6 @@ export function buildSpawnRequest(
|
||||
req.worktree = {
|
||||
base_repo: baseRepo,
|
||||
branch,
|
||||
// The daemon always cleans up, guarded against unsaved work (#56);
|
||||
// the wire field stays for older daemons.
|
||||
cleanup_on_kill: true,
|
||||
// Existing-mode picks must check out as-is; the daemon creates
|
||||
// anything else with `worktree add -b` (#56/#61).
|
||||
create_branch: mode !== "existing",
|
||||
@@ -83,14 +85,20 @@ export function buildSpawnRequest(
|
||||
}
|
||||
|
||||
/** Built-ins used when the daemon's `/api/info` presets can't be fetched
|
||||
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`. */
|
||||
const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
* (older daemon, transient error). Mirrors `maestro_config::builtin_presets`.
|
||||
* Exported for the fast-forward spawn's preset fallback (#67). */
|
||||
export const FALLBACK_PRESETS: SpawnPreset[] = [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "opencode", label: "opencode", command: "opencode" },
|
||||
{ id: "zsh", label: "Shell", command: "zsh" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
];
|
||||
|
||||
/** Quiet period after raw-path typing before `/api/gitroot` fires (#74).
|
||||
* Must stay equal to `GITROOT_PROBE_DEBOUNCE_MS` in
|
||||
* crates/maestro-tui/src/draft.rs so both clients debounce alike. */
|
||||
const GITROOT_PROBE_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface SpawnFormHandle {
|
||||
el: HTMLFormElement;
|
||||
/** Remove global listeners (dir autocomplete) — call when unmounting. */
|
||||
@@ -122,15 +130,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
placeholder="bash -l" autocomplete="off" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Working directory</span>
|
||||
<span class="field-label">Project</span>
|
||||
<input class="raw-input project-filter" type="text" placeholder="Filter projects…"
|
||||
autocomplete="off" />
|
||||
<div class="picker projects"></div>
|
||||
<button type="button" class="project-other">Other directory…</button>
|
||||
<div class="raw-wrap cwd-raw-wrap" hidden>
|
||||
<input class="raw-input cwd-raw" name="cwd_raw" type="text"
|
||||
placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="field wt-step" hidden>
|
||||
<span class="field-label">Worktree</span>
|
||||
<div class="picker wt-choices"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field-label">Name</span>
|
||||
<div class="name-row">
|
||||
@@ -139,33 +152,38 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<button type="button" class="tree-folder-header wt-toggle" aria-expanded="false">
|
||||
<span class="chevron">▾</span>
|
||||
<span class="folder-name">Advanced options</span>
|
||||
<span class="wt-summary"></span>
|
||||
</button>
|
||||
<div class="wt-collapsible" hidden>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
</legend>
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
<p class="wt-hint" hidden></p>
|
||||
<div class="wt-body" hidden>
|
||||
<p class="wt-repo"></p>
|
||||
<div class="wt-mode" role="radiogroup" aria-label="Branch source">
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="new" />
|
||||
New branch
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
New branch name
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="radio" name="worktree_branch_mode" value="existing" />
|
||||
Existing branch
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Pick a branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="wt-new-wrap">
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="wt-exist-wrap" hidden>
|
||||
Branch
|
||||
<select class="branch-select"></select>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
@@ -179,11 +197,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
const errEl = form.querySelector(".form-error") as HTMLElement;
|
||||
const cmdTiles = form.querySelector(".cmd-tiles") as HTMLElement;
|
||||
const cmdRaw = form.querySelector(".cmd-raw") as HTMLInputElement;
|
||||
const recentsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const projectsEl = form.querySelector(".projects") as HTMLElement;
|
||||
const filterInput = form.querySelector(".project-filter") as HTMLInputElement;
|
||||
const otherBtn = form.querySelector(".project-other") as HTMLButtonElement;
|
||||
const wtStep = form.querySelector(".wt-step") as HTMLElement;
|
||||
const wtChoices = form.querySelector(".wt-choices") as HTMLElement;
|
||||
const cwdRawWrap = form.querySelector(".cwd-raw-wrap") as HTMLElement;
|
||||
const cwdRaw = form.querySelector(".cwd-raw") as HTMLInputElement;
|
||||
const wtEnabled = form.querySelector("input[name=worktree_enabled]") as HTMLInputElement;
|
||||
const wtToggle = form.querySelector(".wt-toggle") as HTMLButtonElement;
|
||||
const wtCollapsible = form.querySelector(".wt-collapsible") as HTMLElement;
|
||||
const wtSummary = form.querySelector(".wt-summary") as HTMLElement;
|
||||
const wtHint = form.querySelector(".wt-hint") as HTMLElement;
|
||||
const wtBody = form.querySelector(".wt-body") as HTMLElement;
|
||||
const wtRepo = form.querySelector(".wt-repo") as HTMLElement;
|
||||
@@ -229,6 +253,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
worktreeBranchMode: branchMode,
|
||||
cwdRawVisible: !cwdRawWrap.hidden,
|
||||
});
|
||||
@@ -238,7 +263,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// The worktree branch defaults to the session name — including a clone's
|
||||
// prefilled name — and tracks it on reroll/edit until the user types their
|
||||
// own branch. Only in "new" mode: an existing-branch pick is not a name.
|
||||
let branchEdited = Boolean(prefill.worktreeBranch);
|
||||
// True only once the operator types their own branch. It cannot be inferred
|
||||
// from the seed: in "new" mode `emit()` publishes the auto-synced session
|
||||
// name, so seeding from it froze the branch after any form rebuild
|
||||
// (#67 review). `worktreeBranchEdited` is carried explicitly instead.
|
||||
let branchEdited = Boolean(prefill.worktreeBranchEdited);
|
||||
const syncBranchToName = () => {
|
||||
if (!branchEdited && branchMode === "new") branchInput.value = nameInput.value;
|
||||
};
|
||||
@@ -329,6 +358,15 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Re-highlight the operator's already-picked tile after `renderCommandTiles`
|
||||
* rebuilds the row from scratch (new tiles carry no `is-active` class) —
|
||||
* without touching `command`, since that was already settled. */
|
||||
function reapplyActiveTile(): void {
|
||||
for (const tile of cmdTiles.querySelectorAll(".tile")) {
|
||||
tile.classList.toggle("is-active", (tile as HTMLElement).dataset.id === selectedPresetId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCommandTiles(): void {
|
||||
cmdTiles.replaceChildren();
|
||||
for (const p of presets) {
|
||||
@@ -337,13 +375,23 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
tile.className = "tile";
|
||||
tile.dataset.id = p.id;
|
||||
tile.innerHTML = `<span class="tile-label">${escapeHtml(p.label)}</span>`;
|
||||
tile.addEventListener("click", () => selectPreset(p.id));
|
||||
tile.addEventListener("click", () => {
|
||||
commandTouched = true;
|
||||
selectPreset(p.id);
|
||||
});
|
||||
cmdTiles.appendChild(tile);
|
||||
}
|
||||
}
|
||||
|
||||
// Set once the operator picks a tile or types a custom command — guards
|
||||
// `applyInitialCommand()` the same way `nameTouched` guards a late name
|
||||
// batch: a late `/api/info` resolution must never overwrite a field the
|
||||
// operator has already touched (#76).
|
||||
let commandTouched = false;
|
||||
|
||||
cmdRaw.addEventListener("input", () => {
|
||||
if (selectedPresetId === "custom" || presets.find((p) => p.id === selectedPresetId)?.command === "") {
|
||||
commandTouched = true;
|
||||
command = cmdRaw.value.trim();
|
||||
emit();
|
||||
}
|
||||
@@ -359,7 +407,11 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (info.presets?.length) {
|
||||
presets = info.presets;
|
||||
renderCommandTiles();
|
||||
applyInitialCommand();
|
||||
if (commandTouched) {
|
||||
reapplyActiveTile();
|
||||
} else {
|
||||
applyInitialCommand();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -386,12 +438,36 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (first) selectPreset(first.id, false);
|
||||
}
|
||||
|
||||
// ---- working-directory picker ---------------------------------------
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
// worktrees newest-first. "Other directory…" clears both steps.
|
||||
// 4. "+ new" is the default selection — except on a box that is itself a
|
||||
// linked worktree, which defaults to that worktree.
|
||||
// 5. "+ new" means create: cwd is the repository, create-worktree on.
|
||||
// An existing worktree means run in it: cwd is that worktree,
|
||||
// create-worktree OFF. The daemon never adopts (docs/WORKTREES.md), so
|
||||
// these are separate acts — #91's rule, now an explicit choice.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||
// membership. Only the free-text hatch pays for a probe.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
let groups: ProjectGroup[] = []; // rebuilt on every render
|
||||
let selectedGroup: ProjectGroup | null = null;
|
||||
let worktreeChoice: WorktreeChoice = "new";
|
||||
|
||||
function showRawCwd(prefillValue?: string, focus = true): void {
|
||||
cwdRawWrap.hidden = false;
|
||||
if (prefillValue != null) cwd = prefillValue;
|
||||
cwdRaw.value = cwd;
|
||||
markActiveProject(null);
|
||||
clearProjectSelection();
|
||||
if (focus) {
|
||||
cwdRaw.focus();
|
||||
cwdRaw.select();
|
||||
@@ -405,55 +481,98 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
cwd = cwdRaw.value.trim();
|
||||
branchCache = null;
|
||||
emit();
|
||||
if (wtEnabled.checked) probeGitRoot();
|
||||
if (wtEnabled.checked) scheduleGitRootProbe();
|
||||
});
|
||||
|
||||
function markActiveProject(activeCwd: string | null): void {
|
||||
for (const row of recentsEl.querySelectorAll(".project-row")) {
|
||||
row.classList.toggle(
|
||||
"is-active",
|
||||
(row as HTMLElement).dataset.cwd === activeCwd,
|
||||
);
|
||||
/** Every worktree of a group, including the group's own box when the scan
|
||||
* found no main checkout to nest it under. */
|
||||
function worktreesOf(group: ProjectGroup): Project[] {
|
||||
return [group.main, ...group.worktrees].filter((p) => p.is_worktree);
|
||||
}
|
||||
|
||||
function markActiveBox(): void {
|
||||
const active = selectedGroup?.main.path ?? null;
|
||||
for (const box of projectsEl.querySelectorAll(".project-box")) {
|
||||
box.classList.toggle("is-active", (box as HTMLElement).dataset.cwd === active);
|
||||
}
|
||||
}
|
||||
|
||||
function selectProject(project: Project): void {
|
||||
cwd = project.path;
|
||||
function markActiveChoice(): void {
|
||||
for (const btn of wtChoices.querySelectorAll(".wt-choice")) {
|
||||
const el = btn as HTMLElement;
|
||||
const value = el.dataset.kind === "new" ? "new" : (el.dataset.cwd ?? "");
|
||||
el.classList.toggle("is-active", value === worktreeChoice);
|
||||
}
|
||||
}
|
||||
|
||||
function clearProjectSelection(): void {
|
||||
selectedGroup = null;
|
||||
worktreeChoice = "new";
|
||||
wtChoices.replaceChildren();
|
||||
wtStep.hidden = true;
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
function selectProject(group: ProjectGroup): void {
|
||||
selectedGroup = group;
|
||||
cwdRawWrap.hidden = true;
|
||||
markActiveProject(project.path);
|
||||
// The scan already knows this directory is inside a repo (#61): the
|
||||
// toggle defaults back on and no /api/gitroot round trip is needed.
|
||||
wtEnabled.checked = true;
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
renderWorktreeChoices(group);
|
||||
markActiveBox();
|
||||
// Rule 4: a box that is itself a linked worktree defaults to running there.
|
||||
applyWorktreeChoice(group.main.is_worktree ? group.main.path : "new");
|
||||
}
|
||||
|
||||
function applyWorktreeChoice(choice: WorktreeChoice): void {
|
||||
const group = selectedGroup;
|
||||
if (!group) return;
|
||||
const target =
|
||||
choice === "new" ? group.main : worktreesOf(group).find((p) => p.path === choice);
|
||||
if (!target) return;
|
||||
worktreeChoice = choice;
|
||||
cwd = target.path;
|
||||
branchCache = null;
|
||||
markActiveChoice();
|
||||
if (choice === "new") {
|
||||
wtEnabled.checked = true;
|
||||
updateWtSummary();
|
||||
applyProjectRepo(group.main.base_repo ?? group.main.path);
|
||||
} else {
|
||||
wtEnabled.checked = false;
|
||||
updateWtSummary();
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
emit();
|
||||
// Picking a project pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === project.last_command && p.command !== "");
|
||||
// The chosen directory pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
let discovered: Project[] = []; // daemon snapshot, daemon-sorted
|
||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||
function visibleGroups(query: string): ProjectGroup[] {
|
||||
return groups.filter((g) => filterProjects([g.main, ...g.worktrees], query).length > 0);
|
||||
}
|
||||
|
||||
function renderProjects(): void {
|
||||
recentsEl.replaceChildren();
|
||||
const sorted = sortProjects(filterProjects(discovered, filterInput.value), new Date());
|
||||
for (const group of groupProjects(sorted)) {
|
||||
recentsEl.appendChild(projectRow(group.main));
|
||||
for (const wt of group.worktrees) recentsEl.appendChild(projectRow(wt));
|
||||
}
|
||||
if (discovered.length > 0 && sorted.length === 0) {
|
||||
projectsEl.replaceChildren();
|
||||
groups = groupProjects(sortProjects(discovered, new Date()));
|
||||
const visible = visibleGroups(filterInput.value);
|
||||
for (const group of visible) projectsEl.appendChild(projectBox(group));
|
||||
if (discovered.length > 0 && visible.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "picker-empty";
|
||||
empty.textContent = "No projects match.";
|
||||
recentsEl.appendChild(empty);
|
||||
projectsEl.appendChild(empty);
|
||||
}
|
||||
recentsEl.appendChild(otherDirRow());
|
||||
markActiveProject(cwdRawWrap.hidden ? cwd : null);
|
||||
markActiveBox();
|
||||
}
|
||||
|
||||
filterInput.addEventListener("input", renderProjects);
|
||||
otherBtn.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
|
||||
async function loadProjects(): Promise<void> {
|
||||
recentsEl.replaceChildren(loadingRow());
|
||||
projectsEl.replaceChildren(loadingRow());
|
||||
try {
|
||||
discovered = await fetchProjects();
|
||||
} catch {
|
||||
@@ -464,12 +583,13 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
if (seedCwd) {
|
||||
// A seed sets the directory verbatim, regardless of what the scan found.
|
||||
// `cwdRawVisible === false` marks a draft restore whose cwd came from a
|
||||
// picked row: keep the picker and don't steal focus. Otherwise land
|
||||
// picked box: keep the picker and don't steal focus. Otherwise land
|
||||
// focused in the raw input (clone / no-match flows).
|
||||
if (prefill.cwdRawVisible === false) {
|
||||
cwd = seedCwd;
|
||||
cwdRaw.value = seedCwd;
|
||||
markActiveProject(cwd);
|
||||
// Restoring must not touch the worktree toggle — the seed carries it.
|
||||
if (!restoreSelection(seedCwd)) clearProjectSelection();
|
||||
} else {
|
||||
showRawCwd(seedCwd, false);
|
||||
}
|
||||
@@ -486,34 +606,66 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
emit();
|
||||
}
|
||||
|
||||
function projectRow(project: Project): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = `project-row${project.is_worktree ? " is-worktree" : ""}${
|
||||
isStale(project, new Date()) ? " is-stale" : ""
|
||||
}`;
|
||||
row.dataset.cwd = project.path;
|
||||
// Abbreviate exactly like the sidebar: home → `~`, then collapse leading
|
||||
// segments to initials (`~/g/n/maestro`). The full path stays in the tooltip
|
||||
// and `dataset.cwd` (what's actually submitted).
|
||||
const display = abbreviateFolderName(abbreviateCwd(project.path, homeDir));
|
||||
row.innerHTML = `
|
||||
<span class="project-info">
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="recent-dir" title="${escapeHtml(project.path)}">${display}</span>
|
||||
</span>
|
||||
<span class="recent-cmd">${escapeHtml(project.last_command ?? "")}</span>`;
|
||||
row.addEventListener("click", () => selectProject(project));
|
||||
return row;
|
||||
/** Put the two steps back on a restored draft's cwd, without re-running the
|
||||
* side effects of a fresh pick. */
|
||||
function restoreSelection(path: string): boolean {
|
||||
for (const group of groups) {
|
||||
const wt = worktreesOf(group).find((p) => p.path === path);
|
||||
const choice: WorktreeChoice | null =
|
||||
wt ? wt.path : group.main.path === path ? "new" : null;
|
||||
if (choice === null) continue;
|
||||
selectedGroup = group;
|
||||
worktreeChoice = choice;
|
||||
renderWorktreeChoices(group);
|
||||
markActiveChoice();
|
||||
markActiveBox();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function otherDirRow(): HTMLElement {
|
||||
const row = document.createElement("button");
|
||||
row.type = "button";
|
||||
row.className = "project-row project-other";
|
||||
row.innerHTML = `<span class="project-info"><span class="recent-dir">Other directory…</span></span>`;
|
||||
row.addEventListener("click", () => showRawCwd(cwd || homeDir));
|
||||
return row;
|
||||
function projectBox(group: ProjectGroup): HTMLElement {
|
||||
const project = group.main;
|
||||
const box = document.createElement("button");
|
||||
box.type = "button";
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderWorktreeChoices(group: ProjectGroup): void {
|
||||
wtChoices.replaceChildren();
|
||||
wtChoices.appendChild(
|
||||
choiceButton("new", "+ new", `new worktree of ${group.main.name}`),
|
||||
);
|
||||
for (const wt of worktreesOf(group)) {
|
||||
wtChoices.appendChild(choiceButton(wt.path, wt.name, wt.path));
|
||||
}
|
||||
wtStep.hidden = false;
|
||||
}
|
||||
|
||||
function choiceButton(choice: WorktreeChoice, label: string, sub: string): HTMLElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `wt-choice${choice === "new" ? " wt-choice-new" : ""}`;
|
||||
btn.dataset.kind = choice === "new" ? "new" : "existing";
|
||||
if (choice !== "new") btn.dataset.cwd = choice;
|
||||
btn.innerHTML = `
|
||||
<span class="wt-choice-name">${escapeHtml(label)}</span>
|
||||
<span class="wt-choice-sub">${escapeHtml(sub)}</span>`;
|
||||
btn.addEventListener("click", () => applyWorktreeChoice(choice));
|
||||
return btn;
|
||||
}
|
||||
|
||||
function loadingRow(): HTMLElement {
|
||||
@@ -523,6 +675,18 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return p;
|
||||
}
|
||||
|
||||
// ---- worktree: disclosure (#93), reuses the sidebar's folder-header ---
|
||||
let advancedCollapsed = true;
|
||||
function setAdvancedCollapsed(collapsed: boolean): void {
|
||||
advancedCollapsed = collapsed;
|
||||
wtToggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
wtCollapsible.hidden = collapsed;
|
||||
}
|
||||
function updateWtSummary(): void {
|
||||
wtSummary.textContent = wtEnabled.checked ? "creates a worktree" : "no worktree";
|
||||
}
|
||||
wtToggle.addEventListener("click", () => setAdvancedCollapsed(!advancedCollapsed));
|
||||
|
||||
// ---- worktree (branch-only; base_repo inferred via scan or /gitroot) --
|
||||
// The project scan already reports repo membership, so a picked project
|
||||
// resolves its base repo locally (#61); only the free-text escape hatch
|
||||
@@ -539,6 +703,16 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
/** Decide how the worktree section describes the current target. Called
|
||||
* once the initial load has settled `cwd`. */
|
||||
function resolveBaseRepo(): void {
|
||||
// Nothing to resolve when the operator turned the worktree off — the
|
||||
// section would otherwise rebuild expanded under an unchecked box, and
|
||||
// fire a `/api/gitroot` probe for a worktree nobody asked for
|
||||
// (#67 review).
|
||||
if (!wtEnabled.checked) {
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = true;
|
||||
inferredBaseRepo = null;
|
||||
return;
|
||||
}
|
||||
if (!cwd) {
|
||||
inferredBaseRepo = null;
|
||||
wtBody.hidden = true;
|
||||
@@ -551,7 +725,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
applyProjectRepo(project.base_repo ?? project.path);
|
||||
return;
|
||||
}
|
||||
void probeGitRoot();
|
||||
scheduleGitRootProbe();
|
||||
}
|
||||
|
||||
// Every keystroke resets this timer, so a burst of typing issues one
|
||||
// request once the operator pauses. `probeToken` inside probeGitRoot still
|
||||
// owns ordering: a scheduled probe that fires after a newer one started
|
||||
// loses the token race and is dropped.
|
||||
let probeTimer: number | undefined;
|
||||
function scheduleGitRootProbe(): void {
|
||||
window.clearTimeout(probeTimer);
|
||||
probeTimer = window.setTimeout(() => {
|
||||
probeTimer = undefined;
|
||||
void probeGitRoot();
|
||||
}, GITROOT_PROBE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function probeGitRoot(): Promise<void> {
|
||||
@@ -580,6 +767,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// Publish the untick too, or the next render seeds the box checked with
|
||||
// no inferred repo and submitting fails.
|
||||
wtEnabled.checked = false;
|
||||
updateWtSummary();
|
||||
emit();
|
||||
wtBody.hidden = true;
|
||||
wtHint.hidden = false;
|
||||
@@ -633,11 +821,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
return;
|
||||
}
|
||||
for (const b of branches) {
|
||||
// A branch another worktree holds stays visible but unpickable — git
|
||||
// would refuse to check it out twice; the path says who holds it.
|
||||
const o = opt(b.worktree ? `${b.name} — held by ${b.worktree}` : b.name, b.name);
|
||||
o.disabled = Boolean(b.worktree);
|
||||
o.title = b.worktree ?? "";
|
||||
// A branch that is already checked out anywhere stays visible but
|
||||
// unpickable — git refuses a second checkout. That includes the main
|
||||
// checkout's own HEAD, which is commonly `main` and would otherwise be
|
||||
// the default pick, making every existing-branch spawn fail (#54 review).
|
||||
const held = b.worktree ?? (b.is_head ? "the main checkout" : null);
|
||||
const o = opt(held ? `${b.name} — held by ${held}` : b.name, b.name);
|
||||
o.disabled = held !== null;
|
||||
o.title = held ?? "";
|
||||
branchSelect.appendChild(o);
|
||||
}
|
||||
}
|
||||
@@ -650,6 +841,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
branchSelect.addEventListener("change", () => emit());
|
||||
|
||||
wtEnabled.addEventListener("change", () => {
|
||||
updateWtSummary();
|
||||
emit();
|
||||
if (wtEnabled.checked) {
|
||||
resolveBaseRepo();
|
||||
@@ -663,6 +855,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// The toggle defaults ON (#61): the operator almost always wants a
|
||||
// worktree, and every non-repo target unticks it via the gitroot probe.
|
||||
wtEnabled.checked = prefill.worktreeEnabled ?? true;
|
||||
updateWtSummary();
|
||||
if (prefill.worktreeBranch) branchInput.value = prefill.worktreeBranch;
|
||||
setBranchMode(branchMode);
|
||||
|
||||
@@ -673,6 +866,43 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// ---- live dir autocomplete on the raw cwd input (NAP-329) ------------
|
||||
const autocomplete = attachDirAutocomplete(cwdRaw);
|
||||
|
||||
// ---- Enter submits from anywhere (#97) --------------------------------
|
||||
// A project-box/wt-choice that isn't yet the active pick takes the Enter to
|
||||
// choose it instead; the next Enter, now that it's active, submits.
|
||||
// dirAutocomplete's own Enter (accept the highlight) already preventDefaults
|
||||
// when it handles the key, which the `e.defaultPrevented` guard defers to.
|
||||
// The worktree checkbox keeps Enter as a toggle (TUI parity: `WorktreeToggle`
|
||||
// does the same) rather than folding it into the blanket-submit rule below.
|
||||
form.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Enter" || e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target === wtEnabled) {
|
||||
e.preventDefault();
|
||||
wtEnabled.checked = !wtEnabled.checked;
|
||||
wtEnabled.dispatchEvent(new Event("change"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (target instanceof HTMLButtonElement) {
|
||||
if (!target.classList.contains("project-box") && !target.classList.contains("wt-choice")) return;
|
||||
e.preventDefault();
|
||||
if (!target.classList.contains("is-active")) {
|
||||
target.click();
|
||||
return;
|
||||
}
|
||||
form.requestSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
if (target === filterInput) {
|
||||
const top = visibleGroups(filterInput.value)[0];
|
||||
if (top && top.main.path !== selectedGroup?.main.path) selectProject(top);
|
||||
}
|
||||
form.requestSubmit();
|
||||
});
|
||||
|
||||
// ---- submit ----------------------------------------------------------
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -684,6 +914,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
name: nameInput.value,
|
||||
worktreeEnabled: wtEnabled.checked,
|
||||
worktreeBranch: branchValue(),
|
||||
worktreeBranchEdited: branchEdited,
|
||||
worktreeBranchMode: branchMode,
|
||||
},
|
||||
inferredBaseRepo,
|
||||
@@ -708,6 +939,7 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
el: form,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
window.clearTimeout(probeTimer);
|
||||
autocomplete.destroy();
|
||||
form.remove();
|
||||
},
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Enter submits the draft form from any field, at any point (#97). The two
|
||||
// exceptions: a picker button that isn't yet the active choice takes the
|
||||
// Enter to *choose* it instead (the next Enter submits), and the project
|
||||
// filter must resolve to the highlighted project rather than sending
|
||||
// whatever cwd was already set.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const MAESTRO: Project = {
|
||||
path: "/home/user/tea/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
};
|
||||
|
||||
const WT_A: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||
name: "bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-22T10:00:00Z",
|
||||
};
|
||||
|
||||
const OTHER: Project = {
|
||||
path: "/home/user/tea/rev",
|
||||
name: "rev",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-19T10:00:00Z",
|
||||
};
|
||||
|
||||
function stubFetch(projects: Project[]): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
const body = url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["amber-fox"] }
|
||||
: url.includes("/api/branches")
|
||||
? []
|
||||
: url.includes("/api/dirs")
|
||||
? { path: "/opt", entries: ["nomatch-zzz"], truncated: false }
|
||||
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
describe("spawnForm Enter-to-submit (#97)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
function submitSpy(): ReturnType<typeof vi.fn> {
|
||||
return vi.fn();
|
||||
}
|
||||
|
||||
function pressEnter(el: HTMLElement): { defaultPrevented: boolean } {
|
||||
const evt = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||
el.dispatchEvent(evt);
|
||||
return { defaultPrevented: evt.defaultPrevented };
|
||||
}
|
||||
|
||||
const box = (form: HTMLFormElement, path: string) =>
|
||||
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||
|
||||
it("toggles the worktree checkbox on Enter instead of submitting (TUI parity)", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const toggle = form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||
expect(toggle.checked).toBe(true);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(toggle);
|
||||
expect(toggle.checked).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(toggle);
|
||||
expect(toggle.checked).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("submits immediately from ordinary fields", () => {
|
||||
it("name field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>("input[name=name]")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("worktree branch field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(form.querySelector<HTMLInputElement>("input[name=worktree_branch]")!);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("custom command field", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>('.cmd-tiles .tile[data-id="custom"]')!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
raw.value = "bash -l";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(raw);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("raw cwd field once the autocomplete dropdown is closed", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/xyz-nothing-here";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(raw);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not submit while the raw cwd autocomplete has a highlighted suggestion", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.focus();
|
||||
raw.value = "/opt/";
|
||||
raw.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
const { defaultPrevented } = pressEnter(raw);
|
||||
|
||||
expect(defaultPrevented).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(raw.value).toBe("/opt/nomatch-zzz/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("two-step picker: first Enter chooses, second submits", () => {
|
||||
it("project box", async () => {
|
||||
const form = await mount([MAESTRO]);
|
||||
const b = box(form, MAESTRO.path);
|
||||
b.focus();
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
const first = pressEnter(b);
|
||||
expect(first.defaultPrevented).toBe(true);
|
||||
expect(b.classList.contains("is-active")).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(b);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("worktree choice", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
const choice = form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!;
|
||||
expect(choice.classList.contains("is-active")).toBe(false); // "+ new" is the default
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(choice);
|
||||
expect(choice.classList.contains("is-active")).toBe(true);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
pressEnter(choice);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("project filter", () => {
|
||||
it("submits with the highlighted (first matching) project, not the filter text", async () => {
|
||||
const form = await mount([MAESTRO, OTHER]);
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "rev";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(filter);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(box(form, OTHER.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not clobber an already-chosen worktree when the filter still matches it", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.click();
|
||||
await drain();
|
||||
|
||||
const filter = form.querySelector<HTMLInputElement>(".project-filter")!;
|
||||
filter.value = "maestro";
|
||||
filter.dispatchEvent(new Event("input"));
|
||||
const spy = submitSpy();
|
||||
form.addEventListener("submit", spy);
|
||||
|
||||
pressEnter(filter);
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
form.querySelector<HTMLButtonElement>(`.wt-choice[data-cwd="${WT_A.path}"]`)!.classList.contains(
|
||||
"is-active",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||
|
||||
/** Stubs fetch so `/api/info` only resolves once `resolveInfo()` is called —
|
||||
* everything else (names, projects, gitroot) answers immediately with an
|
||||
* empty/default body. Lets a test pick a preset (or type a custom command)
|
||||
* mid-flight, then observe what the late `/api/info` resolution does to it. */
|
||||
function stubDeferredInfo(): { resolveInfo: () => void } {
|
||||
let releaseInfo: () => void = () => {};
|
||||
const infoGate = new Promise<void>((resolve) => {
|
||||
releaseInfo = resolve;
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/info")) {
|
||||
await infoGate;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
hostname: "h",
|
||||
version: "0",
|
||||
home_dir: "/home/user",
|
||||
presets: [
|
||||
{ id: "claude", label: "Claude", command: "claude" },
|
||||
{ id: "fish", label: "Fish", command: "fish" },
|
||||
{ id: "custom", label: "Custom…", command: "" },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return { resolveInfo: releaseInfo };
|
||||
}
|
||||
|
||||
describe("spawnForm preset selection vs. late /api/info (#76)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function drain(): Promise<void> {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
}
|
||||
|
||||
it("keeps an operator-picked tile after /api/info resolves late", async () => {
|
||||
const { resolveInfo } = stubDeferredInfo();
|
||||
let values: SpawnFormValues | undefined;
|
||||
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
// Fallback presets are up; the operator picks "zsh" before /api/info lands.
|
||||
const zshTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
(t) => t.dataset.id === "zsh",
|
||||
)!;
|
||||
zshTile.click();
|
||||
expect(zshTile.classList.contains("is-active")).toBe(true);
|
||||
|
||||
// /api/info lands late with a preset list that doesn't even include "zsh".
|
||||
resolveInfo();
|
||||
await drain();
|
||||
|
||||
// The operator's pick must not be discarded back to the new list's first
|
||||
// entry — `onChange` only starts firing once the initial load settles
|
||||
// (`ready`), which happens right here, so this is the first place the
|
||||
// published command is observable.
|
||||
expect(values?.command).toBe("zsh");
|
||||
});
|
||||
|
||||
it("keeps an operator-typed custom command after /api/info resolves late", async () => {
|
||||
const { resolveInfo } = stubDeferredInfo();
|
||||
let values: SpawnFormValues | undefined;
|
||||
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
const customTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
(t) => t.dataset.id === "custom",
|
||||
)!;
|
||||
customTile.click();
|
||||
const cmdRaw = form.querySelector<HTMLInputElement>(".cmd-raw")!;
|
||||
cmdRaw.value = "htop";
|
||||
cmdRaw.dispatchEvent(new Event("input"));
|
||||
|
||||
resolveInfo();
|
||||
await drain();
|
||||
|
||||
expect(values?.command).toBe("htop");
|
||||
expect(cmdRaw.value).toBe("htop");
|
||||
});
|
||||
|
||||
it("still applies the real presets' first entry when the operator never touched the command", async () => {
|
||||
const { resolveInfo } = stubDeferredInfo();
|
||||
let values: SpawnFormValues | undefined;
|
||||
handle = createSpawnForm({ onChange: (v) => (values = v), onSuccess() {} });
|
||||
const form = handle.el;
|
||||
document.body.appendChild(form);
|
||||
await drain();
|
||||
|
||||
resolveInfo();
|
||||
await drain();
|
||||
|
||||
// Untouched: the refined preset list is still free to settle the command.
|
||||
expect(values?.command).toBe("claude");
|
||||
const fishTile = [...form.querySelectorAll<HTMLButtonElement>(".tile")].find(
|
||||
(t) => t.dataset.id === "fish",
|
||||
);
|
||||
expect(fishTile).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
/** Debounce window under test — must equal GITROOT_PROBE_DEBOUNCE_MS in
|
||||
* spawnForm.ts (#74). */
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
const PROJECTS = [
|
||||
{ path: "/home/user/maestro", name: "maestro", last_activity: "2026-08-20T10:00:00Z" },
|
||||
];
|
||||
|
||||
/** Stub the fetches the form makes. Gitroot responses for a given path can be
|
||||
* held until `releaseGitRoot(path)` is called, so tests can land answers out
|
||||
* of order. */
|
||||
function stubFetch(opts?: { holdGitRootFor?: string[] }): {
|
||||
urls: string[];
|
||||
gitRootCalls: string[];
|
||||
releaseGitRoot: (path: string) => void;
|
||||
} {
|
||||
const urls: string[] = [];
|
||||
const gitRootCalls: string[] = [];
|
||||
const releases = new Map<string, () => void>();
|
||||
const held = new Map<string, Promise<void>>();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url.includes("/api/dirs")) {
|
||||
return new Response(JSON.stringify({ entries: [] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/gitroot")) {
|
||||
const path = new URL(url, "http://localhost").searchParams.get("path") ?? "";
|
||||
gitRootCalls.push(path);
|
||||
if (opts?.holdGitRootFor?.includes(path)) {
|
||||
let release!: () => void;
|
||||
held.set(
|
||||
path,
|
||||
new Promise<void>((r) => {
|
||||
release = r;
|
||||
}),
|
||||
);
|
||||
releases.set(path, release);
|
||||
}
|
||||
await held.get(path);
|
||||
return new Response(JSON.stringify({ root: path }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/names")) {
|
||||
return new Response(JSON.stringify({ names: ["amber-fox"] }), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||
{ status: 200 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
return {
|
||||
urls,
|
||||
gitRootCalls,
|
||||
releaseGitRoot: (path: string) => releases.get(path)?.(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("spawnForm gitroot probe debounce (#74)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function mount(opts?: Parameters<typeof stubFetch>[0]) {
|
||||
const stub = stubFetch(opts);
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
return { form: handle.el, stub };
|
||||
}
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
function typeRaw(form: HTMLFormElement, value: string): void {
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = value;
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
}
|
||||
|
||||
it("a keystroke burst fires exactly one probe after the quiet period", async () => {
|
||||
const { form, stub } = await mount();
|
||||
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
// Burst: every keystroke reschedules; nothing may hit /api/gitroot yet.
|
||||
for (let i = 1; i <= "/opt/somewhere".length; i++) {
|
||||
typeRaw(form, "/opt/somewhere".slice(0, i));
|
||||
await drain();
|
||||
expect(stub.gitRootCalls).toEqual([]);
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
await drain();
|
||||
|
||||
expect(stub.gitRootCalls).toEqual(["/opt/somewhere"]);
|
||||
expect(stub.urls.filter((u) => u.includes("/api/gitroot")).length).toBe(1);
|
||||
});
|
||||
|
||||
it("a stale probe answer cannot overwrite a newer input", async () => {
|
||||
const { form, stub } = await mount({ holdGitRootFor: ["/repo/old"] });
|
||||
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
|
||||
// Probe for the old target, held in flight.
|
||||
typeRaw(form, "/repo/old");
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
|
||||
// Newer target supersedes it and answers immediately.
|
||||
typeRaw(form, "/repo/new");
|
||||
await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
|
||||
await drain();
|
||||
|
||||
expect(stub.gitRootCalls).toEqual(["/repo/old", "/repo/new"]);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||
expect(
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||
).toBe(true);
|
||||
|
||||
// The late answer to the old target lands last but must lose.
|
||||
stub.releaseGitRoot("/repo/old");
|
||||
await drain();
|
||||
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain("/repo/new");
|
||||
expect(
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ const BRANCHES = [
|
||||
* its branch list is still in flight. */
|
||||
function stubFetch(opts?: {
|
||||
gateBranches?: (release: () => void) => void;
|
||||
projects?: unknown[];
|
||||
}): string[] {
|
||||
const urls: string[] = [];
|
||||
const gated = new Promise<void>((resolve) => {
|
||||
@@ -37,7 +38,7 @@ function stubFetch(opts?: {
|
||||
return new Response(JSON.stringify(BRANCHES), { status: 200 });
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
return new Response(JSON.stringify(PROJECTS), { status: 200 });
|
||||
return new Response(JSON.stringify(opts?.projects ?? PROJECTS), { status: 200 });
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({ hostname: "h", version: "0", home_dir: "/home/user", presets: [] }),
|
||||
@@ -79,10 +80,10 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
};
|
||||
|
||||
function pickProject(form: HTMLFormElement): void {
|
||||
form.querySelector<HTMLButtonElement>(".project-row.project-other")!.click();
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
form
|
||||
.querySelectorAll<HTMLButtonElement>(".project-row")
|
||||
.forEach((row) => row.dataset.cwd === "/home/user/maestro" && row.click());
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/maestro" && box.click());
|
||||
}
|
||||
|
||||
it("defaults the worktree toggle on for a picked project with no gitroot probe", async () => {
|
||||
@@ -100,14 +101,44 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults the worktree toggle off for a row that is itself a worktree (#91)", async () => {
|
||||
urls = stubFetch({
|
||||
projects: [
|
||||
{
|
||||
path: "/home/user/wt-91",
|
||||
name: "wt-91",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/user/maestro",
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!.checked).toBe(
|
||||
false,
|
||||
);
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
expect(handle.el.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("still probes the gitroot for a free-text directory", async () => {
|
||||
const form = await mount();
|
||||
|
||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot fire.
|
||||
form.querySelector<HTMLElement>(".project-row.project-other")!.click();
|
||||
// Escape hatch: switch to a free-text dir; only now does /api/gitroot
|
||||
// fire — debounced, so the quiet period must elapse first (#74).
|
||||
form.querySelector<HTMLElement>(".project-other")!.click();
|
||||
const raw = form.querySelector<HTMLInputElement>("input[name=cwd_raw]")!;
|
||||
raw.value = "/opt/somewhere";
|
||||
raw.dispatchEvent(new Event("input"));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
await drain();
|
||||
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(true);
|
||||
@@ -149,6 +180,74 @@ describe("spawnForm worktree defaults and branch picker (#61)", () => {
|
||||
expect(modes.at(-1)).toBe("existing");
|
||||
});
|
||||
|
||||
it("labels the new-branch input and existing-branch picker distinctly (#94)", async () => {
|
||||
const form = await mount();
|
||||
pickProject(form);
|
||||
await drain();
|
||||
|
||||
const newLabel = form.querySelector<HTMLElement>(".wt-new-wrap")!.textContent!.trim();
|
||||
const existLabel = form.querySelector<HTMLElement>(".wt-exist-wrap")!.textContent!.trim();
|
||||
|
||||
expect(newLabel).not.toBe("Branch");
|
||||
expect(existLabel).not.toBe("Branch");
|
||||
expect(newLabel).not.toBe(existLabel);
|
||||
expect(newLabel).toMatch(/name/i);
|
||||
expect(existLabel).toMatch(/pick|choose|select/i);
|
||||
});
|
||||
|
||||
it("collapses the advanced (worktree) section by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
const toggle = form.querySelector<HTMLButtonElement>(".wt-toggle")!;
|
||||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("states in the collapsed header that a worktree will be created by default (#93)", async () => {
|
||||
const form = await mount();
|
||||
expect(form.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/creates? a worktree/i);
|
||||
});
|
||||
|
||||
it("states in the collapsed header that no worktree will be created for a worktree row (#91, #93)", async () => {
|
||||
urls = stubFetch({
|
||||
projects: [
|
||||
{
|
||||
path: "/home/user/wt-91",
|
||||
name: "wt-91",
|
||||
is_worktree: true,
|
||||
base_repo: "/home/user/maestro",
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
handle = createSpawnForm({ onSuccess() {} });
|
||||
document.body.appendChild(handle.el);
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
|
||||
handle.el
|
||||
.querySelectorAll<HTMLButtonElement>(".project-box")
|
||||
.forEach((box) => box.dataset.cwd === "/home/user/wt-91" && box.click());
|
||||
await drain();
|
||||
|
||||
expect(handle.el.querySelector<HTMLElement>(".wt-summary")!.textContent).toMatch(/no worktree/i);
|
||||
// Still collapsed — picking a project must not force it open.
|
||||
expect(handle.el.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("expanding the advanced section does not reorder the fields above it (#93)", async () => {
|
||||
const form = await mount();
|
||||
const before = [...form.querySelectorAll(".field")];
|
||||
|
||||
form.querySelector<HTMLButtonElement>(".wt-toggle")!.click();
|
||||
|
||||
expect(form.querySelector<HTMLButtonElement>(".wt-toggle")!.getAttribute("aria-expanded")).toBe(
|
||||
"true",
|
||||
);
|
||||
expect(form.querySelector<HTMLElement>(".wt-collapsible")!.hidden).toBe(false);
|
||||
expect([...form.querySelectorAll(".field")]).toStrictEqual(before);
|
||||
});
|
||||
|
||||
it("does not repopulate a destroyed form when the branch list lands late", async () => {
|
||||
const gate: { release?: () => void } = {};
|
||||
urls = stubFetch({ gateBranches: (r) => (gate.release = r) });
|
||||
|
||||
+365
-78
@@ -727,10 +727,8 @@ textarea {
|
||||
}
|
||||
|
||||
/* Earlier path segments collapse to their initial; the trailing segment (the
|
||||
one that actually names the dir) stays full. Dim the collapsed prefix. Shared
|
||||
by the sidebar tree and the spawn dialog's recent-directory picker. */
|
||||
.folder-name .folder-stem,
|
||||
.recent-dir .folder-stem {
|
||||
one that actually names the dir) stays full. Dim the collapsed prefix. */
|
||||
.folder-name .folder-stem {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
@@ -789,53 +787,96 @@ textarea {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
|
||||
always, line 2 (meta) only when it has something to say. */
|
||||
.tree-leaf.session-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.card-line1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leaf-name {
|
||||
font-weight: 500;
|
||||
font-size: var(--fs-13);
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaf-meta {
|
||||
font-size: var(--fs-11);
|
||||
color: var(--muted);
|
||||
margin-left: auto;
|
||||
padding-left: var(--sp-2);
|
||||
/* Grows to push the actions to the right edge but shrinks first, so the name
|
||||
truncates before it can ever reach them — overlap is a defect (#107). */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
flex: 0 1 auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
/* Line 2 — dot + duration in the state's colour, then muted fragments. The
|
||||
state word itself is never spelled out here; the colour carries it. */
|
||||
.card-line2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.leaf-cmd {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.leaf-badge {
|
||||
font-size: var(--fs-11);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-pill);
|
||||
padding: 1px 8px;
|
||||
}
|
||||
|
||||
.card-line2[data-activity="active"] {
|
||||
color: var(--live);
|
||||
}
|
||||
.card-line2[data-activity="idle"] {
|
||||
color: var(--muted);
|
||||
}
|
||||
.card-line2[data-activity="awaiting-input"] {
|
||||
color: var(--warn);
|
||||
}
|
||||
.card-line2[data-activity="error"] {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.activity-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.activity-duration {
|
||||
color: inherit;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Agent-reported detail (#112): sits after the duration, shrinks with an
|
||||
ellipsis so it never pushes the trailing fragments off the line. */
|
||||
.activity-detail {
|
||||
color: var(--faint);
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Blocked-work / exited / command fragments recede: the ⚠ glyph already
|
||||
signals severity and "exited" dims with the row. */
|
||||
.card-flag {
|
||||
color: var(--faint);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||
name (#107). They reserve their width even while hover-faded. */
|
||||
.leaf-actions {
|
||||
position: absolute;
|
||||
right: var(--sp-1);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -1208,6 +1249,9 @@ textarea {
|
||||
/* ---------- forms ---------- */
|
||||
|
||||
.spawn-form {
|
||||
width: 100%;
|
||||
max-width: 60ch;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-3);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -1276,18 +1320,33 @@ textarea {
|
||||
.worktree {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
padding: var(--sp-3);
|
||||
padding: 0 var(--sp-3);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
background: color-mix(in oklab, var(--surface-2) 50%, transparent);
|
||||
}
|
||||
|
||||
.worktree legend {
|
||||
padding: 0 6px;
|
||||
color: var(--ink);
|
||||
font-size: var(--fs-13);
|
||||
.worktree .wt-toggle {
|
||||
margin: 0 calc(var(--sp-3) * -1);
|
||||
padding-left: var(--sp-3);
|
||||
width: calc(100% + var(--sp-3) * 2);
|
||||
}
|
||||
|
||||
.wt-summary {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-12);
|
||||
}
|
||||
|
||||
.wt-collapsible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
padding-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.wt-collapsible[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@@ -1371,8 +1430,9 @@ textarea {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Project picker rows (#59). Worktree rows are indented under their base
|
||||
repository; rows stale beyond 7 days are dimmed but still selectable. */
|
||||
/* Working-directory picker (#95). Two steps: a grid of project boxes, then the
|
||||
picked project's worktree choices. Stale beyond 7 days dims but stays
|
||||
selectable. */
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1387,73 +1447,155 @@ textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-row {
|
||||
/* `display` from `.field`/`.raw-wrap` outranks the UA sheet's `[hidden]`, so
|
||||
step two and the raw-cwd hatch stayed visible while hidden. */
|
||||
.wt-step[hidden],
|
||||
.raw-wrap[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.picker.projects {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
align-content: start;
|
||||
gap: var(--sp-2);
|
||||
max-height: 300px;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.project-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.project-box:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.project-box.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-box.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The whole path, never abbreviated — it is what tells two projects sharing a
|
||||
basename apart. Wraps rather than truncating so none of it is hidden. */
|
||||
.project-path {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--faint);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-meta:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.project-other {
|
||||
align-self: flex-start;
|
||||
padding: 4px 0;
|
||||
min-height: 30px;
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-13);
|
||||
font-style: italic;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.project-other:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Step two: "+ new" (create one) or an existing worktree (run in it). */
|
||||
.picker.wt-choices {
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.wt-choice {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
min-height: 32px;
|
||||
padding: 6px var(--sp-3);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
color: var(--ink);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color var(--dur-fast) var(--ease-out),
|
||||
background var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.project-row:hover {
|
||||
.wt-choice:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.project-row.is-active {
|
||||
.wt-choice.is-active {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.project-row.is-worktree {
|
||||
margin-left: var(--sp-4);
|
||||
}
|
||||
|
||||
.project-row.is-stale {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
.wt-choice-name {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--fs-13);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recent-dir {
|
||||
.wt-choice-new .wt-choice-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.recent-cmd {
|
||||
flex: 0 0 auto;
|
||||
.wt-choice-sub {
|
||||
min-width: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-12);
|
||||
line-height: 1.35;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.project-other .recent-dir {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.picker-empty {
|
||||
@@ -2225,3 +2367,148 @@ textarea {
|
||||
background: oklch(0 0 0 / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- retained worktrees (#84) ---------- */
|
||||
|
||||
.worktrees {
|
||||
padding: var(--sp-4);
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wt-head {
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.wt-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-15);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
margin: 0 0 var(--sp-1);
|
||||
}
|
||||
|
||||
.wt-sub {
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-13);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wt-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
border-bottom: 1px solid var(--surface-2);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-13);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wt-branch {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wt-path {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wt-reason {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* Status never rides on color alone: the label itself says what blocks. */
|
||||
.wt-way[data-clear] {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.wt-way:not([data-clear]) {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.wt-row .wt-reclaim-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.wt-row .wt-reclaim-btn::before {
|
||||
content: "[";
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.wt-row .wt-reclaim-btn::after {
|
||||
content: "]";
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.wt-row .wt-reclaim-btn:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-tint);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: var(--danger-tint);
|
||||
}
|
||||
|
||||
/* The destructive confirmation, centered over the section. */
|
||||
.wt-confirm {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: oklch(0 0 0 / 0.5);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.wt-confirm[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.wt-confirm-panel {
|
||||
pointer-events: auto;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--surface-2);
|
||||
padding: var(--sp-4);
|
||||
max-width: 480px;
|
||||
font-family: var(--font-sans);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.wt-confirm-q {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.wt-confirm-way {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-13);
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.wt-confirm-note {
|
||||
margin: 0;
|
||||
font-size: var(--fs-13);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wt-confirm-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
justify-content: flex-end;
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
|
||||
expect(merged.path).toBe("a/b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("worktree sessions group under their repository (#99)", () => {
|
||||
/** A session the daemon spawned into a worktree of `repo`. */
|
||||
function worktreeSession(id: string, repo: string, wt: string): Session {
|
||||
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
|
||||
}
|
||||
|
||||
/** Top-level folder names, after linear chains are collapsed. */
|
||||
function topFolders(root: TreeFolder): string[] {
|
||||
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
|
||||
}
|
||||
|
||||
it("nests under the repo, not the worktree directory", () => {
|
||||
const root = buildTree(
|
||||
[
|
||||
worktreeSession(
|
||||
"a",
|
||||
"/home/n/tea/agent-skills",
|
||||
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
const repo = root.children[0] as TreeFolder;
|
||||
expect(repo.children).toHaveLength(1);
|
||||
expect(repo.children[0].kind).toBe("leaf");
|
||||
});
|
||||
|
||||
it("puts a repo's plain and worktree sessions side by side", () => {
|
||||
const root = buildTree(
|
||||
[
|
||||
session("plain", "/home/n/tea/agent-skills"),
|
||||
worktreeSession(
|
||||
"wt",
|
||||
"/home/n/tea/agent-skills",
|
||||
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||
),
|
||||
],
|
||||
"/home/n",
|
||||
"",
|
||||
) as TreeFolder;
|
||||
|
||||
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||
const repo = root.children[0] as TreeFolder;
|
||||
expect(repo.children).toHaveLength(2);
|
||||
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves a session without a worktree where it is", () => {
|
||||
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
||||
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||
});
|
||||
});
|
||||
|
||||
+4
-2
@@ -2,7 +2,7 @@
|
||||
// so it can be tested directly.
|
||||
|
||||
import type { Session } from "./types.js";
|
||||
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
||||
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
|
||||
|
||||
export interface TreeFolder {
|
||||
kind: "folder";
|
||||
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
||||
const lc = filter.trim().toLowerCase();
|
||||
|
||||
for (const s of sessions) {
|
||||
const cwd = sessionCwd(s);
|
||||
// Grouped by repository, so a worktree session lands under its repo
|
||||
// rather than in a folder of its own (#99).
|
||||
const cwd = sessionTreePath(s);
|
||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||
if (lc) {
|
||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||
|
||||
+100
-4
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
|
||||
export type SessionStatus = "Running" | "Dead";
|
||||
|
||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||
* Absent on payloads from daemons that predate the field. */
|
||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
|
||||
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||
* when an agent reports failure (#105); the daemon never infers it. */
|
||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
||||
|
||||
export interface Session {
|
||||
id: Uuid;
|
||||
@@ -25,12 +26,36 @@ export interface Session {
|
||||
foreground?: string | null;
|
||||
/** Inferred activity. Defaults to `Idle` on the wire when omitted. */
|
||||
activity?: ActivityState;
|
||||
/** Short human string an agent reported alongside its state (#105) — e.g.
|
||||
* why it is in `Error`. Shown on a sidebar card's meta line while the
|
||||
* report is in force; cleared when the report lapses or is replaced. */
|
||||
activity_detail?: string | null;
|
||||
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
||||
last_activity_at?: IsoTimestamp;
|
||||
/** Wall-clock of when the session entered its current `activity` state
|
||||
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
|
||||
* Defaults to the Unix epoch on payloads from daemons that predate the
|
||||
* field. */
|
||||
activity_since?: IsoTimestamp;
|
||||
/** The pane's *current* working directory, as the daemon's activity watcher
|
||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||
* Drives the directory-based session tree. Absent until first observed. */
|
||||
current_cwd?: string | null;
|
||||
/** What currently blocks an unforced kill of this session (#83), as the
|
||||
* daemon's watcher last probed it. Absent until probed and for sessions
|
||||
* without a worktree; all counts default to 0 on the wire for older
|
||||
* daemons. */
|
||||
blocked_work?: InTheWay | null;
|
||||
/** The worktree the daemon created for this session, absent when it did not
|
||||
* create one. `base_repo` is the repository's main checkout, which is what
|
||||
* the session tree groups by (#99). */
|
||||
worktree?: SessionWorktree | null;
|
||||
}
|
||||
|
||||
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||
export interface SessionWorktree {
|
||||
path: string;
|
||||
base_repo: string;
|
||||
}
|
||||
|
||||
/** One directory's immediate subdirectories (NAP-329). */
|
||||
@@ -113,7 +138,6 @@ export type Projects = Project[];
|
||||
export interface WorktreeSpec {
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
cleanup_on_kill?: boolean;
|
||||
/** When false, `branch` already exists and is checked out as-is (#61).
|
||||
* Defaults to true (create a new branch) on older daemons. */
|
||||
create_branch?: boolean;
|
||||
@@ -133,6 +157,67 @@ export interface BranchInfo {
|
||||
/** `GET /api/branches?path=` — the local branches of one repository (#55). */
|
||||
export type Branches = BranchInfo[];
|
||||
|
||||
/** Why a retained worktree outlived its session (#80). New values are
|
||||
* additive; unknown reasons render generically. */
|
||||
export type RetainedReason = "force-killed" | "exited-with-unsaved-work";
|
||||
|
||||
/** What currently blocks an unforced kill of a worktree's session (#80),
|
||||
* recomputed by the daemon on every read. All counts default to 0 on the
|
||||
* wire for older daemons. */
|
||||
export interface InTheWay {
|
||||
uncommitted_files?: number;
|
||||
untracked_files?: number;
|
||||
unpushed_commits?: number;
|
||||
}
|
||||
|
||||
/** One entry of `GET /api/worktrees` (#80): a retained worktree with its
|
||||
* in-the-way state as of this read. Reclaiming it removes the directory but
|
||||
* never the branch. Mirrors `maestro_protocol::RetainedWorktree`. */
|
||||
export interface RetainedWorktree {
|
||||
id: Uuid;
|
||||
path: string;
|
||||
base_repo: string;
|
||||
branch: string;
|
||||
reason: RetainedReason;
|
||||
retained_at: IsoTimestamp;
|
||||
in_the_way?: InTheWay;
|
||||
}
|
||||
|
||||
/** The phrasing `maestro_protocol::InTheWay`'s Display impl uses — shared by
|
||||
* the kill refusal, the TUI and this client, so no two clients can describe
|
||||
* the same tree differently. Zero parts are omitted; all-zero renders
|
||||
* "no unsaved work". */
|
||||
export function formatInTheWay(w: InTheWay): string {
|
||||
const parts: string[] = [];
|
||||
const push = (n: number | undefined, unit: string): void => {
|
||||
if (!n || n <= 0) return;
|
||||
parts.push(n === 1 ? `1 ${unit}` : `${n} ${unit}s`);
|
||||
};
|
||||
push(w.uncommitted_files, "uncommitted file");
|
||||
push(w.untracked_files, "untracked file");
|
||||
push(w.unpushed_commits, "unpushed commit");
|
||||
return parts.length > 0 ? parts.join(", ") : "no unsaved work";
|
||||
}
|
||||
|
||||
/** Same reason labels the CLI table and the TUI overlay use; unknown future
|
||||
* reasons render their raw wire value. */
|
||||
export function reasonLabel(reason: RetainedReason): string {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** #83: this session's blocked-work breakdown when something actually blocks
|
||||
* an unforced kill; `null` while clear or not yet probed. Render it with
|
||||
* {@link formatInTheWay} so every client phrases it identically. */
|
||||
export function sessionBlockers(s: Session): InTheWay | null {
|
||||
const w = s.blocked_work;
|
||||
if (!w) return null;
|
||||
const blocked =
|
||||
(w.uncommitted_files ?? 0) > 0 ||
|
||||
(w.untracked_files ?? 0) > 0 ||
|
||||
(w.unpushed_commits ?? 0) > 0;
|
||||
return blocked ? w : null;
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
cwd: string;
|
||||
command: string;
|
||||
@@ -166,12 +251,23 @@ export function sessionName(s: Session): string {
|
||||
}
|
||||
|
||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
|
||||
* observed one, else the spawn `cwd`. */
|
||||
export function sessionCwd(s: Session): string | undefined {
|
||||
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
||||
return s.cwd || undefined;
|
||||
}
|
||||
|
||||
/** Where a session sits in the directory tree. A worktree session belongs to
|
||||
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||
* session of the same name, and the repository stops being where you look for
|
||||
* the work. `base_repo` is the main checkout — the daemon resolves it. */
|
||||
export function sessionTreePath(s: Session): string | undefined {
|
||||
const repo = s.worktree?.base_repo;
|
||||
if (typeof repo === "string" && repo) return repo;
|
||||
return sessionCwd(s);
|
||||
}
|
||||
|
||||
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
||||
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
||||
export function abbreviateCwd(cwd: string, home?: string): string {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Second step of the working-directory picker (#95). See the interaction rules
|
||||
// block in spawnForm.ts for what each choice means.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle, type SpawnFormValues } from "./spawnForm.js";
|
||||
import type { Project } from "./types.js";
|
||||
|
||||
const MAESTRO: Project = {
|
||||
path: "/home/user/tea/maestro",
|
||||
name: "maestro",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-20T10:00:00Z",
|
||||
last_command: "claude",
|
||||
};
|
||||
|
||||
const WT_A: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/bulgarians",
|
||||
name: "bulgarians",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-22T10:00:00Z",
|
||||
};
|
||||
|
||||
const WT_B: Project = {
|
||||
path: "/home/user/tea/maestro/worktrees/spawn-v2",
|
||||
name: "spawn-v2",
|
||||
is_worktree: true,
|
||||
base_repo: MAESTRO.path,
|
||||
last_activity: "2026-08-21T10:00:00Z",
|
||||
};
|
||||
|
||||
const OTHER: Project = {
|
||||
path: "/home/user/tea/rev",
|
||||
name: "rev",
|
||||
is_worktree: false,
|
||||
last_activity: "2026-08-19T10:00:00Z",
|
||||
};
|
||||
|
||||
function stubFetch(projects: Project[]): string[] {
|
||||
const urls: string[] = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
const body = url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["amber-fox"] }
|
||||
: url.includes("/api/branches")
|
||||
? []
|
||||
: { hostname: "h", version: "0", home_dir: "/home/user", presets: [] };
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
);
|
||||
return urls;
|
||||
}
|
||||
|
||||
describe("spawnForm worktree step (#95)", () => {
|
||||
let handle: SpawnFormHandle | null = null;
|
||||
let urls: string[] = [];
|
||||
let changes: SpawnFormValues[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: Date.parse("2026-08-23T12:00:00Z") });
|
||||
changes = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
handle?.destroy();
|
||||
handle = null;
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const drain = async () => {
|
||||
for (let i = 0; i < 40; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
async function mount(projects: Project[]): Promise<HTMLFormElement> {
|
||||
urls = stubFetch(projects);
|
||||
handle = createSpawnForm({ onSuccess() {}, onChange: (v) => changes.push(v) });
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
return handle.el;
|
||||
}
|
||||
|
||||
const step = (form: HTMLFormElement) => form.querySelector<HTMLElement>(".wt-step")!;
|
||||
const choices = (form: HTMLFormElement) => [
|
||||
...form.querySelectorAll<HTMLButtonElement>(".wt-choice"),
|
||||
];
|
||||
const activeChoice = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLButtonElement>(".wt-choice.is-active");
|
||||
const box = (form: HTMLFormElement, path: string) =>
|
||||
form.querySelector<HTMLButtonElement>(`.project-box[data-cwd="${path}"]`)!;
|
||||
const toggle = (form: HTMLFormElement) =>
|
||||
form.querySelector<HTMLInputElement>("input[name=worktree_enabled]")!;
|
||||
|
||||
it("hides the worktree step until a project is picked", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
expect(step(form).hidden).toBe(true);
|
||||
expect(choices(form)).toEqual([]);
|
||||
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
expect(step(form).hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("lists '+ new' first and selected by default, then the project's worktrees", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
const labels = choices(form).map((c) => c.querySelector(".wt-choice-name")!.textContent);
|
||||
expect(labels).toEqual(["+ new", "bulgarians", "spawn-v2"]);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
});
|
||||
|
||||
it("taking the '+ new' default runs in the repo with the create toggle on", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
expect(form.querySelector<HTMLElement>(".wt-repo")!.textContent).toContain(MAESTRO.path);
|
||||
// The scan already knows the repo — no probe.
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
});
|
||||
|
||||
it("picking an existing worktree runs in it with the create toggle off (#91)", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, WT_B]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
|
||||
const existing = choices(form).find((c) => c.dataset.cwd === WT_A.path)!;
|
||||
existing.click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
expect(existing.classList.contains("is-active")).toBe(true);
|
||||
// Still no probe: the scan reported this worktree's repo membership.
|
||||
expect(urls.some((u) => u.includes("/api/gitroot"))).toBe(false);
|
||||
// The project box stays marked — it is what the worktree list belongs to.
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
});
|
||||
|
||||
it("switching back to '+ new' re-enables the create toggle", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
choices(form)
|
||||
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
choices(form)
|
||||
.find((c) => c.dataset.kind === "new")!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
expect(changes.at(-1)!.cwd).toBe(MAESTRO.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
expect(toggle(form).checked).toBe(true);
|
||||
});
|
||||
|
||||
it("repopulates and resets to '+ new' when another project is picked", async () => {
|
||||
const form = await mount([MAESTRO, WT_A, OTHER]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
choices(form)
|
||||
.find((c) => c.dataset.cwd === WT_A.path)!
|
||||
.click();
|
||||
await drain();
|
||||
|
||||
box(form, OTHER.path).click();
|
||||
await drain();
|
||||
|
||||
expect(choices(form).map((c) => c.dataset.kind)).toEqual(["new"]);
|
||||
expect(activeChoice(form)!.dataset.kind).toBe("new");
|
||||
expect(changes.at(-1)!.cwd).toBe(OTHER.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to the worktree itself when the box is a linked worktree (#91)", async () => {
|
||||
// No main checkout in the scan, so the orphan worktree is its own box.
|
||||
const form = await mount([WT_A]);
|
||||
box(form, WT_A.path).click();
|
||||
await drain();
|
||||
|
||||
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.cwd).toBe(WT_A.path);
|
||||
expect(changes.at(-1)!.worktreeEnabled).toBe(false);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
expect(form.querySelector<HTMLElement>(".wt-body")!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("hides the worktree step again when the operator takes the raw-input hatch", async () => {
|
||||
const form = await mount([MAESTRO, WT_A]);
|
||||
box(form, MAESTRO.path).click();
|
||||
await drain();
|
||||
expect(step(form).hidden).toBe(false);
|
||||
|
||||
form.querySelector<HTMLButtonElement>(".project-other")!.click();
|
||||
await drain();
|
||||
|
||||
expect(step(form).hidden).toBe(true);
|
||||
expect(choices(form)).toEqual([]);
|
||||
expect(form.querySelector(".project-box.is-active")).toBeNull();
|
||||
});
|
||||
|
||||
it("restores the picked worktree from a draft seed", async () => {
|
||||
urls = stubFetch([MAESTRO, WT_A, WT_B]);
|
||||
handle = createSpawnForm({
|
||||
onSuccess() {},
|
||||
seed: { cwd: WT_A.path, cwdRawVisible: false, worktreeEnabled: false },
|
||||
});
|
||||
document.body.appendChild(handle.el);
|
||||
await drain();
|
||||
const form = handle.el;
|
||||
|
||||
expect(step(form).hidden).toBe(false);
|
||||
expect(activeChoice(form)!.dataset.cwd).toBe(WT_A.path);
|
||||
expect(box(form, MAESTRO.path).classList.contains("is-active")).toBe(true);
|
||||
expect(toggle(form).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createWorktreesView } from "./worktreesView.js";
|
||||
import type { RetainedWorktree } from "./types.js";
|
||||
|
||||
function worktree(overrides: Partial<RetainedWorktree>): RetainedWorktree {
|
||||
return {
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
||||
base_repo: "/home/naps/tea/maestro",
|
||||
branch: "feat/login",
|
||||
reason: "force-killed",
|
||||
retained_at: "2026-08-24T10:00:00Z",
|
||||
in_the_way: {
|
||||
uncommitted_files: 2,
|
||||
untracked_files: 1,
|
||||
unpushed_commits: 3,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Mount a view over a scripted store and return (el, reclaimed-ids). */
|
||||
function mount(items: RetainedWorktree[], homeDir = "/home/naps") {
|
||||
const reclaimed: string[] = [];
|
||||
const view = createWorktreesView({
|
||||
load: async () => items,
|
||||
reclaim: async (id) => {
|
||||
reclaimed.push(id);
|
||||
items.splice(
|
||||
items.findIndex((w) => w.id === id),
|
||||
1,
|
||||
);
|
||||
},
|
||||
homeDir: () => homeDir,
|
||||
});
|
||||
document.body.appendChild(view.el);
|
||||
return { view, reclaimed };
|
||||
}
|
||||
|
||||
describe("retained-worktrees view (#84)", () => {
|
||||
it("lists branch, abbreviated path, reason and what is in the way", async () => {
|
||||
const { view } = mount([
|
||||
worktree({
|
||||
path: "/home/naps/tea/maestro/worktrees/feat/login",
|
||||
}),
|
||||
]);
|
||||
await view.refresh();
|
||||
const text = view.el.textContent ?? "";
|
||||
expect(text).toContain("feat/login");
|
||||
// Sidebar-style abbreviation of the path against the daemon's home dir,
|
||||
// full path kept in the tooltip.
|
||||
expect(text).toContain("t/m/w/f/login");
|
||||
const pathEl = view.el.querySelector<HTMLElement>(".wt-path")!;
|
||||
expect(pathEl.title).toBe("/home/naps/tea/maestro/worktrees/feat/login");
|
||||
// Same reason labels as `maestro worktrees list` and the TUI overlay.
|
||||
expect(text).toContain("force-killed");
|
||||
// Same phrasing as InTheWay's Display impl — shared with the kill refusal.
|
||||
expect(text).toContain("2 uncommitted files, 1 untracked file, 3 unpushed commits");
|
||||
});
|
||||
|
||||
it("says a tree whose work has since been pushed is clear", async () => {
|
||||
const { view } = mount([worktree({ in_the_way: {} })]);
|
||||
await view.refresh();
|
||||
expect(view.el.textContent).toContain("no unsaved work");
|
||||
});
|
||||
|
||||
it("renders an empty store as a normal state, not an error", async () => {
|
||||
const { view } = mount([]);
|
||||
await view.refresh();
|
||||
const text = view.el.textContent ?? "";
|
||||
expect(text).toContain("no retained worktrees");
|
||||
expect(text.toLowerCase()).not.toContain("error");
|
||||
expect(text.toLowerCase()).not.toContain("failed");
|
||||
});
|
||||
|
||||
it("guards reclaim with a confirmation that names what is lost and that the branch survives", async () => {
|
||||
const wt = worktree({ branch: "feat/login" });
|
||||
const { view, reclaimed } = mount([wt]);
|
||||
await view.refresh();
|
||||
|
||||
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
||||
const confirmText = view.el.querySelector<HTMLElement>(".wt-confirm")!.textContent ?? "";
|
||||
expect(confirmText).toContain("Reclaim worktree feat/login?");
|
||||
// Names what will be lost before it happens…
|
||||
expect(confirmText).toContain(
|
||||
"2 uncommitted files, 1 untracked file, 3 unpushed commits",
|
||||
);
|
||||
// …and says reclaim does not take the commits too.
|
||||
expect(confirmText).toContain("never deleted");
|
||||
|
||||
view.el.querySelector<HTMLButtonElement>(".wt-confirm-cancel")!.click();
|
||||
expect(reclaimed).toEqual([]);
|
||||
|
||||
view.el.querySelector<HTMLButtonElement>(".wt-reclaim-btn")!.click();
|
||||
view.el.querySelector<HTMLButtonElement>(".wt-confirm-go")!.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(reclaimed).toEqual(["11111111-2222-3333-4444-555555555555"]);
|
||||
// The row is gone once the refresh lands.
|
||||
expect(view.el.textContent).toContain("no retained worktrees");
|
||||
});
|
||||
|
||||
it("sorts oldest-retained first, like every other client", async () => {
|
||||
const { view } = mount([
|
||||
worktree({ branch: "newer", retained_at: "2026-08-24T12:00:00Z" }),
|
||||
worktree({ branch: "older", retained_at: "2026-08-24T08:00:00Z" }),
|
||||
]);
|
||||
await view.refresh();
|
||||
const branches = [...view.el.querySelectorAll(".wt-branch")].map((n) => n.textContent);
|
||||
expect(branches).toEqual(["older", "newer"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Retained-worktrees view (#84): every worktree the daemon is holding that no
|
||||
// session uses, listed from `GET /api/worktrees` with a reclaim action.
|
||||
//
|
||||
// Wording is shared with the CLI (`maestro worktrees list`) and the TUI
|
||||
// overlay: same reason labels, same in-the-way phrasing via
|
||||
// `formatInTheWay` (the mirror of `InTheWay`'s Display impl). Reclaim reads
|
||||
// as what it is — destructive — naming what will be lost before it happens
|
||||
// and saying out loud that the branch survives (docs/WORKTREES.md).
|
||||
|
||||
import { escapeHtml } from "./html.js";
|
||||
import {
|
||||
abbreviateCwd,
|
||||
abbreviateFolderName,
|
||||
formatInTheWay,
|
||||
reasonLabel,
|
||||
type RetainedWorktree,
|
||||
type Uuid,
|
||||
} from "./types.js";
|
||||
|
||||
export interface WorktreesDeps {
|
||||
/** `GET /api/worktrees`. */
|
||||
load(): Promise<RetainedWorktree[]>;
|
||||
/** `DELETE /api/worktrees/:id`. */
|
||||
reclaim(id: Uuid): Promise<void>;
|
||||
/** The daemon's home dir, for `~` abbreviation of paths. */
|
||||
homeDir(): string | undefined;
|
||||
}
|
||||
|
||||
export interface WorktreesViewHandle {
|
||||
el: HTMLElement;
|
||||
/** Re-fetch the store and re-render. Safe to call repeatedly. */
|
||||
refresh(): Promise<void>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/** Oldest-retained first, matching `maestro worktrees list`'s ordering. */
|
||||
function sorted(items: RetainedWorktree[]): RetainedWorktree[] {
|
||||
return [...items].sort(
|
||||
(a, b) => Date.parse(a.retained_at) - Date.parse(b.retained_at),
|
||||
);
|
||||
}
|
||||
|
||||
export function createWorktreesView(deps: WorktreesDeps): WorktreesViewHandle {
|
||||
const el = document.createElement("section");
|
||||
el.className = "worktrees";
|
||||
el.innerHTML = `
|
||||
<header class="wt-head">
|
||||
<h2 class="wt-title">retained worktrees</h2>
|
||||
<p class="wt-sub">
|
||||
Kept when a session ended with unsaved work. Reclaiming removes the
|
||||
directory only; branches are never deleted.
|
||||
</p>
|
||||
</header>
|
||||
<div class="wt-list"></div>
|
||||
<div class="wt-confirm" role="alertdialog" aria-modal="true" hidden></div>
|
||||
`;
|
||||
const listHost = el.querySelector<HTMLElement>(".wt-list")!;
|
||||
const confirmEl = el.querySelector<HTMLElement>(".wt-confirm")!;
|
||||
|
||||
let pendingId: string | null = null;
|
||||
|
||||
const render = (items: RetainedWorktree[]) => {
|
||||
listHost.replaceChildren();
|
||||
if (items.length === 0) {
|
||||
// A drained store is the normal state — say so plainly, not like
|
||||
// something went wrong.
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "empty";
|
||||
empty.innerHTML = `
|
||||
<div class="empty-glyph" aria-hidden="true">⌂</div>
|
||||
<div class="empty-title">no retained worktrees</div>
|
||||
<p class="empty-hint">Nothing is being held. Worktrees kept after a force-kill or an unsaved exit show up here.</p>`;
|
||||
listHost.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const home = deps.homeDir();
|
||||
for (const w of sorted(items)) {
|
||||
listHost.appendChild(renderRow(w, home));
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (w: RetainedWorktree, home: string | undefined): HTMLElement => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "wt-row";
|
||||
const clear = formatInTheWay(w.in_the_way ?? {}) === "no unsaved work";
|
||||
row.innerHTML = `
|
||||
<span class="wt-branch">${escapeHtml(w.branch)}</span>
|
||||
<span class="wt-path" title="${escapeHtml(w.path)}">${abbreviateFolderName(abbreviateCwd(w.path, home))}</span>
|
||||
<span class="wt-reason">${escapeHtml(reasonLabel(w.reason))}</span>
|
||||
<span class="wt-way"${clear ? ' data-clear="true"' : ""}>${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</span>
|
||||
<button class="btn wt-reclaim-btn" type="button">Reclaim</button>
|
||||
`;
|
||||
row.querySelector(".wt-reclaim-btn")!.addEventListener("click", () => {
|
||||
showConfirm(w);
|
||||
});
|
||||
return row;
|
||||
};
|
||||
|
||||
const showConfirm = (w: RetainedWorktree) => {
|
||||
pendingId = w.id;
|
||||
// Mirrors the TUI's ReclaimConfirm line for line: what goes away, what is
|
||||
// in the way right now, and that the branch survives reclaim.
|
||||
confirmEl.innerHTML = `
|
||||
<div class="wt-confirm-panel">
|
||||
<p class="wt-confirm-q">Reclaim worktree ${escapeHtml(w.branch)}?</p>
|
||||
<p class="wt-confirm-way">Currently in the way: ${escapeHtml(formatInTheWay(w.in_the_way ?? {}))}</p>
|
||||
<p class="wt-confirm-note">The directory is removed; the branch is never deleted.</p>
|
||||
<div class="wt-confirm-actions">
|
||||
<button class="btn btn-danger wt-confirm-go" type="button">Reclaim</button>
|
||||
<button class="btn btn-ghost wt-confirm-cancel" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
confirmEl.hidden = false;
|
||||
confirmEl.querySelector<HTMLButtonElement>(".wt-confirm-go")!.focus();
|
||||
};
|
||||
|
||||
const hideConfirm = () => {
|
||||
pendingId = null;
|
||||
confirmEl.hidden = true;
|
||||
confirmEl.replaceChildren();
|
||||
};
|
||||
|
||||
confirmEl.addEventListener("click", async (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".wt-confirm-cancel")) {
|
||||
hideConfirm();
|
||||
return;
|
||||
}
|
||||
if (!target.closest(".wt-confirm-go") || !pendingId) return;
|
||||
const id = pendingId;
|
||||
hideConfirm();
|
||||
try {
|
||||
await deps.reclaim(id);
|
||||
} catch (err) {
|
||||
alert(`Failed to reclaim: ${(err as Error).message}`);
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !confirmEl.hidden) hideConfirm();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
render(await deps.load());
|
||||
} catch (err) {
|
||||
listHost.replaceChildren();
|
||||
const fail = document.createElement("div");
|
||||
fail.className = "empty";
|
||||
fail.innerHTML = `<div class="empty-title">Couldn't load retained worktrees</div><p class="empty-hint"></p>`;
|
||||
fail.querySelector(".empty-hint")!.textContent = (err as Error).message;
|
||||
listHost.appendChild(fail);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
el,
|
||||
refresh,
|
||||
dispose() {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
el.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user