mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat: add bsk record for semantic user-action traces
Capture user actions in the Agent Window and export a record-only trace (pages[] + navigate/click/fill/select/press steps with target descriptors). Adds the record overlay UI aligned with the control overlay, and wires record start/stop/await through CLI, daemon, protocol, and extension.
This commit is contained in:
@@ -242,7 +242,10 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
||||
| Method::ToolSelect
|
||||
| Method::ToolEvaluate
|
||||
| Method::ToolWaitForNavigation
|
||||
| Method::ToolRequestHelp => {
|
||||
| Method::ToolRequestHelp
|
||||
| Method::ToolRecordStart
|
||||
| Method::ToolRecordStop
|
||||
| Method::ToolRecordAwait => {
|
||||
handle_tool_dispatch(&state, rpc_id, method, params).await
|
||||
}
|
||||
Method::ToolWaitMs => handle_wait_ms(&state.abort_registry, rpc_id, params).await,
|
||||
@@ -319,10 +322,19 @@ async fn handle_tool_dispatch(
|
||||
}
|
||||
};
|
||||
let entry = inflight_guard.entry();
|
||||
let outcome = state
|
||||
.tool_queues
|
||||
.dispatch(&session_id, method, params, timeout, Some(entry))
|
||||
.await;
|
||||
// `record_stop` must reach the extension while `record_await` holds the
|
||||
// serial busy lock — finishing the recording unblocks await.
|
||||
let outcome = if method == Method::ToolRecordStop {
|
||||
state
|
||||
.tool_queues
|
||||
.dispatch_unlocked(&session_id, method, params, timeout, Some(entry))
|
||||
.await
|
||||
} else {
|
||||
state
|
||||
.tool_queues
|
||||
.dispatch(&session_id, method, params, timeout, Some(entry))
|
||||
.await
|
||||
};
|
||||
drop(inflight_guard);
|
||||
match outcome {
|
||||
Ok(v) => ResponseBody::Ok(v),
|
||||
|
||||
@@ -95,6 +95,11 @@ pub fn sock_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("run").join("daemon.sock"))
|
||||
}
|
||||
|
||||
/// Path to the in-progress recording session state (`record-session.json`).
|
||||
pub fn record_session_path() -> Result<PathBuf> {
|
||||
Ok(bsk_home()?.join("record-session.json"))
|
||||
}
|
||||
|
||||
/// Windows named-pipe name. Include the resolved `BSK_HOME` path in the
|
||||
/// token so test homes and custom installs do not share a predictable
|
||||
/// per-username pipe.
|
||||
@@ -163,6 +168,10 @@ mod tests {
|
||||
assert_eq!(log_path().unwrap(), home.join("daemon.log"));
|
||||
assert_eq!(update_check_path().unwrap(), home.join("update-check.json"));
|
||||
assert_eq!(sock_path().unwrap(), home.join("run").join("daemon.sock"));
|
||||
assert_eq!(
|
||||
record_session_path().unwrap(),
|
||||
home.join("record-session.json")
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,13 @@ impl ToolQueueRegistry {
|
||||
guard.get(sid).is_some_and(|entry| entry.accepting)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn force_busy_for_tests(&self, sid: &SessionId, busy: bool) {
|
||||
let guard = self.queues.lock().expect("tool queue registry poisoned");
|
||||
let entry = guard.get(sid).expect("queue must exist");
|
||||
entry.state.lock().expect("queue state poisoned").busy = busy;
|
||||
}
|
||||
|
||||
/// Spawn the worker task for a session id. Idempotent: spawning the
|
||||
/// same id twice replaces the previous queue (the previous worker
|
||||
/// closes when its sender drops).
|
||||
@@ -320,6 +327,42 @@ impl ToolQueueRegistry {
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Forward a tool RPC to the extension without taking the
|
||||
/// per-session busy lock. Used by `tool.record_stop` so a second
|
||||
/// CLI can finish an in-flight `tool.record_await` (extension
|
||||
/// dispatch already runs concurrent `void dispatch(msg)` handlers).
|
||||
pub async fn dispatch_unlocked(
|
||||
&self,
|
||||
sid: &SessionId,
|
||||
method: Method,
|
||||
params: Value,
|
||||
timeout: Duration,
|
||||
inflight: Option<Arc<ToolInflightEntry>>,
|
||||
) -> Result<Value, DispatchError> {
|
||||
{
|
||||
let guard = self.queues.lock().expect("tool queue registry poisoned");
|
||||
let Some(entry) = guard.get(sid) else {
|
||||
return Err(DispatchError::SessionNotFound);
|
||||
};
|
||||
if !entry.accepting {
|
||||
return Err(DispatchError::SessionStopping);
|
||||
}
|
||||
}
|
||||
let (respond_tx, _respond_rx) = oneshot::channel();
|
||||
let job = ToolJob {
|
||||
method,
|
||||
params,
|
||||
timeout,
|
||||
respond: respond_tx,
|
||||
inflight,
|
||||
session_id: sid.clone(),
|
||||
};
|
||||
match forward_one(sid, &self.browsers, &self.sessions, &job).await {
|
||||
Ok(v) => Ok(v),
|
||||
Err(err) => Err(DispatchError::Rpc(err)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop accepting new jobs for `sid`, enqueue one final control RPC,
|
||||
/// and wait for it behind any already-queued tools. Used by
|
||||
/// `session.stop` so extension teardown cannot race an in-flight
|
||||
@@ -789,3 +832,50 @@ mod tool_job_session_id_tests {
|
||||
assert_eq!(job.session_id.0, "sess-A");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dispatch_unlocked_tests {
|
||||
use super::*;
|
||||
use crate::daemon::browsers::BrowserRegistry;
|
||||
use crate::daemon::sessions::{SessionId, SessionRegistry};
|
||||
use bsk_protocol::Method;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test]
|
||||
async fn unlocked_dispatch_skips_session_busy_gate() {
|
||||
let browsers = Arc::new(BrowserRegistry::new());
|
||||
let sessions = Arc::new(SessionRegistry::new());
|
||||
let queues = ToolQueueRegistry::new(browsers, sessions);
|
||||
let sid = SessionId("s-record".into());
|
||||
queues.spawn(sid.clone());
|
||||
queues.force_busy_for_tests(&sid, true);
|
||||
|
||||
let busy = queues
|
||||
.dispatch(
|
||||
&sid,
|
||||
Method::ToolTabList,
|
||||
json!({ "session_id": "s-record" }),
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(busy, Err(DispatchError::SessionBusy)));
|
||||
|
||||
let unlocked = queues
|
||||
.dispatch_unlocked(
|
||||
&sid,
|
||||
Method::ToolRecordStop,
|
||||
json!({ "session_id": "s-record" }),
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!matches!(unlocked, Err(DispatchError::SessionBusy)),
|
||||
"record_stop must not be rejected as SessionBusy; got {unlocked:?}"
|
||||
);
|
||||
// No session row → forward_one returns NotFound as Rpc.
|
||||
assert!(matches!(unlocked, Err(DispatchError::Rpc(_))));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user