Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
164 changes: 150 additions & 14 deletions crates/buckaroo-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,51 @@ pub(crate) struct LoadPathArgs {
/// mints one server-side and returns it in the response.
#[serde(default)]
pub session: Option<String>,
/// 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<String>,
/// 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<String>,
}

#[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<String>,
/// 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<String>,
/// Optional: prompt string echoed back in the initial_state payload,
/// used by some hosts for breadcrumb / title chrome.
#[serde(default)]
pub prompt: Option<String>,
/// Optional: server-side component_config override forwarded verbatim
/// into the session.
#[serde(default)]
pub component_config: Option<serde_json::Value>,
}

/// Diagnostic info about the running sidecar.
Expand Down Expand Up @@ -65,20 +110,18 @@ pub(crate) async fn buckaroo_load_path<R: Runtime>(
.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))
Expand Down Expand Up @@ -111,6 +154,99 @@ pub(crate) async fn buckaroo_load_path<R: Runtime>(
})
}

/// 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<R: Runtime>(
args: LoadExprArgs,
app: AppHandle<R>,
state: tauri::State<'_, SidecarState>,
) -> Result<LoadResult, String> {
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<R: Runtime>(
_app: AppHandle<R>,
state: tauri::State<'_, SidecarState>,
) -> Result<serde_json::Value, String> {
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]
Expand Down
2 changes: 2 additions & 0 deletions crates/buckaroo-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ pub fn init<R: Runtime>(config: BuckarooConfig) -> TauriPlugin<R> {
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,
])
Expand Down
81 changes: 74 additions & 7 deletions packages/buckaroo-tauri-adapter/src/TauriIPCModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any>> {
return new Promise((resolve) => {
Expand All @@ -164,10 +173,68 @@ export async function waitForInitialState(): Promise<Record<string, any>> {
});
}

/**
* 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<Promise<Record<string, any>>> {
let resolveFn!: (v: Record<string, any>) => void;
const received = new Promise<Record<string, any>>((res) => {
resolveFn = res;
});
const unlisten = await listen<any>("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 ?? {}) },
});
}
8 changes: 7 additions & 1 deletion packages/buckaroo-tauri-adapter/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export { TauriIPCModel, waitForInitialState, loadPath } from "./TauriIPCModel";
export {
TauriIPCModel,
waitForInitialState,
listenForInitialState,
loadPath,
loadExpr,
} from "./TauriIPCModel";
Loading