From 53e753f0609bc4b5b77bf8fa69fde5c05dfd4ce0 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:30:09 +0000 Subject: [PATCH 1/3] Serve the app's tools to Redrob Code through the app bridge The egui shell now starts `redrob-app-bridge` on a thread once the egui context exists and registers as `app:query` with the shared daemon (optional: a daemon that cannot be reached is logged and the app runs without it). Tool handlers run on the bridge's threads and push requests onto a channel; `QueryApp::serve_bridge` drains it once per frame and runs each request against the real app state (ProfilesPanel, Workspace, tabs), polling the engine's `Pending` answers on the following frames so the UI thread never blocks. Tools: list_connections, connect, list_tables, describe_table, run_sql (TSV, 200 rows by default, summary line with count / time / truncation / rows affected), open_in_editor, export_result (csv | tsv of the last run_sql page, else the active tab's grid). app.rs only gains the bridge field, the start / serve / stop hooks and pub(crate) on the fields and helpers the bridge module shares. READMEs get a "Control from Redrob Code" section in both languages. --- README.ko.md | 16 + README.md | 18 + apps/redrob-query/Cargo.toml | 3 + apps/redrob-query/src/app.rs | 25 +- apps/redrob-query/src/app_bridge.rs | 867 ++++++++++++++++++++++++++++ apps/redrob-query/src/main.rs | 1 + 6 files changed, 921 insertions(+), 9 deletions(-) create mode 100644 apps/redrob-query/src/app_bridge.rs diff --git a/README.ko.md b/README.ko.md index 53eda4d..458d086 100644 --- a/README.ko.md +++ b/README.ko.md @@ -149,6 +149,22 @@ Ask는 Redrob Launcher가 이 컴퓨터를 로그인시킨 계정을 씁니다. 그대로 보여 줍니다. 계정이 없으면 Ask 도크가 그렇다고 말하고 런처를 열어 줍니다. 이 앱에는 자체 로그인이 없습니다. +## Redrob Code에서 조작 + +앱이 열려 있는 동안 자기 도구를 공유 Redrob Code 데몬에 루프백 MCP(`app:query`)로 제공하므로, 어느 Redrob +표면의 에이전트든(휴대폰 채팅 포함) 이 앱을 움직일 수 있습니다. 데몬은 선택 사항이며 없으면 앱은 그대로 +동작합니다. 도구는 짧은 TSV 텍스트로 답하고 패널이 쓰는 엔진 경로를 그대로 지나가므로, 브리지로 들어온 +질의도 탭에 입력한 것과 똑같이 한도와 게이트를 받습니다. + +- `list_connections` - 저장된 연결 목록: id, 이름, 드라이버, 연결 여부. +- `connect {id}` - 저장된 연결을 id 또는 이름으로 연결하고, 올라오면 답합니다. +- `list_tables {connection?, schema?}` - 연결된 데이터베이스의 표·뷰·컬렉션. +- `describe_table {connection?, table}` - 한 표의 열(이름, 타입, null 허용). +- `run_sql {connection?, sql, max_rows?}` - 읽기 전용 문장 하나를 실행. TSV 헤더와 행(기본 200행), 그다음 + 개수·시간·더 있는지 여부를 담은 요약 한 줄. +- `open_in_editor {sql, connection?}` - 실행하지 않고 새 편집기 탭에 텍스트를 넣습니다. +- `export_result {path, format}` - 마지막 `run_sql` 결과(없으면 활성 탭의 그리드)를 CSV 또는 TSV로 씁니다. + ## 현재 경계 - 자유 형식 쓰기 SQL은 없습니다. 변경은 검토한 편집 집합과 표 폼으로만 갑니다. 고치려면 단일 열 기본 키가 diff --git a/README.md b/README.md index 6b95f9f..3a351a9 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,24 @@ environment, then this app's own `account.json`, win over it. The key is sent on refuses. Without an account the Ask dock says so and offers to open the launcher; this app has no sign-in of its own. +## Control from Redrob Code + +While the app is open it serves its tools to the shared Redrob Code daemon over loopback MCP (as +`app:query`), so the agent in any Redrob surface, a phone chat included, can drive it. The daemon is +optional: without one the app runs as before. The tools answer short TSV text and go through the +same engine paths the panels use, so a bridged query is bounded and gated exactly like one typed +into a tab. + +- `list_connections` - the saved connections: id, name, driver, connected. +- `connect {id}` - connect a saved connection by id or name; answers when it is up. +- `list_tables {connection?, schema?}` - tables, views or collections of a connected database. +- `describe_table {connection?, table}` - the columns (name, type, nullable) of one table. +- `run_sql {connection?, sql, max_rows?}` - run one read-only statement; TSV header and rows + (200 by default), then a summary line with the count, the time and whether more rows exist. +- `open_in_editor {sql, connection?}` - put the text in a new editor tab without running it. +- `export_result {path, format}` - write the last `run_sql` result (else the active tab's grid) as + CSV or TSV. + ## Current boundaries - No free-form write SQL: changes go through the reviewed edit set and the table forms. Tables need a diff --git a/apps/redrob-query/Cargo.toml b/apps/redrob-query/Cargo.toml index fea84bf..c301eba 100644 --- a/apps/redrob-query/Cargo.toml +++ b/apps/redrob-query/Cargo.toml @@ -16,6 +16,9 @@ path = "src/main.rs" [dependencies] redrob-query-core = { path = "../../crates/redrob-query-core" } +# The Redrob Code app bridge: serves this app's tools to the shared daemon over loopback MCP. +# TODO pin to the tag of the redrob-code release that ships it +redrob-app-bridge = { git = "https://github.com/redrob-labs/redrob-code", branch = "feat/channels-app-bridge" } eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "default_fonts"] } egui = { workspace = true } egui_extras = { workspace = true } diff --git a/apps/redrob-query/src/app.rs b/apps/redrob-query/src/app.rs index b0603c1..bc30371 100644 --- a/apps/redrob-query/src/app.rs +++ b/apps/redrob-query/src/app.rs @@ -14,6 +14,7 @@ use uuid::Uuid; use crate::account::AccountWatch; use crate::ai_dock::{AiDock, AiEvent}; +use crate::app_bridge::Bridge; use crate::changes::{ChangesEvent, ChangesPanel}; use crate::editor::{QueryTab, TabEvent}; use crate::highlight::Dialect; @@ -72,15 +73,15 @@ const SHORTCUTS: &[(&str, &str)] = &[ ]; pub struct QueryApp { - workspace: Workspace, - profiles: ProfilesPanel, + pub(crate) workspace: Workspace, + pub(crate) profiles: ProfilesPanel, schema: SchemaBrowser, structure: StructurePanel, changes: ChangesPanel, ai: AiDock, account: AccountWatch, - tabs: Vec, - active: usize, + pub(crate) tabs: Vec, + pub(crate) active: usize, history: Vec, show_structure: bool, show_ai: bool, @@ -91,8 +92,10 @@ pub struct QueryApp { status: Option, language: i18n::Language, theme_pref: ThemePref, - tab_counter: usize, + pub(crate) tab_counter: usize, dirty_since_save: bool, + /// The Redrob Code app bridge (`app_bridge.rs`); the app runs without the daemon too. + bridge: Bridge, } impl QueryApp { @@ -118,6 +121,7 @@ impl QueryApp { }); let repaint = ctx.clone(); workspace.set_wake(move || repaint.request_repaint()); + let bridge = Bridge::start(ctx); let mut tabs: Vec = saved .tabs @@ -152,12 +156,13 @@ impl QueryApp { theme_pref, tab_counter, dirty_since_save: false, + bridge, workspace, } } /// Show `text` on the status line (an error in the error colour). - fn notify(&mut self, text: impl Into, is_error: bool) { + pub(crate) fn notify(&mut self, text: impl Into, is_error: bool) { let text = text.into(); if is_error { log::warn!("{text}"); @@ -174,7 +179,7 @@ impl QueryApp { } /// The connection the shell acts on: the active tab's, else the selected one. - fn active_profile(&self) -> Option<&ConnectionProfile> { + pub(crate) fn active_profile(&self) -> Option<&ConnectionProfile> { self.tabs .get(self.active) .and_then(|tab| self.profile(tab.connection_id)) @@ -211,7 +216,7 @@ impl QueryApp { self.dirty_since_save = false; } - fn push_tab(&mut self, mut tab: QueryTab) { + pub(crate) fn push_tab(&mut self, mut tab: QueryTab) { if self.tabs.len() >= crate::workspace::MAX_TABS { self.notify( tf( @@ -228,7 +233,7 @@ impl QueryApp { self.dirty_since_save = true; } - fn open_tab_for(&mut self, profile: &ConnectionProfile, text: Option) { + pub(crate) fn open_tab_for(&mut self, profile: &ConnectionProfile, text: Option) { // Re-use an open tab on this connection that is still its untouched starter. if text.is_none() && let Some(index) = self.tabs.iter().position(|t| { @@ -1275,6 +1280,7 @@ impl QueryApp { impl eframe::App for QueryApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { self.poll(); + self.serve_bridge(); self.handle_shortcuts(ui.ctx()); self.top_bar(ui); @@ -1356,6 +1362,7 @@ impl eframe::App for QueryApp { } fn on_exit(&mut self) { + self.bridge.stop(); self.save_workspace(); } } diff --git a/apps/redrob-query/src/app_bridge.rs b/apps/redrob-query/src/app_bridge.rs new file mode 100644 index 0000000..8494c13 --- /dev/null +++ b/apps/redrob-query/src/app_bridge.rs @@ -0,0 +1,867 @@ +//! The Redrob Code app bridge: this app's tools, served to the shared Redrob Code daemon. +//! +//! `redrob-app-bridge` serves the tools over MCP on loopback and registers them with the daemon, +//! so the agent in any Redrob surface (a phone chat included) can list connections, connect, read +//! the schema, run SQL and put text into an editor tab while the app is open. The daemon is +//! optional: when it cannot be reached the failure is logged and the app runs without the bridge. +//! +//! Shape: the tool handlers run on the bridge's own threads, the app state lives on the UI thread. +//! Every handler pushes a [`BridgeRequest`] onto a queue, asks egui for a repaint and blocks on the +//! reply; once per frame [`QueryApp::serve_bridge`] drains the queue and runs each request against +//! the real app state through the same `Workspace`, `ProfilesPanel` and tab functions the panels +//! use. Engine answers (`Pending`) are polled on the following frames and the reply is sent the +//! moment they land, so a slow database holds up the handler thread, never a paint. + +use std::sync::mpsc::{self, Receiver, Sender}; +use std::time::Duration; + +use redrob_app_bridge::{AppBridge, AppBridgeConfig, Tool}; +use redrob_query_core::{ + ConnectionProfile, DatabaseKind, MetadataKind, MetadataNode, MetadataRequest, Pending, + QueryRequest, QueryResultPage, Workspace, +}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::app::QueryApp; +use crate::editor::QueryTab; +use crate::grid::format_value; +use crate::highlight::Dialect; +use crate::profiles::kind_name; + +/// The name the daemon lists the tools under (`app:query`). +const APP_NAME: &str = "query"; +/// How long a handler waits for the UI thread (and the engine behind it) before giving up. +const REPLY_TIMEOUT: Duration = Duration::from_secs(30); +/// The engine's own deadline for a bridged query: under [`REPLY_TIMEOUT`], so the engine's error +/// reaches the agent before the handler stops waiting. +const QUERY_TIMEOUT_MS: u64 = 25_000; +/// Rows `run_sql` answers with unless `max_rows` says otherwise. +const DEFAULT_MAX_ROWS: usize = 200; +/// The engine's own ceiling on a page. +const MAX_ROWS: usize = 10_000; + +type Reply = Result; + +/// One tool call waiting for the UI thread. +pub struct BridgeRequest { + tool: String, + args: Value, + reply: Sender, +} + +/// The last `run_sql` page, for `export_result`. +struct LastResult { + page: QueryResultPage, + connection: String, +} + +/// A request whose engine answer has not landed yet; polled once per frame. +struct Waiting { + reply: Sender, + job: Job, +} + +enum Job { + /// `connect`: done when the profiles panel reports the connection up (or gone). + Connect { id: Uuid, name: String }, + /// `list_tables` / `describe_table`: one or two metadata loads in a row. + Metadata(MetadataStep), + /// `run_sql`. + Query { + pending: Pending, + limit: usize, + connection: String, + }, +} + +/// A metadata load and what to do with its nodes: answer, or load once more. +struct MetadataStep { + pending: Pending>, + then: Box) -> StepOutcome>, +} + +enum StepOutcome { + Done(Reply), + Next(MetadataStep), +} + +/// What a tool did on the UI thread: answered, or started something to wait for. +enum Outcome { + Reply(Reply), + Wait(Job), +} + +/// The bridge as the app holds it: the registration (once the daemon answered), the request +/// queue, the requests still waiting on the engine and the last result. +pub struct Bridge { + /// The daemon is found or started on a thread so a slow start never delays the window. + starting: Option>>, + handle: Option, + requests: Receiver, + waiting: Vec, + last_result: Option, +} + +impl Bridge { + /// Serve the tools and register with the daemon, off the UI thread. The returned bridge is + /// usable at once; the registration arrives on a later frame (or a logged failure does). + pub fn start(ctx: &egui::Context) -> Self { + let (queue, requests) = mpsc::channel::(); + let tool_list = tools(&queue, ctx); + let (started, starting) = mpsc::channel(); + let spawned = std::thread::Builder::new() + .name("redrob-query-app-bridge".into()) + .spawn(move || { + let result = AppBridge::start(AppBridgeConfig { + version: Some(env!("CARGO_PKG_VERSION").into()), + ..AppBridgeConfig::new(APP_NAME, tool_list) + }); + let _ = started.send(result); + }); + if let Err(error) = spawned { + log::warn!("app bridge not started: {error}"); + } + Self { + starting: Some(starting), + handle: None, + requests, + waiting: Vec::new(), + last_result: None, + } + } + + /// Unregister from the daemon and stop the server. Idempotent. + pub fn stop(&mut self) { + self.starting = None; + if let Some(handle) = self.handle.take() { + handle.stop(); + } + } + + fn poll_start(&mut self) { + let Some(starting) = &self.starting else { + return; + }; + match starting.try_recv() { + Ok(Ok(handle)) => { + log::info!( + "app bridge registered with Redrob Code as app:{APP_NAME} at {}", + handle.mcp_url() + ); + self.handle = Some(handle); + self.starting = None; + } + Ok(Err(error)) => { + log::info!("app bridge unavailable, running without Redrob Code: {error}"); + self.starting = None; + } + Err(mpsc::TryRecvError::Empty) => {} + Err(mpsc::TryRecvError::Disconnected) => self.starting = None, + } + } +} + +impl Drop for Bridge { + fn drop(&mut self) { + self.stop(); + } +} + +// ---- the handler side: push a request, wait for the UI thread ---------------------------------- + +fn call(queue: &Sender, ctx: &egui::Context, tool: &str, args: Value) -> Reply { + let (reply, answer) = mpsc::channel(); + queue + .send(BridgeRequest { + tool: tool.to_owned(), + args, + reply, + }) + .map_err(|_| "Redrob Query is shutting down".to_owned())?; + ctx.request_repaint(); + match answer.recv_timeout(REPLY_TIMEOUT) { + Ok(reply) => reply, + Err(_) => Err(format!( + "{tool}: Redrob Query did not answer within {} s", + REPLY_TIMEOUT.as_secs() + )), + } +} + +fn tool( + queue: &Sender, + ctx: &egui::Context, + name: &'static str, + description: &str, + schema: Value, +) -> Tool { + let queue = queue.clone(); + let ctx = ctx.clone(); + Tool::new(name, description, schema, move |args| { + call(&queue, &ctx, name, args) + }) +} + +/// The `connection` argument every connection-bound tool takes. +fn connection_property() -> Value { + json!({ + "type": "string", + "description": "Connection id or name from list_connections; defaults to the connection the app has active." + }) +} + +fn tools(queue: &Sender, ctx: &egui::Context) -> Vec { + vec![ + tool( + queue, + ctx, + "list_connections", + "List the saved database connections as TSV: id, name, driver (PostgreSQL, MySQL, SQLite, MongoDB), connected (yes/no).", + json!({ "type": "object", "properties": {} }), + ), + tool( + queue, + ctx, + "connect", + "Connect a saved connection by id or name (its password comes from the OS keyring); answers when it is up.", + json!({ + "type": "object", + "properties": { + "id": { "type": "string", "description": "Connection id or name from list_connections." } + }, + "required": ["id"] + }), + ), + tool( + queue, + ctx, + "list_tables", + "List the tables, views or collections of a connected database as TSV (schema, name, kind), optionally one schema only.", + json!({ + "type": "object", + "properties": { + "connection": connection_property(), + "schema": { "type": "string", "description": "Only this schema (PostgreSQL / MySQL)." } + } + }), + ), + tool( + queue, + ctx, + "describe_table", + "Describe one table, view or collection: its columns as TSV (name, type, nullable); for MongoDB the sampled fields.", + json!({ + "type": "object", + "properties": { + "connection": connection_property(), + "table": { "type": "string", "description": "Table name, optionally schema-qualified as schema.table." } + }, + "required": ["table"] + }), + ), + tool( + queue, + ctx, + "run_sql", + "Run one statement on a connected database (SQL, or a Mongo JSON query on MongoDB) and answer the result as TSV: a header row, then at most max_rows rows (default 200), then a summary line with the row count, the time, whether more rows exist and any rows affected.", + json!({ + "type": "object", + "properties": { + "connection": connection_property(), + "sql": { "type": "string", "description": "The statement to run." }, + "max_rows": { "type": "integer", "minimum": 1, "maximum": MAX_ROWS, "description": "Rows to return at most (default 200)." } + }, + "required": ["sql"] + }), + ), + tool( + queue, + ctx, + "open_in_editor", + "Open a new editor tab in Redrob Query with this text, bound to the given (or active) connection, without running it.", + json!({ + "type": "object", + "properties": { + "sql": { "type": "string", "description": "The text to put in the new tab." }, + "connection": connection_property() + }, + "required": ["sql"] + }), + ), + tool( + queue, + ctx, + "export_result", + "Write the last run_sql result (or, when there is none, the active tab's result grid) to a file as CSV or TSV with a header row; answers the path and the row count.", + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Where to write the file." }, + "format": { "type": "string", "enum": ["csv", "tsv"], "description": "csv or tsv (default csv)." } + }, + "required": ["path"] + }), + ), + ] +} + +// ---- the UI side: run requests against the app state -------------------------------------------- + +impl QueryApp { + /// Once per frame, after the panels have polled: pick up the registration, run the requests + /// that arrived, and answer the ones whose engine result has landed. + pub(crate) fn serve_bridge(&mut self) { + self.bridge.poll_start(); + while let Ok(request) = self.bridge.requests.try_recv() { + let outcome = self.bridge_dispatch(&request.tool, &request.args); + match outcome { + Outcome::Reply(reply) => { + let _ = request.reply.send(reply); + } + Outcome::Wait(job) => self.bridge.waiting.push(Waiting { + reply: request.reply, + job, + }), + } + } + let waiting = std::mem::take(&mut self.bridge.waiting); + for Waiting { reply, job } in waiting { + match self.bridge_advance(job) { + Ok(answer) => { + let _ = reply.send(answer); + } + Err(job) => self.bridge.waiting.push(Waiting { reply, job }), + } + } + } + + fn bridge_dispatch(&mut self, tool: &str, args: &Value) -> Outcome { + match tool { + "list_connections" => Outcome::Reply(Ok(self.bridge_list_connections())), + "connect" => self.bridge_connect(args), + "list_tables" => self.bridge_list_tables(args), + "describe_table" => self.bridge_describe_table(args), + "run_sql" => self.bridge_run_sql(args), + "open_in_editor" => Outcome::Reply(self.bridge_open_in_editor(args)), + "export_result" => Outcome::Reply(self.bridge_export_result(args)), + other => Outcome::Reply(Err(format!("unknown tool {other}"))), + } + } + + /// Advance one waiting job: `Ok` with the answer when it is done, `Err` with the job otherwise. + fn bridge_advance(&mut self, job: Job) -> Result { + match job { + Job::Connect { id, name } => { + if self.profiles.is_connected(id) { + Ok(Ok(format!("connected: {name} ({id})"))) + } else if self.profiles.is_connecting(id) { + Err(Job::Connect { id, name }) + } else { + let why = self + .profiles + .status + .get(&id) + .map(|s| s.message.clone()) + .unwrap_or_else(|| "see the app's status line".to_owned()); + Ok(Err(format!("could not connect {name}: {why}"))) + } + } + Job::Metadata(mut step) => match step.pending.poll() { + None => Err(Job::Metadata(step)), + Some(Err(error)) => Ok(Err(error.to_string())), + Some(Ok(nodes)) => match (step.then)(&self.workspace, nodes) { + StepOutcome::Done(reply) => Ok(reply), + StepOutcome::Next(next) => Err(Job::Metadata(next)), + }, + }, + Job::Query { + mut pending, + limit, + connection, + } => match pending.poll() { + None => Err(Job::Query { + pending, + limit, + connection, + }), + Some(Err(error)) => Ok(Err(error.to_string())), + Some(Ok(page)) => { + let text = page_as_tsv(&page, limit, &connection); + self.notify( + format!( + "Redrob Code ran a query on {connection}: {} rows in {} ms", + page.stats.rows_returned, page.stats.elapsed_ms + ), + false, + ); + self.bridge.last_result = Some(LastResult { page, connection }); + Ok(Ok(text)) + } + }, + } + } + + /// The profile a tool means: `args[key]` as an id or a name, else the app's active one, else + /// the one connection that is up. + fn bridge_resolve(&self, args: &Value, key: &str) -> Result { + let wanted = args.get(key).and_then(Value::as_str).map(str::trim); + if let Some(wanted) = wanted.filter(|w| !w.is_empty()) { + let by_id = wanted.parse::().ok(); + return self + .profiles + .profiles + .iter() + .find(|p| by_id == Some(p.id) || p.name.eq_ignore_ascii_case(wanted)) + .cloned() + .ok_or_else(|| { + let known: Vec<&str> = self + .profiles + .profiles + .iter() + .map(|p| p.name.as_str()) + .collect(); + format!( + "no connection with id or name {wanted}; known: {}", + known.join(", ") + ) + }); + } + self.active_profile() + .cloned() + .or_else(|| { + self.profiles + .profiles + .iter() + .find(|p| self.profiles.is_connected(p.id)) + .cloned() + }) + .ok_or_else(|| { + "no connection is active; pass connection (an id or name from list_connections)" + .to_owned() + }) + } + + /// Like [`Self::bridge_resolve`], and the connection must be up. + fn bridge_resolve_connected( + &self, + args: &Value, + key: &str, + ) -> Result { + let profile = self.bridge_resolve(args, key)?; + if !self.profiles.is_connected(profile.id) { + return Err(format!( + "{} is not connected; call connect with id {} first", + profile.name, profile.id + )); + } + Ok(profile) + } + + fn bridge_list_connections(&self) -> String { + let mut out = String::from("id\tname\tdriver\tconnected\n"); + for profile in &self.profiles.profiles { + out.push_str(&format!( + "{}\t{}\t{}\t{}\n", + profile.id, + tsv_field(&profile.name), + kind_name(profile.kind), + if self.profiles.is_connected(profile.id) { + "yes" + } else { + "no" + } + )); + } + out.push_str(&format!("-- {} connections", self.profiles.profiles.len())); + out + } + + fn bridge_connect(&mut self, args: &Value) -> Outcome { + let profile = match self.bridge_resolve(args, "id") { + Ok(p) => p, + Err(e) => return Outcome::Reply(Err(e)), + }; + if !profile.capabilities.connect { + return Outcome::Reply(Err(profile + .capabilities + .unsupported_reason + .clone() + .unwrap_or_else(|| format!("{} cannot be connected", profile.name)))); + } + if self.profiles.is_connected(profile.id) { + return Outcome::Reply(Ok(format!( + "already connected: {} ({})", + profile.name, profile.id + ))); + } + self.profiles.connect(&self.workspace, profile.id); + Outcome::Wait(Job::Connect { + id: profile.id, + name: profile.name, + }) + } + + fn bridge_list_tables(&mut self, args: &Value) -> Outcome { + let profile = match self.bridge_resolve_connected(args, "connection") { + Ok(p) => p, + Err(e) => return Outcome::Reply(Err(e)), + }; + let schema = args + .get("schema") + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + let name = profile.name.clone(); + let pending = self.workspace.load_metadata(MetadataRequest { + connection_id: profile.id, + parent_id: None, + }); + Outcome::Wait(Job::Metadata(MetadataStep { + pending, + then: Box::new(move |_: &Workspace, nodes: Vec| { + let mut out = String::from("schema\tname\tkind\n"); + let mut count = 0; + for node in nodes.iter().filter(|n| { + schema.as_ref().is_none_or(|s| { + n.attributes + .get("schema") + .is_some_and(|have| have.eq_ignore_ascii_case(s)) + }) + }) { + count += 1; + out.push_str(&format!( + "{}\t{}\t{}\n", + node.attributes.get("schema").map_or("", String::as_str), + tsv_field(&node.name), + kind_label(node.kind) + )); + } + out.push_str(&format!("-- {count} on {name}")); + if let Some(schema) = &schema { + out.push_str(&format!(" in schema {schema}")); + } + StepOutcome::Done(Ok(out)) + }), + })) + } + + fn bridge_describe_table(&mut self, args: &Value) -> Outcome { + let profile = match self.bridge_resolve_connected(args, "connection") { + Ok(p) => p, + Err(e) => return Outcome::Reply(Err(e)), + }; + let Some(table) = args + .get("table") + .and_then(Value::as_str) + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned) + else { + return Outcome::Reply(Err("table is required".to_owned())); + }; + let connection_id = profile.id; + let kind = profile.kind; + // The roots first (to find the node the engine addresses the table by), then its columns. + let pending = self.workspace.load_metadata(MetadataRequest { + connection_id, + parent_id: None, + }); + Outcome::Wait(Job::Metadata(MetadataStep { + pending, + then: Box::new(move |workspace: &Workspace, roots: Vec| { + let node = match find_table(&roots, &table, kind) { + Ok(node) => node, + Err(error) => return StepOutcome::Done(Err(error)), + }; + let pending = workspace.load_metadata(MetadataRequest { + connection_id, + parent_id: Some(node.id.clone()), + }); + StepOutcome::Next(MetadataStep { + pending, + then: Box::new(move |_: &Workspace, columns: Vec| { + let qualified = match node.attributes.get("schema") { + Some(schema) if !schema.is_empty() => { + format!("{schema}.{}", node.name) + } + _ => node.name.clone(), + }; + let mut out = format!( + "{qualified} ({})\nname\ttype\tnullable\n", + kind_label(node.kind) + ); + for column in &columns { + out.push_str(&format!( + "{}\t{}\t{}\n", + tsv_field(&column.name), + column.data_type.as_deref().unwrap_or(""), + match column.nullable { + Some(true) => "yes", + Some(false) => "no", + None => "", + } + )); + } + out.push_str(&format!("-- {} columns", columns.len())); + StepOutcome::Done(Ok(out)) + }), + }) + }), + })) + } + + fn bridge_run_sql(&mut self, args: &Value) -> Outcome { + let profile = match self.bridge_resolve_connected(args, "connection") { + Ok(p) => p, + Err(e) => return Outcome::Reply(Err(e)), + }; + let Some(sql) = args + .get("sql") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) + else { + return Outcome::Reply(Err("sql is required".to_owned())); + }; + let limit = args + .get("max_rows") + .and_then(Value::as_u64) + .map_or(DEFAULT_MAX_ROWS, |n| { + usize::try_from(n).unwrap_or(MAX_ROWS).clamp(1, MAX_ROWS) + }); + let pending = self.workspace.execute_query(QueryRequest { + connection_id: profile.id, + query: sql, + language: Dialect::for_kind(profile.kind).language(), + parameters: Vec::new(), + limit, + offset: 0, + timeout_ms: QUERY_TIMEOUT_MS, + }); + Outcome::Wait(Job::Query { + pending, + limit, + connection: profile.name, + }) + } + + fn bridge_open_in_editor(&mut self, args: &Value) -> Reply { + let Some(text) = args + .get("sql") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()) + .map(str::to_owned) + else { + return Err("sql is required".to_owned()); + }; + let before = self.tabs.len(); + match self.bridge_resolve(args, "connection") { + Ok(profile) => self.open_tab_for(&profile, Some(text)), + Err(_) => { + // No connection to bind: a plain SQL tab the person picks a connection for. + self.tab_counter += 1; + let title = format!("Query {}", self.tab_counter); + self.push_tab(QueryTab::new(title, text, None)); + } + } + if self.tabs.len() == before { + return Err("could not open a tab: see the app's status line".to_owned()); + } + let tab = &self.tabs[self.active]; + let connection = tab + .connection_id + .and_then(|id| self.profiles.profile(id)) + .map_or("no connection", |p| p.name.as_str()); + Ok(format!( + "opened tab {} \"{}\" on {connection}", + self.active + 1, + tab.title + )) + } + + fn bridge_export_result(&mut self, args: &Value) -> Reply { + let Some(path) = args + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|p| !p.is_empty()) + else { + return Err("path is required".to_owned()); + }; + let format = args + .get("format") + .and_then(Value::as_str) + .unwrap_or("csv") + .trim() + .to_ascii_lowercase(); + let separator = match format.as_str() { + "csv" => ',', + "tsv" => '\t', + other => return Err(format!("format must be csv or tsv, not {other}")), + }; + let (page, source) = match &self.bridge.last_result { + Some(last) => (&last.page, format!("run_sql on {}", last.connection)), + None => match self.tabs.get(self.active).and_then(|t| t.result.as_ref()) { + Some(grid) => ( + &grid.page, + format!("tab \"{}\"", self.tabs[self.active].title), + ), + None => { + return Err( + "nothing to export: run_sql first, or run a query in the active tab" + .to_owned(), + ); + } + }, + }; + let text = page_as_delimited(page, separator); + std::fs::write(path, text).map_err(|error| format!("could not write {path}: {error}"))?; + let rows = page.rows.len(); + let columns = page.columns.len(); + self.notify(format!("Redrob Code exported {rows} rows to {path}"), false); + Ok(format!( + "wrote {rows} rows x {columns} columns ({source}) as {format} to {path}" + )) + } +} + +// ---- text helpers ------------------------------------------------------------------------------ + +fn kind_label(kind: MetadataKind) -> &'static str { + match kind { + MetadataKind::Server => "server", + MetadataKind::Database => "database", + MetadataKind::Schema => "schema", + MetadataKind::Table => "table", + MetadataKind::View => "view", + MetadataKind::Collection => "collection", + MetadataKind::Column => "column", + MetadataKind::Index => "index", + } +} + +/// The root node `table` names: `schema.name` or `name`, case-insensitive when nothing matches +/// exactly; an unqualified name that several schemas have is refused with the candidates. +fn find_table( + roots: &[MetadataNode], + table: &str, + kind: DatabaseKind, +) -> Result { + let (schema, name) = match kind { + DatabaseKind::PostgreSql | DatabaseKind::MySql => match table.split_once('.') { + Some((schema, name)) => (Some(schema.trim()), name.trim()), + None => (None, table), + }, + _ => (None, table), + }; + let unquote = |s: &str| s.trim_matches(['"', '`', '[', ']']).to_owned(); + let (schema, name) = (schema.map(unquote), unquote(name)); + let matching = |exact: bool| -> Vec<&MetadataNode> { + roots + .iter() + .filter(|node| { + let name_ok = if exact { + node.name == name + } else { + node.name.eq_ignore_ascii_case(&name) + }; + let schema_ok = schema.as_ref().is_none_or(|s| { + node.attributes.get("schema").is_some_and(|have| { + if exact { + have == s + } else { + have.eq_ignore_ascii_case(s) + } + }) + }); + name_ok && schema_ok + }) + .collect() + }; + let mut found = matching(true); + if found.is_empty() { + found = matching(false); + } + match found.as_slice() { + [] => Err(format!( + "no table, view or collection named {table}; list_tables shows them" + )), + [one] => Ok((*one).clone()), + many => { + let names: Vec = many + .iter() + .map(|n| match n.attributes.get("schema") { + Some(s) => format!("{s}.{}", n.name), + None => n.name.clone(), + }) + .collect(); + Err(format!( + "{table} is ambiguous; qualify it as one of: {}", + names.join(", ") + )) + } + } +} + +/// One TSV field: tabs and line breaks escaped so a row stays one line. +fn tsv_field(text: &str) -> String { + text.replace('\t', "\\t") + .replace('\r', "") + .replace('\n', "\\n") +} + +/// One CSV field: quoted when it holds the separator, a quote or a line break. +fn csv_field(text: &str) -> String { + if text.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", text.replace('"', "\"\"")) + } else { + text.to_owned() + } +} + +/// The page as TSV with a header, then the summary line the agent reads the outcome from. +fn page_as_tsv(page: &QueryResultPage, limit: usize, connection: &str) -> String { + let mut out = page_as_delimited(page, '\t'); + let returned = page.stats.rows_returned; + if page.stats.truncated { + out.push_str(&format!( + "-- first {returned} rows in {} ms on {connection}; more rows exist (truncated at max_rows {limit}, up to {MAX_ROWS})", + page.stats.elapsed_ms + )); + } else { + out.push_str(&format!( + "-- {returned} rows in {} ms on {connection}; complete", + page.stats.elapsed_ms + )); + } + if page.stats.rows_affected > 0 { + out.push_str(&format!("; {} rows affected", page.stats.rows_affected)); + } + out +} + +/// The page as delimited text with a header row, every row on one line. +fn page_as_delimited(page: &QueryResultPage, separator: char) -> String { + let field = |text: &str| -> String { + if separator == '\t' { + tsv_field(text) + } else { + csv_field(text) + } + }; + let sep = separator.to_string(); + let mut out = String::new(); + if !page.columns.is_empty() { + let header: Vec = page.columns.iter().map(|c| field(&c.name)).collect(); + out.push_str(&header.join(sep.as_str())); + out.push('\n'); + } + for row in &page.rows { + let cells: Vec = row.iter().map(|v| field(&format_value(v))).collect(); + out.push_str(&cells.join(sep.as_str())); + out.push('\n'); + } + out +} diff --git a/apps/redrob-query/src/main.rs b/apps/redrob-query/src/main.rs index 345d6e1..f08f07d 100644 --- a/apps/redrob-query/src/main.rs +++ b/apps/redrob-query/src/main.rs @@ -17,6 +17,7 @@ mod account; mod ai_dock; mod app; +mod app_bridge; mod changes; mod editor; mod grid; From 38f43eede0d2e767fc04b2bb4271e1390c85b4fc Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:53:02 +0000 Subject: [PATCH 2/3] build: pin redrob-app-bridge to redrob-code v0.7.0 --- apps/redrob-query/Cargo.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/redrob-query/Cargo.toml b/apps/redrob-query/Cargo.toml index c301eba..dd4d90e 100644 --- a/apps/redrob-query/Cargo.toml +++ b/apps/redrob-query/Cargo.toml @@ -17,8 +17,7 @@ path = "src/main.rs" [dependencies] redrob-query-core = { path = "../../crates/redrob-query-core" } # The Redrob Code app bridge: serves this app's tools to the shared daemon over loopback MCP. -# TODO pin to the tag of the redrob-code release that ships it -redrob-app-bridge = { git = "https://github.com/redrob-labs/redrob-code", branch = "feat/channels-app-bridge" } +redrob-app-bridge = { git = "https://github.com/redrob-labs/redrob-code", tag = "v0.7.0" } eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "default_fonts"] } egui = { workspace = true } egui_extras = { workspace = true } From da02ef78da140a94de67a815342efd8373174f86 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:26:54 +0000 Subject: [PATCH 3/3] Make the app bridge pass the repo gate Lock redrob-app-bridge (redrob-code v0.7.0) and its dependencies into Cargo.lock, expose QueryApp.bridge to the crate so main.rs can stop it, name the metadata-step continuation type and use map_or_else, as clippy -D warnings asks. --- Cargo.lock | 124 ++++++++++++++++++++++++++++ apps/redrob-query/src/app.rs | 2 +- apps/redrob-query/src/app_bridge.rs | 15 ++-- 3 files changed, 133 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 941eaa3..c27e2c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -268,6 +268,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "ash" version = "0.38.0+1.3.281" @@ -757,6 +763,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clipboard-win" version = "5.4.1" @@ -1167,6 +1179,27 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "dispatch" version = "0.2.0" @@ -2171,6 +2204,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.15" @@ -3389,6 +3428,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "orbclient" version = "0.3.55" @@ -3916,6 +3961,41 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "redrob-app-bridge" +version = "0.1.0" +source = "git+https://github.com/redrob-labs/redrob-code?tag=v0.7.0#31a7dd330c33961f506a7346ad1fe9943691a6f5" +dependencies = [ + "log", + "redrob-engine-client", + "serde", + "serde_json", + "tiny_http", +] + +[[package]] +name = "redrob-engine-client" +version = "0.1.0" +source = "git+https://github.com/redrob-labs/redrob-code?tag=v0.7.0#31a7dd330c33961f506a7346ad1fe9943691a6f5" +dependencies = [ + "base64 0.22.1", + "dirs", + "log", + "serde", + "serde_json", + "ureq", +] + [[package]] name = "redrob-query" version = "0.4.0" @@ -3926,6 +4006,7 @@ dependencies = [ "egui_extras", "env_logger", "log", + "redrob-app-bridge", "redrob-query-core", "rfd", "serde", @@ -5047,6 +5128,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5369,6 +5462,31 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5382,6 +5500,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/apps/redrob-query/src/app.rs b/apps/redrob-query/src/app.rs index bc30371..4351d6f 100644 --- a/apps/redrob-query/src/app.rs +++ b/apps/redrob-query/src/app.rs @@ -95,7 +95,7 @@ pub struct QueryApp { pub(crate) tab_counter: usize, dirty_since_save: bool, /// The Redrob Code app bridge (`app_bridge.rs`); the app runs without the daemon too. - bridge: Bridge, + pub(crate) bridge: Bridge, } impl QueryApp { diff --git a/apps/redrob-query/src/app_bridge.rs b/apps/redrob-query/src/app_bridge.rs index 8494c13..6dd584f 100644 --- a/apps/redrob-query/src/app_bridge.rs +++ b/apps/redrob-query/src/app_bridge.rs @@ -75,10 +75,13 @@ enum Job { }, } +/// What a metadata step does with the nodes it loaded. +type MetadataThen = Box) -> StepOutcome>; + /// A metadata load and what to do with its nodes: answer, or load once more. struct MetadataStep { pending: Pending>, - then: Box) -> StepOutcome>, + then: MetadataThen, } enum StepOutcome { @@ -358,12 +361,10 @@ impl QueryApp { } else if self.profiles.is_connecting(id) { Err(Job::Connect { id, name }) } else { - let why = self - .profiles - .status - .get(&id) - .map(|s| s.message.clone()) - .unwrap_or_else(|| "see the app's status line".to_owned()); + let why = self.profiles.status.get(&id).map_or_else( + || "see the app's status line".to_owned(), + |s| s.message.clone(), + ); Ok(Err(format!("could not connect {name}: {why}"))) } }