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:
“haonan”
2026-07-22 11:27:30 +08:00
parent ccf3c32140
commit ac028a7a18
47 changed files with 6038 additions and 46 deletions
+17 -5
View File
@@ -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),
+9
View File
@@ -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")
);
});
}
}
+90
View File
@@ -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(_))));
}
}