merge: tui-pod-restore-picker
This commit is contained in:
commit
828004a5e2
|
|
@ -56,19 +56,15 @@ fn resolve_socket(pod_name: &str, override_path: Option<PathBuf>) -> PathBuf {
|
|||
#[derive(Debug)]
|
||||
enum Mode {
|
||||
Spawn,
|
||||
Attach {
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
/// `tui --pod <name>`: attach to a live Pod by name if possible;
|
||||
/// otherwise launch `pod --pod <name>` so the pod process resumes from
|
||||
/// name-keyed state or creates a fresh same-name Pod.
|
||||
/// `tui <name>` / `tui --pod <name>`: attach to a live Pod by name if
|
||||
/// possible; otherwise launch `pod --pod <name>` so the pod process
|
||||
/// resumes from name-keyed state or creates a fresh same-name Pod.
|
||||
PodName {
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
/// `tui -r` / `tui --resume`: open the session picker first, then
|
||||
/// run the same name dialog as Spawn but in resume mode.
|
||||
/// `tui -r` / `tui --resume`: open the Pod picker, then attach to the
|
||||
/// selected live Pod or restore the selected stopped Pod by name.
|
||||
Resume,
|
||||
/// `tui --session <UUID>`: skip the picker, go straight to the
|
||||
/// resume name dialog with `id` baked in.
|
||||
|
|
@ -178,7 +174,7 @@ where
|
|||
return Ok(Mode::Resume);
|
||||
}
|
||||
if let Some(pod_name) = positional {
|
||||
return Ok(Mode::Attach {
|
||||
return Ok(Mode::PodName {
|
||||
pod_name,
|
||||
socket_override,
|
||||
});
|
||||
|
|
@ -208,10 +204,6 @@ async fn main() -> ExitCode {
|
|||
|
||||
let result = match mode {
|
||||
Mode::Spawn => run_spawn(None).await,
|
||||
Mode::Attach {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => run_attach(pod_name, socket_override).await,
|
||||
Mode::PodName {
|
||||
pod_name,
|
||||
socket_override,
|
||||
|
|
@ -239,8 +231,8 @@ async fn main() -> ExitCode {
|
|||
// SpawnError has already been painted into the inline
|
||||
// viewport's final frame, so it's already visible in the
|
||||
// user's scrollback — printing it again would be a noisy
|
||||
// duplicate. Other errors (attach-mode failures, terminal
|
||||
// setup hiccups, etc.) need surfacing here.
|
||||
// duplicate. Other errors (pod-name failures, terminal setup
|
||||
// hiccups, etc.) need surfacing here.
|
||||
if e.downcast_ref::<spawn::SpawnError>().is_none() {
|
||||
eprintln!("tui: {e}");
|
||||
}
|
||||
|
|
@ -249,21 +241,14 @@ async fn main() -> ExitCode {
|
|||
}
|
||||
}
|
||||
|
||||
async fn run_attach(
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let socket_path = resolve_socket(&pod_name, socket_override);
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
run(&mut terminal, pod_name, &socket_path).await
|
||||
}
|
||||
|
||||
async fn run_pod_name(
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let socket_path = resolve_socket(&pod_name, socket_override);
|
||||
if let Ok(client) = PodClient::connect(&socket_path).await {
|
||||
let preferred_socket = resolve_socket(&pod_name, socket_override.clone());
|
||||
if let Some((_socket_path, client)) =
|
||||
connect_live_pod(&pod_name, preferred_socket, socket_override.is_none()).await
|
||||
{
|
||||
let mut terminal = enter_fullscreen()?;
|
||||
let mut app = App::new(pod_name);
|
||||
app.connected = true;
|
||||
|
|
@ -289,15 +274,39 @@ async fn run_pod_name(
|
|||
result
|
||||
}
|
||||
|
||||
async fn connect_live_pod(
|
||||
pod_name: &str,
|
||||
preferred_socket: PathBuf,
|
||||
allow_registry_fallback: bool,
|
||||
) -> Option<(PathBuf, PodClient)> {
|
||||
if let Ok(client) = PodClient::connect(&preferred_socket).await {
|
||||
return Some((preferred_socket, client));
|
||||
}
|
||||
|
||||
if !allow_registry_fallback {
|
||||
return None;
|
||||
}
|
||||
let registry_socket = picker::live_socket_for_pod(pod_name)?;
|
||||
if registry_socket == preferred_socket {
|
||||
return None;
|
||||
}
|
||||
PodClient::connect(®istry_socket)
|
||||
.await
|
||||
.ok()
|
||||
.map(|client| (registry_socket, client))
|
||||
}
|
||||
|
||||
async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Phase 1: pick a session in its own inline viewport, dropping the
|
||||
// viewport before the name dialog opens so each phase gets fresh
|
||||
// vertical room.
|
||||
let segment_id = match picker::run().await? {
|
||||
PickerOutcome::Picked { segment_id } => segment_id,
|
||||
// Pick a Pod in its own inline viewport, dropping the viewport before
|
||||
// attaching/restoring so each phase gets fresh vertical room.
|
||||
let (pod_name, socket_override) = match picker::run().await? {
|
||||
PickerOutcome::Picked {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => (pod_name, socket_override),
|
||||
PickerOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
run_spawn(Some(segment_id)).await
|
||||
run_pod_name(pod_name, socket_override).await
|
||||
}
|
||||
|
||||
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
@ -813,6 +822,20 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_positional_name_uses_pod_name_mode() {
|
||||
match parse_args_from(["agent"]).unwrap() {
|
||||
Mode::PodName {
|
||||
pod_name,
|
||||
socket_override,
|
||||
} => {
|
||||
assert_eq!(pod_name, "agent");
|
||||
assert_eq!(socket_override, None);
|
||||
}
|
||||
_ => panic!("expected PodName mode"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_pod_and_session() {
|
||||
let segment_id = session_store::new_segment_id().to_string();
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
//! Inline-viewport "pick a session to restore" UX.
|
||||
//! Inline-viewport "pick a Pod to attach or restore" UX.
|
||||
//!
|
||||
//! Reads the most recently updated sessions from the configured store,
|
||||
//! lets the user pick one with the arrow keys, and returns the chosen
|
||||
//! `SegmentId`. Closes its inline viewport before returning so the
|
||||
//! caller can open a fresh viewport for the name dialog.
|
||||
//!
|
||||
//! The picker only handles selection. Forking, pod-registry checks, and
|
||||
//! actual `pod` launch happen later in the resume flow.
|
||||
//! Reads live Pod allocations from the runtime registry and stopped Pod state
|
||||
//! from the session store's name-keyed metadata. Picking a live row attaches to
|
||||
//! its socket; picking a stopped row restores via `pod --pod <name>`.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use client::PodClient;
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use pod_registry::lookup_segment;
|
||||
use pod_registry::{LockFileGuard, default_registry_path};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
|
|
@ -21,7 +21,7 @@ use ratatui::text::{Line, Span};
|
|||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, TerminalOptions, Viewport};
|
||||
use session_store::{
|
||||
FsStore, LogEntry, LoggedContentPart, LoggedItem, SegmentId, SessionId, Store,
|
||||
FsStore, LogEntry, LoggedContentPart, LoggedItem, PodMetadata, SegmentId, SessionId, Store,
|
||||
};
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
|
|
@ -31,7 +31,7 @@ const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
|||
pub enum PickerError {
|
||||
Io(io::Error),
|
||||
Store(session_store::StoreError),
|
||||
NoSessions,
|
||||
NoPods,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PickerError {
|
||||
|
|
@ -39,9 +39,9 @@ impl std::fmt::Display for PickerError {
|
|||
match self {
|
||||
Self::Io(e) => write!(f, "io error: {e}"),
|
||||
Self::Store(e) => write!(f, "session store error: {e}"),
|
||||
Self::NoSessions => write!(
|
||||
Self::NoPods => write!(
|
||||
f,
|
||||
"no sessions found — start a fresh pod with `tui` and try again"
|
||||
"no pods found — start a fresh pod with `tui` and try again"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -62,43 +62,77 @@ impl From<session_store::StoreError> for PickerError {
|
|||
}
|
||||
|
||||
pub enum PickerOutcome {
|
||||
/// User picked a session; resume at the segment represented by the
|
||||
/// selected row. The pod-cli rehydrates `session_id` via
|
||||
/// `Store::lookup_session_of` so we only need to surface the segment
|
||||
/// here.
|
||||
/// User picked a Pod. `socket_override` is set for live rows when the
|
||||
/// runtime registry knows the exact socket path; stopped rows leave it
|
||||
/// empty so the caller restores with `pod --pod <name>`.
|
||||
Picked {
|
||||
segment_id: SegmentId,
|
||||
pod_name: String,
|
||||
socket_override: Option<PathBuf>,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// One row in the picker view. Rendered from the most recently updated
|
||||
/// segment of a Session so the user can recognise their conversation at a
|
||||
/// glance without parsing UUIDs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PodRowState {
|
||||
Live,
|
||||
Stopped,
|
||||
Corrupt,
|
||||
}
|
||||
|
||||
impl PodRowState {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Live => "live",
|
||||
Self::Stopped => "stopped",
|
||||
Self::Corrupt => "corrupt",
|
||||
}
|
||||
}
|
||||
|
||||
fn style(self) -> Style {
|
||||
match self {
|
||||
Self::Live => Style::default()
|
||||
.fg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Self::Stopped => Style::default().fg(Color::Yellow),
|
||||
Self::Corrupt => Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One row in the Pod picker. The primary key is the Pod name; Session/Segment
|
||||
/// IDs are included only as debug context.
|
||||
#[derive(Debug, Clone)]
|
||||
struct Row {
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
/// Latest log-entry timestamp in the row's selected segment. Used only
|
||||
/// to order the picker newest-update first.
|
||||
pod_name: String,
|
||||
state: PodRowState,
|
||||
updated_at: u64,
|
||||
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
|
||||
preview: String,
|
||||
/// `Some(pod_name)` when a live Pod currently holds an allocation
|
||||
/// for this row's segment in `pods.json`. Picking such a row launches
|
||||
/// `pod --session <UUID>` which will fail with `SegmentConflict` — the
|
||||
/// badge warns the user up-front.
|
||||
live_pod: Option<String>,
|
||||
active_session_id: Option<SessionId>,
|
||||
active_segment_id: Option<SegmentId>,
|
||||
preview: Option<String>,
|
||||
socket_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PodStateRecord {
|
||||
pod_name: String,
|
||||
state: Result<PodMetadata, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LivePodRecord {
|
||||
pub pod_name: String,
|
||||
pub socket_path: PathBuf,
|
||||
pub segment_id: Option<SegmentId>,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||
let store = open_default_store()?;
|
||||
let sessions = store.list_sessions()?;
|
||||
if sessions.is_empty() {
|
||||
return Err(PickerError::NoSessions);
|
||||
}
|
||||
let rows = build_rows(&store, sessions)?;
|
||||
let store_dir = default_store_dir()?;
|
||||
let store = FsStore::new(&store_dir)?;
|
||||
let pod_states = read_pod_state_records(&store_dir)?;
|
||||
let live_pods = read_reachable_live_pod_records().await.unwrap_or_default();
|
||||
let rows = build_rows(&store, pod_states, live_pods)?;
|
||||
if rows.is_empty() {
|
||||
return Err(PickerError::NoSessions);
|
||||
return Err(PickerError::NoPods);
|
||||
}
|
||||
|
||||
let mut selected = 0usize;
|
||||
|
|
@ -108,9 +142,7 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
|||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => {
|
||||
if selected > 0 {
|
||||
selected -= 1;
|
||||
}
|
||||
selected = selected.saturating_sub(1);
|
||||
}
|
||||
Some(Action::Down) => {
|
||||
if selected + 1 < rows.len() {
|
||||
|
|
@ -119,8 +151,10 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
|||
}
|
||||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
let row = &rows[selected];
|
||||
return Ok(PickerOutcome::Picked {
|
||||
segment_id: rows[selected].segment_id,
|
||||
pod_name: row.pod_name.clone(),
|
||||
socket_override: row.socket_path.clone(),
|
||||
});
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
|
|
@ -131,17 +165,9 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Park the cursor at the very bottom of the picker's inline viewport
|
||||
/// and emit one newline before dropping the terminal. Without this the
|
||||
/// inline area is left with the cursor still inside it, so the next
|
||||
/// `Terminal::with_options(Inline(_))` call (the resume name dialog)
|
||||
/// computes its own area starting from inside the picker — drawing the
|
||||
/// new dialog on top of the lower picker rows.
|
||||
///
|
||||
/// Setting the cursor to `area.bottom() - 1` and writing `\r\n`
|
||||
/// scrolls the terminal up exactly one row, so the next inline
|
||||
/// viewport opens immediately below the picker rather than on top of
|
||||
/// it.
|
||||
/// Park the cursor at the very bottom of the picker's inline viewport and emit
|
||||
/// one newline before dropping the terminal. This keeps any next inline viewport
|
||||
/// from drawing over the lower picker rows.
|
||||
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
|
||||
let area = terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
|
|
@ -153,76 +179,234 @@ fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn open_default_store() -> Result<FsStore, PickerError> {
|
||||
let dir = manifest::paths::sessions_dir().ok_or_else(|| {
|
||||
fn default_store_dir() -> Result<PathBuf, PickerError> {
|
||||
manifest::paths::sessions_dir().ok_or_else(|| {
|
||||
PickerError::Io(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"could not resolve sessions directory \
|
||||
(set INSOMNIA_HOME, INSOMNIA_DATA_DIR, or HOME)",
|
||||
))
|
||||
})?;
|
||||
Ok(FsStore::new(&dir)?)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_rows(store: &FsStore, sessions: Vec<SessionId>) -> Result<Vec<Row>, PickerError> {
|
||||
let mut rows = Vec::new();
|
||||
for session_id in sessions {
|
||||
let mut selected_segment: Option<(SegmentId, u64, String)> = None;
|
||||
for segment_id in store.list_segments(session_id)? {
|
||||
let (updated_at, preview) = summarize_segment(store, session_id, segment_id);
|
||||
if selected_segment
|
||||
.as_ref()
|
||||
.is_none_or(|(best_segment_id, best_updated_at, _)| {
|
||||
updated_at > *best_updated_at
|
||||
|| (updated_at == *best_updated_at && segment_id > *best_segment_id)
|
||||
})
|
||||
{
|
||||
selected_segment = Some((segment_id, updated_at, preview));
|
||||
}
|
||||
fn read_pod_state_records(store_dir: &Path) -> Result<Vec<PodStateRecord>, PickerError> {
|
||||
let pods_dir = store_dir.join("pods");
|
||||
let mut records = Vec::new();
|
||||
if !pods_dir.exists() {
|
||||
return Ok(records);
|
||||
}
|
||||
|
||||
let Some((segment_id, updated_at, preview)) = selected_segment else {
|
||||
for entry in fs::read_dir(pods_dir)? {
|
||||
let entry = entry?;
|
||||
if !entry.file_type()?.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let pod_name = entry.file_name().to_string_lossy().to_string();
|
||||
let path = entry.path().join("metadata.json");
|
||||
let state = match fs::read_to_string(&path) {
|
||||
Ok(content) => serde_json::from_str::<PodMetadata>(&content).map_err(|e| e.to_string()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
rows.push(Row {
|
||||
session_id,
|
||||
segment_id,
|
||||
records.push(PodStateRecord { pod_name, state });
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn read_live_pod_records() -> Result<Vec<LivePodRecord>, io::Error> {
|
||||
let path = default_registry_path()?;
|
||||
let guard = LockFileGuard::open(&path)?;
|
||||
Ok(guard
|
||||
.data()
|
||||
.allocations
|
||||
.iter()
|
||||
.map(|allocation| LivePodRecord {
|
||||
pod_name: allocation.pod_name.clone(),
|
||||
socket_path: allocation.socket.clone(),
|
||||
segment_id: allocation.segment_id,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn read_reachable_live_pod_records() -> Result<Vec<LivePodRecord>, io::Error> {
|
||||
let records = read_live_pod_records()?;
|
||||
let mut reachable = Vec::new();
|
||||
for record in records {
|
||||
if PodClient::connect(&record.socket_path).await.is_ok() {
|
||||
reachable.push(record);
|
||||
}
|
||||
}
|
||||
Ok(reachable)
|
||||
}
|
||||
|
||||
pub(crate) fn live_socket_for_pod(pod_name: &str) -> Option<PathBuf> {
|
||||
read_live_pod_records()
|
||||
.ok()?
|
||||
.into_iter()
|
||||
.find(|pod| pod.pod_name == pod_name)
|
||||
.map(|pod| pod.socket_path)
|
||||
}
|
||||
|
||||
fn build_rows(
|
||||
store: &FsStore,
|
||||
pod_states: Vec<PodStateRecord>,
|
||||
live_pods: Vec<LivePodRecord>,
|
||||
) -> Result<Vec<Row>, PickerError> {
|
||||
let mut rows_by_name: BTreeMap<String, Row> = BTreeMap::new();
|
||||
let mut live_by_name: HashMap<String, LivePodRecord> = HashMap::new();
|
||||
|
||||
for live in live_pods {
|
||||
let (active_session_id, active_segment_id, updated_at, preview) =
|
||||
summarize_live_pod(store, &live);
|
||||
rows_by_name.insert(
|
||||
live.pod_name.clone(),
|
||||
Row {
|
||||
pod_name: live.pod_name.clone(),
|
||||
state: PodRowState::Live,
|
||||
updated_at,
|
||||
active_session_id,
|
||||
active_segment_id,
|
||||
preview,
|
||||
live_pod: None,
|
||||
socket_path: Some(live.socket_path.clone()),
|
||||
},
|
||||
);
|
||||
live_by_name.insert(live.pod_name.clone(), live);
|
||||
}
|
||||
|
||||
for record in pod_states {
|
||||
match record.state {
|
||||
Ok(metadata) => {
|
||||
let summary = summarize_metadata(store, &metadata);
|
||||
let state = if live_by_name.contains_key(&record.pod_name) {
|
||||
PodRowState::Live
|
||||
} else {
|
||||
PodRowState::Stopped
|
||||
};
|
||||
upsert_metadata_row(&mut rows_by_name, record.pod_name, metadata, summary, state);
|
||||
}
|
||||
Err(message) => {
|
||||
rows_by_name.entry(record.pod_name.clone()).or_insert(Row {
|
||||
pod_name: record.pod_name,
|
||||
state: PodRowState::Corrupt,
|
||||
updated_at: 0,
|
||||
active_session_id: None,
|
||||
active_segment_id: None,
|
||||
preview: Some(format!("metadata: {}", trim_one_line(&message, 48))),
|
||||
socket_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut rows: Vec<Row> = rows_by_name.into_values().collect();
|
||||
rows.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| b.segment_id.cmp(&a.segment_id))
|
||||
.then_with(|| b.session_id.cmp(&a.session_id))
|
||||
.then_with(|| a.pod_name.cmp(&b.pod_name))
|
||||
});
|
||||
rows.truncate(MAX_ROWS);
|
||||
for row in &mut rows {
|
||||
// Best-effort live check. A pods.json I/O hiccup downgrades
|
||||
// the row to "no badge" rather than killing the picker — the
|
||||
// user still gets to see the listing.
|
||||
row.live_pod = lookup_segment(row.segment_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|info| info.pod_name);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn upsert_metadata_row(
|
||||
rows_by_name: &mut BTreeMap<String, Row>,
|
||||
pod_name: String,
|
||||
metadata: PodMetadata,
|
||||
summary: SegmentSummary,
|
||||
state: PodRowState,
|
||||
) {
|
||||
let active = metadata.active;
|
||||
let active_session_id = active.as_ref().map(|a| a.session_id);
|
||||
let active_segment_id = active.as_ref().and_then(|a| a.segment_id);
|
||||
|
||||
match rows_by_name.get_mut(&pod_name) {
|
||||
Some(existing) => {
|
||||
existing.state = state;
|
||||
if summary.updated_at > existing.updated_at {
|
||||
existing.updated_at = summary.updated_at;
|
||||
}
|
||||
if existing.active_session_id.is_none() {
|
||||
existing.active_session_id = active_session_id;
|
||||
}
|
||||
if existing.active_segment_id.is_none() {
|
||||
existing.active_segment_id = active_segment_id;
|
||||
}
|
||||
if existing.preview.is_none() {
|
||||
existing.preview = summary.preview;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
rows_by_name.insert(
|
||||
pod_name.clone(),
|
||||
Row {
|
||||
pod_name,
|
||||
state,
|
||||
updated_at: summary.updated_at,
|
||||
active_session_id,
|
||||
active_segment_id,
|
||||
preview: summary.preview,
|
||||
socket_path: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SegmentSummary {
|
||||
updated_at: u64,
|
||||
preview: Option<String>,
|
||||
}
|
||||
|
||||
fn summarize_live_pod(
|
||||
store: &FsStore,
|
||||
live: &LivePodRecord,
|
||||
) -> (Option<SessionId>, Option<SegmentId>, u64, Option<String>) {
|
||||
let Some(segment_id) = live.segment_id else {
|
||||
return (None, None, 0, None);
|
||||
};
|
||||
let session_id = store.lookup_session_of(segment_id).ok().flatten();
|
||||
let Some(session_id) = session_id else {
|
||||
return (None, Some(segment_id), 0, None);
|
||||
};
|
||||
let summary = summarize_segment(store, session_id, segment_id);
|
||||
(
|
||||
Some(session_id),
|
||||
Some(segment_id),
|
||||
summary.updated_at,
|
||||
summary.preview,
|
||||
)
|
||||
}
|
||||
|
||||
fn summarize_metadata(store: &FsStore, metadata: &PodMetadata) -> SegmentSummary {
|
||||
let Some(active) = metadata.active.as_ref() else {
|
||||
return SegmentSummary {
|
||||
updated_at: 0,
|
||||
preview: None,
|
||||
};
|
||||
};
|
||||
let Some(segment_id) = active.segment_id else {
|
||||
return SegmentSummary {
|
||||
updated_at: 0,
|
||||
preview: Some("[pending segment]".to_string()),
|
||||
};
|
||||
};
|
||||
summarize_segment(store, active.session_id, segment_id)
|
||||
}
|
||||
|
||||
fn summarize_segment(
|
||||
store: &FsStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> (u64, String) {
|
||||
) -> SegmentSummary {
|
||||
match store.read_all(session_id, segment_id) {
|
||||
Ok(entries) => (
|
||||
last_entry_ts(&entries).unwrap_or(0),
|
||||
last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
|
||||
),
|
||||
Err(_) => (0, "[corrupt]".to_string()),
|
||||
Ok(entries) => SegmentSummary {
|
||||
updated_at: last_entry_ts(&entries).unwrap_or(0),
|
||||
preview: last_message_preview(&entries).or_else(|| Some("[empty]".to_string())),
|
||||
},
|
||||
Err(_) => SegmentSummary {
|
||||
updated_at: 0,
|
||||
preview: Some("[corrupt segment]".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -247,9 +431,8 @@ fn log_entry_ts(entry: &LogEntry) -> u64 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Walk the log from the tail looking for the most recent user-message
|
||||
/// or assistant-message entry, then render its first text fragment in
|
||||
/// a single line.
|
||||
/// Walk the log from the tail looking for the most recent user-message or
|
||||
/// assistant-message entry, then render its first text fragment in a single line.
|
||||
fn last_message_preview(entries: &[LogEntry]) -> Option<String> {
|
||||
for entry in entries.iter().rev() {
|
||||
match entry {
|
||||
|
|
@ -342,7 +525,7 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
|||
|
||||
f.render_widget(
|
||||
Paragraph::new(Line::from(vec![Span::styled(
|
||||
"resume pod pick a session",
|
||||
picker_title(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)])),
|
||||
layout[0],
|
||||
|
|
@ -358,7 +541,7 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
|||
Span::styled("[↑/↓]", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(" select "),
|
||||
Span::styled("[enter]", Style::default().fg(Color::Green)),
|
||||
Span::raw(" pick "),
|
||||
Span::raw(" attach/restore "),
|
||||
Span::styled("[esc]", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(" cancel"),
|
||||
])),
|
||||
|
|
@ -366,9 +549,13 @@ fn draw(f: &mut Frame<'_>, rows: &[Row], selected: usize) {
|
|||
);
|
||||
}
|
||||
|
||||
fn picker_title() -> &'static str {
|
||||
"resume pod pick a pod"
|
||||
}
|
||||
|
||||
fn row_line(row: &Row, selected: bool) -> Line<'_> {
|
||||
let marker = if selected { "▶ " } else { " " };
|
||||
let id_style = if selected {
|
||||
let name_style = if selected {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
|
|
@ -380,35 +567,60 @@ fn row_line(row: &Row, selected: bool) -> Line<'_> {
|
|||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
|
||||
let mut spans = vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(short_segment(row.session_id), id_style),
|
||||
Span::styled(row.pod_name.as_str(), name_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("[{}]", row.state.label()), row.state.style()),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
format_updated_at(row.updated_at),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(debug_ids(row), Style::default().fg(Color::DarkGray)),
|
||||
];
|
||||
if let Some(ref pod_name) = row.live_pod {
|
||||
spans.push(Span::styled(
|
||||
format!("[live: {pod_name}] "),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
));
|
||||
if let Some(preview) = row.preview.as_ref() {
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(preview.as_str(), preview_style));
|
||||
}
|
||||
spans.push(Span::styled(row.preview.clone(), preview_style));
|
||||
Line::from(spans)
|
||||
}
|
||||
|
||||
fn short_segment(id: SessionId) -> String {
|
||||
let s = id.to_string();
|
||||
s.chars().take(8).collect()
|
||||
fn format_updated_at(updated_at: u64) -> String {
|
||||
if updated_at == 0 {
|
||||
"updated: —".to_string()
|
||||
} else {
|
||||
format!("updated: {updated_at}")
|
||||
}
|
||||
}
|
||||
|
||||
fn debug_ids(row: &Row) -> String {
|
||||
let session = row
|
||||
.active_session_id
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "--------".to_string());
|
||||
let segment = row
|
||||
.active_segment_id
|
||||
.map(short_id)
|
||||
.unwrap_or_else(|| "--------".to_string());
|
||||
format!("s:{session} g:{segment}")
|
||||
}
|
||||
|
||||
fn short_id<T: ToString>(id: T) -> String {
|
||||
id.to_string().chars().take(8).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_worker::llm_client::types::RequestConfig;
|
||||
use session_store::{new_segment_id, new_session_id};
|
||||
use session_store::{PodActiveSegmentRef, PodMetadataStore, new_segment_id, new_session_id};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn rows_are_sorted_by_latest_log_entry_timestamp() {
|
||||
fn pod_rows_are_sorted_by_active_segment_timestamp() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let earlier_session = new_session_id();
|
||||
|
|
@ -417,42 +629,116 @@ mod tests {
|
|||
let later_segment = new_segment_id();
|
||||
|
||||
append_start(&store, earlier_session, earlier_segment, 10);
|
||||
append_start(&store, later_session, later_segment, 20);
|
||||
append_user(
|
||||
&store,
|
||||
earlier_session,
|
||||
earlier_segment,
|
||||
100,
|
||||
"latest update",
|
||||
"old pod update",
|
||||
);
|
||||
append_start(&store, later_session, later_segment, 20);
|
||||
append_user(&store, later_session, later_segment, 200, "new pod update");
|
||||
|
||||
let rows = build_rows(&store, store.list_sessions().unwrap()).unwrap();
|
||||
let records = vec![
|
||||
metadata_record("older", earlier_session, earlier_segment),
|
||||
metadata_record("newer", later_session, later_segment),
|
||||
];
|
||||
let rows = build_rows(&store, records, vec![]).unwrap();
|
||||
|
||||
assert_eq!(rows[0].session_id, earlier_session);
|
||||
assert_eq!(rows[0].segment_id, earlier_segment);
|
||||
assert_eq!(rows[0].updated_at, 100);
|
||||
assert_eq!(rows[0].preview, "user: latest update");
|
||||
assert_eq!(rows[1].session_id, later_session);
|
||||
assert_eq!(rows[0].pod_name, "newer");
|
||||
assert_eq!(rows[0].state, PodRowState::Stopped);
|
||||
assert_eq!(rows[0].updated_at, 200);
|
||||
assert_eq!(rows[0].preview.as_deref(), Some("user: new pod update"));
|
||||
assert_eq!(rows[1].pod_name, "older");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_uses_the_most_recently_updated_segment_in_a_session() {
|
||||
fn pod_rows_include_live_and_stopped_pods() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let old_segment = new_segment_id();
|
||||
let new_segment = new_segment_id();
|
||||
let stopped_session = new_session_id();
|
||||
let stopped_segment = new_segment_id();
|
||||
let live_session = new_session_id();
|
||||
let live_segment = new_segment_id();
|
||||
|
||||
append_start(&store, session_id, old_segment, 10);
|
||||
append_start(&store, session_id, new_segment, 20);
|
||||
append_user(&store, session_id, old_segment, 200, "continued old branch");
|
||||
append_start(&store, stopped_session, stopped_segment, 10);
|
||||
append_user(
|
||||
&store,
|
||||
stopped_session,
|
||||
stopped_segment,
|
||||
50,
|
||||
"stopped preview",
|
||||
);
|
||||
append_start(&store, live_session, live_segment, 20);
|
||||
append_user(&store, live_session, live_segment, 70, "live preview");
|
||||
|
||||
let rows = build_rows(&store, vec![session_id]).unwrap();
|
||||
let rows = build_rows(
|
||||
&store,
|
||||
vec![metadata_record("stopped", stopped_session, stopped_segment)],
|
||||
vec![LivePodRecord {
|
||||
pod_name: "live".to_string(),
|
||||
socket_path: PathBuf::from("/tmp/live.sock"),
|
||||
segment_id: Some(live_segment),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let live = rows.iter().find(|row| row.pod_name == "live").unwrap();
|
||||
assert_eq!(live.state, PodRowState::Live);
|
||||
assert_eq!(live.active_session_id, Some(live_session));
|
||||
assert_eq!(
|
||||
live.socket_path.as_deref(),
|
||||
Some(Path::new("/tmp/live.sock"))
|
||||
);
|
||||
|
||||
let stopped = rows.iter().find(|row| row.pod_name == "stopped").unwrap();
|
||||
assert_eq!(stopped.state, PodRowState::Stopped);
|
||||
assert_eq!(stopped.socket_path, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_pod_state_is_rendered_as_corrupt_row() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let rows = build_rows(
|
||||
&store,
|
||||
vec![PodStateRecord {
|
||||
pod_name: "broken".to_string(),
|
||||
state: Err("expected value".to_string()),
|
||||
}],
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].segment_id, old_segment);
|
||||
assert_eq!(rows[0].updated_at, 200);
|
||||
assert_eq!(rows[0].preview, "user: continued old branch");
|
||||
assert_eq!(rows[0].pod_name, "broken");
|
||||
assert_eq!(rows[0].state, PodRowState::Corrupt);
|
||||
assert!(
|
||||
rows[0]
|
||||
.preview
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("expected value")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_title_names_pods_not_sessions() {
|
||||
assert_eq!(picker_title(), "resume pod pick a pod");
|
||||
}
|
||||
|
||||
fn metadata_record(
|
||||
pod_name: &str,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> PodStateRecord {
|
||||
PodStateRecord {
|
||||
pod_name: pod_name.to_string(),
|
||||
state: Ok(PodMetadata::new(
|
||||
pod_name,
|
||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) {
|
||||
|
|
@ -491,4 +777,36 @@ mod tests {
|
|||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_pod_state_records_reports_corrupt_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let pod_dir = dir.path().join("pods").join("broken");
|
||||
fs::create_dir_all(&pod_dir).unwrap();
|
||||
fs::write(pod_dir.join("metadata.json"), "{not-json").unwrap();
|
||||
|
||||
let records = read_pod_state_records(dir.path()).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].pod_name, "broken");
|
||||
assert!(records[0].state.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_pod_state_records_reads_metadata() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let segment_id = new_segment_id();
|
||||
store
|
||||
.write(&PodMetadata::new(
|
||||
"agent",
|
||||
Some(PodActiveSegmentRef::active_segment(session_id, segment_id)),
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let records = read_pod_state_records(dir.path()).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].pod_name, "agent");
|
||||
assert!(records[0].state.is_ok());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,18 +173,7 @@ pub async fn run(resume_from: Option<SegmentId>) -> Result<SpawnOutcome, SpawnEr
|
|||
/// with the usual TUI cwd-scope fallback.
|
||||
pub async fn run_pod_name(pod_name: String) -> Result<SpawnOutcome, SpawnError> {
|
||||
let defaults = load_spawn_defaults()?;
|
||||
let mut form = Form {
|
||||
cwd: defaults.cwd,
|
||||
cascade_has_scope: defaults.cascade_has_scope,
|
||||
scope_origin: defaults.scope_origin,
|
||||
name_cursor: pod_name.chars().count(),
|
||||
name: pod_name,
|
||||
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
|
||||
editing: false,
|
||||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
};
|
||||
let mut form = form_for_pod_name(pod_name, defaults);
|
||||
let overlay_toml = build_overlay_toml(&form);
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
terminal.draw(|f| draw_form(f, &form))?;
|
||||
|
|
@ -263,6 +252,21 @@ fn load_spawn_defaults() -> Result<SpawnDefaults, SpawnError> {
|
|||
})
|
||||
}
|
||||
|
||||
fn form_for_pod_name(pod_name: String, defaults: SpawnDefaults) -> Form {
|
||||
Form {
|
||||
cwd: defaults.cwd,
|
||||
cascade_has_scope: defaults.cascade_has_scope,
|
||||
scope_origin: defaults.scope_origin,
|
||||
name_cursor: pod_name.chars().count(),
|
||||
name: pod_name,
|
||||
message: Some(("resuming pod...".to_string(), MessageKind::Progress)),
|
||||
editing: false,
|
||||
resume_from: None,
|
||||
resume_by_pod_name: true,
|
||||
resume_scope: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<InlineTerminal> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
|
|
@ -397,6 +401,7 @@ async fn load_resume_scope(segment_id: SegmentId) -> Result<ScopeConfig, SpawnEr
|
|||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MessageKind {
|
||||
Info,
|
||||
Ok,
|
||||
|
|
@ -622,6 +627,28 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pod_name_form_restores_or_creates_by_pod_name() {
|
||||
let defaults = SpawnDefaults {
|
||||
cwd: PathBuf::from("/work/example"),
|
||||
cascade_has_scope: true,
|
||||
scope_origin: ScopeOrigin::FromProject,
|
||||
default_name: "ignored".to_string(),
|
||||
};
|
||||
let f = form_for_pod_name("agent".to_string(), defaults);
|
||||
|
||||
assert_eq!(f.name, "agent");
|
||||
assert_eq!(f.name_cursor, "agent".chars().count());
|
||||
assert_eq!(f.resume_from, None);
|
||||
assert!(f.resume_by_pod_name);
|
||||
assert!(f.resume_scope.is_none());
|
||||
assert!(!f.editing);
|
||||
assert_eq!(
|
||||
f.message,
|
||||
Some(("resuming pod...".to_string(), MessageKind::Progress))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_adds_scope_default_when_cascade_lacks_scope() {
|
||||
let f = form("agent-1", false);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user