Synced from monorepo
Synced from monorepo Changes: - Workspace server: report `/ready` as failed with dwell on hub connect failure - Refresh OIDC token for the Grok agent in the shell - ACP terminal output recorder - Cross-platform provider auth commands in the shell - Default `/resume` to Grok sessions with a hint for hidden external sessions - Resume sessions by title with `--resume` - Limit app-builder archive size - Data-driven tag labels for slash commands - Doctor fixes for tmux - Custom provider gateways and subprocess environment policy in the shell - `/tutorial` — opt-in onboarding tour of Grok Build - Soft and required CLI version checks in the shell - Privacy banner env overrides survive live settings updates - Add remote flag to override the image-edit model - Return profile fields from auth info even when the access token is expired - Add edit control on queued prompt rows - Keep fail-closed policy when clearing orphans with no team - Setting to disable the Ctrl+Space/F8 voice shortcut - Pass `--raw` to pw-record so Linux dictation works on older PipeWire - Validate git URLs when adding marketplace entries - Stop shipping stale tool-doc parameter and tool names - Re-point dashboard attach after `/fork` only when the parent was attached - Surface Grok Computer media-generation results as file-path chunks - Clear web background-task tray on kill and keep the task description - Show privacy upsell banner in agent view until acted on - Add tools-server client callback surface - Protect persistent global hook sources Source-Revision: 95d84f443eddcbed6cbfd6eed22e2eafe6b3939d
This commit is contained in:
parent
a5727c5960
commit
69f0ba880a
286 changed files with 22939 additions and 9624 deletions
|
|
@ -5,21 +5,59 @@
|
|||
//! automatically.
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
use xai_grok_workspace::config::WorkspaceServerMetadata;
|
||||
use xai_grok_workspace::daemonize;
|
||||
use xai_grok_workspace::diag_server;
|
||||
use xai_grok_workspace::diag_server::{self, DiagHandle, ErrorClass};
|
||||
use xai_grok_workspace::error::WorkspaceError;
|
||||
use xai_grok_workspace::preview_supervisor::{self, PreviewArgs, PreviewVisibility};
|
||||
/// OTLP `service.name` for this binary's exported traces/logs/metrics and
|
||||
/// direct-OTLP fastrace export. Single source so the call sites can't drift.
|
||||
const SERVICE_NAME: &str = "prod_grok_workspace";
|
||||
const EXIT_SERVER_ID_INVALID: i32 = 3;
|
||||
const INVALID_SERVER_ID_MARKER: &str = "workspace-server: invalid --server-id";
|
||||
const WORKSPACE_HUB_AUTH_FAILED_MARKER: &str = "workspace hub auth failed";
|
||||
/// Post-failure dwell so the host can poll `/ready` before exit ([500ms, 2s]).
|
||||
const HUB_CONNECT_FAILED_DWELL: Duration = Duration::from_millis(750);
|
||||
fn server_id_startup_error(id: &str) -> Option<String> {
|
||||
id.parse::<xai_tool_protocol::ServerId>()
|
||||
.err()
|
||||
.map(|e| format!("{INVALID_SERVER_ID_MARKER} {id:?}: {e}"))
|
||||
}
|
||||
/// Classify hub-connect Display strings for `/ready` error_class.
|
||||
/// Auth needles → `hub_auth`; other hub-connect path failures → `hub_connect`;
|
||||
/// pre-hub workspace setup messages → `unknown` (still retryable alongside hub_connect).
|
||||
fn classify_hub_connect_failure(err_msg: &str) -> ErrorClass {
|
||||
if err_msg.contains("handshake auth failed") || err_msg.contains("auth error:") {
|
||||
ErrorClass::HubAuth
|
||||
} else if err_msg.contains("failed to create workspace") {
|
||||
ErrorClass::Unknown
|
||||
} else {
|
||||
ErrorClass::HubConnect
|
||||
}
|
||||
}
|
||||
/// Drop outer `hub error: ` so `/ready` detail is the inner failure text.
|
||||
fn hub_connect_error_detail(err_msg: &str) -> &str {
|
||||
err_msg.strip_prefix("hub error: ").unwrap_or(err_msg)
|
||||
}
|
||||
fn hub_connect_failure_log_message(class: ErrorClass) -> &'static str {
|
||||
match class {
|
||||
ErrorClass::HubAuth => WORKSPACE_HUB_AUTH_FAILED_MARKER,
|
||||
ErrorClass::HubConnect | ErrorClass::Unknown => "failed to connect workspace to hub",
|
||||
}
|
||||
}
|
||||
/// Mark `/ready` failed and dwell so the host can observe state before exit.
|
||||
async fn report_hub_connect_failure(diag: &DiagHandle, err: &WorkspaceError) {
|
||||
let err_msg = err.to_string();
|
||||
let class = classify_hub_connect_failure(&err_msg);
|
||||
diag.set_failed(class, hub_connect_error_detail(&err_msg));
|
||||
tracing::error!(error = %err_msg, "{}", hub_connect_failure_log_message(class));
|
||||
dwell_after_hub_connect_failed().await;
|
||||
}
|
||||
async fn dwell_after_hub_connect_failed() {
|
||||
tokio::time::sleep(HUB_CONNECT_FAILED_DWELL).await;
|
||||
}
|
||||
#[derive(Parser)]
|
||||
#[command(name = "xai-workspace-server")]
|
||||
#[command(about = "Standalone workspace ToolServer for the server connection")]
|
||||
|
|
@ -345,7 +383,7 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
|
|||
};
|
||||
let preview_scrape_interval = status_config.preview_activity_scrape_interval;
|
||||
xai_grok_workspace::init_metrics();
|
||||
let ws_handle = xai_grok_workspace::handle::connect_local_workspace(
|
||||
let ws_handle = match xai_grok_workspace::handle::connect_local_workspace(
|
||||
cwd,
|
||||
url,
|
||||
auth_provider,
|
||||
|
|
@ -361,7 +399,13 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
|
|||
args.confine_fs_to_workspace_root,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("failed to connect workspace to hub: {e}"))?;
|
||||
{
|
||||
Ok(handle) => handle,
|
||||
Err(e) => {
|
||||
report_hub_connect_failure(&diag_handle, &e).await;
|
||||
return Err(anyhow::anyhow!("failed to connect workspace to hub: {e}"));
|
||||
}
|
||||
};
|
||||
if let Some((tx, control_port)) = &preview_shutdown {
|
||||
tokio::spawn(preview_supervisor::supervise_preview_activity(
|
||||
*control_port,
|
||||
|
|
@ -447,6 +491,216 @@ async fn run(args: Args, cwd: PathBuf) -> anyhow::Result<()> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn hub_connect_failed_dwell_is_within_design_bounds() {
|
||||
assert!(HUB_CONNECT_FAILED_DWELL >= Duration::from_millis(500));
|
||||
assert!(HUB_CONNECT_FAILED_DWELL <= Duration::from_secs(2));
|
||||
}
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn hub_connect_failed_dwell_elapses_exact_budget() {
|
||||
let start = tokio::time::Instant::now();
|
||||
dwell_after_hub_connect_failed().await;
|
||||
assert_eq!(start.elapsed(), HUB_CONNECT_FAILED_DWELL);
|
||||
}
|
||||
#[test]
|
||||
fn classify_hub_connect_auth_needles() {
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("hub error: handshake auth failed: HTTP 401"),
|
||||
ErrorClass::HubAuth
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("handshake auth failed: HTTP 401"),
|
||||
ErrorClass::HubAuth
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("hub error: auth error: token rejected"),
|
||||
ErrorClass::HubAuth
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("HTTP 401 unauthorized"),
|
||||
ErrorClass::HubConnect
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("token refresh failed"),
|
||||
ErrorClass::HubConnect
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn classify_from_client_error_display_round_trip() {
|
||||
let handshake = WorkspaceError::HubError(
|
||||
xai_computer_hub_sdk::ClientError::HandshakeAuthFailed { status: 401 }.to_string(),
|
||||
);
|
||||
let handshake_msg = handshake.to_string();
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure(&handshake_msg),
|
||||
ErrorClass::HubAuth
|
||||
);
|
||||
assert_eq!(
|
||||
hub_connect_failure_log_message(ErrorClass::HubAuth),
|
||||
WORKSPACE_HUB_AUTH_FAILED_MARKER
|
||||
);
|
||||
let auth = WorkspaceError::HubError(
|
||||
xai_computer_hub_sdk::ClientError::AuthError("token rejected".into()).to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure(&auth.to_string()),
|
||||
ErrorClass::HubAuth
|
||||
);
|
||||
let network = WorkspaceError::HubError(
|
||||
xai_computer_hub_sdk::ClientError::NetworkError("connection refused".into())
|
||||
.to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure(&network.to_string()),
|
||||
ErrorClass::HubConnect
|
||||
);
|
||||
assert_ne!(
|
||||
hub_connect_failure_log_message(ErrorClass::HubConnect),
|
||||
WORKSPACE_HUB_AUTH_FAILED_MARKER
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn classify_hub_connect_non_auth_is_hub_connect() {
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("hub error: network error: connection refused"),
|
||||
ErrorClass::HubConnect
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("hub error: protocol error: bad hello"),
|
||||
ErrorClass::HubConnect
|
||||
);
|
||||
assert_eq!(
|
||||
classify_hub_connect_failure("failed to create workspace: disk full"),
|
||||
ErrorClass::Unknown
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hub_auth_marker_is_stable_literal() {
|
||||
assert_eq!(
|
||||
WORKSPACE_HUB_AUTH_FAILED_MARKER,
|
||||
"workspace hub auth failed"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn hub_connect_error_detail_strips_hub_error_prefix() {
|
||||
let err = WorkspaceError::HubError("handshake auth failed: HTTP 401".into());
|
||||
assert_eq!(
|
||||
hub_connect_error_detail(&err.to_string()),
|
||||
"handshake auth failed: HTTP 401"
|
||||
);
|
||||
let other = WorkspaceError::HubError("network error: timeout".into());
|
||||
assert_eq!(
|
||||
hub_connect_error_detail(&other.to_string()),
|
||||
"network error: timeout"
|
||||
);
|
||||
}
|
||||
/// Install a capturing tracing subscriber for the duration of an async
|
||||
/// report; returns emitted event messages.
|
||||
async fn report_with_captured_messages(
|
||||
handle: &DiagHandle,
|
||||
err: &WorkspaceError,
|
||||
) -> (Duration, Vec<String>) {
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::field::{Field, Visit};
|
||||
use tracing_subscriber::layer::{Context, SubscriberExt as _};
|
||||
use tracing_subscriber::{Layer, Registry};
|
||||
#[derive(Default)]
|
||||
struct MsgVisitor {
|
||||
message: Option<String>,
|
||||
}
|
||||
impl Visit for MsgVisitor {
|
||||
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
self.message = Some(format!("{value:?}").trim_matches('"').to_owned());
|
||||
}
|
||||
}
|
||||
fn record_str(&mut self, field: &Field, value: &str) {
|
||||
if field.name() == "message" {
|
||||
self.message = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
struct CaptureLayer {
|
||||
msgs: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
impl<S: tracing::Subscriber> Layer<S> for CaptureLayer {
|
||||
fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
|
||||
let mut v = MsgVisitor::default();
|
||||
event.record(&mut v);
|
||||
if let Some(msg) = v.message {
|
||||
self.msgs
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
let msgs = Arc::new(Mutex::new(Vec::new()));
|
||||
let subscriber = Registry::default().with(CaptureLayer { msgs: msgs.clone() });
|
||||
let _guard = tracing::subscriber::set_default(subscriber);
|
||||
let start = tokio::time::Instant::now();
|
||||
report_hub_connect_failure(handle, err).await;
|
||||
let elapsed = start.elapsed();
|
||||
let messages = msgs.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
(elapsed, messages)
|
||||
}
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn report_hub_connect_failure_sets_ready_failed_auth_and_dwells() {
|
||||
let handle = DiagHandle::new(Some("nonce-auth".to_owned()));
|
||||
let bound = diag_server::serve(diag_server::DiagListener::Tcp(0), handle.clone(), None)
|
||||
.await
|
||||
.expect("bind");
|
||||
let port = bound.port.expect("tcp port");
|
||||
let err = WorkspaceError::HubError("handshake auth failed: HTTP 401".into());
|
||||
let (elapsed, messages) = report_with_captured_messages(&handle, &err).await;
|
||||
assert_eq!(elapsed, HUB_CONNECT_FAILED_DWELL);
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| m == WORKSPACE_HUB_AUTH_FAILED_MARKER),
|
||||
"auth path must emit marker, got {messages:?}"
|
||||
);
|
||||
let response = reqwest::get(format!("http://127.0.0.1:{port}/ready"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status().as_u16(), 503);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["state"], "failed");
|
||||
assert_eq!(body["error_class"], "hub_auth");
|
||||
assert_eq!(body["error_detail"], "handshake auth failed: HTTP 401");
|
||||
assert_eq!(body["launch_id"], "nonce-auth");
|
||||
}
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn report_hub_connect_failure_sets_ready_failed_hub_connect() {
|
||||
let handle = DiagHandle::new(None);
|
||||
let bound = diag_server::serve(diag_server::DiagListener::Tcp(0), handle.clone(), None)
|
||||
.await
|
||||
.expect("bind");
|
||||
let port = bound.port.expect("tcp port");
|
||||
let err = WorkspaceError::HubError("network error: connection refused".into());
|
||||
let (elapsed, messages) = report_with_captured_messages(&handle, &err).await;
|
||||
assert_eq!(elapsed, HUB_CONNECT_FAILED_DWELL);
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| m == "failed to connect workspace to hub"),
|
||||
"non-auth path must emit connect failure line, got {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.all(|m| m != WORKSPACE_HUB_AUTH_FAILED_MARKER),
|
||||
"non-auth path must not emit auth marker, got {messages:?}"
|
||||
);
|
||||
let response = reqwest::get(format!("http://127.0.0.1:{port}/ready"))
|
||||
.await
|
||||
.expect("request");
|
||||
assert_eq!(response.status().as_u16(), 503);
|
||||
let body: serde_json::Value = response.json().await.expect("json");
|
||||
assert_eq!(body["state"], "failed");
|
||||
assert_eq!(body["error_class"], "hub_connect");
|
||||
assert_eq!(body["error_detail"], "network error: connection refused");
|
||||
}
|
||||
#[test]
|
||||
fn capabilities_flag_parses_and_defaults_off() {
|
||||
let args = Args::try_parse_from(["xai-workspace-server"]).unwrap();
|
||||
assert!(!args.capabilities);
|
||||
|
|
|
|||
|
|
@ -51,8 +51,21 @@ pub enum DiagState {
|
|||
Starting,
|
||||
Connected,
|
||||
Disconnected,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// `/ready` `error_class` when [`DiagState::Failed`] (`hub_auth` / `hub_connect` / `unknown`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ErrorClass {
|
||||
HubAuth,
|
||||
HubConnect,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Soft cap on `/ready` `error_detail` so guest-local messages stay short.
|
||||
const MAX_ERROR_DETAIL_BYTES: usize = 256;
|
||||
|
||||
/// Response body for `/ready`. The field set is a frozen contract with the
|
||||
/// sandbox readiness gate: never rename or remove fields; additions are
|
||||
/// backward-compatible.
|
||||
|
|
@ -66,6 +79,10 @@ struct ReadyBody {
|
|||
connected_at: Option<u64>,
|
||||
state_changed_at: u64,
|
||||
version: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_class: Option<ErrorClass>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error_detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Response body for `/statusz`: the `/ready` fields plus debug extras.
|
||||
|
|
@ -82,6 +99,14 @@ struct Inner {
|
|||
connected_at: Option<u64>,
|
||||
state_changed_at: u64,
|
||||
shutting_down: bool,
|
||||
error_class: Option<ErrorClass>,
|
||||
error_detail: Option<String>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn is_failed(&self) -> bool {
|
||||
matches!(self.state, DiagState::Failed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable handle publishing hub lifecycle transitions to the server.
|
||||
|
|
@ -102,16 +127,17 @@ impl DiagHandle {
|
|||
connected_at: None,
|
||||
state_changed_at: now_ms(),
|
||||
shutting_down: false,
|
||||
error_class: None,
|
||||
error_detail: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Initial hello completed, or a reconnect's serve replay settled.
|
||||
/// Ignored after [`Self::set_shutting_down`]: a reconnect that settles
|
||||
/// during the shutdown drain must not republish `connected`.
|
||||
/// No-op after [`Self::set_shutting_down`] or [`Self::set_failed`].
|
||||
pub fn set_connected(&self) {
|
||||
let mut inner = self.lock();
|
||||
if inner.shutting_down {
|
||||
if inner.shutting_down || inner.is_failed() {
|
||||
return;
|
||||
}
|
||||
inner.state = DiagState::Connected;
|
||||
|
|
@ -120,28 +146,44 @@ impl DiagHandle {
|
|||
inner.state_changed_at = now;
|
||||
}
|
||||
|
||||
/// Server socket dropped.
|
||||
/// Server socket dropped. No-op after [`Self::set_failed`].
|
||||
pub fn set_disconnected(&self) {
|
||||
let mut inner = self.lock();
|
||||
if inner.is_failed() {
|
||||
return;
|
||||
}
|
||||
inner.state = DiagState::Disconnected;
|
||||
inner.state_changed_at = now_ms();
|
||||
}
|
||||
|
||||
/// Latch `disconnected` for process shutdown: reported as `disconnected`
|
||||
/// on `/ready`, and later `set_connected` calls become no-ops.
|
||||
/// Latch disconnected for process shutdown; later `set_connected` no-ops.
|
||||
/// No-op after [`Self::set_failed`].
|
||||
pub fn set_shutting_down(&self) {
|
||||
let mut inner = self.lock();
|
||||
if inner.is_failed() {
|
||||
return;
|
||||
}
|
||||
inner.shutting_down = true;
|
||||
inner.state = DiagState::Disconnected;
|
||||
inner.state_changed_at = now_ms();
|
||||
}
|
||||
|
||||
/// Terminal connect failure on `/ready`. Sticky; callers dwell before exit.
|
||||
pub fn set_failed(&self, error_class: ErrorClass, error_detail: impl Into<String>) {
|
||||
let mut inner = self.lock();
|
||||
inner.state = DiagState::Failed;
|
||||
inner.error_class = Some(error_class);
|
||||
inner.error_detail = Some(truncate_error_detail(error_detail.into()));
|
||||
inner.state_changed_at = now_ms();
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, Inner> {
|
||||
self.inner.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
fn ready_body(&self) -> ReadyBody {
|
||||
let inner = self.lock();
|
||||
let failed = inner.is_failed();
|
||||
ReadyBody {
|
||||
launch_id: self.launch_id.clone(),
|
||||
state: inner.state,
|
||||
|
|
@ -149,6 +191,12 @@ impl DiagHandle {
|
|||
connected_at: inner.connected_at,
|
||||
state_changed_at: inner.state_changed_at,
|
||||
version: xai_grok_version::VERSION,
|
||||
error_class: failed.then_some(inner.error_class).flatten(),
|
||||
error_detail: if failed {
|
||||
inner.error_detail.clone()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,6 +208,17 @@ impl DiagHandle {
|
|||
}
|
||||
}
|
||||
|
||||
fn truncate_error_detail(detail: String) -> String {
|
||||
if detail.len() <= MAX_ERROR_DETAIL_BYTES {
|
||||
return detail;
|
||||
}
|
||||
let mut end = MAX_ERROR_DETAIL_BYTES;
|
||||
while end > 0 && !detail.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
detail[..end].to_owned()
|
||||
}
|
||||
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
|
@ -412,6 +471,115 @@ mod tests {
|
|||
assert_eq!(body["state"], "disconnected");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ready_reports_failed_with_error_fields() {
|
||||
let handle = DiagHandle::new(Some("nonce-fail".to_owned()));
|
||||
let bound = serve(DiagListener::Tcp(0), handle.clone(), None)
|
||||
.await
|
||||
.expect("bind");
|
||||
let port = bound.port.expect("tcp port");
|
||||
|
||||
handle.set_failed(ErrorClass::HubAuth, "handshake auth failed: HTTP 401");
|
||||
let (status, body) = get_json(port, "/ready").await;
|
||||
|
||||
assert_eq!(status, 503, "failed is not ready");
|
||||
assert_eq!(body["launch_id"], "nonce-fail");
|
||||
assert_eq!(body["state"], "failed");
|
||||
assert_eq!(body["error_class"], "hub_auth");
|
||||
assert_eq!(body["error_detail"], "handshake auth failed: HTTP 401");
|
||||
assert!(body["state_changed_at"].is_u64());
|
||||
assert!(body["pid"].is_u64());
|
||||
assert!(body["version"].is_string());
|
||||
let starting = DiagHandle::new(None);
|
||||
let bound2 = serve(DiagListener::Tcp(0), starting, None)
|
||||
.await
|
||||
.expect("bind");
|
||||
let (_, start_body) = get_json(bound2.port.expect("tcp port"), "/ready").await;
|
||||
assert_eq!(start_body["state"], "starting");
|
||||
assert!(
|
||||
start_body.get("error_class").is_none(),
|
||||
"error_class must be omitted unless failed"
|
||||
);
|
||||
assert!(
|
||||
start_body.get("error_detail").is_none(),
|
||||
"error_detail must be omitted unless failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ready_failed_hub_connect_and_unknown_classes() {
|
||||
let handle = DiagHandle::new(None);
|
||||
let bound = serve(DiagListener::Tcp(0), handle.clone(), None)
|
||||
.await
|
||||
.expect("bind");
|
||||
let port = bound.port.expect("tcp port");
|
||||
|
||||
handle.set_failed(ErrorClass::HubConnect, "network error: connection refused");
|
||||
let (status, body) = get_json(port, "/ready").await;
|
||||
assert_eq!(status, 503);
|
||||
assert_eq!(body["state"], "failed");
|
||||
assert_eq!(body["error_class"], "hub_connect");
|
||||
assert_eq!(body["error_detail"], "network error: connection refused");
|
||||
|
||||
handle.set_failed(ErrorClass::Unknown, "something else");
|
||||
let (status, body) = get_json(port, "/ready").await;
|
||||
assert_eq!(status, 503);
|
||||
assert_eq!(body["state"], "failed");
|
||||
assert_eq!(body["error_class"], "unknown");
|
||||
assert_eq!(body["error_detail"], "something else");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failed_is_sticky_against_later_lifecycle_transitions() {
|
||||
let handle = DiagHandle::new(None);
|
||||
let bound = serve(DiagListener::Tcp(0), handle.clone(), None)
|
||||
.await
|
||||
.expect("bind");
|
||||
let port = bound.port.expect("tcp port");
|
||||
|
||||
handle.set_failed(ErrorClass::HubAuth, "handshake auth failed: HTTP 401");
|
||||
handle.set_connected();
|
||||
handle.set_disconnected();
|
||||
handle.set_shutting_down();
|
||||
|
||||
let (status, body) = get_json(port, "/ready").await;
|
||||
assert_eq!(status, 503);
|
||||
assert_eq!(body["state"], "failed");
|
||||
assert_eq!(body["error_class"], "hub_auth");
|
||||
assert_eq!(body["error_detail"], "handshake auth failed: HTTP 401");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_detail_is_truncated_to_cap() {
|
||||
let handle = DiagHandle::new(None);
|
||||
let long = "x".repeat(MAX_ERROR_DETAIL_BYTES + 64);
|
||||
handle.set_failed(ErrorClass::Unknown, long);
|
||||
let body = handle.ready_body();
|
||||
let detail = body.error_detail.expect("detail");
|
||||
assert_eq!(detail.len(), MAX_ERROR_DETAIL_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_detail_truncation_respects_utf8_char_boundary() {
|
||||
let mut long = "a".repeat(MAX_ERROR_DETAIL_BYTES - 1);
|
||||
long.push('é');
|
||||
assert_eq!(long.len(), MAX_ERROR_DETAIL_BYTES + 1);
|
||||
|
||||
let handle = DiagHandle::new(None);
|
||||
handle.set_failed(ErrorClass::Unknown, long);
|
||||
let detail = handle.ready_body().error_detail.expect("detail");
|
||||
assert!(
|
||||
detail.len() <= MAX_ERROR_DETAIL_BYTES,
|
||||
"truncated length {}",
|
||||
detail.len()
|
||||
);
|
||||
assert!(
|
||||
detail.is_char_boundary(detail.len()),
|
||||
"must not split a multi-byte char"
|
||||
);
|
||||
assert!(detail.ends_with('a') || detail.ends_with('é'));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn unix_socket_serves_ready_and_rebinds_over_stale_socket() {
|
||||
|
|
|
|||
|
|
@ -5356,6 +5356,7 @@ pub(crate) mod tests {
|
|||
foreground_block_budget: None,
|
||||
kind: xai_grok_tools::computer::types::TaskKind::Bash,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
/// Start a `sleep 30` background task on `session`'s owned backend and
|
||||
|
|
|
|||
|
|
@ -1077,6 +1077,7 @@ mod tests {
|
|||
block_waited: false,
|
||||
explicitly_killed: false,
|
||||
owner_session_id: None,
|
||||
description: None,
|
||||
})
|
||||
}
|
||||
fn started_id(n: &ToolNotification) -> &str {
|
||||
|
|
|
|||
|
|
@ -277,6 +277,7 @@ async fn tasks_snapshot(toolset: &FinalizedToolset) -> TasksSnapshotResponse {
|
|||
TaskKind::Monitor => "monitor".to_owned(),
|
||||
},
|
||||
started_at: DateTime::<Utc>::from(t.start_time).to_rfc3339(),
|
||||
description: t.description,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
|
|
@ -1440,6 +1441,11 @@ mod tests {
|
|||
let task = &snap.background_tasks[0];
|
||||
assert_eq!(task.task_id, bg.task_id);
|
||||
assert_eq!(task.kind, "bash");
|
||||
assert!(
|
||||
task.description.is_none(),
|
||||
"start_background_sleep does not set description: {:?}",
|
||||
task.description
|
||||
);
|
||||
assert!(
|
||||
DateTime::parse_from_rfc3339(&task.started_at).is_ok(),
|
||||
"started_at must be RFC3339: {}",
|
||||
|
|
@ -1450,6 +1456,24 @@ mod tests {
|
|||
"no scheduler resource in this toolset: {:?}",
|
||||
snap.scheduled_tasks
|
||||
);
|
||||
{
|
||||
use crate::handle::tests::terminal_run_request;
|
||||
let mut req = terminal_run_request("sleep 30", out_dir.path(), "snap-desc-task");
|
||||
req.description = Some("build frontend".into());
|
||||
let desc_bg = session
|
||||
.terminal_backend()
|
||||
.run_background(req)
|
||||
.await
|
||||
.expect("start described background task");
|
||||
let snap = snapshot(&handler).await;
|
||||
let described = snap
|
||||
.background_tasks
|
||||
.iter()
|
||||
.find(|t| t.task_id == desc_bg.task_id)
|
||||
.expect("described task in snapshot");
|
||||
assert_eq!(described.description.as_deref(), Some("build frontend"));
|
||||
session.terminal_backend().kill_task(&desc_bg.task_id).await;
|
||||
}
|
||||
session.terminal_backend().kill_task(&bg.task_id).await;
|
||||
let snap = snapshot(&handler).await;
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -425,6 +425,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||
image_gen_enabled: true,
|
||||
image_edit_enabled: true,
|
||||
model_override: None,
|
||||
edit_model_override: None,
|
||||
tier_restricted: false,
|
||||
},
|
||||
VideoGenConfig::Enabled {
|
||||
|
|
@ -467,6 +468,7 @@ impl SessionContextFactory for WorkspaceSessionContextFactory {
|
|||
session_env,
|
||||
notification_handle,
|
||||
owner_session_id: None,
|
||||
subagent: None,
|
||||
parent_scheduler_handle: None,
|
||||
skills: vec![],
|
||||
state_path: self.resolve_state_path(session_id),
|
||||
|
|
@ -590,6 +592,7 @@ pub mod test_support {
|
|||
session_env,
|
||||
notification_handle: ToolNotificationHandle::noop(),
|
||||
owner_session_id: None,
|
||||
subagent: None,
|
||||
parent_scheduler_handle: None,
|
||||
skills: vec![],
|
||||
state_path: session_root.join("tool_state.json"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue