Compare commits
8
Commits
bcada300e3
...
62eaefb1fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62eaefb1fa | ||
|
|
10264b4019 | ||
|
|
ab9765d91d | ||
|
|
e7f4c6864f | ||
|
|
bde1dea2a5 | ||
|
|
13a021c480 | ||
|
|
a7f09fad98 | ||
|
|
10eaf4a5fb |
Generated
+3
@@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
|||||||
name = "client"
|
name = "client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"protocol",
|
"protocol",
|
||||||
@@ -3507,6 +3508,7 @@ dependencies = [
|
|||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"uuid",
|
"uuid",
|
||||||
@@ -4622,6 +4624,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
"agen",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"client",
|
||||||
"fs4",
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ edition.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait.workspace = true
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
protocol = { workspace = true }
|
protocol = { workspace = true }
|
||||||
ticket = { workspace = true }
|
ticket = { workspace = true }
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
use crate::{BackendApiClient, BackendApiClientError};
|
use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
|
||||||
use futures::{SinkExt, StreamExt};
|
use crate::{BackendApiClient, BackendApiClientError, Client};
|
||||||
use protocol::stream::{decode_event, encode_method};
|
|
||||||
use protocol::{ErrorCode, Event, Method};
|
|
||||||
use reqwest::Method as HttpMethod;
|
use reqwest::Method as HttpMethod;
|
||||||
use std::collections::VecDeque;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_tungstenite::connect_async;
|
|
||||||
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
|
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||||
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION;
|
||||||
@@ -106,20 +100,12 @@ impl BackendRuntimeListTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct BackendRuntimeClient {
|
|
||||||
target: BackendRuntimeTarget,
|
|
||||||
command_tx: mpsc::UnboundedSender<Method>,
|
|
||||||
events: mpsc::UnboundedReceiver<Event>,
|
|
||||||
diagnostics: VecDeque<Event>,
|
|
||||||
_protocol_task: tokio::task::JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum BackendRuntimeClientError {
|
pub enum BackendRuntimeClientError {
|
||||||
InvalidTarget(String),
|
InvalidTarget(String),
|
||||||
Api(BackendApiClientError),
|
Api(BackendApiClientError),
|
||||||
Http(reqwest::Error),
|
Http(reqwest::Error),
|
||||||
|
Protocol(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for BackendRuntimeClientError {
|
impl fmt::Display for BackendRuntimeClientError {
|
||||||
@@ -128,6 +114,7 @@ impl fmt::Display for BackendRuntimeClientError {
|
|||||||
Self::InvalidTarget(message) => f.write_str(message),
|
Self::InvalidTarget(message) => f.write_str(message),
|
||||||
Self::Api(error) => write!(f, "{error}"),
|
Self::Api(error) => write!(f, "{error}"),
|
||||||
Self::Http(error) => write!(f, "{error}"),
|
Self::Http(error) => write!(f, "{error}"),
|
||||||
|
Self::Protocol(message) => f.write_str(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,151 +269,22 @@ pub async fn restore_backend_worker(
|
|||||||
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BackendRuntimeClient {
|
pub async fn connect_backend_runtime(
|
||||||
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
|
target: BackendRuntimeTarget,
|
||||||
|
) -> Result<Client<WebSocket>, BackendRuntimeClientError> {
|
||||||
validate_target(&target)?;
|
validate_target(&target)?;
|
||||||
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
let api = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
let (event_tx, rx) = mpsc::unbounded_channel();
|
let request = protocol_ws_request(&target, &api).map_err(|error| {
|
||||||
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
BackendRuntimeClientError::Protocol(format!(
|
||||||
|
|
||||||
let protocol_target = target.clone();
|
|
||||||
let protocol_event_tx = event_tx.clone();
|
|
||||||
let protocol_task = tokio::spawn(async move {
|
|
||||||
run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
target,
|
|
||||||
command_tx,
|
|
||||||
events: rx,
|
|
||||||
diagnostics: VecDeque::new(),
|
|
||||||
_protocol_task: protocol_task,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_next_event(&mut self) -> Option<Event> {
|
|
||||||
if let Some(event) = self.diagnostics.pop_front() {
|
|
||||||
return Some(event);
|
|
||||||
}
|
|
||||||
self.events.try_recv().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn next_event(&mut self) -> Option<Event> {
|
|
||||||
if let Some(event) = self.diagnostics.pop_front() {
|
|
||||||
return Some(event);
|
|
||||||
}
|
|
||||||
self.events.recv().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&mut self, method: &Method) -> Result<(), BackendRuntimeClientError> {
|
|
||||||
self.command_tx.send(method.clone()).map_err(|_| {
|
|
||||||
BackendRuntimeClientError::InvalidTarget(format!(
|
|
||||||
"Backend protocol command stream is closed for {}",
|
|
||||||
self.target.display_label()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for BackendRuntimeClient {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self._protocol_task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_worker_protocol_transport(
|
|
||||||
target: BackendRuntimeTarget,
|
|
||||||
api: BackendApiClient,
|
|
||||||
mut commands: mpsc::UnboundedReceiver<Method>,
|
|
||||||
tx: mpsc::UnboundedSender<Event>,
|
|
||||||
) {
|
|
||||||
let request = match protocol_ws_request(&target, &api) {
|
|
||||||
Ok(request) => request,
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol request could not be constructed for {}: {error}",
|
"Backend protocol request could not be constructed for {}: {error}",
|
||||||
target.display_label()
|
target.display_label()
|
||||||
)));
|
))
|
||||||
return;
|
})?;
|
||||||
}
|
match WebSocket::connect(request).await {
|
||||||
};
|
Ok(socket) => Ok(Client::new(socket)),
|
||||||
match connect_async(request).await {
|
Err(WebSocketError::WebSocket(error)) => Err(BackendRuntimeClientError::Protocol(
|
||||||
Ok((ws, _)) => {
|
protocol_connect_error_message(&target, &api, &error),
|
||||||
let (mut sink, mut stream) = ws.split();
|
)),
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
maybe_method = commands.recv() => {
|
|
||||||
let Some(method) = maybe_method else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
match encode_method(&method) {
|
|
||||||
Ok(text) => {
|
|
||||||
if let Err(error) = sink.send(TungsteniteMessage::Text(text.into())).await {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol command send failed for {}: {error}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol command could not serialize method for {}: {error}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
frame = stream.next() => {
|
|
||||||
match frame {
|
|
||||||
Some(Ok(TungsteniteMessage::Text(text))) => {
|
|
||||||
match decode_event(&text) {
|
|
||||||
Ok(event) => {
|
|
||||||
let _ = tx.send(event);
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol response was not valid Event JSON for {}: {error}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(TungsteniteMessage::Close(_))) | None => {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol command stream closed for {}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(Ok(TungsteniteMessage::Ping(_)))
|
|
||||||
| Some(Ok(TungsteniteMessage::Pong(_)))
|
|
||||||
| Some(Ok(TungsteniteMessage::Binary(_)))
|
|
||||||
| Some(Ok(TungsteniteMessage::Frame(_))) => {}
|
|
||||||
Some(Err(error)) => {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol WebSocket error for {}: {error}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
let message = protocol_connect_error_message(&target, &api, &error);
|
|
||||||
let _ = tx.send(diagnostic_event(message));
|
|
||||||
while commands.recv().await.is_some() {
|
|
||||||
let _ = tx.send(diagnostic_event(format!(
|
|
||||||
"Backend protocol command was not sent because command stream is unavailable for {}",
|
|
||||||
target.display_label()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,13 +311,6 @@ fn protocol_connect_error_message(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn diagnostic_event(message: impl Into<String>) -> Event {
|
|
||||||
Event::Error {
|
|
||||||
code: ErrorCode::Internal,
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
|
fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeClientError> {
|
||||||
if target.base_url.trim().is_empty() {
|
if target.base_url.trim().is_empty() {
|
||||||
return Err(BackendRuntimeClientError::InvalidTarget(
|
return Err(BackendRuntimeClientError::InvalidTarget(
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use protocol::stream::{decode_event, encode_method};
|
||||||
|
use protocol::{Event, Method};
|
||||||
|
|
||||||
|
use crate::transport::Socket;
|
||||||
|
|
||||||
|
/// Typed Worker protocol client over an injected message transport.
|
||||||
|
pub struct Client<T> {
|
||||||
|
socket: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ClientError<E> {
|
||||||
|
Transport(E),
|
||||||
|
Protocol(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Client<T> {
|
||||||
|
pub fn new(socket: T) -> Self {
|
||||||
|
Self { socket }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> T {
|
||||||
|
self.socket
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Socket> Client<T> {
|
||||||
|
pub async fn send(&mut self, method: &Method) -> Result<(), ClientError<T::Error>> {
|
||||||
|
let message = encode_method(method).map_err(ClientError::Protocol)?;
|
||||||
|
self.socket
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(ClientError::Transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
|
||||||
|
self.socket
|
||||||
|
.next()
|
||||||
|
.await
|
||||||
|
.map_err(ClientError::Transport)?
|
||||||
|
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn try_next_event(&mut self) -> Result<Option<Event>, ClientError<T::Error>> {
|
||||||
|
self.socket
|
||||||
|
.try_next()
|
||||||
|
.map_err(ClientError::Transport)?
|
||||||
|
.map(|message| decode_event(&message).map_err(ClientError::Protocol))
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: fmt::Display> fmt::Display for ClientError<E> {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Transport(error) => write!(formatter, "Worker transport error: {error}"),
|
||||||
|
Self::Protocol(error) => write!(formatter, "Worker protocol error: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<E: Error + 'static> Error for ClientError<E> {
|
||||||
|
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||||
|
match self {
|
||||||
|
Self::Transport(error) => Some(error),
|
||||||
|
Self::Protocol(error) => Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use protocol::stream::{decode_method, encode_event};
|
||||||
|
use protocol::{Event, Method, WorkerStatus};
|
||||||
|
|
||||||
|
use super::Client;
|
||||||
|
use crate::transport::Socket;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct TestSocket {
|
||||||
|
sent: Vec<String>,
|
||||||
|
incoming: VecDeque<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Socket for TestSocket {
|
||||||
|
type Error = Infallible;
|
||||||
|
|
||||||
|
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||||
|
self.sent.push(message);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
Ok(self.incoming.pop_front())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
Ok(self.incoming.pop_front())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn encodes_methods_and_decodes_events_above_transport() {
|
||||||
|
let mut socket = TestSocket::default();
|
||||||
|
socket.incoming.push_back(
|
||||||
|
encode_event(&Event::Status {
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
})
|
||||||
|
.expect("encode event"),
|
||||||
|
);
|
||||||
|
let mut client = Client::new(socket);
|
||||||
|
|
||||||
|
client
|
||||||
|
.send(&Method::run_text("hello"))
|
||||||
|
.await
|
||||||
|
.expect("send method");
|
||||||
|
assert!(matches!(
|
||||||
|
decode_method(&client.socket.sent[0]),
|
||||||
|
Ok(Method::Run { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
client.next_event().await,
|
||||||
|
Ok(Some(Event::Status {
|
||||||
|
status: WorkerStatus::Idle
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ pub mod backend_api;
|
|||||||
mod backend_auth;
|
mod backend_auth;
|
||||||
pub mod backend_runtime;
|
pub mod backend_runtime;
|
||||||
pub mod backend_workspace;
|
pub mod backend_workspace;
|
||||||
|
mod client;
|
||||||
pub mod target;
|
pub mod target;
|
||||||
mod worker_client;
|
pub mod transport;
|
||||||
mod workspace_product;
|
mod workspace_product;
|
||||||
|
|
||||||
pub use backend_api::{
|
pub use backend_api::{
|
||||||
@@ -20,23 +21,23 @@ pub use backend_auth::{
|
|||||||
poll_device_login, start_device_login, wait_for_device_login,
|
poll_device_login, start_device_login, wait_for_device_login,
|
||||||
};
|
};
|
||||||
pub use backend_runtime::{
|
pub use backend_runtime::{
|
||||||
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClient, BackendRuntimeClientError,
|
BackendDiagnostic, BackendDiagnosticSeverity, BackendRuntimeClientError,
|
||||||
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
BackendRuntimeListResponse, BackendRuntimeListTarget, BackendRuntimeSummary,
|
||||||
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
BackendRuntimeTarget, BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
||||||
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
BackendWorkerRestoreResponse, BackendWorkerRestoreResult, BackendWorkerSummary,
|
||||||
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers,
|
BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime,
|
||||||
list_backend_workers, restore_backend_worker,
|
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
||||||
};
|
};
|
||||||
pub use backend_workspace::{
|
pub use backend_workspace::{
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
||||||
};
|
};
|
||||||
|
pub use client::{Client, ClientError};
|
||||||
pub use target::{
|
pub use target::{
|
||||||
BackendTarget, Dashboard, ResolvedTarget, StandaloneSessionListIntent,
|
BackendTarget, Dashboard, ResolvedTarget, StandaloneTarget, StandaloneWorkerListIntent,
|
||||||
StandaloneSessionResumeIntent, StandaloneTarget, Target, TargetError, TargetKind,
|
StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, WorkerConnection,
|
||||||
WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn,
|
||||||
};
|
};
|
||||||
pub use worker_client::WorkerClient;
|
|
||||||
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
pub use workspace_api::{ObjectiveDetail, ObjectiveSummary};
|
||||||
pub use workspace_product::BackendWorkspaceProductClient;
|
pub use workspace_product::BackendWorkspaceProductClient;
|
||||||
|
|||||||
+24
-24
@@ -105,16 +105,16 @@ pub struct WorkerSpawn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct StandaloneSessionListIntent {
|
pub struct StandaloneWorkerListIntent {
|
||||||
pub state_dir: PathBuf,
|
pub state_dir: PathBuf,
|
||||||
pub cwd: PathBuf,
|
pub cwd: PathBuf,
|
||||||
pub include_all: bool,
|
pub include_all: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct StandaloneSessionResumeIntent {
|
pub struct StandaloneWorkerResumeIntent {
|
||||||
pub state_dir: PathBuf,
|
pub state_dir: PathBuf,
|
||||||
pub session_id: String,
|
pub worker_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
@@ -175,22 +175,22 @@ pub trait Target: fmt::Debug + Send + Sync {
|
|||||||
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
Err(TargetError::unsupported("Worker spawn", self.kind()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standalone_session_list(
|
fn standalone_worker_list(
|
||||||
&self,
|
&self,
|
||||||
_include_all: bool,
|
_include_all: bool,
|
||||||
) -> Result<StandaloneSessionListIntent, TargetError> {
|
) -> Result<StandaloneWorkerListIntent, TargetError> {
|
||||||
Err(TargetError::unsupported(
|
Err(TargetError::unsupported(
|
||||||
"standalone session listing",
|
"standalone Worker listing",
|
||||||
self.kind(),
|
self.kind(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standalone_session_resume(
|
fn standalone_worker_resume(
|
||||||
&self,
|
&self,
|
||||||
_session_id: String,
|
_worker_id: String,
|
||||||
) -> Result<StandaloneSessionResumeIntent, TargetError> {
|
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
|
||||||
Err(TargetError::unsupported(
|
Err(TargetError::unsupported(
|
||||||
"standalone session restore",
|
"standalone Worker restore",
|
||||||
self.kind(),
|
self.kind(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -243,26 +243,26 @@ impl Target for StandaloneTarget {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standalone_session_list(
|
fn standalone_worker_list(
|
||||||
&self,
|
&self,
|
||||||
include_all: bool,
|
include_all: bool,
|
||||||
) -> Result<StandaloneSessionListIntent, TargetError> {
|
) -> Result<StandaloneWorkerListIntent, TargetError> {
|
||||||
let cwd = std::env::current_dir()
|
let cwd = std::env::current_dir()
|
||||||
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
|
.map_err(|error| TargetError::invalid(self.kind(), error.to_string()))?;
|
||||||
Ok(StandaloneSessionListIntent {
|
Ok(StandaloneWorkerListIntent {
|
||||||
state_dir: self.state_dir.clone(),
|
state_dir: self.state_dir.clone(),
|
||||||
cwd,
|
cwd,
|
||||||
include_all,
|
include_all,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn standalone_session_resume(
|
fn standalone_worker_resume(
|
||||||
&self,
|
&self,
|
||||||
session_id: String,
|
worker_id: String,
|
||||||
) -> Result<StandaloneSessionResumeIntent, TargetError> {
|
) -> Result<StandaloneWorkerResumeIntent, TargetError> {
|
||||||
Ok(StandaloneSessionResumeIntent {
|
Ok(StandaloneWorkerResumeIntent {
|
||||||
state_dir: self.state_dir.clone(),
|
state_dir: self.state_dir.clone(),
|
||||||
session_id,
|
worker_id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,17 +437,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn standalone_target_builds_explicit_session_intents() {
|
fn standalone_target_builds_explicit_worker_intents() {
|
||||||
let target = StandaloneTarget::new("/tmp/yoi-client-sessions");
|
let target = StandaloneTarget::new("/tmp/yoi-client-workers");
|
||||||
let list = target.standalone_session_list(true).unwrap();
|
let list = target.standalone_worker_list(true).unwrap();
|
||||||
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-sessions"));
|
assert_eq!(list.state_dir, PathBuf::from("/tmp/yoi-client-workers"));
|
||||||
assert!(list.include_all);
|
assert!(list.include_all);
|
||||||
assert!(list.cwd.is_absolute());
|
assert!(list.cwd.is_absolute());
|
||||||
|
|
||||||
let resume = target
|
let resume = target
|
||||||
.standalone_session_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
.standalone_worker_resume("019d1234-0000-7000-8000-000000000000".to_string())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resume.state_dir, list.state_dir);
|
assert_eq!(resume.state_dir, list.state_dir);
|
||||||
assert_eq!(resume.session_id, "019d1234-0000-7000-8000-000000000000");
|
assert_eq!(resume.worker_id, "019d1234-0000-7000-8000-000000000000");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use super::Socket as SocketContract;
|
||||||
|
|
||||||
|
const CHANNEL_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
pub struct Socket {
|
||||||
|
outgoing: mpsc::Sender<String>,
|
||||||
|
incoming: mpsc::Receiver<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-side endpoint paired with an in-process client transport.
|
||||||
|
pub struct Peer {
|
||||||
|
incoming: mpsc::Receiver<String>,
|
||||||
|
outgoing: mpsc::Sender<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
|
pub enum SocketError {
|
||||||
|
#[error("in-process Worker protocol transport closed")]
|
||||||
|
Closed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Socket {
|
||||||
|
pub fn pair() -> (Self, Peer) {
|
||||||
|
let (client_tx, peer_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||||
|
let (peer_tx, client_rx) = mpsc::channel(CHANNEL_CAPACITY);
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
outgoing: client_tx,
|
||||||
|
incoming: client_rx,
|
||||||
|
},
|
||||||
|
Peer {
|
||||||
|
incoming: peer_rx,
|
||||||
|
outgoing: peer_tx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SocketContract for Socket {
|
||||||
|
type Error = SocketError;
|
||||||
|
|
||||||
|
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||||
|
self.outgoing
|
||||||
|
.send(message)
|
||||||
|
.await
|
||||||
|
.map_err(|_| SocketError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
Ok(self.incoming.recv().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
match self.incoming.try_recv() {
|
||||||
|
Ok(message) => Ok(Some(message)),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Peer {
|
||||||
|
pub async fn next(&mut self) -> Option<String> {
|
||||||
|
self.incoming.recv().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send(&self, message: String) -> Result<(), String> {
|
||||||
|
self.outgoing.send(message).await.map_err(|error| error.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use protocol::stream::{decode_method, encode_event};
|
||||||
|
use protocol::{Event, Method, WorkerStatus};
|
||||||
|
|
||||||
|
use super::Socket;
|
||||||
|
use crate::Client;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pair_carries_typed_protocol_through_generic_client() {
|
||||||
|
let (socket, mut peer) = Socket::pair();
|
||||||
|
let mut client = Client::new(socket);
|
||||||
|
|
||||||
|
client
|
||||||
|
.send(&Method::run_text("hello"))
|
||||||
|
.await
|
||||||
|
.expect("send method");
|
||||||
|
assert!(matches!(
|
||||||
|
peer.next().await.as_deref().map(decode_method),
|
||||||
|
Some(Ok(Method::Run { .. }))
|
||||||
|
));
|
||||||
|
|
||||||
|
peer.send(
|
||||||
|
encode_event(&Event::Status {
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
})
|
||||||
|
.expect("encode event"),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("send event");
|
||||||
|
assert!(matches!(
|
||||||
|
client.next_event().await,
|
||||||
|
Ok(Some(Event::Status {
|
||||||
|
status: WorkerStatus::Idle
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
use std::error::Error;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
pub mod in_process;
|
||||||
|
pub mod unix_socket;
|
||||||
|
pub mod websocket;
|
||||||
|
|
||||||
|
/// Message-oriented transport for one Worker protocol connection.
|
||||||
|
///
|
||||||
|
/// Implementations own physical framing. `client::Client` owns the typed
|
||||||
|
/// Method/Event protocol encoding layered on top of these UTF-8 messages.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Socket {
|
||||||
|
type Error: Error + Send + Sync + 'static;
|
||||||
|
|
||||||
|
async fn send(&mut self, message: String) -> Result<(), Self::Error>;
|
||||||
|
|
||||||
|
async fn next(&mut self) -> Result<Option<String>, Self::Error>;
|
||||||
|
|
||||||
|
fn try_next(&mut self) -> Result<Option<String>, Self::Error>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use super::Socket as SocketContract;
|
||||||
|
|
||||||
|
pub struct Socket {
|
||||||
|
writer: tokio::io::WriteHalf<UnixStream>,
|
||||||
|
messages: mpsc::Receiver<io::Result<String>>,
|
||||||
|
reader_task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Socket {
|
||||||
|
pub async fn connect(path: &Path) -> io::Result<Self> {
|
||||||
|
let stream = UnixStream::connect(path).await?;
|
||||||
|
let (reader, writer) = tokio::io::split(stream);
|
||||||
|
let (message_tx, messages) = mpsc::channel(256);
|
||||||
|
let reader_task = tokio::spawn(async move {
|
||||||
|
let mut lines = BufReader::new(reader).lines();
|
||||||
|
loop {
|
||||||
|
match lines.next_line().await {
|
||||||
|
Ok(Some(message)) if message.trim().is_empty() => {}
|
||||||
|
Ok(Some(message)) => {
|
||||||
|
if message_tx.send(Ok(message)).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => return,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = message_tx.send(Err(error)).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self {
|
||||||
|
writer,
|
||||||
|
messages,
|
||||||
|
reader_task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SocketContract for Socket {
|
||||||
|
type Error = io::Error;
|
||||||
|
|
||||||
|
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||||
|
self.writer.write_all(message.as_bytes()).await?;
|
||||||
|
self.writer.write_all(b"\n").await?;
|
||||||
|
self.writer.flush().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
match self.messages.recv().await {
|
||||||
|
Some(message) => message.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
match self.messages.try_recv() {
|
||||||
|
Ok(message) => message.map(Some),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Socket {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.reader_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::io::ErrorKind;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use protocol::stream::{decode_method, encode_event};
|
||||||
|
use protocol::{Event, Method, WorkerStatus};
|
||||||
|
use tempfile::tempdir;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::UnixListener;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::Client;
|
||||||
|
|
||||||
|
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
|
||||||
|
let mut buf = [0_u8; 1];
|
||||||
|
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
|
||||||
|
.await
|
||||||
|
.expect(reason)
|
||||||
|
{
|
||||||
|
Ok(0) => {}
|
||||||
|
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
|
||||||
|
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
|
||||||
|
Err(error) => panic!("server read failed unexpectedly: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn client_receives_events_over_unix_socket() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("events.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
let event = encode_event(&Event::Status {
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
stream.write_all(event.as_bytes()).await.unwrap();
|
||||||
|
stream.write_all(b"\n").await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
||||||
|
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
||||||
|
.await
|
||||||
|
.expect("client should receive event while alive")
|
||||||
|
.expect("transport should succeed");
|
||||||
|
assert!(matches!(
|
||||||
|
event,
|
||||||
|
Some(Event::Status {
|
||||||
|
status: WorkerStatus::Idle
|
||||||
|
})
|
||||||
|
));
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn client_sends_methods_over_unix_socket() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("send.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (reader, _) = listener.accept().await.unwrap();
|
||||||
|
BufReader::new(reader).lines().next_line().await.unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut client = Client::new(Socket::connect(&socket_path).await.unwrap());
|
||||||
|
client
|
||||||
|
.send(&Method::run_text("hello"))
|
||||||
|
.await
|
||||||
|
.expect("send method");
|
||||||
|
|
||||||
|
let received = server.await.unwrap().expect("method message");
|
||||||
|
assert!(matches!(decode_method(&received), Ok(Method::Run { .. })));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dropping_socket_closes_server_connection() {
|
||||||
|
let socket_dir = tempdir().unwrap();
|
||||||
|
let socket_path = socket_dir.path().join("drop.sock");
|
||||||
|
let listener = UnixListener::bind(&socket_path).unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (mut stream, _) = listener.accept().await.unwrap();
|
||||||
|
assert_peer_closed(&mut stream, "dropped socket should close promptly").await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let socket = Socket::connect(&socket_path).await.unwrap();
|
||||||
|
drop(socket);
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio_tungstenite::tungstenite::http::Request;
|
||||||
|
use tokio_tungstenite::tungstenite::{self, Message};
|
||||||
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async};
|
||||||
|
|
||||||
|
use super::Socket as SocketContract;
|
||||||
|
|
||||||
|
type Writer = futures::stream::SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
|
||||||
|
|
||||||
|
pub struct Socket {
|
||||||
|
writer: Writer,
|
||||||
|
messages: mpsc::Receiver<Result<String, SocketError>>,
|
||||||
|
reader_task: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum SocketError {
|
||||||
|
#[error("WebSocket transport failed: {0}")]
|
||||||
|
WebSocket(#[from] tungstenite::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Socket {
|
||||||
|
pub async fn connect(request: Request<()>) -> Result<Self, SocketError> {
|
||||||
|
let (stream, _) = connect_async(request).await?;
|
||||||
|
let (writer, mut reader) = stream.split();
|
||||||
|
let (message_tx, messages) = mpsc::channel(256);
|
||||||
|
let reader_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match reader.next().await {
|
||||||
|
Some(Ok(Message::Text(message))) => {
|
||||||
|
if message_tx.send(Ok(message.to_string())).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => return,
|
||||||
|
Some(Ok(
|
||||||
|
Message::Binary(_)
|
||||||
|
| Message::Ping(_)
|
||||||
|
| Message::Pong(_)
|
||||||
|
| Message::Frame(_),
|
||||||
|
)) => {}
|
||||||
|
Some(Err(error)) => {
|
||||||
|
let _ = message_tx.send(Err(SocketError::WebSocket(error))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self {
|
||||||
|
writer,
|
||||||
|
messages,
|
||||||
|
reader_task,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SocketContract for Socket {
|
||||||
|
type Error = SocketError;
|
||||||
|
|
||||||
|
async fn send(&mut self, message: String) -> Result<(), Self::Error> {
|
||||||
|
self.writer.send(Message::Text(message.into())).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
match self.messages.recv().await {
|
||||||
|
Some(message) => message.map(Some),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_next(&mut self) -> Result<Option<String>, Self::Error> {
|
||||||
|
match self.messages.try_recv() {
|
||||||
|
Ok(message) => message.map(Some),
|
||||||
|
Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Socket {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.reader_task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use futures::{SinkExt, StreamExt};
|
||||||
|
use protocol::stream::{decode_method, encode_event};
|
||||||
|
use protocol::{Event, Method, WorkerStatus};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio_tungstenite::accept_async;
|
||||||
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::Client;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn carries_typed_protocol_through_generic_client() {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
let mut socket = accept_async(stream).await.unwrap();
|
||||||
|
let message = socket.next().await.unwrap().unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
message,
|
||||||
|
Message::Text(ref text)
|
||||||
|
if matches!(decode_method(text), Ok(Method::Run { .. }))
|
||||||
|
));
|
||||||
|
let event = encode_event(&Event::Status {
|
||||||
|
status: WorkerStatus::Idle,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
socket.send(Message::Text(event.into())).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let request = format!("ws://{address}").into_client_request().unwrap();
|
||||||
|
let mut client = Client::new(Socket::connect(request).await.unwrap());
|
||||||
|
client
|
||||||
|
.send(&Method::run_text("hello"))
|
||||||
|
.await
|
||||||
|
.expect("send method");
|
||||||
|
assert!(matches!(
|
||||||
|
client.next_event().await,
|
||||||
|
Ok(Some(Event::Status {
|
||||||
|
status: WorkerStatus::Idle
|
||||||
|
}))
|
||||||
|
));
|
||||||
|
server.await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
use std::io;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
|
||||||
use protocol::{Event, Method};
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio::task::JoinHandle;
|
|
||||||
|
|
||||||
pub struct WorkerClient {
|
|
||||||
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
|
|
||||||
event_rx: mpsc::Receiver<Event>,
|
|
||||||
reader_task: JoinHandle<()>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkerClient {
|
|
||||||
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
|
||||||
let stream = UnixStream::connect(path).await?;
|
|
||||||
let (reader, writer) = tokio::io::split(stream);
|
|
||||||
let writer = JsonLineWriter::new(writer);
|
|
||||||
|
|
||||||
let (event_tx, event_rx) = mpsc::channel::<Event>(256);
|
|
||||||
|
|
||||||
let reader_task = tokio::spawn(async move {
|
|
||||||
let mut reader = JsonLineReader::new(reader);
|
|
||||||
while let Ok(Some(event)) = reader.next::<Event>().await {
|
|
||||||
if event_tx.send(event).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(Self {
|
|
||||||
writer,
|
|
||||||
event_rx,
|
|
||||||
reader_task,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> {
|
|
||||||
self.writer.write(method).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn try_next_event(&mut self) -> Option<Event> {
|
|
||||||
self.event_rx.try_recv().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn next_event(&mut self) -> Option<Event> {
|
|
||||||
self.event_rx.recv().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for WorkerClient {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.reader_task.abort();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use std::io::ErrorKind;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use protocol::{Segment, WorkerStatus};
|
|
||||||
use tempfile::tempdir;
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
||||||
use tokio::net::UnixListener;
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
async fn assert_peer_closed(stream: &mut UnixStream, reason: &str) {
|
|
||||||
let mut buf = [0_u8; 1];
|
|
||||||
match tokio::time::timeout(Duration::from_secs(1), stream.read(&mut buf))
|
|
||||||
.await
|
|
||||||
.expect(reason)
|
|
||||||
{
|
|
||||||
Ok(0) => {}
|
|
||||||
Err(error) if error.kind() == ErrorKind::ConnectionReset => {}
|
|
||||||
Ok(n) => panic!("server should observe peer close, read {n} byte(s)"),
|
|
||||||
Err(error) => panic!("server read failed unexpectedly: {error}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn receives_events_while_client_is_alive() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("events.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut writer = JsonLineWriter::new(stream);
|
|
||||||
writer
|
|
||||||
.write(&Event::Status {
|
|
||||||
status: WorkerStatus::Idle,
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
|
||||||
|
|
||||||
let event = tokio::time::timeout(Duration::from_secs(1), client.next_event())
|
|
||||||
.await
|
|
||||||
.expect("client should receive event while alive");
|
|
||||||
assert!(matches!(
|
|
||||||
event,
|
|
||||||
Some(Event::Status {
|
|
||||||
status: WorkerStatus::Idle
|
|
||||||
})
|
|
||||||
));
|
|
||||||
server.await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn send_writes_methods_while_client_is_alive() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("send.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
|
||||||
let mut reader = JsonLineReader::new(stream);
|
|
||||||
reader.next::<Method>().await.unwrap()
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut client = WorkerClient::connect(&socket_path).await.unwrap();
|
|
||||||
let method = Method::Run {
|
|
||||||
input: vec![Segment::text("hello")],
|
|
||||||
};
|
|
||||||
client.send(&method).await.unwrap();
|
|
||||||
|
|
||||||
let received = tokio::time::timeout(Duration::from_secs(1), server)
|
|
||||||
.await
|
|
||||||
.expect("server should receive method while client is alive")
|
|
||||||
.unwrap();
|
|
||||||
match received {
|
|
||||||
Some(Method::Run { input }) => assert_eq!(input, vec![Segment::text("hello")]),
|
|
||||||
other => panic!("expected Run method, got {other:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dropping_repeated_clients_closes_server_connections() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("drop.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
for _ in 0..16 {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
assert_peer_closed(
|
|
||||||
&mut stream,
|
|
||||||
"dropped client should close its socket promptly",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for _ in 0..16 {
|
|
||||||
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
|
||||||
drop(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
server.await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dropping_client_aborts_blocked_reader_task() {
|
|
||||||
let socket_dir = tempdir().unwrap();
|
|
||||||
let socket_path = socket_dir.path().join("blocked-reader.sock");
|
|
||||||
let listener = UnixListener::bind(&socket_path).unwrap();
|
|
||||||
let server = tokio::spawn(async move {
|
|
||||||
let (mut stream, _) = listener.accept().await.unwrap();
|
|
||||||
stream.write_all(b"{\"event\"").await.unwrap();
|
|
||||||
assert_peer_closed(
|
|
||||||
&mut stream,
|
|
||||||
"aborting the blocked client reader should close the socket",
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
let client = WorkerClient::connect(&socket_path).await.unwrap();
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
drop(client);
|
|
||||||
|
|
||||||
server.await.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -177,6 +177,8 @@ mod tests {
|
|||||||
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
fn logical_paths_reject_absolute_parent_and_backslash_forms() {
|
||||||
assert!(FsPath::new("src/lib.rs").is_ok());
|
assert!(FsPath::new("src/lib.rs").is_ok());
|
||||||
assert!(FsPath::new("/tmp/file").is_err());
|
assert!(FsPath::new("/tmp/file").is_err());
|
||||||
|
assert!(FsPath::new_scoped("/tmp/file").is_ok());
|
||||||
|
assert!(FsPath::new_scoped("/tmp/../secret").is_err());
|
||||||
assert!(FsPath::new("../file").is_err());
|
assert!(FsPath::new("../file").is_err());
|
||||||
assert!(FsPath::new("src\\lib.rs").is_err());
|
assert!(FsPath::new("src\\lib.rs").is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::FsError;
|
use crate::FsError;
|
||||||
|
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// Scope-checked filesystem path. Relative paths resolve below the bound
|
||||||
|
/// Workdir root; absolute paths require an explicit matching scope rule.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
|
||||||
#[serde(transparent)]
|
#[serde(transparent)]
|
||||||
pub struct FsPath(String);
|
pub struct FsPath(String);
|
||||||
@@ -16,11 +17,30 @@ impl<'de> Deserialize<'de> for FsPath {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let value = String::deserialize(deserializer)?;
|
let value = String::deserialize(deserializer)?;
|
||||||
Self::new(&value).map_err(serde::de::Error::custom)
|
Self::new_scoped(&value).map_err(serde::de::Error::custom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsPath {
|
impl FsPath {
|
||||||
|
/// Construct a path for a scope-checked operation that may target an
|
||||||
|
/// explicitly granted absolute path outside the provider root.
|
||||||
|
pub fn new_scoped(value: impl Into<String>) -> Result<Self, FsError> {
|
||||||
|
let value = value.into();
|
||||||
|
if !Path::new(&value).is_absolute() {
|
||||||
|
return Self::new(value);
|
||||||
|
}
|
||||||
|
if value.contains('\\') {
|
||||||
|
return Err(FsError::InvalidPath(value));
|
||||||
|
}
|
||||||
|
if Path::new(&value)
|
||||||
|
.components()
|
||||||
|
.any(|component| component == Component::ParentDir)
|
||||||
|
{
|
||||||
|
return Err(FsError::InvalidPath(value));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn root() -> Self {
|
pub fn root() -> Self {
|
||||||
Self(String::new())
|
Self(String::new())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ json-schema = ["dep:schemars"]
|
|||||||
schemars = { workspace = true, optional = true }
|
schemars = { workspace = true, optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
sha2.workspace = true
|
||||||
tokio = { workspace = true, features = ["io-util"], optional = true }
|
tokio = { workspace = true, features = ["io-util"], optional = true }
|
||||||
ts-rs = { version = "12.0.1", optional = true }
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
uuid = { workspace = true, features = ["serde"] }
|
uuid = { workspace = true, features = ["serde", "v7"] }
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
use std::{fmt, str::FromStr};
|
||||||
|
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::{Uuid, Version};
|
||||||
|
|
||||||
|
/// Stable Worker identity independent of its current Runtime placement or
|
||||||
|
/// conversation Session.
|
||||||
|
///
|
||||||
|
/// Workspace authority allocates this ID for managed Workers. A standalone
|
||||||
|
/// Worker store allocates it locally when no Workspace authority is present.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
|
pub struct WorkerId(Uuid);
|
||||||
|
|
||||||
|
impl WorkerId {
|
||||||
|
pub fn now_v7() -> Self {
|
||||||
|
Self(Uuid::now_v7())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts a legacy Runtime-local numeric id into a syntactically valid
|
||||||
|
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
|
||||||
|
pub fn from_legacy_u64(value: u64) -> Self {
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
bytes[8..].copy_from_slice(&value.to_be_bytes());
|
||||||
|
bytes[6] = 0x70;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
Self(Uuid::from_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(b"yoi.workspace-worker-id.v1\0");
|
||||||
|
hasher.update(workspace_id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(runtime_id.as_bytes());
|
||||||
|
hasher.update([0]);
|
||||||
|
hasher.update(value.to_be_bytes());
|
||||||
|
let digest = hasher.finalize();
|
||||||
|
let mut bytes = [0_u8; 16];
|
||||||
|
bytes.copy_from_slice(&digest[..16]);
|
||||||
|
// Migrated ids sort before normally allocated UUIDv7 values while retaining
|
||||||
|
// deterministic collision-resistant payload bits.
|
||||||
|
bytes[..6].fill(0);
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||||
|
Self(Uuid::from_bytes(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
|
let value = Uuid::parse_str(value).ok()?;
|
||||||
|
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn as_uuid(self) -> Uuid {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn short(self) -> String {
|
||||||
|
let simple = self.0.simple().to_string();
|
||||||
|
simple[simple.len() - 12..].to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for WorkerId {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(formatter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for WorkerId {
|
||||||
|
type Err = WorkerIdParseError;
|
||||||
|
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
Self::parse(value).ok_or(WorkerIdParseError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for WorkerId {
|
||||||
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
|
where
|
||||||
|
S: Serializer,
|
||||||
|
{
|
||||||
|
serializer.serialize_str(&self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for WorkerId {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct WorkerIdParseError;
|
||||||
|
|
||||||
|
impl fmt::Display for WorkerIdParseError {
|
||||||
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
formatter.write_str("Worker id must be a UUIDv7")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for WorkerIdParseError {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_id_accepts_only_uuid_v7() {
|
||||||
|
let worker_id = WorkerId::now_v7();
|
||||||
|
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
|
||||||
|
assert!(WorkerId::parse("30").is_none());
|
||||||
|
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_worker_id_mapping_is_stable() {
|
||||||
|
assert_eq!(
|
||||||
|
WorkerId::from_legacy_binding("workspace", "runtime", 42),
|
||||||
|
WorkerId::from_legacy_binding("workspace", "runtime", 42)
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
WorkerId::from_legacy_binding("workspace", "runtime", 42),
|
||||||
|
WorkerId::from_legacy_binding("workspace", "runtime", 43)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod identity;
|
||||||
#[cfg(feature = "stream")]
|
#[cfg(feature = "stream")]
|
||||||
pub mod stream;
|
pub mod stream;
|
||||||
pub mod subscription;
|
pub mod subscription;
|
||||||
@@ -8,6 +9,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use identity::{WorkerId, WorkerIdParseError};
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
agen.workspace = true
|
agen.workspace = true
|
||||||
|
client.workspace = true
|
||||||
fs4.workspace = true
|
fs4.workspace = true
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
protocol.workspace = true
|
protocol.workspace = true
|
||||||
|
|||||||
+197
-84
@@ -2,26 +2,34 @@ use std::path::PathBuf;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use agen::llm_client::client::LlmClient;
|
use agen::llm_client::client::LlmClient;
|
||||||
use protocol::{Event, Method};
|
use client::Client;
|
||||||
|
use client::transport::in_process::{Peer as InProcessPeer, Socket as InProcessSocket};
|
||||||
|
use protocol::stream::{decode_method, encode_event};
|
||||||
|
use protocol::{Event, Method, WorkerId};
|
||||||
use session_store::{
|
use session_store::{
|
||||||
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
CombinedStore, FsStore, FsWorkerStore, WorkerActiveSegmentRef, WorkerMetadataStore,
|
||||||
};
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::sync::broadcast;
|
use worker::bootstrap::{
|
||||||
use worker::bootstrap::{WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout};
|
WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout, bash_output_dir_for_worker_id,
|
||||||
|
};
|
||||||
use worker::controller::WorkerControllerTransport;
|
use worker::controller::WorkerControllerTransport;
|
||||||
|
use worker::ipc::protocol_session::{
|
||||||
|
WorkerProtocolSessionStreams, dispatch_worker_protocol_method, live_log_entry_event,
|
||||||
|
subscribe_worker_protocol_session,
|
||||||
|
};
|
||||||
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
|
use worker::{BootstrappedWorker, WorkerError, WorkerFilesystemAuthority, WorkerWorkspaceContext};
|
||||||
|
|
||||||
use crate::launch::ResolvedStandaloneLaunch;
|
use crate::launch::ResolvedStandaloneLaunch;
|
||||||
use crate::store::{
|
use crate::store::{
|
||||||
StaleLeasePolicy, StandaloneSessionId, StandaloneSessionLease, StandaloneSessionRecord,
|
StaleLeasePolicy, StandaloneShutdownReason, StandaloneStoreError, StandaloneWorkerLease,
|
||||||
StandaloneSessionStore, StandaloneShutdownReason, StandaloneStoreError,
|
StandaloneWorkerRecord, StandaloneWorkerStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
const DEFAULT_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
|
type StandaloneBackingStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||||
|
|
||||||
/// One client-owned top-level Worker and its standalone session authority.
|
/// One client-owned top-level Worker and its standalone Worker authority.
|
||||||
///
|
///
|
||||||
/// The host deliberately exposes the existing typed Worker protocol rather than owning an
|
/// The host deliberately exposes the existing typed Worker protocol rather than owning an
|
||||||
/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records.
|
/// HTTP/WebSocket server or creating Runtime/Workspace/Ticket/Workdir domain records.
|
||||||
@@ -29,21 +37,21 @@ pub struct StandaloneHost {
|
|||||||
handle: worker::WorkerHandle,
|
handle: worker::WorkerHandle,
|
||||||
shutdown: Option<worker::controller::ShutdownReceiver>,
|
shutdown: Option<worker::controller::ShutdownReceiver>,
|
||||||
shutdown_timeout: Duration,
|
shutdown_timeout: Duration,
|
||||||
store: StandaloneSessionStore,
|
store: StandaloneWorkerStore,
|
||||||
worker_store: FsWorkerStore,
|
worker_store: FsWorkerStore,
|
||||||
record: StandaloneSessionRecord,
|
record: StandaloneWorkerRecord,
|
||||||
lease: Option<StandaloneSessionLease>,
|
lease: Option<StandaloneWorkerLease>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
pub enum StandaloneStartupError {
|
pub enum StandaloneStartupError {
|
||||||
#[error("the standalone state store could not be opened or validated")]
|
#[error("the standalone state store could not be opened or validated")]
|
||||||
StateStore,
|
StateStore,
|
||||||
#[error("the standalone session is already active")]
|
#[error("the standalone Worker is already active")]
|
||||||
SessionActive,
|
WorkerActive,
|
||||||
#[error("the standalone session lease cannot be observed safely; recovery is rejected")]
|
#[error("the standalone Worker lease cannot be observed safely; recovery is rejected")]
|
||||||
LeaseLivenessUnknown,
|
LeaseLivenessUnknown,
|
||||||
#[error("the standalone session working directory is unavailable or changed")]
|
#[error("the standalone Worker working directory is unavailable or changed")]
|
||||||
WorkingDirectoryUnavailable,
|
WorkingDirectoryUnavailable,
|
||||||
#[error("the resolved Worker configuration or persisted history is invalid")]
|
#[error("the resolved Worker configuration or persisted history is invalid")]
|
||||||
WorkerConfiguration,
|
WorkerConfiguration,
|
||||||
@@ -55,19 +63,13 @@ pub enum StandaloneStartupError {
|
|||||||
Controller,
|
Controller,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
|
||||||
pub enum StandaloneRequestError {
|
|
||||||
#[error("the standalone Worker is no longer accepting requests")]
|
|
||||||
WorkerUnavailable,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||||
pub enum StandaloneShutdownError {
|
pub enum StandaloneShutdownError {
|
||||||
#[error("the standalone Worker did not stop before the shutdown deadline")]
|
#[error("the standalone Worker did not stop before the shutdown deadline")]
|
||||||
DeadlineExceeded,
|
DeadlineExceeded,
|
||||||
#[error("the standalone Worker shutdown confirmation was lost")]
|
#[error("the standalone Worker shutdown confirmation was lost")]
|
||||||
ConfirmationLost,
|
ConfirmationLost,
|
||||||
#[error("the standalone session final state could not be committed")]
|
#[error("the standalone Worker final state could not be committed")]
|
||||||
StateStore,
|
StateStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,22 +89,24 @@ impl StandaloneHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn start_with_optional_model_client(
|
async fn start_with_optional_model_client(
|
||||||
mut launch: ResolvedStandaloneLaunch,
|
launch: ResolvedStandaloneLaunch,
|
||||||
model_client: Option<Box<dyn LlmClient>>,
|
model_client: Option<Box<dyn LlmClient>>,
|
||||||
) -> Result<Self, StandaloneStartupError> {
|
) -> Result<Self, StandaloneStartupError> {
|
||||||
let store = StandaloneSessionStore::open(&launch.state_dir)
|
let store =
|
||||||
.map_err(classify_store_startup_error)?;
|
StandaloneWorkerStore::open(&launch.state_dir).map_err(classify_store_startup_error)?;
|
||||||
let allocation = store
|
let allocation = store
|
||||||
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
|
.allocate(&launch.cwd, StaleLeasePolicy::Reject)
|
||||||
.map_err(classify_store_startup_error)?;
|
.map_err(classify_store_startup_error)?;
|
||||||
let id = allocation.id();
|
let worker_id = allocation.worker_id();
|
||||||
|
|
||||||
// The standalone session ID is the local identity. A unique internal Worker name avoids
|
// WorkerId is the stable identity. The current Worker store remains
|
||||||
// process-global allocation collisions without creating a Runtime/Workspace Worker ID.
|
// name-keyed, so keep its derived storage key separate from the
|
||||||
launch.profile.manifest.worker.name = format!("standalone-{id}");
|
// user-facing profile name.
|
||||||
let manifest = launch.profile.manifest.clone();
|
let manifest = launch.profile.manifest.clone();
|
||||||
let worker_name = manifest.worker.name.clone();
|
let storage_key = format!("standalone-{worker_id}");
|
||||||
let (backing_store, worker_store) = match backing_store(&store, id) {
|
let mut bootstrap_manifest = manifest.clone();
|
||||||
|
bootstrap_manifest.worker.name = storage_key.clone();
|
||||||
|
let (backing_store, worker_store) = match backing_store(&store, worker_id) {
|
||||||
Ok(stores) => stores,
|
Ok(stores) => stores,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = store.abandon_allocation(allocation);
|
let _ = store.abandon_allocation(allocation);
|
||||||
@@ -112,15 +116,19 @@ impl StandaloneHost {
|
|||||||
let filesystem_authority =
|
let filesystem_authority =
|
||||||
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
WorkerFilesystemAuthority::local(launch.cwd.clone(), launch.cwd.clone());
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||||
let runtime_base = store.runtime_dir(id);
|
let runtime_base = store.runtime_dir(worker_id);
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
let mut bootstrap = WorkerBootstrap::new(
|
||||||
manifest.clone(),
|
bootstrap_manifest,
|
||||||
backing_store,
|
backing_store,
|
||||||
launch.prompt_catalog,
|
launch.prompt_catalog,
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::Direct { runtime_base },
|
WorkerBootstrapLayout::Direct {
|
||||||
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
);
|
);
|
||||||
if let Some(model_client) = model_client {
|
if let Some(model_client) = model_client {
|
||||||
@@ -133,7 +141,7 @@ impl StandaloneHost {
|
|||||||
return Err(classify_startup_error(error));
|
return Err(classify_startup_error(error));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let active = match active_pointer(&worker_store, &worker_name) {
|
let active = match active_pointer(&worker_store, &storage_key) {
|
||||||
Ok(active) => active,
|
Ok(active) => active,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
stop_started_worker(started).await;
|
stop_started_worker(started).await;
|
||||||
@@ -141,9 +149,13 @@ impl StandaloneHost {
|
|||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let record =
|
let record = match store.commit_created(
|
||||||
match store.commit_created(&allocation, manifest, active.session_id, active.segment_id)
|
&allocation,
|
||||||
{
|
manifest,
|
||||||
|
storage_key,
|
||||||
|
active.session_id,
|
||||||
|
active.segment_id,
|
||||||
|
) {
|
||||||
Ok(record) => record,
|
Ok(record) => record,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
stop_started_worker(started).await;
|
stop_started_worker(started).await;
|
||||||
@@ -162,50 +174,47 @@ impl StandaloneHost {
|
|||||||
|
|
||||||
pub async fn restore(
|
pub async fn restore(
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
session_id: StandaloneSessionId,
|
worker_id: WorkerId,
|
||||||
) -> Result<Self, StandaloneStartupError> {
|
) -> Result<Self, StandaloneStartupError> {
|
||||||
Self::restore_with_optional_model_client(state_dir, session_id, None).await
|
Self::restore_with_optional_model_client(state_dir, worker_id, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn restore_with_model_client<C>(
|
pub async fn restore_with_model_client<C>(
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
session_id: StandaloneSessionId,
|
worker_id: WorkerId,
|
||||||
model_client: C,
|
model_client: C,
|
||||||
) -> Result<Self, StandaloneStartupError>
|
) -> Result<Self, StandaloneStartupError>
|
||||||
where
|
where
|
||||||
C: LlmClient + 'static,
|
C: LlmClient + 'static,
|
||||||
{
|
{
|
||||||
Self::restore_with_optional_model_client(
|
Self::restore_with_optional_model_client(state_dir, worker_id, Some(Box::new(model_client)))
|
||||||
state_dir,
|
|
||||||
session_id,
|
|
||||||
Some(Box::new(model_client)),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn restore_with_optional_model_client(
|
async fn restore_with_optional_model_client(
|
||||||
state_dir: PathBuf,
|
state_dir: PathBuf,
|
||||||
session_id: StandaloneSessionId,
|
worker_id: WorkerId,
|
||||||
model_client: Option<Box<dyn LlmClient>>,
|
model_client: Option<Box<dyn LlmClient>>,
|
||||||
) -> Result<Self, StandaloneStartupError> {
|
) -> Result<Self, StandaloneStartupError> {
|
||||||
let store =
|
let store = StandaloneWorkerStore::open(state_dir).map_err(classify_store_startup_error)?;
|
||||||
StandaloneSessionStore::open(state_dir).map_err(classify_store_startup_error)?;
|
|
||||||
let record = store
|
let record = store
|
||||||
.load(session_id)
|
.load(worker_id)
|
||||||
.map_err(classify_store_startup_error)?;
|
.map_err(classify_store_startup_error)?;
|
||||||
record.cwd.verify().map_err(classify_store_startup_error)?;
|
record.cwd.verify().map_err(classify_store_startup_error)?;
|
||||||
let lease = store
|
let lease = store
|
||||||
.acquire_lease(session_id, StaleLeasePolicy::Recover)
|
.acquire_lease(worker_id, StaleLeasePolicy::Recover)
|
||||||
.map_err(classify_store_startup_error)?;
|
.map_err(classify_store_startup_error)?;
|
||||||
let (backing_store, worker_store) = backing_store(&store, session_id)?;
|
let (backing_store, worker_store) = backing_store(&store, worker_id)?;
|
||||||
let worker_name = record.worker_name.clone();
|
let storage_key = record.storage_key.clone();
|
||||||
let manifest = record.manifest.clone();
|
let mut manifest = record.manifest.clone();
|
||||||
|
manifest.worker.name = storage_key.clone();
|
||||||
let filesystem_authority = WorkerFilesystemAuthority::local(
|
let filesystem_authority = WorkerFilesystemAuthority::local(
|
||||||
record.cwd.canonical_path.clone(),
|
record.cwd.canonical_path.clone(),
|
||||||
record.cwd.canonical_path.clone(),
|
record.cwd.canonical_path.clone(),
|
||||||
);
|
);
|
||||||
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
let workspace_context = WorkerWorkspaceContext::local_filesystem(None);
|
||||||
let runtime_base = store.runtime_dir(session_id);
|
let runtime_base = store.runtime_dir(worker_id);
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(worker_id);
|
||||||
|
|
||||||
let mut bootstrap = WorkerBootstrap::new(
|
let mut bootstrap = WorkerBootstrap::new(
|
||||||
manifest,
|
manifest,
|
||||||
@@ -213,18 +222,21 @@ impl StandaloneHost {
|
|||||||
worker::PromptCatalogSource::builtins_only(),
|
worker::PromptCatalogSource::builtins_only(),
|
||||||
workspace_context,
|
workspace_context,
|
||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::Direct { runtime_base },
|
WorkerBootstrapLayout::Direct {
|
||||||
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
},
|
||||||
WorkerControllerTransport::InProcess,
|
WorkerControllerTransport::InProcess,
|
||||||
);
|
);
|
||||||
if let Some(model_client) = model_client {
|
if let Some(model_client) = model_client {
|
||||||
bootstrap = bootstrap.with_model_client(model_client);
|
bootstrap = bootstrap.with_model_client(model_client);
|
||||||
}
|
}
|
||||||
let prepared = bootstrap
|
let prepared = bootstrap
|
||||||
.prepare_restored(&worker_name)
|
.prepare_restored(&storage_key)
|
||||||
.await
|
.await
|
||||||
.map_err(classify_startup_error)?;
|
.map_err(classify_startup_error)?;
|
||||||
let started = prepared.start().await.map_err(classify_startup_error)?;
|
let started = prepared.start().await.map_err(classify_startup_error)?;
|
||||||
let active = match active_pointer(&worker_store, &worker_name) {
|
let active = match active_pointer(&worker_store, &storage_key) {
|
||||||
Ok(active) => active,
|
Ok(active) => active,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
stop_started_worker(started).await;
|
stop_started_worker(started).await;
|
||||||
@@ -251,10 +263,10 @@ impl StandaloneHost {
|
|||||||
|
|
||||||
fn from_started(
|
fn from_started(
|
||||||
started: BootstrappedWorker,
|
started: BootstrappedWorker,
|
||||||
store: StandaloneSessionStore,
|
store: StandaloneWorkerStore,
|
||||||
worker_store: FsWorkerStore,
|
worker_store: FsWorkerStore,
|
||||||
record: StandaloneSessionRecord,
|
record: StandaloneWorkerRecord,
|
||||||
lease: StandaloneSessionLease,
|
lease: StandaloneWorkerLease,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
handle: started.handle,
|
handle: started.handle,
|
||||||
@@ -268,28 +280,24 @@ impl StandaloneHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn session_id(&self) -> StandaloneSessionId {
|
pub fn worker_id(&self) -> WorkerId {
|
||||||
self.record.session_id
|
self.record.worker_id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn record(&self) -> &StandaloneSessionRecord {
|
pub fn record(&self) -> &StandaloneWorkerRecord {
|
||||||
&self.record
|
&self.record
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send(&self, method: Method) -> Result<(), StandaloneRequestError> {
|
/// Open one complete client-side Worker protocol session.
|
||||||
self.handle
|
///
|
||||||
.send(method)
|
/// Working events, committed session entries, alert snapshots, and the
|
||||||
.await
|
/// initial history snapshot are merged behind the client boundary.
|
||||||
.map_err(|_| StandaloneRequestError::WorkerUnavailable)
|
pub fn connect(&self) -> Client<InProcessSocket> {
|
||||||
}
|
let streams = subscribe_worker_protocol_session(&self.handle);
|
||||||
|
let (socket, peer) = InProcessSocket::pair();
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
tokio::spawn(run_protocol_session(self.handle.clone(), streams, peer));
|
||||||
self.handle.subscribe()
|
Client::new(socket)
|
||||||
}
|
|
||||||
|
|
||||||
pub fn snapshot(&self) -> Event {
|
|
||||||
self.handle.snapshot_event()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
|
pub fn with_shutdown_timeout(mut self, shutdown_timeout: Duration) -> Self {
|
||||||
@@ -314,7 +322,7 @@ impl StandaloneHost {
|
|||||||
return Err(StandaloneShutdownError::DeadlineExceeded);
|
return Err(StandaloneShutdownError::DeadlineExceeded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let active = match active_pointer(&self.worker_store, &self.record.worker_name) {
|
let active = match active_pointer(&self.worker_store, &self.record.storage_key) {
|
||||||
Ok(active) => active,
|
Ok(active) => active,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.retain_lease();
|
self.retain_lease();
|
||||||
@@ -349,13 +357,118 @@ impl StandaloneHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_protocol_session(
|
||||||
|
handle: worker::WorkerHandle,
|
||||||
|
streams: WorkerProtocolSessionStreams,
|
||||||
|
mut peer: InProcessPeer,
|
||||||
|
) {
|
||||||
|
let WorkerProtocolSessionStreams {
|
||||||
|
snapshot_event,
|
||||||
|
mut log_entries,
|
||||||
|
alert_snapshot,
|
||||||
|
mut events,
|
||||||
|
} = streams;
|
||||||
|
|
||||||
|
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
message = peer.next() => {
|
||||||
|
let Some(message) = message else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(method) = decode_method(&message) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(event) = dispatch_worker_protocol_method(&handle, method).await
|
||||||
|
&& !send_protocol_event(&peer, event).await
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = events.recv() => {
|
||||||
|
match event {
|
||||||
|
Ok(event) => {
|
||||||
|
if !send_protocol_event(&peer, event).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
let replacement = subscribe_worker_protocol_session(&handle);
|
||||||
|
let WorkerProtocolSessionStreams {
|
||||||
|
snapshot_event,
|
||||||
|
log_entries: replacement_log_entries,
|
||||||
|
alert_snapshot,
|
||||||
|
events: replacement_events,
|
||||||
|
} = replacement;
|
||||||
|
log_entries = replacement_log_entries;
|
||||||
|
events = replacement_events;
|
||||||
|
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry = log_entries.recv() => {
|
||||||
|
match entry {
|
||||||
|
Ok(entry) => {
|
||||||
|
if let Some(event) = live_log_entry_event(entry)
|
||||||
|
&& !send_protocol_event(&peer, event).await
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
|
||||||
|
let replacement = subscribe_worker_protocol_session(&handle);
|
||||||
|
let WorkerProtocolSessionStreams {
|
||||||
|
snapshot_event,
|
||||||
|
log_entries: replacement_log_entries,
|
||||||
|
alert_snapshot,
|
||||||
|
events: replacement_events,
|
||||||
|
} = replacement;
|
||||||
|
log_entries = replacement_log_entries;
|
||||||
|
events = replacement_events;
|
||||||
|
if !send_protocol_snapshot(&peer, alert_snapshot, snapshot_event).await {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_protocol_snapshot(
|
||||||
|
peer: &InProcessPeer,
|
||||||
|
alert_snapshot: Vec<protocol::Alert>,
|
||||||
|
snapshot_event: Event,
|
||||||
|
) -> bool {
|
||||||
|
for alert in alert_snapshot {
|
||||||
|
if !send_protocol_event(peer, Event::Alert(alert)).await {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send_protocol_event(peer, snapshot_event).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_protocol_event(peer: &InProcessPeer, event: Event) -> bool {
|
||||||
|
let Ok(message) = encode_event(&event) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
peer.send(message).await.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
fn backing_store(
|
fn backing_store(
|
||||||
store: &StandaloneSessionStore,
|
store: &StandaloneWorkerStore,
|
||||||
id: StandaloneSessionId,
|
worker_id: WorkerId,
|
||||||
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
|
) -> Result<(StandaloneBackingStore, FsWorkerStore), StandaloneStartupError> {
|
||||||
let session_store =
|
let session_store = FsStore::new(store.sessions_dir(worker_id))
|
||||||
FsStore::new(store.session_log_dir(id)).map_err(|_| StandaloneStartupError::StateStore)?;
|
.map_err(|_| StandaloneStartupError::StateStore)?;
|
||||||
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(id))
|
let worker_store = FsWorkerStore::new(store.worker_metadata_dir(worker_id))
|
||||||
.map_err(|_| StandaloneStartupError::StateStore)?;
|
.map_err(|_| StandaloneStartupError::StateStore)?;
|
||||||
Ok((
|
Ok((
|
||||||
CombinedStore::new(session_store, worker_store.clone()),
|
CombinedStore::new(session_store, worker_store.clone()),
|
||||||
@@ -365,10 +478,10 @@ fn backing_store(
|
|||||||
|
|
||||||
fn active_pointer(
|
fn active_pointer(
|
||||||
worker_store: &FsWorkerStore,
|
worker_store: &FsWorkerStore,
|
||||||
worker_name: &str,
|
storage_key: &str,
|
||||||
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
|
) -> Result<WorkerActiveSegmentRef, StandaloneStartupError> {
|
||||||
worker_store
|
worker_store
|
||||||
.read_by_name(worker_name)
|
.read_by_name(storage_key)
|
||||||
.map_err(|_| StandaloneStartupError::StateStore)?
|
.map_err(|_| StandaloneStartupError::StateStore)?
|
||||||
.and_then(|metadata| metadata.active)
|
.and_then(|metadata| metadata.active)
|
||||||
.ok_or(StandaloneStartupError::StateStore)
|
.ok_or(StandaloneStartupError::StateStore)
|
||||||
@@ -381,7 +494,7 @@ async fn stop_started_worker(started: BootstrappedWorker) {
|
|||||||
|
|
||||||
fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError {
|
fn classify_store_startup_error(error: StandaloneStoreError) -> StandaloneStartupError {
|
||||||
match error {
|
match error {
|
||||||
StandaloneStoreError::SessionLeased(_) => StandaloneStartupError::SessionActive,
|
StandaloneStoreError::WorkerLeased(_) => StandaloneStartupError::WorkerActive,
|
||||||
StandaloneStoreError::LeaseLivenessUnknown(_) => {
|
StandaloneStoreError::LeaseLivenessUnknown(_) => {
|
||||||
StandaloneStartupError::LeaseLivenessUnknown
|
StandaloneStartupError::LeaseLivenessUnknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ pub mod host;
|
|||||||
pub mod launch;
|
pub mod launch;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
pub use host::{
|
pub use host::{StandaloneHost, StandaloneShutdownError, StandaloneStartupError};
|
||||||
StandaloneHost, StandaloneRequestError, StandaloneShutdownError, StandaloneStartupError,
|
|
||||||
};
|
|
||||||
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
|
pub use launch::{ResolvedStandaloneLaunch, StandaloneLaunchConfig, StandaloneLaunchError};
|
||||||
|
pub use protocol::WorkerId;
|
||||||
pub use store::{
|
pub use store::{
|
||||||
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneSessionId,
|
StaleLeasePolicy, StandaloneCwdIdentity, StandaloneListScope, StandaloneShutdownReason,
|
||||||
StandaloneSessionRecord, StandaloneSessionStatus, StandaloneSessionStore,
|
StandaloneStoreError, StandaloneWorkerRecord, StandaloneWorkerStatus, StandaloneWorkerStore,
|
||||||
StandaloneShutdownReason, StandaloneStoreError,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+106
-143
@@ -1,12 +1,11 @@
|
|||||||
use std::fmt;
|
|
||||||
use std::fs::{self, File, OpenOptions};
|
use std::fs::{self, File, OpenOptions};
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use fs4::fs_std::FileExt;
|
use fs4::fs_std::FileExt;
|
||||||
use manifest::WorkerManifest;
|
use manifest::WorkerManifest;
|
||||||
|
use protocol::WorkerId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::{SegmentId, SessionId};
|
use session_store::{SegmentId, SessionId};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -16,47 +15,10 @@ const RECORD_FILE: &str = "record.json";
|
|||||||
const COMMIT_MARKER: &str = "commit.pending";
|
const COMMIT_MARKER: &str = "commit.pending";
|
||||||
const LEASE_FILE: &str = "lease.json";
|
const LEASE_FILE: &str = "lease.json";
|
||||||
const LEASE_LOCK_FILE: &str = "lease.lock";
|
const LEASE_LOCK_FILE: &str = "lease.lock";
|
||||||
const SESSION_DIR: &str = "session";
|
const SESSIONS_DIR: &str = "sessions";
|
||||||
const WORKER_DIR: &str = "worker";
|
const WORKER_DIR: &str = "worker";
|
||||||
const SCHEMA_VERSION: u32 = 1;
|
const SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(transparent)]
|
|
||||||
pub struct StandaloneSessionId(Uuid);
|
|
||||||
|
|
||||||
impl StandaloneSessionId {
|
|
||||||
#[must_use]
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self(Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn short(self) -> String {
|
|
||||||
let simple = self.0.simple().to_string();
|
|
||||||
simple[simple.len() - 12..].to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StandaloneSessionId {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for StandaloneSessionId {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
self.0.fmt(formatter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for StandaloneSessionId {
|
|
||||||
type Err = uuid::Error;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
Uuid::parse_str(value).map(Self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct StandaloneCwdIdentity {
|
pub struct StandaloneCwdIdentity {
|
||||||
pub canonical_path: PathBuf,
|
pub canonical_path: PathBuf,
|
||||||
@@ -100,7 +62,7 @@ impl StandaloneCwdIdentity {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum StandaloneSessionStatus {
|
pub enum StandaloneWorkerStatus {
|
||||||
Active,
|
Active,
|
||||||
Stopped,
|
Stopped,
|
||||||
}
|
}
|
||||||
@@ -115,17 +77,20 @@ pub enum StandaloneShutdownReason {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StandaloneSessionRecord {
|
pub struct StandaloneWorkerRecord {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub revision: u64,
|
pub revision: u64,
|
||||||
pub session_id: StandaloneSessionId,
|
pub worker_id: WorkerId,
|
||||||
|
/// User-facing Worker name resolved from the profile.
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
|
/// Internal key used by the current name-keyed Worker store.
|
||||||
|
pub storage_key: String,
|
||||||
pub cwd: StandaloneCwdIdentity,
|
pub cwd: StandaloneCwdIdentity,
|
||||||
pub manifest: WorkerManifest,
|
pub manifest: WorkerManifest,
|
||||||
pub active_session_id: SessionId,
|
pub active_session_id: SessionId,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub active_segment_id: Option<SegmentId>,
|
pub active_segment_id: Option<SegmentId>,
|
||||||
pub status: StandaloneSessionStatus,
|
pub status: StandaloneWorkerStatus,
|
||||||
pub created_at_unix_ms: u64,
|
pub created_at_unix_ms: u64,
|
||||||
pub updated_at_unix_ms: u64,
|
pub updated_at_unix_ms: u64,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -145,11 +110,11 @@ pub enum StaleLeasePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct StandaloneSessionStore {
|
pub struct StandaloneWorkerStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StandaloneSessionStore {
|
impl StandaloneWorkerStore {
|
||||||
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
|
pub fn open(root: impl Into<PathBuf>) -> Result<Self, StandaloneStoreError> {
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?;
|
fs::create_dir_all(&root).map_err(StandaloneStoreError::Io)?;
|
||||||
@@ -171,35 +136,41 @@ impl StandaloneSessionStore {
|
|||||||
&self,
|
&self,
|
||||||
cwd: impl AsRef<Path>,
|
cwd: impl AsRef<Path>,
|
||||||
policy: StaleLeasePolicy,
|
policy: StaleLeasePolicy,
|
||||||
) -> Result<StandaloneSessionAllocation, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerAllocation, StandaloneStoreError> {
|
||||||
let id = StandaloneSessionId::new();
|
let worker_id = WorkerId::now_v7();
|
||||||
let cwd = StandaloneCwdIdentity::capture(cwd)?;
|
let cwd = StandaloneCwdIdentity::capture(cwd)?;
|
||||||
let dir = self.session_dir(id);
|
let dir = self.worker_dir(worker_id);
|
||||||
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
|
fs::create_dir(&dir).map_err(StandaloneStoreError::Io)?;
|
||||||
fs::create_dir(dir.join(SESSION_DIR)).map_err(StandaloneStoreError::Io)?;
|
fs::create_dir(dir.join(SESSIONS_DIR)).map_err(StandaloneStoreError::Io)?;
|
||||||
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
|
fs::create_dir(dir.join(WORKER_DIR)).map_err(StandaloneStoreError::Io)?;
|
||||||
let lease = self.acquire_lease(id, policy)?;
|
let lease = self.acquire_lease(worker_id, policy)?;
|
||||||
Ok(StandaloneSessionAllocation { id, cwd, lease })
|
Ok(StandaloneWorkerAllocation {
|
||||||
|
worker_id,
|
||||||
|
cwd,
|
||||||
|
lease,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn commit_created(
|
pub fn commit_created(
|
||||||
&self,
|
&self,
|
||||||
allocation: &StandaloneSessionAllocation,
|
allocation: &StandaloneWorkerAllocation,
|
||||||
manifest: WorkerManifest,
|
manifest: WorkerManifest,
|
||||||
|
storage_key: String,
|
||||||
active_session_id: SessionId,
|
active_session_id: SessionId,
|
||||||
active_segment_id: Option<SegmentId>,
|
active_segment_id: Option<SegmentId>,
|
||||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
let now = now_unix_ms()?;
|
let now = now_unix_ms()?;
|
||||||
let record = StandaloneSessionRecord {
|
let record = StandaloneWorkerRecord {
|
||||||
schema_version: SCHEMA_VERSION,
|
schema_version: SCHEMA_VERSION,
|
||||||
revision: 1,
|
revision: 1,
|
||||||
session_id: allocation.id,
|
worker_id: allocation.worker_id,
|
||||||
worker_name: manifest.worker.name.clone(),
|
worker_name: manifest.worker.name.clone(),
|
||||||
|
storage_key,
|
||||||
cwd: allocation.cwd.clone(),
|
cwd: allocation.cwd.clone(),
|
||||||
manifest,
|
manifest,
|
||||||
active_session_id,
|
active_session_id,
|
||||||
active_segment_id,
|
active_segment_id,
|
||||||
status: StandaloneSessionStatus::Active,
|
status: StandaloneWorkerStatus::Active,
|
||||||
created_at_unix_ms: now,
|
created_at_unix_ms: now,
|
||||||
updated_at_unix_ms: now,
|
updated_at_unix_ms: now,
|
||||||
shutdown_reason: None,
|
shutdown_reason: None,
|
||||||
@@ -208,22 +179,19 @@ impl StandaloneSessionStore {
|
|||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(
|
pub fn load(&self, id: WorkerId) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
&self,
|
let dir = self.worker_dir(id);
|
||||||
id: StandaloneSessionId,
|
|
||||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
|
||||||
let dir = self.session_dir(id);
|
|
||||||
if dir.join(COMMIT_MARKER).exists() {
|
if dir.join(COMMIT_MARKER).exists() {
|
||||||
return Err(StandaloneStoreError::IncompleteCommit(id));
|
return Err(StandaloneStoreError::IncompleteCommit(id));
|
||||||
}
|
}
|
||||||
let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| {
|
let bytes = fs::read(dir.join(RECORD_FILE)).map_err(|error| {
|
||||||
if error.kind() == io::ErrorKind::NotFound {
|
if error.kind() == io::ErrorKind::NotFound {
|
||||||
StandaloneStoreError::SessionNotFound(id)
|
StandaloneStoreError::WorkerNotFound(id)
|
||||||
} else {
|
} else {
|
||||||
StandaloneStoreError::Io(error)
|
StandaloneStoreError::Io(error)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let record: StandaloneSessionRecord = serde_json::from_slice(&bytes)
|
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
|
||||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
|
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
|
||||||
if record.schema_version > SCHEMA_VERSION {
|
if record.schema_version > SCHEMA_VERSION {
|
||||||
return Err(StandaloneStoreError::NewerSchema {
|
return Err(StandaloneStoreError::NewerSchema {
|
||||||
@@ -232,7 +200,7 @@ impl StandaloneSessionStore {
|
|||||||
supported: SCHEMA_VERSION,
|
supported: SCHEMA_VERSION,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if record.schema_version != SCHEMA_VERSION || record.session_id != id {
|
if record.schema_version != SCHEMA_VERSION || record.worker_id != id {
|
||||||
return Err(StandaloneStoreError::InvalidRecord(id));
|
return Err(StandaloneStoreError::InvalidRecord(id));
|
||||||
}
|
}
|
||||||
Ok(record)
|
Ok(record)
|
||||||
@@ -243,7 +211,7 @@ impl StandaloneSessionStore {
|
|||||||
cwd: impl AsRef<Path>,
|
cwd: impl AsRef<Path>,
|
||||||
scope: StandaloneListScope,
|
scope: StandaloneListScope,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<StandaloneSessionRecord>, StandaloneStoreError> {
|
) -> Result<Vec<StandaloneWorkerRecord>, StandaloneStoreError> {
|
||||||
let current_cwd = (scope == StandaloneListScope::CurrentCwd)
|
let current_cwd = (scope == StandaloneListScope::CurrentCwd)
|
||||||
.then(|| StandaloneCwdIdentity::capture(cwd))
|
.then(|| StandaloneCwdIdentity::capture(cwd))
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
@@ -269,12 +237,7 @@ impl StandaloneSessionStore {
|
|||||||
right
|
right
|
||||||
.updated_at_unix_ms
|
.updated_at_unix_ms
|
||||||
.cmp(&left.updated_at_unix_ms)
|
.cmp(&left.updated_at_unix_ms)
|
||||||
.then_with(|| {
|
.then_with(|| right.worker_id.to_string().cmp(&left.worker_id.to_string()))
|
||||||
right
|
|
||||||
.session_id
|
|
||||||
.to_string()
|
|
||||||
.cmp(&left.session_id.to_string())
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
records.truncate(limit);
|
records.truncate(limit);
|
||||||
Ok(records)
|
Ok(records)
|
||||||
@@ -282,10 +245,10 @@ impl StandaloneSessionStore {
|
|||||||
|
|
||||||
pub fn acquire_lease(
|
pub fn acquire_lease(
|
||||||
&self,
|
&self,
|
||||||
id: StandaloneSessionId,
|
id: WorkerId,
|
||||||
policy: StaleLeasePolicy,
|
policy: StaleLeasePolicy,
|
||||||
) -> Result<StandaloneSessionLease, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerLease, StandaloneStoreError> {
|
||||||
let dir = self.session_dir(id);
|
let dir = self.worker_dir(id);
|
||||||
let path = dir.join(LEASE_FILE);
|
let path = dir.join(LEASE_FILE);
|
||||||
let _guard = LeaseMutationGuard::acquire(&dir)?;
|
let _guard = LeaseMutationGuard::acquire(&dir)?;
|
||||||
let lease = LeaseRecord::current()?;
|
let lease = LeaseRecord::current()?;
|
||||||
@@ -296,7 +259,7 @@ impl StandaloneSessionStore {
|
|||||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
||||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||||
sync_directory(&dir)?;
|
sync_directory(&dir)?;
|
||||||
return Ok(StandaloneSessionLease {
|
return Ok(StandaloneWorkerLease {
|
||||||
path,
|
path,
|
||||||
lease_id: lease.lease_id,
|
lease_id: lease.lease_id,
|
||||||
released: false,
|
released: false,
|
||||||
@@ -306,7 +269,7 @@ impl StandaloneSessionStore {
|
|||||||
let existing = read_lease(&path, id)?;
|
let existing = read_lease(&path, id)?;
|
||||||
match existing.liveness() {
|
match existing.liveness() {
|
||||||
LeaseLiveness::Live => {
|
LeaseLiveness::Live => {
|
||||||
return Err(StandaloneStoreError::SessionLeased(id));
|
return Err(StandaloneStoreError::WorkerLeased(id));
|
||||||
}
|
}
|
||||||
LeaseLiveness::Unknown => {
|
LeaseLiveness::Unknown => {
|
||||||
return Err(StandaloneStoreError::LeaseLivenessUnknown(id));
|
return Err(StandaloneStoreError::LeaseLivenessUnknown(id));
|
||||||
@@ -326,16 +289,16 @@ impl StandaloneSessionStore {
|
|||||||
|
|
||||||
pub fn update_active_pointer(
|
pub fn update_active_pointer(
|
||||||
&self,
|
&self,
|
||||||
record: &StandaloneSessionRecord,
|
record: &StandaloneWorkerRecord,
|
||||||
active_session_id: SessionId,
|
active_session_id: SessionId,
|
||||||
active_segment_id: Option<SegmentId>,
|
active_segment_id: Option<SegmentId>,
|
||||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
let mut next = record.clone();
|
let mut next = record.clone();
|
||||||
next.revision = next.revision.saturating_add(1);
|
next.revision = next.revision.saturating_add(1);
|
||||||
next.updated_at_unix_ms = now_unix_ms()?;
|
next.updated_at_unix_ms = now_unix_ms()?;
|
||||||
next.active_session_id = active_session_id;
|
next.active_session_id = active_session_id;
|
||||||
next.active_segment_id = active_segment_id;
|
next.active_segment_id = active_segment_id;
|
||||||
next.status = StandaloneSessionStatus::Active;
|
next.status = StandaloneWorkerStatus::Active;
|
||||||
next.shutdown_reason = None;
|
next.shutdown_reason = None;
|
||||||
self.commit_record(Some(record.revision), &next)?;
|
self.commit_record(Some(record.revision), &next)?;
|
||||||
Ok(next)
|
Ok(next)
|
||||||
@@ -343,73 +306,73 @@ impl StandaloneSessionStore {
|
|||||||
|
|
||||||
pub fn mark_stopped(
|
pub fn mark_stopped(
|
||||||
&self,
|
&self,
|
||||||
record: &StandaloneSessionRecord,
|
record: &StandaloneWorkerRecord,
|
||||||
active_session_id: SessionId,
|
active_session_id: SessionId,
|
||||||
active_segment_id: Option<SegmentId>,
|
active_segment_id: Option<SegmentId>,
|
||||||
reason: StandaloneShutdownReason,
|
reason: StandaloneShutdownReason,
|
||||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
let mut next = record.clone();
|
let mut next = record.clone();
|
||||||
next.revision = next.revision.saturating_add(1);
|
next.revision = next.revision.saturating_add(1);
|
||||||
next.updated_at_unix_ms = now_unix_ms()?;
|
next.updated_at_unix_ms = now_unix_ms()?;
|
||||||
next.active_session_id = active_session_id;
|
next.active_session_id = active_session_id;
|
||||||
next.active_segment_id = active_segment_id;
|
next.active_segment_id = active_segment_id;
|
||||||
next.status = StandaloneSessionStatus::Stopped;
|
next.status = StandaloneWorkerStatus::Stopped;
|
||||||
next.shutdown_reason = Some(reason);
|
next.shutdown_reason = Some(reason);
|
||||||
self.commit_record(Some(record.revision), &next)?;
|
self.commit_record(Some(record.revision), &next)?;
|
||||||
Ok(next)
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete(&self, id: StandaloneSessionId) -> Result<(), StandaloneStoreError> {
|
pub fn delete(&self, id: WorkerId) -> Result<(), StandaloneStoreError> {
|
||||||
let record = self.load(id)?;
|
let record = self.load(id)?;
|
||||||
if record.status != StandaloneSessionStatus::Stopped {
|
if record.status != StandaloneWorkerStatus::Stopped {
|
||||||
return Err(StandaloneStoreError::DeleteActive(id));
|
return Err(StandaloneStoreError::DeleteActive(id));
|
||||||
}
|
}
|
||||||
let session_dir = self.session_dir(id);
|
let worker_dir = self.worker_dir(id);
|
||||||
let _guard = LeaseMutationGuard::acquire(&session_dir)?;
|
let _guard = LeaseMutationGuard::acquire(&worker_dir)?;
|
||||||
let lease_path = session_dir.join(LEASE_FILE);
|
let lease_path = worker_dir.join(LEASE_FILE);
|
||||||
if lease_path.exists() {
|
if lease_path.exists() {
|
||||||
let lease = read_lease(&lease_path, id)?;
|
let lease = read_lease(&lease_path, id)?;
|
||||||
return Err(match lease.liveness() {
|
return Err(match lease.liveness() {
|
||||||
LeaseLiveness::Live => StandaloneStoreError::SessionLeased(id),
|
LeaseLiveness::Live => StandaloneStoreError::WorkerLeased(id),
|
||||||
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
|
LeaseLiveness::Stale => StandaloneStoreError::StaleLease(id),
|
||||||
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
|
LeaseLiveness::Unknown => StandaloneStoreError::LeaseLivenessUnknown(id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?;
|
fs::remove_dir_all(self.worker_dir(id)).map_err(StandaloneStoreError::Io)?;
|
||||||
sync_directory(&self.root)
|
sync_directory(&self.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn session_log_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
pub fn sessions_dir(&self, id: WorkerId) -> PathBuf {
|
||||||
self.session_dir(id).join(SESSION_DIR)
|
self.worker_dir(id).join(SESSIONS_DIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn worker_metadata_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
pub fn worker_metadata_dir(&self, id: WorkerId) -> PathBuf {
|
||||||
self.session_dir(id).join(WORKER_DIR)
|
self.worker_dir(id).join(WORKER_DIR)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub(crate) fn runtime_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
pub(crate) fn runtime_dir(&self, id: WorkerId) -> PathBuf {
|
||||||
self.session_dir(id).join("runtime")
|
self.worker_dir(id).join("runtime")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn abandon_allocation(
|
pub(crate) fn abandon_allocation(
|
||||||
&self,
|
&self,
|
||||||
allocation: StandaloneSessionAllocation,
|
allocation: StandaloneWorkerAllocation,
|
||||||
) -> Result<(), StandaloneStoreError> {
|
) -> Result<(), StandaloneStoreError> {
|
||||||
let id = allocation.id;
|
let worker_id = allocation.worker_id;
|
||||||
allocation.lease.release()?;
|
allocation.lease.release()?;
|
||||||
fs::remove_dir_all(self.session_dir(id)).map_err(StandaloneStoreError::Io)?;
|
fs::remove_dir_all(self.worker_dir(worker_id)).map_err(StandaloneStoreError::Io)?;
|
||||||
sync_directory(&self.root)
|
sync_directory(&self.root)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn commit_record(
|
fn commit_record(
|
||||||
&self,
|
&self,
|
||||||
expected_revision: Option<u64>,
|
expected_revision: Option<u64>,
|
||||||
next: &StandaloneSessionRecord,
|
next: &StandaloneWorkerRecord,
|
||||||
) -> Result<(), StandaloneStoreError> {
|
) -> Result<(), StandaloneStoreError> {
|
||||||
let dir = self.session_dir(next.session_id);
|
let dir = self.worker_dir(next.worker_id);
|
||||||
let marker = dir.join(COMMIT_MARKER);
|
let marker = dir.join(COMMIT_MARKER);
|
||||||
let mut marker_file = OpenOptions::new()
|
let mut marker_file = OpenOptions::new()
|
||||||
.write(true)
|
.write(true)
|
||||||
@@ -417,7 +380,7 @@ impl StandaloneSessionStore {
|
|||||||
.open(&marker)
|
.open(&marker)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
if error.kind() == io::ErrorKind::AlreadyExists {
|
if error.kind() == io::ErrorKind::AlreadyExists {
|
||||||
StandaloneStoreError::IncompleteCommit(next.session_id)
|
StandaloneStoreError::IncompleteCommit(next.worker_id)
|
||||||
} else {
|
} else {
|
||||||
StandaloneStoreError::Io(error)
|
StandaloneStoreError::Io(error)
|
||||||
}
|
}
|
||||||
@@ -427,11 +390,11 @@ impl StandaloneSessionStore {
|
|||||||
sync_directory(&dir)?;
|
sync_directory(&dir)?;
|
||||||
|
|
||||||
if let Some(expected) = expected_revision {
|
if let Some(expected) = expected_revision {
|
||||||
let current = self.load_record_while_committing(next.session_id)?;
|
let current = self.load_record_while_committing(next.worker_id)?;
|
||||||
if current.revision != expected {
|
if current.revision != expected {
|
||||||
let _ = fs::remove_file(&marker);
|
let _ = fs::remove_file(&marker);
|
||||||
return Err(StandaloneStoreError::RevisionConflict {
|
return Err(StandaloneStoreError::RevisionConflict {
|
||||||
id: next.session_id,
|
id: next.worker_id,
|
||||||
expected,
|
expected,
|
||||||
found: current.revision,
|
found: current.revision,
|
||||||
});
|
});
|
||||||
@@ -461,30 +424,30 @@ impl StandaloneSessionStore {
|
|||||||
|
|
||||||
fn load_record_while_committing(
|
fn load_record_while_committing(
|
||||||
&self,
|
&self,
|
||||||
id: StandaloneSessionId,
|
id: WorkerId,
|
||||||
) -> Result<StandaloneSessionRecord, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
let bytes =
|
let bytes =
|
||||||
fs::read(self.session_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||||
serde_json::from_slice(&bytes)
|
serde_json::from_slice(&bytes)
|
||||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn session_dir(&self, id: StandaloneSessionId) -> PathBuf {
|
fn worker_dir(&self, id: WorkerId) -> PathBuf {
|
||||||
self.root.join(id.to_string())
|
self.root.join(id.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct StandaloneSessionAllocation {
|
pub struct StandaloneWorkerAllocation {
|
||||||
id: StandaloneSessionId,
|
worker_id: WorkerId,
|
||||||
cwd: StandaloneCwdIdentity,
|
cwd: StandaloneCwdIdentity,
|
||||||
lease: StandaloneSessionLease,
|
lease: StandaloneWorkerLease,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StandaloneSessionAllocation {
|
impl StandaloneWorkerAllocation {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn id(&self) -> StandaloneSessionId {
|
pub fn worker_id(&self) -> WorkerId {
|
||||||
self.id
|
self.worker_id
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
@@ -492,19 +455,19 @@ impl StandaloneSessionAllocation {
|
|||||||
&self.cwd
|
&self.cwd
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn into_lease(self) -> StandaloneSessionLease {
|
pub fn into_lease(self) -> StandaloneWorkerLease {
|
||||||
self.lease
|
self.lease
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct StandaloneSessionLease {
|
pub struct StandaloneWorkerLease {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
lease_id: Uuid,
|
lease_id: Uuid,
|
||||||
released: bool,
|
released: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StandaloneSessionLease {
|
impl StandaloneWorkerLease {
|
||||||
pub fn release(mut self) -> Result<(), StandaloneStoreError> {
|
pub fn release(mut self) -> Result<(), StandaloneStoreError> {
|
||||||
self.release_inner()
|
self.release_inner()
|
||||||
}
|
}
|
||||||
@@ -534,7 +497,7 @@ impl StandaloneSessionLease {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for StandaloneSessionLease {
|
impl Drop for StandaloneWorkerLease {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = self.release_inner();
|
let _ = self.release_inner();
|
||||||
}
|
}
|
||||||
@@ -624,7 +587,7 @@ fn classify_lease_liveness(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_lease(path: &Path, id: StandaloneSessionId) -> Result<LeaseRecord, StandaloneStoreError> {
|
fn read_lease(path: &Path, id: WorkerId) -> Result<LeaseRecord, StandaloneStoreError> {
|
||||||
let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?;
|
let bytes = fs::read(path).map_err(StandaloneStoreError::Io)?;
|
||||||
serde_json::from_slice(&bytes)
|
serde_json::from_slice(&bytes)
|
||||||
.map_err(|source| StandaloneStoreError::CorruptLease { id, source })
|
.map_err(|source| StandaloneStoreError::CorruptLease { id, source })
|
||||||
@@ -692,47 +655,47 @@ pub enum StandaloneStoreError {
|
|||||||
CwdUnavailable(#[source] io::Error),
|
CwdUnavailable(#[source] io::Error),
|
||||||
#[error("standalone cwd is not a directory")]
|
#[error("standalone cwd is not a directory")]
|
||||||
CwdNotDirectory,
|
CwdNotDirectory,
|
||||||
#[error("standalone cwd identity no longer matches the persisted session")]
|
#[error("standalone cwd identity no longer matches the persisted Worker")]
|
||||||
CwdIdentityMismatch,
|
CwdIdentityMismatch,
|
||||||
#[error("standalone session {0} was not found")]
|
#[error("standalone Worker {0} was not found")]
|
||||||
SessionNotFound(StandaloneSessionId),
|
WorkerNotFound(WorkerId),
|
||||||
#[error("standalone session {0} has an incomplete metadata commit")]
|
#[error("standalone Worker {0} has an incomplete metadata commit")]
|
||||||
IncompleteCommit(StandaloneSessionId),
|
IncompleteCommit(WorkerId),
|
||||||
#[error("standalone session {0} has invalid metadata")]
|
#[error("standalone Worker {0} has invalid metadata")]
|
||||||
InvalidRecord(StandaloneSessionId),
|
InvalidRecord(WorkerId),
|
||||||
#[error("standalone session {id} metadata is corrupt")]
|
#[error("standalone Worker {id} metadata is corrupt")]
|
||||||
CorruptRecord {
|
CorruptRecord {
|
||||||
id: StandaloneSessionId,
|
id: WorkerId,
|
||||||
#[source]
|
#[source]
|
||||||
source: serde_json::Error,
|
source: serde_json::Error,
|
||||||
},
|
},
|
||||||
#[error("standalone session {id} lease is corrupt")]
|
#[error("standalone Worker {id} lease is corrupt")]
|
||||||
CorruptLease {
|
CorruptLease {
|
||||||
id: StandaloneSessionId,
|
id: WorkerId,
|
||||||
#[source]
|
#[source]
|
||||||
source: serde_json::Error,
|
source: serde_json::Error,
|
||||||
},
|
},
|
||||||
#[error("standalone session {id} uses schema {found}, newer than supported schema {supported}")]
|
#[error("standalone Worker {id} uses schema {found}, newer than supported schema {supported}")]
|
||||||
NewerSchema {
|
NewerSchema {
|
||||||
id: StandaloneSessionId,
|
id: WorkerId,
|
||||||
found: u32,
|
found: u32,
|
||||||
supported: u32,
|
supported: u32,
|
||||||
},
|
},
|
||||||
#[error("standalone session {0} is already active")]
|
#[error("standalone Worker {0} is already active")]
|
||||||
SessionLeased(StandaloneSessionId),
|
WorkerLeased(WorkerId),
|
||||||
#[error("standalone session {0} lease liveness cannot be proven; recovery is rejected")]
|
#[error("standalone Worker {0} lease liveness cannot be proven; recovery is rejected")]
|
||||||
LeaseLivenessUnknown(StandaloneSessionId),
|
LeaseLivenessUnknown(WorkerId),
|
||||||
#[error("standalone session {0} has a stale lease; explicit recovery is required")]
|
#[error("standalone Worker {0} has a stale lease; explicit recovery is required")]
|
||||||
StaleLease(StandaloneSessionId),
|
StaleLease(WorkerId),
|
||||||
#[error("standalone session lease ownership changed")]
|
#[error("standalone Worker lease ownership changed")]
|
||||||
LeaseOwnershipLost,
|
LeaseOwnershipLost,
|
||||||
#[error("standalone session {0} must be stopped before deletion")]
|
#[error("standalone Worker {0} must be stopped before deletion")]
|
||||||
DeleteActive(StandaloneSessionId),
|
DeleteActive(WorkerId),
|
||||||
#[error(
|
#[error(
|
||||||
"standalone session {id} metadata revision changed (expected {expected}, found {found})"
|
"standalone Worker {id} metadata revision changed (expected {expected}, found {found})"
|
||||||
)]
|
)]
|
||||||
RevisionConflict {
|
RevisionConflict {
|
||||||
id: StandaloneSessionId,
|
id: WorkerId,
|
||||||
expected: u64,
|
expected: u64,
|
||||||
found: u64,
|
found: u64,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ use agen::llm_client::error::ClientError;
|
|||||||
use agen::llm_client::event::{Event as LlmEvent, StopReason};
|
use agen::llm_client::event::{Event as LlmEvent, StopReason};
|
||||||
use agen::llm_client::types::Request;
|
use agen::llm_client::types::Request;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use client::Client;
|
||||||
|
use client::transport::in_process::Socket as InProcessSocket;
|
||||||
use futures::{Stream, stream};
|
use futures::{Stream, stream};
|
||||||
use protocol::{Event, Method};
|
use protocol::{Event, Method};
|
||||||
use standalone::{
|
use standalone::{
|
||||||
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
|
StaleLeasePolicy, StandaloneHost, StandaloneLaunchConfig, StandaloneListScope,
|
||||||
StandaloneSessionStatus, StandaloneSessionStore, StandaloneStartupError, StandaloneStoreError,
|
StandaloneStartupError, StandaloneStoreError, StandaloneWorkerStatus, StandaloneWorkerStore,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -88,17 +90,35 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
|||||||
let host = StandaloneHost::start_with_model_client(launch, client)
|
let host = StandaloneHost::start_with_model_client(launch, client)
|
||||||
.await
|
.await
|
||||||
.expect("start in-process host");
|
.expect("start in-process host");
|
||||||
let mut events = host.subscribe();
|
assert_eq!(host.record().worker_name, worker_name);
|
||||||
|
assert_eq!(host.record().manifest.worker.name, worker_name);
|
||||||
|
assert_eq!(
|
||||||
|
host.record().storage_key,
|
||||||
|
format!("standalone-{}", host.worker_id())
|
||||||
|
);
|
||||||
|
let mut protocol_client = host.connect();
|
||||||
|
|
||||||
host.send(Method::run_text("read the probe"))
|
protocol_client
|
||||||
|
.send(&Method::run_text("read the probe"))
|
||||||
.await
|
.await
|
||||||
.expect("submit input");
|
.expect("submit input");
|
||||||
|
|
||||||
tokio::time::timeout(Duration::from_secs(30), async {
|
tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
let mut saw_user_message = false;
|
||||||
let mut saw_text = false;
|
let mut saw_text = false;
|
||||||
let mut saw_tool_result = false;
|
let mut saw_tool_result = false;
|
||||||
loop {
|
loop {
|
||||||
match events.recv().await.expect("worker event") {
|
match protocol_client
|
||||||
|
.next_event()
|
||||||
|
.await
|
||||||
|
.expect("protocol event")
|
||||||
|
.expect("worker event")
|
||||||
|
{
|
||||||
|
Event::UserMessage { segments }
|
||||||
|
if format!("{segments:?}").contains("read the probe") =>
|
||||||
|
{
|
||||||
|
saw_user_message = true;
|
||||||
|
}
|
||||||
Event::TextDelta { text } if text.contains("standalone response") => {
|
Event::TextDelta { text } if text.contains("standalone response") => {
|
||||||
saw_text = true;
|
saw_text = true;
|
||||||
}
|
}
|
||||||
@@ -106,6 +126,10 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() {
|
|||||||
saw_tool_result = true;
|
saw_tool_result = true;
|
||||||
}
|
}
|
||||||
Event::RunEnd { .. } => {
|
Event::RunEnd { .. } => {
|
||||||
|
assert!(
|
||||||
|
saw_user_message,
|
||||||
|
"stream must expose the committed user message"
|
||||||
|
);
|
||||||
assert!(saw_text, "stream must expose the model text delta");
|
assert!(saw_text, "stream must expose the model text delta");
|
||||||
assert!(saw_tool_result, "stream must expose the tool result");
|
assert!(saw_tool_result, "stream must expose the tool result");
|
||||||
break;
|
break;
|
||||||
@@ -220,7 +244,7 @@ type TestResult = Result<(), Box<dyn std::error::Error>>;
|
|||||||
async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult {
|
async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope() -> TestResult {
|
||||||
let temp = tempfile::tempdir()?;
|
let temp = tempfile::tempdir()?;
|
||||||
let cwd = temp.path().join("project");
|
let cwd = temp.path().join("project");
|
||||||
let state_dir = temp.path().join("client").join("standalone-sessions");
|
let state_dir = temp.path().join("client").join("standalone-workers");
|
||||||
std::fs::create_dir_all(&cwd)?;
|
std::fs::create_dir_all(&cwd)?;
|
||||||
let launch = StandaloneLaunchConfig::new(
|
let launch = StandaloneLaunchConfig::new(
|
||||||
&cwd,
|
&cwd,
|
||||||
@@ -250,23 +274,26 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
|
let host = StandaloneHost::start_with_model_client(launch, first_client).await?;
|
||||||
let session_id = host.session_id();
|
let worker_id = host.worker_id();
|
||||||
let mut events = host.subscribe();
|
let mut protocol_client = host.connect();
|
||||||
host.send(Method::run_text("first request")).await?;
|
protocol_client
|
||||||
wait_for_run_end(&mut events).await?;
|
.send(&Method::run_text("first request"))
|
||||||
host.send(Method::Notify {
|
.await?;
|
||||||
|
wait_for_run_end(&mut protocol_client).await?;
|
||||||
|
protocol_client
|
||||||
|
.send(&Method::Notify {
|
||||||
message: "persisted notification".to_string(),
|
message: "persisted notification".to_string(),
|
||||||
auto_run: true,
|
auto_run: true,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
wait_for_run_end(&mut events).await?;
|
wait_for_run_end(&mut protocol_client).await?;
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
|
|
||||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||||
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
|
let current = store.list(&cwd, StandaloneListScope::CurrentCwd, 100)?;
|
||||||
assert_eq!(current.len(), 1);
|
assert_eq!(current.len(), 1);
|
||||||
assert_eq!(current[0].session_id, session_id);
|
assert_eq!(current[0].worker_id, worker_id);
|
||||||
assert_eq!(current[0].status, StandaloneSessionStatus::Stopped);
|
assert_eq!(current[0].status, StandaloneWorkerStatus::Stopped);
|
||||||
let other_cwd = temp.path().join("other");
|
let other_cwd = temp.path().join("other");
|
||||||
std::fs::create_dir(&other_cwd)?;
|
std::fs::create_dir(&other_cwd)?;
|
||||||
assert!(
|
assert!(
|
||||||
@@ -286,18 +313,31 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
|
|||||||
]]);
|
]]);
|
||||||
let second_inspection = second_client.clone();
|
let second_inspection = second_client.clone();
|
||||||
let host =
|
let host =
|
||||||
StandaloneHost::restore_with_model_client(state_dir.clone(), session_id, second_client)
|
StandaloneHost::restore_with_model_client(state_dir.clone(), worker_id, second_client)
|
||||||
.await?;
|
.await?;
|
||||||
let snapshot = format!("{:?}", host.snapshot());
|
assert_eq!(
|
||||||
|
host.record().worker_name,
|
||||||
|
"display-name-is-not-session-identity"
|
||||||
|
);
|
||||||
|
assert_eq!(host.record().storage_key, format!("standalone-{worker_id}"));
|
||||||
|
let mut protocol_client = host.connect();
|
||||||
|
let snapshot = format!(
|
||||||
|
"{:?}",
|
||||||
|
protocol_client
|
||||||
|
.next_event()
|
||||||
|
.await
|
||||||
|
.expect("restored protocol stream")
|
||||||
|
.expect("restored snapshot")
|
||||||
|
);
|
||||||
assert!(snapshot.contains("first request"), "{snapshot}");
|
assert!(snapshot.contains("first request"), "{snapshot}");
|
||||||
assert!(snapshot.contains("first answer"), "{snapshot}");
|
assert!(snapshot.contains("first answer"), "{snapshot}");
|
||||||
assert!(snapshot.contains("persisted task"), "{snapshot}");
|
assert!(snapshot.contains("persisted task"), "{snapshot}");
|
||||||
assert!(snapshot.contains("persisted notification"), "{snapshot}");
|
assert!(snapshot.contains("persisted notification"), "{snapshot}");
|
||||||
|
|
||||||
let mut events = host.subscribe();
|
protocol_client
|
||||||
host.send(Method::run_text("continue after restore"))
|
.send(&Method::run_text("continue after restore"))
|
||||||
.await?;
|
.await?;
|
||||||
wait_for_run_end(&mut events).await?;
|
wait_for_run_end(&mut protocol_client).await?;
|
||||||
let request = second_inspection
|
let request = second_inspection
|
||||||
.requests()
|
.requests()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -309,11 +349,11 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope(
|
|||||||
assert!(projected.contains("persisted task"), "{projected}");
|
assert!(projected.contains("persisted task"), "{projected}");
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
|
|
||||||
store.delete(session_id)?;
|
store.delete(worker_id)?;
|
||||||
assert!(cwd.exists(), "deleting session state must not mutate cwd");
|
assert!(cwd.exists(), "deleting session state must not mutate cwd");
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.load(session_id),
|
store.load(worker_id),
|
||||||
Err(StandaloneStoreError::SessionNotFound(_))
|
Err(StandaloneStoreError::WorkerNotFound(_))
|
||||||
));
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -334,28 +374,25 @@ async fn standalone_restore_rejects_concurrent_lease_and_missing_cwd() -> TestRe
|
|||||||
.resolve()?;
|
.resolve()?;
|
||||||
let host =
|
let host =
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||||
let session_id = host.session_id();
|
let worker_id = host.worker_id();
|
||||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.acquire_lease(session_id, StaleLeasePolicy::Recover),
|
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
|
||||||
Err(StandaloneStoreError::SessionLeased(id)) if id == session_id
|
Err(StandaloneStoreError::WorkerLeased(id)) if id == worker_id
|
||||||
));
|
));
|
||||||
let restore = StandaloneHost::restore_with_model_client(
|
let restore = StandaloneHost::restore_with_model_client(
|
||||||
state_dir.clone(),
|
state_dir.clone(),
|
||||||
session_id,
|
worker_id,
|
||||||
ScriptedClient::new(Vec::new()),
|
ScriptedClient::new(Vec::new()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
assert!(matches!(
|
assert!(matches!(restore, Err(StandaloneStartupError::WorkerActive)));
|
||||||
restore,
|
|
||||||
Err(StandaloneStartupError::SessionActive)
|
|
||||||
));
|
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
|
|
||||||
std::fs::rename(&cwd, &moved)?;
|
std::fs::rename(&cwd, &moved)?;
|
||||||
let restore = StandaloneHost::restore_with_model_client(
|
let restore = StandaloneHost::restore_with_model_client(
|
||||||
state_dir,
|
state_dir,
|
||||||
session_id,
|
worker_id,
|
||||||
ScriptedClient::new(Vec::new()),
|
ScriptedClient::new(Vec::new()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -392,11 +429,11 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
|||||||
});
|
});
|
||||||
let host =
|
let host =
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||||
let session_id = host.session_id();
|
let worker_id = host.worker_id();
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.load(session_id)?.manifest.profile,
|
store.load(worker_id)?.manifest.profile,
|
||||||
Some(manifest::ProfileManifestSnapshot {
|
Some(manifest::ProfileManifestSnapshot {
|
||||||
source: manifest::ProfileSource::Registry {
|
source: manifest::ProfileSource::Registry {
|
||||||
source: manifest::ProfileRegistrySource::User,
|
source: manifest::ProfileRegistrySource::User,
|
||||||
@@ -405,9 +442,9 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
|||||||
..
|
..
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
let session_dir = state_dir.join(session_id.to_string());
|
let worker_dir = state_dir.join(worker_id.to_string());
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
session_dir.join("lease.json"),
|
worker_dir.join("lease.json"),
|
||||||
serde_json::to_vec(&serde_json::json!({
|
serde_json::to_vec(&serde_json::json!({
|
||||||
"lease_id": uuid::Uuid::now_v7(),
|
"lease_id": uuid::Uuid::now_v7(),
|
||||||
"pid": u32::MAX,
|
"pid": u32::MAX,
|
||||||
@@ -418,7 +455,7 @@ async fn standalone_restore_recovers_only_a_proven_stale_lease() -> TestResult {
|
|||||||
|
|
||||||
let host = StandaloneHost::restore_with_model_client(
|
let host = StandaloneHost::restore_with_model_client(
|
||||||
state_dir,
|
state_dir,
|
||||||
session_id,
|
worker_id,
|
||||||
ScriptedClient::new(Vec::new()),
|
ScriptedClient::new(Vec::new()),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -439,11 +476,11 @@ async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestRes
|
|||||||
.resolve()?;
|
.resolve()?;
|
||||||
let host =
|
let host =
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||||
let session_id = host.session_id();
|
let worker_id = host.worker_id();
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
let session_dir = state_dir.join(session_id.to_string());
|
let worker_dir = state_dir.join(worker_id.to_string());
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
session_dir.join("lease.json"),
|
worker_dir.join("lease.json"),
|
||||||
serde_json::to_vec(&serde_json::json!({
|
serde_json::to_vec(&serde_json::json!({
|
||||||
"lease_id": uuid::Uuid::now_v7(),
|
"lease_id": uuid::Uuid::now_v7(),
|
||||||
"pid": std::process::id(),
|
"pid": std::process::id(),
|
||||||
@@ -451,14 +488,14 @@ async fn standalone_restore_rejects_lease_with_missing_start_marker() -> TestRes
|
|||||||
}))?,
|
}))?,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.acquire_lease(session_id, StaleLeasePolicy::Recover),
|
store.acquire_lease(worker_id, StaleLeasePolicy::Recover),
|
||||||
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == session_id
|
Err(StandaloneStoreError::LeaseLivenessUnknown(id)) if id == worker_id
|
||||||
));
|
));
|
||||||
let restore = StandaloneHost::restore_with_model_client(
|
let restore = StandaloneHost::restore_with_model_client(
|
||||||
state_dir,
|
state_dir,
|
||||||
session_id,
|
worker_id,
|
||||||
ScriptedClient::new(Vec::new()),
|
ScriptedClient::new(Vec::new()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -482,31 +519,31 @@ async fn standalone_metadata_fails_closed_on_incomplete_or_newer_records() -> Te
|
|||||||
.resolve()?;
|
.resolve()?;
|
||||||
let host =
|
let host =
|
||||||
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
StandaloneHost::start_with_model_client(launch, ScriptedClient::new(Vec::new())).await?;
|
||||||
let session_id = host.session_id();
|
let worker_id = host.worker_id();
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
let store = StandaloneSessionStore::open(&state_dir)?;
|
let store = StandaloneWorkerStore::open(&state_dir)?;
|
||||||
let session_dir = state_dir.join(session_id.to_string());
|
let worker_dir = state_dir.join(worker_id.to_string());
|
||||||
std::fs::write(session_dir.join("commit.pending"), b"interrupted\n")?;
|
std::fs::write(worker_dir.join("commit.pending"), b"interrupted\n")?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.load(session_id),
|
store.load(worker_id),
|
||||||
Err(StandaloneStoreError::IncompleteCommit(id)) if id == session_id
|
Err(StandaloneStoreError::IncompleteCommit(id)) if id == worker_id
|
||||||
));
|
));
|
||||||
std::fs::remove_file(session_dir.join("commit.pending"))?;
|
std::fs::remove_file(worker_dir.join("commit.pending"))?;
|
||||||
let record_path = session_dir.join("record.json");
|
let record_path = worker_dir.join("record.json");
|
||||||
let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?;
|
let mut record: serde_json::Value = serde_json::from_slice(&std::fs::read(&record_path)?)?;
|
||||||
record["schema_version"] = serde_json::json!(u32::MAX);
|
record["schema_version"] = serde_json::json!(u32::MAX);
|
||||||
std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?;
|
std::fs::write(&record_path, serde_json::to_vec_pretty(&record)?)?;
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
store.load(session_id),
|
store.load(worker_id),
|
||||||
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == session_id
|
Err(StandaloneStoreError::NewerSchema { id, .. }) if id == worker_id
|
||||||
));
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_run_end(events: &mut tokio::sync::broadcast::Receiver<Event>) -> TestResult {
|
async fn wait_for_run_end(client: &mut Client<InProcessSocket>) -> TestResult {
|
||||||
tokio::time::timeout(Duration::from_secs(10), async {
|
tokio::time::timeout(Duration::from_secs(10), async {
|
||||||
loop {
|
loop {
|
||||||
if matches!(events.recv().await, Ok(Event::RunEnd { .. })) {
|
if matches!(client.next_event().await, Ok(Some(Event::RunEnd { .. }))) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-6
@@ -21,6 +21,7 @@ struct BashParams {
|
|||||||
|
|
||||||
pub(crate) struct BashTool {
|
pub(crate) struct BashTool {
|
||||||
session: WorkdirSessionHandle,
|
session: WorkdirSessionHandle,
|
||||||
|
output_dir: PathBuf,
|
||||||
state: Arc<Mutex<BashExecutionState>>,
|
state: Arc<Mutex<BashExecutionState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ impl Tool for BashTool {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
output_limit: INLINE_BYTE_BUDGET,
|
||||||
|
spill_dir: Some(self.output_dir.clone()),
|
||||||
tool_call_id: Some(call_id.clone()),
|
tool_call_id: Some(call_id.clone()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -183,10 +185,15 @@ impl Tool for BashTool {
|
|||||||
let content = if output.content.is_empty() {
|
let content = if output.content.is_empty() {
|
||||||
None
|
None
|
||||||
} else if output.truncated {
|
} else if output.truncated {
|
||||||
Some(format!(
|
let notice = match output.output_path {
|
||||||
"[showing bounded WorkdirSession command output; additional output was truncated]\n{}",
|
Some(path) => format!(
|
||||||
output.content
|
"[showing bounded WorkdirSession command output; full output saved to {}]",
|
||||||
))
|
path.display()
|
||||||
|
),
|
||||||
|
None => "[showing bounded WorkdirSession command output; additional output was truncated]"
|
||||||
|
.to_owned(),
|
||||||
|
};
|
||||||
|
Some(format!("{notice}\n{}", output.content))
|
||||||
} else {
|
} else {
|
||||||
Some(output.content)
|
Some(output.content)
|
||||||
};
|
};
|
||||||
@@ -259,16 +266,137 @@ fn truncate_for_summary(command: &str) -> String {
|
|||||||
summary
|
summary
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDefinition {
|
pub fn bash_tool(session: WorkdirSessionHandle, output_dir: PathBuf) -> ToolDefinition {
|
||||||
Arc::new(move || {
|
Arc::new(move || {
|
||||||
let schema = schemars::schema_for!(BashParams);
|
let schema = schemars::schema_for!(BashParams);
|
||||||
let meta = ToolMeta::new("Bash")
|
let meta = ToolMeta::new("Bash")
|
||||||
.description("Execute a shell command in the bound Workdir. Process start, bounded output, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
.description("Execute a shell command in the bound Workdir. Process start, bounded inline output, full-output spill, timeout and cancellation are owned by the WorkdirSession provider. This is not a sandbox.")
|
||||||
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
|
||||||
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
let tool: Arc<dyn Tool> = Arc::new(BashTool {
|
||||||
session: session.clone(),
|
session: session.clone(),
|
||||||
|
output_dir: output_dir.clone(),
|
||||||
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
state: Arc::new(Mutex::new(BashExecutionState::default())),
|
||||||
});
|
});
|
||||||
(meta, tool)
|
(meta, tool)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
use workdir::{LocalWorkdirSession, WorkdirSessionHandle};
|
||||||
|
|
||||||
|
use super::bash_tool;
|
||||||
|
use crate::{grep::grep_tool, read::read_tool, tracker::Tracker};
|
||||||
|
|
||||||
|
fn session_with_output_scope(root: &TempDir, output: &TempDir) -> WorkdirSessionHandle {
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
ScopeRule {
|
||||||
|
target: root.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
ScopeRule {
|
||||||
|
target: output.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
Arc::new(LocalWorkdirSession::new(scope, root.path().to_path_buf()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn long_output_is_spilled_and_available_to_read_and_grep() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let output = TempDir::new().unwrap();
|
||||||
|
let session = session_with_output_scope(&root, &output);
|
||||||
|
let (_, bash) = bash_tool(session.clone(), output.path().to_path_buf())();
|
||||||
|
let command = "i=0; while [ $i -lt 2000 ]; do printf 'line-%04d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'";
|
||||||
|
let result = bash
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({ "command": command }).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let rendered = result.content.expect("bounded Bash output");
|
||||||
|
let artifact = std::fs::read_dir(output.path())
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.expect("artifact entry")
|
||||||
|
.unwrap()
|
||||||
|
.path();
|
||||||
|
|
||||||
|
assert!(rendered.contains("full output saved to"));
|
||||||
|
assert!(rendered.contains(&artifact.display().to_string()));
|
||||||
|
let retained = std::fs::read_to_string(&artifact).unwrap();
|
||||||
|
assert!(retained.starts_with("line-0000\n"));
|
||||||
|
assert!(retained.ends_with("FINAL-NEEDLE\n"));
|
||||||
|
assert_eq!(retained.lines().count(), 2001);
|
||||||
|
|
||||||
|
let (_, read) = read_tool(session.clone(), Tracker::new())();
|
||||||
|
let read_result = read
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"file_path": artifact,
|
||||||
|
"offset": 2000,
|
||||||
|
"limit": 1,
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
read_result
|
||||||
|
.content
|
||||||
|
.expect("Read content")
|
||||||
|
.contains("FINAL-NEEDLE")
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, grep) = grep_tool(session)();
|
||||||
|
let grep_result = grep
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({
|
||||||
|
"pattern": "FINAL-NEEDLE",
|
||||||
|
"path": artifact,
|
||||||
|
"output_mode": "content",
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let grep_content = grep_result.content.expect("Grep content");
|
||||||
|
assert!(
|
||||||
|
grep_content.contains("FINAL-NEEDLE"),
|
||||||
|
"unexpected Grep content: {grep_content:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn short_output_does_not_leave_a_spill_artifact() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let output = TempDir::new().unwrap();
|
||||||
|
let session = session_with_output_scope(&root, &output);
|
||||||
|
let (_, bash) = bash_tool(session, output.path().to_path_buf())();
|
||||||
|
|
||||||
|
let result = bash
|
||||||
|
.execute(
|
||||||
|
&serde_json::json!({ "command": "printf short" }).to_string(),
|
||||||
|
Default::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.content.as_deref(), Some("short"));
|
||||||
|
assert_eq!(std::fs::read_dir(output.path()).unwrap().count(), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ enum OutputMode {
|
|||||||
#[derive(Debug, Deserialize, JsonSchema)]
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
struct GrepParams {
|
struct GrepParams {
|
||||||
pattern: String,
|
pattern: String,
|
||||||
/// Logical Workdir-relative file or directory to search. Defaults to the Workdir root.
|
/// Workdir-relative path, or an absolute path covered by readable scope. Defaults to the Workdir root.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -61,7 +61,7 @@ impl Tool for GrepTool {
|
|||||||
let params: GrepParams = serde_json::from_str(input_json)
|
let params: GrepParams = serde_json::from_str(input_json)
|
||||||
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid Grep input: {error}")))?;
|
||||||
let path = match params.path {
|
let path = match params.path {
|
||||||
Some(path) => WorkdirPath::new(&path).map_err(ToolsError::from)?,
|
Some(path) => WorkdirPath::new_scoped(&path).map_err(ToolsError::from)?,
|
||||||
None => WorkdirPath::root(),
|
None => WorkdirPath::root(),
|
||||||
};
|
};
|
||||||
let mode = match params.output_mode.unwrap_or_default() {
|
let mode = match params.output_mode.unwrap_or_default() {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
|
|||||||
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
const DESCRIPTION: &str = "Read a text file from the local filesystem. \
|
||||||
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
Supports offset/limit for large files. Returns line-numbered output (1-based). \
|
||||||
Directories cannot be read. The file must be read before Write or Edit can \
|
Directories cannot be read. The file must be read before Write or Edit can \
|
||||||
modify it. Paths are relative to the bound Workdir.";
|
modify it. Paths are Workdir-relative unless an absolute path is explicitly readable.";
|
||||||
|
|
||||||
const DEFAULT_LIMIT: usize = 2000;
|
const DEFAULT_LIMIT: usize = 2000;
|
||||||
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
const PROVIDER_BYTE_LIMIT: usize = 256 * 1024;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||||
pub(crate) struct ReadParams {
|
pub(crate) struct ReadParams {
|
||||||
/// Logical path relative to the bound Workdir root.
|
/// Workdir-relative path, or an absolute path covered by readable scope.
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
/// 0-based line offset from the start. Defaults to 0.
|
/// 0-based line offset from the start. Defaults to 0.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -47,7 +47,7 @@ impl Tool for ReadTool {
|
|||||||
let offset = params.offset.unwrap_or(0);
|
let offset = params.offset.unwrap_or(0);
|
||||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||||
|
|
||||||
let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?;
|
let path = WorkdirPath::new_scoped(¶ms.file_path).map_err(ToolsError::from)?;
|
||||||
tracing::debug!(path = %path, offset, limit, "Read");
|
tracing::debug!(path = %path, offset, limit, "Read");
|
||||||
|
|
||||||
let result = self
|
let result = self
|
||||||
|
|||||||
@@ -224,20 +224,23 @@ async fn very_long_single_line() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn absolute_path_is_rejected() {
|
async fn absolute_path_requires_matching_read_scope() {
|
||||||
let (dir, _spill, reg) = setup();
|
let (_dir, _spill, reg) = setup();
|
||||||
|
let outside = tempfile::tempdir().unwrap();
|
||||||
|
let outside_file = outside.path().join("outside.txt");
|
||||||
|
std::fs::write(&outside_file, "secret").unwrap();
|
||||||
let read = reg.get("Read");
|
let read = reg.get("Read");
|
||||||
let err = read
|
let err = read
|
||||||
.execute(
|
.execute(
|
||||||
&json!({ "file_path": dir.path().join("outside.txt") }).to_string(),
|
&json!({ "file_path": outside_file }).to_string(),
|
||||||
Default::default(),
|
Default::default(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("invalid logical filesystem path"),
|
msg.contains("outside allowed scope"),
|
||||||
"absolute path was not rejected as invalid: {msg}"
|
"absolute path escaped readable scope: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -394,14 +394,21 @@ async fn bash_inherits_workdir_cwd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bash_provider_output_does_not_expose_internal_paths() {
|
async fn bash_provider_output_exposes_readable_retained_path() {
|
||||||
let (_dir, spill, reg) = setup();
|
let (_dir, spill, reg) = setup();
|
||||||
let bash = reg.get("Bash");
|
let bash = reg.get("Bash");
|
||||||
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
let out = call(&bash, json!({ "command": "printf 'x%.0s' {1..20480}" })).await;
|
||||||
let body = out.content.unwrap();
|
let body = out.content.unwrap();
|
||||||
assert!(body.contains("bounded WorkdirSession command output"));
|
assert!(body.contains("bounded WorkdirSession command output"));
|
||||||
assert!(!body.contains(spill.path().to_str().unwrap()));
|
assert!(body.contains("full output saved to"));
|
||||||
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
|
assert!(body.contains(spill.path().to_str().unwrap()));
|
||||||
|
let artifact = std::fs::read_dir(spill.path())
|
||||||
|
.unwrap()
|
||||||
|
.next()
|
||||||
|
.expect("retained output")
|
||||||
|
.unwrap()
|
||||||
|
.path();
|
||||||
|
assert_eq!(std::fs::metadata(artifact).unwrap().len(), 20_480);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
+33
-5
@@ -249,6 +249,9 @@ pub struct App {
|
|||||||
pub running: bool,
|
pub running: bool,
|
||||||
/// True while the Worker is in `WorkerStatus::Paused`.
|
/// True while the Worker is in `WorkerStatus::Paused`.
|
||||||
pub paused: bool,
|
pub paused: bool,
|
||||||
|
/// Local observation time for the current run. Used only for live UI
|
||||||
|
/// elapsed time and spinner animation; it is not persisted in history.
|
||||||
|
pub run_started_at: Option<Instant>,
|
||||||
pub run_requests: usize,
|
pub run_requests: usize,
|
||||||
/// Sum of `input_tokens - cache_read_input_tokens` across the
|
/// Sum of `input_tokens - cache_read_input_tokens` across the
|
||||||
/// current turn's LLM requests — i.e. the net tokens this turn
|
/// current turn's LLM requests — i.e. the net tokens this turn
|
||||||
@@ -281,6 +284,9 @@ pub struct App {
|
|||||||
/// records the instant; a second press within the timeout exits the
|
/// records the instant; a second press within the timeout exits the
|
||||||
/// TUI (the Worker itself stays alive).
|
/// TUI (the Worker itself stays alive).
|
||||||
pub quit_confirm: Option<std::time::Instant>,
|
pub quit_confirm: Option<std::time::Instant>,
|
||||||
|
/// Independent 2-tap guard for `Ctrl-X` when the Worker is idle or
|
||||||
|
/// stopped. A second press within the timeout shuts down the Worker.
|
||||||
|
pub shutdown_confirm: Option<std::time::Instant>,
|
||||||
/// Full display history in render order.
|
/// Full display history in render order.
|
||||||
pub blocks: Vec<Block>,
|
pub blocks: Vec<Block>,
|
||||||
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
||||||
@@ -352,6 +358,7 @@ impl App {
|
|||||||
worker_status: WorkerStatus::Idle,
|
worker_status: WorkerStatus::Idle,
|
||||||
running: false,
|
running: false,
|
||||||
paused: false,
|
paused: false,
|
||||||
|
run_started_at: None,
|
||||||
run_requests: 0,
|
run_requests: 0,
|
||||||
run_upload_tokens: 0,
|
run_upload_tokens: 0,
|
||||||
run_output_tokens: 0,
|
run_output_tokens: 0,
|
||||||
@@ -369,6 +376,7 @@ impl App {
|
|||||||
command_completion_selected: None,
|
command_completion_selected: None,
|
||||||
quit: false,
|
quit: false,
|
||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
|
shutdown_confirm: None,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
run_error_messages: Vec::new(),
|
run_error_messages: Vec::new(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
@@ -553,11 +561,18 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
||||||
|
let was_running = self.running;
|
||||||
self.worker_status = status;
|
self.worker_status = status;
|
||||||
self.running = status == WorkerStatus::Running;
|
self.running = status == WorkerStatus::Running;
|
||||||
self.paused = status == WorkerStatus::Paused;
|
self.paused = status == WorkerStatus::Paused;
|
||||||
if self.running {
|
if self.running {
|
||||||
|
if !was_running {
|
||||||
|
self.run_started_at = Some(Instant::now());
|
||||||
|
}
|
||||||
self.quit_confirm = None;
|
self.quit_confirm = None;
|
||||||
|
self.shutdown_confirm = None;
|
||||||
|
} else {
|
||||||
|
self.run_started_at = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1121,11 +1136,13 @@ impl App {
|
|||||||
self.latest_llm_wait_event = None;
|
self.latest_llm_wait_event = None;
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
// UI consumers of Invoke / LlmCall semantics are out of scope
|
Event::InvokeStart { .. } => {
|
||||||
// for `tickets/invoke-turn-llmcall-semantics.md`; events flow
|
self.set_worker_status(WorkerStatus::Running);
|
||||||
// through to subscribers but the TUI currently derives its
|
}
|
||||||
// turn header from `UserMessage` / `SystemItem` arrivals.
|
// UI consumers of per-attempt LlmCall semantics remain out of scope;
|
||||||
Event::InvokeStart { .. } | Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
|
// the run-level status starts at InvokeStart and TurnStart counts each
|
||||||
|
// LLM request within that run.
|
||||||
|
Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
|
||||||
self.latest_llm_wait_event = None;
|
self.latest_llm_wait_event = None;
|
||||||
}
|
}
|
||||||
Event::LlmRetry {
|
Event::LlmRetry {
|
||||||
@@ -3377,6 +3394,17 @@ mod completion_flow_tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn running_status_starts_and_stops_live_run_clock() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
|
||||||
|
app.set_worker_status(WorkerStatus::Running);
|
||||||
|
assert!(app.run_started_at.is_some());
|
||||||
|
|
||||||
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
assert!(app.run_started_at.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn running_submit_is_queued_locally_and_clears_composer() {
|
fn running_submit_is_queued_locally_and_clears_composer() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|||||||
+181
-92
@@ -21,10 +21,11 @@ use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
|||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
|
||||||
use client::{BackendRuntimeClient, BackendRuntimeTarget, StandaloneSessionResumeIntent};
|
use client::transport::Socket;
|
||||||
|
use client::{BackendRuntimeTarget, Client, StandaloneWorkerResumeIntent, connect_backend_runtime};
|
||||||
|
|
||||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||||
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
use crate::composer_keys::{ComposerEditAction, composer_edit_action};
|
||||||
@@ -119,74 +120,40 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
|
|||||||
copy_selection_to_writer(app, &mut stdout)
|
copy_selection_to_writer(app, &mut stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ConsoleConnection {
|
struct ConsoleConnection<T> {
|
||||||
BackendRuntime(BackendRuntimeClient),
|
client: Client<T>,
|
||||||
Standalone {
|
standalone_host: Option<StandaloneHost>,
|
||||||
host: Option<StandaloneHost>,
|
|
||||||
events: broadcast::Receiver<Event>,
|
|
||||||
initial_snapshot: Option<Event>,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsoleConnection {
|
impl<T: Socket> ConsoleConnection<T> {
|
||||||
fn standalone(host: StandaloneHost) -> Self {
|
fn new(client: Client<T>) -> Self {
|
||||||
let events = host.subscribe();
|
Self {
|
||||||
let initial_snapshot = Some(host.snapshot());
|
client,
|
||||||
Self::Standalone {
|
standalone_host: None,
|
||||||
host: Some(host),
|
|
||||||
events,
|
|
||||||
initial_snapshot,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_next_event(&mut self) -> Option<Event> {
|
fn with_standalone_host(client: Client<T>, host: StandaloneHost) -> Self {
|
||||||
match self {
|
Self {
|
||||||
Self::BackendRuntime(client) => client.try_next_event(),
|
client,
|
||||||
Self::Standalone {
|
standalone_host: Some(host),
|
||||||
events,
|
|
||||||
initial_snapshot,
|
|
||||||
..
|
|
||||||
} => initial_snapshot.take().or_else(|| events.try_recv().ok()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_event(&mut self) -> Option<Event> {
|
fn try_next_event(&mut self) -> Result<Option<Event>, Box<dyn std::error::Error>> {
|
||||||
match self {
|
Ok(self.client.try_next_event()?)
|
||||||
Self::BackendRuntime(client) => client.next_event().await,
|
|
||||||
Self::Standalone { host, events, .. } => loop {
|
|
||||||
match events.recv().await {
|
|
||||||
Ok(event) => break Some(event),
|
|
||||||
Err(broadcast::error::RecvError::Lagged(_)) => {
|
|
||||||
let Some(host) = host.as_ref() else {
|
|
||||||
break None;
|
|
||||||
};
|
|
||||||
break Some(host.snapshot());
|
|
||||||
}
|
|
||||||
Err(broadcast::error::RecvError::Closed) => break None,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn next_event(&mut self) -> Result<Option<Event>, Box<dyn std::error::Error>> {
|
||||||
|
Ok(self.client.next_event().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
|
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match self {
|
Ok(self.client.send(method).await?)
|
||||||
Self::BackendRuntime(client) => Ok(client.send(method).await?),
|
|
||||||
Self::Standalone { host, .. } => {
|
|
||||||
let host = host.as_ref().ok_or_else(|| {
|
|
||||||
io::Error::new(
|
|
||||||
io::ErrorKind::BrokenPipe,
|
|
||||||
"Standalone Worker has already shut down",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(host.send(method.clone()).await?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if let Self::Standalone { host, .. } = self
|
if let Some(host) = self.standalone_host.take() {
|
||||||
&& let Some(host) = host.take()
|
|
||||||
{
|
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -224,18 +191,18 @@ pub(crate) async fn run_standalone(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn run_standalone_restore(
|
pub(crate) async fn run_standalone_restore(
|
||||||
intent: StandaloneSessionResumeIntent,
|
intent: StandaloneWorkerResumeIntent,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let session_id = intent.session_id.parse().map_err(|error| {
|
let worker_id = intent.worker_id.parse().map_err(|error| {
|
||||||
io::Error::new(
|
io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
format!("Invalid standalone session ID: {error}"),
|
format!("Invalid standalone Worker ID: {error}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let host = StandaloneHost::restore(intent.state_dir, session_id)
|
let host = StandaloneHost::restore(intent.state_dir, worker_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
|
.map_err(|error| io::Error::other(format!("Standalone restore failed: {error}")))?;
|
||||||
let worker_label = format!("standalone-{}", session_id.short());
|
let worker_label = host.record().worker_name.clone();
|
||||||
let history_root = host.record().cwd.canonical_path.clone();
|
let history_root = host.record().cwd.canonical_path.clone();
|
||||||
run_standalone_host(host, worker_label, history_root).await
|
run_standalone_host(host, worker_label, history_root).await
|
||||||
}
|
}
|
||||||
@@ -251,7 +218,8 @@ async fn run_standalone_host(
|
|||||||
worker_label: String,
|
worker_label: String,
|
||||||
history_root: PathBuf,
|
history_root: PathBuf,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut connection = ConsoleConnection::standalone(host);
|
let client = host.connect();
|
||||||
|
let mut connection = ConsoleConnection::with_standalone_host(client, host);
|
||||||
|
|
||||||
let mut terminal = match enter_fullscreen() {
|
let mut terminal = match enter_fullscreen() {
|
||||||
Ok(terminal) => terminal,
|
Ok(terminal) => terminal,
|
||||||
@@ -280,12 +248,12 @@ pub(crate) async fn run_backend_runtime(
|
|||||||
target: BackendRuntimeTarget,
|
target: BackendRuntimeTarget,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let worker_label = target.display_label();
|
let worker_label = target.display_label();
|
||||||
let client = BackendRuntimeClient::connect(target).await?;
|
let client = connect_backend_runtime(target).await?;
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
||||||
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
let mut connection = ConsoleConnection::BackendRuntime(client);
|
let mut connection = ConsoleConnection::new(client);
|
||||||
let result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
let result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||||
let _ = leave_fullscreen(&mut terminal);
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
result
|
result
|
||||||
@@ -560,16 +528,20 @@ enum E2eRewindInput {
|
|||||||
|
|
||||||
enum LoopInput<P> {
|
enum LoopInput<P> {
|
||||||
Terminal(TerminalEventResult),
|
Terminal(TerminalEventResult),
|
||||||
Worker(Option<P>),
|
Worker(P),
|
||||||
|
Tick,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn next_loop_input<P, F>(
|
async fn next_loop_input<P, F, T>(
|
||||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||||
connected: bool,
|
connected: bool,
|
||||||
pod_next: F,
|
pod_next: F,
|
||||||
|
animate: bool,
|
||||||
|
animation_tick: T,
|
||||||
) -> LoopInput<P>
|
) -> LoopInput<P>
|
||||||
where
|
where
|
||||||
F: Future<Output = Option<P>>,
|
F: Future<Output = P>,
|
||||||
|
T: Future,
|
||||||
{
|
{
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
biased;
|
biased;
|
||||||
@@ -583,12 +555,13 @@ where
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
event = pod_next, if connected => LoopInput::Worker(event),
|
event = pod_next, if connected => LoopInput::Worker(event),
|
||||||
|
_ = animation_tick, if animate => LoopInput::Tick,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drain_terminal_events(
|
async fn drain_terminal_events<T: Socket>(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut ConsoleConnection,
|
client: &mut ConsoleConnection<T>,
|
||||||
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
term_rx: &mut mpsc::UnboundedReceiver<TerminalEventResult>,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
@@ -613,13 +586,13 @@ async fn drain_terminal_events(
|
|||||||
Ok(handled)
|
Ok(handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn drain_worker_events(
|
async fn drain_worker_events<T: Socket>(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut ConsoleConnection,
|
client: &mut ConsoleConnection<T>,
|
||||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
for _ in 0..POD_EVENT_DRAIN_LIMIT {
|
||||||
match client.try_next_event() {
|
match client.try_next_event()? {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
handled = true;
|
handled = true;
|
||||||
if let Some(method) = app.handle_worker_event(ev) {
|
if let Some(method) = app.handle_worker_event(ev) {
|
||||||
@@ -632,12 +605,14 @@ async fn drain_worker_events(
|
|||||||
Ok(handled)
|
Ok(handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_loop(
|
async fn run_loop<T: Socket>(
|
||||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut ConsoleConnection,
|
client: &mut ConsoleConnection<T>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
let (_terminal_reader, mut term_rx) = TerminalEventReader::spawn()?;
|
||||||
|
let mut animation_tick = tokio::time::interval(Duration::from_millis(80));
|
||||||
|
animation_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||||
|
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
|
|
||||||
@@ -656,11 +631,19 @@ async fn run_loop(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match next_loop_input(&mut term_rx, app.connected, client.next_event()).await {
|
match next_loop_input(
|
||||||
|
&mut term_rx,
|
||||||
|
app.connected,
|
||||||
|
client.next_event(),
|
||||||
|
app.running,
|
||||||
|
animation_tick.tick(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
LoopInput::Terminal(term_event) => {
|
LoopInput::Terminal(term_event) => {
|
||||||
handle_terminal_event(app, client, term_event?).await?;
|
handle_terminal_event(app, client, term_event?).await?;
|
||||||
}
|
}
|
||||||
LoopInput::Worker(event) => match event {
|
LoopInput::Worker(event) => match event? {
|
||||||
Some(ev) => {
|
Some(ev) => {
|
||||||
if let Some(method) = app.handle_worker_event(ev) {
|
if let Some(method) = app.handle_worker_event(ev) {
|
||||||
client.send(&method).await?;
|
client.send(&method).await?;
|
||||||
@@ -672,6 +655,7 @@ async fn run_loop(
|
|||||||
app.push_error("Connection lost");
|
app.push_error("Connection lost");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
LoopInput::Tick => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
@@ -680,9 +664,9 @@ async fn run_loop(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_terminal_event(
|
async fn handle_terminal_event<T: Socket>(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut ConsoleConnection,
|
client: &mut ConsoleConnection<T>,
|
||||||
event: TermEvent,
|
event: TermEvent,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match event {
|
match event {
|
||||||
@@ -838,13 +822,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
KeyCode::Char('c') if ctrl => Some(handle_pause_or_quit(app)),
|
||||||
KeyCode::Char('x') if ctrl => Some(match app.worker_status {
|
KeyCode::Char('x') if ctrl => Some(handle_cancel_or_shutdown(app)),
|
||||||
WorkerStatus::Running | WorkerStatus::Paused => {
|
|
||||||
app.clear_queued_inputs();
|
|
||||||
Some(Method::Cancel)
|
|
||||||
}
|
|
||||||
WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
|
|
||||||
}),
|
|
||||||
KeyCode::Char('d') if ctrl => {
|
KeyCode::Char('d') if ctrl => {
|
||||||
app.quit = true;
|
app.quit = true;
|
||||||
Some(None)
|
Some(None)
|
||||||
@@ -1101,6 +1079,33 @@ fn handle_command_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
|
|
||||||
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
const CONFIRM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||||
|
|
||||||
|
/// Running / Paused → send `Method::Cancel` immediately.
|
||||||
|
/// Idle / Stopped → 2-tap to shut down the Worker.
|
||||||
|
fn handle_cancel_or_shutdown(app: &mut App) -> Option<Method> {
|
||||||
|
if matches!(
|
||||||
|
app.worker_status,
|
||||||
|
WorkerStatus::Running | WorkerStatus::Paused
|
||||||
|
) {
|
||||||
|
app.shutdown_confirm = None;
|
||||||
|
app.clear_queued_inputs();
|
||||||
|
return Some(Method::Cancel);
|
||||||
|
}
|
||||||
|
if let Some(pressed_at) = app.shutdown_confirm
|
||||||
|
&& pressed_at.elapsed() < CONFIRM_TIMEOUT
|
||||||
|
{
|
||||||
|
app.shutdown_confirm = None;
|
||||||
|
return Some(Method::Shutdown);
|
||||||
|
}
|
||||||
|
app.shutdown_confirm = Some(std::time::Instant::now());
|
||||||
|
app.flash_actionbar_notice(
|
||||||
|
"Press Ctrl-X again within 3 s to shut down the Worker.",
|
||||||
|
ActionbarNoticeLevel::Warn,
|
||||||
|
ActionbarNoticeSource::Tui,
|
||||||
|
CONFIRM_TIMEOUT,
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Running → send `Method::Pause`.
|
/// Running → send `Method::Pause`.
|
||||||
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
/// Idle / Paused → 2-tap to quit the TUI (the Worker keeps running).
|
||||||
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
@@ -1246,6 +1251,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn animation_tick_wakes_loop_while_running() {
|
||||||
|
let (_tx, mut rx) = mpsc::unbounded_channel::<TerminalEventResult>();
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
next_loop_input(
|
||||||
|
&mut rx,
|
||||||
|
true,
|
||||||
|
std::future::pending::<Option<u8>>(),
|
||||||
|
true,
|
||||||
|
std::future::ready(()),
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
LoopInput::Tick
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn terminal_event_is_selected_before_ready_worker_event() {
|
async fn terminal_event_is_selected_before_ready_worker_event() {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
@@ -1255,7 +1277,15 @@ mod tests {
|
|||||||
))))
|
))))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
match next_loop_input(&mut rx, true, std::future::ready(Some(()))).await {
|
match next_loop_input(
|
||||||
|
&mut rx,
|
||||||
|
true,
|
||||||
|
std::future::ready(Some(())),
|
||||||
|
false,
|
||||||
|
std::future::pending::<()>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
||||||
assert_eq!(key.code, KeyCode::Char('x'));
|
assert_eq!(key.code, KeyCode::Char('x'));
|
||||||
}
|
}
|
||||||
@@ -1267,7 +1297,15 @@ mod tests {
|
|||||||
async fn terminal_event_is_preserved_after_worker_event_wins() {
|
async fn terminal_event_is_preserved_after_worker_event_wins() {
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
match next_loop_input(&mut rx, true, std::future::ready(Some(1_u8))).await {
|
match next_loop_input(
|
||||||
|
&mut rx,
|
||||||
|
true,
|
||||||
|
std::future::ready(Some(1_u8)),
|
||||||
|
false,
|
||||||
|
std::future::pending::<()>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
LoopInput::Worker(Some(1)) => {}
|
LoopInput::Worker(Some(1)) => {}
|
||||||
_ => panic!("expected the first ready Worker event to win before any terminal input"),
|
_ => panic!("expected the first ready Worker event to win before any terminal input"),
|
||||||
}
|
}
|
||||||
@@ -1278,7 +1316,15 @@ mod tests {
|
|||||||
))))
|
))))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
match next_loop_input(&mut rx, true, std::future::ready(Some(2_u8))).await {
|
match next_loop_input(
|
||||||
|
&mut rx,
|
||||||
|
true,
|
||||||
|
std::future::ready(Some(2_u8)),
|
||||||
|
false,
|
||||||
|
std::future::pending::<()>(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
LoopInput::Terminal(Ok(TermEvent::Key(key))) => {
|
||||||
assert_eq!(key.code, KeyCode::Char('y'));
|
assert_eq!(key.code, KeyCode::Char('y'));
|
||||||
}
|
}
|
||||||
@@ -1445,15 +1491,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ctrl_x_shutdown_while_idle_is_unchanged() {
|
fn ctrl_x_requires_confirmation_before_shutdown_while_idle() {
|
||||||
|
let mut app = App::new("agent".to_string());
|
||||||
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
let ctrl_x = || KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
assert!(handle_key(&mut app, ctrl_x()).is_none());
|
||||||
|
assert!(app.shutdown_confirm.is_some());
|
||||||
|
let notice = app
|
||||||
|
.current_actionbar_notice(std::time::Instant::now())
|
||||||
|
.expect("first Ctrl-X should arm shutdown confirmation");
|
||||||
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
||||||
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
||||||
|
assert!(notice.text.contains("Ctrl-X"));
|
||||||
|
assert!(notice.text.contains("shut down the Worker"));
|
||||||
|
assert!(!has_alert(&app, "shut down the Worker"));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
handle_key(&mut app, ctrl_x()),
|
||||||
|
Some(Method::Shutdown)
|
||||||
|
));
|
||||||
|
assert!(app.shutdown_confirm.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ctrl_c_and_ctrl_x_confirmations_do_not_authorize_each_other() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
app.set_worker_status(WorkerStatus::Idle);
|
app.set_worker_status(WorkerStatus::Idle);
|
||||||
|
|
||||||
let shutdown = handle_key(
|
assert!(
|
||||||
|
handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(app.quit_confirm.is_some());
|
||||||
|
assert!(app.shutdown_confirm.is_none());
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
)
|
||||||
|
.is_none()
|
||||||
);
|
);
|
||||||
assert!(matches!(shutdown, Some(Method::Shutdown)));
|
assert!(!app.quit);
|
||||||
|
assert!(app.shutdown_confirm.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2169,12 +2253,17 @@ mod tests {
|
|||||||
handle_key(&mut app, key(KeyCode::Tab));
|
handle_key(&mut app, key(KeyCode::Tab));
|
||||||
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge");
|
||||||
|
|
||||||
let method = handle_key(
|
let first = handle_key(
|
||||||
|
&mut app,
|
||||||
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
|
);
|
||||||
|
let second = handle_key(
|
||||||
&mut app,
|
&mut app,
|
||||||
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(matches!(method, Some(Method::Shutdown)));
|
assert!(first.is_none());
|
||||||
|
assert!(matches!(second, Some(Method::Shutdown)));
|
||||||
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ pub enum LaunchMode {
|
|||||||
worker_name: Option<String>,
|
worker_name: Option<String>,
|
||||||
profile: Option<String>,
|
profile: Option<String>,
|
||||||
},
|
},
|
||||||
/// Restore one client-owned standalone session. The current cwd is the default scope;
|
/// Restore one client-owned standalone Worker. The current cwd is the default scope;
|
||||||
/// `include_all` opts into all standalone sessions under the same client data root.
|
/// `include_all` opts into all standalone Workers under the same client data root.
|
||||||
StandaloneResume { include_all: bool },
|
StandaloneResume { include_all: bool },
|
||||||
/// List Backend Workers and attach to the selected Worker.
|
/// List Backend Workers and attach to the selected Worker.
|
||||||
Workers {
|
Workers {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{StandaloneSessionListIntent, StandaloneSessionResumeIntent, 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::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
@@ -9,7 +9,7 @@ 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 ratatui::{TerminalOptions, Viewport};
|
||||||
use standalone::{StandaloneListScope, StandaloneSessionRecord, StandaloneSessionStore};
|
use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
const LIMIT: usize = 100;
|
const LIMIT: usize = 100;
|
||||||
@@ -17,28 +17,28 @@ const LIMIT: usize = 100;
|
|||||||
pub(crate) fn pick(
|
pub(crate) fn pick(
|
||||||
target: &dyn Target,
|
target: &dyn Target,
|
||||||
include_all: bool,
|
include_all: bool,
|
||||||
) -> Result<Option<StandaloneSessionResumeIntent>, StandalonePickerError> {
|
) -> Result<Option<StandaloneWorkerResumeIntent>, StandalonePickerError> {
|
||||||
let intent = target
|
let intent = target
|
||||||
.standalone_session_list(include_all)
|
.standalone_worker_list(include_all)
|
||||||
.map_err(StandalonePickerError::Target)?;
|
.map_err(StandalonePickerError::Target)?;
|
||||||
let records = load_records(&intent)?;
|
let records = load_records(&intent)?;
|
||||||
if records.is_empty() {
|
if records.is_empty() {
|
||||||
return Err(StandalonePickerError::NoSessions { include_all });
|
return Err(StandalonePickerError::NoWorkers { include_all });
|
||||||
}
|
}
|
||||||
let selected = run_picker(records)?;
|
let selected = run_picker(records)?;
|
||||||
selected
|
selected
|
||||||
.map(|record| {
|
.map(|record| {
|
||||||
target
|
target
|
||||||
.standalone_session_resume(record.session_id.to_string())
|
.standalone_worker_resume(record.worker_id.to_string())
|
||||||
.map_err(StandalonePickerError::Target)
|
.map_err(StandalonePickerError::Target)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_records(
|
fn load_records(
|
||||||
intent: &StandaloneSessionListIntent,
|
intent: &StandaloneWorkerListIntent,
|
||||||
) -> Result<Vec<StandaloneSessionRecord>, StandalonePickerError> {
|
) -> Result<Vec<StandaloneWorkerRecord>, StandalonePickerError> {
|
||||||
let store = StandaloneSessionStore::open(&intent.state_dir)
|
let store = StandaloneWorkerStore::open(&intent.state_dir)
|
||||||
.map_err(StandalonePickerError::StateStore)?;
|
.map_err(StandalonePickerError::StateStore)?;
|
||||||
store
|
store
|
||||||
.list(
|
.list(
|
||||||
@@ -54,8 +54,8 @@ fn load_records(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_picker(
|
fn run_picker(
|
||||||
records: Vec<StandaloneSessionRecord>,
|
records: Vec<StandaloneWorkerRecord>,
|
||||||
) -> Result<Option<StandaloneSessionRecord>, 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(
|
let mut terminal = Terminal::with_options(
|
||||||
CrosstermBackend::new(io::stdout()),
|
CrosstermBackend::new(io::stdout()),
|
||||||
@@ -94,14 +94,14 @@ fn run_picker(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], selected: usize) {
|
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) {
|
||||||
let mut constraints = vec![Constraint::Length(1)];
|
let mut constraints = vec![Constraint::Length(1)];
|
||||||
constraints.extend(records.iter().map(|_| Constraint::Length(1)));
|
constraints.extend(records.iter().map(|_| Constraint::Length(1)));
|
||||||
constraints.push(Constraint::Length(1));
|
constraints.push(Constraint::Length(1));
|
||||||
let rows = Layout::vertical(constraints).split(frame.area());
|
let rows = Layout::vertical(constraints).split(frame.area());
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(Span::styled(
|
Paragraph::new(Line::from(Span::styled(
|
||||||
"resume standalone session",
|
"resume standalone Worker",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
))),
|
))),
|
||||||
rows[0],
|
rows[0],
|
||||||
@@ -120,7 +120,10 @@ fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], sel
|
|||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(Line::from(vec![
|
Paragraph::new(Line::from(vec![
|
||||||
Span::raw(marker),
|
Span::raw(marker),
|
||||||
Span::styled(record.session_id.short(), style),
|
Span::styled(
|
||||||
|
format!("{} ({})", record.worker_name, record.worker_id.short()),
|
||||||
|
style,
|
||||||
|
),
|
||||||
Span::raw(format!(
|
Span::raw(format!(
|
||||||
" [{:?}] updated:{} {}",
|
" [{:?}] updated:{} {}",
|
||||||
record.status, record.updated_at_unix_ms, cwd
|
record.status, record.updated_at_unix_ms, cwd
|
||||||
@@ -139,13 +142,13 @@ fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneSessionRecord], sel
|
|||||||
pub(crate) enum StandalonePickerError {
|
pub(crate) enum StandalonePickerError {
|
||||||
#[error("standalone target error: {0}")]
|
#[error("standalone target error: {0}")]
|
||||||
Target(#[source] client::TargetError),
|
Target(#[source] client::TargetError),
|
||||||
#[error("standalone session state is unavailable: {0}")]
|
#[error("standalone Worker state is unavailable: {0}")]
|
||||||
StateStore(#[source] standalone::StandaloneStoreError),
|
StateStore(#[source] standalone::StandaloneStoreError),
|
||||||
#[error(
|
#[error(
|
||||||
"no standalone sessions 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"
|
||||||
)]
|
)]
|
||||||
NoSessions { include_all: bool },
|
NoWorkers { include_all: bool },
|
||||||
#[error("standalone session picker I/O failed: {0}")]
|
#[error("standalone Worker picker I/O failed: {0}")]
|
||||||
Io(#[source] io::Error),
|
Io(#[source] io::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+109
-39
@@ -36,6 +36,9 @@ use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
|||||||
use crate::text_selection::{HistoryViewport, SelectionRow};
|
use crate::text_selection::{HistoryViewport, SelectionRow};
|
||||||
use crate::view_mode::Mode;
|
use crate::view_mode::Mode;
|
||||||
|
|
||||||
|
const RUN_SPINNER_FRAMES: [&str; 8] = ["⣷", "⣯", "⣟", "⡿", "⢿", "⣻", "⣽", "⣾"];
|
||||||
|
const RUN_SPINNER_FRAME_MS: u128 = 80;
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, app: &mut App) {
|
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
// Input content starts after the prompt (`> ` or `: `), so the width
|
// Input content starts after the prompt (`> ` or `: `), so the width
|
||||||
@@ -57,14 +60,22 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
let tabs = app.worker_view_tabs();
|
let tabs = app.worker_view_tabs();
|
||||||
let show_tabs = tabs.len() > 1;
|
let show_tabs = tabs.len() > 1;
|
||||||
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs);
|
let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs);
|
||||||
// One blank row separates the history tail from the mini-view so
|
let run_status_h = u16::from(app.running);
|
||||||
// the latest message doesn't visually crash into the task summary.
|
let run_status_gap = run_status_h;
|
||||||
// Folds away with the mini-view when there are no tasks.
|
// One blank row separates the history tail from the run/task mini-view so
|
||||||
let mini_view_gap = if mini_view_h > 0 { 1 } else { 0 };
|
// the latest message doesn't visually crash into operational status.
|
||||||
|
// Folds away when neither run status nor tasks are visible.
|
||||||
|
let mini_view_gap = if mini_view_h > 0 || run_status_h > 0 {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
let chunks = Layout::vertical([
|
let chunks = Layout::vertical([
|
||||||
Constraint::Min(0), // history view
|
Constraint::Min(0), // history view
|
||||||
Constraint::Length(mini_view_gap), // gap above mini-view
|
Constraint::Length(mini_view_gap), // gap above run/task mini-view
|
||||||
|
Constraint::Length(run_status_h), // active run status
|
||||||
|
Constraint::Length(run_status_gap), // gap below active run status
|
||||||
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
|
Constraint::Length(mini_view_h), // task mini-view (0 when empty)
|
||||||
Constraint::Length(1), // separator
|
Constraint::Length(1), // separator
|
||||||
Constraint::Length(1), // status
|
Constraint::Length(1), // status
|
||||||
@@ -82,24 +93,27 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
} else {
|
} else {
|
||||||
draw_history(frame, app, chunks[0]);
|
draw_history(frame, app, chunks[0]);
|
||||||
}
|
}
|
||||||
|
if run_status_h > 0 {
|
||||||
|
draw_run_status(frame, app, chunks[2]);
|
||||||
|
}
|
||||||
if mini_view_h > 0 {
|
if mini_view_h > 0 {
|
||||||
draw_task_mini_view(
|
draw_task_mini_view(
|
||||||
frame,
|
frame,
|
||||||
&app.selected_worker_view().task_store,
|
&app.selected_worker_view().task_store,
|
||||||
&tabs,
|
&tabs,
|
||||||
chunks[2],
|
chunks[4],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
draw_separator(frame, chunks[3]);
|
draw_separator(frame, chunks[5]);
|
||||||
// Status/composer/control surfaces remain parent-owned. View selection changes
|
// Status/composer/control surfaces remain parent-owned. View selection changes
|
||||||
// only transcript/task presentation and never implies SubWorker control.
|
// only transcript/task presentation and never implies SubWorker control.
|
||||||
draw_status(frame, app, chunks[4]);
|
draw_status(frame, app, chunks[6]);
|
||||||
draw_input(frame, app, &input_render, chunks[5]);
|
draw_input(frame, app, &input_render, chunks[7]);
|
||||||
draw_actionbar(frame, app, chunks[6]);
|
draw_actionbar(frame, app, chunks[8]);
|
||||||
if app.is_command_mode() {
|
if app.is_command_mode() {
|
||||||
draw_command_popup(frame, app, chunks[5]);
|
draw_command_popup(frame, app, chunks[7]);
|
||||||
} else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
|
} else if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) {
|
||||||
draw_completion_popup(frame, state, chunks[5]);
|
draw_completion_popup(frame, state, chunks[7]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +134,65 @@ fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 {
|
|||||||
(active_shown as u16).saturating_add(1)
|
(active_shown as u16).saturating_add(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_run_status(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
frame.render_widget(Paragraph::new(run_status_line(app, Instant::now())), area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
||||||
|
let elapsed = app
|
||||||
|
.run_started_at
|
||||||
|
.and_then(|started_at| now.checked_duration_since(started_at))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let spinner_index =
|
||||||
|
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
|
||||||
|
let request_label = if app.run_requests == 1 {
|
||||||
|
"1 req".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{} reqs", app.run_requests)
|
||||||
|
};
|
||||||
|
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
RUN_SPINNER_FRAMES[spinner_index],
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
fmt_run_elapsed(elapsed.as_secs()),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
Span::styled(" ・ ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled(request_label, Style::default().fg(Color::Gray)),
|
||||||
|
Span::styled(" | ", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled("↑", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled(
|
||||||
|
fmt_tokens(app.run_upload_tokens),
|
||||||
|
Style::default().fg(Color::Green),
|
||||||
|
),
|
||||||
|
Span::styled("/", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::styled("↓", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(
|
||||||
|
fmt_tokens(app.run_output_tokens),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_run_elapsed(secs: u64) -> String {
|
||||||
|
let hours = secs / 3600;
|
||||||
|
let minutes = (secs % 3600) / 60;
|
||||||
|
let seconds = secs % 60;
|
||||||
|
if hours > 0 {
|
||||||
|
format!("{hours}h {minutes}m {seconds:02}s")
|
||||||
|
} else if minutes > 0 {
|
||||||
|
format!("{minutes}m {seconds:02}s")
|
||||||
|
} else {
|
||||||
|
format!("{seconds}s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
|
fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) {
|
||||||
if area.height == 0 || area.width == 0 {
|
if area.height == 0 || area.width == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -1726,32 +1799,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
if app.running {
|
if app.paused {
|
||||||
let status = if let Some(wait_event) = &app.latest_llm_wait_event {
|
|
||||||
format!(
|
|
||||||
"request: {} | ↑{}/↓{} | {wait_event}",
|
|
||||||
app.run_requests,
|
|
||||||
fmt_tokens(app.run_upload_tokens),
|
|
||||||
fmt_tokens(app.run_output_tokens),
|
|
||||||
)
|
|
||||||
} else if let Some(tool) = &app.current_tool {
|
|
||||||
format!(
|
|
||||||
"request: {} | ↑{}/↓{} | tool: {tool}",
|
|
||||||
app.run_requests,
|
|
||||||
fmt_tokens(app.run_upload_tokens),
|
|
||||||
fmt_tokens(app.run_output_tokens),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"request: {} | ↑{}/↓{}",
|
|
||||||
app.run_requests,
|
|
||||||
fmt_tokens(app.run_upload_tokens),
|
|
||||||
fmt_tokens(app.run_output_tokens),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
spans.push(Span::raw(" | "));
|
|
||||||
spans.push(Span::styled(status, Style::default().fg(Color::Yellow)));
|
|
||||||
} else if app.paused {
|
|
||||||
spans.push(Span::raw(" | "));
|
spans.push(Span::raw(" | "));
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"paused",
|
"paused",
|
||||||
@@ -1763,7 +1811,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
" — Enter to resume, Ctrl-X to cancel, type to start new turn",
|
" — Enter to resume, Ctrl-X to cancel, type to start new turn",
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
));
|
));
|
||||||
} else {
|
} else if !app.running {
|
||||||
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
|
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2053,6 +2101,28 @@ mod tests {
|
|||||||
use protocol::WorkerStatus;
|
use protocol::WorkerStatus;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_status_line_matches_console_metrics_and_spinner_frame() {
|
||||||
|
let now = Instant::now();
|
||||||
|
let mut app = App::new("worker".into());
|
||||||
|
app.run_started_at = now.checked_sub(Duration::from_millis(160));
|
||||||
|
app.run_requests = 1;
|
||||||
|
app.run_upload_tokens = 1_200;
|
||||||
|
app.run_output_tokens = 45;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
line_text(&run_status_line(&app, now)),
|
||||||
|
"⣟ 0s ・ 1 req | ↑1.2k/↓45"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_elapsed_uses_console_style_units() {
|
||||||
|
assert_eq!(fmt_run_elapsed(9), "9s");
|
||||||
|
assert_eq!(fmt_run_elapsed(65), "1m 05s");
|
||||||
|
assert_eq!(fmt_run_elapsed(3_726), "1h 2m 06s");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
|
fn task_summary_right_aligns_worker_tabs_and_highlights_selection() {
|
||||||
let tabs = vec![
|
let tabs = vec![
|
||||||
|
|||||||
@@ -743,6 +743,7 @@ mod tests {
|
|||||||
command: command.into(),
|
command: command.into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some(tool_call_id.into()),
|
tool_call_id: Some(tool_call_id.into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -770,6 +771,7 @@ mod tests {
|
|||||||
command: "printf ready; sleep 0.2; printf done".into(),
|
command: "printf ready; sleep 0.2; printf done".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-delegated".into()),
|
tool_call_id: Some("tool-delegated".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -858,6 +860,7 @@ mod tests {
|
|||||||
command: "printf denied".into(),
|
command: "printf denied".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("read-only-command".into()),
|
tool_call_id: Some("read-only-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -993,6 +996,7 @@ mod tests {
|
|||||||
command: "printf revoked".into(),
|
command: "printf revoked".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("revoked-child-command".into()),
|
tool_call_id: Some("revoked-child-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -1171,6 +1175,7 @@ mod tests {
|
|||||||
command: "printf closed".into(),
|
command: "printf closed".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("closed-parent-command".into()),
|
tool_call_id: Some("closed-parent-command".into()),
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
|
|||||||
+231
-3
@@ -10,9 +10,7 @@
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
#[cfg(test)]
|
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||||
use std::io::Write as _;
|
|
||||||
use std::io::{Read as _, Seek as _, SeekFrom};
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -691,6 +689,11 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
self.ensure_capability(WorkdirSessionCapability::Command)?;
|
||||||
self.ensure_open()?;
|
self.ensure_open()?;
|
||||||
|
if let Some(spill_dir) = request.spill_dir.as_deref()
|
||||||
|
&& !self.inner.scope.snapshot().is_readable(spill_dir)
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
|
||||||
|
}
|
||||||
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
||||||
let handle = CommandHandle(format!("command-{id}"));
|
let handle = CommandHandle(format!("command-{id}"));
|
||||||
let cwd = self.inner.cwd.clone();
|
let cwd = self.inner.cwd.clone();
|
||||||
@@ -776,6 +779,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
drop(commands);
|
drop(commands);
|
||||||
@@ -792,6 +796,7 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
break commands
|
break commands
|
||||||
@@ -901,6 +906,7 @@ fn command_output_page(output: &CommandOutput, cursor: usize, limit: usize) -> C
|
|||||||
content,
|
content,
|
||||||
next_cursor: (end < total_chars).then_some(end),
|
next_cursor: (end < total_chars).then_some(end),
|
||||||
truncated: output.truncated || end < total_chars,
|
truncated: output.truncated || end < total_chars,
|
||||||
|
output_path: output.output_path.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1059,6 +1065,22 @@ async fn run_command(
|
|||||||
|
|
||||||
let (content, truncated) =
|
let (content, truncated) =
|
||||||
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?;
|
||||||
|
let output_path = match (truncated, request.spill_dir) {
|
||||||
|
(true, Some(spill_dir)) => {
|
||||||
|
let stdout_path = stdout_path.to_path_buf();
|
||||||
|
let stderr_path = stderr_path.to_path_buf();
|
||||||
|
Some(
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
persist_command_output(&stdout_path, &stderr_path, &spill_dir)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
WorkdirError::Unavailable(format!("Bash output spill task failed: {error}"))
|
||||||
|
})??,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
Ok(CommandOutput {
|
Ok(CommandOutput {
|
||||||
status,
|
status,
|
||||||
exit_code,
|
exit_code,
|
||||||
@@ -1066,6 +1088,7 @@ async fn run_command(
|
|||||||
content,
|
content,
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated,
|
truncated,
|
||||||
|
output_path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1154,6 +1177,59 @@ fn stable_utf8_prefix_len(bytes: &[u8]) -> usize {
|
|||||||
inspected
|
inspected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_command_output(
|
||||||
|
stdout_path: &Path,
|
||||||
|
stderr_path: &Path,
|
||||||
|
spill_dir: &Path,
|
||||||
|
) -> Result<PathBuf, WorkdirError> {
|
||||||
|
std::fs::create_dir_all(spill_dir).map_err(|error| WorkdirError::io(spill_dir, error))?;
|
||||||
|
let mut artifact = tempfile::Builder::new()
|
||||||
|
.prefix("bash-")
|
||||||
|
.suffix(".log")
|
||||||
|
.tempfile_in(spill_dir)
|
||||||
|
.map_err(|error| WorkdirError::io(spill_dir, error))?;
|
||||||
|
let artifact_path = artifact.path().to_path_buf();
|
||||||
|
|
||||||
|
let mut stdout =
|
||||||
|
std::fs::File::open(stdout_path).map_err(|error| WorkdirError::io(stdout_path, error))?;
|
||||||
|
let stdout_len = stdout
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| WorkdirError::io(stdout_path, error))?
|
||||||
|
.len();
|
||||||
|
std::io::copy(&mut stdout, &mut artifact)
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
|
||||||
|
let mut stderr =
|
||||||
|
std::fs::File::open(stderr_path).map_err(|error| WorkdirError::io(stderr_path, error))?;
|
||||||
|
let stderr_len = stderr
|
||||||
|
.metadata()
|
||||||
|
.map_err(|error| WorkdirError::io(stderr_path, error))?
|
||||||
|
.len();
|
||||||
|
if stdout_len > 0 && stderr_len > 0 {
|
||||||
|
stdout
|
||||||
|
.seek(SeekFrom::End(-1))
|
||||||
|
.map_err(|error| WorkdirError::io(stdout_path, error))?;
|
||||||
|
let mut last = [0_u8; 1];
|
||||||
|
stdout
|
||||||
|
.read_exact(&mut last)
|
||||||
|
.map_err(|error| WorkdirError::io(stdout_path, error))?;
|
||||||
|
if last[0] != b'\n' {
|
||||||
|
artifact
|
||||||
|
.write_all(b"\n")
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::io::copy(&mut stderr, &mut artifact)
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
artifact
|
||||||
|
.flush()
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error))?;
|
||||||
|
artifact
|
||||||
|
.keep()
|
||||||
|
.map(|(_, path)| path)
|
||||||
|
.map_err(|error| WorkdirError::io(&artifact_path, error.error))
|
||||||
|
}
|
||||||
|
|
||||||
fn read_command_output_files(
|
fn read_command_output_files(
|
||||||
stdout_path: &Path,
|
stdout_path: &Path,
|
||||||
stderr_path: &Path,
|
stderr_path: &Path,
|
||||||
@@ -1440,6 +1516,7 @@ mod tests {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1966,6 +2043,7 @@ mod tests {
|
|||||||
command: "pwd && printf provider-command".into(),
|
command: "pwd && printf provider-command".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1991,6 +2069,151 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn explicitly_scoped_absolute_artifact_can_be_read_and_grepped() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
let artifact = spill.path().join("bash-output.log");
|
||||||
|
std::fs::write(&artifact, "first\nFINAL-NEEDLE\nlast\n").unwrap();
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
ScopeRule {
|
||||||
|
target: spill.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
|
||||||
|
let artifact_path = WorkdirPath::new_scoped(artifact.to_string_lossy()).unwrap();
|
||||||
|
|
||||||
|
let read = WorkdirSession::read(
|
||||||
|
&workdir,
|
||||||
|
ReadRequest {
|
||||||
|
path: artifact_path.clone(),
|
||||||
|
offset: 1,
|
||||||
|
limit: 1,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(String::from_utf8(read.bytes).unwrap(), "FINAL-NEEDLE\n");
|
||||||
|
|
||||||
|
let grep = WorkdirSession::grep(
|
||||||
|
&workdir,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "FINAL-NEEDLE".into(),
|
||||||
|
path: artifact_path,
|
||||||
|
glob: None,
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: crate::GrepOutputMode::Content,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(grep.match_count, 1);
|
||||||
|
assert!(grep.output.contains("FINAL-NEEDLE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn command_rejects_spill_directory_without_read_scope() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
let workdir = make_fs(&dir);
|
||||||
|
|
||||||
|
let error = WorkdirSession::start_command(
|
||||||
|
&workdir,
|
||||||
|
CommandRequest {
|
||||||
|
command: "printf hidden".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 1,
|
||||||
|
spill_dir: Some(spill.path().to_path_buf()),
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(matches!(error, WorkdirError::OutOfScope(path) if path == spill.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn truncated_command_output_is_retained_in_the_requested_spill_directory() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let spill = TempDir::new().unwrap();
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![
|
||||||
|
ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
ScopeRule {
|
||||||
|
target: spill.path().to_path_buf(),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let workdir = LocalWorkdirSession::new(scope, dir.path().to_path_buf());
|
||||||
|
let handle = WorkdirSession::start_command(
|
||||||
|
&workdir,
|
||||||
|
CommandRequest {
|
||||||
|
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
|
||||||
|
timeout_secs: 5,
|
||||||
|
output_limit: 64,
|
||||||
|
spill_dir: Some(spill.path().to_path_buf()),
|
||||||
|
tool_call_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let output = WorkdirSession::command_output(
|
||||||
|
&workdir,
|
||||||
|
CommandOutputRequest {
|
||||||
|
handle,
|
||||||
|
cursor: 0,
|
||||||
|
limit: 4096,
|
||||||
|
wait: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(output.truncated);
|
||||||
|
let output_path = output.output_path.expect("retained output path");
|
||||||
|
assert_eq!(output_path.parent(), Some(spill.path()));
|
||||||
|
let retained = std::fs::read_to_string(&output_path).unwrap();
|
||||||
|
assert!(retained.starts_with("line-000\n"));
|
||||||
|
assert!(retained.ends_with("FINAL-NEEDLE\n"));
|
||||||
|
assert_eq!(retained.lines().count(), 201);
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(output_path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn completed_command_output_can_be_read_in_bounded_unicode_pages() {
|
async fn completed_command_output_can_be_read_in_bounded_unicode_pages() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -2001,6 +2224,7 @@ mod tests {
|
|||||||
command: "printf 'aéz'".into(),
|
command: "printf 'aéz'".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2120,6 +2344,7 @@ mod tests {
|
|||||||
content: "done".into(),
|
content: "done".into(),
|
||||||
next_cursor: None,
|
next_cursor: None,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
workdir.inner.commands.lock().await.insert(
|
workdir.inner.commands.lock().await.insert(
|
||||||
@@ -2224,6 +2449,7 @@ mod tests {
|
|||||||
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-7".into()),
|
tool_call_id: Some("tool-7".into()),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2327,6 +2553,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -2396,6 +2623,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -9,6 +11,9 @@ pub struct CommandRequest {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
pub output_limit: usize,
|
pub output_limit: usize,
|
||||||
|
/// Provider-local directory where complete output is retained when the
|
||||||
|
/// inline result exceeds `output_limit`.
|
||||||
|
pub spill_dir: Option<PathBuf>,
|
||||||
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
/// Optional caller-owned correlation id. Bash supplies its tool-call id so
|
||||||
/// user-facing command telemetry can update the corresponding Console row
|
/// user-facing command telemetry can update the corresponding Console row
|
||||||
/// without exposing provider/session handles.
|
/// without exposing provider/session handles.
|
||||||
@@ -96,4 +101,6 @@ pub struct CommandOutput {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
pub next_cursor: Option<usize>,
|
pub next_cursor: Option<usize>,
|
||||||
pub truncated: bool,
|
pub truncated: bool,
|
||||||
|
/// Complete output retained by the provider when `truncated` is true.
|
||||||
|
pub output_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -703,6 +703,7 @@ async fn run_workdir_session_operation(
|
|||||||
content: String::new(),
|
content: String::new(),
|
||||||
next_cursor: Some(cursor),
|
next_cursor: Some(cursor),
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
output_path: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
WorkdirSessionOperationResult::CommandOutput(output)
|
WorkdirSessionOperationResult::CommandOutput(output)
|
||||||
|
|||||||
@@ -1,105 +1,9 @@
|
|||||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::{fmt, str::FromStr};
|
|
||||||
use uuid::{Uuid, Version};
|
|
||||||
|
|
||||||
|
pub use protocol::{WorkerId, WorkerIdParseError};
|
||||||
pub use workdir::workspace::RuntimeWorkerRef;
|
pub use workdir::workspace::RuntimeWorkerRef;
|
||||||
|
|
||||||
/// Stable Workspace-owned Worker identity.
|
|
||||||
///
|
|
||||||
/// Runtime placement is deliberately not part of this value. New identities are
|
|
||||||
/// allocated by Workspace authority before a Runtime create request is sent.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
|
||||||
pub struct WorkerId(Uuid);
|
|
||||||
|
|
||||||
impl WorkerId {
|
|
||||||
pub fn now_v7() -> Self {
|
|
||||||
Self(Uuid::now_v7())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a legacy Runtime-local numeric id into a syntactically valid
|
|
||||||
/// migration-only UUIDv7 value. New Worker allocation must use `now_v7`.
|
|
||||||
pub fn from_legacy_u64(value: u64) -> Self {
|
|
||||||
let mut bytes = [0_u8; 16];
|
|
||||||
bytes[8..].copy_from_slice(&value.to_be_bytes());
|
|
||||||
bytes[6] = 0x70;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
Self(Uuid::from_bytes(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_legacy_binding(workspace_id: &str, runtime_id: &str, value: u64) -> Self {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(b"yoi.workspace-worker-id.v1\0");
|
|
||||||
hasher.update(workspace_id.as_bytes());
|
|
||||||
hasher.update([0]);
|
|
||||||
hasher.update(runtime_id.as_bytes());
|
|
||||||
hasher.update([0]);
|
|
||||||
hasher.update(value.to_be_bytes());
|
|
||||||
let digest = hasher.finalize();
|
|
||||||
let mut bytes = [0_u8; 16];
|
|
||||||
bytes.copy_from_slice(&digest[..16]);
|
|
||||||
// Migrated ids sort before normally allocated UUIDv7 values while retaining
|
|
||||||
// deterministic collision-resistant payload bits.
|
|
||||||
bytes[..6].fill(0);
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
||||||
Self(Uuid::from_bytes(bytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse(value: &str) -> Option<Self> {
|
|
||||||
let value = Uuid::parse_str(value).ok()?;
|
|
||||||
(value.get_version() == Some(Version::SortRand)).then_some(Self(value))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const fn as_uuid(self) -> Uuid {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for WorkerId {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
self.0.fmt(formatter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FromStr for WorkerId {
|
|
||||||
type Err = WorkerIdParseError;
|
|
||||||
|
|
||||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
||||||
Self::parse(value).ok_or(WorkerIdParseError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Serialize for WorkerId {
|
|
||||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
||||||
where
|
|
||||||
S: Serializer,
|
|
||||||
{
|
|
||||||
serializer.serialize_str(&self.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'de> Deserialize<'de> for WorkerId {
|
|
||||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
||||||
where
|
|
||||||
D: Deserializer<'de>,
|
|
||||||
{
|
|
||||||
let value = String::deserialize(deserializer)?;
|
|
||||||
Self::parse(&value).ok_or_else(|| de::Error::custom("Worker id must be a UUIDv7"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct WorkerIdParseError;
|
|
||||||
|
|
||||||
impl fmt::Display for WorkerIdParseError {
|
|
||||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
formatter.write_str("Worker id must be a UUIDv7")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for WorkerIdParseError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct LegacyWorkerIdentityMapping {
|
pub struct LegacyWorkerIdentityMapping {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
@@ -140,7 +44,7 @@ pub fn legacy_worker_identity_mapping_digest(mappings: &[LegacyWorkerIdentityMap
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Runtime-local authority reference for Worker operations. The contained id is
|
/// Runtime-local authority reference for Worker operations. The contained id is
|
||||||
/// nevertheless the Workspace-owned stable identity; the Runtime does not mint it.
|
/// nevertheless the stable Worker identity; the Runtime does not mint it.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
pub struct WorkerRef {
|
pub struct WorkerRef {
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
@@ -164,14 +68,6 @@ impl TryFrom<&RuntimeWorkerRef> for WorkerRef {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worker_id_accepts_only_uuid_v7() {
|
|
||||||
let worker_id = WorkerId::now_v7();
|
|
||||||
assert_eq!(WorkerId::parse(&worker_id.to_string()), Some(worker_id));
|
|
||||||
assert!(WorkerId::parse("30").is_none());
|
|
||||||
assert!(WorkerId::parse(&Uuid::nil().to_string()).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_worker_ref_preserves_stable_worker_identity() {
|
fn runtime_worker_ref_preserves_stable_worker_identity() {
|
||||||
let worker_id = WorkerId::now_v7();
|
let worker_id = WorkerId::now_v7();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ use worker::{
|
|||||||
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
Worker, WorkerBootstrap, WorkerBootstrapError, WorkerBootstrapLayout,
|
||||||
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
WorkerControllerTransport, WorkerError, WorkerFilesystemAuthority, WorkerHandle,
|
||||||
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
WorkerSharedState, WorkerWorkspaceContext, WorkspaceClient, WorkspaceId,
|
||||||
|
bash_output_dir_for_worker_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
const DEFAULT_BACKEND_ID: &str = "worker-crate";
|
||||||
@@ -886,6 +887,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
let run_dir = worker_aggregate_dir
|
let run_dir = worker_aggregate_dir
|
||||||
.join("runs")
|
.join("runs")
|
||||||
.join(request.run_generation.to_string());
|
.join(request.run_generation.to_string());
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
||||||
let mut prepared = WorkerBootstrap::new(
|
let mut prepared = WorkerBootstrap::new(
|
||||||
manifest,
|
manifest,
|
||||||
store,
|
store,
|
||||||
@@ -894,6 +896,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
filesystem_authority,
|
filesystem_authority,
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: run_dir.clone(),
|
run_dir: run_dir.clone(),
|
||||||
|
bash_output_dir,
|
||||||
},
|
},
|
||||||
self.controller_transport,
|
self.controller_transport,
|
||||||
)
|
)
|
||||||
@@ -1131,10 +1134,12 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
|
|||||||
let run_dir = worker_aggregate_dir
|
let run_dir = worker_aggregate_dir
|
||||||
.join("runs")
|
.join("runs")
|
||||||
.join(request.run_generation.to_string());
|
.join(request.run_generation.to_string());
|
||||||
|
let bash_output_dir = bash_output_dir_for_worker_id(&request.worker_ref.worker_id);
|
||||||
let started = PreparedWorker::new(
|
let started = PreparedWorker::new(
|
||||||
worker,
|
worker,
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
run_dir: run_dir.clone(),
|
run_dir: run_dir.clone(),
|
||||||
|
bash_output_dir,
|
||||||
},
|
},
|
||||||
self.controller_transport,
|
self.controller_transport,
|
||||||
)
|
)
|
||||||
@@ -2552,8 +2557,12 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
let (handle, shutdown_rx) =
|
let bash_output_dir = self.runtime_base.join("bash-output");
|
||||||
WorkerController::spawn_runtime_managed(worker, &self.runtime_base)
|
let (handle, shutdown_rx) = WorkerController::spawn_runtime_managed(
|
||||||
|
worker,
|
||||||
|
&self.runtime_base,
|
||||||
|
&bash_output_dir,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| err.to_string())?;
|
.map_err(|err| err.to_string())?;
|
||||||
Ok(RuntimeWorkerController {
|
Ok(RuntimeWorkerController {
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
let worker = worker::Worker::from_manifest_toml(&toml, store).await?;
|
||||||
|
|
||||||
let runtime_tmp = tempfile::tempdir()?;
|
let runtime_tmp = tempfile::tempdir()?;
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, runtime_tmp.path()).await?;
|
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||||
|
let (handle, _shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir).await?;
|
||||||
|
|
||||||
// Check initial status via shared state
|
// Check initial status via shared state
|
||||||
println!("[shared_state] {}", handle.shared_state.status_json());
|
println!("[shared_state] {}", handle.shared_state.status_json());
|
||||||
|
|||||||
@@ -17,9 +17,28 @@ use manifest::WorkerManifest;
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WorkerBootstrapLayout {
|
pub enum WorkerBootstrapLayout {
|
||||||
/// A direct Worker rooted below the supplied runtime base directory.
|
/// A direct Worker rooted below the supplied runtime base directory.
|
||||||
Direct { runtime_base: PathBuf },
|
Direct {
|
||||||
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
|
},
|
||||||
/// A runtime-managed Worker with an exact persisted run directory.
|
/// A runtime-managed Worker with an exact persisted run directory.
|
||||||
RuntimeManagedRun { run_dir: PathBuf },
|
RuntimeManagedRun {
|
||||||
|
run_dir: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the temporary Bash spill directory owned by a stable Worker identity.
|
||||||
|
///
|
||||||
|
/// The directory deliberately lives outside session/run-generation storage so a
|
||||||
|
/// restarted controller for the same Worker keeps the same readable artifact
|
||||||
|
/// boundary.
|
||||||
|
pub fn bash_output_dir_for_worker_id(worker_id: impl std::fmt::Display) -> PathBuf {
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join("yoi")
|
||||||
|
.join("workers")
|
||||||
|
.join(worker_id.to_string())
|
||||||
|
.join("bash-output")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Construction and controller inputs that are stable for one Worker launch.
|
/// Construction and controller inputs that are stable for one Worker launch.
|
||||||
@@ -204,11 +223,28 @@ where
|
|||||||
{
|
{
|
||||||
let cleanup_session = worker.workdir_session().cloned();
|
let cleanup_session = worker.workdir_session().cloned();
|
||||||
let controller = match layout {
|
let controller = match layout {
|
||||||
WorkerBootstrapLayout::Direct { runtime_base } => {
|
WorkerBootstrapLayout::Direct {
|
||||||
WorkerController::spawn_with_transport(worker, &runtime_base, transport).await
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
} => {
|
||||||
|
WorkerController::spawn_with_transport(
|
||||||
|
worker,
|
||||||
|
&runtime_base,
|
||||||
|
&bash_output_dir,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
WorkerBootstrapLayout::RuntimeManagedRun { run_dir } => {
|
WorkerBootstrapLayout::RuntimeManagedRun {
|
||||||
WorkerController::spawn_runtime_managed_run_with_transport(worker, &run_dir, transport)
|
run_dir,
|
||||||
|
bash_output_dir,
|
||||||
|
} => {
|
||||||
|
WorkerController::spawn_runtime_managed_run_with_transport(
|
||||||
|
worker,
|
||||||
|
&run_dir,
|
||||||
|
&bash_output_dir,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -227,3 +263,22 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::bash_output_dir_for_worker_id;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bash_output_directory_is_stable_per_worker_below_system_temp() {
|
||||||
|
let path = bash_output_dir_for_worker_id("019c1234-worker");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
path,
|
||||||
|
std::env::temp_dir()
|
||||||
|
.join("yoi")
|
||||||
|
.join("workers")
|
||||||
|
.join("019c1234-worker")
|
||||||
|
.join("bash-output")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+146
-112
@@ -45,12 +45,12 @@ use workdir::{
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WorkerHandle {
|
pub struct WorkerHandle {
|
||||||
method_tx: mpsc::Sender<Method>,
|
method_tx: mpsc::Sender<Method>,
|
||||||
event_tx: broadcast::Sender<Event>,
|
working_event_tx: broadcast::Sender<Event>,
|
||||||
pub shared_state: Arc<WorkerSharedState>,
|
pub shared_state: Arc<WorkerSharedState>,
|
||||||
pub runtime_dir: Arc<RuntimeDir>,
|
pub runtime_dir: Arc<RuntimeDir>,
|
||||||
pub alerter: Alerter,
|
pub alerter: Alerter,
|
||||||
pub in_flight: InFlightEvents,
|
pub in_flight: InFlightEvents,
|
||||||
/// Segment-log mirror + broadcast handle. The IPC server snapshots
|
/// Segment-log mirror + session-entry channel. The IPC server snapshots
|
||||||
/// it on every new connection (Event::Snapshot) and forwards
|
/// it on every new connection (Event::Snapshot) and forwards
|
||||||
/// subsequent commits (Event::Entry) on the receiver.
|
/// subsequent commits (Event::Entry) on the receiver.
|
||||||
pub sink: SegmentLogSink,
|
pub sink: SegmentLogSink,
|
||||||
@@ -63,7 +63,7 @@ impl WorkerHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||||
self.event_tx.subscribe()
|
self.working_event_tx.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn committed_entries(&self) -> Vec<LogEntry> {
|
pub fn committed_entries(&self) -> Vec<LogEntry> {
|
||||||
@@ -117,7 +117,7 @@ impl WorkerHandle {
|
|||||||
|
|
||||||
/// Broadcast an event to all listeners (including socket clients).
|
/// Broadcast an event to all listeners (including socket clients).
|
||||||
pub fn send_event(&self, event: Event) -> Result<usize, broadcast::error::SendError<Event>> {
|
pub fn send_event(&self, event: Event) -> Result<usize, broadcast::error::SendError<Event>> {
|
||||||
self.event_tx.send(event)
|
self.working_event_tx.send(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Emit a user-facing alert. Thin wrapper over `Alerter::alert`.
|
/// Emit a user-facing alert. Thin wrapper over `Alerter::alert`.
|
||||||
@@ -129,19 +129,19 @@ impl WorkerHandle {
|
|||||||
async fn set_controller_status(
|
async fn set_controller_status(
|
||||||
shared_state: &Arc<WorkerSharedState>,
|
shared_state: &Arc<WorkerSharedState>,
|
||||||
runtime_dir: &RuntimeDir,
|
runtime_dir: &RuntimeDir,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
status: WorkerStatus,
|
status: WorkerStatus,
|
||||||
) {
|
) {
|
||||||
shared_state.set_status(status);
|
shared_state.set_status(status);
|
||||||
let _ = runtime_dir.write_status(shared_state).await;
|
let _ = runtime_dir.write_status(shared_state).await;
|
||||||
let _ = event_tx.send(Event::Status { status });
|
let _ = working_event_tx.send(Event::Status { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finish_controller_run<C, St>(
|
async fn finish_controller_run<C, St>(
|
||||||
worker: &mut Worker<C, St>,
|
worker: &mut Worker<C, St>,
|
||||||
shared_state: &Arc<WorkerSharedState>,
|
shared_state: &Arc<WorkerSharedState>,
|
||||||
runtime_dir: &RuntimeDir,
|
runtime_dir: &RuntimeDir,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
new_status: WorkerStatus,
|
new_status: WorkerStatus,
|
||||||
) where
|
) where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -157,7 +157,7 @@ async fn finish_controller_run<C, St>(
|
|||||||
// the terminal run boundary so reconnect snapshots cannot append stale
|
// the terminal run boundary so reconnect snapshots cannot append stale
|
||||||
// partial text/tool arguments after newer entries.
|
// partial text/tool arguments after newer entries.
|
||||||
worker.clear_in_flight_events();
|
worker.clear_in_flight_events();
|
||||||
set_controller_status(shared_state, runtime_dir, event_tx, new_status).await;
|
set_controller_status(shared_state, runtime_dir, working_event_tx, new_status).await;
|
||||||
worker.spawn_post_run_memory_jobs();
|
worker.spawn_post_run_memory_jobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +222,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn<C, St>(
|
pub async fn spawn<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -230,6 +231,7 @@ impl WorkerController {
|
|||||||
Self::spawn_inner(
|
Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
false,
|
false,
|
||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
@@ -242,24 +244,9 @@ impl WorkerController {
|
|||||||
pub async fn spawn_with_transport<C, St>(
|
pub async fn spawn_with_transport<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
|
||||||
C: LlmClient + Clone + 'static,
|
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
|
||||||
{
|
|
||||||
Self::spawn_inner(worker, runtime_base, false, None, transport).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Spawn a Worker owned by `worker-runtime`.
|
|
||||||
///
|
|
||||||
/// The controller still uses an ephemeral directory for Unix sockets and
|
|
||||||
/// tool spill artifacts, but does not write legacy pid/status/manifest
|
|
||||||
/// liveness projections.
|
|
||||||
pub async fn spawn_runtime_managed<C, St>(
|
|
||||||
worker: Worker<C, St>,
|
|
||||||
runtime_base: &Path,
|
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
@@ -267,6 +254,33 @@ impl WorkerController {
|
|||||||
Self::spawn_inner(
|
Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
|
false,
|
||||||
|
None,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a Worker owned by `worker-runtime`.
|
||||||
|
///
|
||||||
|
/// The controller uses an ephemeral directory for Unix sockets while tool
|
||||||
|
/// spill artifacts use the separately supplied Worker-owned temporary path.
|
||||||
|
/// Runtime-managed Workers do not write legacy pid/status/manifest liveness
|
||||||
|
/// projections.
|
||||||
|
pub async fn spawn_runtime_managed<C, St>(
|
||||||
|
worker: Worker<C, St>,
|
||||||
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
|
where
|
||||||
|
C: LlmClient + Clone + 'static,
|
||||||
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self::spawn_inner(
|
||||||
|
worker,
|
||||||
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
true,
|
true,
|
||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
@@ -278,6 +292,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn_runtime_managed_run<C, St>(
|
pub async fn spawn_runtime_managed_run<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -286,6 +301,7 @@ impl WorkerController {
|
|||||||
Self::spawn_runtime_managed_run_with_transport(
|
Self::spawn_runtime_managed_run_with_transport(
|
||||||
worker,
|
worker,
|
||||||
run_dir,
|
run_dir,
|
||||||
|
bash_output_dir,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -296,6 +312,7 @@ impl WorkerController {
|
|||||||
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
pub async fn spawn_runtime_managed_run_with_transport<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
run_dir: &Path,
|
run_dir: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
where
|
where
|
||||||
@@ -305,12 +322,21 @@ impl WorkerController {
|
|||||||
let parent = run_dir
|
let parent = run_dir
|
||||||
.parent()
|
.parent()
|
||||||
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
.ok_or_else(|| std::io::Error::other("run path has no parent"))?;
|
||||||
Self::spawn_inner(worker, parent, true, Some(run_dir), transport).await
|
Self::spawn_inner(
|
||||||
|
worker,
|
||||||
|
parent,
|
||||||
|
bash_output_dir,
|
||||||
|
true,
|
||||||
|
Some(run_dir),
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn spawn_inner<C, St>(
|
async fn spawn_inner<C, St>(
|
||||||
worker: Worker<C, St>,
|
worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
@@ -323,6 +349,7 @@ impl WorkerController {
|
|||||||
let result = Self::spawn_initialized(
|
let result = Self::spawn_initialized(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
runtime_managed,
|
runtime_managed,
|
||||||
runtime_run,
|
runtime_run,
|
||||||
transport,
|
transport,
|
||||||
@@ -340,6 +367,7 @@ impl WorkerController {
|
|||||||
async fn spawn_initialized<C, St>(
|
async fn spawn_initialized<C, St>(
|
||||||
mut worker: Worker<C, St>,
|
mut worker: Worker<C, St>,
|
||||||
runtime_base: &Path,
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
@@ -353,9 +381,9 @@ impl WorkerController {
|
|||||||
// bash-output scope) ===
|
// bash-output scope) ===
|
||||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||||
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
let (method_tx, method_rx) = mpsc::channel::<Method>(32);
|
||||||
let (event_tx, _) = broadcast::channel::<Event>(256);
|
let (working_event_tx, _) = broadcast::channel::<Event>(256);
|
||||||
let alerter = Alerter::new(event_tx.clone());
|
let alerter = Alerter::new(working_event_tx.clone());
|
||||||
let in_flight = InFlightEvents::new(event_tx.clone());
|
let in_flight = InFlightEvents::new(working_event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.clone());
|
worker.attach_in_flight_events(in_flight.clone());
|
||||||
|
|
||||||
// Runtime directory is created before tool registration because it owns
|
// Runtime directory is created before tool registration because it owns
|
||||||
@@ -395,13 +423,13 @@ impl WorkerController {
|
|||||||
// Also hand the raw broadcast sender so Worker-internal operations
|
// Also hand the raw broadcast sender so Worker-internal operations
|
||||||
// can emit typed lifecycle `Event`s (currently: compact progress).
|
// can emit typed lifecycle `Event`s (currently: compact progress).
|
||||||
worker.attach_internal_worker_registry(spawned_registry.clone());
|
worker.attach_internal_worker_registry(spawned_registry.clone());
|
||||||
worker.attach_event_tx(event_tx.clone());
|
worker.attach_working_event_tx(working_event_tx.clone());
|
||||||
|
|
||||||
// Bash spills long outputs to a per-worker subdir under the runtime
|
// Bash spill artifacts are owned by the stable Worker identity rather
|
||||||
// dir. Push a recursive `allow(Read)` for that path into the
|
// than a controller session/run generation. Push a recursive
|
||||||
// Worker's runtime scope so the agent can `Read` saved files
|
// `allow(Read)` for the exact tool output path into the Worker's shared
|
||||||
// without polluting the workspace.
|
// runtime scope so the Workdir session and system prompt stay aligned.
|
||||||
let bash_output_dir = runtime_dir.path().join("bash-output");
|
let bash_output_dir = bash_output_dir.to_path_buf();
|
||||||
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
std::fs::create_dir_all(&bash_output_dir).map_err(|e| {
|
||||||
std::io::Error::other(format!(
|
std::io::Error::other(format!(
|
||||||
"create bash output dir {}: {e}",
|
"create bash output dir {}: {e}",
|
||||||
@@ -430,7 +458,7 @@ impl WorkerController {
|
|||||||
worker.wire_history_persistence();
|
worker.wire_history_persistence();
|
||||||
|
|
||||||
// === 2. Engine event bridge wiring ===
|
// === 2. Engine event bridge wiring ===
|
||||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
wire_event_bridges_on_engine(&mut worker, &working_event_tx, &alerter, &in_flight);
|
||||||
|
|
||||||
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
|
||||||
let fs_for_view = register_worker_tools(
|
let fs_for_view = register_worker_tools(
|
||||||
@@ -477,7 +505,7 @@ impl WorkerController {
|
|||||||
|
|
||||||
let handle = WorkerHandle {
|
let handle = WorkerHandle {
|
||||||
method_tx,
|
method_tx,
|
||||||
event_tx: event_tx.clone(),
|
working_event_tx: working_event_tx.clone(),
|
||||||
shared_state: shared_state.clone(),
|
shared_state: shared_state.clone(),
|
||||||
runtime_dir: runtime_dir.clone(),
|
runtime_dir: runtime_dir.clone(),
|
||||||
alerter: alerter.clone(),
|
alerter: alerter.clone(),
|
||||||
@@ -502,7 +530,7 @@ impl WorkerController {
|
|||||||
tokio::spawn(controller_loop(
|
tokio::spawn(controller_loop(
|
||||||
worker,
|
worker,
|
||||||
method_rx,
|
method_rx,
|
||||||
event_tx,
|
working_event_tx,
|
||||||
shared_state,
|
shared_state,
|
||||||
runtime_dir,
|
runtime_dir,
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
@@ -640,7 +668,7 @@ fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Wire the per-event broadcast bridges on the Worker's Engine. Each callback
|
/// Wire the per-event broadcast bridges on the Worker's Engine. Each callback
|
||||||
/// re-publishes a worker-level signal as a `protocol::Event` on `event_tx`
|
/// re-publishes a worker-level signal as a `protocol::Event` on `working_event_tx`
|
||||||
/// so subscribers (TUI, socket clients) get a single typed stream.
|
/// so subscribers (TUI, socket clients) get a single typed stream.
|
||||||
///
|
///
|
||||||
/// `Worker::wire_history_persistence` is called separately to wire the
|
/// `Worker::wire_history_persistence` is called separately to wire the
|
||||||
@@ -649,7 +677,7 @@ fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatu
|
|||||||
/// / `AnnotatedToolResult` commit through the sync writer.
|
/// / `AnnotatedToolResult` commit through the sync writer.
|
||||||
pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
||||||
worker: &mut Worker<C, St>,
|
worker: &mut Worker<C, St>,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
alerter: &Alerter,
|
alerter: &Alerter,
|
||||||
in_flight: &InFlightEvents,
|
in_flight: &InFlightEvents,
|
||||||
) where
|
) where
|
||||||
@@ -659,12 +687,12 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
let ai_activity = worker.ai_activity_counter();
|
let ai_activity = worker.ai_activity_counter();
|
||||||
let worker = worker.engine_mut();
|
let worker = worker.engine_mut();
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_turn_start(move |turn| {
|
worker.on_turn_start(move |turn| {
|
||||||
let _ = tx.send(Event::TurnStart { turn });
|
let _ = tx.send(Event::TurnStart { turn });
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_turn_end(move |turn| {
|
worker.on_turn_end(move |turn| {
|
||||||
let _ = tx.send(Event::TurnEnd {
|
let _ = tx.send(Event::TurnEnd {
|
||||||
turn,
|
turn,
|
||||||
@@ -672,17 +700,17 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_llm_call_start(move |llm_call| {
|
worker.on_llm_call_start(move |llm_call| {
|
||||||
let _ = tx.send(Event::LlmCallStart { llm_call });
|
let _ = tx.send(Event::LlmCallStart { llm_call });
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_llm_call_end(move |llm_call| {
|
worker.on_llm_call_end(move |llm_call| {
|
||||||
let _ = tx.send(Event::LlmCallEnd { llm_call });
|
let _ = tx.send(Event::LlmCallEnd { llm_call });
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_llm_retry(move |llm_call, notice| {
|
worker.on_llm_retry(move |llm_call, notice| {
|
||||||
let _ = tx.send(Event::LlmRetry {
|
let _ = tx.send(Event::LlmRetry {
|
||||||
llm_call,
|
llm_call,
|
||||||
@@ -695,7 +723,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_llm_continuation(move |llm_call, attempt, max_attempts, reason| {
|
worker.on_llm_continuation(move |llm_call, attempt, max_attempts, reason| {
|
||||||
let _ = tx.send(Event::LlmContinuation {
|
let _ = tx.send(Event::LlmContinuation {
|
||||||
llm_call,
|
llm_call,
|
||||||
@@ -768,7 +796,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
let activity = ai_activity.clone();
|
let activity = ai_activity.clone();
|
||||||
worker.on_tool_result(move |result| {
|
worker.on_tool_result(move |result| {
|
||||||
activity.fetch_add(1, Ordering::SeqCst);
|
activity.fetch_add(1, Ordering::SeqCst);
|
||||||
@@ -793,7 +821,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_usage(move |event| {
|
worker.on_usage(move |event| {
|
||||||
let _ = tx.send(Event::Usage {
|
let _ = tx.send(Event::Usage {
|
||||||
input_tokens: event.input_tokens,
|
input_tokens: event.input_tokens,
|
||||||
@@ -802,7 +830,7 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let tx = event_tx.clone();
|
let tx = working_event_tx.clone();
|
||||||
worker.on_error(move |event| {
|
worker.on_error(move |event| {
|
||||||
let _ = tx.send(Event::Error {
|
let _ = tx.send(Event::Error {
|
||||||
code: ErrorCode::ProviderError,
|
code: ErrorCode::ProviderError,
|
||||||
@@ -880,7 +908,7 @@ where
|
|||||||
.register_tools(tools::core_builtin_tools(
|
.register_tools(tools::core_builtin_tools(
|
||||||
workdir.clone(),
|
workdir.clone(),
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
bash_output_dir,
|
bash_output_dir.clone(),
|
||||||
));
|
));
|
||||||
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model)
|
if feature_config.image.enabled && model_supports_image_attachments(&spawner_manifest.model)
|
||||||
{
|
{
|
||||||
@@ -1103,6 +1131,7 @@ where
|
|||||||
spawner_workspace_context,
|
spawner_workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
|
bash_output_dir.clone(),
|
||||||
spawner_workspace_root,
|
spawner_workspace_root,
|
||||||
source_workdir_session,
|
source_workdir_session,
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
@@ -1156,7 +1185,7 @@ where
|
|||||||
async fn controller_loop<C, St>(
|
async fn controller_loop<C, St>(
|
||||||
mut worker: Worker<C, St>,
|
mut worker: Worker<C, St>,
|
||||||
mut method_rx: mpsc::Receiver<Method>,
|
mut method_rx: mpsc::Receiver<Method>,
|
||||||
event_tx: broadcast::Sender<Event>,
|
working_event_tx: broadcast::Sender<Event>,
|
||||||
shared_state: Arc<WorkerSharedState>,
|
shared_state: Arc<WorkerSharedState>,
|
||||||
runtime_dir: Arc<RuntimeDir>,
|
runtime_dir: Arc<RuntimeDir>,
|
||||||
cancel_tx: mpsc::Sender<()>,
|
cancel_tx: mpsc::Sender<()>,
|
||||||
@@ -1213,7 +1242,7 @@ async fn controller_loop<C, St>(
|
|||||||
set_controller_status(
|
set_controller_status(
|
||||||
&shared_state,
|
&shared_state,
|
||||||
&runtime_dir,
|
&runtime_dir,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
WorkerStatus::Running,
|
WorkerStatus::Running,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1230,7 +1259,7 @@ async fn controller_loop<C, St>(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
&pause_tx,
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
@@ -1255,7 +1284,7 @@ async fn controller_loop<C, St>(
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
&pause_tx,
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
@@ -1273,7 +1302,7 @@ async fn controller_loop<C, St>(
|
|||||||
drive_turn(
|
drive_turn(
|
||||||
worker.run_for_notification(kind),
|
worker.run_for_notification(kind),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
&pause_tx,
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
@@ -1291,7 +1320,7 @@ async fn controller_loop<C, St>(
|
|||||||
drive_turn(
|
drive_turn(
|
||||||
worker.resume(),
|
worker.resume(),
|
||||||
&mut method_rx,
|
&mut method_rx,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
&cancel_tx,
|
&cancel_tx,
|
||||||
&pause_tx,
|
&pause_tx,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
@@ -1315,16 +1344,16 @@ async fn controller_loop<C, St>(
|
|||||||
&mut worker,
|
&mut worker,
|
||||||
&shared_state,
|
&shared_state,
|
||||||
&runtime_dir,
|
&runtime_dir,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
new_status,
|
new_status,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if shutdown {
|
if shutdown {
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
let _ = working_event_tx.send(Event::Shutdown);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if take_shutdown_request_after_status(&shutdown_after_idle, new_status) {
|
if take_shutdown_request_after_status(&shutdown_after_idle, new_status) {
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
let _ = working_event_tx.send(Event::Shutdown);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -1342,7 +1371,7 @@ async fn controller_loop<C, St>(
|
|||||||
// already rejects `Run` while a turn is live, so
|
// already rejects `Run` while a turn is live, so
|
||||||
// this branch is only reachable across a race window
|
// this branch is only reachable across a race window
|
||||||
// around status flips.
|
// around status flips.
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Worker is already executing a turn".into(),
|
message: "Worker is already executing a turn".into(),
|
||||||
});
|
});
|
||||||
@@ -1393,7 +1422,7 @@ async fn controller_loop<C, St>(
|
|||||||
|
|
||||||
Method::Resume => {
|
Method::Resume => {
|
||||||
if shared_state.get_status() != WorkerStatus::Paused {
|
if shared_state.get_status() != WorkerStatus::Paused {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::NotPaused,
|
code: ErrorCode::NotPaused,
|
||||||
message: "Worker is not paused".into(),
|
message: "Worker is not paused".into(),
|
||||||
});
|
});
|
||||||
@@ -1409,20 +1438,20 @@ async fn controller_loop<C, St>(
|
|||||||
set_controller_status(
|
set_controller_status(
|
||||||
&shared_state,
|
&shared_state,
|
||||||
&runtime_dir,
|
&runtime_dir,
|
||||||
&event_tx,
|
&working_event_tx,
|
||||||
WorkerStatus::Idle,
|
WorkerStatus::Idle,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: worker_error_code(&error),
|
code: worker_error_code(&error),
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
WorkerStatus::Idle | WorkerStatus::Stopped => {
|
WorkerStatus::Idle | WorkerStatus::Stopped => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::NotRunning,
|
code: ErrorCode::NotRunning,
|
||||||
message: "Worker is not running".into(),
|
message: "Worker is not running".into(),
|
||||||
});
|
});
|
||||||
@@ -1439,7 +1468,7 @@ async fn controller_loop<C, St>(
|
|||||||
// Worker is Idle (Running turns go through `drive_turn`,
|
// Worker is Idle (Running turns go through `drive_turn`,
|
||||||
// not this outer match), so there is nothing to pause.
|
// not this outer match), so there is nothing to pause.
|
||||||
if shared_state.get_status() != WorkerStatus::Paused {
|
if shared_state.get_status() != WorkerStatus::Paused {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::NotRunning,
|
code: ErrorCode::NotRunning,
|
||||||
message: "Worker is not running".into(),
|
message: "Worker is not running".into(),
|
||||||
});
|
});
|
||||||
@@ -1449,21 +1478,21 @@ async fn controller_loop<C, St>(
|
|||||||
Method::Compact => match shared_state.get_status() {
|
Method::Compact => match shared_state.get_status() {
|
||||||
WorkerStatus::Idle => {
|
WorkerStatus::Idle => {
|
||||||
if let Err(error) = worker.manual_compact().await {
|
if let Err(error) = worker.manual_compact().await {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: worker_error_code(&error),
|
code: worker_error_code(&error),
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WorkerStatus::Paused => {
|
WorkerStatus::Paused => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: "Cannot compact while the Worker is paused; resume or start a fresh turn first"
|
message: "Cannot compact while the Worker is paused; resume or start a fresh turn first"
|
||||||
.into(),
|
.into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message:
|
message:
|
||||||
"Worker is already executing a turn; compact can only run while idle"
|
"Worker is already executing a turn; compact can only run while idle"
|
||||||
@@ -1474,10 +1503,10 @@ async fn controller_loop<C, St>(
|
|||||||
|
|
||||||
Method::ListRewindTargets => match shared_state.get_status() {
|
Method::ListRewindTargets => match shared_state.get_status() {
|
||||||
WorkerStatus::Idle | WorkerStatus::Paused => {
|
WorkerStatus::Idle | WorkerStatus::Paused => {
|
||||||
emit_rewind_targets(&worker, &event_tx)
|
emit_rewind_targets(&worker, &working_event_tx)
|
||||||
}
|
}
|
||||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||||
.into(),
|
.into(),
|
||||||
@@ -1490,23 +1519,28 @@ async fn controller_loop<C, St>(
|
|||||||
expected_head_entries,
|
expected_head_entries,
|
||||||
} => match shared_state.get_status() {
|
} => match shared_state.get_status() {
|
||||||
WorkerStatus::Idle => {
|
WorkerStatus::Idle => {
|
||||||
if apply_rewind(&mut worker, &event_tx, target, expected_head_entries) {
|
if apply_rewind(
|
||||||
|
&mut worker,
|
||||||
|
&working_event_tx,
|
||||||
|
target,
|
||||||
|
expected_head_entries,
|
||||||
|
) {
|
||||||
worker.clear_in_flight_events();
|
worker.clear_in_flight_events();
|
||||||
shared_state.set_status(WorkerStatus::Idle);
|
shared_state.set_status(WorkerStatus::Idle);
|
||||||
let _ = event_tx.send(Event::Status {
|
let _ = working_event_tx.send(Event::Status {
|
||||||
status: WorkerStatus::Idle,
|
status: WorkerStatus::Idle,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WorkerStatus::Paused => {
|
WorkerStatus::Paused => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: "Cannot apply rewind while the Worker is paused; resume or wait for idle first"
|
message: "Cannot apply rewind while the Worker is paused; resume or wait for idle first"
|
||||||
.into(),
|
.into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
WorkerStatus::Running | WorkerStatus::Stopped => {
|
WorkerStatus::Running | WorkerStatus::Stopped => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
message: "Worker is already executing a turn; rewind can only run while idle or paused"
|
||||||
.into(),
|
.into(),
|
||||||
@@ -1515,24 +1549,24 @@ async fn controller_loop<C, St>(
|
|||||||
},
|
},
|
||||||
|
|
||||||
Method::Shutdown => {
|
Method::Shutdown => {
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
let _ = working_event_tx.send(Event::Shutdown);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Method::ListWorkers => match discovery.list_visible().await {
|
Method::ListWorkers => match discovery.list_visible().await {
|
||||||
Ok(workers) => match serde_json::to_value(workers) {
|
Ok(workers) => match serde_json::to_value(workers) {
|
||||||
Ok(workers) => {
|
Ok(workers) => {
|
||||||
let _ = event_tx.send(Event::WorkersListed { workers });
|
let _ = working_event_tx.send(Event::WorkersListed { workers });
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::Internal,
|
code: ErrorCode::Internal,
|
||||||
message: format!("serialize visible workers: {error}"),
|
message: format!("serialize visible workers: {error}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
@@ -1542,17 +1576,17 @@ async fn controller_loop<C, St>(
|
|||||||
Method::RestoreWorker { name } => match discovery.restore(&name).await {
|
Method::RestoreWorker { name } => match discovery.restore(&name).await {
|
||||||
Ok(result) => match serde_json::to_value(result) {
|
Ok(result) => match serde_json::to_value(result) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let _ = event_tx.send(Event::WorkerRestored { result });
|
let _ = working_event_tx.send(Event::WorkerRestored { result });
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::Internal,
|
code: ErrorCode::Internal,
|
||||||
message: format!("serialize worker restore result: {error}"),
|
message: format!("serialize worker restore result: {error}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
@@ -1562,17 +1596,17 @@ async fn controller_loop<C, St>(
|
|||||||
Method::RegisterPeer { name } => match discovery.register_peer(&name) {
|
Method::RegisterPeer { name } => match discovery.register_peer(&name) {
|
||||||
Ok(result) => match serde_json::to_value(result) {
|
Ok(result) => match serde_json::to_value(result) {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let _ = event_tx.send(Event::PeerRegistered { result });
|
let _ = working_event_tx.send(Event::PeerRegistered { result });
|
||||||
}
|
}
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::Internal,
|
code: ErrorCode::Internal,
|
||||||
message: format!("serialize peer registration result: {error}"),
|
message: format!("serialize peer registration result: {error}"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
});
|
});
|
||||||
@@ -1691,7 +1725,7 @@ async fn handle_inbound_worker_event(
|
|||||||
async fn drive_turn<F>(
|
async fn drive_turn<F>(
|
||||||
worker_future: F,
|
worker_future: F,
|
||||||
method_rx: &mut mpsc::Receiver<Method>,
|
method_rx: &mut mpsc::Receiver<Method>,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
cancel_tx: &mpsc::Sender<()>,
|
cancel_tx: &mpsc::Sender<()>,
|
||||||
pause_tx: &mpsc::Sender<()>,
|
pause_tx: &mpsc::Sender<()>,
|
||||||
shared_state: &Arc<WorkerSharedState>,
|
shared_state: &Arc<WorkerSharedState>,
|
||||||
@@ -1727,7 +1761,7 @@ where
|
|||||||
set_controller_status(
|
set_controller_status(
|
||||||
shared_state,
|
shared_state,
|
||||||
runtime_dir,
|
runtime_dir,
|
||||||
event_tx,
|
working_event_tx,
|
||||||
WorkerStatus::Running,
|
WorkerStatus::Running,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -1745,11 +1779,11 @@ where
|
|||||||
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
|
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
|
||||||
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
|
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
|
||||||
WorkerRunResult::Interrupted { .. } if pause_requested => {
|
WorkerRunResult::Interrupted { .. } if pause_requested => {
|
||||||
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
|
let _ = working_event_tx.send(Event::RunEnd { result: RunResult::Paused });
|
||||||
return (WorkerStatus::Paused, shutdown_requested);
|
return (WorkerStatus::Paused, shutdown_requested);
|
||||||
}
|
}
|
||||||
WorkerRunResult::Interrupted { code, message } => {
|
WorkerRunResult::Interrupted { code, message } => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code,
|
code,
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
});
|
});
|
||||||
@@ -1765,7 +1799,7 @@ where
|
|||||||
return (WorkerStatus::Idle, shutdown_requested);
|
return (WorkerStatus::Idle, shutdown_requested);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let _ = event_tx.send(Event::RunEnd { result: run_result });
|
let _ = working_event_tx.send(Event::RunEnd { result: run_result });
|
||||||
if parent_originated && matches!(run_result, RunResult::Finished) {
|
if parent_originated && matches!(run_result, RunResult::Finished) {
|
||||||
crate::ipc::event::fire_and_forget(
|
crate::ipc::event::fire_and_forget(
|
||||||
parent_socket.cloned(),
|
parent_socket.cloned(),
|
||||||
@@ -1782,13 +1816,13 @@ where
|
|||||||
// intentionally skip `WorkerEvent::Errored` upward:
|
// intentionally skip `WorkerEvent::Errored` upward:
|
||||||
// that channel is reserved for worker runtime
|
// that channel is reserved for worker runtime
|
||||||
// failures, not deliberate interruptions.
|
// failures, not deliberate interruptions.
|
||||||
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
|
let _ = working_event_tx.send(Event::RunEnd { result: RunResult::Paused });
|
||||||
(WorkerStatus::Paused, shutdown_requested)
|
(WorkerStatus::Paused, shutdown_requested)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let code = worker_error_code(&e);
|
let code = worker_error_code(&e);
|
||||||
let message = e.to_string();
|
let message = e.to_string();
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code,
|
code,
|
||||||
message: message.clone(),
|
message: message.clone(),
|
||||||
});
|
});
|
||||||
@@ -1819,13 +1853,13 @@ where
|
|||||||
let _ = cancel_tx.try_send(());
|
let _ = cancel_tx.try_send(());
|
||||||
}
|
}
|
||||||
Some(Method::Run { .. } | Method::RunTracked { .. } | Method::Resume) => {
|
Some(Method::Run { .. } | Method::RunTracked { .. } | Method::Resume) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Worker is already executing a turn".into(),
|
message: "Worker is already executing a turn".into(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(Method::Compact | Method::ListRewindTargets | Method::RewindTo { .. }) => {
|
Some(Method::Compact | Method::ListRewindTargets | Method::RewindTo { .. }) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Worker is already executing a turn; rewind/compact can only run while idle or paused"
|
message: "Worker is already executing a turn; rewind/compact can only run while idle or paused"
|
||||||
.into(),
|
.into(),
|
||||||
@@ -1839,7 +1873,7 @@ where
|
|||||||
}
|
}
|
||||||
Some(Method::ListCompletions { .. }) => {}
|
Some(Method::ListCompletions { .. }) => {}
|
||||||
Some(Method::ListWorkers | Method::RestoreWorker { .. } | Method::RegisterPeer { .. }) => {
|
Some(Method::ListWorkers | Method::RestoreWorker { .. } | Method::RegisterPeer { .. }) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::AlreadyRunning,
|
code: ErrorCode::AlreadyRunning,
|
||||||
message: "Worker discovery/control requests are only handled while the Worker is idle or paused"
|
message: "Worker discovery/control requests are only handled while the Worker is idle or paused"
|
||||||
.into(),
|
.into(),
|
||||||
@@ -1872,20 +1906,20 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
|
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, working_event_tx: &broadcast::Sender<Event>)
|
||||||
where
|
where
|
||||||
C: LlmClient + 'static,
|
C: LlmClient + 'static,
|
||||||
St: Store,
|
St: Store,
|
||||||
{
|
{
|
||||||
match worker.list_rewind_targets() {
|
match worker.list_rewind_targets() {
|
||||||
Ok((head_entries, targets)) => {
|
Ok((head_entries, targets)) => {
|
||||||
let _ = event_tx.send(Event::RewindTargets {
|
let _ = working_event_tx.send(Event::RewindTargets {
|
||||||
head_entries,
|
head_entries,
|
||||||
targets,
|
targets,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::Internal,
|
code: ErrorCode::Internal,
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
});
|
});
|
||||||
@@ -1895,7 +1929,7 @@ where
|
|||||||
|
|
||||||
fn apply_rewind<C, St>(
|
fn apply_rewind<C, St>(
|
||||||
worker: &mut Worker<C, St>,
|
worker: &mut Worker<C, St>,
|
||||||
event_tx: &broadcast::Sender<Event>,
|
working_event_tx: &broadcast::Sender<Event>,
|
||||||
target: RewindTargetId,
|
target: RewindTargetId,
|
||||||
expected_head_entries: usize,
|
expected_head_entries: usize,
|
||||||
) -> bool
|
) -> bool
|
||||||
@@ -1907,7 +1941,7 @@ where
|
|||||||
Ok(applied) => {
|
Ok(applied) => {
|
||||||
let session =
|
let session =
|
||||||
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
session_store::public_snapshot::project_current_session_snapshot(&applied.entries);
|
||||||
let _ = event_tx.send(Event::RewindApplied {
|
let _ = working_event_tx.send(Event::RewindApplied {
|
||||||
session,
|
session,
|
||||||
input: applied.input,
|
input: applied.input,
|
||||||
summary: applied.summary,
|
summary: applied.summary,
|
||||||
@@ -1915,7 +1949,7 @@ where
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let _ = event_tx.send(Event::Error {
|
let _ = working_event_tx.send(Event::Error {
|
||||||
code: ErrorCode::InvalidRequest,
|
code: ErrorCode::InvalidRequest,
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
});
|
});
|
||||||
@@ -2049,7 +2083,7 @@ mod tests {
|
|||||||
// would observe channel-closed and confuse the select! arm.
|
// would observe channel-closed and confuse the select! arm.
|
||||||
_method_tx: mpsc::Sender<Method>,
|
_method_tx: mpsc::Sender<Method>,
|
||||||
method_rx: mpsc::Receiver<Method>,
|
method_rx: mpsc::Receiver<Method>,
|
||||||
event_tx: broadcast::Sender<Event>,
|
working_event_tx: broadcast::Sender<Event>,
|
||||||
cancel_tx: mpsc::Sender<()>,
|
cancel_tx: mpsc::Sender<()>,
|
||||||
_cancel_rx: mpsc::Receiver<()>,
|
_cancel_rx: mpsc::Receiver<()>,
|
||||||
pause_tx: mpsc::Sender<()>,
|
pause_tx: mpsc::Sender<()>,
|
||||||
@@ -2070,7 +2104,7 @@ mod tests {
|
|||||||
.expect("runtime dir create"),
|
.expect("runtime dir create"),
|
||||||
);
|
);
|
||||||
let (method_tx, method_rx) = mpsc::channel::<Method>(16);
|
let (method_tx, method_rx) = mpsc::channel::<Method>(16);
|
||||||
let (event_tx, _) = broadcast::channel::<Event>(16);
|
let (working_event_tx, _) = broadcast::channel::<Event>(16);
|
||||||
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
|
||||||
let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
|
let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
|
||||||
let shared_state = Arc::new(WorkerSharedState::new(
|
let shared_state = Arc::new(WorkerSharedState::new(
|
||||||
@@ -2095,7 +2129,7 @@ mod tests {
|
|||||||
DriveTurnEnv {
|
DriveTurnEnv {
|
||||||
_method_tx: method_tx,
|
_method_tx: method_tx,
|
||||||
method_rx,
|
method_rx,
|
||||||
event_tx,
|
working_event_tx,
|
||||||
cancel_tx,
|
cancel_tx,
|
||||||
_cancel_rx: cancel_rx,
|
_cancel_rx: cancel_rx,
|
||||||
pause_tx,
|
pause_tx,
|
||||||
@@ -2157,7 +2191,7 @@ mod tests {
|
|||||||
let (status, shutdown) = drive_turn(
|
let (status, shutdown) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2200,7 +2234,7 @@ mod tests {
|
|||||||
let (status, shutdown) = drive_turn(
|
let (status, shutdown) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2230,7 +2264,7 @@ mod tests {
|
|||||||
let (status, _) = drive_turn(
|
let (status, _) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2268,7 +2302,7 @@ mod tests {
|
|||||||
let (status, _) = drive_turn(
|
let (status, _) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2312,7 +2346,7 @@ mod tests {
|
|||||||
let (status, _) = drive_turn(
|
let (status, _) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2354,7 +2388,7 @@ mod tests {
|
|||||||
let (status, shutdown) = drive_turn(
|
let (status, shutdown) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2393,7 +2427,7 @@ mod tests {
|
|||||||
let (status, shutdown) = drive_turn(
|
let (status, shutdown) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2430,7 +2464,7 @@ mod tests {
|
|||||||
let (status, shutdown) = drive_turn(
|
let (status, shutdown) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
@@ -2453,7 +2487,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn compact_method_is_rejected_while_running() {
|
async fn compact_method_is_rejected_while_running() {
|
||||||
let mut env = make_env().await;
|
let mut env = make_env().await;
|
||||||
let mut events = env.event_tx.subscribe();
|
let mut events = env.working_event_tx.subscribe();
|
||||||
env._method_tx
|
env._method_tx
|
||||||
.send(Method::Compact)
|
.send(Method::Compact)
|
||||||
.await
|
.await
|
||||||
@@ -2466,7 +2500,7 @@ mod tests {
|
|||||||
let (status, shutdown) = drive_turn(
|
let (status, shutdown) = drive_turn(
|
||||||
worker_future,
|
worker_future,
|
||||||
&mut env.method_rx,
|
&mut env.method_rx,
|
||||||
&env.event_tx,
|
&env.working_event_tx,
|
||||||
&env.cancel_tx,
|
&env.cancel_tx,
|
||||||
&env.pause_tx,
|
&env.pause_tx,
|
||||||
&env.shared_state,
|
&env.shared_state,
|
||||||
|
|||||||
@@ -634,10 +634,12 @@ async fn run_cli_inner(cli: Cli) -> ExitCode {
|
|||||||
return ExitCode::FAILURE;
|
return ExitCode::FAILURE;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let bash_output_dir = crate::bash_output_dir_for_worker_id(&worker_name);
|
||||||
let started = match start_worker_controller(
|
let started = match start_worker_controller(
|
||||||
worker,
|
worker,
|
||||||
WorkerBootstrapLayout::Direct {
|
WorkerBootstrapLayout::Direct {
|
||||||
runtime_base: runtime_base.clone(),
|
runtime_base: runtime_base.clone(),
|
||||||
|
bash_output_dir,
|
||||||
},
|
},
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ pub struct InFlightBlockId(u64);
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct InFlightEvents {
|
pub struct InFlightEvents {
|
||||||
inner: Arc<Mutex<InFlightInner>>,
|
inner: Arc<Mutex<InFlightInner>>,
|
||||||
event_tx: broadcast::Sender<Event>,
|
working_event_tx: broadcast::Sender<Event>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -47,14 +47,14 @@ enum TrackedBlock {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InFlightEvents {
|
impl InFlightEvents {
|
||||||
pub(crate) fn new(event_tx: broadcast::Sender<Event>) -> Self {
|
pub(crate) fn new(working_event_tx: broadcast::Sender<Event>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Mutex::new(InFlightInner {
|
inner: Arc::new(Mutex::new(InFlightInner {
|
||||||
next_block_id: 1,
|
next_block_id: 1,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
})),
|
})),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ impl InFlightEvents {
|
|||||||
current.push_str(&text);
|
current.push_str(&text);
|
||||||
*finished = false;
|
*finished = false;
|
||||||
}
|
}
|
||||||
let _ = self.event_tx.send(Event::TextDelta { text });
|
let _ = self.working_event_tx.send(Event::TextDelta { text });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn text_done(&self, block_id: InFlightBlockId, text: String) {
|
pub(crate) fn text_done(&self, block_id: InFlightBlockId, text: String) {
|
||||||
@@ -100,7 +100,7 @@ impl InFlightEvents {
|
|||||||
}
|
}
|
||||||
*finished = true;
|
*finished = true;
|
||||||
}
|
}
|
||||||
let _ = self.event_tx.send(Event::TextDone { text });
|
let _ = self.working_event_tx.send(Event::TextDone { text });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn thinking_start(&self) -> InFlightBlockId {
|
pub(crate) fn thinking_start(&self) -> InFlightBlockId {
|
||||||
@@ -111,7 +111,7 @@ impl InFlightEvents {
|
|||||||
text: String::new(),
|
text: String::new(),
|
||||||
finished: false,
|
finished: false,
|
||||||
});
|
});
|
||||||
let _ = self.event_tx.send(Event::ThinkingStart);
|
let _ = self.working_event_tx.send(Event::ThinkingStart);
|
||||||
block_id
|
block_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ impl InFlightEvents {
|
|||||||
current.push_str(&text);
|
current.push_str(&text);
|
||||||
*finished = false;
|
*finished = false;
|
||||||
}
|
}
|
||||||
let _ = self.event_tx.send(Event::ThinkingDelta { text });
|
let _ = self.working_event_tx.send(Event::ThinkingDelta { text });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn thinking_done(&self, block_id: InFlightBlockId, text: String) {
|
pub(crate) fn thinking_done(&self, block_id: InFlightBlockId, text: String) {
|
||||||
@@ -142,7 +142,7 @@ impl InFlightEvents {
|
|||||||
}
|
}
|
||||||
*finished = true;
|
*finished = true;
|
||||||
}
|
}
|
||||||
let _ = self.event_tx.send(Event::ThinkingDone { text });
|
let _ = self.working_event_tx.send(Event::ThinkingDone { text });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn tool_call_start(&self, id: String, name: String) -> InFlightBlockId {
|
pub(crate) fn tool_call_start(&self, id: String, name: String) -> InFlightBlockId {
|
||||||
@@ -155,7 +155,9 @@ impl InFlightEvents {
|
|||||||
args: String::new(),
|
args: String::new(),
|
||||||
state: InFlightToolCallState::Pending,
|
state: InFlightToolCallState::Pending,
|
||||||
});
|
});
|
||||||
let _ = self.event_tx.send(Event::ToolCallStart { id, name });
|
let _ = self
|
||||||
|
.working_event_tx
|
||||||
|
.send(Event::ToolCallStart { id, name });
|
||||||
block_id
|
block_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +173,7 @@ impl InFlightEvents {
|
|||||||
*state = InFlightToolCallState::StreamingArgs;
|
*state = InFlightToolCallState::StreamingArgs;
|
||||||
}
|
}
|
||||||
let _ = self
|
let _ = self
|
||||||
.event_tx
|
.working_event_tx
|
||||||
.send(Event::ToolCallArgsDelta { id, json: delta });
|
.send(Event::ToolCallArgsDelta { id, json: delta });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,7 +193,7 @@ impl InFlightEvents {
|
|||||||
}
|
}
|
||||||
*state = InFlightToolCallState::Done;
|
*state = InFlightToolCallState::Done;
|
||||||
}
|
}
|
||||||
let _ = self.event_tx.send(Event::ToolCallDone {
|
let _ = self.working_event_tx.send(Event::ToolCallDone {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
arguments: args,
|
arguments: args,
|
||||||
@@ -210,7 +212,7 @@ impl InFlightEvents {
|
|||||||
|
|
||||||
pub(crate) fn publish_command_event(&self, event: CommandEvent) {
|
pub(crate) fn publish_command_event(&self, event: CommandEvent) {
|
||||||
self.lock().apply_command_event(&event);
|
self.lock().apply_command_event(&event);
|
||||||
let _ = self.event_tx.send(Event::Command { event });
|
let _ = self.working_event_tx.send(Event::Command { event });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn replace_command_snapshot(&self, commands: Vec<CommandSnapshot>) {
|
pub(crate) fn replace_command_snapshot(&self, commands: Vec<CommandSnapshot>) {
|
||||||
@@ -492,13 +494,13 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_boundary_does_not_duplicate_or_gap_delta_sent_after_subscribe() {
|
fn snapshot_boundary_does_not_duplicate_or_gap_delta_sent_after_subscribe() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let in_flight = InFlightEvents::new(event_tx.clone());
|
let in_flight = InFlightEvents::new(working_event_tx.clone());
|
||||||
let block_id = in_flight.start_text_block();
|
let block_id = in_flight.start_text_block();
|
||||||
in_flight.text_delta(block_id, "hel".into());
|
in_flight.text_delta(block_id, "hel".into());
|
||||||
|
|
||||||
let guard = in_flight.snapshot_guard();
|
let guard = in_flight.snapshot_guard();
|
||||||
let mut rx = event_tx.subscribe();
|
let mut rx = working_event_tx.subscribe();
|
||||||
let snapshot = snapshot_from_guard(&guard);
|
let snapshot = snapshot_from_guard(&guard);
|
||||||
drop(guard);
|
drop(guard);
|
||||||
|
|
||||||
@@ -526,9 +528,9 @@ mod tests {
|
|||||||
use crate::segment_log_sink::SegmentLogSink;
|
use crate::segment_log_sink::SegmentLogSink;
|
||||||
use session_store::{LogEntry, LoggedRole};
|
use session_store::{LogEntry, LoggedRole};
|
||||||
|
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let sink = SegmentLogSink::new();
|
let sink = SegmentLogSink::new();
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let block_id = in_flight.start_text_block();
|
let block_id = in_flight.start_text_block();
|
||||||
in_flight.text_delta(block_id, "done".into());
|
in_flight.text_delta(block_id, "done".into());
|
||||||
in_flight.text_done(block_id, "done".into());
|
in_flight.text_done(block_id, "done".into());
|
||||||
@@ -580,9 +582,9 @@ mod tests {
|
|||||||
use crate::segment_log_sink::SegmentLogSink;
|
use crate::segment_log_sink::SegmentLogSink;
|
||||||
use session_store::{LogEntry, LoggedRole};
|
use session_store::{LogEntry, LoggedRole};
|
||||||
|
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let sink = SegmentLogSink::new();
|
let sink = SegmentLogSink::new();
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let block_id = in_flight.start_text_block();
|
let block_id = in_flight.start_text_block();
|
||||||
in_flight.text_delta(block_id, "done".into());
|
in_flight.text_delta(block_id, "done".into());
|
||||||
in_flight.text_done(block_id, "done".into());
|
in_flight.text_done(block_id, "done".into());
|
||||||
@@ -615,8 +617,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn committed_item_clears_matching_in_flight_block() {
|
fn committed_item_clears_matching_in_flight_block() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let block_id = in_flight.start_text_block();
|
let block_id = in_flight.start_text_block();
|
||||||
in_flight.text_delta(block_id, "done".into());
|
in_flight.text_delta(block_id, "done".into());
|
||||||
in_flight.clear_for_committed_item_then(
|
in_flight.clear_for_committed_item_then(
|
||||||
@@ -635,8 +637,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn committed_reasoning_summary_clears_matching_in_flight_thinking_blocks() {
|
fn committed_reasoning_summary_clears_matching_in_flight_thinking_blocks() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let first = in_flight.thinking_start();
|
let first = in_flight.thinking_start();
|
||||||
in_flight.thinking_delta(first, "summary A".into());
|
in_flight.thinking_delta(first, "summary A".into());
|
||||||
in_flight.thinking_done(first, "".into());
|
in_flight.thinking_done(first, "".into());
|
||||||
@@ -660,8 +662,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn committed_encrypted_only_reasoning_clears_empty_finished_thinking_block() {
|
fn committed_encrypted_only_reasoning_clears_empty_finished_thinking_block() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let first = in_flight.thinking_start();
|
let first = in_flight.thinking_start();
|
||||||
in_flight.thinking_done(first, "".into());
|
in_flight.thinking_done(first, "".into());
|
||||||
let second = in_flight.thinking_start();
|
let second = in_flight.thinking_start();
|
||||||
@@ -689,9 +691,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_events_are_bounded_and_recoverable_from_snapshot() {
|
fn command_events_are_bounded_and_recoverable_from_snapshot() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let mut rx = event_tx.subscribe();
|
let mut rx = working_event_tx.subscribe();
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
in_flight.publish_command_event(CommandEvent::Started {
|
in_flight.publish_command_event(CommandEvent::Started {
|
||||||
command_id: "command-1".into(),
|
command_id: "command-1".into(),
|
||||||
tool_call_id: Some("tool-1".into()),
|
tool_call_id: Some("tool-1".into()),
|
||||||
@@ -740,9 +742,9 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let mut rx = event_tx.subscribe();
|
let mut rx = working_event_tx.subscribe();
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let text = in_flight.start_text_block();
|
let text = in_flight.start_text_block();
|
||||||
in_flight.text_delta(text, "stale".into());
|
in_flight.text_delta(text, "stale".into());
|
||||||
let tool = in_flight.tool_call_start("call-1".into(), "Bash".into());
|
let tool = in_flight.tool_call_start("call-1".into(), "Bash".into());
|
||||||
@@ -770,8 +772,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_omits_empty_finished_thinking_blocks() {
|
fn snapshot_omits_empty_finished_thinking_blocks() {
|
||||||
let (event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
let in_flight = InFlightEvents::new(event_tx);
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
let empty_finished = in_flight.thinking_start();
|
let empty_finished = in_flight.thinking_start();
|
||||||
in_flight.thinking_done(empty_finished, "".into());
|
in_flight.thinking_done(empty_finished, "".into());
|
||||||
let empty_running = in_flight.thinking_start();
|
let empty_running = in_flight.thinking_start();
|
||||||
|
|||||||
@@ -752,7 +752,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
}
|
}
|
||||||
let actor_in_flight = in_flight.clone();
|
let actor_in_flight = in_flight.clone();
|
||||||
worker.attach_alerter(alerter.clone());
|
worker.attach_alerter(alerter.clone());
|
||||||
worker.attach_event_tx(event_tx.clone());
|
worker.attach_working_event_tx(event_tx.clone());
|
||||||
worker.attach_in_flight_events(in_flight.clone());
|
worker.attach_in_flight_events(in_flight.clone());
|
||||||
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
|
||||||
|
|
||||||
|
|||||||
@@ -28,15 +28,15 @@ pub struct Alerter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct Inner {
|
struct Inner {
|
||||||
event_tx: broadcast::Sender<Event>,
|
working_event_tx: broadcast::Sender<Event>,
|
||||||
buffer: Mutex<VecDeque<Alert>>,
|
buffer: Mutex<VecDeque<Alert>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Alerter {
|
impl Alerter {
|
||||||
pub fn new(event_tx: broadcast::Sender<Event>) -> Self {
|
pub fn new(working_event_tx: broadcast::Sender<Event>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(Inner {
|
inner: Arc::new(Inner {
|
||||||
event_tx,
|
working_event_tx,
|
||||||
buffer: Mutex::new(VecDeque::with_capacity(MAX_BUFFERED_ALERTS)),
|
buffer: Mutex::new(VecDeque::with_capacity(MAX_BUFFERED_ALERTS)),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ impl Alerter {
|
|||||||
buf.pop_front();
|
buf.pop_front();
|
||||||
}
|
}
|
||||||
buf.push_back(alert.clone());
|
buf.push_back(alert.clone());
|
||||||
let _ = self.inner.event_tx.send(Event::Alert(alert));
|
let _ = self.inner.working_event_tx.send(Event::Alert(alert));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ impl Alerter {
|
|||||||
.buffer
|
.buffer
|
||||||
.lock()
|
.lock()
|
||||||
.expect("alerter buffer mutex poisoned");
|
.expect("alerter buffer mutex poisoned");
|
||||||
let rx = self.inner.event_tx.subscribe();
|
let rx = self.inner.working_event_tx.subscribe();
|
||||||
let snapshot: Vec<Alert> = buf.iter().cloned().collect();
|
let snapshot: Vec<Alert> = buf.iter().cloned().collect();
|
||||||
(snapshot, rx)
|
(snapshot, rx)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ mod worker;
|
|||||||
|
|
||||||
pub use bootstrap::{
|
pub use bootstrap::{
|
||||||
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
BootstrappedWorker, PreparedWorker, WorkerBootstrap, WorkerBootstrapError,
|
||||||
WorkerBootstrapLayout, start_worker_controller,
|
WorkerBootstrapLayout, bash_output_dir_for_worker_id, start_worker_controller,
|
||||||
};
|
};
|
||||||
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
pub use compact::token_counter::{EstimateSource, SplitPoint, TokenEstimate};
|
||||||
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
pub use controller::{ShutdownReceiver, WorkerController, WorkerControllerTransport, WorkerHandle};
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ struct SinkInner {
|
|||||||
/// survives session swaps so existing subscribers keep their
|
/// survives session swaps so existing subscribers keep their
|
||||||
/// receiver — they observe the swap as a freshly broadcast
|
/// receiver — they observe the swap as a freshly broadcast
|
||||||
/// `LogEntry::AnnotatedSegmentStart` and reset their view accordingly.
|
/// `LogEntry::AnnotatedSegmentStart` and reset their view accordingly.
|
||||||
broadcast_tx: broadcast::Sender<LogEntry>,
|
session_entry_tx: broadcast::Sender<LogEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SegmentLogSink {
|
impl SegmentLogSink {
|
||||||
@@ -59,11 +59,11 @@ impl SegmentLogSink {
|
|||||||
/// has been written (deferred SegmentStart) or as a placeholder in
|
/// has been written (deferred SegmentStart) or as a placeholder in
|
||||||
/// tests.
|
/// tests.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (broadcast_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
let (session_entry_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(SinkInner {
|
inner: Arc::new(SinkInner {
|
||||||
mirror: StdMutex::new(Vec::new()),
|
mirror: StdMutex::new(Vec::new()),
|
||||||
broadcast_tx,
|
session_entry_tx,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,11 +72,11 @@ impl SegmentLogSink {
|
|||||||
/// Used by restore / fork-at-restore code paths that materialise
|
/// Used by restore / fork-at-restore code paths that materialise
|
||||||
/// the existing log before the sink starts taking new commits.
|
/// the existing log before the sink starts taking new commits.
|
||||||
pub fn with_initial(entries: Vec<LogEntry>) -> Self {
|
pub fn with_initial(entries: Vec<LogEntry>) -> Self {
|
||||||
let (broadcast_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
let (session_entry_tx, _) = broadcast::channel(BROADCAST_CAPACITY);
|
||||||
Self {
|
Self {
|
||||||
inner: Arc::new(SinkInner {
|
inner: Arc::new(SinkInner {
|
||||||
mirror: StdMutex::new(entries),
|
mirror: StdMutex::new(entries),
|
||||||
broadcast_tx,
|
session_entry_tx,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ impl SegmentLogSink {
|
|||||||
// SendError means there are zero subscribers; harmless. The
|
// SendError means there are zero subscribers; harmless. The
|
||||||
// mirror lock is held across `send` so subscribers cannot
|
// mirror lock is held across `send` so subscribers cannot
|
||||||
// observe an inconsistent (snapshot, receiver) pair.
|
// observe an inconsistent (snapshot, receiver) pair.
|
||||||
let _ = self.inner.broadcast_tx.send(entry);
|
let _ = self.inner.session_entry_tx.send(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ impl SegmentLogSink {
|
|||||||
.expect("session log mirror mutex poisoned");
|
.expect("session log mirror mutex poisoned");
|
||||||
mirror.clear();
|
mirror.clear();
|
||||||
mirror.push(initial.clone());
|
mirror.push(initial.clone());
|
||||||
let _ = self.inner.broadcast_tx.send(initial);
|
let _ = self.inner.session_entry_tx.send(initial);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomically swap the mirror to the supplied replacement-session prefix
|
/// Atomically swap the mirror to the supplied replacement-session prefix
|
||||||
@@ -161,7 +161,7 @@ impl SegmentLogSink {
|
|||||||
.expect("session log mirror mutex poisoned");
|
.expect("session log mirror mutex poisoned");
|
||||||
*mirror = entries;
|
*mirror = entries;
|
||||||
if let Some(initial) = first {
|
if let Some(initial) = first {
|
||||||
let _ = self.inner.broadcast_tx.send(initial);
|
let _ = self.inner.session_entry_tx.send(initial);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +199,7 @@ impl SegmentLogSink {
|
|||||||
.lock()
|
.lock()
|
||||||
.expect("session log mirror mutex poisoned");
|
.expect("session log mirror mutex poisoned");
|
||||||
let snapshot = mirror.clone();
|
let snapshot = mirror.clone();
|
||||||
let rx = self.inner.broadcast_tx.subscribe();
|
let rx = self.inner.session_entry_tx.subscribe();
|
||||||
(snapshot, rx)
|
(snapshot, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -414,10 +414,10 @@ impl SpawnedWorkerRegistry {
|
|||||||
|
|
||||||
pub(crate) fn attach_parent_protocol(
|
pub(crate) fn attach_parent_protocol(
|
||||||
&self,
|
&self,
|
||||||
event_tx: broadcast::Sender<Event>,
|
working_event_tx: broadcast::Sender<Event>,
|
||||||
parent_session_id: String,
|
parent_session_id: String,
|
||||||
) {
|
) {
|
||||||
*self.parent_protocol.lock().unwrap() = Some((event_tx, parent_session_id));
|
*self.parent_protocol.lock().unwrap() = Some((working_event_tx, parent_session_id));
|
||||||
for record in self.internal_records.lock().unwrap().clone() {
|
for record in self.internal_records.lock().unwrap().clone() {
|
||||||
self.start_protocol_forwarding(record);
|
self.start_protocol_forwarding(record);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use manifest::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use workdir::{
|
use workdir::{
|
||||||
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
|
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, WorkdirPath,
|
||||||
WorkdirSessionHandle,
|
WorkdirSessionHandle,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,9 +258,10 @@ pub struct SubWorkerSpawnTool {
|
|||||||
spawner_name: String,
|
spawner_name: String,
|
||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
/// Runtime-owned root used only for bounded Internal Worker tool artifacts such as Bash spill
|
/// Runtime-owned root used for Internal Worker controller state.
|
||||||
/// output. It is not an Internal Worker identity or catalog location.
|
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
/// Parent Worker-owned temporary root used for bounded Bash spill output.
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
/// Inherited runtime workspace root for Profile/project/Ticket/workflow/
|
||||||
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
/// memory context. SubWorkerSpawn `cwd` must not affect this value.
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
@@ -292,6 +293,7 @@ impl SubWorkerSpawnTool {
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -304,6 +306,7 @@ impl SubWorkerSpawnTool {
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
source_workdir_session,
|
source_workdir_session,
|
||||||
registry,
|
registry,
|
||||||
@@ -367,7 +370,22 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.reserve_internal_name(input.name.clone())
|
.reserve_internal_name(input.name.clone())
|
||||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||||
|
|
||||||
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
let mut workdir_rules = parse_workdir_scope(&input.scope)?;
|
||||||
|
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
|
||||||
|
tokio::fs::create_dir_all(&child_bash_output_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"create Internal Worker Bash output directory {}: {error}",
|
||||||
|
child_bash_output_dir.display()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
workdir_rules.push(WorkdirDelegationRule {
|
||||||
|
target: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy())
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
||||||
|
permission: WorkdirDelegationPermission::Read,
|
||||||
|
recursive: true,
|
||||||
|
});
|
||||||
let source_workdir_session =
|
let source_workdir_session =
|
||||||
require_active_workdir_session(self.source_workdir_session.as_ref())?;
|
require_active_workdir_session(self.source_workdir_session.as_ref())?;
|
||||||
let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
|
let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
|
||||||
@@ -464,14 +482,22 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
||||||
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
|
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
|
||||||
|
child
|
||||||
|
.add_scope_rules([ScopeRule {
|
||||||
|
target: child_bash_output_dir.clone(),
|
||||||
|
permission: manifest::Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
}])
|
||||||
|
.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"grant Internal Worker Bash output scope: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let child_scope = child.scope().clone();
|
let child_scope = child.scope().clone();
|
||||||
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
let child_registry = SpawnedWorkerRegistry::new_internal(input.name.clone(), child_scope);
|
||||||
register_worker_tools(
|
register_worker_tools(
|
||||||
&mut child,
|
&mut child,
|
||||||
self.runtime_base
|
child_bash_output_dir,
|
||||||
.join("internal-workers")
|
|
||||||
.join(&input.name)
|
|
||||||
.join("bash-output"),
|
|
||||||
self.runtime_base.clone(),
|
self.runtime_base.clone(),
|
||||||
child_registry.clone(),
|
child_registry.clone(),
|
||||||
None,
|
None,
|
||||||
@@ -883,6 +909,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -894,6 +921,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
parent_notifications,
|
parent_notifications,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
|
bash_output_dir,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
source_workdir_session,
|
source_workdir_session,
|
||||||
registry,
|
registry,
|
||||||
@@ -907,6 +935,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
workspace_context: crate::worker::WorkerWorkspaceContext,
|
workspace_context: crate::worker::WorkerWorkspaceContext,
|
||||||
parent_notifications: ParentNotificationTarget,
|
parent_notifications: ParentNotificationTarget,
|
||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
source_workdir_session: Option<WorkdirSessionHandle>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
@@ -938,6 +967,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
workspace_context.clone(),
|
workspace_context.clone(),
|
||||||
parent_notifications.clone(),
|
parent_notifications.clone(),
|
||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
source_workdir_session.clone(),
|
source_workdir_session.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1082,12 +1112,17 @@ extract_threshold = 4000
|
|||||||
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
async fn reviewer_profile_write_scope_exposes_command_tools_and_notifies_parent_controller() {
|
||||||
let runtime = TempDir::new().unwrap();
|
let runtime = TempDir::new().unwrap();
|
||||||
let workspace_root = runtime.path().join("project");
|
let workspace_root = runtime.path().join("project");
|
||||||
|
let bash_output_dir = runtime.path().join("bash-output");
|
||||||
let available_profiles = write_project_profile_registry(
|
let available_profiles = write_project_profile_registry(
|
||||||
&workspace_root,
|
&workspace_root,
|
||||||
Some("reviewer"),
|
Some("reviewer"),
|
||||||
&[("reviewer", "reviewer.toml", INTERNAL_REVIEWER_PROFILE)],
|
&[("reviewer", "reviewer.toml", INTERNAL_REVIEWER_PROFILE)],
|
||||||
);
|
);
|
||||||
let mut manifest = parent_manifest(&workspace_root, None);
|
let mut manifest = parent_manifest(&workspace_root, None);
|
||||||
|
manifest
|
||||||
|
.scope
|
||||||
|
.allow
|
||||||
|
.push(abs_rule(&bash_output_dir, Permission::Read));
|
||||||
manifest.delegation_scope = ScopeConfig {
|
manifest.delegation_scope = ScopeConfig {
|
||||||
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
allow: vec![abs_rule(&workspace_root, Permission::Write)],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
@@ -1118,6 +1153,7 @@ extract_threshold = 4000
|
|||||||
workspace_context,
|
workspace_context,
|
||||||
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
|
ParentNotificationTarget::Controller(parent_method_tx.downgrade()),
|
||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(source_workdir_session),
|
Some(source_workdir_session),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
@@ -1167,6 +1203,12 @@ extract_threshold = 4000
|
|||||||
.await
|
.await
|
||||||
.expect("spawn project reviewer as Internal Worker");
|
.expect("spawn project reviewer as Internal Worker");
|
||||||
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
assert!(output.summary.contains("internal worker `reviewer-child`"));
|
||||||
|
assert!(
|
||||||
|
bash_output_dir
|
||||||
|
.join("sub-workers")
|
||||||
|
.join("reviewer-child")
|
||||||
|
.is_dir()
|
||||||
|
);
|
||||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||||
let record = registry
|
let record = registry
|
||||||
.get_internal("reviewer-child")
|
.get_internal("reviewer-child")
|
||||||
|
|||||||
+32
-32
@@ -1149,7 +1149,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
/// etc.). Attached by the Controller alongside `alerter`. Unlike
|
/// etc.). Attached by the Controller alongside `alerter`. Unlike
|
||||||
/// notifications, events sent here are NOT replayed to clients that
|
/// notifications, events sent here are NOT replayed to clients that
|
||||||
/// connect after the fact — they are fire-and-forget broadcasts.
|
/// connect after the fact — they are fire-and-forget broadcasts.
|
||||||
event_tx: Option<broadcast::Sender<Event>>,
|
working_event_tx: Option<broadcast::Sender<Event>>,
|
||||||
/// Parent-owned projection/control boundary for observable Internal service Workers.
|
/// Parent-owned projection/control boundary for observable Internal service Workers.
|
||||||
/// Service Workers are never exposed through the model-facing SubWorker control surface.
|
/// Service Workers are never exposed through the model-facing SubWorker control surface.
|
||||||
internal_worker_registry: Option<Arc<crate::spawn::registry::SpawnedWorkerRegistry>>,
|
internal_worker_registry: Option<Arc<crate::spawn::registry::SpawnedWorkerRegistry>>,
|
||||||
@@ -1304,7 +1304,7 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
|
|||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
feature_instructions: self.feature_instructions.clone(),
|
feature_instructions: self.feature_instructions.clone(),
|
||||||
alerter: self.alerter.clone(),
|
alerter: self.alerter.clone(),
|
||||||
event_tx: self.event_tx.clone(),
|
working_event_tx: self.working_event_tx.clone(),
|
||||||
internal_worker_registry: self.internal_worker_registry.clone(),
|
internal_worker_registry: self.internal_worker_registry.clone(),
|
||||||
in_flight: self.in_flight.clone(),
|
in_flight: self.in_flight.clone(),
|
||||||
ai_activity_counter: self.ai_activity_counter.clone(),
|
ai_activity_counter: self.ai_activity_counter.clone(),
|
||||||
@@ -1497,7 +1497,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
feature_instructions: Vec::new(),
|
feature_instructions: Vec::new(),
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
working_event_tx: None,
|
||||||
internal_worker_registry: None,
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
@@ -2190,13 +2190,13 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
/// The Controller wires this alongside [`attach_alerter`] so that
|
/// The Controller wires this alongside [`attach_alerter`] so that
|
||||||
/// Worker-internal operations (currently: compaction) can surface
|
/// Worker-internal operations (currently: compaction) can surface
|
||||||
/// progress to connected clients.
|
/// progress to connected clients.
|
||||||
pub fn attach_event_tx(&mut self, event_tx: broadcast::Sender<Event>) {
|
pub fn attach_working_event_tx(&mut self, working_event_tx: broadcast::Sender<Event>) {
|
||||||
let session_id = self.session_id().to_string();
|
let session_id = self.session_id().to_string();
|
||||||
let registry = self.internal_worker_registry.get_or_insert_with(
|
let registry = self.internal_worker_registry.get_or_insert_with(
|
||||||
crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services,
|
crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services,
|
||||||
);
|
);
|
||||||
registry.attach_parent_protocol(event_tx.clone(), session_id);
|
registry.attach_parent_protocol(working_event_tx.clone(), session_id);
|
||||||
self.event_tx = Some(event_tx);
|
self.working_event_tx = Some(working_event_tx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn attach_internal_worker_registry(
|
pub(crate) fn attach_internal_worker_registry(
|
||||||
@@ -2240,10 +2240,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast a typed `Event` to connected clients. No-op when no
|
/// Broadcast a typed `Event` to connected clients. No-op when no
|
||||||
/// `event_tx` is attached (tests / direct `Worker::new` usage) or when
|
/// `working_event_tx` is attached (tests / direct `Worker::new` usage) or when
|
||||||
/// no clients are currently subscribed.
|
/// no clients are currently subscribed.
|
||||||
fn send_event(&self, event: Event) {
|
fn send_event(&self, event: Event) {
|
||||||
if let Some(tx) = self.event_tx.as_ref() {
|
if let Some(tx) = self.working_event_tx.as_ref() {
|
||||||
let _ = tx.send(event);
|
let _ = tx.send(event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4407,7 +4407,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.with_memory_settings(&memory_cfg)
|
.with_memory_settings(&memory_cfg)
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
self.event_tx.as_ref(),
|
self.working_event_tx.as_ref(),
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"extract_threshold_disabled",
|
"extract_threshold_disabled",
|
||||||
None,
|
None,
|
||||||
@@ -4438,7 +4438,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.with_memory_settings(&memory_cfg)
|
.with_memory_settings(&memory_cfg)
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
self.event_tx.as_ref(),
|
self.working_event_tx.as_ref(),
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"extract_already_in_flight",
|
"extract_already_in_flight",
|
||||||
None,
|
None,
|
||||||
@@ -4503,7 +4503,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
Some(model_audit_from_manifest(model)),
|
Some(model_audit_from_manifest(model)),
|
||||||
)
|
)
|
||||||
.with_memory_settings(memory_cfg);
|
.with_memory_settings(memory_cfg);
|
||||||
let event_tx = self.event_tx.as_ref();
|
let working_event_tx = self.working_event_tx.as_ref();
|
||||||
|
|
||||||
let pointer_snapshot = self
|
let pointer_snapshot = self
|
||||||
.extract_pointer
|
.extract_pointer
|
||||||
@@ -4519,7 +4519,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
if tokens_since < threshold {
|
if tokens_since < threshold {
|
||||||
audit.emit(
|
audit.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
format!(
|
format!(
|
||||||
"token_threshold_not_reached tokens_since={tokens_since} threshold={threshold}"
|
"token_threshold_not_reached tokens_since={tokens_since} threshold={threshold}"
|
||||||
@@ -4536,7 +4536,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"no_new_history_items",
|
"no_new_history_items",
|
||||||
None,
|
None,
|
||||||
@@ -4564,7 +4564,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"empty_segment_log",
|
"empty_segment_log",
|
||||||
None,
|
None,
|
||||||
@@ -4583,7 +4583,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"no_new_segment_entries",
|
"no_new_segment_entries",
|
||||||
None,
|
None,
|
||||||
@@ -4613,7 +4613,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Started,
|
memory::audit::WorkerLifecycleStatus::Started,
|
||||||
format!(
|
format!(
|
||||||
"token_threshold_reached tokens_since={tokens_since} threshold={threshold}"
|
"token_threshold_reached tokens_since={tokens_since} threshold={threshold}"
|
||||||
@@ -4637,7 +4637,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Failed,
|
memory::audit::WorkerLifecycleStatus::Failed,
|
||||||
format!("client_build_failed: {err}"),
|
format!("client_build_failed: {err}"),
|
||||||
None,
|
None,
|
||||||
@@ -4659,7 +4659,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Failed,
|
memory::audit::WorkerLifecycleStatus::Failed,
|
||||||
format!("prompt_render_failed: {err}"),
|
format!("prompt_render_failed: {err}"),
|
||||||
None,
|
None,
|
||||||
@@ -4737,7 +4737,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Cancelled,
|
memory::audit::WorkerLifecycleStatus::Cancelled,
|
||||||
"worker_cancelled: internal Worker run rolled back before AI output",
|
"worker_cancelled: internal Worker run rolled back before AI output",
|
||||||
usage,
|
usage,
|
||||||
@@ -4760,7 +4760,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
lifecycle_status_for_worker_error(&err.source),
|
lifecycle_status_for_worker_error(&err.source),
|
||||||
format!("worker_failed: {}", err.source),
|
format!("worker_failed: {}", err.source),
|
||||||
usage,
|
usage,
|
||||||
@@ -4812,7 +4812,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
audit
|
audit
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
event_tx,
|
working_event_tx,
|
||||||
memory::audit::WorkerLifecycleStatus::Completed,
|
memory::audit::WorkerLifecycleStatus::Completed,
|
||||||
reason,
|
reason,
|
||||||
usage,
|
usage,
|
||||||
@@ -4847,7 +4847,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.with_memory_settings(&memory_cfg)
|
.with_memory_settings(&memory_cfg)
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
self.event_tx.as_ref(),
|
self.working_event_tx.as_ref(),
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"consolidation_threshold_disabled",
|
"consolidation_threshold_disabled",
|
||||||
None,
|
None,
|
||||||
@@ -4889,7 +4889,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.with_memory_settings(&memory_cfg)
|
.with_memory_settings(&memory_cfg)
|
||||||
.emit(
|
.emit(
|
||||||
self.workspace_client(),
|
self.workspace_client(),
|
||||||
self.event_tx.as_ref(),
|
self.working_event_tx.as_ref(),
|
||||||
memory::audit::WorkerLifecycleStatus::Skipped,
|
memory::audit::WorkerLifecycleStatus::Skipped,
|
||||||
"consolidation_backend_operation_failed",
|
"consolidation_backend_operation_failed",
|
||||||
None,
|
None,
|
||||||
@@ -4942,18 +4942,18 @@ fn model_audit_from_manifest(model: &manifest::ModelManifest) -> memory::audit::
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn emit_memory_worker_event(
|
fn emit_memory_worker_event(
|
||||||
event_tx: Option<&broadcast::Sender<Event>>,
|
working_event_tx: Option<&broadcast::Sender<Event>>,
|
||||||
run_id: uuid::Uuid,
|
run_id: uuid::Uuid,
|
||||||
worker: memory::audit::AuditWorker,
|
worker: memory::audit::AuditWorker,
|
||||||
status: memory::audit::WorkerLifecycleStatus,
|
status: memory::audit::WorkerLifecycleStatus,
|
||||||
trigger: memory::audit::AuditTrigger,
|
trigger: memory::audit::AuditTrigger,
|
||||||
reason: &str,
|
reason: &str,
|
||||||
) {
|
) {
|
||||||
let Some(event_tx) = event_tx else {
|
let Some(working_event_tx) = working_event_tx else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let message = format!("memory {} {}: {reason}", worker.label(), status.label());
|
let message = format!("memory {} {}: {reason}", worker.label(), status.label());
|
||||||
let _ = event_tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
let _ = working_event_tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
||||||
worker: worker.label().to_string(),
|
worker: worker.label().to_string(),
|
||||||
status: status.label().to_string(),
|
status: status.label().to_string(),
|
||||||
run_id: run_id.to_string(),
|
run_id: run_id.to_string(),
|
||||||
@@ -5003,7 +5003,7 @@ impl WorkerAuditBase {
|
|||||||
async fn emit(
|
async fn emit(
|
||||||
&self,
|
&self,
|
||||||
workspace_client: &dyn WorkspaceClient,
|
workspace_client: &dyn WorkspaceClient,
|
||||||
event_tx: Option<&broadcast::Sender<Event>>,
|
working_event_tx: Option<&broadcast::Sender<Event>>,
|
||||||
status: memory::audit::WorkerLifecycleStatus,
|
status: memory::audit::WorkerLifecycleStatus,
|
||||||
reason: impl Into<String>,
|
reason: impl Into<String>,
|
||||||
usage: Option<memory::audit::UsageAudit>,
|
usage: Option<memory::audit::UsageAudit>,
|
||||||
@@ -5034,7 +5034,7 @@ impl WorkerAuditBase {
|
|||||||
.await;
|
.await;
|
||||||
if should_emit_memory_worker_event(self.worker, status, &reason) {
|
if should_emit_memory_worker_event(self.worker, status, &reason) {
|
||||||
emit_memory_worker_event(
|
emit_memory_worker_event(
|
||||||
event_tx,
|
working_event_tx,
|
||||||
self.run_id,
|
self.run_id,
|
||||||
self.worker,
|
self.worker,
|
||||||
status,
|
status,
|
||||||
@@ -5218,7 +5218,7 @@ where
|
|||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
working_event_tx: None,
|
||||||
internal_worker_registry: None,
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
@@ -5302,7 +5302,7 @@ where
|
|||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
working_event_tx: None,
|
||||||
internal_worker_registry: None,
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
@@ -5421,7 +5421,7 @@ where
|
|||||||
system_prompt_template: common.system_prompt_template,
|
system_prompt_template: common.system_prompt_template,
|
||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
working_event_tx: None,
|
||||||
internal_worker_registry: None,
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
@@ -5797,7 +5797,7 @@ where
|
|||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
feature_instructions: common.feature_instructions,
|
feature_instructions: common.feature_instructions,
|
||||||
alerter: None,
|
alerter: None,
|
||||||
event_tx: None,
|
working_event_tx: None,
|
||||||
internal_worker_registry: None,
|
internal_worker_registry: None,
|
||||||
in_flight: None,
|
in_flight: None,
|
||||||
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
ai_activity_counter: Arc::new(AtomicUsize::new(0)),
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
|
|||||||
let mut worker = make_worker(client).await;
|
let mut worker = make_worker(client).await;
|
||||||
|
|
||||||
let (tx, _rx_keep) = broadcast::channel::<Event>(64);
|
let (tx, _rx_keep) = broadcast::channel::<Event>(64);
|
||||||
worker.attach_event_tx(tx);
|
worker.attach_working_event_tx(tx);
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
worker.run_text("first").await.unwrap();
|
||||||
let session_id = worker.session_id();
|
let session_id = worker.session_id();
|
||||||
@@ -429,7 +429,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
let mut worker = make_worker(client).await;
|
let mut worker = make_worker(client).await;
|
||||||
|
|
||||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||||
worker.attach_event_tx(tx);
|
worker.attach_working_event_tx(tx);
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
worker.run_text("first").await.unwrap();
|
||||||
// Drain run events so only compact events remain in `rx`.
|
// Drain run events so only compact events remain in `rx`.
|
||||||
@@ -539,7 +539,7 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
let mut worker = make_worker_with_manifest(MID_TURN_MANIFEST_TOML, client).await;
|
let mut worker = make_worker_with_manifest(MID_TURN_MANIFEST_TOML, client).await;
|
||||||
|
|
||||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||||
worker.attach_event_tx(tx);
|
worker.attach_working_event_tx(tx);
|
||||||
|
|
||||||
// First run populates usage_history above the request threshold.
|
// First run populates usage_history above the request threshold.
|
||||||
worker.run_text("first").await.unwrap();
|
worker.run_text("first").await.unwrap();
|
||||||
@@ -718,7 +718,7 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
|||||||
let mut worker = make_worker(client).await;
|
let mut worker = make_worker(client).await;
|
||||||
|
|
||||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||||
worker.attach_event_tx(tx);
|
worker.attach_working_event_tx(tx);
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
worker.run_text("first").await.unwrap();
|
||||||
let _ = drain(&mut rx);
|
let _ = drain(&mut rx);
|
||||||
@@ -861,7 +861,8 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
]);
|
]);
|
||||||
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
|
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path())
|
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||||
|
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|||||||
@@ -276,12 +276,38 @@ async fn spawn_controller(worker: Worker<MockClient, TestStore>) -> WorkerHandle
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let runtime_base = tmp.path().to_owned();
|
let runtime_base = tmp.path().to_owned();
|
||||||
std::mem::forget(tmp);
|
std::mem::forget(tmp);
|
||||||
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base)
|
let bash_output_dir = runtime_base.join("bash-output");
|
||||||
|
let (handle, _shutdown_rx) = WorkerController::spawn(worker, &runtime_base, &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
handle
|
handle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn controller_grants_read_scope_for_exact_bash_output_directory() {
|
||||||
|
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||||
|
let shared_scope = worker.scope().clone();
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let worker_tmp = tempfile::tempdir().unwrap();
|
||||||
|
let bash_output_dir = worker_tmp.path().join("worker-1").join("bash-output");
|
||||||
|
|
||||||
|
let (handle, shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(bash_output_dir.is_dir());
|
||||||
|
assert!(shared_scope.snapshot().allow_rules().iter().any(|rule| {
|
||||||
|
rule.target == bash_output_dir
|
||||||
|
&& rule.permission == manifest::Permission::Read
|
||||||
|
&& rule.recursive
|
||||||
|
}));
|
||||||
|
assert!(!handle.runtime_dir.path().join("bash-output").exists());
|
||||||
|
|
||||||
|
handle.send(Method::Shutdown).await.unwrap();
|
||||||
|
shutdown_rx.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn shutdown_closes_bound_workdir_session() {
|
async fn shutdown_closes_bound_workdir_session() {
|
||||||
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await;
|
||||||
@@ -297,6 +323,7 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -304,7 +331,9 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
worker.bind_workdir_session(Some(Arc::clone(&session)));
|
||||||
|
|
||||||
let runtime_base = tempfile::tempdir().unwrap();
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
let (handle, shutdown_rx) = WorkerController::spawn(worker, runtime_base.path())
|
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||||
|
let (handle, shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
handle.send(Method::Shutdown).await.unwrap();
|
handle.send(Method::Shutdown).await.unwrap();
|
||||||
@@ -338,6 +367,7 @@ async fn controller_projects_workdir_command_events_and_snapshot_state() {
|
|||||||
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-command-1".into()),
|
tool_call_id: Some("tool-command-1".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -445,6 +475,7 @@ async fn controller_refreshes_command_snapshot_after_high_output_provider_lag()
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
timeout_secs: 10,
|
timeout_secs: 10,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-high-output".into()),
|
tool_call_id: Some("tool-high-output".into()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -508,8 +539,9 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
let invalid_runtime_base = runtime_base.path().join("not-a-directory");
|
||||||
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
std::fs::write(&invalid_runtime_base, "file").unwrap();
|
||||||
|
|
||||||
|
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||||
assert!(
|
assert!(
|
||||||
WorkerController::spawn(worker, &invalid_runtime_base)
|
WorkerController::spawn(worker, &invalid_runtime_base, &bash_output_dir)
|
||||||
.await
|
.await
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
@@ -519,6 +551,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
command: "printf unreachable".to_owned(),
|
command: "printf unreachable".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
.await,
|
.await,
|
||||||
@@ -863,7 +896,8 @@ permission = "write"
|
|||||||
let client = MockClient::new(simple_text_events());
|
let client = MockClient::new(simple_text_events());
|
||||||
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
let worker = make_worker_with_pwd_and_manifest(client, manifest).await.0;
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let result = WorkerController::spawn(worker, tmp.path()).await;
|
let bash_output_dir = tmp.path().join("bash-output");
|
||||||
|
let result = WorkerController::spawn(worker, tmp.path(), &bash_output_dir).await;
|
||||||
assert!(
|
assert!(
|
||||||
result.is_ok(),
|
result.is_ok(),
|
||||||
"feature exposure must not imply delegation authority"
|
"feature exposure must not imply delegation authority"
|
||||||
@@ -1515,7 +1549,7 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
|||||||
// after that.
|
// after that.
|
||||||
wait_for_status(&handle, WorkerStatus::Idle).await;
|
wait_for_status(&handle, WorkerStatus::Idle).await;
|
||||||
// The live echo arrives via the sink's `Event::SystemItem` lane,
|
// The live echo arrives via the sink's `Event::SystemItem` lane,
|
||||||
// not on the `event_tx` broadcast that `handle.subscribe()` taps.
|
// not on the `working_event_tx` broadcast that `handle.subscribe()` taps.
|
||||||
// Verify the notification landed on the sink mirror instead.
|
// Verify the notification landed on the sink mirror instead.
|
||||||
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
let (entries, _) = handle.sink.subscribe_with_snapshot();
|
||||||
let saw_notify_in_mirror = entries.iter().any(|e| {
|
let saw_notify_in_mirror = entries.iter().any(|e| {
|
||||||
@@ -1899,7 +1933,7 @@ async fn socket_worker_event_turn_ended_while_idle_auto_starts_turn() {
|
|||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||||
// The SystemItem and TurnEnd events arrive through independent
|
// The SystemItem and TurnEnd events arrive through independent
|
||||||
// broadcast lanes (sink fan-out vs `event_tx`), so their relative
|
// broadcast lanes (sink fan-out vs `working_event_tx`), so their relative
|
||||||
// order on the wire is non-deterministic. Keep reading until both
|
// order on the wire is non-deterministic. Keep reading until both
|
||||||
// are observed (or the deadline trips), rather than breaking on
|
// are observed (or the deadline trips), rather than breaking on
|
||||||
// the first TurnEnd.
|
// the first TurnEnd.
|
||||||
|
|||||||
@@ -15430,6 +15430,7 @@ mod tests {
|
|||||||
command: "printf ready; sleep 30".to_string(),
|
command: "printf ready; sleep 30".to_string(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-call-command-session".to_string()),
|
tool_call_id: Some("tool-call-command-session".to_string()),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ fn standalone_target() -> Result<Box<dyn Target>, ParseError> {
|
|||||||
})?
|
})?
|
||||||
.join("client")
|
.join("client")
|
||||||
.join("standalone")
|
.join("standalone")
|
||||||
.join("sessions");
|
.join("workers");
|
||||||
Ok(Box::new(StandaloneTarget::new(state_dir)))
|
Ok(Box::new(StandaloneTarget::new(state_dir)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -798,7 +798,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 session restore"
|
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -834,7 +834,7 @@ 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 session store"
|
"Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone Worker store"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -951,7 +951,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 sessions"
|
"yoi workers requires a Backend connection target; use yoi --local --resume for Standalone Workers"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1750,8 +1750,8 @@ 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 session store
|
--resume With --local, restore from the Standalone Worker store
|
||||||
--all With Standalone restore, include sessions from every cwd identity
|
--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
|
||||||
|
|
||||||
@@ -1761,7 +1761,7 @@ Target selection:
|
|||||||
|
|
||||||
Connection-aware commands:
|
Connection-aware commands:
|
||||||
yoi Standalone: new Console. Backend: Worker picker.
|
yoi Standalone: new Console. Backend: Worker picker.
|
||||||
yoi resume Standalone session picker or stopped Backend Worker picker.
|
yoi resume Standalone Worker picker or stopped Backend Worker picker.
|
||||||
yoi workers Backend Workspace Worker picker.
|
yoi workers Backend Workspace Worker picker.
|
||||||
yoi panel Backend Workspace dashboard.
|
yoi panel Backend Workspace dashboard.
|
||||||
|
|
||||||
@@ -1800,7 +1800,7 @@ Usage:
|
|||||||
yoi --backend <URL> [--workspace-id <ID>] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]
|
yoi --backend <URL> [--workspace-id <ID>] workers [-r|--stopped] [--workspace <PATH>] [--runtime-id <ID>]
|
||||||
|
|
||||||
Authority:
|
Authority:
|
||||||
Lists Workers from the selected Backend Workspace. Standalone sessions 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:
|
||||||
@@ -1822,13 +1822,13 @@ Usage:
|
|||||||
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
|
yoi [TARGET] resume [--workspace <PATH>|--all] [--runtime-id <ID>]
|
||||||
|
|
||||||
Target options:
|
Target options:
|
||||||
--local Restore from the client-owned Standalone session store
|
--local Restore from the client-owned Standalone Worker store
|
||||||
--backend <URL> Restore a stopped Backend Workspace Worker
|
--backend <URL> Restore a stopped Backend Workspace Worker
|
||||||
--workspace-id <ID> Scope Backend routes to a Workspace id
|
--workspace-id <ID> Scope Backend routes to a Workspace id
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
--workspace <PATH> Scope Standalone sessions to this cwd identity (defaults to cwd)
|
--workspace <PATH> Scope Standalone Workers to this cwd identity (defaults to cwd)
|
||||||
--all Include Standalone sessions from every cwd identity
|
--all Include Standalone Workers from every cwd identity
|
||||||
--runtime-id <ID> Restrict the Backend stopped-Worker picker to a Runtime id
|
--runtime-id <ID> Restrict the Backend stopped-Worker picker to a Runtime id
|
||||||
-h, --help Print help
|
-h, --help Print help
|
||||||
"#;
|
"#;
|
||||||
@@ -2222,8 +2222,8 @@ backend = "shared"
|
|||||||
mode,
|
mode,
|
||||||
LaunchMode::StandaloneResume { include_all: false }
|
LaunchMode::StandaloneResume { include_all: false }
|
||||||
));
|
));
|
||||||
let intent = target.standalone_session_list(false).unwrap();
|
let intent = target.standalone_worker_list(false).unwrap();
|
||||||
assert!(intent.state_dir.ends_with("client/standalone/sessions"));
|
assert!(intent.state_dir.ends_with("client/standalone/workers"));
|
||||||
assert!(!intent.include_all);
|
assert!(!intent.include_all);
|
||||||
|
|
||||||
let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap();
|
let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap();
|
||||||
@@ -2264,7 +2264,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 session restore"
|
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
|
||||||
);
|
);
|
||||||
|
|
||||||
let socket_args = [
|
let socket_args = [
|
||||||
@@ -2903,12 +2903,12 @@ backend = "shared"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_resume_help_uses_standalone_session_store_terminology() {
|
fn parse_resume_help_uses_standalone_worker_store_terminology() {
|
||||||
match parse_args_from(["resume", "--help"]).unwrap() {
|
match parse_args_from(["resume", "--help"]).unwrap() {
|
||||||
Mode::ResumeHelp => {}
|
Mode::ResumeHelp => {}
|
||||||
_ => panic!("expected ResumeHelp mode"),
|
_ => panic!("expected ResumeHelp mode"),
|
||||||
}
|
}
|
||||||
assert!(RESUME_HELP.contains("Standalone session store"));
|
assert!(RESUME_HELP.contains("Standalone Worker store"));
|
||||||
assert!(RESUME_HELP.contains("Backend stopped-Worker picker"));
|
assert!(RESUME_HELP.contains("Backend stopped-Worker picker"));
|
||||||
assert!(!RESUME_HELP.contains("local Worker records"));
|
assert!(!RESUME_HELP.contains("local Worker records"));
|
||||||
assert!(!RESUME_HELP.contains("local workspace"));
|
assert!(!RESUME_HELP.contains("local workspace"));
|
||||||
|
|||||||
@@ -147,9 +147,21 @@ Deno.test("workspace Worker list lives on the dedicated Workers page", async ()
|
|||||||
assert(
|
assert(
|
||||||
!sidebar.includes("CompanionNavSection") &&
|
!sidebar.includes("CompanionNavSection") &&
|
||||||
sidebar.includes("TicketsNavSection") &&
|
sidebar.includes("TicketsNavSection") &&
|
||||||
sidebar.includes("MemoryNavSection") &&
|
sidebar.includes("MergeRequestsNavSection") &&
|
||||||
sidebar.includes("WorkersNavSection"),
|
sidebar.lastIndexOf("MergeRequestsNavSection") <
|
||||||
"standalone Companion/Console navigation should not remain canonical and Tickets should be primary workspace navigation",
|
sidebar.lastIndexOf("MemoryNavSection") &&
|
||||||
|
sidebar.includes("WorkersNavSection") &&
|
||||||
|
sidebarCss.includes("gap: var(--space-1)") &&
|
||||||
|
sidebarCss.includes(".sidebar-nav-section--category > .sidebar-link") &&
|
||||||
|
sidebarCss.includes("padding-block: var(--space-1)") &&
|
||||||
|
sidebarCss.includes("margin-left: var(--space-3)") &&
|
||||||
|
sidebarCss.includes("--sidebar-item-hover: oklch(24% 0 0)") &&
|
||||||
|
sidebarCss.includes("--sidebar-item-active: oklch(32% 0 0)") &&
|
||||||
|
sidebarCss.includes("background: var(--sidebar-item-hover)") &&
|
||||||
|
sidebarCss.includes("background: var(--sidebar-item-active)") &&
|
||||||
|
!sidebarCss.includes("background: var(--interactive-selected)") &&
|
||||||
|
!sidebarCss.includes("margin-inline: calc(-1"),
|
||||||
|
"workspace navigation should place Merge Requests before an indented compact Memory category",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,6 +172,9 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
const objectivesNav = await Deno.readTextFile(
|
const objectivesNav = await Deno.readTextFile(
|
||||||
new URL("../sidebar/ObjectivesNavSection.svelte", import.meta.url),
|
new URL("../sidebar/ObjectivesNavSection.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
|
const mergeRequestsNav = await Deno.readTextFile(
|
||||||
|
new URL("../sidebar/MergeRequestsNavSection.svelte", import.meta.url),
|
||||||
|
);
|
||||||
const ticketsLoad = await Deno.readTextFile(
|
const ticketsLoad = await Deno.readTextFile(
|
||||||
new URL(
|
new URL(
|
||||||
"./../../../routes/w/[workspaceId]/tickets/+page.ts",
|
"./../../../routes/w/[workspaceId]/tickets/+page.ts",
|
||||||
@@ -205,15 +220,22 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
|||||||
|
|
||||||
assert(
|
assert(
|
||||||
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
ticketsNav.includes("workspaceRoute(workspaceId, '/tickets')") &&
|
||||||
ticketsNav.includes('class="primary-nav-link"') &&
|
ticketsNav.includes("sidebar-nav-section--resource") &&
|
||||||
|
ticketsNav.includes('class="sidebar-link"') &&
|
||||||
ticketsNav.includes(">Tickets</a>") &&
|
ticketsNav.includes(">Tickets</a>") &&
|
||||||
!ticketsNav.includes("Open Tickets") &&
|
!ticketsNav.includes("Open Tickets") &&
|
||||||
!ticketsNav.includes("workspace tickets") &&
|
!ticketsNav.includes("workspace tickets") &&
|
||||||
objectivesNav.includes('class="primary-nav-link"') &&
|
objectivesNav.includes("sidebar-nav-section--resource") &&
|
||||||
|
objectivesNav.includes('class="sidebar-link"') &&
|
||||||
objectivesNav.includes(">Objectives</a>") &&
|
objectivesNav.includes(">Objectives</a>") &&
|
||||||
!objectivesNav.includes("Open Objectives") &&
|
!objectivesNav.includes("Open Objectives") &&
|
||||||
!objectivesNav.includes("workspace objectives"),
|
!objectivesNav.includes("workspace objectives") &&
|
||||||
"Tickets and Objectives should each be a single sidebar link",
|
mergeRequestsNav.includes("sidebar-nav-section--resource") &&
|
||||||
|
mergeRequestsNav.includes('class="sidebar-link"') &&
|
||||||
|
mergeRequestsNav.includes(">Merge Requests</a>") &&
|
||||||
|
!mergeRequestsNav.includes("All Merge Requests") &&
|
||||||
|
!mergeRequestsNav.includes("review and integration resources"),
|
||||||
|
"Tickets, Objectives, and Merge Requests should each be a single primary sidebar link",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
|
ticketsLoad.includes("Object.entries(LANE_STATES)") &&
|
||||||
@@ -295,11 +317,19 @@ Deno.test("workspace Memory surfaces use read-only scoped memory APIs", async ()
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
memoryNav.includes("workspaceRoute(workspaceId, '/memory')") &&
|
memoryNav.includes('workspaceRoute(workspaceId, "/memory")') &&
|
||||||
memoryNav.includes("durable workspace memory") &&
|
memoryNav.includes(
|
||||||
memoryNav.includes("workspaceRoute(workspaceId, '/memory/staging')") &&
|
'<h2 class="sidebar-nav-section__header">Memory</h2>',
|
||||||
memoryNav.includes("pending extraction candidates"),
|
) &&
|
||||||
"Memory sidebar section should link to Document and Staging surfaces",
|
memoryNav.includes("Document</a>") &&
|
||||||
|
memoryNav.includes("sidebar-nav-section--category") &&
|
||||||
|
memoryNav.includes('class="sidebar-link"') &&
|
||||||
|
memoryNav.includes('workspaceRoute(workspaceId, "/memory/staging")') &&
|
||||||
|
memoryNav.includes("Staging</a>") &&
|
||||||
|
!memoryNav.includes("item-meta") &&
|
||||||
|
!memoryNav.includes("durable workspace memory") &&
|
||||||
|
!memoryNav.includes("pending extraction candidates"),
|
||||||
|
"Memory sidebar section should show Document and Staging as compact single-line links",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
memoryDocumentLoad.includes("workspaceApiPath(params.workspaceId") &&
|
||||||
@@ -430,7 +460,7 @@ Deno.test("Worker Console expands uncapped tool body from the hover detail actio
|
|||||||
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
|
"return detailOpen ? (line.expandedBody ?? line.body) : line.body",
|
||||||
) &&
|
) &&
|
||||||
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
|
consoleLine.includes("line.toolCallLabel ?? line.toolCall?.name") &&
|
||||||
consoleLine.includes('class={`tool-status') &&
|
consoleLine.includes("class={`tool-status") &&
|
||||||
consoleLine.includes('class="tool-detail-button"') &&
|
consoleLine.includes('class="tool-detail-button"') &&
|
||||||
consoleLine.includes("aria-expanded={detailOpen}") &&
|
consoleLine.includes("aria-expanded={detailOpen}") &&
|
||||||
consoleLine.includes("detailOpen = !detailOpen") &&
|
consoleLine.includes("detailOpen = !detailOpen") &&
|
||||||
@@ -513,7 +543,7 @@ Deno.test("Worker Console removes redundant chrome and uses shared alerts", asyn
|
|||||||
"padding: var(--space-3) var(--space-6) var(--space-4)",
|
"padding: var(--space-3) var(--space-6) var(--space-4)",
|
||||||
) &&
|
) &&
|
||||||
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
|
!page.includes("margin-inline: calc(-1 * var(--space-6))") &&
|
||||||
page.includes('import { pushWorkspaceAlert }') &&
|
page.includes("import { pushWorkspaceAlert }") &&
|
||||||
page.includes('title: "Worker control"') &&
|
page.includes('title: "Worker control"') &&
|
||||||
page.includes('title: "Rewind targets"') &&
|
page.includes('title: "Rewind targets"') &&
|
||||||
page.includes('pushWorkspaceAlert("error"') &&
|
page.includes('pushWorkspaceAlert("error"') &&
|
||||||
@@ -698,7 +728,9 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
|||||||
assert(
|
assert(
|
||||||
consolePage.includes("const token = advanceReloadToken();") &&
|
consolePage.includes("const token = advanceReloadToken();") &&
|
||||||
consolePage.includes("worker = targetWorker;") &&
|
consolePage.includes("worker = targetWorker;") &&
|
||||||
consolePage.includes("if (!targetWorker) void loadWorker(target, token);") &&
|
consolePage.includes(
|
||||||
|
"if (!targetWorker) void loadWorker(target, token);",
|
||||||
|
) &&
|
||||||
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
|
!consolePage.includes("void refreshConsole();\n });\n\n $effect"),
|
||||||
"target-change effect should install route data and guard fallback loading with the new target token",
|
"target-change effect should install route data and guard fallback loading with the new target token",
|
||||||
);
|
);
|
||||||
@@ -789,18 +821,21 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
||||||
rootLayout.includes("GlobalSidebar") &&
|
rootLayout.includes("GlobalSidebar") &&
|
||||||
rootLayout.includes("SidebarFrame") &&
|
rootLayout.includes("SidebarFrame") &&
|
||||||
rootLayout.includes("{@render sidebar()}") &&
|
rootLayout.includes("content={sidebar}") &&
|
||||||
!rootLayout.includes("WorkspaceSidebar") &&
|
!rootLayout.includes("WorkspaceSidebar") &&
|
||||||
rootLayout.includes("workspace-topbar") &&
|
rootLayout.includes('class="app-shell"') &&
|
||||||
rootLayout.includes("topbar-icon-button") &&
|
rootLayout.includes('class="app-shell__main"') &&
|
||||||
|
rootLayout.includes("app-shell__topbar") &&
|
||||||
|
rootLayout.includes("app-shell__icon-button") &&
|
||||||
rootLayout.includes('href="/account"') &&
|
rootLayout.includes('href="/account"') &&
|
||||||
rootLayout.includes("Open Account") &&
|
rootLayout.includes("Open Account") &&
|
||||||
!sidebar.includes("accountHref") &&
|
!sidebar.includes("accountHref") &&
|
||||||
!sidebar.includes("Open Account"),
|
!sidebar.includes("Open Account"),
|
||||||
"Root layout chrome should render a registered sidebar snippet or default global sidebar 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("Global") &&
|
globalSidebar.includes('aria-label="Global pages"') &&
|
||||||
|
!globalSidebar.includes('<p class="sidebar-section-label">') &&
|
||||||
globalSidebar.includes("/account") &&
|
globalSidebar.includes("/account") &&
|
||||||
globalSidebar.includes("/login/device") &&
|
globalSidebar.includes("/login/device") &&
|
||||||
!globalSidebar.includes("Tickets") &&
|
!globalSidebar.includes("Tickets") &&
|
||||||
@@ -810,12 +845,11 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
assert(
|
assert(
|
||||||
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
|
workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
|
||||||
workspaceLayout.includes("WorkspaceSidebar") &&
|
workspaceLayout.includes("WorkspaceSidebar") &&
|
||||||
workspaceLayout.includes(
|
workspaceLayout.includes("controller={parentSidebarController}") &&
|
||||||
"<SidebarOverride sidebar={workspaceSidebar} />",
|
workspaceLayout.includes("sidebar={workspaceSidebar}") &&
|
||||||
) &&
|
|
||||||
workspaceLayoutLoad.includes("params.workspaceId") &&
|
workspaceLayoutLoad.includes("params.workspaceId") &&
|
||||||
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
|
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
|
||||||
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
|
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
sidebarFrame.includes("let folded = $state(false)") &&
|
sidebarFrame.includes("let folded = $state(false)") &&
|
||||||
@@ -872,8 +906,8 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
|
|||||||
"Sidebar and Console should share one Workspace multiplexer and route Worker methods through a subscription lane",
|
"Sidebar and Console should share one Workspace multiplexer and route Worker methods through a subscription lane",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
multiplexer.includes('nextMultiplexerId') &&
|
multiplexer.includes("nextMultiplexerId") &&
|
||||||
!multiplexer.includes('crypto.randomUUID'),
|
!multiplexer.includes("crypto.randomUUID"),
|
||||||
"Workspace subscription correlation IDs should not require secure-context crypto APIs",
|
"Workspace subscription correlation IDs should not require secure-context crypto APIs",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
@@ -881,7 +915,9 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
|
|||||||
multiplexer.includes("this.#sendSubscribe(subscription)") &&
|
multiplexer.includes("this.#sendSubscribe(subscription)") &&
|
||||||
consolePage.includes("const targetWorker = data.worker") &&
|
consolePage.includes("const targetWorker = data.worker") &&
|
||||||
consolePage.includes("worker = targetWorker") &&
|
consolePage.includes("worker = targetWorker") &&
|
||||||
consolePage.includes("const consoleTarget = $derived({ workspaceId, runtimeId, workerId })"),
|
consolePage.includes(
|
||||||
|
"const consoleTarget = $derived({ workspaceId, runtimeId, workerId })",
|
||||||
|
),
|
||||||
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
|
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -945,7 +981,9 @@ Deno.test("Web Console switches main and direct SubWorker views from the Tasks r
|
|||||||
consolePage.includes("selectedConsoleProjection.lines") &&
|
consolePage.includes("selectedConsoleProjection.lines") &&
|
||||||
consolePage.includes("selectedConsoleProjection.tasks") &&
|
consolePage.includes("selectedConsoleProjection.tasks") &&
|
||||||
consolePage.includes("onSelectWorkerView") &&
|
consolePage.includes("onSelectWorkerView") &&
|
||||||
consolePage.includes("selectConsoleWorkerView(resolvedSessionId, false)") &&
|
consolePage.includes(
|
||||||
|
"selectConsoleWorkerView(resolvedSessionId, false)",
|
||||||
|
) &&
|
||||||
consolePage.includes("consoleWorkerViewSelectionIsResolved") &&
|
consolePage.includes("consoleWorkerViewSelectionIsResolved") &&
|
||||||
!consolePage.includes("internal-worker-pane") &&
|
!consolePage.includes("internal-worker-pane") &&
|
||||||
!consolePage.includes("flattenInternalWorkers"),
|
!consolePage.includes("flattenInternalWorkers"),
|
||||||
|
|||||||
@@ -1,33 +1,59 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from "$app/state";
|
||||||
import { buildWorkspaceBreadcrumbs } from './breadcrumb-model';
|
import WorkspaceSwitcher from "$lib/workspace/sidebar/WorkspaceSwitcher.svelte";
|
||||||
|
import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
|
||||||
|
import { buildWorkspaceBreadcrumbs } from "./breadcrumb-model";
|
||||||
|
|
||||||
let { workspaceId }: { workspaceId: string } = $props();
|
type Props = {
|
||||||
|
workspaceId: string;
|
||||||
|
workspace?: WorkspaceResponse | null;
|
||||||
|
workspaceError?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { workspaceId, workspace = null, workspaceError = null }: Props = $props();
|
||||||
|
|
||||||
const workerName = $derived.by(() => {
|
const workerName = $derived.by(() => {
|
||||||
const data = page.data as Record<string, unknown>;
|
const data = page.data as Record<string, unknown>;
|
||||||
const worker = data.worker as { display_name?: string | null; label?: string | null } | null | undefined;
|
const worker = data.worker as
|
||||||
|
| { display_name?: string | null; label?: string | null }
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
return worker?.display_name ?? worker?.label ?? null;
|
return worker?.display_name ?? worker?.label ?? null;
|
||||||
});
|
});
|
||||||
const breadcrumbs = $derived(buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }));
|
const breadcrumbs = $derived(
|
||||||
const workspaceRoot = $derived(`/w/${encodeURIComponent(workspaceId)}`);
|
buildWorkspaceBreadcrumbs(page.url.pathname, workspaceId, { workerName }),
|
||||||
|
);
|
||||||
|
const currentWorkspaceName = $derived(
|
||||||
|
workspaceError ? workspaceId : workspace?.display_name || workspaceId,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
|
<div class="workspace-header-location">
|
||||||
<a class="workspace-breadcrumb-root" href={workspaceRoot} aria-label="Workspace home">/</a>
|
<WorkspaceSwitcher
|
||||||
|
variant="header"
|
||||||
|
currentWorkspaceId={workspaceId}
|
||||||
|
{currentWorkspaceName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if breadcrumbs.length > 0}
|
||||||
|
<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>
|
||||||
|
<nav class="workspace-breadcrumbs" aria-label="Current workspace location">
|
||||||
{#each breadcrumbs as breadcrumb, index (`${index}:${breadcrumb.label}`)}
|
{#each breadcrumbs as breadcrumb, index (`${index}:${breadcrumb.label}`)}
|
||||||
{#if index > 0}<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>{/if}
|
{#if index > 0}<span class="workspace-breadcrumb-separator" aria-hidden="true">/</span>{/if}
|
||||||
{#if breadcrumb.href}
|
{#if breadcrumb.href}
|
||||||
<a href={breadcrumb.href}>{breadcrumb.label}</a>
|
<a href={breadcrumb.href}>{breadcrumb.label}</a>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="workspace-breadcrumb-label" aria-current={index === breadcrumbs.length - 1 ? 'page' : undefined}>
|
<span class="workspace-breadcrumb-label" aria-current={index === breadcrumbs.length - 1 ? "page" : undefined}>
|
||||||
{breadcrumb.label}
|
{breadcrumb.label}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.workspace-header-location,
|
||||||
.workspace-breadcrumbs {
|
.workspace-breadcrumbs {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -58,16 +84,12 @@
|
|||||||
text-underline-offset: 0.22rem;
|
text-underline-offset: 0.22rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-breadcrumb-root {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workspace-breadcrumb-separator {
|
.workspace-breadcrumb-separator {
|
||||||
color: color-mix(in srgb, currentColor 45%, transparent);
|
color: color-mix(in srgb, currentColor 45%, transparent);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-breadcrumbs span[aria-current='page'] {
|
.workspace-breadcrumbs span[aria-current="page"] {
|
||||||
color: var(--text-strong);
|
color: var(--text-strong);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import type { SidebarSnippet } from './context';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath: string;
|
currentPath: string;
|
||||||
|
content?: SidebarSnippet | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { currentPath }: Props = $props();
|
const { currentPath, content = null }: Props = $props();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ href: '/', label: 'Workspaces' },
|
{ href: '/', label: 'Workspaces' },
|
||||||
@@ -15,9 +17,11 @@
|
|||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="global-sidebar" aria-label="Global navigation">
|
{#if content}
|
||||||
|
{@render content()}
|
||||||
|
{:else}
|
||||||
|
<div class="global-sidebar" aria-label="Global navigation">
|
||||||
<div class="global-sidebar-section">
|
<div class="global-sidebar-section">
|
||||||
<p class="sidebar-section-label">Global</p>
|
|
||||||
<nav class="sidebar-list" aria-label="Global pages">
|
<nav class="sidebar-list" aria-label="Global pages">
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<a
|
<a
|
||||||
@@ -31,4 +35,5 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
import { workspaceRoute } from "$lib/workspace/api/http";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
currentPath?: string;
|
currentPath?: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { currentPath = '/', workspaceId }: Props = $props();
|
let { currentPath = "/", workspaceId }: Props = $props();
|
||||||
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory') : '/');
|
let documentHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory") : "/");
|
||||||
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/memory/staging') : '/');
|
let stagingHref = $derived(workspaceId ? workspaceRoute(workspaceId, "/memory/staging") : "/");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section">
|
<section class="sidebar-nav-section sidebar-nav-section--category">
|
||||||
<header class="section-header">
|
<h2 class="sidebar-nav-section__header">Memory</h2>
|
||||||
<span>Memory</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<a class="objective-link" class:active={currentPath === documentHref} href={documentHref}>
|
<a
|
||||||
<span class="item-title">Document</span>
|
class="sidebar-link"
|
||||||
<span class="item-meta">durable workspace memory</span>
|
class:active={currentPath === documentHref}
|
||||||
</a>
|
href={documentHref}
|
||||||
|
aria-current={currentPath === documentHref ? "page" : undefined}
|
||||||
|
>Document</a>
|
||||||
|
|
||||||
<a class="objective-link" class:active={currentPath.startsWith(stagingHref)} href={stagingHref}>
|
<a
|
||||||
<span class="item-title">Staging</span>
|
class="sidebar-link"
|
||||||
<span class="item-meta">pending extraction candidates</span>
|
class:active={currentPath.startsWith(stagingHref)}
|
||||||
</a>
|
href={stagingHref}
|
||||||
|
aria-current={currentPath.startsWith(stagingHref) ? "page" : undefined}
|
||||||
|
>Staging</a>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,10 +10,11 @@
|
|||||||
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
|
let href = $derived(workspaceId ? mergeRequestPagePath(workspaceId) : "/");
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section">
|
<section class="sidebar-nav-section sidebar-nav-section--resource">
|
||||||
<header class="section-header"><span>Merge Requests</span></header>
|
<a
|
||||||
<a class="objective-link" class:active={currentPath.startsWith(href)} {href}>
|
class="sidebar-link"
|
||||||
<span class="item-title">All Merge Requests</span>
|
class:active={currentPath.startsWith(href)}
|
||||||
<span class="item-meta">review and integration resources</span>
|
{href}
|
||||||
</a>
|
aria-current={currentPath.startsWith(href) ? "page" : undefined}
|
||||||
|
>Merge Requests</a>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
|
let objectivesHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/objectives') : '/objectives');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section">
|
<section class="sidebar-nav-section sidebar-nav-section--resource">
|
||||||
<a
|
<a
|
||||||
class="primary-nav-link"
|
class="sidebar-link"
|
||||||
class:active={currentPath.startsWith(objectivesHref)}
|
class:active={currentPath.startsWith(objectivesHref)}
|
||||||
href={objectivesHref}
|
href={objectivesHref}
|
||||||
aria-current={currentPath.startsWith(objectivesHref) ? 'page' : undefined}
|
aria-current={currentPath.startsWith(objectivesHref) ? 'page' : undefined}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
|
||||||
|
import type { SidebarSnippet } from './context';
|
||||||
|
|
||||||
|
let {
|
||||||
|
workspaceId,
|
||||||
|
currentPath,
|
||||||
|
content = null,
|
||||||
|
}: {
|
||||||
|
workspaceId: string;
|
||||||
|
currentPath: string;
|
||||||
|
content?: SidebarSnippet | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
|
||||||
|
|
||||||
|
function sectionHref(path: string): string {
|
||||||
|
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(href: string): boolean {
|
||||||
|
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="settings-sidebar">
|
||||||
|
<div class="section-heading">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if content}
|
||||||
|
{@render content()}
|
||||||
|
{:else}
|
||||||
|
<nav class="sidebar-sections" aria-label="Settings sections">
|
||||||
|
<div class="sidebar-nav-section">
|
||||||
|
<div class="sidebar-list">
|
||||||
|
<a
|
||||||
|
class:active={currentPath === settingsHref}
|
||||||
|
class="sidebar-link"
|
||||||
|
href={settingsHref}
|
||||||
|
aria-current={currentPath === settingsHref ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span class="sidebar-link-label">Overview</span>
|
||||||
|
</a>
|
||||||
|
{#each SETTINGS_SECTIONS as section}
|
||||||
|
{@const href = sectionHref(settingsSectionHref(section.id))}
|
||||||
|
<a
|
||||||
|
class:active={isActive(href)}
|
||||||
|
class="sidebar-link"
|
||||||
|
href={href}
|
||||||
|
aria-current={isActive(href) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span class="sidebar-link-label">{section.label}</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { workspaceRoute } from '$lib/workspace/api/http';
|
|
||||||
import { SETTINGS_SECTIONS, SETTINGS_ROUTE, settingsSectionHref } from '$lib/workspace/settings/model';
|
|
||||||
|
|
||||||
let { workspaceId, currentPath }: { workspaceId: string; currentPath: string } = $props();
|
|
||||||
|
|
||||||
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, SETTINGS_ROUTE) : SETTINGS_ROUTE);
|
|
||||||
|
|
||||||
function sectionHref(path: string): string {
|
|
||||||
return workspaceId ? workspaceRoute(workspaceId, path) : path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isActive(href: string): boolean {
|
|
||||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="sidebar-sections" aria-label="Settings sections">
|
|
||||||
<div class="nav-section">
|
|
||||||
<div class="section-heading">
|
|
||||||
<h2>Settings</h2>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-list">
|
|
||||||
<a
|
|
||||||
class:active={currentPath === settingsHref}
|
|
||||||
class="sidebar-link"
|
|
||||||
href={settingsHref}
|
|
||||||
aria-current={currentPath === settingsHref ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<span class="sidebar-link-label">Overview</span>
|
|
||||||
</a>
|
|
||||||
{#each SETTINGS_SECTIONS as section}
|
|
||||||
{@const href = sectionHref(settingsSectionHref(section.id))}
|
|
||||||
<a
|
|
||||||
class:active={isActive(href)}
|
|
||||||
class="sidebar-link"
|
|
||||||
href={href}
|
|
||||||
aria-current={isActive(href) ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
<span class="sidebar-link-label">{section.label}</span>
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getSidebarController, type SidebarSnippet } from './context';
|
import {
|
||||||
|
getSidebarController,
|
||||||
|
type SidebarController,
|
||||||
|
type SidebarSnippet,
|
||||||
|
} from './context';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sidebar: SidebarSnippet;
|
sidebar: SidebarSnippet;
|
||||||
|
controller?: SidebarController;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { sidebar }: Props = $props();
|
const inheritedController = getSidebarController();
|
||||||
const controller = getSidebarController();
|
const { sidebar, controller = inheritedController }: Props = $props();
|
||||||
|
|
||||||
$effect(() => controller.registerSidebar(sidebar));
|
$effect(() => controller.registerSidebar(sidebar));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
|
let ticketsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/tickets') : '/');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="nav-section">
|
<section class="sidebar-nav-section sidebar-nav-section--resource">
|
||||||
<a
|
<a
|
||||||
class="primary-nav-link"
|
class="sidebar-link"
|
||||||
class:active={currentPath.startsWith(ticketsHref)}
|
class:active={currentPath.startsWith(ticketsHref)}
|
||||||
href={ticketsHref}
|
href={ticketsHref}
|
||||||
aria-current={currentPath.startsWith(ticketsHref) ? 'page' : undefined}
|
aria-current={currentPath.startsWith(ticketsHref) ? 'page' : undefined}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="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">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
|
||||||
import MemoryNavSection from './MemoryNavSection.svelte';
|
import MemoryNavSection from './MemoryNavSection.svelte';
|
||||||
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
|
import MergeRequestsNavSection from './MergeRequestsNavSection.svelte';
|
||||||
import TicketsNavSection from './TicketsNavSection.svelte';
|
import TicketsNavSection from './TicketsNavSection.svelte';
|
||||||
import WorkersNavSection from './WorkersNavSection.svelte';
|
import WorkersNavSection from './WorkersNavSection.svelte';
|
||||||
import WorkspaceSwitcher from './WorkspaceSwitcher.svelte';
|
|
||||||
import type { WorkspaceResponse } from './types';
|
import type { WorkspaceResponse } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -24,15 +24,44 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
let workspaceId = $derived(workspace?.workspace_id ?? '');
|
||||||
|
let workspaceHomeHref = $derived(workspaceId ? workspaceRoute(workspaceId) : '/');
|
||||||
|
let workspaceSettingsHref = $derived(
|
||||||
|
workspaceId ? workspaceRoute(workspaceId, '/settings') : '/',
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-sidebar">
|
<div class="workspace-sidebar">
|
||||||
<header class="sidebar-header">
|
<header class="sidebar-header">
|
||||||
{#if workspace}
|
{#if workspace}
|
||||||
<WorkspaceSwitcher
|
<nav class="workspace-sidebar-shortcuts" aria-label="Workspace shortcuts">
|
||||||
currentWorkspaceId={workspaceId}
|
<a
|
||||||
currentWorkspaceName={workspace.display_name}
|
class="workspace-sidebar-shortcut"
|
||||||
/>
|
class:active={currentPath === workspaceHomeHref}
|
||||||
|
href={workspaceHomeHref}
|
||||||
|
aria-label="Workspace home"
|
||||||
|
title="Workspace home"
|
||||||
|
aria-current={currentPath === workspaceHomeHref ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m3 11 9-8 9 8"></path>
|
||||||
|
<path d="M5 10v10h14V10"></path>
|
||||||
|
<path d="M9 20v-6h6v6"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="workspace-sidebar-shortcut"
|
||||||
|
class:active={currentPath.startsWith(workspaceSettingsHref)}
|
||||||
|
href={workspaceSettingsHref}
|
||||||
|
aria-label="Workspace settings"
|
||||||
|
title="Workspace settings"
|
||||||
|
aria-current={currentPath.startsWith(workspaceSettingsHref) ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M4 5h16M4 12h16M4 19h16"></path>
|
||||||
|
<path d="M8 3v4M16 10v4M10 17v4"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="workspace-label">
|
<div class="workspace-label">
|
||||||
<div class="workspace-name">Yoi workspace</div>
|
<div class="workspace-name">Yoi workspace</div>
|
||||||
@@ -51,8 +80,8 @@
|
|||||||
<nav class="sidebar-sections" aria-label="Workspace sections">
|
<nav class="sidebar-sections" aria-label="Workspace sections">
|
||||||
<TicketsNavSection {currentPath} {workspaceId} />
|
<TicketsNavSection {currentPath} {workspaceId} />
|
||||||
<ObjectivesNavSection {currentPath} {workspaceId} />
|
<ObjectivesNavSection {currentPath} {workspaceId} />
|
||||||
<MemoryNavSection {currentPath} {workspaceId} />
|
|
||||||
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
<MergeRequestsNavSection {currentPath} {workspaceId} />
|
||||||
|
<MemoryNavSection {currentPath} {workspaceId} />
|
||||||
<WorkersNavSection {currentPath} {workspaceId} />
|
<WorkersNavSection {currentPath} {workspaceId} />
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getContext, type Snippet } from 'svelte';
|
|
||||||
import {
|
|
||||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
|
||||||
type WorkspaceSidebarContentController,
|
|
||||||
} from './workspace-content-context';
|
|
||||||
|
|
||||||
let { content }: { content: Snippet } = $props();
|
|
||||||
|
|
||||||
const controller = getContext<WorkspaceSidebarContentController>(
|
|
||||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => controller.registerContent(content));
|
|
||||||
</script>
|
|
||||||
@@ -8,9 +8,11 @@
|
|||||||
let {
|
let {
|
||||||
currentWorkspaceId,
|
currentWorkspaceId,
|
||||||
currentWorkspaceName,
|
currentWorkspaceName,
|
||||||
|
variant = "sidebar",
|
||||||
}: {
|
}: {
|
||||||
currentWorkspaceId: string;
|
currentWorkspaceId: string;
|
||||||
currentWorkspaceName: string;
|
currentWorkspaceName: string;
|
||||||
|
variant?: "sidebar" | "header";
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
let workspaces = $state<WorkspaceCatalogRecord[]>([]);
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
let root = $state.raw<HTMLDivElement>();
|
let root = $state.raw<HTMLDivElement>();
|
||||||
let trigger = $state.raw<HTMLButtonElement>();
|
let trigger = $state.raw<HTMLButtonElement>();
|
||||||
let menu = $state.raw<HTMLDivElement>();
|
let menu = $state.raw<HTMLDivElement>();
|
||||||
|
const menuId = $derived(`workspace-menu-popover-${variant}`);
|
||||||
|
|
||||||
const menuWorkspaces = $derived.by(() => {
|
const menuWorkspaces = $derived.by(() => {
|
||||||
const entries = workspaces.map((workspace) => ({
|
const entries = workspaces.map((workspace) => ({
|
||||||
@@ -112,14 +115,14 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="workspace-menu" bind:this={root}>
|
<div class="workspace-menu" class:workspace-menu-header={variant === "header"} bind:this={root}>
|
||||||
<button
|
<button
|
||||||
bind:this={trigger}
|
bind:this={trigger}
|
||||||
type="button"
|
type="button"
|
||||||
class="workspace-menu-trigger"
|
class="workspace-menu-trigger"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
aria-controls="workspace-menu-popover"
|
aria-controls={menuId}
|
||||||
onclick={toggleMenu}
|
onclick={toggleMenu}
|
||||||
onkeydown={handleTriggerKeydown}
|
onkeydown={handleTriggerKeydown}
|
||||||
>
|
>
|
||||||
@@ -132,7 +135,7 @@
|
|||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
bind:this={menu}
|
bind:this={menu}
|
||||||
id="workspace-menu-popover"
|
id={menuId}
|
||||||
class="workspace-menu-popover"
|
class="workspace-menu-popover"
|
||||||
role="menu"
|
role="menu"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
|
|||||||
@@ -75,58 +75,95 @@ Deno.test("sidebar disposers remove only their own registration", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("settings replaces only WorkspaceSidebar content", async () => {
|
Deno.test("Global, Workspace, and Settings use one recursive sidebar slot contract", async () => {
|
||||||
const layoutUrl = new URL(
|
const rootLayoutUrl = new URL(
|
||||||
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
|
"../../../routes/+layout.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
);
|
);
|
||||||
const workspaceLayoutUrl = new URL(
|
const workspaceLayoutUrl = new URL(
|
||||||
"../../../routes/w/[workspaceId]/+layout.svelte",
|
"../../../routes/w/[workspaceId]/+layout.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
);
|
);
|
||||||
|
const settingsLayoutUrl = new URL(
|
||||||
|
"../../../routes/w/[workspaceId]/settings/+layout.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
);
|
||||||
|
const globalSidebarUrl = new URL("./GlobalSidebar.svelte", import.meta.url);
|
||||||
const workspaceSidebarUrl = new URL(
|
const workspaceSidebarUrl = new URL(
|
||||||
"./WorkspaceSidebar.svelte",
|
"./WorkspaceSidebar.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
);
|
);
|
||||||
const settingsContentUrl = new URL(
|
const settingsSidebarUrl = new URL(
|
||||||
"./SettingsSidebarContent.svelte",
|
"./SettingsSidebar.svelte",
|
||||||
import.meta.url,
|
import.meta.url,
|
||||||
);
|
);
|
||||||
const [layout, workspaceLayout, workspaceSidebar, settingsContent] =
|
const settingsErrorUrl = new URL(
|
||||||
await Promise.all([
|
"../../../routes/w/[workspaceId]/settings/+error.svelte",
|
||||||
Deno.readTextFile(layoutUrl),
|
import.meta.url,
|
||||||
|
);
|
||||||
|
const [
|
||||||
|
rootLayout,
|
||||||
|
workspaceLayout,
|
||||||
|
settingsLayout,
|
||||||
|
globalSidebar,
|
||||||
|
workspaceSidebar,
|
||||||
|
settingsSidebar,
|
||||||
|
settingsError,
|
||||||
|
] = await Promise.all([
|
||||||
|
Deno.readTextFile(rootLayoutUrl),
|
||||||
Deno.readTextFile(workspaceLayoutUrl),
|
Deno.readTextFile(workspaceLayoutUrl),
|
||||||
|
Deno.readTextFile(settingsLayoutUrl),
|
||||||
|
Deno.readTextFile(globalSidebarUrl),
|
||||||
Deno.readTextFile(workspaceSidebarUrl),
|
Deno.readTextFile(workspaceSidebarUrl),
|
||||||
Deno.readTextFile(settingsContentUrl),
|
Deno.readTextFile(settingsSidebarUrl),
|
||||||
|
Deno.readTextFile(settingsErrorUrl),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
rootLayout.includes("<GlobalSidebar") &&
|
||||||
|
rootLayout.includes("content={sidebar}"),
|
||||||
|
"root layout should always render GlobalSidebar as the root slot owner",
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const [name, layout] of [
|
||||||
|
["Workspace", workspaceLayout],
|
||||||
|
["Settings", settingsLayout],
|
||||||
|
] as const
|
||||||
|
) {
|
||||||
assert(
|
assert(
|
||||||
layout.includes(
|
layout.includes(
|
||||||
"<WorkspaceSidebarContentOverride content={settingsSidebarContent} />",
|
"const parentSidebarController = getSidebarController();",
|
||||||
),
|
|
||||||
"settings layout should override the WorkspaceSidebar content slot",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
!layout.includes("<SidebarOverride") && !layout.includes("settings-nav"),
|
|
||||||
"settings layout should not replace the whole sidebar or retain inline navigation",
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
workspaceLayout.includes(
|
|
||||||
"registerContent: sidebarContentOverrides.register",
|
|
||||||
) &&
|
) &&
|
||||||
workspaceLayout.includes("content={sidebarContent}"),
|
layout.includes("setContext<SidebarController>(SIDEBAR_CONTEXT") &&
|
||||||
"workspace layout should provide and project the active child content",
|
layout.includes("controller={parentSidebarController}"),
|
||||||
|
`${name} layout should register with its parent and provide the same slot contract to children`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
!workspaceLayout.includes("WORKSPACE_SIDEBAR_CONTENT_CONTEXT") &&
|
||||||
|
!settingsLayout.includes("WorkspaceSidebarContentOverride"),
|
||||||
|
"recursive slots should not retain Workspace-specific context or override components",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
workspaceSidebar.includes("<WorkspaceSwitcher") &&
|
globalSidebar.includes("{@render content()}") &&
|
||||||
workspaceSidebar.indexOf("<WorkspaceSwitcher") <
|
workspaceSidebar.includes("{@render content()}") &&
|
||||||
workspaceSidebar.indexOf("{#if content}") &&
|
settingsSidebar.includes("{@render content()}"),
|
||||||
workspaceSidebar.includes("{@render content()}"),
|
"every sidebar layer should render its child through the same content contract",
|
||||||
"WorkspaceSidebar should retain its header and render child content below it",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
settingsContent.includes("SETTINGS_SECTIONS") &&
|
workspaceSidebar.includes('aria-label="Workspace shortcuts"') &&
|
||||||
settingsContent.includes('aria-label="Settings sections"'),
|
workspaceSidebar.indexOf('aria-label="Workspace shortcuts"') <
|
||||||
"SettingsSidebarContent should render the authoritative settings section catalog",
|
workspaceSidebar.indexOf("{#if content}"),
|
||||||
|
"WorkspaceSidebar should keep its shortcuts above the recursive child slot",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
settingsSidebar.includes("SETTINGS_SECTIONS") &&
|
||||||
|
settingsSidebar.includes('aria-label="Settings sections"'),
|
||||||
|
"SettingsSidebar should render the authoritative settings catalog as its fallback",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
settingsError.includes("This settings page could not be loaded") &&
|
||||||
|
settingsError.includes("Back to Settings"),
|
||||||
|
"settings load failures should stay inside the Settings layout and preserve its sidebar",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.sidebar-frame {
|
.sidebar-frame {
|
||||||
|
--sidebar-item-hover: oklch(96% 0 0);
|
||||||
|
--sidebar-item-active: oklch(92.5% 0 0);
|
||||||
|
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 1 / 3;
|
grid-row: 1 / 3;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -13,6 +16,12 @@
|
|||||||
padding-block: var(--space-4);
|
padding-block: var(--space-4);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.sidebar-frame {
|
||||||
|
--sidebar-item-hover: oklch(24% 0 0);
|
||||||
|
--sidebar-item-active: oklch(32% 0 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
.sidebar-frame.folded {
|
.sidebar-frame.folded {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -27,12 +36,14 @@
|
|||||||
}
|
}
|
||||||
.sidebar-frame-content,
|
.sidebar-frame-content,
|
||||||
.global-sidebar,
|
.global-sidebar,
|
||||||
.workspace-sidebar {
|
.workspace-sidebar,
|
||||||
|
.settings-sidebar {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.global-sidebar,
|
.global-sidebar,
|
||||||
.global-sidebar-section,
|
.global-sidebar-section,
|
||||||
.workspace-sidebar {
|
.workspace-sidebar,
|
||||||
|
.settings-sidebar {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
@@ -42,6 +53,40 @@
|
|||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.workspace-sidebar-shortcuts {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
.workspace-sidebar-shortcut {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.workspace-sidebar-shortcut > svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.workspace-sidebar-shortcut:hover,
|
||||||
|
.workspace-sidebar-shortcut:focus-visible {
|
||||||
|
background: var(--sidebar-item-hover);
|
||||||
|
}
|
||||||
|
.workspace-sidebar-shortcut.active {
|
||||||
|
background: var(--sidebar-item-active);
|
||||||
|
}
|
||||||
|
.workspace-sidebar-shortcut:focus-visible {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
.sidebar-control-row {
|
.sidebar-control-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -77,6 +122,10 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.workspace-menu-header {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: min(28vw, 20rem);
|
||||||
|
}
|
||||||
.workspace-menu-trigger {
|
.workspace-menu-trigger {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -119,6 +168,21 @@
|
|||||||
.workspace-menu-trigger[aria-expanded="true"] > svg {
|
.workspace-menu-trigger[aria-expanded="true"] > svg {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
.workspace-menu-header .workspace-menu-trigger {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.35rem 0.45rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.workspace-menu-header .workspace-menu-popover {
|
||||||
|
inset-inline-start: 0;
|
||||||
|
inset-inline-end: auto;
|
||||||
|
width: min(20rem, 72vw);
|
||||||
|
}
|
||||||
.workspace-menu-trigger:hover,
|
.workspace-menu-trigger:hover,
|
||||||
.workspace-menu-trigger:focus-visible {
|
.workspace-menu-trigger:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--interactive-hover);
|
||||||
@@ -165,12 +229,11 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.workspace-menu-item:hover,
|
.workspace-menu-item:hover,
|
||||||
.workspace-menu-item:focus-visible,
|
.workspace-menu-item:focus-visible {
|
||||||
.workspace-menu-item.current {
|
background: var(--sidebar-item-hover);
|
||||||
background: var(--interactive-hover);
|
|
||||||
}
|
}
|
||||||
.workspace-menu-item.current {
|
.workspace-menu-item.current {
|
||||||
color: var(--accent);
|
background: var(--sidebar-item-active);
|
||||||
}
|
}
|
||||||
.workspace-menu-separator {
|
.workspace-menu-separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
@@ -268,22 +331,22 @@
|
|||||||
}
|
}
|
||||||
.sidebar-sections {
|
.sidebar-sections {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-3);
|
gap: var(--space-1);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.nav-section {
|
.sidebar-nav-section {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-2);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
.section-heading-row,
|
.section-heading-row,
|
||||||
.section-header {
|
.sidebar-nav-section__header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.section-heading-row h2,
|
.section-heading-row h2,
|
||||||
.section-header,
|
.sidebar-nav-section__header,
|
||||||
.sidebar-section-label {
|
.sidebar-section-label {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -292,17 +355,21 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.section-heading-row h2,
|
.section-heading-row h2,
|
||||||
|
.sidebar-nav-section__header,
|
||||||
.sidebar-section-label {
|
.sidebar-section-label {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.section-heading-link {
|
.section-heading-link {
|
||||||
|
border-radius: var(--radius-soft);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.section-heading-link:hover,
|
.section-heading-link:hover,
|
||||||
.section-heading-link:focus-visible,
|
.section-heading-link:focus-visible {
|
||||||
|
background: var(--sidebar-item-hover);
|
||||||
|
}
|
||||||
.section-heading-link.active {
|
.section-heading-link.active {
|
||||||
color: var(--accent);
|
background: var(--sidebar-item-active);
|
||||||
}
|
}
|
||||||
.section-count {
|
.section-count {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -317,61 +384,31 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
.primary-nav-link,
|
|
||||||
.nav-item,
|
|
||||||
.objective-link,
|
|
||||||
.sidebar-link {
|
.sidebar-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-inline: calc(-1 * var(--space-2));
|
margin: 0;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-radius: var(--radius-soft);
|
border-radius: var(--radius-soft);
|
||||||
color: inherit;
|
color: var(--text-strong);
|
||||||
|
font-size: 0.9rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 140ms ease, color 140ms ease;
|
transition: background-color 140ms ease, color 140ms ease;
|
||||||
}
|
}
|
||||||
.primary-nav-link {
|
.sidebar-nav-section--category {
|
||||||
color: var(--text-strong);
|
padding-block: var(--space-1);
|
||||||
font-size: 0.9rem;
|
}
|
||||||
font-weight: 700;
|
.sidebar-nav-section--category > .sidebar-link {
|
||||||
|
margin-left: var(--space-3);
|
||||||
}
|
}
|
||||||
a.primary-nav-link:hover,
|
|
||||||
a.primary-nav-link:focus-visible,
|
|
||||||
a.nav-item:hover,
|
|
||||||
a.nav-item:focus-visible,
|
|
||||||
a.objective-link:hover,
|
|
||||||
a.objective-link:focus-visible,
|
|
||||||
a.sidebar-link:hover,
|
a.sidebar-link:hover,
|
||||||
a.sidebar-link:focus-visible {
|
a.sidebar-link:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--sidebar-item-hover);
|
||||||
}
|
}
|
||||||
a.primary-nav-link.active,
|
|
||||||
a.nav-item.active,
|
|
||||||
a.objective-link.active,
|
|
||||||
a.sidebar-link.active {
|
a.sidebar-link.active {
|
||||||
background: var(--interactive-selected);
|
background: var(--sidebar-item-active);
|
||||||
}
|
|
||||||
a.primary-nav-link.active,
|
|
||||||
a.nav-item.active .item-title,
|
|
||||||
a.objective-link.active .item-title,
|
|
||||||
a.sidebar-link.active {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.item-title,
|
|
||||||
.item-meta {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.item-title {
|
|
||||||
color: var(--text-strong);
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
.item-meta {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
}
|
||||||
.worker-nav-link {
|
.worker-nav-link {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -386,12 +423,10 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.worker-nav-link:hover {
|
.worker-nav-link:hover {
|
||||||
background: var(--interactive-hover);
|
background: var(--sidebar-item-hover);
|
||||||
color: var(--text-strong);
|
|
||||||
}
|
}
|
||||||
.worker-nav-link.active {
|
.worker-nav-link.active {
|
||||||
background: var(--interactive-selected);
|
background: var(--sidebar-item-active);
|
||||||
color: var(--accent);
|
|
||||||
}
|
}
|
||||||
.worker-status-indicator {
|
.worker-status-indicator {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
export const WORKSPACE_SIDEBAR_CONTENT_CONTEXT = Symbol(
|
|
||||||
"workspace-sidebar-content",
|
|
||||||
);
|
|
||||||
|
|
||||||
export type WorkspaceSidebarContentController = {
|
|
||||||
registerContent(content: Snippet): () => void;
|
|
||||||
};
|
|
||||||
@@ -13,6 +13,15 @@ const switcherSource = await Deno.readTextFile(
|
|||||||
const sidebarSource = await Deno.readTextFile(
|
const sidebarSource = await Deno.readTextFile(
|
||||||
new URL("./WorkspaceSidebar.svelte", import.meta.url),
|
new URL("./WorkspaceSidebar.svelte", import.meta.url),
|
||||||
);
|
);
|
||||||
|
const sidebarStyles = await Deno.readTextFile(
|
||||||
|
new URL("./sidebar.css", import.meta.url),
|
||||||
|
);
|
||||||
|
const headerSource = await Deno.readTextFile(
|
||||||
|
new URL("../header/WorkspaceBreadcrumbs.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
const workspaceLayoutSource = await Deno.readTextFile(
|
||||||
|
new URL("../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
Deno.test("workspace name opens the settings and workspace menu", () => {
|
Deno.test("workspace name opens the settings and workspace menu", () => {
|
||||||
assert(
|
assert(
|
||||||
@@ -39,6 +48,10 @@ Deno.test("workspace name opens the settings and workspace menu", () => {
|
|||||||
switcherSource.includes("currentWorkspaceName"),
|
switcherSource.includes("currentWorkspaceName"),
|
||||||
"trigger does not use the name",
|
"trigger does not use the name",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
switcherSource.includes("workspace-menu-popover-${variant}"),
|
||||||
|
"sidebar and header instances should use distinct menu ids",
|
||||||
|
);
|
||||||
assert(!switcherSource.includes("<select"), "legacy select switcher remains");
|
assert(!switcherSource.includes("<select"), "legacy select switcher remains");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,14 +74,51 @@ Deno.test("workspace menu lists catalog entries and marks the current workspace"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("workspace sidebar uses the workspace name menu as its header", () => {
|
Deno.test("workspace header prefixes breadcrumbs with the same Workspace menu", () => {
|
||||||
assert(
|
assert(
|
||||||
sidebarSource.includes("<WorkspaceSwitcher"),
|
headerSource.includes("<WorkspaceSwitcher") &&
|
||||||
"sidebar omits the menu",
|
headerSource.includes('variant="header"') &&
|
||||||
|
headerSource.includes("currentWorkspaceName"),
|
||||||
|
"header should render the shared Workspace selector before breadcrumbs",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
sidebarSource.includes("currentWorkspaceName={workspace.display_name}"),
|
headerSource.indexOf("<WorkspaceSwitcher") <
|
||||||
"sidebar does not pass the current Workspace name",
|
headerSource.indexOf('<nav class="workspace-breadcrumbs"'),
|
||||||
|
"Workspace selector should precede the breadcrumb trail",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
workspaceLayoutSource.includes("workspace={data.workspace ?? null}") &&
|
||||||
|
workspaceLayoutSource.includes(
|
||||||
|
"workspaceError={data.workspaceError ?? null}",
|
||||||
|
),
|
||||||
|
"Workspace layout should pass the authoritative Workspace name to the header selector",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("WorkspaceSidebar exposes icon-only home and settings shortcuts", () => {
|
||||||
|
assert(
|
||||||
|
sidebarSource.includes('aria-label="Workspace shortcuts"') &&
|
||||||
|
sidebarSource.includes('aria-label="Workspace home"') &&
|
||||||
|
sidebarSource.includes('aria-label="Workspace settings"') &&
|
||||||
|
sidebarSource.includes("workspaceRoute(workspaceId)") &&
|
||||||
|
sidebarSource.includes("workspaceRoute(workspaceId, '/settings')") &&
|
||||||
|
sidebarStyles.includes(".workspace-sidebar-shortcut.active"),
|
||||||
|
"Workspace Sidebar should expose accessible Home and Settings icon links",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!headerSource.includes("workspace-sidebar-shortcut"),
|
||||||
|
"Workspace shortcuts should remain specific to the Sidebar",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Workspace selector remains in the Header only", () => {
|
||||||
|
assert(
|
||||||
|
!sidebarSource.includes("<WorkspaceSwitcher"),
|
||||||
|
"Workspace Sidebar should not render the Workspace selector",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
headerSource.includes("<WorkspaceSwitcher"),
|
||||||
|
"Header should retain the shared Workspace selector",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
!sidebarSource.includes('class="sidebar-actions-row"'),
|
!sidebarSource.includes('class="sidebar-actions-row"'),
|
||||||
|
|||||||
@@ -25,34 +25,30 @@
|
|||||||
|
|
||||||
<WorkspaceAlerts />
|
<WorkspaceAlerts />
|
||||||
|
|
||||||
<div class="workspace-layout">
|
<div class="app-shell">
|
||||||
<SidebarFrame>
|
<SidebarFrame>
|
||||||
{#if sidebar}
|
<GlobalSidebar currentPath={page.url.pathname} content={sidebar} />
|
||||||
{@render sidebar()}
|
|
||||||
{:else}
|
|
||||||
<GlobalSidebar currentPath={page.url.pathname} />
|
|
||||||
{/if}
|
|
||||||
</SidebarFrame>
|
</SidebarFrame>
|
||||||
<header class="workspace-topbar">
|
<header class="app-shell__topbar">
|
||||||
<div class="workspace-topbar-location">
|
<div class="app-shell__topbar-location">
|
||||||
{#if headerController.content}{@render headerController.content()}{/if}
|
{#if headerController.content}{@render headerController.content()}{/if}
|
||||||
</div>
|
</div>
|
||||||
<nav class="workspace-topbar-actions" aria-label="Global navigation">
|
<nav class="app-shell__topbar-actions" aria-label="Global navigation">
|
||||||
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">
|
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
|
||||||
<svg class="topbar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2" />
|
||||||
<circle cx="12" cy="7" r="4" />
|
<circle cx="12" cy="7" r="4" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main class="shell">
|
<main class="app-shell__main">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.workspace-layout {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
@@ -64,7 +60,9 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-topbar {
|
.app-shell__topbar {
|
||||||
|
position: relative;
|
||||||
|
z-index: 30;
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -79,18 +77,19 @@
|
|||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-topbar-location {
|
.app-shell__topbar-location {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-topbar-actions {
|
.app-shell__topbar-actions {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-icon-button {
|
.app-shell__icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
width: 2.35rem;
|
width: 2.35rem;
|
||||||
height: 2.35rem;
|
height: 2.35rem;
|
||||||
@@ -101,13 +100,13 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-icon-button:hover,
|
.app-shell__icon-button:hover,
|
||||||
.topbar-icon-button:focus-visible {
|
.app-shell__icon-button:focus-visible {
|
||||||
background: var(--interactive-hover);
|
background: var(--interactive-hover);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar-icon {
|
.app-shell__icon {
|
||||||
width: 1.1rem;
|
width: 1.1rem;
|
||||||
height: 1.1rem;
|
height: 1.1rem;
|
||||||
fill: none;
|
fill: none;
|
||||||
@@ -117,7 +116,7 @@
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.app-shell__main {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -133,7 +132,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.workspace-layout {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: auto auto 1fr;
|
grid-template-rows: auto auto 1fr;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -142,13 +141,13 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-topbar {
|
.app-shell__topbar {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 2;
|
grid-row: 2;
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shell {
|
.app-shell__main {
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { setContext, type Snippet } from 'svelte';
|
import { setContext } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
import HeaderOverride from '$lib/workspace/header/HeaderOverride.svelte';
|
||||||
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
import WorkspaceBreadcrumbs from '$lib/workspace/header/WorkspaceBreadcrumbs.svelte';
|
||||||
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
|
||||||
import {
|
import {
|
||||||
WORKSPACE_SIDEBAR_CONTENT_CONTEXT,
|
getSidebarController,
|
||||||
type WorkspaceSidebarContentController,
|
SIDEBAR_CONTEXT,
|
||||||
} from '$lib/workspace/sidebar/workspace-content-context';
|
type SidebarController,
|
||||||
|
type SidebarSnippet,
|
||||||
|
} from '$lib/workspace/sidebar/context';
|
||||||
|
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||||
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
|
||||||
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
|
||||||
import '$lib/workspace/styles/workspace-pages.css';
|
import '$lib/workspace/styles/workspace-pages.css';
|
||||||
@@ -17,13 +19,14 @@
|
|||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { data, children }: LayoutProps = $props();
|
let { data, children }: LayoutProps = $props();
|
||||||
let sidebarContent = $state<Snippet | null>(null);
|
const parentSidebarController = getSidebarController();
|
||||||
const sidebarContentOverrides = createOverrideStack<Snippet>((activeContent) => {
|
let sidebarContent = $state<SidebarSnippet | null>(null);
|
||||||
|
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
|
||||||
sidebarContent = activeContent;
|
sidebarContent = activeContent;
|
||||||
});
|
});
|
||||||
|
|
||||||
setContext<WorkspaceSidebarContentController>(WORKSPACE_SIDEBAR_CONTENT_CONTEXT, {
|
setContext<SidebarController>(SIDEBAR_CONTEXT, {
|
||||||
registerContent: sidebarContentOverrides.register,
|
registerSidebar: sidebarContentOverrides.register,
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -34,7 +37,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet workspaceHeader()}
|
{#snippet workspaceHeader()}
|
||||||
<WorkspaceBreadcrumbs workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''} />
|
<WorkspaceBreadcrumbs
|
||||||
|
workspaceId={page.params.workspaceId ?? data.workspace?.workspace_id ?? ''}
|
||||||
|
workspace={data.workspace ?? null}
|
||||||
|
workspaceError={data.workspaceError ?? null}
|
||||||
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet workspaceSidebar()}
|
{#snippet workspaceSidebar()}
|
||||||
@@ -47,6 +54,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<HeaderOverride content={workspaceHeader} />
|
<HeaderOverride content={workspaceHeader} />
|
||||||
<SidebarOverride sidebar={workspaceSidebar} />
|
<SidebarOverride controller={parentSidebarController} sidebar={workspaceSidebar} />
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { workspaceRoute } from '$lib/workspace/api/http';
|
||||||
|
|
||||||
|
let settingsHref = $derived(
|
||||||
|
page.params.workspaceId
|
||||||
|
? workspaceRoute(page.params.workspaceId, '/settings')
|
||||||
|
: '/settings',
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Settings unavailable · Yoi Workspace</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="settings-panel" aria-labelledby="settings-error-title">
|
||||||
|
<p class="eyebrow">Settings</p>
|
||||||
|
<h1 id="settings-error-title">This settings page could not be loaded</h1>
|
||||||
|
<p class="section-state error">
|
||||||
|
{page.error?.message ?? 'The Backend rejected or could not complete the settings request.'}
|
||||||
|
</p>
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<a class="button-link" href={settingsHref}>Back to Settings</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1,21 +1,39 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import SettingsSidebarContent from '$lib/workspace/sidebar/SettingsSidebarContent.svelte';
|
import { setContext } from 'svelte';
|
||||||
import WorkspaceSidebarContentOverride from '$lib/workspace/sidebar/WorkspaceSidebarContentOverride.svelte';
|
import SettingsSidebar from '$lib/workspace/sidebar/SettingsSidebar.svelte';
|
||||||
|
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
|
||||||
|
import {
|
||||||
|
getSidebarController,
|
||||||
|
SIDEBAR_CONTEXT,
|
||||||
|
type SidebarController,
|
||||||
|
type SidebarSnippet,
|
||||||
|
} from '$lib/workspace/sidebar/context';
|
||||||
|
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||||
import '$lib/workspace/styles/settings.css';
|
import '$lib/workspace/styles/settings.css';
|
||||||
import type { LayoutProps } from './$types';
|
import type { LayoutProps } from './$types';
|
||||||
|
|
||||||
let { children }: LayoutProps = $props();
|
let { children }: LayoutProps = $props();
|
||||||
|
const parentSidebarController = getSidebarController();
|
||||||
|
let sidebarContent = $state<SidebarSnippet | null>(null);
|
||||||
|
const sidebarContentOverrides = createOverrideStack<SidebarSnippet>((activeContent) => {
|
||||||
|
sidebarContent = activeContent;
|
||||||
|
});
|
||||||
|
|
||||||
|
setContext<SidebarController>(SIDEBAR_CONTEXT, {
|
||||||
|
registerSidebar: sidebarContentOverrides.register,
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet settingsSidebarContent()}
|
{#snippet settingsSidebar()}
|
||||||
<SettingsSidebarContent
|
<SettingsSidebar
|
||||||
workspaceId={page.params.workspaceId ?? ''}
|
workspaceId={page.params.workspaceId ?? ''}
|
||||||
currentPath={page.url.pathname}
|
currentPath={page.url.pathname}
|
||||||
|
content={sidebarContent}
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<WorkspaceSidebarContentOverride content={settingsSidebarContent} />
|
<SidebarOverride controller={parentSidebarController} sidebar={settingsSidebar} />
|
||||||
|
|
||||||
<section class="settings-page">
|
<section class="settings-page">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
Reference in New Issue
Block a user