feat: add standalone resume picker
This commit is contained in:
@@ -37,9 +37,10 @@ pub use backend_workspace::{
|
||||
};
|
||||
pub use runtime_command::WorkerRuntimeCommand;
|
||||
pub use target::{
|
||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError,
|
||||
TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList,
|
||||
WorkerListRequest, WorkerResume, WorkerSpawn,
|
||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent,
|
||||
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName,
|
||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
|
||||
WorkerSpawn,
|
||||
};
|
||||
|
||||
pub use spawn::{
|
||||
|
||||
@@ -132,6 +132,19 @@ pub struct WorkerResume {
|
||||
pub runtime_command: WorkerRuntimeCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StandaloneSessionListIntent {
|
||||
pub state_dir: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub include_all: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StandaloneSessionResumeIntent {
|
||||
pub state_dir: PathBuf,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Dashboard {
|
||||
Local {
|
||||
@@ -204,6 +217,26 @@ pub trait Target: fmt::Debug + Send + Sync {
|
||||
|
||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
|
||||
|
||||
fn standalone_session_list(
|
||||
&self,
|
||||
_include_all: bool,
|
||||
) -> Result<StandaloneSessionListIntent, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"standalone session listing",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn standalone_session_resume(
|
||||
&self,
|
||||
_session_id: String,
|
||||
) -> Result<StandaloneSessionResumeIntent, TargetError> {
|
||||
Err(TargetError::unsupported(
|
||||
"standalone session restore",
|
||||
self.kind(),
|
||||
))
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError>;
|
||||
|
||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, TargetError>;
|
||||
@@ -312,6 +345,29 @@ impl Target for StandaloneTarget {
|
||||
Err(TargetError::unsupported("Worker restore", self.kind()))
|
||||
}
|
||||
|
||||
fn standalone_session_list(
|
||||
&self,
|
||||
include_all: bool,
|
||||
) -> Result<StandaloneSessionListIntent, TargetError> {
|
||||
let cwd = std::env::current_dir()
|
||||
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
|
||||
Ok(StandaloneSessionListIntent {
|
||||
state_dir: self.state_dir.clone(),
|
||||
cwd,
|
||||
include_all,
|
||||
})
|
||||
}
|
||||
|
||||
fn standalone_session_resume(
|
||||
&self,
|
||||
session_id: String,
|
||||
) -> Result<StandaloneSessionResumeIntent, TargetError> {
|
||||
Ok(StandaloneSessionResumeIntent {
|
||||
state_dir: self.state_dir.clone(),
|
||||
session_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
||||
}
|
||||
@@ -575,6 +631,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_target_builds_explicit_session_intents() {
|
||||
let target = StandaloneTarget::new("/tmp/yoi-client-sessions");
|
||||
let list = target.standalone_session_list(true).unwrap();
|
||||
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-sessions"));
|
||||
assert!(list.include_all);
|
||||
assert!(list.cwd.is_absolute());
|
||||
|
||||
let resume = target
|
||||
.standalone_session_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
||||
.unwrap();
|
||||
assert_eq!(resume.state_dir, list.state_dir);
|
||||
assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000");
|
||||
|
||||
assert!(
|
||||
LocalTarget::new()
|
||||
.standalone_session_list(false)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not supported")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_target_builds_local_worker_list() {
|
||||
let target = LocalTarget::new();
|
||||
|
||||
@@ -11,6 +11,7 @@ e2e-test = []
|
||||
[dependencies]
|
||||
client = { workspace = true }
|
||||
standalone = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
protocol = { workspace = true }
|
||||
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
||||
base64 = "0.22.1"
|
||||
|
||||
@@ -26,7 +26,10 @@ use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||
use client::{BackendRuntimeClient, BackendRuntimeTarget, WorkerClient, WorkerRuntimeCommand};
|
||||
use client::{
|
||||
BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent, WorkerClient,
|
||||
WorkerRuntimeCommand,
|
||||
};
|
||||
|
||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||
@@ -277,6 +280,31 @@ pub(crate) async fn run_standalone(
|
||||
let host = StandaloneHost::start(launch)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?;
|
||||
run_standalone_host(host, worker_name, history_root).await
|
||||
}
|
||||
|
||||
pub(crate) async fn run_standalone_restore(
|
||||
intent: StandaloneSessionResumeIntent,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let session_id = intent.session_id.parse().map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("Invalid standalone session ID: {error}"),
|
||||
)
|
||||
})?;
|
||||
let host = StandaloneHost::restore(intent.state_dir, session_id)
|
||||
.await
|
||||
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
|
||||
let worker_label = format!("standalone-{}", session_id.short());
|
||||
let history_root = host.record().cwd.canonical_path.clone();
|
||||
run_standalone_host(host, worker_label, history_root).await
|
||||
}
|
||||
|
||||
async fn run_standalone_host(
|
||||
host: StandaloneHost,
|
||||
worker_label: String,
|
||||
history_root: PathBuf,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut connection = ConsoleConnection::standalone(host);
|
||||
|
||||
let mut terminal = match enter_fullscreen() {
|
||||
@@ -286,7 +314,7 @@ pub(crate) async fn run_standalone(
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut app = App::new_with_persistent_input_history(worker_name, &history_root);
|
||||
let mut app = App::new_with_persistent_input_history(worker_label, &history_root);
|
||||
let run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await;
|
||||
let shutdown_result = connection
|
||||
.shutdown()
|
||||
|
||||
@@ -19,6 +19,7 @@ mod role_session_registry;
|
||||
mod scroll;
|
||||
pub mod setup_model;
|
||||
mod spawn;
|
||||
mod standalone_picker;
|
||||
mod task;
|
||||
mod text_selection;
|
||||
mod tool;
|
||||
@@ -51,6 +52,9 @@ pub enum LaunchMode {
|
||||
worker_name: Option<String>,
|
||||
profile: Option<String>,
|
||||
},
|
||||
/// Restore one client-owned standalone session. The current cwd is the default scope;
|
||||
/// `include_all` opts into all standalone sessions under the same client data root.
|
||||
StandaloneResume { include_all: bool },
|
||||
/// `yoi --worker <name>`: attach to a live Worker by name if possible;
|
||||
/// otherwise launch the Worker runtime command with `--worker <name>` so it
|
||||
/// resumes from name-keyed state or creates a fresh same-name Worker.
|
||||
@@ -168,6 +172,13 @@ pub async fn launch(options: LaunchOptions) -> ExitCode {
|
||||
}
|
||||
Err(e) => Err(Box::new(e) as Box<dyn std::error::Error>),
|
||||
},
|
||||
LaunchMode::StandaloneResume { include_all } => {
|
||||
match standalone_picker::pick(target.as_ref(), include_all) {
|
||||
Ok(Some(intent)) => console::run_standalone_restore(intent).await,
|
||||
Ok(None) => Ok(()),
|
||||
Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
|
||||
}
|
||||
}
|
||||
LaunchMode::WorkerName {
|
||||
worker_name,
|
||||
socket_override,
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use client::{StandaloneSessionListIntent, StandaloneSessionResumeIntent, Target};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{TerminalOptions, Viewport};
|
||||
use standalone::{StandaloneListScope, StandaloneSessionRecord, StandaloneSessionStore};
|
||||
use thiserror::Error;
|
||||
|
||||
const LIMIT: usize = 100;
|
||||
|
||||
pub(crate) fn pick(
|
||||
target: &dyn Target,
|
||||
include_all: bool,
|
||||
) -> Result<Option<StandaloneSessionResumeIntent>, StandalonePickerError> {
|
||||
let intent = target
|
||||
.standalone_session_list(include_all)
|
||||
.map_err(StandalonePickerError::Target)?;
|
||||
let records = load_records(&intent)?;
|
||||
if records.is_empty() {
|
||||
return Err(StandalonePickerError::NoSessions { include_all });
|
||||
}
|
||||
let selected = run_picker(records)?;
|
||||
selected
|
||||
.map(|record| {
|
||||
target
|
||||
.standalone_session_resume(record.session_id.to_string())
|
||||
.map_err(StandalonePickerError::Target)
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn load_records(
|
||||
intent: &StandaloneSessionListIntent,
|
||||
) -> Result<Vec<StandaloneSessionRecord>, StandalonePickerError> {
|
||||
let store = StandaloneSessionStore::open(&intent.state_dir)
|
||||
.map_err(StandalonePickerError::StateStore)?;
|
||||
store
|
||||
.list(
|
||||
&intent.cwd,
|
||||
if intent.include_all {
|
||||
StandaloneListScope::All
|
||||
} else {
|
||||
StandaloneListScope::CurrentCwd
|
||||
},
|
||||
LIMIT,
|
||||
)
|
||||
.map_err(StandalonePickerError::StateStore)
|
||||
}
|
||||
|
||||
fn run_picker(
|
||||
records: Vec<StandaloneSessionRecord>,
|
||||
) -> Result<Option<StandaloneSessionRecord>, StandalonePickerError> {
|
||||
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
|
||||
let mut terminal = Terminal::with_options(
|
||||
CrosstermBackend::new(io::stdout()),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(height),
|
||||
},
|
||||
)
|
||||
.map_err(StandalonePickerError::Io)?;
|
||||
let mut selected = 0usize;
|
||||
loop {
|
||||
terminal
|
||||
.draw(|frame| draw(frame, &records, selected))
|
||||
.map_err(StandalonePickerError::Io)?;
|
||||
if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
|
||||
continue;
|
||||
}
|
||||
let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
|
||||
continue;
|
||||
};
|
||||
if key.kind == KeyEventKind::Release {
|
||||
continue;
|
||||
}
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') if !ctrl => {
|
||||
selected = selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
|
||||
selected = (selected + 1).min(records.len() - 1);
|
||||
}
|
||||
KeyCode::Enter => return Ok(Some(records[selected].clone())),
|
||||
KeyCode::Esc => return Ok(None),
|
||||
KeyCode::Char('c') if ctrl => return Ok(None),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], selected: usize) {
|
||||
let mut constraints = vec![Constraint::Length(1)];
|
||||
constraints.extend(records.iter().map(|_| Constraint::Length(1)));
|
||||
constraints.push(Constraint::Length(1));
|
||||
let rows = Layout::vertical(constraints).split(frame.area());
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(Span::styled(
|
||||
"resume standalone session",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))),
|
||||
rows[0],
|
||||
);
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
let active = index == selected;
|
||||
let marker = if active { "▶ " } else { " " };
|
||||
let style = if active {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
let cwd = record.cwd.canonical_path.display();
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::raw(marker),
|
||||
Span::styled(record.session_id.short(), style),
|
||||
Span::raw(format!(
|
||||
" [{:?}] updated:{} {}",
|
||||
record.status, record.updated_at_unix_ms, cwd
|
||||
)),
|
||||
])),
|
||||
rows[index + 1],
|
||||
);
|
||||
}
|
||||
frame.render_widget(
|
||||
Paragraph::new(" [↑/↓] select [enter] restore [esc] cancel"),
|
||||
rows[records.len() + 1],
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum StandalonePickerError {
|
||||
#[error("standalone target error: {0}")]
|
||||
Target(#[source] client::TargetError),
|
||||
#[error("standalone session state is unavailable: {0}")]
|
||||
StateStore(#[source] standalone::StandaloneStoreError),
|
||||
#[error(
|
||||
"no standalone sessions found for this cwd; use `yoi --local --resume --all` to include all cwd identities"
|
||||
)]
|
||||
NoSessions { include_all: bool },
|
||||
#[error("standalone session picker I/O failed: {0}")]
|
||||
Io(#[source] io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use client::StandaloneTarget;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_picker_keeps_current_cwd_as_default_scope() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let target = StandaloneTarget::new(temp.path());
|
||||
let error = pick(&target, false).expect_err("empty picker should fail explicitly");
|
||||
assert!(error.to_string().contains("this cwd"));
|
||||
assert!(error.to_string().contains("--all"));
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,8 @@ fn standalone_target() -> Result<Box<dyn Target>, ParseError> {
|
||||
.to_string(),
|
||||
)
|
||||
})?
|
||||
.join("standalone");
|
||||
.join("client")
|
||||
.join("standalone-sessions");
|
||||
Ok(Box::new(StandaloneTarget::new(state_dir)))
|
||||
}
|
||||
|
||||
|
||||
+80
-5
@@ -502,10 +502,20 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
let mut socket_override = None;
|
||||
let mut runtime_id = None;
|
||||
let mut worker_id = None;
|
||||
let mut standalone_resume = false;
|
||||
let mut standalone_all = false;
|
||||
let mut i = 0;
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
match arg.as_str() {
|
||||
"--resume" => {
|
||||
standalone_resume = true;
|
||||
i += 1;
|
||||
}
|
||||
"--all" => {
|
||||
standalone_all = true;
|
||||
i += 1;
|
||||
}
|
||||
"--worker" => {
|
||||
let value = args
|
||||
.get(i + 1)
|
||||
@@ -687,10 +697,33 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
&workspace_root,
|
||||
)?;
|
||||
|
||||
if standalone_all && !standalone_resume {
|
||||
return Err(ParseError("--all requires --resume".to_string()));
|
||||
}
|
||||
if standalone_resume {
|
||||
if target.kind() != TargetKind::Standalone {
|
||||
return Err(ParseError(
|
||||
"--resume is a Standalone option and requires --local".to_string(),
|
||||
));
|
||||
}
|
||||
if worker_name.is_some()
|
||||
|| profile.is_some()
|
||||
|| session.is_some()
|
||||
|| socket_override.is_some()
|
||||
|| runtime_id.is_some()
|
||||
|| worker_id.is_some()
|
||||
{
|
||||
return Err(ParseError(
|
||||
"--local --resume cannot be combined with Worker, profile, session, socket, or Runtime selectors"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if target.kind() == TargetKind::Standalone {
|
||||
if session.is_some() {
|
||||
return Err(ParseError(
|
||||
"--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime"
|
||||
"--local does not accept legacy --session; use --local --resume for Standalone session restore"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
@@ -725,7 +758,11 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
});
|
||||
}
|
||||
|
||||
let mode = if let Some(profile) = profile {
|
||||
let mode = if standalone_resume {
|
||||
LaunchMode::StandaloneResume {
|
||||
include_all: standalone_all,
|
||||
}
|
||||
} else if let Some(profile) = profile {
|
||||
LaunchMode::Spawn {
|
||||
worker_name,
|
||||
profile: Some(profile),
|
||||
@@ -1661,6 +1698,7 @@ const TOP_LEVEL_HELP: &str = r#"yoi
|
||||
|
||||
Usage:
|
||||
yoi [TARGET] [CONSOLE_OPTIONS]
|
||||
yoi --local --resume [--all]
|
||||
yoi [TARGET] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]
|
||||
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
|
||||
yoi [TARGET] panel [-r|--stopped] [--workspace <PATH>]
|
||||
@@ -1670,7 +1708,9 @@ Usage:
|
||||
Target selection:
|
||||
Target options are top-level options and must appear before the command.
|
||||
|
||||
--local Start a one-process Standalone Worker (no Server or Runtime)
|
||||
--local Start or restore a client-owned one-process Standalone Worker
|
||||
--resume With --local, open the Standalone session picker for the current cwd
|
||||
--all With --local --resume, include sessions from every cwd identity
|
||||
--backend <URL> Use a Workspace Backend explicitly
|
||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||
|
||||
@@ -2080,6 +2120,42 @@ backend = "shared"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_local_resume_uses_standalone_picker_scope() {
|
||||
let mode = parse_args_from(["--local", "--resume"]).unwrap();
|
||||
let Mode::Tui { target, mode, .. } = mode else {
|
||||
panic!("expected TUI mode")
|
||||
};
|
||||
assert_eq!(target.kind(), TargetKind::Standalone);
|
||||
assert!(matches!(
|
||||
mode,
|
||||
LaunchMode::StandaloneResume { include_all: false }
|
||||
));
|
||||
let intent = target.standalone_session_list(false).unwrap();
|
||||
assert!(intent.state_dir.ends_with("client/standalone-sessions"));
|
||||
assert!(!intent.include_all);
|
||||
|
||||
let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap();
|
||||
let Mode::Tui { mode, .. } = mode else {
|
||||
panic!("expected TUI mode")
|
||||
};
|
||||
assert!(matches!(
|
||||
mode,
|
||||
LaunchMode::StandaloneResume { include_all: true }
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
parse_args_from(["--resume"]).unwrap_err().to_string(),
|
||||
"--resume is a Standalone option and requires --local"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args_from(["--local", "--all"])
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"--all requires --resume"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parser_rejects_standalone_and_backend_target_together() {
|
||||
let err = parse_args_from(["--local", "--backend", "http://backend.example"]).unwrap_err();
|
||||
@@ -2101,7 +2177,7 @@ backend = "shared"
|
||||
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
|
||||
assert_eq!(
|
||||
err.0,
|
||||
"--local starts a fresh Standalone Worker; --session restore requires the legacy local Runtime"
|
||||
"--local does not accept legacy --session; use --local --resume for Standalone session restore"
|
||||
);
|
||||
|
||||
let socket_args = [
|
||||
@@ -2567,7 +2643,6 @@ backend = "shared"
|
||||
fn parse_rejects_legacy_resume_flags() {
|
||||
let cases = [
|
||||
(vec!["-r".to_string()], "unknown argument: -r"),
|
||||
(vec!["--resume".to_string()], "unknown argument: --resume"),
|
||||
(
|
||||
vec![
|
||||
"--worker".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user