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 runtime_command::WorkerRuntimeCommand;
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneTarget, Target, TargetError,
|
BackendTarget, Dashboard, LocalTarget, ResolvedTarget, StandaloneSessionListIntent,
|
||||||
TargetKind, WorkerByName, WorkerConnection, WorkerConnectionSelector, WorkerList,
|
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind, WorkerByName,
|
||||||
WorkerListRequest, WorkerResume, WorkerSpawn,
|
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerResume,
|
||||||
|
WorkerSpawn,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use spawn::{
|
pub use spawn::{
|
||||||
|
|||||||
@@ -132,6 +132,19 @@ pub struct WorkerResume {
|
|||||||
pub runtime_command: WorkerRuntimeCommand,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Dashboard {
|
pub enum Dashboard {
|
||||||
Local {
|
Local {
|
||||||
@@ -204,6 +217,26 @@ pub trait Target: fmt::Debug + Send + Sync {
|
|||||||
|
|
||||||
fn resume_worker(&self) -> Result<WorkerResume, TargetError>;
|
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 dashboard(&self) -> Result<Dashboard, TargetError>;
|
||||||
|
|
||||||
fn list_workers(&self, request: WorkerListRequest) -> Result<WorkerList, 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()))
|
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> {
|
fn dashboard(&self) -> Result<Dashboard, TargetError> {
|
||||||
Err(TargetError::unsupported("Worker dashboard", self.kind()))
|
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]
|
#[test]
|
||||||
fn local_target_builds_local_worker_list() {
|
fn local_target_builds_local_worker_list() {
|
||||||
let target = LocalTarget::new();
|
let target = LocalTarget::new();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ e2e-test = []
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
client = { workspace = true }
|
client = { workspace = true }
|
||||||
standalone = { workspace = true }
|
standalone = { workspace = true }
|
||||||
|
thiserror.workspace = true
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
ratatui = { version = "0.30.0", features = ["scrolling-regions"] }
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
|||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
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::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||||
@@ -277,6 +280,31 @@ pub(crate) async fn run_standalone(
|
|||||||
let host = StandaloneHost::start(launch)
|
let host = StandaloneHost::start(launch)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| io::Error::other(format!("Standalone Worker startup failed: {error}")))?;
|
.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 connection = ConsoleConnection::standalone(host);
|
||||||
|
|
||||||
let mut terminal = match enter_fullscreen() {
|
let mut terminal = match enter_fullscreen() {
|
||||||
@@ -286,7 +314,7 @@ pub(crate) async fn run_standalone(
|
|||||||
return Err(error);
|
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 run_result = run_loop(&mut terminal, &mut app, &mut connection, None).await;
|
||||||
let shutdown_result = connection
|
let shutdown_result = connection
|
||||||
.shutdown()
|
.shutdown()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ mod role_session_registry;
|
|||||||
mod scroll;
|
mod scroll;
|
||||||
pub mod setup_model;
|
pub mod setup_model;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
|
mod standalone_picker;
|
||||||
mod task;
|
mod task;
|
||||||
mod text_selection;
|
mod text_selection;
|
||||||
mod tool;
|
mod tool;
|
||||||
@@ -51,6 +52,9 @@ pub enum LaunchMode {
|
|||||||
worker_name: Option<String>,
|
worker_name: Option<String>,
|
||||||
profile: 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;
|
/// `yoi --worker <name>`: attach to a live Worker by name if possible;
|
||||||
/// otherwise launch the Worker runtime command with `--worker <name>` so it
|
/// otherwise launch the Worker runtime command with `--worker <name>` so it
|
||||||
/// resumes from name-keyed state or creates a fresh same-name Worker.
|
/// 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>),
|
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 {
|
LaunchMode::WorkerName {
|
||||||
worker_name,
|
worker_name,
|
||||||
socket_override,
|
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(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.join("standalone");
|
.join("client")
|
||||||
|
.join("standalone-sessions");
|
||||||
Ok(Box::new(StandaloneTarget::new(state_dir)))
|
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 socket_override = None;
|
||||||
let mut runtime_id = None;
|
let mut runtime_id = None;
|
||||||
let mut worker_id = None;
|
let mut worker_id = None;
|
||||||
|
let mut standalone_resume = false;
|
||||||
|
let mut standalone_all = false;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < args.len() {
|
while i < args.len() {
|
||||||
let arg = &args[i];
|
let arg = &args[i];
|
||||||
match arg.as_str() {
|
match arg.as_str() {
|
||||||
|
"--resume" => {
|
||||||
|
standalone_resume = true;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
"--all" => {
|
||||||
|
standalone_all = true;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
"--worker" => {
|
"--worker" => {
|
||||||
let value = args
|
let value = args
|
||||||
.get(i + 1)
|
.get(i + 1)
|
||||||
@@ -687,10 +697,33 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
|||||||
&workspace_root,
|
&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 target.kind() == TargetKind::Standalone {
|
||||||
if session.is_some() {
|
if session.is_some() {
|
||||||
return Err(ParseError(
|
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(),
|
.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 {
|
LaunchMode::Spawn {
|
||||||
worker_name,
|
worker_name,
|
||||||
profile: Some(profile),
|
profile: Some(profile),
|
||||||
@@ -1661,6 +1698,7 @@ const TOP_LEVEL_HELP: &str = r#"yoi
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
yoi [TARGET] [CONSOLE_OPTIONS]
|
yoi [TARGET] [CONSOLE_OPTIONS]
|
||||||
|
yoi --local --resume [--all]
|
||||||
yoi [TARGET] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]
|
yoi [TARGET] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]
|
||||||
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
|
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
|
||||||
yoi [TARGET] panel [-r|--stopped] [--workspace <PATH>]
|
yoi [TARGET] panel [-r|--stopped] [--workspace <PATH>]
|
||||||
@@ -1670,7 +1708,9 @@ Usage:
|
|||||||
Target selection:
|
Target selection:
|
||||||
Target options are top-level options and must appear before the command.
|
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
|
--backend <URL> Use a Workspace Backend explicitly
|
||||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
--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]
|
#[test]
|
||||||
fn parser_rejects_standalone_and_backend_target_together() {
|
fn parser_rejects_standalone_and_backend_target_together() {
|
||||||
let err = parse_args_from(["--local", "--backend", "http://backend.example"]).unwrap_err();
|
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();
|
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
err.0,
|
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 = [
|
let socket_args = [
|
||||||
@@ -2567,7 +2643,6 @@ backend = "shared"
|
|||||||
fn parse_rejects_legacy_resume_flags() {
|
fn parse_rejects_legacy_resume_flags() {
|
||||||
let cases = [
|
let cases = [
|
||||||
(vec!["-r".to_string()], "unknown argument: -r"),
|
(vec!["-r".to_string()], "unknown argument: -r"),
|
||||||
(vec!["--resume".to_string()], "unknown argument: --resume"),
|
|
||||||
(
|
(
|
||||||
vec![
|
vec![
|
||||||
"--worker".to_string(),
|
"--worker".to_string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user