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..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,10 +173,68 @@ 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. + * + * `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..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 } from "./TauriIPCModel"; +export { + TauriIPCModel, + waitForInitialState, + listenForInitialState, + loadPath, + loadExpr, +} from "./TauriIPCModel";