first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,47 +1,82 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::OsString,
|
||||
future::Future,
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
sync::Arc,
|
||||
task::{Context, Poll},
|
||||
time::Duration,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use futures::channel::oneshot;
|
||||
use session_sharing_protocol::common::{Role, SessionId};
|
||||
use session_sharing_protocol::sharer::SessionRetentionReason;
|
||||
use galaxy_cli::share::{ShareAccessLevel, ShareRequest, ShareSubject};
|
||||
use galaxy_completer::completer::CommandOutput;
|
||||
use galaxy_core::command::ExitCode;
|
||||
use galaxy_core::features::FeatureFlag;
|
||||
use galaxy_terminal::model::grid::Dimensions;
|
||||
use galaxy_util::path::ShellFamily;
|
||||
use galaxyui::{
|
||||
r#async::FutureExt, AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _,
|
||||
ViewHandle,
|
||||
};
|
||||
use session_sharing_protocol::common::{Role, SessionId};
|
||||
use session_sharing_protocol::sharer::SessionSourceType;
|
||||
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
|
||||
use crate::{
|
||||
ai::ambient_agents::AmbientAgentTaskId,
|
||||
pane_group::NewTerminalOptions,
|
||||
root_view::{open_new_with_workspace_source, NewWorkspaceSource},
|
||||
terminal::{
|
||||
model::block::{BlockId, SerializedBlock},
|
||||
shared_session::{self, IsSharedSessionCreator},
|
||||
shell::ShellType,
|
||||
view::ConversationRestorationInNewPaneType,
|
||||
TerminalView,
|
||||
},
|
||||
util::sync::Condition,
|
||||
workspaces::user_workspaces::UserWorkspaces,
|
||||
};
|
||||
|
||||
use crate::ai::attachment_utils::attachments_download_dir;
|
||||
use galaxyui::r#async::FutureExt;
|
||||
use galaxyui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity as _, ViewHandle};
|
||||
|
||||
use super::AgentDriverError;
|
||||
use crate::ai::ambient_agents::AmbientAgentTaskId;
|
||||
use crate::ai::attachment_utils::attachments_download_dir;
|
||||
use crate::pane_group::NewTerminalOptions;
|
||||
use crate::root_view::{open_new_with_workspace_source, NewWorkspaceSource};
|
||||
use crate::terminal::model::block::{BlockId, SerializedBlock};
|
||||
use crate::terminal::model::find::RegexDFAs;
|
||||
use crate::terminal::model::grid::RespectDisplayedOutput;
|
||||
use crate::terminal::model::index::Point;
|
||||
use crate::terminal::model::session::ExecuteCommandOptions;
|
||||
use crate::terminal::model::RespectObfuscatedSecrets;
|
||||
use crate::terminal::shared_session::{self, IsSharedSessionCreator, SharedSessionSource};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::terminal::view::{ConversationRestorationInNewPaneType, Event};
|
||||
use crate::terminal::TerminalView;
|
||||
use crate::workspaces::user_workspaces::UserWorkspaces;
|
||||
|
||||
/// Describes why a terminal session bootstrap failed.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum BootstrapError {
|
||||
/// The PTY or shell process failed before the bootstrap script completed.
|
||||
/// When `reason` is `Some`, the message is
|
||||
/// "Shell spawn failed: {reason}. Check the Warp logs for details."
|
||||
/// When `reason` is `None`, it is
|
||||
/// "Shell spawn failed. Check the Warp logs for details."
|
||||
PtySpawnFailed { reason: Option<String> },
|
||||
/// The bootstrap script did not complete within the expected time.
|
||||
TimedOut,
|
||||
/// An unexpected internal error in the bootstrap channel
|
||||
/// (e.g. the sender was dropped without sending).
|
||||
InternalError,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BootstrapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
BootstrapError::PtySpawnFailed { reason: Some(r) } => {
|
||||
write!(
|
||||
f,
|
||||
"Shell spawn failed: {r}. Check the Warp logs for details."
|
||||
)
|
||||
}
|
||||
BootstrapError::PtySpawnFailed { reason: None } => {
|
||||
write!(f, "Shell spawn failed. Check the Warp logs for details.")
|
||||
}
|
||||
BootstrapError::TimedOut => write!(
|
||||
f,
|
||||
"Terminal session did not start within the expected time. \
|
||||
Check the Warp logs for details."
|
||||
),
|
||||
BootstrapError::InternalError => {
|
||||
write!(f, "An unexpected internal error occurred during bootstrap.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BootstrapError {}
|
||||
|
||||
/// Describes why an agent's session-sharing request failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -66,7 +101,8 @@ pub(crate) enum ShareSessionError {
|
||||
}
|
||||
|
||||
const TERMINAL_SESSION_BOOTSTRAP_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(10);
|
||||
/// The total time to wait for session sharing to start, including retries.
|
||||
const TERMINAL_SESSION_SHARE_DELAY: Duration = Duration::from_secs(20);
|
||||
|
||||
/// Options for creating the terminal view before constructing a [`TerminalDriver`].
|
||||
pub(crate) struct TerminalDriverOptions {
|
||||
@@ -97,7 +133,12 @@ pub(crate) enum TerminalDriverEvent {
|
||||
/// - Detecting block completion
|
||||
pub(crate) struct TerminalDriver {
|
||||
terminal_view: ViewHandle<TerminalView>,
|
||||
session_bootstrapped: Condition,
|
||||
/// Sender half of the bootstrap result channel. Exactly one of
|
||||
/// `SessionBootstrapped`, `PtySpawnFailed`, or `Exited` (pre-bootstrap)
|
||||
/// will send the outcome; all others no-op after the first send.
|
||||
bootstrap_tx: Option<oneshot::Sender<Result<(), BootstrapError>>>,
|
||||
/// Receiver half consumed by `wait_for_session_bootstrapped`.
|
||||
bootstrap_rx: Option<oneshot::Receiver<Result<(), BootstrapError>>>,
|
||||
/// The session ID once sharing has been established.
|
||||
shared_session_id: Option<SessionId>,
|
||||
/// Receiver for the session sharing result. Present when sharing is expected
|
||||
@@ -126,9 +167,7 @@ fn create_terminal_view(
|
||||
) -> Result<ViewHandle<TerminalView>, AgentDriverError> {
|
||||
let is_shared_session_creator = if options.should_share {
|
||||
IsSharedSessionCreator::Yes {
|
||||
source_type: SessionSourceType::AmbientAgent {
|
||||
task_id: options.task_id.map(|t| t.to_string()),
|
||||
},
|
||||
source: SharedSessionSource::ambient_agent(options.task_id.map(|t| t.to_string())),
|
||||
}
|
||||
} else {
|
||||
IsSharedSessionCreator::No
|
||||
@@ -192,8 +231,6 @@ impl TerminalDriver {
|
||||
working_dir: PathBuf,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> Self {
|
||||
let session_bootstrapped = Condition::new();
|
||||
|
||||
// Create a oneshot channel for session sharing when sharing is expected.
|
||||
// When sharing is disabled (or running against ngrok), leave both halves
|
||||
// as None so that `wait_for_session_shared` returns immediately.
|
||||
@@ -213,6 +250,7 @@ impl TerminalDriver {
|
||||
let _ = tx.send(Err(ShareSessionError::Disabled));
|
||||
(None, Some(rx))
|
||||
} else {
|
||||
log::info!("Waiting for requested session sharing to start");
|
||||
let (tx, rx) = oneshot::channel();
|
||||
(Some(tx), Some(rx))
|
||||
}
|
||||
@@ -234,13 +272,14 @@ impl TerminalDriver {
|
||||
});
|
||||
}
|
||||
|
||||
ctx.subscribe_to_view(&terminal_view, move |me, event, ctx| {
|
||||
ctx.subscribe_to_view(&terminal_view, move |me, _, event, ctx| {
|
||||
me.handle_terminal_view_event(event, &mut session_share_tx, ctx);
|
||||
});
|
||||
|
||||
// If the session already bootstrapped before we subscribed, set the
|
||||
// condition immediately so callers of `wait_for_session_bootstrapped`
|
||||
// don't block forever.
|
||||
let (bootstrap_tx_inner, bootstrap_rx) = oneshot::channel::<Result<(), BootstrapError>>();
|
||||
|
||||
// If bootstrap already completed before we subscribed, resolve the
|
||||
// channel immediately so `wait_for_session_bootstrapped` doesn't block.
|
||||
let already_bootstrapped = terminal_view.read(ctx, |terminal, _| {
|
||||
terminal
|
||||
.model
|
||||
@@ -248,13 +287,17 @@ impl TerminalDriver {
|
||||
.block_list()
|
||||
.is_bootstrapping_precmd_done()
|
||||
});
|
||||
if already_bootstrapped {
|
||||
session_bootstrapped.set();
|
||||
}
|
||||
let bootstrap_tx = if already_bootstrapped {
|
||||
let _ = bootstrap_tx_inner.send(Ok(()));
|
||||
None
|
||||
} else {
|
||||
Some(bootstrap_tx_inner)
|
||||
};
|
||||
|
||||
Self {
|
||||
terminal_view,
|
||||
session_bootstrapped,
|
||||
bootstrap_tx,
|
||||
bootstrap_rx: Some(bootstrap_rx),
|
||||
shared_session_id: None,
|
||||
session_share_rx,
|
||||
pending_share_requests: Vec::new(),
|
||||
@@ -362,6 +405,62 @@ impl TerminalDriver {
|
||||
.map(SerializedBlock::from)
|
||||
}
|
||||
|
||||
/// Full visible plaintext of `block_id`'s output grid (no ANSI escape
|
||||
/// sequences; secrets obfuscated). Used by the harness output monitor
|
||||
/// to detect whether the block has stalled — two byte-identical
|
||||
/// snapshots taken N seconds apart imply the harness has produced no
|
||||
/// new output and no spinner activity.
|
||||
///
|
||||
/// We intentionally pass `None` for `max_rows` so we compare the entire
|
||||
/// visible output; capping the row count could falsely report "stalled"
|
||||
/// when content scrolled below the cap actually changed.
|
||||
pub fn block_output_plaintext(&self, block_id: &BlockId, ctx: &AppContext) -> Option<String> {
|
||||
let terminal = self.terminal_view.as_ref(ctx);
|
||||
let model = terminal.model.lock();
|
||||
let block = model.block_list().block_with_id(block_id)?;
|
||||
Some(block.output_grid().contents_to_string(
|
||||
false, // include_escape_sequences
|
||||
None, // max_rows: full visible output
|
||||
))
|
||||
}
|
||||
|
||||
pub fn find_first_match_in_block_output(
|
||||
&self,
|
||||
block_id: &BlockId,
|
||||
dfas: &RegexDFAs,
|
||||
ctx: &AppContext,
|
||||
) -> Option<BlockOutputMatch> {
|
||||
let terminal = self.terminal_view.as_ref(ctx);
|
||||
let model = terminal.model.lock();
|
||||
let block = model.block_list().block_with_id(block_id)?;
|
||||
let grid = block.output_grid();
|
||||
let m = grid.find(dfas).next()?;
|
||||
let handler = grid.grid_handler();
|
||||
let matched_text = handler.bounds_to_string(
|
||||
*m.start(),
|
||||
*m.end(),
|
||||
false, // include_esc_sequences
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false, // force_secrets_obfuscated
|
||||
RespectDisplayedOutput::Yes,
|
||||
);
|
||||
let cols = handler.columns();
|
||||
let row_start = Point::new(m.start().row, 0);
|
||||
let row_end = Point::new(m.end().row, cols.saturating_sub(1));
|
||||
let excerpt = handler.bounds_to_string(
|
||||
row_start,
|
||||
row_end,
|
||||
false,
|
||||
RespectObfuscatedSecrets::Yes,
|
||||
false,
|
||||
RespectDisplayedOutput::Yes,
|
||||
);
|
||||
Some(BlockOutputMatch {
|
||||
matched_text: matched_text.trim().to_owned(),
|
||||
excerpt: excerpt.trim().to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Execute a command in the terminal and return a future that resolves to a
|
||||
/// [`CommandHandle`] once the command starts executing.
|
||||
pub fn execute_command(
|
||||
@@ -487,21 +586,32 @@ impl TerminalDriver {
|
||||
|
||||
/// Returns a future that resolves when the session has bootstrapped.
|
||||
///
|
||||
/// This only waits for the `SessionBootstrapped` terminal view event.
|
||||
/// The bootstrap result channel carries `Ok(())` on success or
|
||||
/// `Err(BootstrapError)` on failure. If the channel times out, the error
|
||||
/// is `BootstrapError::TimedOut`.
|
||||
pub fn wait_for_session_bootstrapped(
|
||||
&self,
|
||||
) -> impl Future<Output = Result<(), AgentDriverError>> {
|
||||
let session_bootstrapped = self.session_bootstrapped.clone();
|
||||
&mut self,
|
||||
) -> impl Future<Output = Result<(), BootstrapError>> {
|
||||
let bootstrap_rx = self.bootstrap_rx.take();
|
||||
|
||||
async move {
|
||||
session_bootstrapped
|
||||
.wait()
|
||||
.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
log::error!("Timed out waiting for session bootstrap");
|
||||
AgentDriverError::BootstrapFailed
|
||||
})
|
||||
let result = if let Some(rx) = bootstrap_rx {
|
||||
// Map channel cancellation (sender dropped without sending)
|
||||
// to InternalError — this shouldn't happen in practice.
|
||||
let inner = async move { rx.await.unwrap_or(Err(BootstrapError::InternalError)) };
|
||||
match inner.with_timeout(TERMINAL_SESSION_BOOTSTRAP_TIMEOUT).await {
|
||||
Ok(result) => result,
|
||||
Err(_timeout) => Err(BootstrapError::TimedOut),
|
||||
}
|
||||
} else {
|
||||
// bootstrap_rx already consumed — shouldn't happen in normal flow.
|
||||
Err(BootstrapError::InternalError)
|
||||
};
|
||||
|
||||
if let Err(ref e) = result {
|
||||
log::error!("Terminal bootstrap failed: {e}");
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +655,42 @@ impl TerminalDriver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extend_shared_session_retention(
|
||||
&mut self,
|
||||
reason: SessionRetentionReason,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.terminal_view.update(ctx, |terminal, ctx| {
|
||||
if !terminal
|
||||
.model
|
||||
.lock()
|
||||
.shared_session_status()
|
||||
.is_active_sharer()
|
||||
{
|
||||
log::warn!(
|
||||
"Tried to extend shared session retention before sharing was active: {reason:?}"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("Emitting request to extend shared session retention: {reason:?}");
|
||||
ctx.emit(Event::ExtendSessionRetention { reason });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The first DFA match returned by
|
||||
/// [`TerminalDriver::find_first_match_in_block_output`].
|
||||
///
|
||||
/// `matched_text` is the exact substring from the grid (no ANSI escapes),
|
||||
/// used by the harness output monitor to map the hit back to the originating
|
||||
/// pattern. `excerpt` is the full row(s) containing the match, also as
|
||||
/// plaintext, suitable for surfacing in user-visible error messages.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BlockOutputMatch {
|
||||
pub matched_text: String,
|
||||
pub excerpt: String,
|
||||
}
|
||||
|
||||
/// A handle to a running terminal command.
|
||||
@@ -584,7 +730,28 @@ impl TerminalDriver {
|
||||
) {
|
||||
match event {
|
||||
crate::terminal::view::Event::SessionBootstrapped => {
|
||||
self.session_bootstrapped.set();
|
||||
if let Some(tx) = self.bootstrap_tx.take() {
|
||||
let _ = tx.send(Ok(()));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::PtySpawnFailed { reason } => {
|
||||
// Signal the bootstrap waiter immediately so it doesn't wait
|
||||
// for the full 60 s timeout when a spawn failure has already
|
||||
// been confirmed.
|
||||
if let Some(tx) = self.bootstrap_tx.take() {
|
||||
let _ = tx.send(Err(BootstrapError::PtySpawnFailed {
|
||||
reason: Some(reason.clone()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::Exited => {
|
||||
// The shell process exited before bootstrap completed —
|
||||
// cancel the wait immediately rather than sitting out the
|
||||
// full 60 s timeout. No specific reason is known at this
|
||||
// point; the logs will have details.
|
||||
if let Some(tx) = self.bootstrap_tx.take() {
|
||||
let _ = tx.send(Err(BootstrapError::PtySpawnFailed { reason: None }));
|
||||
}
|
||||
}
|
||||
crate::terminal::view::Event::SlowBootstrap => {
|
||||
ctx.emit(TerminalDriverEvent::SlowBootstrap);
|
||||
@@ -646,3 +813,7 @@ impl TerminalDriver {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "terminal_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user