Skip to content
Draft
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion src/companion/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ use crate::observability::{ExecutionStep, RunObserver, StepStatus};

use super::{
Authenticator, CompanionControlRequest, CompanionControlResponse, PROTOCOL_SUBPROTOCOL,
PairingSecret, RelayPolicy, RelayState, RunEvent, TabId, WebSocketHandshake, WorkflowSummary,
PairingSecret, RelayPolicy, RelayState, RunEvent, SharedTab, TabId, WebSocketHandshake,
WorkflowSummary,
};

/// Configuration for the native Chrome companion.
Expand Down Expand Up @@ -138,6 +139,70 @@ impl CompanionServer {
list_workflows(&self.inner.workflows_dir)
}

/// Returns a browser relay handle usable by an **external** workflow runner
/// (an embedding host that drives its own engine rather than calling
/// [`start_workflow`](Self::start_workflow)). The returned handle shares this
/// server's live WebSocket session and pending-response map, so wrapping it in
/// a [`RoutingToolInvoker`](crate::browser::RoutingToolInvoker) lets a host
/// route `slug:"browser"` tool calls to the paired extension.
///
/// The handle is always valid; if no extension is currently connected each
/// `execute` fails closed with `relay_disconnected`.
pub fn browser_relay(&self) -> Arc<dyn BrowserRelay> {
Arc::new(SocketRelay {
inner: self.inner.clone(),
})
}

/// Whether a paired extension currently holds an authenticated relay session.
/// External hosts use this to gate author-time / run-time browser readiness.
pub fn is_extension_connected(&self) -> bool {
self.inner
.relay
.lock()
.map(|relay| relay.is_connected())
.unwrap_or(false)
}

/// Snapshot of the tabs the user has explicitly shared with the companion.
/// Empty when no extension is connected or nothing is shared.
pub fn shared_tabs(&self) -> Vec<SharedTab> {
self.inner
.relay
.lock()
.map(|relay| relay.tabs().list().into_iter().cloned().collect())
.unwrap_or_default()
}

/// Binds a workflow run to an explicitly-shared tab so an **external**
/// runner's `slug:"browser"` calls (dispatched through the handle from
/// [`browser_relay`](Self::browser_relay)) are authorized against that tab.
/// This mirrors what [`start_workflow`](Self::start_workflow) does
/// internally for native runs — an embedding host must call this before
/// executing a graph that contains browser nodes, or every browser action
/// fails with `tab_not_shared`.
pub fn bind_run(
&self,
run_id: impl Into<String>,
tab_id: TabId,
) -> Result<(), CompanionServerError> {
self.inner
.relay
.lock()
.map_err(|_| lock_error())?
.tabs_mut()
.bind_run(run_id.into(), tab_id)
.map_err(super::RelayError::from)?;
Ok(())
}

/// Releases a run→tab binding after an external run settles. Idempotent.
pub fn unbind_run(&self, run_id: &str) {
if let Ok(mut relay) = self.inner.relay.lock() {
relay.tabs_mut().unbind_run(run_id);
}
}
Comment on lines +227 to +247

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 bind_run / cancel_workflow asymmetry for external runs

External runs registered via bind_run are never inserted into self.inner.runs, so cancel_workflow returns false immediately for them. If an OpenHuman workflow is running a browser graph and the host wants to abort mid-flight (e.g., on user request), calling cancel_workflow will silently do nothing — neither the CancellationToken signal nor the relay-side cancel_run (which dispatches BrowserCancel to the extension and resolves the in-flight ServerInner.pending senders) will fire.

The in-flight browser actions will keep executing on the extension side and will only settle when they timeout or the extension responds, leaving RelayState.pending and ServerInner.pending occupied in the meantime. At minimum the doc comment on cancel_workflow should note it is for native runs only, and bind_run/unbind_run should document that callers are responsible for cancelling in-flight actions before unbinding.


/// Starts a native run bound to one explicit shared tab.
pub async fn start_workflow(
&self,
Expand Down