Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop

This commit is contained in:
2026-09-03 09:47:53 +09:00
41 changed files with 2218 additions and 379 deletions
+71
View File
@@ -192,6 +192,32 @@ impl BackendApiClient {
format!("Bearer {}", self.access_token.0) 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> { pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
match status { match status {
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized { 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)] #[derive(Debug)]
pub enum BackendApiClientError { pub enum BackendApiClientError {
InvalidBackendOrigin(String), InvalidBackendOrigin(String),
@@ -266,6 +304,11 @@ pub enum BackendApiClientError {
origin: BackendOrigin, origin: BackendOrigin,
status: u16, status: u16,
}, },
BackendResponse {
origin: BackendOrigin,
status: u16,
detail: Option<String>,
},
Io { Io {
path: PathBuf, path: PathBuf,
source: std::io::Error, source: std::io::Error,
@@ -312,6 +355,17 @@ impl fmt::Display for BackendApiClientError {
Self::BackendStatus { origin, status } => { Self::BackendStatus { origin, status } => {
write!(f, "Backend {origin} returned HTTP {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 } => { Self::Io { path, source } => {
write!(f, "failed to access {}: {source}", path.display()) 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] #[test]
fn backend_origin_rejects_unsafe_authority_changes() { fn backend_origin_rejects_unsafe_authority_changes() {
for invalid in [ for invalid in [
+1 -1
View File
@@ -387,7 +387,7 @@ pub async fn restore_backend_worker(
.json(&serde_json::json!({})) .json(&serde_json::json!({}))
.send() .send()
.await?; .await?;
api.check_status(response.status())?; let response = api.require_success(response).await?;
Ok(response.json::<BackendWorkerRestoreResponse>().await?) Ok(response.json::<BackendWorkerRestoreResponse>().await?)
} }
+28 -14
View File
@@ -101,20 +101,24 @@ pub fn complete_current(
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?; let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
let result = session_environment(snapshot.clone()) let result = session_environment(snapshot.clone())
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit) .complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
.map_err(|error| JsValue::from_str(&format!("{error:?}")))? .map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
.map(|result| WasmCompletionResult { let result = result
from: result.from, .map(|result| {
items: result Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
.items from: utf8_to_utf16_offset(&source, result.from)?,
.into_iter() items: result
.map(|item| WasmCompletionItem { .items
label: item.label, .into_iter()
kind: format!("{:?}", item.kind).to_lowercase(), .map(|item| WasmCompletionItem {
detail: item.detail, label: item.label,
priority: item.priority, kind: format!("{:?}", item.kind).to_lowercase(),
}) detail: item.detail,
.collect(), priority: item.priority,
}); })
.collect(),
})
})
.transpose()?;
encode(result) 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> { fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
from_value(value).map_err(|error| JsValue::from_str(&error.to_string())) from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
} }
+28
View File
@@ -1203,6 +1203,9 @@ impl SnapshotEnvironment {
{ {
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}."); let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
member_source.push_str(&context.schema_path.join(".")); 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( let mut completion = LanguageService::new(self).complete(
entrypoint.as_str(), entrypoint.as_str(),
&member_source, &member_source,
@@ -1961,6 +1964,31 @@ mod tests {
.iter() .iter()
.any(|item| item.label == "default_profile") .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] #[test]
+27 -47
View File
@@ -7,15 +7,15 @@ use client::{
list_backend_workers, restore_backend_worker, list_backend_workers, restore_backend_worker,
}; };
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend; use ratatui::Frame;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use crate::backend_workspace_picker::select_backend_workspace; use crate::backend_workspace_picker::select_backend_workspace;
use crate::console; use crate::console;
use crate::inline_terminal::with_inline_terminal;
const MAX_ROWS: usize = 10; const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
@@ -127,31 +127,32 @@ fn pick_worker(
workers.truncate(MAX_ROWS); workers.truncate(MAX_ROWS);
let mut state = BackendWorkerPickerState::new(target, workers); let mut state = BackendWorkerPickerState::new(target, workers);
let mut terminal = make_inline_terminal()?; with_inline_terminal(
loop { VIEWPORT_LINES,
terminal.draw(|frame| draw(frame, &state))?; |terminal| -> Result<_, Box<dyn std::error::Error>> {
match poll_event()? { loop {
None => continue, terminal.draw(|frame| draw(frame, &state))?;
Some(Action::Up) => state.previous(), match poll_event()? {
Some(Action::Down) => state.next(), None => continue,
Some(Action::Submit) => { Some(Action::Up) => state.previous(),
close_viewport(&mut terminal)?; Some(Action::Down) => state.next(),
return Ok(WorkerPickerResult::Selected( Some(Action::Submit) => {
state.selected_worker().clone(), 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 { 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 { enum Action {
Up, Up,
Down, Down,
+109
View File
@@ -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
View File
@@ -1,17 +1,17 @@
use std::io::{self, Stdout, Write};
use std::process::ExitCode; use std::process::ExitCode;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend; use ratatui::Frame;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use secrets::{SecretStore, SecretValue}; use secrets::{SecretStore, SecretValue};
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
enum Mode { enum Mode {
Normal, Normal,
@@ -235,7 +235,6 @@ pub async fn launch() -> ExitCode {
} }
type UiResult<T> = Result<T, Box<dyn std::error::Error>>; type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
const MAX_ROWS: usize = 10; const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5;
@@ -270,37 +269,9 @@ impl Drop for RawModeGuard {
fn run(store: SecretStore) -> UiResult<()> { fn run(store: SecretStore) -> UiResult<()> {
enable_raw_mode()?; enable_raw_mode()?;
let guard = RawModeGuard::new(); let guard = RawModeGuard::new();
let mut terminal = make_inline_terminal()?; let result = with_inline_terminal(VIEWPORT_LINES, |terminal| run_loop(terminal, store));
let result = run_loop(&mut terminal, store);
let close_result = close_viewport(&mut terminal);
drop(terminal);
guard.restore(); guard.restore();
result?; 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(())
} }
fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> { fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> {
+1
View File
@@ -10,6 +10,7 @@ mod composer_keys;
mod console; mod console;
#[cfg(feature = "e2e-test")] #[cfg(feature = "e2e-test")]
mod e2e_observer; mod e2e_observer;
mod inline_terminal;
mod input; mod input;
pub mod keys; pub mod keys;
mod markdown; mod markdown;
+31 -37
View File
@@ -3,15 +3,14 @@ use std::time::Duration;
use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target}; use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout}; use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::{Color, Line, Modifier, Span, Style}; use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport};
use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore}; use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
use thiserror::Error; use thiserror::Error;
use crate::inline_terminal::with_inline_terminal;
const LIMIT: usize = 100; const LIMIT: usize = 100;
pub(crate) fn pick( pub(crate) fn pick(
@@ -57,41 +56,36 @@ fn run_picker(
records: Vec<StandaloneWorkerRecord>, records: Vec<StandaloneWorkerRecord>,
) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> { ) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> {
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20); let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
let mut terminal = Terminal::with_options( with_inline_terminal(height, |terminal| {
CrosstermBackend::new(io::stdout()), let mut selected = 0usize;
TerminalOptions { loop {
viewport: Viewport::Inline(height), terminal
}, .draw(|frame| draw(frame, &records, selected))
) .map_err(StandalonePickerError::Io)?;
.map_err(StandalonePickerError::Io)?; if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
let mut selected = 0usize; continue;
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 => { let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
selected = (selected + 1).min(records.len() - 1); 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) { 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}")] #[error("standalone Worker state is unavailable: {0}")]
StateStore(#[source] standalone::StandaloneStoreError), StateStore(#[source] standalone::StandaloneStoreError),
#[error( #[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 }, NoWorkers { include_all: bool },
#[error("standalone Worker picker I/O failed: {0}")] #[error("standalone Worker picker I/O failed: {0}")]
Io(#[source] io::Error), Io(#[from] io::Error),
} }
#[cfg(test)] #[cfg(test)]
+10 -16
View File
@@ -1,17 +1,17 @@
use std::io::{self, Stdout}; use std::io;
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery; use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span}; use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph; use ratatui::widgets::Paragraph;
use thiserror::Error; use thiserror::Error;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
const VIEWPORT_HEIGHT: u16 = 6; const VIEWPORT_HEIGHT: u16 = 6;
const FALLBACK_WORKER_NAME: &str = "worker"; const FALLBACK_WORKER_NAME: &str = "worker";
@@ -182,15 +182,16 @@ pub(crate) fn select(
return Err(StandaloneSpawnError::NoProfiles); return Err(StandaloneSpawnError::NoProfiles);
} }
let terminal = open_inline_terminal()?; with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
run_picker( run_picker(
terminal, terminal,
SpawnForm::new(worker_name, default_worker_name, choices), SpawnForm::new(worker_name, default_worker_name, choices),
) )
})
} }
fn run_picker( fn run_picker(
mut terminal: Terminal<CrosstermBackend<Stdout>>, terminal: &mut InlineTerminal,
mut form: SpawnForm, mut form: SpawnForm,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> { ) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
loop { 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> { fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
registry registry
.entries() .entries()
+72 -10
View File
@@ -134,6 +134,8 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
enum RuntimeWorkerMutationTransport { enum RuntimeWorkerMutationTransport {
Remote { Remote {
base_url: String, base_url: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
}, },
Embedded { Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>, dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
@@ -157,10 +159,12 @@ impl RuntimeWorkerMutationForwarder {
) -> Self { ) -> Self {
Self { Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity), authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope, scope: scope.clone(),
source_worker_id: source_worker_id.into(), source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote { transport: RuntimeWorkerMutationTransport::Remote {
base_url: base_url.into().trim_end_matches('/').to_string(), 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) { match (&self.transport, proof) {
( (
RuntimeWorkerMutationTransport::Remote { base_url }, RuntimeWorkerMutationTransport::Remote {
base_url,
request_source_signer,
request_source_audience,
},
RuntimeOwnedWorkerMutationProof::Remote(token), RuntimeOwnedWorkerMutationProof::Remote(token),
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest { ) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
base_url: base_url.clone(), base_url: base_url.clone(),
workspace_id: self.scope.workspace_id.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, token,
target_runtime_id: target_runtime_id.to_string(), target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(), target_worker_id: target_worker_id.to_string(),
@@ -224,6 +235,9 @@ impl RuntimeWorkerMutationForwarder {
struct RemoteWorkerRemoveHttpRequest { struct RemoteWorkerRemoveHttpRequest {
base_url: String, base_url: String,
workspace_id: String, workspace_id: String,
source_worker_id: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
token: String, token: String,
target_runtime_id: String, target_runtime_id: String,
target_worker_id: String, target_worker_id: String,
@@ -256,23 +270,35 @@ fn execute_remote_worker_remove_http(
fn execute_remote_worker_remove_http_blocking( fn execute_remote_worker_remove_http_blocking(
request: RemoteWorkerRemoveHttpRequest, request: RemoteWorkerRemoveHttpRequest,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> { ) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let url = format!( let path = format!("/api/w/{}/workers/remove", request.workspace_id);
"{}/api/w/{}/workers/remove", let url = format!("{}{}", request.base_url, path);
request.base_url, request.workspace_id let body = serde_json::to_string(&serde_json::json!({
);
let body = serde_json::json!({
"target_runtime_id": request.target_runtime_id, "target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id, "target_worker_id": request.target_worker_id,
"reason": request.reason, "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 client = reqwest::blocking::Client::new();
let response = client let response = client
.post(url) .post(url)
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
.header( .header(
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER, crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
request.token, request.token,
) )
.json(&body) .header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send() .send()
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?; .map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let status = response.status().as_u16(); let status = response.status().as_u16();
@@ -697,7 +723,8 @@ mod tests {
use super::*; use super::*;
use crate::auth::{ use crate::auth::{
WorkerMutationSourceExpectation, decode_runtime_request_source_claims, 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] #[test]
@@ -1119,6 +1146,41 @@ mod tests {
assert!(request.contains("\"target_worker_id\":\"worker-target\"")); assert!(request.contains("\"target_worker_id\":\"worker-target\""));
assert!(!request.contains("expected_worker_revision")); assert!(!request.contains("expected_worker_revision"));
assert!(request.contains("\"reason\":\"retire obsolete Worker\"")); 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 let token = request
.lines() .lines()
.find_map(|line| { .find_map(|line| {
+154 -8
View File
@@ -1990,16 +1990,18 @@ impl WorkspaceApi {
.list_worker_workdir_links(&self.config.workspace_id, worker)? .list_worker_workdir_links(&self.config.workspace_id, worker)?
.into_iter() .into_iter()
.find(|link| link.unlinked_at.is_none()) .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, self,
&worker.runtime_id, &workdir_runtime_id,
&link.workdir_id, &link.workdir_id,
&format!("worker-restore:{}", WorkerId::now_v7()), &format!("worker-restore:{}", WorkerId::now_v7()),
)? )? {
{ self.runtime
self.runtime .authorize_working_directory_repository_access(&workdir_runtime_id, access)
.authorize_working_directory_repository_access(&worker.runtime_id, access) .map_err(RuntimeRegistryError::into_error)?;
.map_err(RuntimeRegistryError::into_error)?; }
} }
let binding = self let binding = self
.runtime .runtime
@@ -22720,6 +22722,119 @@ mod tests {
assert!(!route_body.contains("source")); assert!(!route_body.contains("source"));
assert!(!route_body.contains("proof")); 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; let mut revoked = trust;
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string()); revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap(); let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
@@ -26480,7 +26595,7 @@ mod tests {
) )
.await .await
.unwrap(); .unwrap();
let app = build_inner_router(api); let app = build_inner_router(api.clone());
let runtimes = get_json(app.clone(), "/api/runtimes").await; let runtimes = get_json(app.clone(), "/api/runtimes").await;
let embedded_summary = runtimes["items"] let embedded_summary = runtimes["items"]
@@ -26535,6 +26650,37 @@ mod tests {
"embedded_worker_runtime" "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( let worker = get_json(
app.clone(), app.clone(),
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"), &format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"),
+15 -76
View File
@@ -571,20 +571,10 @@ 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)
@@ -766,29 +756,6 @@ 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 runtime_id.is_some() || worker_id.is_some() { if runtime_id.is_some() || worker_id.is_some() {
return Err(ParseError( return Err(ParseError(
@@ -798,7 +765,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
} }
if session.is_some() { if session.is_some() {
return Err(ParseError( 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(), .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()) { if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) {
return Err(ParseError( 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(), .to_string(),
)); ));
} }
let mode = if standalone_resume { let mode = if target.kind() == TargetKind::Standalone {
LaunchMode::StandaloneResume {
include_all: standalone_all,
}
} else if target.kind() == TargetKind::Standalone {
LaunchMode::Spawn { LaunchMode::Spawn {
worker_name, worker_name,
profile, profile,
@@ -951,7 +914,7 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
)?; )?;
if target.kind() != TargetKind::Backend { if target.kind() != TargetKind::Backend {
return Err(ParseError( 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(), .to_string(),
)); ));
} }
@@ -1741,7 +1704,6 @@ const TOP_LEVEL_HELP: &str = r#"yoi
Usage: Usage:
yoi [TARGET] yoi [TARGET]
yoi --local --resume [--all]
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>] yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
yoi [TARGET] resume [--all] [--runtime-id <ID>] yoi [TARGET] resume [--all] [--runtime-id <ID>]
yoi --backend <URL> [--workspace-id <ID>] panel yoi --backend <URL> [--workspace-id <ID>] panel
@@ -1750,8 +1712,6 @@ Usage:
Target selection: Target selection:
--local Use the client-owned one-process Standalone host --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 --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
@@ -1801,7 +1761,7 @@ Usage:
Authority: Authority:
Lists Workers from the selected Backend Workspace. Standalone Workers are restored with 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: Options:
--backend <URL> Use this Workspace Backend --backend <URL> Use this Workspace Backend
@@ -2212,35 +2172,14 @@ backend = "shared"
} }
#[test] #[test]
fn parser_local_resume_uses_standalone_picker_scope() { fn parser_rejects_removed_top_level_resume_flags() {
let mode = parse_args_from(["--local", "--resume"]).unwrap(); for (args, expected) in [
let Mode::Tui { target, mode, .. } = mode else { (vec!["--resume"], "unknown argument: --resume"),
panic!("expected TUI mode") (vec!["--local", "--resume"], "unknown argument: --resume"),
}; (vec!["--all"], "unknown argument: --all"),
assert_eq!(target.kind(), TargetKind::Standalone); ] {
assert!(matches!( assert_eq!(parse_args_from(args).unwrap_err().to_string(), expected);
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"
);
} }
#[test] #[test]
@@ -2264,7 +2203,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 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 = [ let socket_args = [
@@ -2897,7 +2836,7 @@ backend = "shared"
other => panic!("expected WorkersHelp mode, got {other:?}"), other => panic!("expected WorkersHelp mode, got {other:?}"),
} }
assert!(WORKERS_HELP.contains("selected Backend Workspace")); 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|--backend"));
assert!(!WORKERS_HELP.contains("local Worker records")); assert!(!WORKERS_HELP.contains("local Worker records"));
} }
+11 -11
View File
@@ -7,17 +7,17 @@ It is not a dumping ground for external research, old plans, API inventories, or
## Reading order ## Reading order
1. [`design/overview.md`](design/overview.md) — the system map. 1. [`design/overview.md`](design/overview.md) — the system map.
2. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context. 2. [`design/durable-operations.md`](design/durable-operations.md) — cross-domain operation identity, checkpoints, retries, child operations, and disposition, including durable Workdir removal.
3. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints. 3. [`design/context-history.md`](design/context-history.md) — the highest-risk invariant: inputs that affect the model must be committed to history before they enter context.
4. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation. 4. [`design/worker-session-state.md`](design/worker-session-state.md) — Worker identity, replayable session logs, current metadata, and live process hints.
5. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification. 5. [`design/session-observation.md`](design/session-observation.md) — common session captures, `SessionEntryRef`, Memory evidence, and host-authorized Worker observation.
6. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification.
7. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 7. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
8. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. 8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
9. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. 9. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries.
10. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. 10. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins.
11. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. 11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
12. [`design/durable-operations.md`](design/durable-operations.md) — durable Backend intents that cross Runtime/provider side-effect boundaries, including Workdir removal. 12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. 13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
14. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks. 14. [`development/server-runtime-auth.md`](development/server-runtime-auth.md) — manual Workspace Server / Runtime public-key exchange and authenticated Runtime startup checks.
15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 15. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed.
+325 -35
View File
@@ -1,45 +1,335 @@
# Durable side-effect operations # Durable operation design
A durable side-effect operation is a Backend-owned intent whose execution crosses an authority boundary, such as a Runtime/provider mutation, and must converge across duplicate requests and bounded recovery. The durable record is not a trace of Rust control flow. Durable operation records make retries converge on the same authorized intent
and preserve the facts needed to explain an externally visible result. They are
not execution traces and must not mirror every Rust function or implementation
step as persisted state.
## Durable authority This document defines the cross-domain rules for operation identity, state,
checkpoints, child operations, failure evidence, and terminal disposition.
Domain code may use different records where its atomicity boundary differs, but
it should classify the operation before choosing a schema.
The record stores only facts that affect identity, authorization, replay, or the final domain result: ## Core rule
- a stable operation identity and request fingerprint derived from caller intent; Persist authority and non-reconstructable facts, not control flow.
- the Workspace resource and the resolved authority that exact retries must keep;
- `pending`, `failed`, or `completed` lifecycle state;
- attempt count and timestamps as operational evidence;
- explicit retryability, bounded failure category, and bounded disposition;
- a factual checkpoint only when a non-idempotent provider effect cannot be safely re-observed or repeated.
A fingerprint excludes Server-generated result identifiers, attempt data, diagnostics, and fresh observations. Reusing one operation identity with a different fingerprint is an error. A completed exact retry replays the committed bounded result. A durable one-pending-operation constraint plus an atomic attempt claim prevents concurrent callers from entering the provider side effect for the same Workdir; the in-process resource lock is an additional serialization layer, not the sole authority. Each active attempt persists the Server process ID and process-start marker. Recovery reclaims only an owner proven missing or replaced; a live or unobservable owner is never stolen. The reclaim transaction CAS-checks the exact proved owner snapshot and attempt count so a stale orphan proof cannot overwrite a newer live claim. A value belongs in durable operation state only when at least one of the
following is true:
`pending` means only that the intent remains open. Function names, validation steps, and provider-call positions are not persisted as lifecycle stages. `failed` records the latest terminal attempt outcome; retryability remains separate metadata. `completed` means the required domain result and disposition are durably committed. - it identifies the caller's stable intent and detects conflicting reuse;
- it freezes authority or configuration that an exact retry must continue to
use;
- it binds a preallocated or created resource to the operation;
- it records an externally visible side effect that cannot be safely derived or
repeated;
- it records the final domain result or disposition;
- it provides bounded evidence needed for retry, reconciliation, or audit.
## Live authority and provider evidence A local step does not become durable merely because it occurs before or after
another function call. If current authority can be reread or the step can be
Before every attempt, the Backend rereads current Workspace ownership and guards. A previous observation that a resource was detached, unblocked, or clean is not authorization for a later retry. repeated safely, derive or repeat it instead of adding a stage.
Provider timeout, unavailability, an empty response, or an unknown outcome is not authoritative absence. Registry cleanup may use only an explicit provider success contract or the provider's exact not-found evidence. If a provider effect is idempotent and exact not-found can be re-observed, arbitrary execution stages and crash-window checkpoints are unnecessary: recovery repeats observation and converges from last committed facts.
## Workdir removal
Workdir removal is one durable side-effect operation in the Workspace Server DB. It binds the Workspace, Workdir, owning Runtime, Repository/materialization identity, source actor, stable intent fingerprint, lifecycle, retry metadata, and bounded result. Runtime URL, provider handle, host path, credentials, and caller-selected Runtime are not operation inputs.
Each attempt:
1. resolves or revalidates the persisted same-Workspace Workdir, Runtime, Repository, and materialization identity;
2. checks current attachments, attachment reservations, current assignment occupancy, retention/cleanup holds, and pending materialization authority; a failed Workdir-create retry must atomically return to `pending` before provider work and is rejected while removal is pending;
3. retains dirty, occupied, blocked, or otherwise unknown Workdirs without detaching a Worker or forcing deletion;
4. observes the owning Runtime/provider and calls its existing Workdir cleanup only for an eligible clean Workdir;
5. treats only successful provider cleanup or exact `working_directory_not_found` as removal evidence;
6. deletes the Backend Workdir registry row and commits the operation's `completed`/`removed` result in one SQLite transaction.
A provider error leaves the registry intact and records a bounded `attention_required` result with explicit retryability. Startup recovery lists `pending` and retryable `failed` operations, then executes this same path after rereading live authority. `WorkdirDelete`, Workspace REST removal, Runtime cleanup execution, and recovery must not maintain separate inline provider-delete paths.
The public request contains only `working_directory_id` plus a bounded reason. The public result contains only the Workdir ID, `removed | retained | attention_required`, retryability, and an optional bounded failure category. Internal operation identifiers, checkpoints, provider paths, and credentials are not public DTO fields.
## Resilience boundary ## Resilience boundary
This pattern covers duplicate requests, returned failures, timeouts and unknown outcomes, known partial-completion contracts, and restart recovery from committed facts. It does not provide general exactly-once execution or claim recovery from every instruction-boundary panic, process kill, machine loss, or power failure. A stronger provider guarantee requires a separately specified protocol, checkpoint ordering, reconciliation evidence, and tests. These rules cover normal product retry and recovery boundaries: duplicate
requests, returned errors, timeouts, known partial-completion outcomes, process
restart from the last committed facts, and retries of provider operations with
an explicit idempotency or observation contract.
They do not require the system to survive an unexpected stop at every point
while Rust code is executing. A panic, abort, process kill, machine loss, or
power failure may occur between an external side effect and its next durable
checkpoint. Yoi does not attempt to close every such instruction-level crash
window by writing a stage before and after every `await`, function, or provider
call.
Consequently:
- do not claim general exactly-once execution;
- do not introduce write-ahead stages solely to model arbitrary Rust
control-flow interruption;
- rely on SQLite transaction atomicity for work inside one database transaction;
- prefer provider idempotency, compare-and-swap, stable resource identity, and
authoritative observation for external effects;
- when an uncovered crash window cannot be reconciled automatically, retain the
last committed facts and surface an `unknown` or attention-required
disposition rather than guessing that the side effect did or did not happen.
A domain may require a stronger crash-consistency contract for a specific
destructive or security-sensitive effect. That requirement must be explicit and
must define the provider protocol, checkpoint ordering, replay behavior, and
reconciliation evidence. It is not implied by calling a record a durable
operation.
## Classify the record before adding state
Not every record containing an `operation_id` is a state machine. Use one of the
following shapes.
### Atomic idempotency ledger
Use an idempotency ledger when all authoritative mutations and result recording
commit in one database transaction.
The record normally contains:
- Workspace and operation identity;
- a fingerprint of stable caller intent;
- the created resource or result identity;
- the committed revision and timestamp where relevant.
It does not need `pending`, `executing`, or intermediate stages. An exact retry
returns the recorded result. Reusing the same operation identity with a
different fingerprint fails.
Repository secret mutation results, Workspace resource creation results, and
transactionally appended domain events are examples of this shape.
### Reservation
Use a reservation when an identity or exclusive right must exist before a later
binding can complete.
Persist factual transitions such as:
- the reserved resource identity;
- the immutable request fingerprint and authority snapshot;
- the concrete resource or assignment bound to the reservation;
- reservation expiry or release evidence when the contract requires it.
Do not model internal dispatch, validation, construction, or callback steps as
reservation states. A nullable result binding or a small `reserved | created`
state can be sufficient when those values correspond to real authority facts.
### Durable side-effect operation
Use a durable side-effect operation when work crosses a database/provider
boundary and a retry needs durable intent or result evidence.
The default lifecycle is deliberately small:
```text
pending -> completed
pending -> failed
failed -> pending # only when the domain explicitly permits retry
```
Existing code may use `succeeded` for the successful terminal value; new naming
should prefer `completed`. Do not rewrite applied migrations or historical audit
text only to normalize that word.
The operation should contain:
- stable operation identity and request fingerprint;
- immutable resolved authority needed by an exact retry;
- preallocated resource identity where it prevents duplicate creation;
- only the necessary irreversible checkpoints;
- bounded failure evidence;
- the final result and domain disposition.
`pending` means that the intent remains open and current authority must be
reread before progress. It does not identify which Rust function should execute
next. `failed` records the latest terminal attempt outcome; retryability is an
explicit domain rule, not something inferred from the word. `completed` means
the operation's required result and evidence are durably committed.
### Parent workflow
A parent workflow coordinates domain operations but does not duplicate their
lifecycle.
Persist:
- the parent intent and fencing authority;
- stable child operation identities;
- the final workflow result or disposition;
- bounded attention or decision evidence.
Read child state from the child authority. Do not copy child states, provider
stages, Worker status, attachment status, or Workdir status into a second parent
state machine. A parent cleanup workflow will often need only
`pending | completed`; child failure remains on the child operation and appears
in the parent as current attention metadata.
Creating or binding a child must itself be idempotent. Prefer a deterministic
child operation identity or persist the child reference atomically with the
parent decision so a retry cannot create siblings for one intent.
## Checkpoint rules
A checkpoint records a fact that changes retry semantics. It is not a progress
notification.
Add a checkpoint only when all of the following hold:
1. A side effect may already have occurred outside the current transaction.
2. Current authority cannot derive the fact reliably enough for safe retry, or
repeating the effect is not safe under the provider contract.
3. The retry algorithm changes after the fact is committed.
4. Tests can exercise behavior before and after the checkpoint.
Prefer factual fields over stage names:
- `provider_deleted_at` is evidence that provider deletion succeeded;
- `child_operation_id` binds delegated work;
- `result_revision` identifies the committed result;
- `target_ref_after` records verified merge evidence.
Avoid fields such as `validating`, `closing_session`, `detaching`,
`deleting_registry`, or `finalizing`. Those names describe code location, not
durable authority. If those steps are safe to rerun or their result can be read
from Worker, attachment, Workdir, repository, or provider authority, they are
not checkpoints.
A checkpoint must never claim more than the authority that produced it. For
example, sending a provider request is not proof that provider deletion
completed, and receiving a Worker notification is not proof that a Ticket or
cleanup workflow completed.
## State, failure, blockers, and disposition are separate
Do not overload one enum with unrelated dimensions.
- **Operation state** says whether the intent is open, completed, or has a
recorded failed attempt.
- **Failure evidence** records a bounded category, timestamp, and safe
diagnostic detail for the latest failure.
- **Blockers and eligibility** are normally derived by rereading current
authority. Persist them only as audit or attention evidence, not as a
substitute for live validation.
- **Disposition** records what the domain decided to retain, delete, release,
tombstone, abandon, or leave unknown.
- **Attention metadata** explains why automated progress currently cannot
continue and what authority must change.
Values such as `blocked`, `executing`, `stale`, `dirty`, `retained`, and
`deleted` therefore do not all belong in one operation-state enum. Some are
derived conditions, some describe transient execution, and some are domain
results.
Before every retry or side effect, reread live authority and revalidate its
fence. A previously recorded blocker does not prove that the operation remains
blocked, and a previously unblocked operation does not retain permission after
assignment, ownership, revision, or attachment authority changes.
## Identity and fingerprinting
Every externally retryable operation has a stable identity in its owning
Workspace or authority scope. The operation fingerprint represents stable caller
intent, not generated results or mutable observations.
Include inputs whose change would mean a different requested operation. Exclude:
- generated resource IDs when the Server allocates and persists them as the
result;
- timestamps assigned by the Server;
- retry counters and diagnostics;
- current provider observations that are expected to change;
- secret bytes and credential material.
Resolved authority snapshots may be stored separately from the caller
fingerprint. An exact retry uses the persisted snapshot where replay convergence
requires it; a new operation resolves current authority. Unknown, foreign, or
conflicting operation identity fails closed.
## Transactions and external providers
Keep database work in one transaction whenever the owning authority and result
live in the same database. Do not create a durable operation merely to split a
transaction that can remain atomic.
When an external provider is involved:
1. reserve stable intent and identity if retry needs them;
2. invoke the provider with the strongest available idempotency, expected-old
revision, or stable resource key;
3. verify the provider result through authoritative response or observation;
4. commit only the checkpoint or result evidence that changes retry behavior;
5. on retry, reread both the operation and current domain/provider authority
before acting.
Compensation is a domain operation, not an invisible `finally` block. If
compensation has its own external side effects or retry lifecycle, give it a
stable child operation identity rather than expanding the parent into a list of
cleanup stages.
## Workdir removal application
Workdir removal is one durable side-effect operation in the Workspace Server
DB. It binds the Workspace, Workdir, owning Runtime,
Repository/materialization identity, source actor, stable intent fingerprint,
lifecycle, retry metadata, and bounded result. Runtime URL, provider handle,
host path, credentials, and caller-selected Runtime are not operation inputs.
A durable one-pending-operation constraint plus an atomic attempt claim prevents
concurrent callers from entering the provider side effect for the same Workdir;
the in-process resource lock is an additional serialization layer, not the sole
authority. Each active attempt persists the Server process ID and process-start
marker. Recovery reclaims only an owner proven missing or replaced; a live or
unobservable owner is never stolen. The reclaim transaction compare-and-set
checks the exact proved owner snapshot and attempt count so stale orphan proof
cannot overwrite a newer live claim.
Each attempt:
1. resolves or revalidates the persisted same-Workspace Workdir, Runtime,
Repository, and materialization identity;
2. checks current attachments, attachment reservations, current assignment
occupancy, retention/cleanup holds, and pending materialization authority; a
failed Workdir-create retry must atomically return to `pending` before
provider work and is rejected while removal is pending;
3. retains dirty, occupied, blocked, or otherwise unknown Workdirs without
detaching a Worker or forcing deletion;
4. observes the owning Runtime/provider and calls its existing Workdir cleanup
only for an eligible clean Workdir;
5. treats only successful provider cleanup or exact
`working_directory_not_found` as removal evidence;
6. deletes the Backend Workdir registry row and commits the operation's
`completed`/`removed` result in one SQLite transaction.
A provider error leaves the registry intact and records a bounded
`attention_required` result with explicit retryability. Startup recovery lists
`pending` and retryable `failed` operations, then executes this same path after
rereading live authority. `WorkdirDelete`, Workspace REST removal, Runtime
cleanup execution, and recovery must not maintain separate inline
provider-delete paths.
The public request contains only `working_directory_id` plus a bounded reason.
The public result contains only the Workdir ID,
`removed | retained | attention_required`, retryability, and an optional bounded
failure category. Internal operation identifiers, checkpoints, provider paths,
and credentials are not public DTO fields.
## Diagnostics and audit
Persist bounded error categories and identifiers needed to investigate or retry.
Do not persist credentials, provider handles, raw command output, raw prompts,
full session transcripts, or host paths in ordinary operation diagnostics.
Attempt counts, last-attempt timestamps, and safe provider categories may help
operations, but they are telemetry and evidence rather than lifecycle authority.
Logs may describe detailed execution stages; the durable record should remain
centered on intent, checkpoints, result, and disposition.
## Applying this rule
For a new or materially changed operation:
1. identify the owning authority and transaction boundary;
2. classify it as an atomic ledger, reservation, durable side-effect operation,
or parent workflow;
3. define stable identity, fingerprint, and exact-retry behavior;
4. list external side effects and decide which are idempotent or authoritatively
observable;
5. add only checkpoints that change retry behavior;
6. keep child operation state in the child authority;
7. separate failure, blocker, attention, and disposition from lifecycle state;
8. state the unsupported crash windows honestly;
9. test fingerprint conflict, exact retry, authority revalidation, checkpoint
replay, and result/disposition projection as applicable.
Existing operation schemas need not be rewritten solely for vocabulary
consistency. When an operation is changed for functional reasons, use this
classification to remove derived or control-flow stages rather than adding
another special-case lifecycle.
+2 -1
View File
@@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev", "dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -20,6 +20,7 @@
"@codemirror/language": "npm:@codemirror/language@6.12.4", "@codemirror/language": "npm:@codemirror/language@6.12.4",
"@codemirror/state": "npm:@codemirror/state@6.7.1", "@codemirror/state": "npm:@codemirror/state@6.7.1",
"@codemirror/view": "npm:@codemirror/view@6.43.8", "@codemirror/view": "npm:@codemirror/view@6.43.8",
"@lezer/common": "npm:@lezer/common@1.5.2",
"@lezer/highlight": "npm:@lezer/highlight@1.2.3", "@lezer/highlight": "npm:@lezer/highlight@1.2.3",
"decodal-codemirror": "npm:decodal-codemirror@0.3.0", "decodal-codemirror": "npm:decodal-codemirror@0.3.0",
"clsx": "npm:clsx@2.1.1", "clsx": "npm:clsx@2.1.1",
+2
View File
@@ -9,6 +9,7 @@
"npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/state@6.7.1": "6.7.1",
"npm:@codemirror/view@6.43.8": "6.43.8", "npm:@codemirror/view@6.43.8": "6.43.8",
"npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0",
"npm:@lezer/common@1.5.2": "1.5.2",
"npm:@lezer/highlight@1.2.3": "1.2.3", "npm:@lezer/highlight@1.2.3": "1.2.3",
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
@@ -1026,6 +1027,7 @@
"npm:@codemirror/language@6.12.4", "npm:@codemirror/language@6.12.4",
"npm:@codemirror/state@6.7.1", "npm:@codemirror/state@6.7.1",
"npm:@codemirror/view@6.43.8", "npm:@codemirror/view@6.43.8",
"npm:@lezer/common@1.5.2",
"npm:@lezer/highlight@1.2.3", "npm:@lezer/highlight@1.2.3",
"npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/adapter-static@3.0.9",
"npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/kit@2.49.4",
@@ -43,17 +43,21 @@ Deno.test("root layout leaves Workspace selection explicit", async () => {
new URL("./../../../routes/+layout.ts", import.meta.url), new URL("./../../../routes/+layout.ts", import.meta.url),
); );
assert( assert(
!layout.includes("/api/workspace") && !layout.includes('"/api/workspace"') &&
!layout.includes("redirect(") && !layout.includes("redirect(") &&
layout.includes("Workspace selection is explicit"), layout.includes("listWorkspaces(fetch)") &&
"root layout must not infer or redirect to a singleton Workspace", layout.includes("accessibleWorkspaces"),
"root layout may list accessible Workspaces but must not infer or redirect to a singleton Workspace",
); );
}); });
Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => { Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => {
const [layout, multiplexer] = await Promise.all([ const [layout, multiplexer] = await Promise.all([
Deno.readTextFile( Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), new URL(
"./../../../routes/w/[workspaceId]/+layout.svelte",
import.meta.url,
),
), ),
Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)), Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)),
]); ]);
@@ -328,10 +328,12 @@
</header> </header>
<DecodalSourceEditor <DecodalSourceEditor
value={source} value={source}
readonly={!selectedPath || busy} readonly={!selectedPath || busy || !analysisReady}
fixedSchemaWrapper={mainSelected} fixedSchemaWrapper={mainSelected}
onChange={(value) => source = value} onChange={(value) => source = value}
onComplete={(value, offset, explicit) => toolchain?.complete(selectedPath, value, offset, explicit) ?? Promise.resolve(null)} onComplete={(value, offset, explicit) => analysisReady && toolchain
? toolchain.complete(selectedPath, value, offset, explicit)
: Promise.resolve(null)}
/> />
<p class="config-source-status" aria-live="polite">{status}</p> <p class="config-source-status" aria-live="polite">{status}</p>
{#if conflict} {#if conflict}
@@ -12,13 +12,18 @@ type ConfigSourceCompletionItem = {
priority: number; priority: number;
}; };
export function shouldStartCompletionAfterTyping(
insertedText: string,
): boolean {
return /\S/u.test(insertedText);
}
export function toCodeMirrorCompletion( export function toCodeMirrorCompletion(
source: string,
result: ConfigSourceCompletionResult | null, result: ConfigSourceCompletionResult | null,
): CompletionResult | null { ): CompletionResult | null {
if (!result) return null; if (!result) return null;
return { return {
from: utf8ByteOffsetToUtf16(source, result.from), from: result.from,
options: result.items.map((item) => ({ options: result.items.map((item) => ({
label: item.label, label: item.label,
type: item.kind, type: item.kind,
@@ -27,31 +32,3 @@ export function toCodeMirrorCompletion(
})), })),
}; };
} }
function utf8ByteOffsetToUtf16(source: string, byteOffset: number): number {
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
throw new RangeError(
"completion byte offset must be a non-negative integer",
);
}
let bytes = 0;
let utf16 = 0;
for (const character of source) {
if (bytes === byteOffset) return utf16;
const codePoint = character.codePointAt(0)!;
bytes += codePoint <= 0x7f
? 1
: codePoint <= 0x7ff
? 2
: codePoint <= 0xffff
? 3
: 4;
utf16 += character.length;
if (bytes > byteOffset) {
throw new RangeError("completion byte offset splits a UTF-8 code point");
}
}
if (bytes === byteOffset) return utf16;
throw new RangeError("completion byte offset is outside the source");
}
@@ -79,7 +79,7 @@ export class ConfigSourceToolchain {
utf16Offset, utf16Offset,
explicit, explicit,
}); });
return toCodeMirrorCompletion(source, result); return toCodeMirrorCompletion(result);
} }
format(source: string): Promise<string> { format(source: string): Promise<string> {
return this.#request({ kind: "format", source }); return this.#request({ kind: "format", source });
@@ -0,0 +1,7 @@
export const CODEMIRROR_VITE_DEDUPE = [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
];
@@ -37,12 +37,21 @@
type ComposerPaste, type ComposerPaste,
type ComposerTextPaste, type ComposerTextPaste,
} from "$lib/workspace/console/composer-draft.ts"; } from "$lib/workspace/console/composer-draft.ts";
import {
ComposerHistory,
loadComposerHistory,
saveComposerHistory,
shouldBrowseComposerHistory,
type ComposerHistoryDirection,
type ComposerHistoryEntry,
} from "$lib/workspace/console/composer-history.ts";
import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts"; import { shouldSubmitChatKey } from "$lib/workspace/console/chat-submit.ts";
interface Props { interface Props {
disabled?: boolean; disabled?: boolean;
ariaLabel?: string; ariaLabel?: string;
ariaKeyShortcuts?: string; ariaKeyShortcuts?: string;
historyScope: string;
onchange?: (snapshot: ComposerDraftSnapshot) => void; onchange?: (snapshot: ComposerDraftSnapshot) => void;
onkeydown?: (event: KeyboardEvent) => void; onkeydown?: (event: KeyboardEvent) => void;
onsubmit?: () => void; onsubmit?: () => void;
@@ -52,6 +61,7 @@
disabled = false, disabled = false,
ariaLabel = "Message", ariaLabel = "Message",
ariaKeyShortcuts = "Meta+Enter Control+Enter", ariaKeyShortcuts = "Meta+Enter Control+Enter",
historyScope,
onchange, onchange,
onkeydown, onkeydown,
onsubmit, onsubmit,
@@ -59,6 +69,8 @@
let mountElement: HTMLDivElement; let mountElement: HTMLDivElement;
let view: EditorView | null = null; let view: EditorView | null = null;
let composerHistory = new ComposerHistory();
let restoringHistory = false;
let nextPasteId = 1; let nextPasteId = 1;
let nextPasteKey = 1; let nextPasteKey = 1;
const editable = new Compartment(); const editable = new Compartment();
@@ -179,6 +191,40 @@
onchange?.(currentSnapshot()); onchange?.(currentSnapshot());
} }
function historyEntry(state: EditorState): ComposerHistoryEntry {
const snapshot = currentSnapshot(state);
return {
segments: snapshot.segments,
preserveExactText: snapshot.textPastes.length > 0,
};
}
function browseHistory(currentView: EditorView, direction: ComposerHistoryDirection): boolean {
const selection = currentView.state.selection.main;
const cursorLine = currentView.state.doc.lineAt(selection.head).number;
if (!shouldBrowseComposerHistory({
direction,
cursorLine,
lineCount: currentView.state.doc.lines,
selectionEmpty: selection.empty,
readOnly: currentView.state.readOnly,
composing: currentView.composing,
})) return false;
const entry = direction === "older"
? composerHistory.previous(historyEntry(currentView.state))
: composerHistory.next();
if (!entry) return false;
restoringHistory = true;
try {
restoreSegments(entry.segments, entry.preserveExactText);
} finally {
restoringHistory = false;
}
return true;
}
function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void { function insertPasteChip(content: string, measurement: ComposerPasteMeasurement): void {
if (!view) return; if (!view) return;
const selection = view.state.selection.main; const selection = view.state.selection.main;
@@ -273,6 +319,10 @@
return true; return true;
} }
$effect(() => {
composerHistory = loadComposerHistory(localStorage, historyScope);
});
onMount(() => { onMount(() => {
view = new EditorView({ view = new EditorView({
parent: mountElement, parent: mountElement,
@@ -292,6 +342,14 @@
key: "Mod-y", key: "Mod-y",
run: (currentView) => currentView.state.readOnly, run: (currentView) => currentView.state.readOnly,
}, },
{
key: "ArrowUp",
run: (currentView) => browseHistory(currentView, "older"),
},
{
key: "ArrowDown",
run: (currentView) => browseHistory(currentView, "newer"),
},
{ {
key: "Backspace", key: "Backspace",
run: (currentView) => run: (currentView) =>
@@ -319,6 +377,7 @@
spellcheck: "true", spellcheck: "true",
}), }),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged && !restoringHistory) composerHistory.cancelNavigation();
if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) { if (update.docChanged || update.transactions.some((tx) => tx.effects.length > 0)) {
emitChange(); emitChange();
} }
@@ -396,6 +455,16 @@
return currentSnapshot(); return currentSnapshot();
} }
export function recordHistory(value: ComposerDraftSnapshot): void {
const entry: ComposerHistoryEntry = {
segments: value.segments,
preserveExactText: value.textPastes.length > 0,
};
if (composerHistory.record(entry)) {
saveComposerHistory(localStorage, historyScope, composerHistory);
}
}
export function focus(): void { export function focus(): void {
view?.focus(); view?.focus();
} }
@@ -0,0 +1,193 @@
import type { Segment } from "$lib/generated/protocol";
export const COMPOSER_HISTORY_LIMIT = 30;
const COMPOSER_HISTORY_VERSION = 1;
const COMPOSER_HISTORY_KEY_PREFIX = "yoi.composer-history.v1.workspace.";
export type ComposerHistoryDirection = "older" | "newer";
export type ComposerHistoryCursor = {
direction: ComposerHistoryDirection;
cursorLine: number;
lineCount: number;
selectionEmpty: boolean;
readOnly: boolean;
composing: boolean;
};
export type ComposerHistoryEntry = {
segments: Segment[];
preserveExactText: boolean;
};
type StoredComposerHistory = {
version: typeof COMPOSER_HISTORY_VERSION;
entries: ComposerHistoryEntry[];
};
type ComposerHistoryStorage = Pick<Storage, "getItem" | "setItem">;
function cloneEntry(entry: ComposerHistoryEntry): ComposerHistoryEntry {
return {
segments: entry.segments.map((segment) => ({ ...segment })) as Segment[],
preserveExactText: entry.preserveExactText,
};
}
function isSegment(value: unknown): value is Segment {
if (!value || typeof value !== "object") return false;
const segment = value as Record<string, unknown>;
if (segment.kind === "text") return typeof segment.content === "string";
if (segment.kind === "paste") {
return typeof segment.content === "string" &&
typeof segment.id === "number" &&
typeof segment.chars === "number" &&
typeof segment.lines === "number";
}
if (segment.kind === "file_ref") return typeof segment.path === "string";
return false;
}
function isHistoryEntry(value: unknown): value is ComposerHistoryEntry {
if (!value || typeof value !== "object") return false;
const entry = value as Record<string, unknown>;
return Array.isArray(entry.segments) &&
entry.segments.every(isSegment) &&
typeof entry.preserveExactText === "boolean";
}
function isBlankEntry(entry: ComposerHistoryEntry): boolean {
return entry.segments.length === 0 ||
entry.segments.every((segment) =>
segment.kind === "text" && segment.content.trim().length === 0
);
}
function sameEntry(
left: ComposerHistoryEntry,
right: ComposerHistoryEntry,
): boolean {
return JSON.stringify(left) === JSON.stringify(right);
}
export function shouldBrowseComposerHistory(
cursor: ComposerHistoryCursor,
): boolean {
if (cursor.readOnly || cursor.composing || !cursor.selectionEmpty) {
return false;
}
return cursor.direction === "older"
? cursor.cursorLine === 1
: cursor.cursorLine === cursor.lineCount;
}
export function composerHistoryStorageKey(workspaceId: string): string {
return `${COMPOSER_HISTORY_KEY_PREFIX}${encodeURIComponent(workspaceId)}`;
}
export class ComposerHistory {
#entries: ComposerHistoryEntry[];
#index: number | null = null;
#draft: ComposerHistoryEntry | null = null;
constructor(entries: ComposerHistoryEntry[] = []) {
this.#entries = [];
for (const entry of entries) this.record(entry);
}
get entries(): ComposerHistoryEntry[] {
return this.#entries.map(cloneEntry);
}
get browsing(): boolean {
return this.#index !== null;
}
record(entry: ComposerHistoryEntry): boolean {
if (isBlankEntry(entry)) {
this.cancelNavigation();
return false;
}
const last = this.#entries.at(-1);
if (last && sameEntry(last, entry)) {
this.cancelNavigation();
return false;
}
this.#entries.push(cloneEntry(entry));
if (this.#entries.length > COMPOSER_HISTORY_LIMIT) {
this.#entries.splice(0, this.#entries.length - COMPOSER_HISTORY_LIMIT);
}
this.cancelNavigation();
return true;
}
previous(draft: ComposerHistoryEntry): ComposerHistoryEntry | null {
if (this.#entries.length === 0) return null;
if (this.#index === null) {
this.#draft = cloneEntry(draft);
this.#index = this.#entries.length - 1;
} else if (this.#index > 0) {
this.#index -= 1;
}
return cloneEntry(this.#entries[this.#index]);
}
next(): ComposerHistoryEntry | null {
if (this.#index === null) return null;
if (this.#index < this.#entries.length - 1) {
this.#index += 1;
return cloneEntry(this.#entries[this.#index]);
}
const draft = this.#draft
? cloneEntry(this.#draft)
: { segments: [], preserveExactText: false };
this.cancelNavigation();
return draft;
}
cancelNavigation(): void {
this.#index = null;
this.#draft = null;
}
}
export function loadComposerHistory(
storage: ComposerHistoryStorage,
workspaceId: string,
): ComposerHistory {
try {
const raw = storage.getItem(composerHistoryStorageKey(workspaceId));
if (!raw) return new ComposerHistory();
const value = JSON.parse(raw) as unknown;
if (!value || typeof value !== "object") return new ComposerHistory();
const stored = value as Partial<StoredComposerHistory>;
if (
stored.version !== COMPOSER_HISTORY_VERSION ||
!Array.isArray(stored.entries)
) {
return new ComposerHistory();
}
return new ComposerHistory(stored.entries.filter(isHistoryEntry));
} catch {
return new ComposerHistory();
}
}
export function saveComposerHistory(
storage: ComposerHistoryStorage,
workspaceId: string,
history: ComposerHistory,
): void {
const value: StoredComposerHistory = {
version: COMPOSER_HISTORY_VERSION,
entries: history.entries,
};
try {
storage.setItem(
composerHistoryStorageKey(workspaceId),
JSON.stringify(value),
);
} catch {
// History is an optional convenience; storage failures must not block input submission.
}
}
@@ -358,9 +358,10 @@ Deno.test("root layout keeps Workspace selection explicit", async () => {
assert( assert(
layoutLoad.includes("export const load") && layoutLoad.includes("export const load") &&
layoutLoad.includes("() => ({})") && layoutLoad.includes("listWorkspaces(fetch)") &&
layoutLoad.includes("accessibleWorkspaces") &&
!layoutLoad.includes("scopedCompatibilityRoute") && !layoutLoad.includes("scopedCompatibilityRoute") &&
!layoutLoad.includes("/api/workspace") && !layoutLoad.includes('"/api/workspace"') &&
!layoutLoad.includes("workspaceRoute") && !layoutLoad.includes("workspaceRoute") &&
!layoutLoad.includes("redirect("), !layoutLoad.includes("redirect("),
"root layout should not infer, bootstrap, or redirect through a singleton Workspace", "root layout should not infer, bootstrap, or redirect through a singleton Workspace",
@@ -812,6 +813,9 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const globalSidebar = await Deno.readTextFile( const globalSidebar = await Deno.readTextFile(
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url), new URL("../sidebar/GlobalSidebar.svelte", import.meta.url),
); );
const globalNavSections = await Deno.readTextFile(
new URL("../sidebar/GlobalNavSections.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile( const sidebarFrame = await Deno.readTextFile(
new URL("../sidebar/SidebarFrame.svelte", import.meta.url), new URL("../sidebar/SidebarFrame.svelte", import.meta.url),
); );
@@ -874,13 +878,23 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Root layout chrome should keep GlobalSidebar as the root slot owner while account navigation stays in the header", "Root layout chrome should keep GlobalSidebar as the root slot owner while account navigation stays in the header",
); );
assert( assert(
globalSidebar.includes('aria-label="Global pages"') && globalSidebar.includes("GlobalNavSections") &&
!globalSidebar.includes('<p class="sidebar-section-label">') && globalNavSections.includes('aria-label="Global pages"') &&
globalSidebar.includes("/account") && !globalNavSections.includes('<p class="sidebar-section-label">') &&
globalSidebar.includes("/login/device") && globalNavSections.includes('"/account"') &&
!globalSidebar.includes("Tickets") && globalNavSections.includes('"/login/device"') &&
!globalSidebar.includes("Repositories"), !globalNavSections.includes('label: "Workspaces"') &&
"Root default sidebar should contain only global navigation, not workspace-scoped sections", globalNavSections.includes("sidebar-nav-section--category") &&
globalNavSections.includes("global-workspaces-heading") &&
globalNavSections.includes("workspaces") &&
globalNavSections.includes("workspace.display_name") &&
globalNavSections.includes("workspaceHref(workspace.workspace_id)") &&
rootLayout.includes("workspaces={data.accessibleWorkspaces}") &&
rootLayout.includes("workspaceError={data.workspaceCatalogError}") &&
rootLayoutLoad.includes("listWorkspaces(fetch)") &&
!globalNavSections.includes("Tickets") &&
!globalNavSections.includes("Repositories"),
"Top-level sidebar should replace the Workspaces button with a categorized accessible Workspace list below the remaining global navigation",
); );
assert( assert(
workspaceLayout.includes("{#snippet workspaceSidebar()}") && workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
@@ -918,7 +932,8 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
); );
assert( assert(
rootLayoutLoad.includes("export const load") && rootLayoutLoad.includes("export const load") &&
rootLayoutLoad.includes("() => ({})") && rootLayoutLoad.includes("listWorkspaces(fetch)") &&
rootLayoutLoad.includes("accessibleWorkspaces") &&
!rootLayoutLoad.includes("workspaceRoute") && !rootLayoutLoad.includes("workspaceRoute") &&
!rootLayoutLoad.includes("redirect("), !rootLayoutLoad.includes("redirect("),
"Root layout should leave account and device-login routes public by avoiding Workspace redirects entirely", "Root layout should leave account and device-login routes public by avoiding Workspace redirects entirely",
@@ -1,11 +1,20 @@
<script lang="ts"> <script lang="ts">
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete'; import {
autocompletion,
closeCompletion,
completionKeymap,
completionStatus,
startCompletion,
type CompletionContext,
type CompletionResult,
} from '@codemirror/autocomplete';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state'; import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection } from '@codemirror/view'; import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, type ViewUpdate } from '@codemirror/view';
import { tags } from '@lezer/highlight'; import { tags } from '@lezer/highlight';
import { decodal } from 'decodal-codemirror'; import { decodal } from 'decodal-codemirror';
import { shouldStartCompletionAfterTyping } from '$lib/workspace/config-source/completion.ts';
import { import {
fixedSchemaWrapperExtension, fixedSchemaWrapperExtension,
moveSelectionIntoFixedWrapper, moveSelectionIntoFixedWrapper,
@@ -69,6 +78,39 @@
'.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' }, '.cm-tooltip-autocomplete > ul > li[aria-selected]': { background: 'var(--interactive-selected)', color: 'var(--text-strong)' },
}); });
function scheduleCompletion(editor: EditorView) {
queueMicrotask(() => {
if (
editor.hasFocus &&
!editor.state.facet(EditorState.readOnly) &&
completionStatus(editor.state) === null
) {
startCompletion(editor);
}
});
}
function typedText(update: ViewUpdate): string | null {
let foundTyping = false;
let insertedText = '';
for (const transaction of update.transactions) {
if (!transaction.isUserEvent('input.type')) continue;
foundTyping = true;
transaction.changes.iterChanges((_fromA, _toA, _fromB, _toB, inserted) => {
insertedText += inserted.toString();
});
}
return foundTyping ? insertedText : null;
}
function dismissCompletion(editor: EditorView) {
queueMicrotask(() => {
if (completionStatus(editor.state) !== null) closeCompletion(editor);
});
}
const completionKeymapWithoutEnter = completionKeymap.filter((binding) => binding.key !== 'Enter');
$effect(() => { $effect(() => {
if (!host || untrack(() => view)) return; if (!host || untrack(() => view)) return;
const initialValue = untrack(() => value); const initialValue = untrack(() => value);
@@ -86,11 +128,17 @@
highlightActiveLine(), highlightActiveLine(),
decodal({ highlight: false }), decodal({ highlight: false }),
syntaxHighlighting(syntaxTheme), syntaxHighlighting(syntaxTheme),
...(handleComplete ? [autocompletion({ override: [async (context: CompletionContext) => { autocompletion({
const doc = context.state.doc.toString(); activateOnTyping: false,
return await handleComplete(doc, context.pos, context.explicit); override: [
}] })] : []), async (context: CompletionContext) => {
keymap.of([]), if (!handleComplete) return null;
const doc = context.state.doc.toString();
return await handleComplete(doc, context.pos, context.explicit);
},
],
}),
keymap.of(completionKeymapWithoutEnter),
fixedSchemaWrapperCompartment.of( fixedSchemaWrapperCompartment.of(
initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [], initialFixedSchemaWrapper ? fixedSchemaWrapperExtension() : [],
), ),
@@ -99,7 +147,17 @@
EditorView.editable.of(!initialReadonly), EditorView.editable.of(!initialReadonly),
]), ]),
EditorView.updateListener.of((update) => { EditorView.updateListener.of((update) => {
if (update.docChanged) handleChange(update.state.doc.toString()); if (update.docChanged) {
handleChange(update.state.doc.toString());
const insertedText = typedText(update);
if (insertedText !== null) {
if (shouldStartCompletionAfterTyping(insertedText)) {
scheduleCompletion(update.view);
} else {
dismissCompletion(update.view);
}
}
}
}), }),
theme, theme,
], ],
@@ -0,0 +1,70 @@
<script lang="ts">
import type { WorkspaceCatalogRecord } from "$lib/workspace/api/workspace-catalog";
type Props = {
currentPath: string;
workspaces?: WorkspaceCatalogRecord[] | null;
workspaceError?: string | null;
};
let {
currentPath,
workspaces = null,
workspaceError = null,
}: Props = $props();
const globalItems = [
{ href: "/#workspace-create-title", label: "Create Workspace" },
{ href: "/account", label: "Account" },
{ href: "/login/device", label: "Device Login" },
];
function workspaceHref(workspaceId: string): string {
return `/w/${encodeURIComponent(workspaceId)}`;
}
</script>
<nav class="sidebar-sections" aria-label="Global pages">
<section class="sidebar-nav-section">
<div class="sidebar-list">
{#each globalItems as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? "page" : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</div>
</section>
{#if workspaces !== null}
<section class="sidebar-nav-section sidebar-nav-section--category" aria-labelledby="global-workspaces-heading">
<h2 id="global-workspaces-heading" class="sidebar-nav-section__header">workspaces</h2>
{#if workspaceError}
<p class="workspace-status error">Workspace list unavailable.</p>
{/if}
{#if workspaces.length > 0}
<div class="sidebar-list">
{#each workspaces as workspace (workspace.workspace_id)}
{@const href = workspaceHref(workspace.workspace_id)}
<a
class="sidebar-link"
class:active={currentPath === href}
{href}
aria-current={currentPath === href ? "page" : undefined}
>
<span>{workspace.display_name}</span>
</a>
{/each}
</div>
{:else if !workspaceError}
<p class="workspace-status">No accessible Workspaces.</p>
{/if}
</section>
{/if}
</nav>
@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { WorkspaceCatalogRecord } from '../api/workspace-catalog';
import type { SidebarSnippet } from './context'; import type { SidebarSnippet } from './context';
import GlobalNavSections from './GlobalNavSections.svelte';
import './sidebar.css'; import './sidebar.css';
type Props = { type Props = {
currentPath: string; currentPath: string;
content?: SidebarSnippet | null; content?: SidebarSnippet | null;
workspaces?: WorkspaceCatalogRecord[];
workspaceError?: string | null;
}; };
const { currentPath, content = null }: Props = $props(); const {
currentPath,
const items = [ content = null,
{ href: '/', label: 'Workspaces' }, workspaces = [],
{ href: '/#workspace-create-title', label: 'Create Workspace' }, workspaceError = null,
{ href: '/account', label: 'Account' }, }: Props = $props();
{ href: '/login/device', label: 'Device Login' },
];
</script> </script>
{#if content} {#if content}
@@ -22,18 +24,7 @@
{:else} {:else}
<div class="global-sidebar" aria-label="Global navigation"> <div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section"> <div class="global-sidebar-section">
<nav class="sidebar-list" aria-label="Global pages"> <GlobalNavSections {currentPath} {workspaces} {workspaceError} />
{#each items as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? 'page' : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</nav>
</div> </div>
</div> </div>
{/if} {/if}
@@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import Spinner from '$lib/workspace/console/Spinner.svelte'; import Spinner from '$lib/workspace/console/Spinner.svelte';
import { workerConsoleHref } from '$lib/workspace/console/model'; import { workerConsoleHref } from '$lib/workspace/console/model';
import { pushWorkspaceAlert } from '$lib/workspace/alerts/store';
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from './worker-actions';
import { import {
workspaceWorkersStore, workspaceWorkersStore,
type SidebarWorker, type SidebarWorker,
@@ -8,6 +14,7 @@
import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers'; import { canShowWorkerInSidebar, sidebarWorkerActivity } from './workers';
const COLLAPSED_WORKER_COUNT = 6; const COLLAPSED_WORKER_COUNT = 6;
type WorkerActionKind = 'stop' | 'delete';
type Props = { type Props = {
currentPath?: string; currentPath?: string;
@@ -19,6 +26,10 @@
let error = $state<string | null>(null); let error = $state<string | null>(null);
let workers = $state<SidebarWorker[]>([]); let workers = $state<SidebarWorker[]>([]);
let expanded = $state(false); let expanded = $state(false);
let openWorkerKey = $state<string | null>(null);
let menuElement = $state<HTMLElement | null>(null);
let menuTrigger = $state<HTMLButtonElement | null>(null);
let busyAction = $state<{ workerKey: string; kind: WorkerActionKind } | null>(null);
let visibleWorkers = $derived( let visibleWorkers = $derived(
expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT), expanded ? workers : workers.slice(0, COLLAPSED_WORKER_COUNT),
); );
@@ -26,8 +37,95 @@
Math.max(0, workers.length - COLLAPSED_WORKER_COUNT), Math.max(0, workers.length - COLLAPSED_WORKER_COUNT),
); );
function workerKey(worker: SidebarWorker): string {
return `${worker.runtime_id}:${worker.worker_id}`;
}
function isBusy(worker: SidebarWorker, kind: WorkerActionKind): boolean {
return busyAction?.workerKey === workerKey(worker) && busyAction.kind === kind;
}
function closeWorkerMenu(restoreFocus = false) {
const trigger = menuTrigger;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
if (restoreFocus) queueMicrotask(() => trigger?.focus());
}
function toggleWorkerMenu(worker: SidebarWorker, trigger: HTMLButtonElement) {
const key = workerKey(worker);
if (openWorkerKey === key) {
closeWorkerMenu();
return;
}
openWorkerKey = key;
menuTrigger = trigger;
queueMicrotask(() => {
menuElement?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
});
}
function handleWindowClick(event: MouseEvent) {
if (!openWorkerKey) return;
const target = event.target;
const owner = target instanceof Element ? target.closest('[data-worker-actions]') : null;
if (owner?.getAttribute('data-worker-actions') !== openWorkerKey) closeWorkerMenu();
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !openWorkerKey) return;
event.preventDefault();
closeWorkerMenu(true);
}
async function stopWorker(worker: SidebarWorker) {
if (busyAction || !worker.capabilities.can_stop) return;
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'stop' };
try {
await stopSidebarWorker(workspaceId, worker);
workers = workers.map((item) =>
workerKey(item) === workerKey(worker)
? { ...item, state: 'stopped', capabilities: { ...item.capabilities, can_stop: false } }
: item
);
pushWorkspaceAlert('info', `${worker.display_name || worker.label} stopped`, {
title: 'Worker stopped',
});
} catch (cause) {
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker stop failed', {
title: 'Worker stop failed',
});
} finally {
busyAction = null;
}
}
async function deleteWorker(worker: SidebarWorker) {
if (busyAction || !canDeleteSidebarWorker(worker)) return;
closeWorkerMenu();
busyAction = { workerKey: workerKey(worker), kind: 'delete' };
try {
await deleteSidebarWorker(workspaceId, worker);
workers = workers.filter((item) => workerKey(item) !== workerKey(worker));
pushWorkspaceAlert('info', `${worker.display_name || worker.label} deleted`, {
title: 'Worker deleted',
});
} catch (cause) {
pushWorkspaceAlert('error', cause instanceof Error ? cause.message : 'Worker deletion failed', {
title: 'Worker deletion failed',
});
} finally {
busyAction = null;
}
}
$effect(() => { $effect(() => {
expanded = false; expanded = false;
openWorkerKey = null;
menuElement = null;
menuTrigger = null;
const subscription = workspaceWorkersStore(workspaceId); const subscription = workspaceWorkersStore(workspaceId);
return subscription.subscribe((state) => { return subscription.subscribe((state) => {
loading = state.loading; loading = state.loading;
@@ -37,6 +135,8 @@
}); });
</script> </script>
<svelte:window onclick={handleWindowClick} onkeydown={handleWindowKeydown} />
<section class="sidebar-nav-section" aria-labelledby="workers-heading"> <section class="sidebar-nav-section" aria-labelledby="workers-heading">
<div class="section-heading-row"> <div class="section-heading-row">
<h2 id="workers-heading"> <h2 id="workers-heading">
@@ -70,7 +170,9 @@
{#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)} {#each visibleWorkers as worker (`${worker.runtime_id}:${worker.worker_id}`)}
{@const href = workerConsoleHref(worker, workspaceId)} {@const href = workerConsoleHref(worker, workspaceId)}
{@const activity = sidebarWorkerActivity(worker)} {@const activity = sidebarWorkerActivity(worker)}
<li> {@const key = workerKey(worker)}
{@const label = worker.display_name || worker.label}
<li class="worker-nav-item" data-worker-actions={key}>
<a <a
href={href} href={href}
class="worker-nav-link" class="worker-nav-link"
@@ -86,11 +188,47 @@
<span class="worker-status-dot" aria-label="Idle"></span> <span class="worker-status-dot" aria-label="Idle"></span>
{/if} {/if}
</span> </span>
<span class="worker-nav-label">{worker.display_name || worker.label}</span> <span class="worker-nav-label">{label}</span>
<small class="worker-nav-meta"> <small class="worker-nav-meta">
{worker.repository_key ?? '—'}{worker.working_directory_id ?? '—'} {worker.repository_key ?? '—'}{worker.working_directory_id ?? '—'}
</small> </small>
</a> </a>
<button
class="worker-actions-trigger"
class:open={openWorkerKey === key}
type="button"
aria-label={`Actions for ${label}`}
aria-haspopup="menu"
aria-expanded={openWorkerKey === key}
onclick={(event) => toggleWorkerMenu(worker, event.currentTarget)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="5" cy="12" r="1.5"></circle>
<circle cx="12" cy="12" r="1.5"></circle>
<circle cx="19" cy="12" r="1.5"></circle>
</svg>
</button>
{#if openWorkerKey === key}
<div class="worker-actions-menu" role="menu" aria-label={`Actions for ${label}`} bind:this={menuElement}>
<button
type="button"
role="menuitem"
disabled={busyAction !== null || !worker.capabilities.can_stop}
onclick={() => stopWorker(worker)}
>
{isBusy(worker, 'stop') ? 'Stopping…' : 'Stop'}
</button>
<button
class="danger"
type="button"
role="menuitem"
disabled={busyAction !== null || !canDeleteSidebarWorker(worker)}
onclick={() => deleteWorker(worker)}
>
{isBusy(worker, 'delete') ? 'Deleting…' : 'Delete'}
</button>
</div>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -410,6 +410,91 @@
a.sidebar-link.active { a.sidebar-link.active {
background: var(--sidebar-item-active); background: var(--sidebar-item-active);
} }
.worker-nav-item {
position: relative;
min-width: 0;
}
.worker-actions-trigger {
position: absolute;
z-index: 2;
top: 0.35rem;
right: 0.35rem;
display: grid;
width: 1.75rem;
height: 1.75rem;
place-items: center;
border: 0;
border-radius: var(--radius-soft);
background: transparent;
color: var(--text-muted);
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
}
.worker-actions-trigger svg {
width: 1rem;
height: 1rem;
fill: currentColor;
}
.worker-nav-item:hover .worker-actions-trigger,
.worker-nav-item:focus-within .worker-actions-trigger,
.worker-actions-trigger.open {
opacity: 1;
pointer-events: auto;
}
.worker-actions-trigger:hover,
.worker-actions-trigger:focus-visible,
.worker-actions-trigger.open {
background: var(--interactive-hover);
color: var(--text-strong);
}
.worker-actions-trigger:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 1px;
}
.worker-actions-menu {
position: absolute;
z-index: 20;
top: 2rem;
right: 0.35rem;
display: grid;
min-width: 7rem;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-soft);
padding: var(--space-1);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
}
.worker-actions-menu button {
border: 0;
border-radius: var(--radius-soft);
padding: var(--space-2);
background: transparent;
color: var(--text-strong);
font: inherit;
font-size: 0.76rem;
font-weight: 650;
line-height: 1.2;
text-align: left;
cursor: pointer;
}
.worker-actions-menu button:hover:not(:disabled),
.worker-actions-menu button:focus-visible {
background: var(--sidebar-item-hover);
}
.worker-actions-menu button:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
.worker-actions-menu button.danger {
color: var(--danger);
}
.worker-actions-menu button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.worker-nav-link { .worker-nav-link {
display: grid; display: grid;
grid-template-columns: 0.75rem minmax(0, 1fr); grid-template-columns: 0.75rem minmax(0, 1fr);
@@ -418,6 +503,7 @@
row-gap: 0.1rem; row-gap: 0.1rem;
margin: 0.0625rem 0; margin: 0.0625rem 0;
padding: var(--space-2); padding: var(--space-2);
padding-right: 2.35rem;
border-radius: var(--radius-soft); border-radius: var(--radius-soft);
color: var(--text-muted); color: var(--text-muted);
text-decoration: none; text-decoration: none;
@@ -0,0 +1,123 @@
import { workspaceApiPath } from "$lib/workspace/api/http";
import type {
Diagnostic,
RuntimeCleanupExecutionResponse,
RuntimeCleanupPlanResponse,
Worker,
} from "./types";
type FetchFn = typeof fetch;
type WorkerLifecycleResponse = {
state: string;
diagnostics?: Diagnostic[];
};
function workerPath(workspaceId: string, worker: Worker): string {
return workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(worker.runtime_id)}/workers/${
encodeURIComponent(worker.worker_id)
}`,
);
}
async function responseError(response: Response): Promise<string> {
const fallback = `${response.status} ${response.statusText}`.trim();
try {
const payload = await response.json() as {
message?: string;
error?: { message?: string };
};
return payload.error?.message ?? payload.message ?? fallback;
} catch {
return fallback;
}
}
function diagnosticMessage(
diagnostics: Diagnostic[] | undefined,
fallback: string,
): string {
return diagnostics?.find((diagnostic) => diagnostic.severity === "error")
?.message ??
diagnostics?.[0]?.message ??
fallback;
}
export function canDeleteSidebarWorker(worker: Worker): boolean {
return worker.state === "stopped" || worker.state === "cancelled";
}
export async function stopSidebarWorker(
workspaceId: string,
worker: Worker,
fetchFn: FetchFn = fetch,
): Promise<void> {
const response = await fetchFn(`${workerPath(workspaceId, worker)}/stop`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ reason: "stopped from Workspace sidebar" }),
});
if (!response.ok) throw new Error(await responseError(response));
const result = await response.json() as WorkerLifecycleResponse;
if (result.state !== "accepted") {
throw new Error(
diagnosticMessage(result.diagnostics, `Worker stop was ${result.state}`),
);
}
}
export async function deleteSidebarWorker(
workspaceId: string,
worker: Worker,
fetchFn: FetchFn = fetch,
): Promise<void> {
const runtimePath = `/runtimes/${encodeURIComponent(worker.runtime_id)}`;
const planResponse = await fetchFn(
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-plan`),
);
if (!planResponse.ok) throw new Error(await responseError(planResponse));
const plan = await planResponse.json() as RuntimeCleanupPlanResponse;
const candidate = plan.workers.find((item) =>
item.runtime_id === worker.runtime_id &&
item.runtime_worker_id === worker.worker_id
);
if (!candidate) throw new Error("Worker is not available for deletion");
if (candidate.blocking_reason) throw new Error(candidate.blocking_reason);
const executionResponse = await fetchFn(
workspaceApiPath(workspaceId, `${runtimePath}/cleanup-executions`),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
expected_plan_revision: plan.revision,
expected_plan_digest: plan.digest,
worker_target_ids: [candidate.target_id],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
}),
},
);
if (!executionResponse.ok) {
throw new Error(await responseError(executionResponse));
}
const execution = await executionResponse
.json() as RuntimeCleanupExecutionResponse;
const outcome = execution.results.find((result) =>
result.target_id === candidate.target_id
);
if (!outcome || outcome.status !== "deleted") {
throw new Error(
outcome?.message ??
diagnosticMessage(
execution.diagnostics,
"Worker deletion was not completed",
),
);
}
}
+7 -2
View File
@@ -10,7 +10,7 @@
import '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props(); let { children, data }: LayoutProps = $props();
let sidebar = $state<SidebarSnippet | null>(null); let sidebar = $state<SidebarSnippet | null>(null);
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => { const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
sidebar = activeSidebar; sidebar = activeSidebar;
@@ -27,7 +27,12 @@
<div class="app-shell"> <div class="app-shell">
<SidebarFrame> <SidebarFrame>
<GlobalSidebar currentPath={page.url.pathname} content={sidebar} /> <GlobalSidebar
currentPath={page.url.pathname}
content={sidebar}
workspaces={data.accessibleWorkspaces}
workspaceError={data.workspaceCatalogError}
/>
</SidebarFrame> </SidebarFrame>
<header class="app-shell__topbar"> <header class="app-shell__topbar">
<div class="app-shell__topbar-location"> <div class="app-shell__topbar-location">
+17 -4
View File
@@ -1,5 +1,18 @@
import type { LayoutLoad } from './$types'; import { listWorkspaces } from "$lib/workspace/api/workspace-catalog";
import type { LayoutLoad } from "./$types";
// Workspace selection is explicit at `/`; the root layout must never infer a export const load: LayoutLoad = async ({ fetch }) => {
// singleton Workspace or redirect based on an unscoped compatibility endpoint. try {
export const load: LayoutLoad = () => ({}); return {
accessibleWorkspaces: await listWorkspaces(fetch),
workspaceCatalogError: null,
};
} catch (error) {
return {
accessibleWorkspaces: [],
workspaceCatalogError: error instanceof Error
? error.message
: "Unable to load Workspaces",
};
}
};
@@ -776,6 +776,7 @@
try { try {
const method = composerRequestToProtocolMethod(command.request); const method = composerRequestToProtocolMethod(command.request);
sendProtocolMethod(method); sendProtocolMethod(method);
composerInputElement?.recordHistory(value);
composerInputElement?.clear(); composerInputElement?.clear();
attachments = []; attachments = [];
if (method.method === "run" || method.method === "notify") { if (method.method === "run" || method.method === "notify") {
@@ -1763,6 +1764,7 @@
/> />
<ComposerInput <ComposerInput
bind:this={composerInputElement} bind:this={composerInputElement}
historyScope={workspaceId}
ariaLabel="Console input" ariaLabel="Console input"
ariaKeyShortcuts="Meta+Enter Control+Enter" ariaKeyShortcuts="Meta+Enter Control+Enter"
disabled={!composerEditable} disabled={!composerEditable}
+201
View File
@@ -0,0 +1,201 @@
import type { Segment } from "../src/lib/generated/protocol.ts";
import {
COMPOSER_HISTORY_LIMIT,
ComposerHistory,
type ComposerHistoryEntry,
composerHistoryStorageKey,
loadComposerHistory,
saveComposerHistory,
shouldBrowseComposerHistory,
} from "../src/lib/workspace/console/composer-history.ts";
function assert(
condition: unknown,
message = "assertion failed",
): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
}
}
function entry(content: string): ComposerHistoryEntry {
return {
segments: [{ kind: "text", content }],
preserveExactText: false,
};
}
function text(entryValue: ComposerHistoryEntry | null): string | null {
const segment = entryValue?.segments[0];
return segment?.kind === "text" ? segment.content : null;
}
function memoryStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial));
return {
getItem(key: string): string | null {
return values.get(key) ?? null;
},
setItem(key: string, value: string): void {
values.set(key, value);
},
value(key: string): string | null {
return values.get(key) ?? null;
},
};
}
Deno.test("Composer history skips blank and consecutive duplicate entries", () => {
const history = new ComposerHistory();
assert(!history.record(entry(" \n")));
assert(history.record(entry("first")));
assert(!history.record(entry("first")));
assert(history.record(entry("second")));
assertEquals(history.entries.map(text), ["first", "second"]);
});
Deno.test("Composer history keeps the newest 30 entries", () => {
const history = new ComposerHistory();
for (let index = 0; index < COMPOSER_HISTORY_LIMIT + 4; index += 1) {
history.record(entry(`message-${index}`));
}
assertEquals(history.entries.length, COMPOSER_HISTORY_LIMIT);
assertEquals(text(history.entries[0] ?? null), "message-4");
assertEquals(text(history.entries.at(-1) ?? null), "message-33");
});
Deno.test("Composer history uses only the multiline input boundaries", () => {
const base = {
lineCount: 3,
selectionEmpty: true,
readOnly: false,
composing: false,
};
assert(
shouldBrowseComposerHistory({ ...base, direction: "older", cursorLine: 1 }),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "older",
cursorLine: 2,
}),
);
assert(
shouldBrowseComposerHistory({ ...base, direction: "newer", cursorLine: 3 }),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "newer",
cursorLine: 2,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "older",
cursorLine: 1,
selectionEmpty: false,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "newer",
cursorLine: 3,
readOnly: true,
}),
);
assert(
!shouldBrowseComposerHistory({
...base,
direction: "older",
cursorLine: 1,
composing: true,
}),
);
});
Deno.test("Composer history navigates older and restores the draft after newer", () => {
const history = new ComposerHistory([entry("first"), entry("second")]);
assertEquals(text(history.previous(entry("unsent draft"))), "second");
assertEquals(text(history.previous(entry("ignored draft"))), "first");
assertEquals(text(history.previous(entry("ignored draft"))), "first");
assertEquals(text(history.next()), "second");
assertEquals(text(history.next()), "unsent draft");
assert(!history.browsing);
assertEquals(history.next(), null);
});
Deno.test("editing cancels Composer history navigation", () => {
const history = new ComposerHistory([entry("sent")]);
history.previous(entry("draft"));
assert(history.browsing);
history.cancelNavigation();
assert(!history.browsing);
assertEquals(history.next(), null);
});
Deno.test("Composer history persists segments by workspace and ignores corrupt storage", () => {
const storage = memoryStorage();
const workspaceId = "workspace / one";
const history = new ComposerHistory();
const paste = {
kind: "paste",
id: 7,
content: "large paste",
chars: 11,
lines: 1,
} satisfies Segment;
history.record({ segments: [paste], preserveExactText: true });
saveComposerHistory(storage, workspaceId, history);
const restored = loadComposerHistory(storage, workspaceId);
assertEquals(restored.entries, history.entries);
assertEquals(
composerHistoryStorageKey(workspaceId),
"yoi.composer-history.v1.workspace.workspace%20%2F%20one",
);
const corrupt = memoryStorage({
[composerHistoryStorageKey(workspaceId)]: "not-json",
});
assertEquals(loadComposerHistory(corrupt, workspaceId).entries, []);
});
Deno.test("Composer input uses boundary-aware Up and Down history navigation", async () => {
const inputSource = await Deno.readTextFile(
new URL(
"../src/lib/workspace/console/ComposerInput.svelte",
import.meta.url,
),
);
const consoleSource = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte",
import.meta.url,
),
);
assert(inputSource.includes('key: "ArrowUp"'));
assert(inputSource.includes('key: "ArrowDown"'));
assert(inputSource.includes("shouldBrowseComposerHistory({"));
assert(inputSource.includes("lineCount: currentView.state.doc.lines"));
assert(inputSource.includes("composerHistory.cancelNavigation()"));
assert(consoleSource.includes("historyScope={workspaceId}"));
assert(consoleSource.includes("composerInputElement?.recordHistory(value)"));
});
@@ -1,3 +1,6 @@
import denoConfig from "../../deno.json" with { type: "json" };
import { CODEMIRROR_VITE_DEDUPE } from "../../src/lib/workspace/config-source/vite-dedupe.ts";
declare const Deno: { declare const Deno: {
test(name: string, fn: () => Promise<void> | void): void; test(name: string, fn: () => Promise<void> | void): void;
readTextFile(path: URL): Promise<string>; readTextFile(path: URL): Promise<string>;
@@ -7,6 +10,30 @@ function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message); if (!condition) throw new Error(message);
} }
Deno.test("Vite deduplicates CodeMirror stateful packages", () => {
for (
const packageName of [
"@codemirror/autocomplete",
"@codemirror/language",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
]
) {
assert(
CODEMIRROR_VITE_DEDUPE.includes(packageName),
`Vite must deduplicate ${packageName}`,
);
}
for (const packageName of CODEMIRROR_VITE_DEDUPE) {
assert(
packageName in (denoConfig.imports ?? {}),
`${packageName} must be a direct dependency so Vite can deduplicate it`,
);
}
});
Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => { Deno.test("config editor snapshots Svelte proxies before cloning baselines", async () => {
const source = await Deno.readTextFile( const source = await Deno.readTextFile(
new URL( new URL(
@@ -72,6 +99,17 @@ Deno.test("Decodal editor follows readonly prop changes after mount", async () =
!source.includes("--border-subtle"), !source.includes("--border-subtle"),
"CodeMirror theme must use workspace tokens that actually exist", "CodeMirror theme must use workspace tokens that actually exist",
); );
assert(
source.includes("keymap.of(completionKeymapWithoutEnter)") &&
source.includes("binding.key !== 'Enter'") &&
source.includes("activateOnTyping: false") &&
source.includes("shouldStartCompletionAfterTyping(insertedText)") &&
source.includes("startCompletion(editor)") &&
!source.includes("EditorView.domEventHandlers") &&
!source.includes("update.selectionSet") &&
source.includes("completionStatus(editor.state) === null"),
"completion should start only after non-whitespace typing, without using focus, cursor movement, Space, or Enter",
);
assert( assert(
source.includes("fixedSchemaWrapperCompartment.reconfigure") && source.includes("fixedSchemaWrapperCompartment.reconfigure") &&
source.includes("fixedSchemaWrapperExtension()") && source.includes("fixedSchemaWrapperExtension()") &&
@@ -3,7 +3,10 @@ declare const Deno: {
readTextFile(path: URL): Promise<string>; readTextFile(path: URL): Promise<string>;
}; };
import { toCodeMirrorCompletion } from "../../src/lib/workspace/config-source/completion.ts"; import {
shouldStartCompletionAfterTyping,
toCodeMirrorCompletion,
} from "../../src/lib/workspace/config-source/completion.ts";
import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts"; import { jsonWorkerMessage } from "../../src/lib/workspace/config-source/toolchain-message.ts";
function assert(condition: unknown, message: string): asserts condition { function assert(condition: unknown, message: string): asserts condition {
@@ -67,10 +70,28 @@ Deno.test("toolchain converts reactive-like proxies to plain Worker messages", a
); );
}); });
Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirror", () => { Deno.test("completion starts only after non-whitespace typing", () => {
const source = "let 名 = tru"; assert(
const result = toCodeMirrorCompletion(source, { !shouldStartCompletionAfterTyping(" "),
from: new TextEncoder().encode("let 名 = ").length, "Space should not start completion",
);
assert(
!shouldStartCompletionAfterTyping("\n"),
"Enter should not start completion",
);
assert(
!shouldStartCompletionAfterTyping("\t"),
"other whitespace should not start completion",
);
assert(
shouldStartCompletionAfterTyping("p"),
"non-whitespace typing should start completion",
);
});
Deno.test("toolchain preserves WASM UTF-16 completion ranges for CodeMirror", () => {
const result = toCodeMirrorCompletion({
from: "let 名 = ".length,
items: [ items: [
{ {
label: "true", label: "true",
@@ -84,7 +105,7 @@ Deno.test("toolchain adapts WASM completion items and byte offsets for CodeMirro
assert(result !== null, "WASM completion should produce a CodeMirror result"); assert(result !== null, "WASM completion should produce a CodeMirror result");
assert( assert(
result.from === "let 名 = ".length, result.from === "let 名 = ".length,
"byte offsets should become UTF-16 offsets", "WASM UTF-16 offsets should be preserved for CodeMirror",
); );
assert( assert(
result.options.length === 1, result.options.length === 1,
@@ -299,6 +299,45 @@ Deno.test("generated WASM returns completion items for the editor adapter", () =
assertEquals(result.items[0].kind, "file"); assertEquals(result.items[0].kind, "file");
}); });
Deno.test("generated WASM completes blank nested schema positions after Unicode", () => {
const source =
'{ description = "日本語"; profile = { }\n} as WorkspaceConfigSchema';
const cursor = source.indexOf("{ }") + 2;
set_snapshot({
...snapshot,
entries: {
...snapshot.entries,
"workspace.dcdl": {
...snapshot.entries["workspace.dcdl"],
content: source,
},
},
});
set_schema_bundle({
contributions: [],
source: "{ profile = { default_profile = String; }; prompts = {}; }",
fingerprint: "sha256:test-schema",
});
const result = complete_current(
"workspace.dcdl",
source,
cursor,
true,
) as {
from: number;
items: Array<{ label: string; kind: string }>;
};
assertEquals(result.from, cursor);
assertEquals(
result.items.some((item) => item.label === "default_profile"),
true,
);
assertEquals(result.items.some((item) => item.label === "profile"), false);
assertEquals(result.items.some((item) => item.label === "prompts"), false);
});
Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => { Deno.test("generated WASM completes asserted WorkspaceConfigSchema keys", () => {
const bareSource = "{ pro }"; const bareSource = "{ pro }";
const source = "{ pro } as WorkspaceConfigSchema"; const source = "{ pro } as WorkspaceConfigSchema";
@@ -0,0 +1,180 @@
import {
canDeleteSidebarWorker,
deleteSidebarWorker,
stopSidebarWorker,
} from "../../src/lib/workspace/sidebar/worker-actions.ts";
import type { Worker } from "../../src/lib/workspace/sidebar/types.ts";
function assert(
condition: unknown,
message = "assertion failed",
): asserts condition {
if (!condition) throw new Error(message);
}
function assertEquals(actual: unknown, expected: unknown): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`expected ${expectedJson}, received ${actualJson}`);
}
}
async function assertRejects(
operation: () => Promise<unknown>,
message: string,
): Promise<void> {
try {
await operation();
} catch (cause) {
assert(cause instanceof Error, "expected an Error");
assert(
cause.message.includes(message),
`expected error containing ${message}`,
);
return;
}
throw new Error("expected operation to reject");
}
const worker = {
runtime_id: "runtime /",
worker_id: "worker /",
state: "running",
capabilities: { can_stop: true },
} as Worker;
function jsonResponse(payload: unknown, status = 200): Response {
return new Response(JSON.stringify(payload), {
status,
headers: { "content-type": "application/json" },
});
}
Deno.test("sidebar Stop uses the workspace-scoped Worker lifecycle endpoint", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
return jsonResponse({ state: "accepted", diagnostics: [] });
}) as typeof fetch;
await stopSidebarWorker("team space", worker, fetchFn);
assertEquals(requests.length, 1);
assertEquals(
requests[0]?.url,
"/api/w/team%20space/runtimes/runtime%20%2F/workers/worker%20%2F/stop",
);
assertEquals(requests[0]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[0]?.init?.body)), {
reason: "stopped from Workspace sidebar",
});
});
Deno.test("sidebar Stop rejects non-accepted lifecycle responses", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
state: "rejected",
diagnostics: [{ severity: "error", message: "Worker cannot stop" }],
}),
)) as typeof fetch;
await assertRejects(
() => stopSidebarWorker("workspace", worker, fetchFn),
"Worker cannot stop",
);
});
Deno.test("sidebar Delete executes the authoritative runtime cleanup plan", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: input.toString(), init });
if (requests.length === 1) {
return jsonResponse({
revision: 7,
digest: "digest-7",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: null,
}],
workdirs: [],
diagnostics: [],
});
}
return jsonResponse({
results: [{
target_id: "worker-target",
status: "deleted",
message: null,
}],
diagnostics: [],
});
}) as typeof fetch;
await deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn);
assertEquals(requests.map((request) => request.url), [
"/api/w/team/runtimes/runtime%20%2F/cleanup-plan",
"/api/w/team/runtimes/runtime%20%2F/cleanup-executions",
]);
assertEquals(requests[1]?.init?.method, "POST");
assertEquals(JSON.parse(String(requests[1]?.init?.body)), {
expected_plan_revision: 7,
expected_plan_digest: "digest-7",
worker_target_ids: ["worker-target"],
workdir_target_ids: [],
confirm_dirty_discard_target_ids: [],
});
});
Deno.test("sidebar Delete reports cleanup-plan blocking reasons", async () => {
const fetchFn = (() =>
Promise.resolve(
jsonResponse({
revision: 8,
digest: "digest-8",
candidates: [],
workers: [{
target_id: "worker-target",
runtime_id: worker.runtime_id,
runtime_worker_id: worker.worker_id,
blocking_reason: "Worker is pinned",
}],
workdirs: [],
diagnostics: [],
}),
)) as typeof fetch;
await assertRejects(
() => deleteSidebarWorker("team", { ...worker, state: "stopped" }, fetchFn),
"Worker is pinned",
);
});
Deno.test("sidebar Delete is enabled only for terminal Worker states", () => {
assert(!canDeleteSidebarWorker(worker));
assert(canDeleteSidebarWorker({ ...worker, state: "stopped" }));
assert(canDeleteSidebarWorker({ ...worker, state: "cancelled" }));
});
Deno.test("Worker navigation exposes an accessible hover action menu", async () => {
const source = await Deno.readTextFile(
new URL(
"../../src/lib/workspace/sidebar/WorkersNavSection.svelte",
import.meta.url,
),
);
const styles = await Deno.readTextFile(
new URL("../../src/lib/workspace/sidebar/sidebar.css", import.meta.url),
);
assert(source.includes('aria-haspopup="menu"'));
assert(source.includes('role="menuitem"'));
assert(source.includes("stopSidebarWorker(workspaceId, worker)"));
assert(source.includes("deleteSidebarWorker(workspaceId, worker)"));
assert(styles.includes(".worker-nav-item:hover .worker-actions-trigger"));
});
+5
View File
@@ -1,9 +1,14 @@
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { CODEMIRROR_VITE_DEDUPE } from "./src/lib/workspace/config-source/vite-dedupe";
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
resolve: {
dedupe: CODEMIRROR_VITE_DEDUPE,
},
server: { server: {
host: "localhost", host: "localhost",
port: 5173, port: 5173,