Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ddca5e7b | ||
|
|
fed0c102a5 | ||
|
|
5a47f6fbf6 | ||
|
|
e7830d15b0 | ||
|
|
0fd107be77 | ||
|
|
915e408c16 | ||
|
|
577434dc43 | ||
|
|
cc1e7b5f98 | ||
|
|
c29d6f4339 | ||
|
|
938dd62648 | ||
|
|
8269bcabcc |
@@ -84,6 +84,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
|
|||||||
config.spawn_history_path(),
|
config.spawn_history_path(),
|
||||||
config.spawn_history_days,
|
config.spawn_history_days,
|
||||||
))
|
))
|
||||||
|
// #171: mirror PTY scrollback so a resumed session opens on what it was
|
||||||
|
// showing, instead of a blank pane.
|
||||||
|
.with_scrollback(config.scrollback_dir())
|
||||||
.with_boot_id(
|
.with_boot_id(
|
||||||
config.boot_id_path(),
|
config.boot_id_path(),
|
||||||
std::sync::Arc::new(crate::boot_id::ProcBootId),
|
std::sync::Arc::new(crate::boot_id::ProcBootId),
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ impl DaemonConfig {
|
|||||||
self.state_dir.join("spawn-history.jsonl")
|
self.state_dir.join("spawn-history.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where PTY scrollback is mirrored so a resumed session opens on what
|
||||||
|
/// it printed before (#171): `<state_dir>/scrollback`.
|
||||||
|
pub fn scrollback_dir(&self) -> PathBuf {
|
||||||
|
self.state_dir.join("scrollback")
|
||||||
|
}
|
||||||
|
|
||||||
/// Where recovery records the kernel boot id it saw last (#164):
|
/// Where recovery records the kernel boot id it saw last (#164):
|
||||||
/// `<state_dir>/boot_id`.
|
/// `<state_dir>/boot_id`.
|
||||||
pub fn boot_id_path(&self) -> PathBuf {
|
pub fn boot_id_path(&self) -> PathBuf {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub mod projects;
|
|||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod release;
|
pub mod release;
|
||||||
pub mod resume;
|
pub mod resume;
|
||||||
|
pub mod scrollback;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
pub mod sidecar;
|
pub mod sidecar;
|
||||||
pub mod spawn_history;
|
pub mod spawn_history;
|
||||||
|
|||||||
@@ -21,6 +21,11 @@
|
|||||||
//! one-shot prefix before joining the live broadcast, so attaching to an
|
//! one-shot prefix before joining the live broadcast, so attaching to an
|
||||||
//! idle session immediately shows the last screenful instead of a blank
|
//! idle session immediately shows the last screenful instead of a blank
|
||||||
//! terminal.
|
//! terminal.
|
||||||
|
//! - #171: a bridge can be started with scrollback restored from disk — what
|
||||||
|
//! the session printed before the daemon (or the host) went away. Those
|
||||||
|
//! bytes are handed to subscribers ahead of the live ones until the ring
|
||||||
|
//! has a full budget of new output of its own, at which point the restored
|
||||||
|
//! history is older than anything the budget covers and is dropped.
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::ffi::CString;
|
use std::ffi::CString;
|
||||||
@@ -58,6 +63,10 @@ const READ_BUF_BYTES: usize = 4096;
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct PtyBridge {
|
pub struct PtyBridge {
|
||||||
fifo_path: PathBuf,
|
fifo_path: PathBuf,
|
||||||
|
/// Scrollback from a previous life of this session (#171), replayed
|
||||||
|
/// ahead of the ring. Empty for a fresh spawn. Never appended to, so it
|
||||||
|
/// can never duplicate a byte the ring also holds.
|
||||||
|
restored: Vec<u8>,
|
||||||
/// `None` once [`Self::seal`] has run: live subscribers see the channel
|
/// `None` once [`Self::seal`] has run: live subscribers see the channel
|
||||||
/// close, but the replay buffer stays readable.
|
/// close, but the replay buffer stays readable.
|
||||||
sender: Option<broadcast::Sender<Vec<u8>>>,
|
sender: Option<broadcast::Sender<Vec<u8>>>,
|
||||||
@@ -79,6 +88,21 @@ impl PtyBridge {
|
|||||||
/// Variant that lets the caller pick a custom replay-buffer cap (bytes).
|
/// Variant that lets the caller pick a custom replay-buffer cap (bytes).
|
||||||
/// Pass `0` to disable replay (subscribers see only live bytes).
|
/// Pass `0` to disable replay (subscribers see only live bytes).
|
||||||
pub async fn start_with_replay(fifo_path: PathBuf, replay_bytes: usize) -> io::Result<Self> {
|
pub async fn start_with_replay(fifo_path: PathBuf, replay_bytes: usize) -> io::Result<Self> {
|
||||||
|
Self::start_restoring(fifo_path, replay_bytes, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// As [`Self::start_with_replay`], seeded with `restored` scrollback read
|
||||||
|
/// back from disk (#171). The seed is truncated to the same
|
||||||
|
/// `replay_bytes` budget the ring uses — there is one budget here, not
|
||||||
|
/// two — and is dropped entirely when replay is disabled.
|
||||||
|
pub async fn start_restoring(
|
||||||
|
fifo_path: PathBuf,
|
||||||
|
replay_bytes: usize,
|
||||||
|
mut restored: Vec<u8>,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
if restored.len() > replay_bytes {
|
||||||
|
restored.drain(..restored.len() - replay_bytes);
|
||||||
|
}
|
||||||
if let Some(parent) = fifo_path.parent() {
|
if let Some(parent) = fifo_path.parent() {
|
||||||
tokio::fs::create_dir_all(parent).await?;
|
tokio::fs::create_dir_all(parent).await?;
|
||||||
}
|
}
|
||||||
@@ -102,6 +126,7 @@ impl PtyBridge {
|
|||||||
});
|
});
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
fifo_path,
|
fifo_path,
|
||||||
|
restored,
|
||||||
sender: Some(sender),
|
sender: Some(sender),
|
||||||
replay,
|
replay,
|
||||||
last_activity,
|
last_activity,
|
||||||
@@ -155,7 +180,8 @@ impl PtyBridge {
|
|||||||
F: FnOnce() -> Option<Vec<u8>>,
|
F: FnOnce() -> Option<Vec<u8>>,
|
||||||
{
|
{
|
||||||
let guard = self.replay.lock_for_snapshot();
|
let guard = self.replay.lock_for_snapshot();
|
||||||
let snapshot = capture().unwrap_or_else(|| guard.snapshot());
|
let live = capture().unwrap_or_else(|| guard.snapshot());
|
||||||
|
let snapshot = guard.with_restored_prefix(&self.restored, live);
|
||||||
let rx = match &self.sender {
|
let rx = match &self.sender {
|
||||||
Some(tx) => tx.subscribe(),
|
Some(tx) => tx.subscribe(),
|
||||||
// Sealed: hand back the scrollback plus a receiver that is already
|
// Sealed: hand back the scrollback plus a receiver that is already
|
||||||
@@ -171,6 +197,25 @@ impl PtyBridge {
|
|||||||
(snapshot, rx)
|
(snapshot, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything worth persisting for a later life of this session (#171):
|
||||||
|
/// the restored scrollback and the ring, trimmed together to the replay
|
||||||
|
/// budget so the file on disk is never bigger than the ring it mirrors.
|
||||||
|
pub fn scrollback(&self) -> Vec<u8> {
|
||||||
|
let guard = self.replay.lock_for_snapshot();
|
||||||
|
guard.with_restored_prefix(&self.restored, guard.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Total bytes this bridge has appended to its ring. The scrollback flush
|
||||||
|
/// pass compares it against what it last wrote, so an idle session costs
|
||||||
|
/// no disk writes at all.
|
||||||
|
pub fn bytes_seen(&self) -> u64 {
|
||||||
|
self.replay
|
||||||
|
.inner
|
||||||
|
.lock()
|
||||||
|
.expect("ReplayBuffer poisoned")
|
||||||
|
.seen
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop reading and close the live stream, keeping the replay buffer.
|
/// Stop reading and close the live stream, keeping the replay buffer.
|
||||||
///
|
///
|
||||||
/// Attached clients see the channel close — without that a `maestro attach`
|
/// Attached clients see the channel close — without that a `maestro attach`
|
||||||
@@ -201,6 +246,8 @@ struct ReplayBuffer {
|
|||||||
struct ReplayInner {
|
struct ReplayInner {
|
||||||
buf: VecDeque<u8>,
|
buf: VecDeque<u8>,
|
||||||
cap: usize,
|
cap: usize,
|
||||||
|
/// Bytes ever appended, not bytes held — the ring evicts, this does not.
|
||||||
|
seen: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayBuffer {
|
impl ReplayBuffer {
|
||||||
@@ -209,6 +256,7 @@ impl ReplayBuffer {
|
|||||||
inner: Arc::new(Mutex::new(ReplayInner {
|
inner: Arc::new(Mutex::new(ReplayInner {
|
||||||
buf: VecDeque::with_capacity(cap.min(READ_BUF_BYTES * 2)),
|
buf: VecDeque::with_capacity(cap.min(READ_BUF_BYTES * 2)),
|
||||||
cap,
|
cap,
|
||||||
|
seen: 0,
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,6 +273,7 @@ impl ReplayBuffer {
|
|||||||
// If the incoming chunk alone exceeds the cap, take only its tail —
|
// If the incoming chunk alone exceeds the cap, take only its tail —
|
||||||
// anything in the buffer would be evicted before this finishes anyway.
|
// anything in the buffer would be evicted before this finishes anyway.
|
||||||
let take_from = bytes.len().saturating_sub(s.cap);
|
let take_from = bytes.len().saturating_sub(s.cap);
|
||||||
|
s.seen += bytes.len() as u64;
|
||||||
let bytes = &bytes[take_from..];
|
let bytes = &bytes[take_from..];
|
||||||
s.buf.extend(bytes);
|
s.buf.extend(bytes);
|
||||||
while s.buf.len() > s.cap {
|
while s.buf.len() > s.cap {
|
||||||
@@ -247,6 +296,24 @@ struct ReplayGuard<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayGuard<'_> {
|
impl ReplayGuard<'_> {
|
||||||
|
/// `live` with the restored scrollback (#171) in front of it, while the
|
||||||
|
/// ring still holds less than a full budget of its own output. Once it
|
||||||
|
/// holds a full budget, everything restored is older than what the
|
||||||
|
/// budget covers, so it is dropped rather than kept forever.
|
||||||
|
fn with_restored_prefix(&self, restored: &[u8], live: Vec<u8>) -> Vec<u8> {
|
||||||
|
let cap = self.inner.cap;
|
||||||
|
let held = self.inner.buf.len();
|
||||||
|
if restored.is_empty() || cap == 0 || held >= cap {
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
let keep = cap - held;
|
||||||
|
let start = restored.len().saturating_sub(keep);
|
||||||
|
let mut out = Vec::with_capacity((restored.len() - start) + live.len());
|
||||||
|
out.extend_from_slice(&restored[start..]);
|
||||||
|
out.extend_from_slice(&live);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn snapshot(&self) -> Vec<u8> {
|
fn snapshot(&self) -> Vec<u8> {
|
||||||
let (a, b) = self.inner.buf.as_slices();
|
let (a, b) = self.inner.buf.as_slices();
|
||||||
let mut out = Vec::with_capacity(a.len() + b.len());
|
let mut out = Vec::with_capacity(a.len() + b.len());
|
||||||
@@ -292,6 +359,7 @@ async fn run_reader(
|
|||||||
// never both, never neither.
|
// never both, never neither.
|
||||||
let mut s = replay.inner.lock().expect("ReplayBuffer poisoned");
|
let mut s = replay.inner.lock().expect("ReplayBuffer poisoned");
|
||||||
if s.cap > 0 {
|
if s.cap > 0 {
|
||||||
|
s.seen += n as u64;
|
||||||
let take_from = n.saturating_sub(s.cap);
|
let take_from = n.saturating_sub(s.cap);
|
||||||
s.buf.extend(&buf[take_from..n]);
|
s.buf.extend(&buf[take_from..n]);
|
||||||
while s.buf.len() > s.cap {
|
while s.buf.len() > s.cap {
|
||||||
@@ -345,6 +413,80 @@ fn shell_quote(s: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
async fn bridge(cap: usize, restored: &[u8]) -> (tempfile::TempDir, PtyBridge) {
|
||||||
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let bridge =
|
||||||
|
PtyBridge::start_restoring(dir.path().join("pty.fifo"), cap, restored.to_vec())
|
||||||
|
.await
|
||||||
|
.expect("start bridge");
|
||||||
|
(dir, bridge)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed the bridge through its own FIFO and wait for the reader to have
|
||||||
|
/// taken the bytes, so the ring state is deterministic.
|
||||||
|
async fn feed(bridge: &PtyBridge, bytes: &[u8]) {
|
||||||
|
use std::io::Write;
|
||||||
|
let before = bridge.bytes_seen();
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.open(bridge.fifo_path())
|
||||||
|
.expect("open fifo for write");
|
||||||
|
f.write_all(bytes).expect("write fifo");
|
||||||
|
drop(f);
|
||||||
|
for _ in 0..400 {
|
||||||
|
if bridge.bytes_seen() >= before + bytes.len() as u64 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||||
|
}
|
||||||
|
panic!("bridge never read the bytes written to its fifo");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restored_scrollback_is_replayed_ahead_of_live_bytes() {
|
||||||
|
let (_dir, bridge) = bridge(64, b"before the reboot\n").await;
|
||||||
|
feed(&bridge, b"after\n").await;
|
||||||
|
let (replay, _rx) = bridge.subscribe_with_replay();
|
||||||
|
assert_eq!(replay, b"before the reboot\nafter\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restored_scrollback_is_trimmed_to_the_replay_budget() {
|
||||||
|
let (_dir, bridge) = bridge(4, b"abcdefghij").await;
|
||||||
|
let (replay, _rx) = bridge.subscribe_with_replay();
|
||||||
|
assert_eq!(replay, b"ghij");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn restored_scrollback_yields_once_the_ring_holds_a_full_budget() {
|
||||||
|
let (_dir, bridge) = bridge(4, b"old!").await;
|
||||||
|
feed(&bridge, b"ab").await;
|
||||||
|
let (replay, _rx) = bridge.subscribe_with_replay();
|
||||||
|
assert_eq!(replay, b"d!ab", "prefix plus ring stays inside the budget");
|
||||||
|
feed(&bridge, b"cd").await;
|
||||||
|
let (replay, _rx) = bridge.subscribe_with_replay();
|
||||||
|
assert_eq!(
|
||||||
|
replay, b"abcd",
|
||||||
|
"restored history is now older than the budget"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scrollback_carries_the_restored_prefix_and_the_ring() {
|
||||||
|
let (_dir, bridge) = bridge(8, b"old").await;
|
||||||
|
feed(&bridge, b"new").await;
|
||||||
|
assert_eq!(bridge.scrollback(), b"oldnew");
|
||||||
|
assert_eq!(bridge.bytes_seen(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn no_replay_budget_means_no_restored_scrollback() {
|
||||||
|
let (_dir, bridge) = bridge(0, b"old").await;
|
||||||
|
let (replay, _rx) = bridge.subscribe_with_replay();
|
||||||
|
assert!(replay.is_empty());
|
||||||
|
assert!(bridge.scrollback().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_buffer_keeps_last_cap_bytes() {
|
fn replay_buffer_keeps_last_cap_bytes() {
|
||||||
let r = ReplayBuffer::new(4);
|
let r = ReplayBuffer::new(4);
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Persisted PTY scrollback (#171).
|
||||||
|
//!
|
||||||
|
//! The PTY replay ring lives in memory, so everything a session printed is
|
||||||
|
//! gone the moment the daemon process is. A resumed session then opens on a
|
||||||
|
//! blank pane: the agent's conversation is back, but the terminal history the
|
||||||
|
//! operator was reading is not.
|
||||||
|
//!
|
||||||
|
//! This store mirrors each bridge's ring to `<state_dir>/scrollback/<id>.bin`,
|
||||||
|
//! and hands it back when a session gets a new bridge — on resume, and on the
|
||||||
|
//! recovery pass that re-adopts a session tmux still holds. The file is
|
||||||
|
//! capped at the same `replay_bytes` budget the in-memory ring uses, so disk
|
||||||
|
//! cost is bounded by the same number the operator already configures.
|
||||||
|
//!
|
||||||
|
//! Nothing here is allowed to fail a resume. The payload is raw PTY bytes
|
||||||
|
//! with no framing to be corrupt, and a file that is missing, unreadable or
|
||||||
|
//! oversized degrades to an empty (or truncated) pane rather than an error.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Filesystem-backed scrollback store, rooted at a directory.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScrollbackStore {
|
||||||
|
dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScrollbackStore {
|
||||||
|
pub fn new(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self { dir: dir.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dir(&self) -> &Path {
|
||||||
|
&self.dir
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn path_for(&self, id: Uuid) -> PathBuf {
|
||||||
|
self.dir.join(format!("{id}.bin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically write `bytes` to `<dir>/<id>.bin`, creating the directory if
|
||||||
|
/// needed. Written through a temp file and renamed, so a reboot in the
|
||||||
|
/// middle of a flush leaves the previous scrollback intact rather than a
|
||||||
|
/// half-file.
|
||||||
|
pub fn write(&self, id: Uuid, bytes: &[u8]) -> io::Result<()> {
|
||||||
|
std::fs::create_dir_all(&self.dir)?;
|
||||||
|
let tmp = self.dir.join(format!("{id}.bin.tmp"));
|
||||||
|
std::fs::write(&tmp, bytes)?;
|
||||||
|
std::fs::rename(&tmp, self.path_for(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored scrollback for `id`, capped to the last `budget` bytes.
|
||||||
|
///
|
||||||
|
/// Never fails: a missing file, an unreadable one, or a `budget` of zero
|
||||||
|
/// all read as "no scrollback", which is exactly the blank pane the
|
||||||
|
/// operator had before this existed.
|
||||||
|
pub fn read(&self, id: Uuid, budget: usize) -> Vec<u8> {
|
||||||
|
if budget == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let path = self.path_for(id);
|
||||||
|
let mut bytes = match std::fs::read(&path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => return Vec::new(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = ?e, %id, path = %path.display(), "scrollback: unreadable, resuming with a blank pane");
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// A file written under a larger budget than this daemon now runs
|
||||||
|
// with keeps its tail, the same end of the stream the ring would.
|
||||||
|
if bytes.len() > budget {
|
||||||
|
bytes.drain(..bytes.len() - budget);
|
||||||
|
}
|
||||||
|
bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the scrollback for `id`. No-op if it is not there.
|
||||||
|
pub fn delete(&self, id: Uuid) -> io::Result<()> {
|
||||||
|
match std::fs::remove_file(self.path_for(id)) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn store() -> (tempfile::TempDir, ScrollbackStore) {
|
||||||
|
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||||
|
let store = ScrollbackStore::new(dir.path().join("scrollback"));
|
||||||
|
(dir, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_bytes() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
store.write(id, b"hello world").expect("write");
|
||||||
|
assert_eq!(store.read(id, 64), b"hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_missing_file_reads_as_empty() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
assert!(store.read(Uuid::new_v4(), 64).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_over_budget_keeps_its_tail() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
store.write(id, b"abcdefghij").expect("write");
|
||||||
|
assert_eq!(store.read(id, 4), b"ghij");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_zero_budget_reads_as_empty() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
store.write(id, b"abcdefghij").expect("write");
|
||||||
|
assert!(store.read(id, 0).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delete_removes_the_file_and_tolerates_a_missing_one() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
store.write(id, b"gone soon").expect("write");
|
||||||
|
store.delete(id).expect("delete");
|
||||||
|
assert!(!store.path_for(id).exists());
|
||||||
|
store.delete(id).expect("second delete is a no-op");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_write_replaces_the_first() {
|
||||||
|
let (_dir, store) = store();
|
||||||
|
let id = Uuid::new_v4();
|
||||||
|
store.write(id, b"first").expect("write");
|
||||||
|
store.write(id, b"second").expect("rewrite");
|
||||||
|
assert_eq!(store.read(id, 64), b"second");
|
||||||
|
assert!(!store.dir().join(format!("{id}.bin.tmp")).exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ use uuid::Uuid;
|
|||||||
use crate::boot_id::BootIdSource;
|
use crate::boot_id::BootIdSource;
|
||||||
use crate::panes::Panes;
|
use crate::panes::Panes;
|
||||||
use crate::pty::{pipe_pane_command, PtyBridge};
|
use crate::pty::{pipe_pane_command, PtyBridge};
|
||||||
|
use crate::scrollback::ScrollbackStore;
|
||||||
use crate::sidecar::{SidecarError, SidecarStore};
|
use crate::sidecar::{SidecarError, SidecarStore};
|
||||||
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
|
||||||
use crate::tmux::TmuxError;
|
use crate::tmux::TmuxError;
|
||||||
@@ -44,6 +45,16 @@ pub const DEFAULT_LIVENESS_INTERVAL: Duration = Duration::from_secs(2);
|
|||||||
/// attempt, which is all #83 needs.
|
/// attempt, which is all #83 needs.
|
||||||
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
pub const DEFAULT_BLOCKED_WORK_INTERVAL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
|
/// How often the liveness watcher mirrors PTY scrollback to disk (#171).
|
||||||
|
///
|
||||||
|
/// A host reboot gives no warning and runs no exit path, so this interval is
|
||||||
|
/// the whole guarantee: whatever a session printed in the last few seconds
|
||||||
|
/// before the power went is what a resumed pane is missing. Ten seconds keeps
|
||||||
|
/// that loss cosmetic — scrollback is visual continuity, not state — while
|
||||||
|
/// costing at most one file write per *active* session per tick, since a
|
||||||
|
/// session whose bridge read nothing is skipped entirely.
|
||||||
|
pub const DEFAULT_SCROLLBACK_INTERVAL: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
/// Gap between the text of a `send` and the Enter that submits it.
|
/// Gap between the text of a `send` and the Enter that submits it.
|
||||||
///
|
///
|
||||||
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
|
/// Agents that run a full-screen prompt (Claude Code, opencode) coalesce a
|
||||||
@@ -97,6 +108,10 @@ pub struct SessionRecord {
|
|||||||
/// glow, this holds through focus loss until the session does new work.
|
/// glow, this holds through focus loss until the session does new work.
|
||||||
/// Daemon-local; never rides the wire.
|
/// Daemon-local; never rides the wire.
|
||||||
pub awaiting_suppressed: bool,
|
pub awaiting_suppressed: bool,
|
||||||
|
/// [`PtyBridge::bytes_seen`] as of this session's last scrollback flush
|
||||||
|
/// (#171). The flush pass skips a session whose bridge has read nothing
|
||||||
|
/// since, so an idle session costs no disk writes.
|
||||||
|
pub flushed_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -206,6 +221,10 @@ struct Inner {
|
|||||||
/// [`ResumeOnBoot::Dormant`] with no presets: register and wait.
|
/// [`ResumeOnBoot::Dormant`] with no presets: register and wait.
|
||||||
resume_policy: ResumeOnBoot,
|
resume_policy: ResumeOnBoot,
|
||||||
resume_presets: Vec<maestro_protocol::SpawnPreset>,
|
resume_presets: Vec<maestro_protocol::SpawnPreset>,
|
||||||
|
/// Where PTY scrollback is mirrored so it survives the daemon, and the
|
||||||
|
/// host (#171). `None` — the default — means scrollback is not persisted
|
||||||
|
/// at all: a resumed session opens on a blank pane, exactly as before.
|
||||||
|
scrollback: Option<ScrollbackStore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where recovery persists the boot id it last saw, and how it reads the
|
/// Where recovery persists the boot id it last saw, and how it reads the
|
||||||
@@ -266,6 +285,7 @@ impl SessionManager {
|
|||||||
boot_id: None,
|
boot_id: None,
|
||||||
resume_policy: ResumeOnBoot::default(),
|
resume_policy: ResumeOnBoot::default(),
|
||||||
resume_presets: Vec::new(),
|
resume_presets: Vec::new(),
|
||||||
|
scrollback: None,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,6 +323,21 @@ impl SessionManager {
|
|||||||
/// # Panics
|
/// # Panics
|
||||||
///
|
///
|
||||||
/// If the handle has already been cloned.
|
/// If the handle has already been cloned.
|
||||||
|
/// Persist PTY scrollback under `dir` (#171), so a resumed session comes
|
||||||
|
/// back showing what it printed before the daemon — or the host — went
|
||||||
|
/// away. Builder, called once right after construction like the other
|
||||||
|
/// `with_*` methods. Without it nothing is written, read or deleted.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If the handle has already been cloned; see [`Self::with_spawn_history`].
|
||||||
|
pub fn with_scrollback(mut self, dir: PathBuf) -> Self {
|
||||||
|
let inner = Arc::get_mut(&mut self.inner)
|
||||||
|
.expect("with_scrollback must be called before the SessionManager is cloned");
|
||||||
|
inner.scrollback = Some(ScrollbackStore::new(dir));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_boot_id(mut self, path: PathBuf, source: Arc<dyn BootIdSource>) -> Self {
|
pub fn with_boot_id(mut self, path: PathBuf, source: Arc<dyn BootIdSource>) -> Self {
|
||||||
let inner = Arc::get_mut(&mut self.inner)
|
let inner = Arc::get_mut(&mut self.inner)
|
||||||
.expect("with_boot_id must be called before the SessionManager is cloned");
|
.expect("with_boot_id must be called before the SessionManager is cloned");
|
||||||
@@ -601,6 +636,7 @@ impl SessionManager {
|
|||||||
focused_since: None,
|
focused_since: None,
|
||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
resuming: false,
|
resuming: false,
|
||||||
|
flushed_bytes: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -676,8 +712,14 @@ impl SessionManager {
|
|||||||
// Same fifo path as the original spawn: it is derived from the id, and
|
// Same fifo path as the original spawn: it is derived from the id, and
|
||||||
// `mkfifo` tolerates the stale node the previous run left behind.
|
// `mkfifo` tolerates the stale node the previous run left behind.
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
let bridge =
|
// #171: the pane is new, but the scrollback the operator was reading
|
||||||
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
|
// before the reboot is on disk. It goes in ahead of the live bytes.
|
||||||
|
let bridge = PtyBridge::start_restoring(
|
||||||
|
fifo_path.clone(),
|
||||||
|
self.inner.replay_bytes,
|
||||||
|
self.restored_scrollback(id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
let mut session_env = sanitize_env(&session.env)?;
|
let mut session_env = sanitize_env(&session.env)?;
|
||||||
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
|
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
|
||||||
if let Some(url) = self
|
if let Some(url) = self
|
||||||
@@ -756,6 +798,43 @@ impl SessionManager {
|
|||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete the scrollback of a sidecar recovery threw away (#171): the
|
||||||
|
/// session will never be listed again, so nothing would ever read it.
|
||||||
|
fn prune_scrollback(&self, id: Uuid) {
|
||||||
|
if let Some(store) = &self.inner.scrollback {
|
||||||
|
if let Err(e) = store.delete(id) {
|
||||||
|
tracing::warn!(error = ?e, %id, "recover: failed to prune scrollback");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrollback persisted for `id` by an earlier life of this session
|
||||||
|
/// (#171), capped to the replay budget. Empty when scrollback is not
|
||||||
|
/// wired, and empty — never an error — when the file is missing or
|
||||||
|
/// unreadable: a blank pane is the honest fallback, and it is what the
|
||||||
|
/// operator had before any of this existed.
|
||||||
|
fn restored_scrollback(&self, id: Uuid) -> Vec<u8> {
|
||||||
|
self.inner
|
||||||
|
.scrollback
|
||||||
|
.as_ref()
|
||||||
|
.map(|store| store.read(id, self.inner.replay_bytes))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mirror every live bridge's scrollback to disk (#171).
|
||||||
|
///
|
||||||
|
/// Called on a timer by the liveness watcher rather than only on a clean
|
||||||
|
/// exit, because the case this exists for — a host reboot — runs no exit
|
||||||
|
/// path at all: anything flushed only on shutdown is missing exactly when
|
||||||
|
/// it is needed. Sessions whose bridge has read nothing since the last
|
||||||
|
/// flush are skipped, so an idle host writes nothing.
|
||||||
|
///
|
||||||
|
/// Best-effort throughout: a failed write is warned and the session keeps
|
||||||
|
/// running.
|
||||||
|
pub async fn flush_scrollback(&self) {
|
||||||
|
run_scrollback_pass(&self.inner).await;
|
||||||
|
}
|
||||||
|
|
||||||
/// Drop the in-flight marker [`Self::resume`] set under its first lock
|
/// Drop the in-flight marker [`Self::resume`] set under its first lock
|
||||||
/// (#175), so a refused or failed resume can be retried. A no-op when the
|
/// (#175), so a refused or failed resume can be retried. A no-op when the
|
||||||
/// record is already gone: a kill that landed mid-resume took the marker
|
/// record is already gone: a kill that landed mid-resume took the marker
|
||||||
@@ -856,6 +935,10 @@ impl SessionManager {
|
|||||||
// Start "due" so the first live tick probes: a recovered daemon
|
// Start "due" so the first live tick probes: a recovered daemon
|
||||||
// should not wait a full interval to show what blocks.
|
// should not wait a full interval to show what blocks.
|
||||||
let mut since_probe = blocked_work_interval;
|
let mut since_probe = blocked_work_interval;
|
||||||
|
// Scrollback starts *not* due: a daemon that just recovered has
|
||||||
|
// nothing new to mirror, and the sessions it adopted were primed
|
||||||
|
// from the files this would overwrite (#171).
|
||||||
|
let mut since_flush = Duration::ZERO;
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
ticker.tick().await;
|
||||||
let Some(inner) = weak.upgrade() else {
|
let Some(inner) = weak.upgrade() else {
|
||||||
@@ -870,6 +953,11 @@ impl SessionManager {
|
|||||||
since_probe = Duration::ZERO;
|
since_probe = Duration::ZERO;
|
||||||
run_blocked_work_pass(&inner).await;
|
run_blocked_work_pass(&inner).await;
|
||||||
}
|
}
|
||||||
|
since_flush += interval;
|
||||||
|
if since_flush >= DEFAULT_SCROLLBACK_INTERVAL {
|
||||||
|
since_flush = Duration::ZERO;
|
||||||
|
run_scrollback_pass(&inner).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -929,8 +1017,15 @@ impl SessionManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
|
||||||
let bridge =
|
// The ring the previous daemon held died with it, so an adopted
|
||||||
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
|
// session would otherwise replay nothing until tmux's own screen
|
||||||
|
// capture — which is one screenful, not scrollback (#171).
|
||||||
|
let bridge = PtyBridge::start_restoring(
|
||||||
|
fifo_path.clone(),
|
||||||
|
self.inner.replay_bytes,
|
||||||
|
self.restored_scrollback(id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
// Re-pipe so output starts flowing again. `Tmux::pipe_pane` runs
|
// Re-pipe so output starts flowing again. `Tmux::pipe_pane` runs
|
||||||
// without tmux's `-o` flag, so any stale pipe the previous daemon
|
// without tmux's `-o` flag, so any stale pipe the previous daemon
|
||||||
// left running to a now-unread FIFO is replaced (NAP-228).
|
// left running to a now-unread FIFO is replaced (NAP-228).
|
||||||
@@ -958,6 +1053,7 @@ impl SessionManager {
|
|||||||
focused_since: None,
|
focused_since: None,
|
||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
resuming: false,
|
resuming: false,
|
||||||
|
flushed_bytes: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let _ = self
|
let _ = self
|
||||||
@@ -1049,6 +1145,7 @@ impl SessionManager {
|
|||||||
if let Err(e) = self.inner.sidecar.delete(session.id) {
|
if let Err(e) = self.inner.sidecar.delete(session.id) {
|
||||||
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar with missing cwd");
|
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar with missing cwd");
|
||||||
}
|
}
|
||||||
|
self.prune_scrollback(session.id);
|
||||||
pruned += 1;
|
pruned += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1059,6 +1156,7 @@ impl SessionManager {
|
|||||||
if let Err(e) = self.inner.sidecar.delete(session.id) {
|
if let Err(e) = self.inner.sidecar.delete(session.id) {
|
||||||
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar under resume_on_boot=never");
|
tracing::warn!(error = ?e, id = %session.id, "recover: failed to prune sidecar under resume_on_boot=never");
|
||||||
}
|
}
|
||||||
|
self.prune_scrollback(session.id);
|
||||||
discarded += 1;
|
discarded += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1085,6 +1183,7 @@ impl SessionManager {
|
|||||||
focused_since: None,
|
focused_since: None,
|
||||||
awaiting_suppressed: false,
|
awaiting_suppressed: false,
|
||||||
resuming: false,
|
resuming: false,
|
||||||
|
flushed_bytes: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
registered.push(session);
|
registered.push(session);
|
||||||
@@ -1724,6 +1823,36 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mirror every live bridge's scrollback to disk. See
|
||||||
|
/// [`SessionManager::flush_scrollback`].
|
||||||
|
async fn run_scrollback_pass(inner: &Arc<Inner>) {
|
||||||
|
let Some(store) = inner.scrollback.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Snapshot under the lock, write with it released: the writes are
|
||||||
|
// filesystem work and every attach needs this map.
|
||||||
|
let pending: Vec<(Uuid, Vec<u8>)> = {
|
||||||
|
let mut sessions = inner.sessions.lock().await;
|
||||||
|
sessions
|
||||||
|
.iter_mut()
|
||||||
|
.filter_map(|(id, rec)| {
|
||||||
|
let bridge = rec.bridge.as_ref()?;
|
||||||
|
let seen = bridge.bytes_seen();
|
||||||
|
if seen == rec.flushed_bytes {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
rec.flushed_bytes = seen;
|
||||||
|
Some((*id, bridge.scrollback()))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
for (id, bytes) in pending {
|
||||||
|
if let Err(e) = store.write(id, &bytes) {
|
||||||
|
tracing::warn!(error = ?e, %id, "scrollback: flush failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
||||||
/// and settle its worktree per the natural-exit rule (#82).
|
/// and settle its worktree per the natural-exit rule (#82).
|
||||||
///
|
///
|
||||||
@@ -1750,6 +1879,15 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
// attach.
|
// attach.
|
||||||
if let Some(bridge) = record.bridge.as_mut() {
|
if let Some(bridge) = record.bridge.as_mut() {
|
||||||
bridge.seal();
|
bridge.seal();
|
||||||
|
// The clean-exit half of #171: the last thing the session
|
||||||
|
// printed is what a later resume should open on, and it
|
||||||
|
// will produce nothing more for the flush pass to notice.
|
||||||
|
if let Some(store) = &inner.scrollback {
|
||||||
|
if let Err(e) = store.write(id, &bridge.scrollback()) {
|
||||||
|
tracing::warn!(error = ?e, %id, "scrollback: final flush failed");
|
||||||
|
}
|
||||||
|
record.flushed_bytes = bridge.bytes_seen();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let session = record.session.clone();
|
let session = record.session.clone();
|
||||||
let worktree = record
|
let worktree = record
|
||||||
@@ -1871,6 +2009,14 @@ async fn cleanup_record(
|
|||||||
if let Err(e) = inner.sidecar.delete(id) {
|
if let Err(e) = inner.sidecar.delete(id) {
|
||||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||||
}
|
}
|
||||||
|
// #171: the session is gone, so its scrollback goes with it — nothing
|
||||||
|
// will ever read the file again, and leaving it would keep a killed
|
||||||
|
// session's output on disk indefinitely.
|
||||||
|
if let Some(store) = &inner.scrollback {
|
||||||
|
if let Err(e) = store.delete(id) {
|
||||||
|
tracing::warn!(error = ?e, %id, "cleanup: failed to delete scrollback");
|
||||||
|
}
|
||||||
|
}
|
||||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||||
if keep_worktree {
|
if keep_worktree {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::process::Command;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||||
use maestro_protocol::{CreateSessionRequest, InTheWay, WorktreeSpec};
|
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
|
||||||
@@ -91,13 +91,12 @@ async fn a_session_without_a_worktree_costs_no_probe() {
|
|||||||
Duration::from_millis(100),
|
Duration::from_millis(100),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The pass demonstrably runs and probed A's tree...
|
// 30s is a hang guard only, not the property under test — that's the
|
||||||
wait_for_async(Duration::from_secs(5), || async {
|
// timing-free `contains` check below.
|
||||||
fx.manager
|
wait_for_async(Duration::from_secs(30), || async {
|
||||||
.snapshot()
|
std::fs::read_to_string(&log_path)
|
||||||
.await
|
.map(|calls| calls.contains(repo_a.path().to_str().unwrap()))
|
||||||
.iter()
|
.unwrap_or(false)
|
||||||
.any(|s| s.worktree.is_some() && s.blocked_work == Some(InTheWay::default()))
|
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -151,27 +151,40 @@ pub struct DaemonFixture {
|
|||||||
|
|
||||||
impl DaemonFixture {
|
impl DaemonFixture {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self::build(None)
|
Self::build(None, maestro_daemon::pty::DEFAULT_REPLAY_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variant whose manager runs a custom PTY replay budget (NAP-229), which
|
||||||
|
/// also caps the scrollback it persists (#171).
|
||||||
|
pub fn new_with_replay(replay_bytes: usize) -> Self {
|
||||||
|
Self::build(None, replay_bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Variant whose manager has a durable spawn history (#24) attached, with
|
/// Variant whose manager has a durable spawn history (#24) attached, with
|
||||||
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
/// `retention_days` recency cap, logging to `<state_dir>/spawn-history.jsonl`.
|
||||||
pub fn new_with_history(retention_days: u32) -> Self {
|
pub fn new_with_history(retention_days: u32) -> Self {
|
||||||
Self::build(Some(retention_days))
|
Self::build(
|
||||||
|
Some(retention_days),
|
||||||
|
maestro_daemon::pty::DEFAULT_REPLAY_BYTES,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build(history_days: Option<u32>) -> Self {
|
fn build(history_days: Option<u32>, replay_bytes: usize) -> Self {
|
||||||
let tmux = TmuxFixture::new();
|
let tmux = TmuxFixture::new();
|
||||||
let state_dir = TempDir::new().unwrap();
|
let state_dir = TempDir::new().unwrap();
|
||||||
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
let sidecar = SidecarStore::new(state_dir.path().join("sessions"));
|
||||||
// Default placement: worktrees land under their base repo (#56).
|
// Default placement: worktrees land under their base repo (#56).
|
||||||
let worktree_mgr = WorktreeManager::new(None);
|
let worktree_mgr = WorktreeManager::new(None);
|
||||||
let mut manager = SessionManager::new(
|
let mut manager = SessionManager::new_with_replay(
|
||||||
Arc::new(tmux.tmux.clone()),
|
Arc::new(tmux.tmux.clone()),
|
||||||
sidecar,
|
sidecar,
|
||||||
worktree_mgr,
|
worktree_mgr,
|
||||||
state_dir.path().join("fifos"),
|
state_dir.path().join("fifos"),
|
||||||
|
replay_bytes,
|
||||||
);
|
);
|
||||||
|
// #171: production wires this, so the fixtures do too — every test
|
||||||
|
// then exercises the same flush/restore/delete paths the daemon runs.
|
||||||
|
manager = manager.with_scrollback(state_dir.path().join("scrollback"));
|
||||||
if let Some(days) = history_days {
|
if let Some(days) = history_days {
|
||||||
manager =
|
manager =
|
||||||
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
manager.with_spawn_history(SpawnHistory::new(Self::history_path(&state_dir), days));
|
||||||
@@ -198,4 +211,8 @@ impl DaemonFixture {
|
|||||||
pub fn fifo_dir(&self) -> PathBuf {
|
pub fn fifo_dir(&self) -> PathBuf {
|
||||||
self.state_dir.path().join("fifos")
|
self.state_dir.path().join("fifos")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn scrollback_dir(&self) -> PathBuf {
|
||||||
|
self.state_dir.path().join("scrollback")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! Persisted PTY scrollback across a reboot (#171), against real tmux.
|
||||||
|
//!
|
||||||
|
//! Everything else in the reboot milestone restores what a session *is*; this
|
||||||
|
//! restores what it *looked like*. The cases that matter are the ones a clean
|
||||||
|
//! shutdown never gets to run: the host went down, and the only scrollback
|
||||||
|
//! that exists is whatever the last periodic flush wrote.
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use common::{wait_for_async, DaemonFixture};
|
||||||
|
use maestro_daemon::scrollback::ScrollbackStore;
|
||||||
|
use maestro_daemon::SessionManager;
|
||||||
|
use maestro_protocol::{CreateSessionRequest, Session, SessionStatus};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// A fresh manager over the same tmux server, sidecars, fifos and scrollback —
|
||||||
|
/// what the daemon builds after a restart.
|
||||||
|
fn revive(fx: &DaemonFixture) -> SessionManager {
|
||||||
|
SessionManager::new(
|
||||||
|
Arc::new(fx.tmux.tmux.clone()),
|
||||||
|
maestro_daemon::sidecar::SidecarStore::new(fx.sidecar_dir()),
|
||||||
|
maestro_daemon::worktree::WorktreeManager::new(None),
|
||||||
|
fx.fifo_dir(),
|
||||||
|
)
|
||||||
|
.with_scrollback(fx.scrollback_dir())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn spawn(fx: &DaemonFixture, cwd: &TempDir, command: &str) -> Session {
|
||||||
|
fx.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: cwd.path().to_path_buf(),
|
||||||
|
command: command.into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until the session has printed `needle`, flushing as we go — the same
|
||||||
|
/// thing the liveness watcher's flush pass does on its timer.
|
||||||
|
async fn wait_for_flushed(
|
||||||
|
manager: &SessionManager,
|
||||||
|
store: &ScrollbackStore,
|
||||||
|
id: Uuid,
|
||||||
|
needle: &str,
|
||||||
|
) {
|
||||||
|
wait_for_async(Duration::from_secs(10), || async {
|
||||||
|
manager.flush_scrollback().await;
|
||||||
|
String::from_utf8_lossy(&store.read(id, 64 * 1024)).contains(needle)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replay_text(replay: &[u8]) -> String {
|
||||||
|
String::from_utf8_lossy(replay).to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The keystone: the host reboots with no exit path run at all, and the
|
||||||
|
/// resumed session opens showing what it printed before.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_resumed_session_replays_the_scrollback_from_before_the_reboot() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let store = ScrollbackStore::new(fx.scrollback_dir());
|
||||||
|
|
||||||
|
let session = spawn(&fx, &cwd, "sh -c 'echo before-the-reboot; sleep 30'").await;
|
||||||
|
wait_for_flushed(&fx.manager, &store, session.id, "before-the-reboot").await;
|
||||||
|
|
||||||
|
// The reboot: tmux dies where it stands. No kill, no seal, no shutdown
|
||||||
|
// hook — only whatever the periodic flush had already written.
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
let manager = revive(&fx);
|
||||||
|
manager.recover().await.expect("recover");
|
||||||
|
assert_eq!(
|
||||||
|
manager.status(session.id).await,
|
||||||
|
Some(SessionStatus::Dormant)
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.resume(session.id, &[]).await.expect("resume");
|
||||||
|
let (replay, _rx) = manager
|
||||||
|
.subscribe_pty_with_replay(session.id)
|
||||||
|
.await
|
||||||
|
.expect("attachable again");
|
||||||
|
assert!(
|
||||||
|
replay_text(&replay).contains("before-the-reboot"),
|
||||||
|
"resumed pane should open on the old scrollback, got {:?}",
|
||||||
|
replay_text(&replay)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A daemon restart leaves tmux alone, so the session is adopted rather than
|
||||||
|
/// resumed — and it too lost its in-memory ring with the old process.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_adopted_session_replays_the_scrollback_of_the_previous_daemon() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let store = ScrollbackStore::new(fx.scrollback_dir());
|
||||||
|
|
||||||
|
let session = spawn(&fx, &cwd, "sh -c 'echo printed-earlier; sleep 30'").await;
|
||||||
|
wait_for_flushed(&fx.manager, &store, session.id, "printed-earlier").await;
|
||||||
|
|
||||||
|
// Daemon restart: the manager goes, tmux stays.
|
||||||
|
let manager = revive(&fx);
|
||||||
|
manager.recover().await.expect("recover");
|
||||||
|
assert_eq!(
|
||||||
|
manager.status(session.id).await,
|
||||||
|
Some(SessionStatus::Running)
|
||||||
|
);
|
||||||
|
|
||||||
|
let (replay, _rx) = manager
|
||||||
|
.subscribe_pty_with_replay(session.id)
|
||||||
|
.await
|
||||||
|
.expect("attachable");
|
||||||
|
assert!(
|
||||||
|
replay_text(&replay).contains("printed-earlier"),
|
||||||
|
"adopted pane should carry the old scrollback ahead of tmux's screen, got {:?}",
|
||||||
|
replay_text(&replay)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Killing a session takes its scrollback with it: nothing will ever read the
|
||||||
|
/// file again, and the operator's output should not linger on disk.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn killing_a_session_deletes_its_scrollback() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let store = ScrollbackStore::new(fx.scrollback_dir());
|
||||||
|
|
||||||
|
let session = spawn(&fx, &cwd, "sh -c 'echo doomed; sleep 30'").await;
|
||||||
|
wait_for_flushed(&fx.manager, &store, session.id, "doomed").await;
|
||||||
|
assert!(store.path_for(session.id).exists());
|
||||||
|
|
||||||
|
fx.manager.kill(session.id, false).await.expect("kill");
|
||||||
|
assert!(
|
||||||
|
!store.path_for(session.id).exists(),
|
||||||
|
"scrollback should not outlive the session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A session that exits on its own does get an exit path, and takes it: the
|
||||||
|
/// last thing it printed is flushed when the bridge is sealed, with no wait
|
||||||
|
/// for the next tick.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_session_that_exits_flushes_its_last_output() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let store = ScrollbackStore::new(fx.scrollback_dir());
|
||||||
|
|
||||||
|
let session = spawn(&fx, &cwd, "sh -c 'echo last-words'").await;
|
||||||
|
fx.manager
|
||||||
|
.start_liveness_watcher(Duration::from_millis(100));
|
||||||
|
wait_for_async(Duration::from_secs(10), || async {
|
||||||
|
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&store.read(session.id, 64 * 1024)).contains("last-words"),
|
||||||
|
"a sealed bridge should have flushed what it printed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing here may fail a resume. A scrollback file that is gone, or one full
|
||||||
|
/// of bytes no terminal would emit, degrades to a pane with no history — not
|
||||||
|
/// to an error.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_or_corrupt_scrollback_still_resumes() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let store = ScrollbackStore::new(fx.scrollback_dir());
|
||||||
|
|
||||||
|
let gone = spawn(&fx, &cwd, "sleep 30").await;
|
||||||
|
let garbled = spawn(&fx, &cwd, "sleep 30").await;
|
||||||
|
fx.manager.flush_scrollback().await;
|
||||||
|
|
||||||
|
fx.tmux.tmux.kill_server().expect("kill tmux server");
|
||||||
|
std::fs::create_dir_all(store.dir()).expect("scrollback dir");
|
||||||
|
std::fs::remove_file(store.path_for(gone.id)).ok();
|
||||||
|
std::fs::write(store.path_for(garbled.id), [0xff, 0xfe, 0x00, 0x80]).expect("write garbage");
|
||||||
|
|
||||||
|
let manager = revive(&fx);
|
||||||
|
manager.recover().await.expect("recover");
|
||||||
|
for id in [gone.id, garbled.id] {
|
||||||
|
manager.resume(id, &[]).await.expect("resume");
|
||||||
|
assert_eq!(manager.status(id).await, Some(SessionStatus::Running));
|
||||||
|
assert!(manager.subscribe_pty_with_replay(id).await.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The disk cost is the replay budget, not a second one of its own.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_flushed_file_stays_inside_the_replay_budget() {
|
||||||
|
let fx = DaemonFixture::new_with_replay(256);
|
||||||
|
let cwd = TempDir::new().unwrap();
|
||||||
|
let store = ScrollbackStore::new(fx.scrollback_dir());
|
||||||
|
|
||||||
|
let session = spawn(
|
||||||
|
&fx,
|
||||||
|
&cwd,
|
||||||
|
"sh -c 'for i in $(seq 1 400); do echo line-$i; done; sleep 30'",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
wait_for_flushed(&fx.manager, &store, session.id, "line-400").await;
|
||||||
|
|
||||||
|
let size = std::fs::metadata(store.path_for(session.id))
|
||||||
|
.expect("stat scrollback")
|
||||||
|
.len();
|
||||||
|
assert!(
|
||||||
|
size <= 256,
|
||||||
|
"scrollback file was {size} bytes, budget is 256"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,12 @@ A preview is a view, not a state. Nothing about it is durable:
|
|||||||
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
|
| Already in a pane, in another tab | That tab is brought forward. The active tab is restored when the preview ends. |
|
||||||
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
|
| Not in any pane | A throwaway tab carries it. It is removed when the preview ends. |
|
||||||
|
|
||||||
|
A session that is not `Running` — a dormant one, most often — never gets a
|
||||||
|
live attach here or anywhere else a pane is filled: the daemon has nothing to
|
||||||
|
answer a `409 not_attachable` and the socket would just die (WS close 1006).
|
||||||
|
Its pane shows the same metadata-and-resume view `docs/SIDEBAR.md` describes
|
||||||
|
for its card, honest about the fact that there is nothing to preview yet.
|
||||||
|
|
||||||
The throwaway tab never counts as an open session: the tab bar does not appear
|
The throwaway tab never counts as an open session: the tab bar does not appear
|
||||||
because of it, the mobile shell does not switch away from the session list for
|
because of it, the mobile shell does not switch away from the session list for
|
||||||
it, and the sidebar does not mark the session as open.
|
it, and the sidebar does not mark the session as open.
|
||||||
|
|||||||
@@ -108,6 +108,32 @@ dormant ghost on the next recovery. The other thing to do with a dormant
|
|||||||
session is dismiss it, which removes the sidecar so the next recovery does not
|
session is dismiss it, which removes the sidecar so the next recovery does not
|
||||||
bring it back.
|
bring it back.
|
||||||
|
|
||||||
|
## What the pane looks like when it comes back
|
||||||
|
|
||||||
|
The PTY replay ring lives in memory, so a resumed session would otherwise open
|
||||||
|
on a blank pane: the conversation is back, the terminal history the operator
|
||||||
|
was reading is not. Each bridge's ring is mirrored to
|
||||||
|
`<state_dir>/scrollback/<id>.bin` (#171) and handed to the new bridge on
|
||||||
|
resume, and on the recovery pass that re-adopts a session tmux still holds —
|
||||||
|
that one lost its ring with the previous daemon too. The restored bytes go out
|
||||||
|
ahead of the live ones, and are dropped once the ring holds a full budget of
|
||||||
|
new output of its own, which is the point where everything restored is older
|
||||||
|
than the budget covers.
|
||||||
|
|
||||||
|
The file is capped at the same `[daemon] pty_replay_bytes` budget the ring
|
||||||
|
uses — one budget, not two — and it is deleted when the session is killed, and
|
||||||
|
when recovery prunes its sidecar. Nothing here can fail a resume: a missing,
|
||||||
|
unreadable or oversized file degrades to an empty pane, which is exactly what
|
||||||
|
the operator had before it existed.
|
||||||
|
|
||||||
|
It is flushed every 10 seconds by the liveness watcher, and once more when a
|
||||||
|
session's bridge is sealed on exit. The timer is the part that matters: a host
|
||||||
|
reboot runs no exit path at all, so scrollback written only on a clean shutdown
|
||||||
|
would be missing in the one case this exists for. Ten seconds bounds the loss
|
||||||
|
to the last few lines — scrollback is visual continuity, not state — and costs
|
||||||
|
at most one file write per *active* session per tick, since a session whose
|
||||||
|
bridge has read nothing since the last flush is skipped.
|
||||||
|
|
||||||
## What recovery does with them: `resume_on_boot`
|
## What recovery does with them: `resume_on_boot`
|
||||||
|
|
||||||
Whether a recovered-but-dead session is left dormant at all is the operator's
|
Whether a recovered-but-dead session is left dormant at all is the operator's
|
||||||
|
|||||||
@@ -113,6 +113,33 @@ pane, no scrollback, and nothing to attach to. It is not an exit — nothing was
|
|||||||
observed ending — so the two fragments never both show. The TUI marks the same
|
observed ending — so the two fragments never both show. The TUI marks the same
|
||||||
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
state on line 1 with `◌`, next to `✗` for exited and `●`/`○` for running.
|
||||||
|
|
||||||
|
### Dormant cards (#168)
|
||||||
|
|
||||||
|
A dormant card is a muted variant of the normal card (the web client's
|
||||||
|
`is-dormant` class, the same muted foreground `exited` already uses): name,
|
||||||
|
cwd and last-known age render as usual, but the activity rail carries no
|
||||||
|
colour, since recovery resets a dormant session's activity to `Idle` and
|
||||||
|
there is nothing running to report otherwise.
|
||||||
|
|
||||||
|
The row's right-hand actions swap **spawn similar** for **resume** (↻) — there
|
||||||
|
is no live state to clone from — and call `POST /api/sessions/:id/resume`
|
||||||
|
(#166). **Kill** stays: it is how a dormant session is dismissed, since the
|
||||||
|
daemon already treats a kill of a dormant session as removing its sidecar
|
||||||
|
rather than anything it needs to be running to do.
|
||||||
|
|
||||||
|
A dormant session takes the **lowest precedence in the ordering**: within a
|
||||||
|
folder, every live (`Running` or `Dead`) session sorts before every dormant
|
||||||
|
one, name notwithstanding, and dormant sessions sort among themselves by name.
|
||||||
|
So a reboot that leaves a whole tree dormant never buries the live work a
|
||||||
|
session spawns into it.
|
||||||
|
|
||||||
|
Selecting a dormant session — by click, by keyboard, or by preview
|
||||||
|
(`docs/PREVIEW.md`) — never opens a live attach: the content pane shows the
|
||||||
|
same metadata and resume affordance the card does, rather than a terminal
|
||||||
|
that would die on the daemon's `409 not_attachable` (WS close 1006). Resuming
|
||||||
|
turns the session `Running`; the pane and the card pick that up on their own
|
||||||
|
next update, no navigation required.
|
||||||
|
|
||||||
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
|
||||||
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
the spelled-out `⚠ 3 uncommitted files, 2 unpushed commits`. It renders only
|
||||||
while something would block an unforced kill, and reads:
|
while something would block an unforced kill, and reads:
|
||||||
|
|||||||
@@ -91,6 +91,18 @@ export async function killSession(id: Uuid, force = false): Promise<void> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Respawn a dormant session under its own uuid and sidecar (#166/#168).
|
||||||
|
* Refused with `409` (`already_running` / `cwd_missing`) when there is
|
||||||
|
* nothing a respawn could fix. */
|
||||||
|
export async function resumeSession(id: Uuid): Promise<Session> {
|
||||||
|
return jsonOrError(
|
||||||
|
await fetch(routes.sessionResume(id), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Report whether this client's pane is the one currently showing `id` — not
|
/** Report whether this client's pane is the one currently showing `id` — not
|
||||||
* merely highlighted in a sidebar. The daemon uses this to clear an
|
* merely highlighted in a sidebar. The daemon uses this to clear an
|
||||||
* `AwaitingInput` glow once the pane has been looked at long enough
|
* `AwaitingInput` glow once the pane has been looked at long enough
|
||||||
|
|||||||
+22
-3
@@ -65,9 +65,17 @@ export interface AttachOptions {
|
|||||||
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
* dropped — but NOT on an intentional `dispose()` (eviction / user close). The
|
||||||
* shell uses this to auto-close the pane rather than leave a dead one. */
|
* shell uses this to auto-close the pane rather than leave a dead one. */
|
||||||
onClosed?: (sessionId: Uuid) => void;
|
onClosed?: (sessionId: Uuid) => void;
|
||||||
|
/** Invoked when the handshake itself never completed — no retry was
|
||||||
|
* attempted (see `shouldReconnect`'s `everOpened`). Unlike `onClosed`, this
|
||||||
|
* is not "the session is gone": it is at least as likely a dormant
|
||||||
|
* session's `409 not_attachable` caught mid-status-update (#168 review).
|
||||||
|
* The shell re-renders rather than tearing the pane's leaf out, so a
|
||||||
|
* dormant session's resume affordance can take over in place. */
|
||||||
|
onNeverAttached?: (sessionId: Uuid) => void;
|
||||||
/** Asked on every close: should the socket be retried? The shell answers
|
/** Asked on every close: should the socket be retried? The shell answers
|
||||||
* from the catalog — see `shouldRetryClose`. */
|
* from the catalog — see `shouldRetryClose`. `everOpened` is false when
|
||||||
shouldReconnect?: (sessionId: Uuid, code: number) => boolean;
|
* this attempt never reached `open`. */
|
||||||
|
shouldReconnect?: (sessionId: Uuid, code: number, everOpened: boolean) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the header shows about the session (name / command). Optional — falls
|
/** What the header shows about the session (name / command). Optional — falls
|
||||||
@@ -379,6 +387,10 @@ export function createAttachConnection(
|
|||||||
let ws: WebSocket;
|
let ws: WebSocket;
|
||||||
let reconnectMs = INITIAL_RECONNECT_MS;
|
let reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
let reconnectTimer: number | null = null;
|
let reconnectTimer: number | null = null;
|
||||||
|
// #168 review: a fresh attempt that never reaches `open` is a rejected
|
||||||
|
// handshake (dormant/unknown session), not a dropped stream — see
|
||||||
|
// `shouldRetryClose`'s `everOpened` param.
|
||||||
|
let everOpened = false;
|
||||||
|
|
||||||
const sendControl = (msg: ClientControl) => {
|
const sendControl = (msg: ClientControl) => {
|
||||||
if (ws.readyState !== WebSocket.OPEN) return;
|
if (ws.readyState !== WebSocket.OPEN) return;
|
||||||
@@ -402,6 +414,7 @@ export function createAttachConnection(
|
|||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
|
everOpened = true;
|
||||||
reconnectMs = INITIAL_RECONNECT_MS;
|
reconnectMs = INITIAL_RECONNECT_MS;
|
||||||
setState("connected", "open");
|
setState("connected", "open");
|
||||||
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
|
||||||
@@ -444,7 +457,7 @@ export function createAttachConnection(
|
|||||||
// close handle their own pane teardown.
|
// close handle their own pane teardown.
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
|
||||||
if (opts.shouldReconnect?.(sessionId, ev.code)) {
|
if (opts.shouldReconnect?.(sessionId, ev.code, everOpened)) {
|
||||||
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
|
||||||
setState("reconnecting…", "connecting");
|
setState("reconnecting…", "connecting");
|
||||||
reconnectTimer = window.setTimeout(() => {
|
reconnectTimer = window.setTimeout(() => {
|
||||||
@@ -455,6 +468,12 @@ export function createAttachConnection(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!everOpened) {
|
||||||
|
setState("not attached", "closed");
|
||||||
|
opts.onNeverAttached?.(sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(`closed (${ev.code})`, "closed");
|
setState(`closed (${ev.code})`, "closed");
|
||||||
opts.onClosed?.(sessionId);
|
opts.onClosed?.(sessionId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -427,6 +427,44 @@ describe("session cards (#107)", () => {
|
|||||||
expect(line2?.textContent).not.toContain("exited");
|
expect(line2?.textContent).not.toContain("exited");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #168: a dormant card is a muted variant with a resume action in place of
|
||||||
|
// "spawn similar", since there is nothing running to clone from live state.
|
||||||
|
it("renders a dormant card muted, with a resume action instead of spawn-similar", async () => {
|
||||||
|
const calls: string[] = [];
|
||||||
|
window.fetch = ((url: string, init?: RequestInit) => {
|
||||||
|
calls.push(`${init?.method ?? "GET"} ${url}`);
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(JSON.stringify({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Running" }), {
|
||||||
|
status: 200,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
const s = session({ id: "aaaaaaaa-0000-0000-0000-000000000120", status: "Dormant" });
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const row = rowFor(el, s.id) as HTMLElement;
|
||||||
|
expect(row.classList.contains("is-dormant")).toBe(true);
|
||||||
|
expect(row.querySelector(".leaf-clone")).toBeNull();
|
||||||
|
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
|
||||||
|
expect(resumeBtn).toBeTruthy();
|
||||||
|
resumeBtn?.click();
|
||||||
|
await flush();
|
||||||
|
expect(calls).toContain(`POST /api/sessions/${s.id}/resume`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #168: a two-way running/dead test made a dormant card lie to screen
|
||||||
|
// readers — the visible flag says dormant, the aria-label said "exited".
|
||||||
|
it("marks a dormant row's status and aria-label distinctly from dead", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-000000000119",
|
||||||
|
status: "Dormant",
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
const row = rowFor(el, s.id) as HTMLElement;
|
||||||
|
expect(row.dataset.status).toBe("dormant");
|
||||||
|
expect(row.getAttribute("aria-label")).toContain("dormant");
|
||||||
|
expect(row.getAttribute("aria-label")).not.toContain("exited");
|
||||||
|
});
|
||||||
|
|
||||||
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
it("carries the state word only in tooltips and aria labels, never as text", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
id: "aaaaaaaa-0000-0000-0000-000000000019",
|
||||||
|
|||||||
+53
-19
@@ -1,5 +1,5 @@
|
|||||||
import { escapeHtml } from "./html.js";
|
import { escapeHtml } from "./html.js";
|
||||||
import { ApiRequestError, getDaemonInfo, killSession, renameSession } from "./api.js";
|
import { ApiRequestError, getDaemonInfo, killSession, renameSession, resumeSession } from "./api.js";
|
||||||
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
import { openForceKillDialog, openKillConfirmDialog } from "./killDialog.js";
|
||||||
import { openContextMenu } from "./contextMenu.js";
|
import { openContextMenu } from "./contextMenu.js";
|
||||||
import { toggleHelp } from "./help.js";
|
import { toggleHelp } from "./help.js";
|
||||||
@@ -104,6 +104,21 @@ const STATE_WORDS: Record<string, string> = {
|
|||||||
error: "Error",
|
error: "Error",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// #168: dormancy is not an exit (docs/SIDEBAR.md), so `dataset.status` and
|
||||||
|
// the aria-label read the session's own three-way `status` rather than a
|
||||||
|
// two-way running/dead test that folded `Dormant` into "dead"/"exited".
|
||||||
|
function statusSlug(status: Session["status"]): string {
|
||||||
|
if (status === "Running") return "running";
|
||||||
|
if (status === "Dormant") return "dormant";
|
||||||
|
return "dead";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusWord(status: Session["status"]): string {
|
||||||
|
if (status === "Running") return "running";
|
||||||
|
if (status === "Dormant") return "dormant";
|
||||||
|
return "exited";
|
||||||
|
}
|
||||||
|
|
||||||
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
/** docs/SIDEBAR.md: the activity rail is pinned to the row's own left edge,
|
||||||
* full height. The row's own padding (`renderNode`) shifts outward with
|
* full height. The row's own padding (`renderNode`) shifts outward with
|
||||||
* nesting depth, carrying the rail with it. */
|
* nesting depth, carrying the rail with it. */
|
||||||
@@ -203,33 +218,46 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
row.style.paddingLeft = `${depth * 16 + 8}px`;
|
||||||
row.dataset.sessionId = s.id;
|
row.dataset.sessionId = s.id;
|
||||||
row.dataset.navKey = `session:${s.id}`;
|
row.dataset.navKey = `session:${s.id}`;
|
||||||
const running = s.status === "Running";
|
row.dataset.status = statusSlug(s.status);
|
||||||
row.dataset.status = running ? "running" : "dead";
|
// docs/SIDEBAR.md: a dormant card is a muted variant — nothing running
|
||||||
|
// behind it, so it earns no activity colour or spawn-similar affordance.
|
||||||
|
const dormant = s.status === "Dormant";
|
||||||
|
row.classList.toggle("is-dormant", dormant);
|
||||||
const name = sessionName(s);
|
const name = sessionName(s);
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||||
row.setAttribute(
|
row.setAttribute("aria-label", `${name}, ${statusWord(s.status)}, ${stateWord}`);
|
||||||
"aria-label",
|
const cloneOrResumeAction = dormant
|
||||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
? `<button class="leaf-act leaf-resume" type="button" title="Resume session" aria-label="Resume ${escapeHtml(name)}">↻</button>`
|
||||||
);
|
: `<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>`;
|
||||||
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
${activityRailMarkup(slug, stateWord)}
|
${activityRailMarkup(slug, stateWord)}
|
||||||
<span class="card-line1">
|
<span class="card-line1">
|
||||||
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
<span class="leaf-name${isProvisionalName(s) ? " is-provisional" : ""}">${escapeHtml(name)}</span>
|
||||||
<span class="leaf-actions">
|
<span class="leaf-actions">
|
||||||
<button class="leaf-act leaf-clone" type="button" title="Spawn similar" aria-label="Spawn similar to ${escapeHtml(name)}">⧉</button>
|
${cloneOrResumeAction}
|
||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
${metaLine(s, now)}
|
${metaLine(s, now)}
|
||||||
`;
|
`;
|
||||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
const similarBtn = row.querySelector<HTMLButtonElement>(".leaf-clone");
|
||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn?.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur }));
|
||||||
});
|
});
|
||||||
|
const resumeBtn = row.querySelector<HTMLButtonElement>(".leaf-resume");
|
||||||
|
resumeBtn?.addEventListener("click", (ev) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
resumeBtn.disabled = true;
|
||||||
|
void resumeSession(cur.id).catch((e: Error) => {
|
||||||
|
resumeBtn.disabled = false;
|
||||||
|
alert(`Failed to resume: ${e.message}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement;
|
||||||
killBtn.addEventListener("click", (ev) => {
|
killBtn.addEventListener("click", (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
@@ -244,11 +272,19 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
openContextMenu(ev.clientX, ev.clientY, [
|
openContextMenu(ev.clientX, ev.clientY, [
|
||||||
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
{ label: "Rename…", onSelect: () => beginRename(row, cur) },
|
||||||
{
|
cur.status === "Dormant"
|
||||||
label: "Spawn similar",
|
? {
|
||||||
onSelect: () =>
|
label: "Resume",
|
||||||
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
onSelect: () =>
|
||||||
},
|
void resumeSession(cur.id).catch((e: Error) =>
|
||||||
|
alert(`Failed to resume: ${e.message}`),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
label: "Spawn similar",
|
||||||
|
onSelect: () =>
|
||||||
|
document.dispatchEvent(new CustomEvent(SPAWN_SIMILAR_EVENT, { detail: cur })),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Kill session",
|
label: "Kill session",
|
||||||
danger: true,
|
danger: true,
|
||||||
@@ -260,10 +296,8 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
cur = next;
|
cur = next;
|
||||||
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
const nextSlug = activityColorSlug(next.activity ?? "Idle");
|
||||||
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
const nextWord = STATE_WORDS[nextSlug] ?? STATE_WORDS.idle;
|
||||||
row.setAttribute(
|
row.dataset.status = statusSlug(next.status);
|
||||||
"aria-label",
|
row.setAttribute("aria-label", `${sessionName(next)}, ${statusWord(next.status)}, ${nextWord}`);
|
||||||
`${sessionName(next)}, ${next.status === "Running" ? "running" : "exited"}, ${nextWord}`,
|
|
||||||
);
|
|
||||||
const rail = row.querySelector(".activity-rail");
|
const rail = row.querySelector(".activity-rail");
|
||||||
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
if (rail) rail.outerHTML = activityRailMarkup(nextSlug, nextWord);
|
||||||
const meta = row.querySelector(".card-line2");
|
const meta = row.querySelector(".card-line2");
|
||||||
|
|||||||
@@ -25,8 +25,13 @@ export interface PoolOptions {
|
|||||||
/** Fired when a connection's socket closes on its own (session ended/dropped),
|
/** Fired when a connection's socket closes on its own (session ended/dropped),
|
||||||
* not via an intentional close/eviction. The embedder closes the pane. */
|
* not via an intentional close/eviction. The embedder closes the pane. */
|
||||||
onClosed?: (id: Uuid) => void;
|
onClosed?: (id: Uuid) => void;
|
||||||
|
/** Fired when a connection's handshake never completed and so was never
|
||||||
|
* retried (see `AttachOptions.onNeverAttached`). Unlike `onClosed`, the
|
||||||
|
* embedder should not assume the session is gone — a dormant session's
|
||||||
|
* pane wants its resume affordance, not teardown. */
|
||||||
|
onNeverAttached?: (id: Uuid) => void;
|
||||||
/** Forwarded to each connection: should a dropped socket be retried? */
|
/** Forwarded to each connection: should a dropped socket be retried? */
|
||||||
shouldReconnect?: (id: Uuid, code: number) => boolean;
|
shouldReconnect?: (id: Uuid, code: number, everOpened: boolean) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,7 +90,9 @@ export class ConnectionPool {
|
|||||||
{
|
{
|
||||||
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
onRequestClose: (sid) => this.opts.onRequestClose?.(sid),
|
||||||
onClosed: (sid) => this.opts.onClosed?.(sid),
|
onClosed: (sid) => this.opts.onClosed?.(sid),
|
||||||
shouldReconnect: (sid, code) => this.opts.shouldReconnect?.(sid, code) ?? false,
|
onNeverAttached: (sid) => this.opts.onNeverAttached?.(sid),
|
||||||
|
shouldReconnect: (sid, code, everOpened) =>
|
||||||
|
this.opts.shouldReconnect?.(sid, code, everOpened) ?? false,
|
||||||
},
|
},
|
||||||
info,
|
info,
|
||||||
);
|
);
|
||||||
|
|||||||
+29
-1
@@ -19,6 +19,7 @@ import {
|
|||||||
getDaemonInfo,
|
getDaemonInfo,
|
||||||
gitRoot,
|
gitRoot,
|
||||||
projects as fetchProjects,
|
projects as fetchProjects,
|
||||||
|
resumeSession,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import {
|
import {
|
||||||
buildFastForwardRequest,
|
buildFastForwardRequest,
|
||||||
@@ -172,17 +173,42 @@ const pool = new ConnectionPool({
|
|||||||
// pane is never left lying around.
|
// pane is never left lying around.
|
||||||
onRequestClose: (id) => closePane(id),
|
onRequestClose: (id) => closePane(id),
|
||||||
onClosed: (id) => closePane(id),
|
onClosed: (id) => closePane(id),
|
||||||
shouldReconnect: (id, code) => shouldRetryClose(id, code, store.snapshot().sessions),
|
// #168 review: a handshake that never opened (a dormant session's `409
|
||||||
|
// not_attachable`, most likely) isn't "the session is gone" — drop the dead
|
||||||
|
// connection and re-render so the leaf gets a fresh attempt if the catalog
|
||||||
|
// still says Running, or the resume affordance if it doesn't.
|
||||||
|
onNeverAttached: (id) => {
|
||||||
|
pool.close(id);
|
||||||
|
mv?.render();
|
||||||
|
},
|
||||||
|
shouldReconnect: (id, code, everOpened) =>
|
||||||
|
shouldRetryClose(id, code, store.snapshot().sessions, everOpened),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Session death used to reach the UI on two uncoordinated channels — the row
|
// Session death used to reach the UI on two uncoordinated channels — the row
|
||||||
// from the events socket, the pane from `ws.onclose` — so either could happen
|
// from the events socket, the pane from `ws.onclose` — so either could happen
|
||||||
// without the other. The catalog is the authority: a pane for a session that is
|
// without the other. The catalog is the authority: a pane for a session that is
|
||||||
// no longer listed gets closed here.
|
// no longer listed gets closed here.
|
||||||
|
//
|
||||||
|
// A pane can also be showing the dormant placeholder (or, briefly, a dead
|
||||||
|
// socket) rather than a live attach (#168): when an *open* session's
|
||||||
|
// Running-ness flips — most commonly a resume completing — the pane and the
|
||||||
|
// placeholder need to swap without waiting on an unrelated layout change.
|
||||||
|
let lastOpenRunning = new Map<string, boolean>();
|
||||||
store.subscribe((state) => {
|
store.subscribe((state) => {
|
||||||
for (const id of mv?.openSessions() ?? []) {
|
for (const id of mv?.openSessions() ?? []) {
|
||||||
if (!state.sessions.has(id)) closePane(id);
|
if (!state.sessions.has(id)) closePane(id);
|
||||||
}
|
}
|
||||||
|
const open = mv?.openSessions() ?? new Set<string>();
|
||||||
|
const nextRunning = new Map<string, boolean>();
|
||||||
|
let runningChanged = false;
|
||||||
|
for (const id of open) {
|
||||||
|
const running = state.sessions.get(id)?.status === "Running";
|
||||||
|
nextRunning.set(id, running);
|
||||||
|
if (lastOpenRunning.get(id) !== running) runningChanged = true;
|
||||||
|
}
|
||||||
|
lastOpenRunning = nextRunning;
|
||||||
|
if (runningChanged) mv?.render();
|
||||||
});
|
});
|
||||||
|
|
||||||
function closePane(id: string): void {
|
function closePane(id: string): void {
|
||||||
@@ -275,6 +301,8 @@ mv = createMultiView({
|
|||||||
// catalog (no sessions) to attach.
|
// catalog (no sessions) to attach.
|
||||||
onOpenSessionsChange: updateShellRoute,
|
onOpenSessionsChange: updateShellRoute,
|
||||||
onLayoutChange: scheduleSave,
|
onLayoutChange: scheduleSave,
|
||||||
|
sessionFor: (id) => store.snapshot().sessions.get(id),
|
||||||
|
onResume: (id) => resumeSession(id),
|
||||||
});
|
});
|
||||||
let contentShowsMv = false;
|
let contentShowsMv = false;
|
||||||
/** Put the multi-view in the content pane, without rendering it. A preview
|
/** Put the multi-view in the content pane, without rendering it. A preview
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function fakeSidebar(): SidebarHandle {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeMv(layout: Layout): MultiView {
|
function makeMv(layout: Layout, overrides: Partial<Parameters<typeof createMultiView>[0]> = {}): MultiView {
|
||||||
return createMultiView({
|
return createMultiView({
|
||||||
pool: fakePool(),
|
pool: fakePool(),
|
||||||
layout,
|
layout,
|
||||||
@@ -53,6 +53,7 @@ function makeMv(layout: Layout): MultiView {
|
|||||||
infoFor: () => ({ name: "sess", command: "bash" }),
|
infoFor: () => ({ name: "sess", command: "bash" }),
|
||||||
onDisconnect: () => {},
|
onDisconnect: () => {},
|
||||||
renderWelcome: () => document.createElement("div"),
|
renderWelcome: () => document.createElement("div"),
|
||||||
|
...overrides,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +88,102 @@ describe("multiview empty panes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #168 review: highlighting a dormant session used to open a pane and attempt
|
||||||
|
// a live attach, which the daemon answers with 404/409 and the socket dies
|
||||||
|
// with WS 1006. A dormant leaf must never reach the pool at all — it gets the
|
||||||
|
// resume affordance instead.
|
||||||
|
describe("multiview dormant gating (#168)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function dormantSession(id: string) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
cwd: "/home/n/work",
|
||||||
|
command: "claude",
|
||||||
|
env: {},
|
||||||
|
metadata: { name: "worker" },
|
||||||
|
status: "Dormant" as const,
|
||||||
|
created_at: "2026-08-24T10:00:00Z",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("never hands a dormant session's leaf to the pool, and shows a resume affordance", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
|
||||||
|
const setVisible = vi.fn();
|
||||||
|
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
|
||||||
|
const mv = createMultiView({
|
||||||
|
pool,
|
||||||
|
layout,
|
||||||
|
sidebar: fakeSidebar(),
|
||||||
|
infoFor: () => ({ name: "worker", command: "claude" }),
|
||||||
|
onDisconnect: () => {},
|
||||||
|
renderWelcome: () => document.createElement("div"),
|
||||||
|
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
|
||||||
|
});
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(setVisible).toHaveBeenCalledWith([], expect.anything());
|
||||||
|
const pane = mv.el.querySelector('[data-leaf-id]') as HTMLElement;
|
||||||
|
expect(pane.querySelector(".mv-pane-dormant")).toBeTruthy();
|
||||||
|
expect(pane.textContent).toContain("worker");
|
||||||
|
expect(pane.textContent).toContain("/home/n/work");
|
||||||
|
expect(pane.querySelector(".mv-dormant-resume")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still attaches a Running session's leaf normally", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "r1", "row", true);
|
||||||
|
const setVisible = vi.fn();
|
||||||
|
const pool = { setVisible, setFocus() {} } as unknown as ConnectionPool;
|
||||||
|
const mv = createMultiView({
|
||||||
|
pool,
|
||||||
|
layout,
|
||||||
|
sidebar: fakeSidebar(),
|
||||||
|
infoFor: () => ({ name: "worker", command: "claude" }),
|
||||||
|
onDisconnect: () => {},
|
||||||
|
renderWelcome: () => document.createElement("div"),
|
||||||
|
sessionFor: (id) => ({ ...dormantSession(id), status: "Running" }),
|
||||||
|
});
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(setVisible).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({ id: "r1" })],
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking resume calls back with the session id", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "d1", "row", true);
|
||||||
|
const onResume = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mv = makeMv(layout, {
|
||||||
|
sessionFor: (id) => (id === "d1" ? dormantSession("d1") : undefined),
|
||||||
|
onResume,
|
||||||
|
});
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
mv.el.querySelector<HTMLButtonElement>(".mv-dormant-resume")?.click();
|
||||||
|
expect(onResume).toHaveBeenCalledWith("d1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("multiview pane-drag swap", () => {
|
describe("multiview pane-drag swap", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
stubFetch();
|
stubFetch();
|
||||||
|
|||||||
+61
-2
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
import type { SidebarHandle } from "./catalog.js";
|
import type { SidebarHandle } from "./catalog.js";
|
||||||
import type { ConnectionPool } from "./connections.js";
|
import type { ConnectionPool } from "./connections.js";
|
||||||
|
import { activityDurationLabel, activityHeldMs } from "./activity.js";
|
||||||
|
import { escapeHtml } from "./html.js";
|
||||||
|
import { sessionName } from "./types.js";
|
||||||
|
import type { Session } from "./types.js";
|
||||||
import {
|
import {
|
||||||
activeTab,
|
activeTab,
|
||||||
addTab,
|
addTab,
|
||||||
@@ -34,6 +38,14 @@ export interface MultiViewOptions {
|
|||||||
onDisconnect: (sessionId: string) => void;
|
onDisconnect: (sessionId: string) => void;
|
||||||
/** Welcome content for an empty tab. */
|
/** Welcome content for an empty tab. */
|
||||||
renderWelcome: () => HTMLElement;
|
renderWelcome: () => HTMLElement;
|
||||||
|
/** The session behind a leaf, when known — used to gate a live attach on
|
||||||
|
* `status === "Running"` (#168 review) and to fill in the dormant pane's
|
||||||
|
* resume affordance. A leaf whose session this returns `undefined` for
|
||||||
|
* (unknown to the caller) attaches as before, so callers that don't wire
|
||||||
|
* this up see no change in behaviour. */
|
||||||
|
sessionFor?: (sessionId: string) => Session | undefined;
|
||||||
|
/** Resume a dormant session (#166/#168), from its pane's own affordance. */
|
||||||
|
onResume?: (sessionId: string) => Promise<unknown>;
|
||||||
/** Called when a spawn fills a pane with a new session, so the shell can
|
/** Called when a spawn fills a pane with a new session, so the shell can
|
||||||
* re-evaluate its route (sidebar vs attach layout). */
|
* re-evaluate its route (sidebar vs attach layout). */
|
||||||
onOpenSessionsChange?: () => void;
|
onOpenSessionsChange?: () => void;
|
||||||
@@ -262,6 +274,44 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- dormant panes (#168 review) ----
|
||||||
|
|
||||||
|
/** The session behind `sessionId` when it is known and not `Running` —
|
||||||
|
* what gates a leaf away from a live attach and towards the resume
|
||||||
|
* affordance instead. */
|
||||||
|
function dormantSessionFor(sessionId: string): Session | undefined {
|
||||||
|
const s = opts.sessionFor?.(sessionId);
|
||||||
|
return s && s.status !== "Running" ? s : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** docs/PREVIEW.md / docs/SIDEBAR.md: what the content pane can honestly
|
||||||
|
* show for a session with no pane behind it — its metadata and a resume
|
||||||
|
* affordance, never a live terminal that would just die with WS 1006. */
|
||||||
|
function renderDormantPane(host: HTMLElement, session: Session): void {
|
||||||
|
const heldMs = activityHeldMs(session.last_activity_at ?? session.created_at, Date.now());
|
||||||
|
const age = heldMs !== null ? activityDurationLabel(heldMs) : null;
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "mv-pane-dormant";
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="mv-dormant-glyph" aria-hidden="true">◌</div>
|
||||||
|
<div class="mv-dormant-name">${escapeHtml(sessionName(session))}</div>
|
||||||
|
<div class="mv-dormant-cwd" title="${escapeHtml(session.cwd)}">${escapeHtml(session.cwd)}</div>
|
||||||
|
<div class="mv-dormant-detail">dormant${age ? ` for ${escapeHtml(age)}` : ""} — nothing running behind it</div>
|
||||||
|
<button type="button" class="btn btn-primary mv-dormant-resume">Resume</button>
|
||||||
|
`;
|
||||||
|
const btn = wrap.querySelector(".mv-dormant-resume") as HTMLButtonElement;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Resuming…";
|
||||||
|
opts.onResume?.(session.id)?.catch((e: Error) => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Resume";
|
||||||
|
alert(`Failed to resume: ${e.message}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
host.replaceChildren(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- pane + split DOM ----
|
// ---- pane + split DOM ----
|
||||||
|
|
||||||
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
|
function buildPane(leaf: Extract<Node, { kind: "leaf" }>): {
|
||||||
@@ -378,7 +428,12 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
if (node.kind === "leaf") {
|
if (node.kind === "leaf") {
|
||||||
const pane = buildPane(node);
|
const pane = buildPane(node);
|
||||||
if (node.sessionId) {
|
if (node.sessionId) {
|
||||||
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
const dormant = dormantSessionFor(node.sessionId);
|
||||||
|
if (dormant) {
|
||||||
|
renderDormantPane(pane.host, dormant);
|
||||||
|
} else {
|
||||||
|
hosts.push({ id: node.sessionId, host: pane.host, info: infoFor(node.sessionId) });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return pane.el;
|
return pane.el;
|
||||||
}
|
}
|
||||||
@@ -616,9 +671,13 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
wrap.appendChild(pane.el);
|
wrap.appendChild(pane.el);
|
||||||
canvas.appendChild(wrap);
|
canvas.appendChild(wrap);
|
||||||
|
|
||||||
|
const dormant = focused.sessionId ? dormantSessionFor(focused.sessionId) : undefined;
|
||||||
|
if (dormant) renderDormantPane(pane.host, dormant);
|
||||||
// Only the focused leaf's session is live on mobile; the rest park.
|
// Only the focused leaf's session is live on mobile; the rest park.
|
||||||
pool.setVisible(
|
pool.setVisible(
|
||||||
focused.sessionId ? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }] : [],
|
focused.sessionId && !dormant
|
||||||
|
? [{ id: focused.sessionId, pane: pane.host, info: infoFor(focused.sessionId) }]
|
||||||
|
: [],
|
||||||
{ focus: !preview && !quietRender },
|
{ focus: !preview && !quietRender },
|
||||||
);
|
);
|
||||||
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
if (!preview) pool.setFocus(focused.sessionId ?? null);
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ describe("shouldRetryClose", () => {
|
|||||||
it("never retries a clean close", () => {
|
it("never retries a clean close", () => {
|
||||||
expect(shouldRetryClose("a", 1000, running)).toBe(false);
|
expect(shouldRetryClose("a", 1000, running)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #168 review: a handshake that never opened is a rejection (e.g. a
|
||||||
|
// dormant session's 409 not_attachable), not a dropped stream. Retrying it
|
||||||
|
// loops forever if the catalog status hasn't caught up yet.
|
||||||
|
it("never retries a socket that never opened, even if the catalog still says running", () => {
|
||||||
|
expect(shouldRetryClose("a", 1006, running, false)).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("backoff bounds", () => {
|
describe("backoff bounds", () => {
|
||||||
|
|||||||
@@ -26,7 +26,15 @@ export function shouldRetryClose(
|
|||||||
sessionId: Uuid,
|
sessionId: Uuid,
|
||||||
code: number,
|
code: number,
|
||||||
sessions: ReadonlyMap<Uuid, Session>,
|
sessions: ReadonlyMap<Uuid, Session>,
|
||||||
|
/** Did this socket ever reach `open`? A handshake that never completed is
|
||||||
|
* the daemon rejecting the attach (dormant/unknown) rather than a dropped
|
||||||
|
* stream — retrying it spins in place until the catalog updates, which for
|
||||||
|
* a dormant session it never will (#168 review). Defaults to `true` so
|
||||||
|
* existing callers that only track drops of an established link are
|
||||||
|
* unaffected. */
|
||||||
|
everOpened = true,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (code >= APPLICATION_CLOSE || code === CLEAN_CLOSE) return false;
|
if (code >= APPLICATION_CLOSE || code === CLEAN_CLOSE) return false;
|
||||||
|
if (!everOpened) return false;
|
||||||
return sessions.get(sessionId)?.status === "Running";
|
return sessions.get(sessionId)?.status === "Running";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export function sessionFocus(id: Uuid): string {
|
|||||||
return `${session(id)}/focus`;
|
return `${session(id)}/focus`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `POST /api/sessions/:id/resume` (#166/#168): respawn a dormant session
|
||||||
|
* under its own uuid and sidecar. */
|
||||||
|
export function sessionResume(id: Uuid): string {
|
||||||
|
return `${session(id)}/resume`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
/** Append the `path` query parameter `DIRS` and `GITROOT` accept. */
|
||||||
export function withPathQuery(route: string, value: string): string {
|
export function withPathQuery(route: string, value: string): string {
|
||||||
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
return `${route}?${PATH_QUERY}=${encodeURIComponent(value)}`;
|
||||||
|
|||||||
+45
-4
@@ -1210,12 +1210,16 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
/* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's
|
||||||
never the running tint's absence alone doing the work. */
|
never the running tint's absence alone doing the work. A dormant session
|
||||||
.tree-leaf[data-status="dead"] {
|
(#168, docs/SIDEBAR.md) reads the same muted way — it is known, not gone,
|
||||||
|
but there is nothing running behind it either. */
|
||||||
|
.tree-leaf[data-status="dead"],
|
||||||
|
.tree-leaf.is-dormant {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-leaf[data-status="dead"] .leaf-name {
|
.tree-leaf[data-status="dead"] .leaf-name,
|
||||||
|
.tree-leaf.is-dormant .leaf-name {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
@@ -1754,7 +1758,8 @@ input.leaf-rename:focus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.leaf-clone:hover {
|
.leaf-clone:hover,
|
||||||
|
.leaf-resume:hover {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-tint);
|
background: var(--accent-tint);
|
||||||
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
border-color: color-mix(in oklab, var(--accent) 40%, transparent);
|
||||||
@@ -3265,6 +3270,42 @@ input.leaf-rename:focus {
|
|||||||
font-size: var(--fs-13);
|
font-size: var(--fs-13);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #168: what a pane shows for a dormant session — its metadata and a resume
|
||||||
|
action, in place of the live terminal there is nothing to attach to. */
|
||||||
|
.mv-pane-dormant {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.mv-dormant-glyph {
|
||||||
|
font-size: 28px;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
.mv-dormant-name {
|
||||||
|
font-size: var(--fs-15);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.mv-dormant-cwd {
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mv-dormant-detail {
|
||||||
|
font-size: var(--fs-12);
|
||||||
|
}
|
||||||
|
.mv-dormant-resume {
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
/* pane tools cluster (disconnect / split / close), top-right corner. The attach
|
||||||
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
|
header's own disconnect button is hidden — ⏻ lives here as the leftmost tool. */
|
||||||
.mv-pane-tools {
|
.mv-pane-tools {
|
||||||
|
|||||||
+37
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { buildTree, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
import { buildTree, compareNodes, mergeLinear, type TreeFolder, type TreeNode } from "./tree.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
|
|
||||||
function session(id: string, cwd: string, command = "bash"): Session {
|
function session(id: string, cwd: string, command = "bash"): Session {
|
||||||
@@ -67,6 +67,42 @@ describe("buildTree", () => {
|
|||||||
expect(root.children[0].kind).toBe("folder");
|
expect(root.children[0].kind).toBe("folder");
|
||||||
expect(root.children[1].kind).toBe("leaf");
|
expect(root.children[1].kind).toBe("leaf");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #168, docs/SIDEBAR.md: a dormant session takes the lowest precedence in
|
||||||
|
// the ordering, so live work always sorts above it, even a name that would
|
||||||
|
// otherwise sort first.
|
||||||
|
it("sorts a dormant session after live ones in the same folder, name notwithstanding", () => {
|
||||||
|
const dormant = { ...session("aaa-dormant", "/home/n/work"), status: "Dormant" } as Session;
|
||||||
|
const live = session("zzz-live", "/home/n/work");
|
||||||
|
const root = buildTree([dormant, live], "/home/n") as TreeFolder;
|
||||||
|
const folder = root.children[0] as TreeFolder;
|
||||||
|
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
|
||||||
|
expect(ids).toEqual(["zzz-live", "aaa-dormant"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sorts dormant sessions by name against each other", () => {
|
||||||
|
const a = {
|
||||||
|
...session("a", "/home/n/work"),
|
||||||
|
status: "Dormant",
|
||||||
|
metadata: { name: "a" },
|
||||||
|
} as Session;
|
||||||
|
const b = {
|
||||||
|
...session("b", "/home/n/work"),
|
||||||
|
status: "Dormant",
|
||||||
|
metadata: { name: "b" },
|
||||||
|
} as Session;
|
||||||
|
const root = buildTree([b, a], "/home/n") as TreeFolder;
|
||||||
|
const folder = root.children[0] as TreeFolder;
|
||||||
|
const ids = folder.children.map((c) => (c.kind === "leaf" ? c.session.id : c.name));
|
||||||
|
expect(ids).toEqual(["a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the leaf/name precedence directly via compareNodes", () => {
|
||||||
|
const dormant: TreeNode = { kind: "leaf", session: { ...session("a", "/x"), status: "Dormant" } };
|
||||||
|
const live: TreeNode = { kind: "leaf", session: session("b", "/x") };
|
||||||
|
expect(compareNodes(dormant, live)).toBeGreaterThan(0);
|
||||||
|
expect(compareNodes(live, dormant)).toBeLessThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("mergeLinear", () => {
|
describe("mergeLinear", () => {
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ export interface TreeLeaf {
|
|||||||
|
|
||||||
export type TreeNode = TreeFolder | TreeLeaf;
|
export type TreeNode = TreeFolder | TreeLeaf;
|
||||||
|
|
||||||
|
/** docs/SIDEBAR.md: a dormant session takes the lowest precedence in the
|
||||||
|
* ordering, so live work always sorts above it regardless of name. */
|
||||||
|
function dormantRank(node: TreeNode): number {
|
||||||
|
return node.kind === "leaf" && node.session.status === "Dormant" ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
export function compareNodes(a: TreeNode, b: TreeNode): number {
|
export function compareNodes(a: TreeNode, b: TreeNode): number {
|
||||||
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
||||||
|
const rank = dormantRank(a) - dormantRank(b);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
const an = a.kind === "folder" ? a.name : sessionName(a.session);
|
const an = a.kind === "folder" ? a.name : sessionName(a.session);
|
||||||
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
|
const bn = b.kind === "folder" ? b.name : sessionName(b.session);
|
||||||
return an.localeCompare(bn);
|
return an.localeCompare(bn);
|
||||||
|
|||||||
Reference in New Issue
Block a user