Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop
This commit is contained in:
@@ -192,6 +192,32 @@ impl BackendApiClient {
|
||||
format!("Bearer {}", self.access_token.0)
|
||||
}
|
||||
|
||||
pub async fn require_success(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> Result<reqwest::Response, BackendApiClientError> {
|
||||
let status = response.status();
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
|
||||
self.check_status(status)?;
|
||||
}
|
||||
status if !status.is_success() => {
|
||||
let detail = response
|
||||
.bytes()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|body| backend_error_detail(&body));
|
||||
return Err(BackendApiClientError::BackendResponse {
|
||||
origin: self.origin.clone(),
|
||||
status: status.as_u16(),
|
||||
detail,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
|
||||
match status {
|
||||
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
|
||||
@@ -235,6 +261,18 @@ fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct BackendErrorBody {
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn backend_error_detail(body: &[u8]) -> Option<String> {
|
||||
serde_json::from_slice::<BackendErrorBody>(body)
|
||||
.ok()
|
||||
.map(|body| body.message)
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendApiClientError {
|
||||
InvalidBackendOrigin(String),
|
||||
@@ -266,6 +304,11 @@ pub enum BackendApiClientError {
|
||||
origin: BackendOrigin,
|
||||
status: u16,
|
||||
},
|
||||
BackendResponse {
|
||||
origin: BackendOrigin,
|
||||
status: u16,
|
||||
detail: Option<String>,
|
||||
},
|
||||
Io {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
@@ -312,6 +355,17 @@ impl fmt::Display for BackendApiClientError {
|
||||
Self::BackendStatus { origin, status } => {
|
||||
write!(f, "Backend {origin} returned HTTP {status}")
|
||||
}
|
||||
Self::BackendResponse {
|
||||
origin,
|
||||
status,
|
||||
detail,
|
||||
} => {
|
||||
write!(f, "Backend {origin} returned HTTP {status}")?;
|
||||
if let Some(detail) = detail {
|
||||
write!(f, ": {detail}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Self::Io { path, source } => {
|
||||
write!(f, "failed to access {}: {source}", path.display())
|
||||
}
|
||||
@@ -584,6 +638,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_error_detail_preserves_public_server_message() {
|
||||
let detail = backend_error_detail(
|
||||
br#"{"error":"Bad Request","message":"working_directory_runtime_mismatch: Working directory is owned by a different Runtime","diagnostics":[{"code":"working_directory_runtime_mismatch"}]}"#,
|
||||
);
|
||||
let error = BackendApiClientError::BackendResponse {
|
||||
origin: BackendOrigin::parse("http://127.0.0.1:8787").unwrap(),
|
||||
status: 400,
|
||||
detail,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Backend http://127.0.0.1:8787 returned HTTP 400: working_directory_runtime_mismatch: Working directory is owned by a different Runtime"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_origin_rejects_unsafe_authority_changes() {
|
||||
for invalid in [
|
||||
|
||||
@@ -387,7 +387,7 @@ pub async fn restore_backend_worker(
|
||||
.json(&serde_json::json!({}))
|
||||
.send()
|
||||
.await?;
|
||||
api.check_status(response.status())?;
|
||||
let response = api.require_success(response).await?;
|
||||
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -101,20 +101,24 @@ pub fn complete_current(
|
||||
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
|
||||
let result = session_environment(snapshot.clone())
|
||||
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
|
||||
.map(|result| WasmCompletionResult {
|
||||
from: result.from,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
|
||||
let result = result
|
||||
.map(|result| {
|
||||
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
|
||||
from: utf8_to_utf16_offset(&source, result.from)?,
|
||||
items: result
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|item| WasmCompletionItem {
|
||||
label: item.label,
|
||||
kind: format!("{:?}", item.kind).to_lowercase(),
|
||||
detail: item.detail,
|
||||
priority: item.priority,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
encode(result)
|
||||
})
|
||||
}
|
||||
@@ -177,6 +181,16 @@ fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsVa
|
||||
}
|
||||
}
|
||||
|
||||
fn utf8_to_utf16_offset(source: &str, utf8_offset: usize) -> Result<usize, JsValue> {
|
||||
if utf8_offset > source.len() {
|
||||
return Err(JsValue::from_str("UTF-8 offset is outside the source"));
|
||||
}
|
||||
if !source.is_char_boundary(utf8_offset) {
|
||||
return Err(JsValue::from_str("UTF-8 offset splits a character"));
|
||||
}
|
||||
Ok(source[..utf8_offset].encode_utf16().count())
|
||||
}
|
||||
|
||||
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
|
||||
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,9 @@ impl SnapshotEnvironment {
|
||||
{
|
||||
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
|
||||
member_source.push_str(&context.schema_path.join("."));
|
||||
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
|
||||
member_source.push('.');
|
||||
}
|
||||
let mut completion = LanguageService::new(self).complete(
|
||||
entrypoint.as_str(),
|
||||
&member_source,
|
||||
@@ -1961,6 +1964,31 @@ mod tests {
|
||||
.iter()
|
||||
.any(|item| item.label == "default_profile")
|
||||
);
|
||||
|
||||
let blank_nested_source = "{ profile = { } } as WorkspaceConfigSchema";
|
||||
let blank_nested_cursor = blank_nested_source.find("{ }").unwrap() + 2;
|
||||
let blank_nested = environment
|
||||
.complete_config(
|
||||
&path("main.dcdl"),
|
||||
blank_nested_source,
|
||||
blank_nested_cursor,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(blank_nested.from, blank_nested_cursor);
|
||||
assert!(
|
||||
blank_nested
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.label == "default_profile")
|
||||
);
|
||||
assert!(
|
||||
!blank_nested
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item.label == "profile")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,15 +7,15 @@ use client::{
|
||||
list_backend_workers, restore_backend_worker,
|
||||
};
|
||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||
|
||||
use crate::backend_workspace_picker::select_backend_workspace;
|
||||
use crate::console;
|
||||
use crate::inline_terminal::with_inline_terminal;
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
|
||||
@@ -127,31 +127,32 @@ fn pick_worker(
|
||||
workers.truncate(MAX_ROWS);
|
||||
|
||||
let mut state = BackendWorkerPickerState::new(target, workers);
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, &state))?;
|
||||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => state.previous(),
|
||||
Some(Action::Down) => state.next(),
|
||||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(WorkerPickerResult::Selected(
|
||||
state.selected_worker().clone(),
|
||||
));
|
||||
with_inline_terminal(
|
||||
VIEWPORT_LINES,
|
||||
|terminal| -> Result<_, Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
terminal.draw(|frame| draw(frame, &state))?;
|
||||
match poll_event()? {
|
||||
None => continue,
|
||||
Some(Action::Up) => state.previous(),
|
||||
Some(Action::Down) => state.next(),
|
||||
Some(Action::Submit) => {
|
||||
return Ok(WorkerPickerResult::Selected(
|
||||
state.selected_worker().clone(),
|
||||
));
|
||||
}
|
||||
Some(Action::SwitchWorkspace) => {
|
||||
return Ok(WorkerPickerResult::SwitchWorkspace);
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
return Err(Box::new(io::Error::other(
|
||||
"Backend worker picker cancelled",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Action::SwitchWorkspace) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(WorkerPickerResult::SwitchWorkspace);
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Err(Box::new(io::Error::other(
|
||||
"Backend worker picker cancelled",
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
struct BackendWorkerPickerState {
|
||||
@@ -184,27 +185,6 @@ impl BackendWorkerPickerState {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
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);
|
||||
terminal.set_cursor_position((0, last_row))?;
|
||||
use std::io::Write;
|
||||
let mut out = io::stdout();
|
||||
out.write_all(b"\r\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum Action {
|
||||
Up,
|
||||
Down,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
use std::io::{self, Stdout, Write};
|
||||
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::{TerminalOptions, Viewport};
|
||||
|
||||
pub(crate) type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
struct InlineTerminalGuard {
|
||||
terminal: InlineTerminal,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl InlineTerminalGuard {
|
||||
fn open(height: u16) -> io::Result<Self> {
|
||||
let terminal = Terminal::with_options(
|
||||
CrosstermBackend::new(io::stdout()),
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(height),
|
||||
},
|
||||
)?;
|
||||
Ok(Self {
|
||||
terminal,
|
||||
closed: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn close(&mut self) -> io::Result<()> {
|
||||
if self.closed {
|
||||
return Ok(());
|
||||
}
|
||||
self.closed = true;
|
||||
|
||||
let area = self.terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
let cursor_result = self.terminal.set_cursor_position((0, last_row));
|
||||
let output_result = write_viewport_terminator(&mut io::stdout());
|
||||
cursor_result?;
|
||||
output_result
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InlineTerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_inline_terminal<T, E>(
|
||||
height: u16,
|
||||
run: impl FnOnce(&mut InlineTerminal) -> Result<T, E>,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
E: From<io::Error>,
|
||||
{
|
||||
let mut guard = InlineTerminalGuard::open(height).map_err(E::from)?;
|
||||
let result = run(&mut guard.terminal);
|
||||
let close_result = guard.close();
|
||||
match result {
|
||||
Ok(value) => {
|
||||
close_result.map_err(E::from)?;
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_viewport_terminator(output: &mut impl Write) -> io::Result<()> {
|
||||
output.write_all(b"\r\n")?;
|
||||
output.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn viewport_terminator_moves_following_output_to_a_fresh_line() {
|
||||
let mut output = Vec::new();
|
||||
|
||||
write_viewport_terminator(&mut output).unwrap();
|
||||
|
||||
assert_eq!(output, b"\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_viewport_construction_is_owned_by_this_module() {
|
||||
fn assert_shared_owner(path: &std::path::Path) {
|
||||
for entry in std::fs::read_dir(path).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
assert_shared_owner(&path);
|
||||
} else if path.extension().and_then(|value| value.to_str()) == Some("rs")
|
||||
&& path.file_name().and_then(|value| value.to_str())
|
||||
!= Some("inline_terminal.rs")
|
||||
{
|
||||
let source = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(
|
||||
!source.contains("Viewport::Inline"),
|
||||
"{} constructs an inline viewport outside its shared owner",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert_shared_owner(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"));
|
||||
}
|
||||
}
|
||||
+5
-34
@@ -1,17 +1,17 @@
|
||||
use std::io::{self, Stdout, Write};
|
||||
use std::process::ExitCode;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::Frame;
|
||||
use ratatui::layout::{Constraint, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
|
||||
use secrets::{SecretStore, SecretValue};
|
||||
|
||||
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
Normal,
|
||||
@@ -235,7 +235,6 @@ pub async fn launch() -> ExitCode {
|
||||
}
|
||||
|
||||
type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
|
||||
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
const MAX_ROWS: usize = 10;
|
||||
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5;
|
||||
@@ -270,37 +269,9 @@ impl Drop for RawModeGuard {
|
||||
fn run(store: SecretStore) -> UiResult<()> {
|
||||
enable_raw_mode()?;
|
||||
let guard = RawModeGuard::new();
|
||||
let mut terminal = make_inline_terminal()?;
|
||||
let result = run_loop(&mut terminal, store);
|
||||
let close_result = close_viewport(&mut terminal);
|
||||
drop(terminal);
|
||||
let result = with_inline_terminal(VIEWPORT_LINES, |terminal| run_loop(terminal, store));
|
||||
guard.restore();
|
||||
result?;
|
||||
close_result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_inline_terminal() -> io::Result<InlineTerminal> {
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
Terminal::with_options(
|
||||
backend,
|
||||
TerminalOptions {
|
||||
viewport: Viewport::Inline(VIEWPORT_LINES),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Park the cursor at the very bottom of the inline viewport and emit one
|
||||
/// newline before dropping the terminal. This matches the resume picker and
|
||||
/// keeps the shell prompt (or a later inline viewport) from drawing over rows.
|
||||
fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> {
|
||||
let area = terminal.get_frame().area();
|
||||
let last_row = area.bottom().saturating_sub(1);
|
||||
terminal.set_cursor_position((0, last_row))?;
|
||||
let mut out = io::stdout();
|
||||
out.write_all(b"\r\n")?;
|
||||
out.flush()?;
|
||||
Ok(())
|
||||
result
|
||||
}
|
||||
|
||||
fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> {
|
||||
|
||||
@@ -10,6 +10,7 @@ mod composer_keys;
|
||||
mod console;
|
||||
#[cfg(feature = "e2e-test")]
|
||||
mod e2e_observer;
|
||||
mod inline_terminal;
|
||||
mod input;
|
||||
pub mod keys;
|
||||
mod markdown;
|
||||
|
||||
@@ -3,15 +3,14 @@ use std::time::Duration;
|
||||
|
||||
use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, 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, StandaloneWorkerRecord, StandaloneWorkerStore};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::inline_terminal::with_inline_terminal;
|
||||
|
||||
const LIMIT: usize = 100;
|
||||
|
||||
pub(crate) fn pick(
|
||||
@@ -57,41 +56,36 @@ fn run_picker(
|
||||
records: Vec<StandaloneWorkerRecord>,
|
||||
) -> Result<Option<StandaloneWorkerRecord>, 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);
|
||||
with_inline_terminal(height, |terminal| {
|
||||
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;
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
|
||||
selected = (selected + 1).min(records.len() - 1);
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
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: &[StandaloneWorkerRecord], selected: usize) {
|
||||
@@ -145,11 +139,11 @@ pub(crate) enum StandalonePickerError {
|
||||
#[error("standalone Worker state is unavailable: {0}")]
|
||||
StateStore(#[source] standalone::StandaloneStoreError),
|
||||
#[error(
|
||||
"no standalone Workers found for this cwd; use `yoi --local --resume --all` to include all cwd identities"
|
||||
"no standalone Workers found for this cwd; use `yoi --local resume --all` to include all cwd identities"
|
||||
)]
|
||||
NoWorkers { include_all: bool },
|
||||
#[error("standalone Worker picker I/O failed: {0}")]
|
||||
Io(#[source] io::Error),
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use std::io::{self, Stdout};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use manifest::ProfileDiscovery;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
|
||||
|
||||
const VIEWPORT_HEIGHT: u16 = 6;
|
||||
const FALLBACK_WORKER_NAME: &str = "worker";
|
||||
|
||||
@@ -182,15 +182,16 @@ pub(crate) fn select(
|
||||
return Err(StandaloneSpawnError::NoProfiles);
|
||||
}
|
||||
|
||||
let terminal = open_inline_terminal()?;
|
||||
run_picker(
|
||||
terminal,
|
||||
SpawnForm::new(worker_name, default_worker_name, choices),
|
||||
)
|
||||
with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
|
||||
run_picker(
|
||||
terminal,
|
||||
SpawnForm::new(worker_name, default_worker_name, choices),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_picker(
|
||||
mut terminal: Terminal<CrosstermBackend<Stdout>>,
|
||||
terminal: &mut InlineTerminal,
|
||||
mut form: SpawnForm,
|
||||
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
|
||||
loop {
|
||||
@@ -222,13 +223,6 @@ fn run_picker(
|
||||
}
|
||||
}
|
||||
|
||||
fn open_inline_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
|
||||
let options = ratatui::TerminalOptions {
|
||||
viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT),
|
||||
};
|
||||
Terminal::with_options(CrosstermBackend::new(io::stdout()), options)
|
||||
}
|
||||
|
||||
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
|
||||
registry
|
||||
.entries()
|
||||
|
||||
@@ -134,6 +134,8 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
||||
enum RuntimeWorkerMutationTransport {
|
||||
Remote {
|
||||
base_url: String,
|
||||
request_source_signer: RuntimeRequestSourceSigner,
|
||||
request_source_audience: String,
|
||||
},
|
||||
Embedded {
|
||||
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||
@@ -157,10 +159,12 @@ impl RuntimeWorkerMutationForwarder {
|
||||
) -> Self {
|
||||
Self {
|
||||
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
|
||||
scope,
|
||||
scope: scope.clone(),
|
||||
source_worker_id: source_worker_id.into(),
|
||||
transport: RuntimeWorkerMutationTransport::Remote {
|
||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
|
||||
request_source_audience: scope.server_id,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -197,11 +201,18 @@ impl RuntimeWorkerMutationForwarder {
|
||||
)?;
|
||||
match (&self.transport, proof) {
|
||||
(
|
||||
RuntimeWorkerMutationTransport::Remote { base_url },
|
||||
RuntimeWorkerMutationTransport::Remote {
|
||||
base_url,
|
||||
request_source_signer,
|
||||
request_source_audience,
|
||||
},
|
||||
RuntimeOwnedWorkerMutationProof::Remote(token),
|
||||
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
|
||||
base_url: base_url.clone(),
|
||||
workspace_id: self.scope.workspace_id.clone(),
|
||||
source_worker_id: self.source_worker_id.clone(),
|
||||
request_source_signer: request_source_signer.clone(),
|
||||
request_source_audience: request_source_audience.clone(),
|
||||
token,
|
||||
target_runtime_id: target_runtime_id.to_string(),
|
||||
target_worker_id: target_worker_id.to_string(),
|
||||
@@ -224,6 +235,9 @@ impl RuntimeWorkerMutationForwarder {
|
||||
struct RemoteWorkerRemoveHttpRequest {
|
||||
base_url: String,
|
||||
workspace_id: String,
|
||||
source_worker_id: String,
|
||||
request_source_signer: RuntimeRequestSourceSigner,
|
||||
request_source_audience: String,
|
||||
token: String,
|
||||
target_runtime_id: String,
|
||||
target_worker_id: String,
|
||||
@@ -256,23 +270,35 @@ fn execute_remote_worker_remove_http(
|
||||
fn execute_remote_worker_remove_http_blocking(
|
||||
request: RemoteWorkerRemoveHttpRequest,
|
||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||
let url = format!(
|
||||
"{}/api/w/{}/workers/remove",
|
||||
request.base_url, request.workspace_id
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
let path = format!("/api/w/{}/workers/remove", request.workspace_id);
|
||||
let url = format!("{}{}", request.base_url, path);
|
||||
let body = serde_json::to_string(&serde_json::json!({
|
||||
"target_runtime_id": request.target_runtime_id,
|
||||
"target_worker_id": request.target_worker_id,
|
||||
"reason": request.reason,
|
||||
});
|
||||
}))
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let request_source_proof = request.request_source_signer.issue(
|
||||
&request.request_source_audience,
|
||||
&request.workspace_id,
|
||||
Some(&request.source_worker_id),
|
||||
WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
&path,
|
||||
body.as_bytes(),
|
||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
30,
|
||||
)?;
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client
|
||||
.post(url)
|
||||
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
|
||||
.header(
|
||||
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
request.token,
|
||||
)
|
||||
.json(&body)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(body)
|
||||
.send()
|
||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||
let status = response.status().as_u16();
|
||||
@@ -697,7 +723,8 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{
|
||||
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
|
||||
decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
|
||||
decode_worker_mutation_source_claims, request_body_digest,
|
||||
verify_worker_mutation_source_proof,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -1119,6 +1146,41 @@ mod tests {
|
||||
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
|
||||
assert!(!request.contains("expected_worker_revision"));
|
||||
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
|
||||
let request_source_token = request
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.split_once(':').and_then(|(name, value)| {
|
||||
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
|
||||
.then(|| value.trim())
|
||||
})
|
||||
})
|
||||
.expect("runtime request source proof header");
|
||||
let request_source_claims =
|
||||
decode_runtime_request_source_claims(request_source_token).unwrap();
|
||||
assert_eq!(request_source_claims.iss, "runtime-a");
|
||||
assert_eq!(request_source_claims.aud, "server-a");
|
||||
assert_eq!(request_source_claims.workspace_id, "workspace-a");
|
||||
assert_eq!(
|
||||
request_source_claims.worker_id.as_deref(),
|
||||
Some("worker-source")
|
||||
);
|
||||
assert_eq!(
|
||||
request_source_claims.permission,
|
||||
WORKSPACE_REQUEST_PERMISSION
|
||||
);
|
||||
assert_eq!(request_source_claims.method, "POST");
|
||||
assert_eq!(
|
||||
request_source_claims.path,
|
||||
"/api/w/workspace-a/workers/remove"
|
||||
);
|
||||
let request_body = request
|
||||
.split_once("\r\n\r\n")
|
||||
.map(|(_, body)| body)
|
||||
.expect("WorkerRemove request body");
|
||||
assert_eq!(
|
||||
request_source_claims.body_digest,
|
||||
request_body_digest(request_body.as_bytes())
|
||||
);
|
||||
let token = request
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
|
||||
@@ -1990,16 +1990,18 @@ impl WorkspaceApi {
|
||||
.list_worker_workdir_links(&self.config.workspace_id, worker)?
|
||||
.into_iter()
|
||||
.find(|link| link.unlinked_at.is_none())
|
||||
&& let Some(access) = repository_access_request_for_workdir(
|
||||
{
|
||||
let workdir_runtime_id = registered_workdir_runtime_id(self, &link.workdir_id)?;
|
||||
if let Some(access) = repository_access_request_for_workdir(
|
||||
self,
|
||||
&worker.runtime_id,
|
||||
&workdir_runtime_id,
|
||||
&link.workdir_id,
|
||||
&format!("worker-restore:{}", WorkerId::now_v7()),
|
||||
)?
|
||||
{
|
||||
self.runtime
|
||||
.authorize_working_directory_repository_access(&worker.runtime_id, access)
|
||||
.map_err(RuntimeRegistryError::into_error)?;
|
||||
)? {
|
||||
self.runtime
|
||||
.authorize_working_directory_repository_access(&workdir_runtime_id, access)
|
||||
.map_err(RuntimeRegistryError::into_error)?;
|
||||
}
|
||||
}
|
||||
let binding = self
|
||||
.runtime
|
||||
@@ -22720,6 +22722,119 @@ mod tests {
|
||||
assert!(!route_body.contains("source"));
|
||||
assert!(!route_body.contains("proof"));
|
||||
|
||||
let outer_path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/remove");
|
||||
let outer_request_body = r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker"}"#;
|
||||
let outer_mutation_token = signer
|
||||
.issue_worker_remove(
|
||||
"server-main",
|
||||
&api.config.workspace_id,
|
||||
"7",
|
||||
"runtime-target",
|
||||
"target-worker",
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let outer_request_token =
|
||||
worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
|
||||
.issue(
|
||||
"server-main",
|
||||
&api.config.workspace_id,
|
||||
Some("7"),
|
||||
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
&outer_path,
|
||||
outer_request_body.as_bytes(),
|
||||
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
let outer_response = build_router(api.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&outer_path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
outer_request_token,
|
||||
)
|
||||
.header(
|
||||
worker_runtime::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
outer_mutation_token,
|
||||
)
|
||||
.body(Body::from(outer_request_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(outer_response.status(), StatusCode::NOT_FOUND);
|
||||
let outer_response_body = axum::body::to_bytes(outer_response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8(outer_response_body.to_vec())
|
||||
.unwrap()
|
||||
.contains("unknown_worker")
|
||||
);
|
||||
|
||||
let missing_outer_mutation_token = signer
|
||||
.issue_worker_remove(
|
||||
"server-main",
|
||||
&api.config.workspace_id,
|
||||
"7",
|
||||
"runtime-target",
|
||||
"target-worker",
|
||||
60,
|
||||
)
|
||||
.unwrap();
|
||||
let missing_outer_response = build_router(api.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&outer_path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
worker_runtime::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||
missing_outer_mutation_token,
|
||||
)
|
||||
.body(Body::from(outer_request_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_outer_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let missing_mutation_request_token =
|
||||
worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
|
||||
.issue(
|
||||
"server-main",
|
||||
&api.config.workspace_id,
|
||||
Some("7"),
|
||||
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
|
||||
"POST",
|
||||
&outer_path,
|
||||
outer_request_body.as_bytes(),
|
||||
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX),
|
||||
30,
|
||||
)
|
||||
.unwrap();
|
||||
let missing_mutation_response = build_router(api.clone())
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri(&outer_path)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(
|
||||
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
||||
missing_mutation_request_token,
|
||||
)
|
||||
.body(Body::from(outer_request_body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let mut revoked = trust;
|
||||
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
|
||||
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
|
||||
@@ -26480,7 +26595,7 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_inner_router(api);
|
||||
let app = build_inner_router(api.clone());
|
||||
|
||||
let runtimes = get_json(app.clone(), "/api/runtimes").await;
|
||||
let embedded_summary = runtimes["items"]
|
||||
@@ -26535,6 +26650,37 @@ mod tests {
|
||||
"embedded_worker_runtime"
|
||||
);
|
||||
|
||||
let workdir_id = "external-workdir";
|
||||
api.store
|
||||
.upsert_workdir_registry(&WorkdirRegistryRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
workdir_id: workdir_id.to_string(),
|
||||
runtime_id: "external-workdir-runtime".to_string(),
|
||||
repository_id: "main".to_string(),
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materialization_status: "present".to_string(),
|
||||
cleanliness: "clean".to_string(),
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
api.store
|
||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id),
|
||||
workdir_id: workdir_id.to_string(),
|
||||
role: "attachment".to_string(),
|
||||
linked_at: "2".to_string(),
|
||||
unlinked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let worker = get_json(
|
||||
app.clone(),
|
||||
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"),
|
||||
|
||||
+15
-76
@@ -571,20 +571,10 @@ 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)
|
||||
@@ -766,29 +756,6 @@ 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 runtime_id.is_some() || worker_id.is_some() {
|
||||
return Err(ParseError(
|
||||
@@ -798,7 +765,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
}
|
||||
if session.is_some() {
|
||||
return Err(ParseError(
|
||||
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
|
||||
"--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
@@ -834,16 +801,12 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
|
||||
|
||||
if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) {
|
||||
return Err(ParseError(
|
||||
"Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone Worker store"
|
||||
"Standalone does not accept legacy Worker session or socket selectors; use `yoi --local resume` for the standalone Worker store"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mode = if standalone_resume {
|
||||
LaunchMode::StandaloneResume {
|
||||
include_all: standalone_all,
|
||||
}
|
||||
} else if target.kind() == TargetKind::Standalone {
|
||||
let mode = if target.kind() == TargetKind::Standalone {
|
||||
LaunchMode::Spawn {
|
||||
worker_name,
|
||||
profile,
|
||||
@@ -951,7 +914,7 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
|
||||
)?;
|
||||
if target.kind() != TargetKind::Backend {
|
||||
return Err(ParseError(
|
||||
"yoi workers requires a Backend connection target; use yoi --local --resume for Standalone Workers"
|
||||
"yoi workers requires a Backend connection target; use yoi --local resume for Standalone Workers"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
@@ -1741,7 +1704,6 @@ const TOP_LEVEL_HELP: &str = r#"yoi
|
||||
|
||||
Usage:
|
||||
yoi [TARGET]
|
||||
yoi --local --resume [--all]
|
||||
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
|
||||
yoi [TARGET] resume [--all] [--runtime-id <ID>]
|
||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||
@@ -1750,8 +1712,6 @@ Usage:
|
||||
|
||||
Target selection:
|
||||
--local Use the client-owned one-process Standalone host
|
||||
--resume With --local, restore from the Standalone Worker store
|
||||
--all With Standalone restore, include Workers from every cwd identity
|
||||
--backend <URL> Use a Workspace Backend explicitly
|
||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||
|
||||
@@ -1801,7 +1761,7 @@ Usage:
|
||||
|
||||
Authority:
|
||||
Lists Workers from the selected Backend Workspace. Standalone Workers are restored with
|
||||
`yoi --local --resume` and are not part of the Workspace Worker catalog.
|
||||
`yoi --local resume` and are not part of the Workspace Worker catalog.
|
||||
|
||||
Options:
|
||||
--backend <URL> Use this Workspace Backend
|
||||
@@ -2212,35 +2172,14 @@ 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_worker_list(false).unwrap();
|
||||
assert!(intent.state_dir.ends_with("client/standalone/workers"));
|
||||
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(["--local", "--all"])
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
"--all requires --resume"
|
||||
);
|
||||
fn parser_rejects_removed_top_level_resume_flags() {
|
||||
for (args, expected) in [
|
||||
(vec!["--resume"], "unknown argument: --resume"),
|
||||
(vec!["--local", "--resume"], "unknown argument: --resume"),
|
||||
(vec!["--all"], "unknown argument: --all"),
|
||||
] {
|
||||
assert_eq!(parse_args_from(args).unwrap_err().to_string(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2264,7 +2203,7 @@ backend = "shared"
|
||||
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
|
||||
assert_eq!(
|
||||
err.0,
|
||||
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
|
||||
"--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore"
|
||||
);
|
||||
|
||||
let socket_args = [
|
||||
@@ -2897,7 +2836,7 @@ backend = "shared"
|
||||
other => panic!("expected WorkersHelp mode, got {other:?}"),
|
||||
}
|
||||
assert!(WORKERS_HELP.contains("selected Backend Workspace"));
|
||||
assert!(WORKERS_HELP.contains("--local --resume"));
|
||||
assert!(WORKERS_HELP.contains("--local resume"));
|
||||
assert!(!WORKERS_HELP.contains("[--local|--backend"));
|
||||
assert!(!WORKERS_HELP.contains("local Worker records"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user