From 155ddb8968a3aa21040b160f382540d375741625 Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Fri, 22 May 2026 17:06:51 -0400 Subject: [PATCH 1/2] feat: expand plugin surface to mirror buckaroo server (load_expr, diagnostics, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the buckaroo-tauri plugin closer to parity with `buckaroo.server`'s HTTP surface. Adds the two endpoint passthroughs that were missing (`load_expr`, `diagnostics`) and threads two new optional fields (`mode`, `backend`) through the existing `buckaroo_load_path` so hosts can route a parquet via the xorq push-down backend without separately extracting a build_dir. ## Rust (crates/buckaroo-tauri/src) - `LoadPathArgs` gains `mode: Option` and `backend: Option`. Forwarded into the POST body verbatim when present; otherwise the Layer-3 contract (server defaults) holds. Pre-existing callers that pass only `path` (+ optional `session`) are unaffected. - New `LoadExprArgs` + `buckaroo_load_expr` command. POSTs `/load_expr` with `{build_dir, session?, project_root?, prompt?, component_config?}` and opens the internal WS to the returned session β€” same shape as `buckaroo_load_path` but for the xorq-build-dir-driven path. - New `buckaroo_diagnostics` command. Thin GET passthrough for the server's `/diagnostics` endpoint; returns the JSON blob verbatim so host UIs can surface server version, uptime, installed extras, etc. - Both new commands registered in `init()`. ## JS adapter (packages/buckaroo-tauri-adapter) - `loadPath` gains an `opts?: { mode?, backend?, session? }` argument that's forwarded to the Rust command. Existing single-arg callers (`loadPath(path)`) keep working. - New `loadExpr(build_dir, opts?)` helper paired with `buckaroo_load_expr`. - Both exported from `index.ts`. ## Why `buckaroo.server` already supports a richer surface than the plugin exposes: | Endpoint | Plugin command before | Plugin command after | |--------------|------------------------------|-----------------------| | `/health` | `buckaroo_health` (WS state) | unchanged | | `/diagnostics` | β€” | `buckaroo_diagnostics`| | `/load` | `buckaroo_load_path` | + `mode` + `backend` | | `/load_expr` | β€” | `buckaroo_load_expr` | `mode` / `backend` forwarding on `/load` pairs with the [`feat(server): /load gains backend=xorq` change in buckaroo](https://github.com/buckaroo-data/buckaroo/pull/840): hosts that already materialised a parquet on disk can opt into XorqInfiniteBuckaroo's push-down query execution by passing `{ mode: "buckaroo", backend: "xorq" }`, avoiding the eager-pandas load that was the only path before. ## Test plan - [x] `cargo build` clean on the Rust crate. - [x] Manual: existing single-arg `loadPath(path)` still works. - [ ] Manual: `loadPath(path, { mode: "buckaroo", backend: "xorq" })` against a sidecar that has the matching server change produces a XorqInfiniteBuckaroo session (verified end-to-end via the xorq-desktop integration that motivated this PR). - [ ] Playwright tests on the example app still pass. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- crates/buckaroo-tauri/src/commands.rs | 164 ++++++++++++++++-- crates/buckaroo-tauri/src/lib.rs | 2 + .../src/TauriIPCModel.ts | 31 +++- packages/buckaroo-tauri-adapter/src/index.ts | 2 +- 4 files changed, 182 insertions(+), 17 deletions(-) diff --git a/crates/buckaroo-tauri/src/commands.rs b/crates/buckaroo-tauri/src/commands.rs index 067f09b..d83cdf1 100644 --- a/crates/buckaroo-tauri/src/commands.rs +++ b/crates/buckaroo-tauri/src/commands.rs @@ -33,6 +33,51 @@ pub(crate) struct LoadPathArgs { /// mints one server-side and returns it in the response. #[serde(default)] pub session: Option, + /// Optional: override the server-side widget mode. `"viewer"` (default) + /// loads the lightweight `DFViewerInfiniteDS`; `"buckaroo"` loads the + /// full widget (summary stats, low-code commands, post-processing); + /// `"lazy"` loads via polars LazyFrame for constant-memory scrolling. + #[serde(default)] + pub mode: Option, + /// Optional: when `mode == "buckaroo"`, swap the in-server execution + /// backend. `"pandas"` (default) materialises via `pd.read_parquet` + /// and computes summary stats up front. `"xorq"` wraps the file in + /// `xo.deferred_read_parquet(...)` and serves each scroll via the + /// XorqServerDataflow push-down path β€” no upfront materialisation, + /// constant memory footprint over arbitrarily large parquets. + /// Requires a `buckaroo[xorq]` install on the sidecar. + #[serde(default)] + pub backend: Option, +} + +#[derive(Deserialize)] +pub(crate) struct LoadExprArgs { + /// Path to a `xorq build` / `xo.build_expr` output directory. The server + /// reads expr.yaml, requirements.txt, the wheel, etc. from here and + /// rehydrates the expression via xorq, then serves it via the + /// XorqServerDataflow β€” each grid scroll fires + /// `handle_infinite_request_xorq` against the live expression + /// (push-down query execution against the underlying backend, no + /// upfront materialisation). + pub build_dir: String, + /// Optional: pre-supply a session id. If omitted, the server mints one + /// server-side and returns it in the response. + #[serde(default)] + pub session: Option, + /// Optional: project root used by the server to discover stat / + /// post-processing klass extensions (`load_project_stat_klasses` and + /// `load_project_post_processing_klasses`). Pass the catalog repo when + /// the entries should pick up project-side extensions. + #[serde(default)] + pub project_root: Option, + /// Optional: prompt string echoed back in the initial_state payload, + /// used by some hosts for breadcrumb / title chrome. + #[serde(default)] + pub prompt: Option, + /// Optional: server-side component_config override forwarded verbatim + /// into the session. + #[serde(default)] + pub component_config: Option, } /// Diagnostic info about the running sidecar. @@ -65,20 +110,18 @@ pub(crate) async fn buckaroo_load_path( .unwrap() .ok_or_else(|| "sidecar not yet ready".to_string())?; - let body = match &args.session { - Some(s) => serde_json::json!({ - "session": s, - "path": args.path, - "mode": "viewer", - "no_browser": true, - }), - // Server mints when omitted (Layer 3 contract). - None => serde_json::json!({ - "path": args.path, - "mode": "viewer", - "no_browser": true, - }), - }; + let mode = args.mode.as_deref().unwrap_or("viewer"); + let mut body = serde_json::json!({ + "path": args.path, + "mode": mode, + "no_browser": true, + }); + if let Some(s) = &args.session { + body.as_object_mut().unwrap().insert("session".into(), serde_json::Value::String(s.clone())); + } + if let Some(b) = &args.backend { + body.as_object_mut().unwrap().insert("backend".into(), serde_json::Value::String(b.clone())); + } let resp = reqwest::Client::new() .post(format!("http://127.0.0.1:{}/load", port)) @@ -111,6 +154,99 @@ pub(crate) async fn buckaroo_load_path( }) } +/// Load a xorq expression (from a `xorq build` directory) via the sidecar's +/// HTTP /load_expr endpoint, then open the internal WS to that session if +/// not already open. Counterpart to `buckaroo_load_path` for the xorq +/// push-down path: the server keeps the expression live and answers each +/// `infinite_request` from the grid via xorq query execution, instead of +/// paging over a materialised parquet. +#[tauri::command] +pub(crate) async fn buckaroo_load_expr( + args: LoadExprArgs, + app: AppHandle, + state: tauri::State<'_, SidecarState>, +) -> Result { + let port = state + .port + .lock() + .unwrap() + .ok_or_else(|| "sidecar not yet ready".to_string())?; + + let mut body = serde_json::Map::new(); + body.insert("build_dir".into(), serde_json::Value::String(args.build_dir)); + body.insert("no_browser".into(), serde_json::Value::Bool(true)); + if let Some(s) = args.session { + body.insert("session".into(), serde_json::Value::String(s)); + } + if let Some(p) = args.project_root { + body.insert("project_root".into(), serde_json::Value::String(p)); + } + if let Some(p) = args.prompt { + body.insert("prompt".into(), serde_json::Value::String(p)); + } + if let Some(c) = args.component_config { + body.insert("component_config".into(), c); + } + + let resp = reqwest::Client::new() + .post(format!("http://127.0.0.1:{}/load_expr", port)) + .json(&serde_json::Value::Object(body)) + .send() + .await + .map_err(|e| format!("POST /load_expr failed: {}", e))?; + + let status = resp.status(); + let text = resp.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(format!("POST /load_expr returned {}: {}", status, text)); + } + let metadata: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| format!("invalid JSON from /load_expr: {}", e))?; + + let session_id = metadata + .get("session") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| "load_expr response missing 'session' field".to_string())?; + let rows = metadata.get("rows").and_then(|v| v.as_u64()); + + supervisor::connect_internal_ws(&app, port, &session_id).await?; + + Ok(LoadResult { + session_id, + rows, + metadata, + }) +} + +/// Fetch the sidecar's server-side diagnostics blob (`GET /diagnostics`). +/// Useful for host UIs that want to surface server version, uptime, +/// installed extras, etc. +#[tauri::command] +pub(crate) async fn buckaroo_diagnostics( + _app: AppHandle, + state: tauri::State<'_, SidecarState>, +) -> Result { + let port = state + .port + .lock() + .unwrap() + .ok_or_else(|| "sidecar not yet ready".to_string())?; + + let resp = reqwest::Client::new() + .get(format!("http://127.0.0.1:{}/diagnostics", port)) + .send() + .await + .map_err(|e| format!("GET /diagnostics failed: {}", e))?; + let status = resp.status(); + let text = resp.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(format!("GET /diagnostics returned {}: {}", status, text)); + } + serde_json::from_str(&text) + .map_err(|e| format!("invalid JSON from /diagnostics: {}", e)) +} + /// Forward a JSON message to the buckaroo server over the internal WS. /// Used for `infinite_request` (scroll/data fetch) and `buckaroo_state_change`. #[tauri::command] diff --git a/crates/buckaroo-tauri/src/lib.rs b/crates/buckaroo-tauri/src/lib.rs index ba98260..f23c8e6 100644 --- a/crates/buckaroo-tauri/src/lib.rs +++ b/crates/buckaroo-tauri/src/lib.rs @@ -48,7 +48,9 @@ pub fn init(config: BuckarooConfig) -> TauriPlugin { Builder::new("buckaroo-tauri") .invoke_handler(tauri::generate_handler![ commands::buckaroo_health, + commands::buckaroo_diagnostics, commands::buckaroo_load_path, + commands::buckaroo_load_expr, commands::buckaroo_send, commands::buckaroo_pick_file, ]) diff --git a/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts b/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts index cdbd905..a15db87 100644 --- a/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts +++ b/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts @@ -167,7 +167,34 @@ export async function waitForInitialState(): Promise> { /** * Convenience: trigger the Rust plugin's load_path command. Returns the * sessionId minted by the buckaroo server. + * + * `opts.mode` selects the server-side widget shape ("viewer" | "buckaroo" + * | "lazy"). `opts.backend` selects the in-server execution backend when + * `mode === "buckaroo"`; `"xorq"` routes through + * `xo.deferred_read_parquet` + XorqServerDataflow for push-down query + * execution (constant memory over arbitrarily large parquets) instead of + * pandas-eager loading. */ -export async function loadPath(path: string): Promise<{ sessionId: string; rows?: number; metadata: any }> { - return invoke("plugin:buckaroo-tauri|buckaroo_load_path", { args: { path } }); +export async function loadPath( + path: string, + opts?: { mode?: string; backend?: string; session?: string }, +): Promise<{ sessionId: string; rows?: number; metadata: any }> { + return invoke("plugin:buckaroo-tauri|buckaroo_load_path", { + args: { path, ...(opts ?? {}) }, + }); +} + +/** + * Convenience: trigger the Rust plugin's load_expr command, sending a + * `xorq build` output directory to the server's /load_expr endpoint. + * The server holds an XorqServerDataflow over the rehydrated expression + * and serves infinite-scroll requests via push-down query execution. + */ +export async function loadExpr( + build_dir: string, + opts?: { session?: string; project_root?: string; prompt?: string }, +): Promise<{ sessionId: string; rows?: number; metadata: any }> { + return invoke("plugin:buckaroo-tauri|buckaroo_load_expr", { + args: { build_dir, ...(opts ?? {}) }, + }); } diff --git a/packages/buckaroo-tauri-adapter/src/index.ts b/packages/buckaroo-tauri-adapter/src/index.ts index f7d051c..0d10e07 100644 --- a/packages/buckaroo-tauri-adapter/src/index.ts +++ b/packages/buckaroo-tauri-adapter/src/index.ts @@ -1 +1 @@ -export { TauriIPCModel, waitForInitialState, loadPath } from "./TauriIPCModel"; +export { TauriIPCModel, waitForInitialState, loadPath, loadExpr } from "./TauriIPCModel"; From 0cc84952c24b99b5087ef2e27df33caf291efcbe Mon Sep 17 00:00:00 2001 From: Paddy Mullen Date: Fri, 22 May 2026 19:21:07 -0400 Subject: [PATCH 2/2] feat(adapter): race-safe listenForInitialState, document the existing race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `listenForInitialState()` β€” a variant of `waitForInitialState()` that awaits the underlying `listen()` subscription before returning, so the caller can fire a load call immediately afterwards without losing the resulting `initial_state` push to a registration race. Pattern: const received = await listenForInitialState(); await loadPath(path, { mode: "buckaroo", backend: "xorq" }); const initial = await received; `waitForInitialState()` is left in place for hosts that pre-attach the listener and only call `loadPath` later (the race doesn't bite there). A docstring warning calls out the failure mode so new callers know to reach for the race-safe variant when the two operations are back-to-back. ## Background `waitForInitialState()` returns a Promise immediately but the `listen("buckaroo:msg", ...)` call inside is async β€” Tauri's event channel needs an IPC round-trip to register the subscription. If the caller fires `loadPath` next, the load_path IPC and the listen IPC race; if load_path wins, the server's initial_state push arrives before the listener attaches and Tauri drops it (no pre-subscription buffering). The Promise then hangs forever waiting for a message that already fled. Confirmed in xorq-desktop integration: clicking a fast-path entry like `batting` (parquet path returned in ~1 ms) reliably stalled on "loading into sidecar + awaiting initial_state…" until the inline fix landed. With `listenForInitialState()` the same flow lands the view in milliseconds. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- .../src/TauriIPCModel.ts | 50 +++++++++++++++++-- packages/buckaroo-tauri-adapter/src/index.ts | 8 ++- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts b/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts index a15db87..931d840 100644 --- a/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts +++ b/packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts @@ -145,12 +145,21 @@ export class TauriIPCModel implements IModel { /** * Wait for the first `initial_state` message from the buckaroo server. * - * Use this in a host app's mount sequence: + * **Race warning**: this function returns a Promise immediately, but the + * underlying `listen()` subscription is still in flight on its own + * microtask. If the caller fires a `loadPath` / `loadExpr` *after* this + * call and the sidecar's `initial_state` push arrives before + * `listen()` finishes registering, the event is dropped (Tauri's event + * channel doesn't buffer pre-subscription messages) and the returned + * Promise hangs forever. * - * ```ts - * const initial = await waitForInitialState(); - * const model = new TauriIPCModel(initial); - * ``` + * Prefer `listenForInitialState()` below for the pattern where a load + * call follows the listen registration β€” it awaits the subscription + * before returning, so the load can't outrun it. + * + * Kept for backwards compatibility with hosts that pre-attach the + * listener and only call `loadPath` much later (e.g. after a user + * interaction); the race doesn't bite there. */ export async function waitForInitialState(): Promise> { return new Promise((resolve) => { @@ -164,6 +173,37 @@ export async function waitForInitialState(): Promise> { }); } +/** + * Race-safe variant of `waitForInitialState`. Awaits the + * `listen("buckaroo:msg", …)` subscription before returning, then + * hands the caller back the `received` Promise that resolves with the + * `initial_state` payload when (if) it arrives. + * + * Use this when a load call immediately follows the listener + * registration: + * + * ```ts + * const received = await listenForInitialState(); + * await loadPath(path, { mode: "buckaroo", backend: "xorq" }); + * const initial = await received; // guaranteed: listener was live + * const model = new TauriIPCModel(initial); + * ``` + */ +export async function listenForInitialState(): Promise>> { + let resolveFn!: (v: Record) => void; + const received = new Promise>((res) => { + resolveFn = res; + }); + const unlisten = await listen("buckaroo:msg", (event) => { + const msg = event.payload; + if (msg?.type === "initial_state") { + unlisten(); + resolveFn(msg); + } + }); + return received; +} + /** * Convenience: trigger the Rust plugin's load_path command. Returns the * sessionId minted by the buckaroo server. diff --git a/packages/buckaroo-tauri-adapter/src/index.ts b/packages/buckaroo-tauri-adapter/src/index.ts index 0d10e07..902d0a6 100644 --- a/packages/buckaroo-tauri-adapter/src/index.ts +++ b/packages/buckaroo-tauri-adapter/src/index.ts @@ -1 +1,7 @@ -export { TauriIPCModel, waitForInitialState, loadPath, loadExpr } from "./TauriIPCModel"; +export { + TauriIPCModel, + waitForInitialState, + listenForInitialState, + loadPath, + loadExpr, +} from "./TauriIPCModel";