first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+234 -63
View File
@@ -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;