diff --git a/Cargo.lock b/Cargo.lock index af5c4a0..b423af4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -886,10 +886,14 @@ name = "chat2db-cli" version = "0.1.0" dependencies = [ "chat2db-contract", + "chat2db-core", "chat2db-local", + "chat2db-runtime", "clap", "serde_json", + "tempfile", "tokio", + "tracing-subscriber", ] [[package]] @@ -953,8 +957,8 @@ dependencies = [ "cap-std", "chat2db-contract", "chat2db-core", - "chat2db-java-bridge", "chat2db-local", + "chat2db-runtime", "chat2db-web", "encoding_rs", "serde", @@ -1048,6 +1052,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "chat2db-runtime" +version = "0.1.0" +dependencies = [ + "chat2db-core", + "chat2db-java-bridge", + "tempfile", + "thiserror 2.0.19", +] + [[package]] name = "chat2db-storage" version = "0.1.0" @@ -1080,6 +1094,7 @@ dependencies = [ "chat2db-core", "chat2db-java-bridge", "chat2db-local", + "chat2db-runtime", "chat2db-storage", "chrono", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index bdd564b..0254c28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/chat2db-java-bridge", "crates/chat2db-local", "crates/chat2db-local-ipc-windows", + "crates/chat2db-runtime", "crates/chat2db-storage", ] diff --git a/README.md b/README.md index 1adb6a9..c6c5f1e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,9 @@ for every platform supported by Chat2DB Community: - Windows x86_64, with NSIS `.exe` and MSI installers; and - Linux x86_64 and ARM64, with AppImage, `.deb`, and `.rpm` artifacts. +Every desktop package embeds the matching `chat2db` headless CLI beside the +shared Java, Community-classpath, and driver-pack resources. + macOS builds are ad-hoc signed for test packages by default. A manual run with `publish_authorized_artifact=true` enables the configured Developer ID signing and notarization path. @@ -201,8 +204,10 @@ the driver exposes their type, and the project does not fork or vendor the upstream crate. Stage 6 and the Stage 7A-7M foundations are complete. Web and desktop own the -product runtime and publish its owner-only local endpoint; CLI and MCP attach to -that host and never contact Java directly. The pinned Community frontend's +product runtime and publish its owner-only local endpoint. The CLI first +attaches to either host and automatically starts the same Rust core in a +headless process when no host is available; MCP remains attachment-only. Neither +adapter contacts Java directly. The pinned Community frontend's complete MySQL workbench surface is mapped through the shared Axum/Tauri legacy dispatcher. Native MySQL connections, metadata, Console, mutations, transfer, class generation, accounts, schema diff, chart refresh, and workspace operations @@ -244,6 +249,7 @@ React / TypeScript CLI / MCP client -> owner-only local attachment \ / -> Rust product runtime + CLI may start this headlessly -> framed Protobuf IPC -> private Java compatibility engine -> Chat2DB plugins + JDBC + Java ANTLR @@ -401,11 +407,17 @@ or modify the `third_party/chat2db-community` submodule worktree. `CHAT2DB_BIND` also requires `CHAT2DB_ACCESS_TOKEN` with at least 32 bytes. The running Web or desktop host also publishes the owner-only local endpoint -used by CLI and MCP. Point either adapter at the same profile explicitly when -the default data directory is not used: +used by CLI and MCP. The CLI defaults to `--host auto`: it reuses that endpoint +when available, otherwise launches `runtime serve` without Tauri, WebKit, or an +HTTP listener. The automatically started host exits after 60 idle seconds and +stays alive while requests or database operations are active. Use +`--host attach` to require an already-running host. Point either adapter at the +same profile explicitly when the default data directory is not used: ```bash cargo run -p chat2db-cli -- --data-dir /path/to/profile datasources +cargo run -p chat2db-cli -- --host attach --data-dir /path/to/profile status +cargo run -p chat2db-cli -- --data-dir /path/to/profile runtime serve cargo run -p chat2db-mcp -- --data-dir /path/to/profile ``` @@ -432,6 +444,7 @@ crates/ chat2db-java-bridge/ supervised Java process client chat2db-local/ owner-only CLI/MCP attachment protocol chat2db-local-ipc-windows/ Windows named-pipe and ACL implementation + chat2db-runtime/ shared packaged-resource and runtime configuration chat2db-storage/ SQLite state, secret references, and retained results proto/ canonical Rust-Java process schema java/ diff --git a/apps/chat2db-cli/Cargo.toml b/apps/chat2db-cli/Cargo.toml index 1a19c79..781cc9a 100644 --- a/apps/chat2db-cli/Cargo.toml +++ b/apps/chat2db-cli/Cargo.toml @@ -14,10 +14,16 @@ path = "src/main.rs" [dependencies] chat2db-contract = { path = "../../crates/chat2db-contract" } +chat2db-core = { path = "../../crates/chat2db-core" } chat2db-local = { path = "../../crates/chat2db-local" } +chat2db-runtime = { path = "../../crates/chat2db-runtime" } clap.workspace = true serde_json.workspace = true tokio.workspace = true +tracing-subscriber.workspace = true + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/apps/chat2db-cli/src/lib.rs b/apps/chat2db-cli/src/lib.rs new file mode 100644 index 0000000..3cfbc4b --- /dev/null +++ b/apps/chat2db-cli/src/lib.rs @@ -0,0 +1,573 @@ +//! Headless `Chat2DB` delivery mode and command-line client. + +use std::{ + env, + ffi::OsString, + path::PathBuf, + process::{Child, Command as ProcessCommand, Stdio}, + time::Duration, +}; + +use chat2db_contract::{ + DatabaseWriteState, ExecuteDatabaseWriteRequest, QueryLimits, ResultPageRequest, + StartQueryRequest, +}; +use chat2db_core::{Application, RuntimeHost}; +use chat2db_local::{LocalClient, LocalServer}; +use chat2db_runtime::{DATA_DIR_ENV, RuntimeOptions, runtime_config_from_environment}; +use clap::{Parser, Subcommand, ValueEnum}; +use tokio::time::Instant; +use tracing_subscriber::EnvFilter; + +const RUNTIME_IDLE_SECONDS_ENV: &str = "CHAT2DB_CLI_RUNTIME_IDLE_SECONDS"; +const DEFAULT_RUNTIME_IDLE_SECONDS: u64 = 60; +const RUNTIME_START_TIMEOUT: Duration = Duration::from_secs(15); +const COMPETING_RUNTIME_GRACE: Duration = Duration::from_secs(2); +const RUNTIME_PROBE_INTERVAL: Duration = Duration::from_millis(50); +const IDLE_PROBE_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Debug, Parser)] +#[command(name = "chat2db", version, about = "Chat2DB Rust command line")] +struct Cli { + /// Override the per-user `Chat2DB` data directory. + #[arg(long, global = true)] + data_dir: Option, + /// Attach only, or automatically start a headless Rust host when needed. + #[arg(long, global = true, value_enum, default_value_t = HostMode::Auto)] + host: HostMode, + #[command(subcommand)] + command: Command, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum HostMode { + Auto, + Attach, +} + +#[derive(Debug, Subcommand)] +enum Command { + /// Print health from the local product host. + Status, + /// List secret-free datasource metadata. + Datasources, + /// Start, inspect, or cancel a forced-read-only database query. + Query { + #[command(subcommand)] + command: QueryCommand, + }, + /// Execute one explicitly confirmed database write statement. + Write { + #[command(subcommand)] + command: WriteCommand, + }, + /// Read one bounded page from a retained query result. + Result { + result_id: String, + #[arg(long, default_value_t = 0)] + offset: u64, + #[arg(long, default_value_t = 100)] + max_rows: u32, + #[arg(long, default_value_t = 262_144)] + max_bytes: u64, + }, + /// Run the shared Rust core without Tauri, `WebKit`, or an HTTP listener. + Runtime { + #[command(subcommand)] + command: RuntimeCommand, + }, +} + +#[derive(Debug, Subcommand)] +enum RuntimeCommand { + /// Serve the owner-only local attachment until interrupted or idle. + Serve { + #[arg(long, default_value_t = DEFAULT_RUNTIME_IDLE_SECONDS)] + idle_seconds: u64, + #[arg(long, hide = true)] + background_child: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum QueryCommand { + /// Start a forced-read-only query and return its operation id. + Start { + #[arg(long)] + datasource_id: String, + #[arg(long)] + sql: String, + #[arg(long, default_value_t = 10_000)] + max_rows: u64, + #[arg(long, default_value_t = 16_777_216)] + max_result_bytes: u64, + #[arg(long, default_value_t = 900)] + result_ttl_seconds: u32, + }, + /// Read the current state of a query operation. + Status { operation_id: String }, + /// Request idempotent cancellation of a query operation. + Cancel { operation_id: String }, +} + +#[derive(Debug, Subcommand)] +enum WriteCommand { + /// Execute exactly one write. Only `not_started` is safe to retry after correction. + Execute { + #[arg(long)] + datasource_id: String, + #[arg(long)] + sql: String, + /// Explicitly confirm that this statement may change the database. + #[arg(long)] + confirm_write: bool, + }, +} + +/// Parses process arguments and runs either the CLI client or headless host. +/// +/// # Errors +/// +/// Returns command-line, runtime configuration, transport, or product errors. +pub fn run() -> Result<(), Box> { + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(run_cli(Cli::parse())) +} + +async fn run_cli(cli: Cli) -> Result<(), Box> { + let data_dir = attachment_data_dir(cli.data_dir, env::var_os(DATA_DIR_ENV))?; + let client = match data_dir { + Some(path) => LocalClient::new(path), + None => LocalClient::discover_default()?, + }; + + match cli.command { + Command::Runtime { command } => match command { + RuntimeCommand::Serve { + idle_seconds, + background_child, + } => serve_runtime(client, idle_seconds, background_child).await, + }, + command => { + if cli.host == HostMode::Auto { + ensure_runtime(&client).await?; + } + execute_command(client, command).await + } + } +} + +async fn execute_command( + client: LocalClient, + command: Command, +) -> Result<(), Box> { + let mut command_succeeded = true; + let output = match command { + Command::Status => serde_json::to_value(client.health().await?)?, + Command::Datasources => serde_json::to_value(client.list_datasources().await?)?, + Command::Query { command } => match command { + QueryCommand::Start { + datasource_id, + sql, + max_rows, + max_result_bytes, + result_ttl_seconds, + } => serde_json::to_value( + client + .start_read_query(StartQueryRequest { + datasource_id, + sql, + parameters: Vec::new(), + limits: QueryLimits { + max_rows: max_rows.to_string(), + max_result_bytes: max_result_bytes.to_string(), + batch_rows: 256, + batch_bytes: 1024 * 1024, + result_ttl_seconds, + }, + }) + .await?, + )?, + QueryCommand::Status { operation_id } => { + serde_json::to_value(client.operation_snapshot(operation_id).await?)? + } + QueryCommand::Cancel { operation_id } => { + serde_json::to_value(client.cancel_operation(operation_id).await?)? + } + }, + Command::Write { command } => match command { + WriteCommand::Execute { + datasource_id, + sql, + confirm_write, + } => { + let result = client + .execute_database_write(ExecuteDatabaseWriteRequest { + datasource_id, + sql, + confirmed: confirm_write, + }) + .await; + command_succeeded = result.state == DatabaseWriteState::Succeeded; + serde_json::to_value(result)? + } + }, + Command::Result { + result_id, + offset, + max_rows, + max_bytes, + } => serde_json::to_value( + client + .result_page( + result_id, + ResultPageRequest { + offset: offset.to_string(), + max_rows: max_rows.to_string(), + max_bytes: max_bytes.to_string(), + }, + ) + .await?, + )?, + Command::Runtime { .. } => unreachable!("runtime commands are dispatched before attach"), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + if !command_succeeded { + return Err(std::io::Error::other("database write did not succeed").into()); + } + Ok(()) +} + +async fn ensure_runtime(client: &LocalClient) -> Result<(), Box> { + if client.health().await.is_ok() { + return Ok(()); + } + + let idle_seconds = runtime_idle_seconds(env::var_os(RUNTIME_IDLE_SECONDS_ENV))?; + let mut child = spawn_runtime_process(client, idle_seconds)?; + let deadline = Instant::now() + RUNTIME_START_TIMEOUT; + let mut child_exit = None; + loop { + if client.health().await.is_ok() { + return Ok(()); + } + if child_exit.is_none() { + child_exit = child.try_wait()?.map(|status| (status, Instant::now())); + } + if let Some((status, exited_at)) = child_exit.as_ref() + && exited_at.elapsed() >= COMPETING_RUNTIME_GRACE + { + return Err(std::io::Error::other(format!( + "headless Chat2DB runtime exited before becoming ready: {status}" + )) + .into()); + } + if Instant::now() >= deadline { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "headless Chat2DB runtime did not become ready within 15 seconds", + ) + .into()); + } + tokio::time::sleep(RUNTIME_PROBE_INTERVAL).await; + } +} + +fn spawn_runtime_process( + client: &LocalClient, + idle_seconds: u64, +) -> Result> { + let executable = env::current_exe()?; + let mut command = ProcessCommand::new(executable); + command + .arg("--data-dir") + .arg(client.data_dir()) + .arg("runtime") + .arg("serve") + .arg("--idle-seconds") + .arg(idle_seconds.to_string()) + .arg("--background-child") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + configure_background_process(&mut command); + command.spawn().map_err(Into::into) +} + +#[cfg(unix)] +fn configure_background_process(command: &mut ProcessCommand) { + use std::os::unix::process::CommandExt as _; + command.process_group(0); +} + +#[cfg(windows)] +fn configure_background_process(command: &mut ProcessCommand) { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(any(unix, windows)))] +fn configure_background_process(_command: &mut ProcessCommand) {} + +async fn serve_runtime( + client: LocalClient, + idle_seconds: u64, + background_child: bool, +) -> Result<(), Box> { + if idle_seconds == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "runtime idle timeout must be greater than zero", + ) + .into()); + } + initialize_runtime_logging(background_child); + + let executable = env::current_exe().ok(); + let config = runtime_config_from_environment(RuntimeOptions { + data_dir: Some(client.data_dir().to_path_buf()), + executable: executable.as_deref(), + resource_dir: None, + })?; + let mut host = RuntimeHost::open(config).await?; + let application = host.application(); + let mut server = LocalServer::start(application.clone())?; + let idle_timeout = Duration::from_secs(idle_seconds); + + tokio::select! { + () = shutdown_signal() => {} + () = wait_for_idle(&application, &server, idle_timeout) => {} + } + + application.begin_shutdown().await; + let local_result = server.shutdown().await; + let host_result = host.shutdown().await; + local_result?; + host_result?; + Ok(()) +} + +async fn wait_for_idle(application: &Application, server: &LocalServer, timeout: Duration) { + let mut observed_revision = server.activity_revision(); + let mut idle_since = Instant::now(); + loop { + tokio::time::sleep(IDLE_PROBE_INTERVAL.min(timeout)).await; + let revision = server.activity_revision(); + let active_operations = application.active_operation_count().await; + if revision != observed_revision + || server.active_request_count() > 0 + || active_operations > 0 + { + observed_revision = revision; + idle_since = Instant::now(); + continue; + } + if idle_since.elapsed() >= timeout { + return; + } + } +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + + let mut terminate = signal(SignalKind::terminate()).expect("SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = terminate.recv() => {} + } + } + + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} + +fn initialize_runtime_logging(background_child: bool) { + if background_child { + return; + } + let _ = tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .try_init(); +} + +fn attachment_data_dir( + command_line: Option, + environment: Option, +) -> Result, String> { + let selected = command_line.or_else(|| environment.map(PathBuf::from)); + if selected + .as_ref() + .is_some_and(|path| path.as_os_str().is_empty()) + { + return Err(format!("{DATA_DIR_ENV} must not be empty")); + } + Ok(selected) +} + +fn runtime_idle_seconds(environment: Option) -> Result { + let Some(value) = environment else { + return Ok(DEFAULT_RUNTIME_IDLE_SECONDS); + }; + let value = value + .into_string() + .map_err(|_| format!("{RUNTIME_IDLE_SECONDS_ENV} must contain valid UTF-8"))?; + let seconds = value.parse::().map_err(|_| { + format!("{RUNTIME_IDLE_SECONDS_ENV} must be a positive integer number of seconds") + })?; + if seconds == 0 { + return Err(format!( + "{RUNTIME_IDLE_SECONDS_ENV} must be greater than zero" + )); + } + Ok(seconds) +} + +#[cfg(test)] +mod tests { + use std::{ffi::OsString, path::PathBuf, time::Duration}; + + use clap::Parser as _; + + use super::{ + Cli, Command, HostMode, QueryCommand, WriteCommand, attachment_data_dir, + runtime_idle_seconds, + }; + + #[test] + fn defaults_to_auto_headless_host_mode() { + let cli = Cli::try_parse_from(["chat2db", "status"]).expect("status must parse"); + assert_eq!(cli.host, HostMode::Auto); + assert!(matches!(cli.command, Command::Status)); + } + + #[test] + fn attach_only_mode_remains_available() { + let cli = Cli::try_parse_from(["chat2db", "--host", "attach", "datasources"]) + .expect("attach mode must parse"); + assert_eq!(cli.host, HostMode::Attach); + } + + #[test] + fn parses_read_query_lifecycle_commands() { + let start = Cli::try_parse_from([ + "chat2db", + "query", + "start", + "--datasource-id", + "datasource-1", + "--sql", + "select 1", + ]) + .expect("query start must parse"); + assert!(matches!( + start.command, + Command::Query { + command: QueryCommand::Start { .. } + } + )); + + let cancel = Cli::try_parse_from(["chat2db", "query", "cancel", "operation-1"]) + .expect("query cancel must parse"); + assert!(matches!( + cancel.command, + Command::Query { + command: QueryCommand::Cancel { .. } + } + )); + } + + #[test] + fn parses_bounded_result_page() { + let cli = Cli::try_parse_from([ + "chat2db", + "--data-dir", + "/tmp/chat2db-test", + "result", + "result-1", + "--offset", + "20", + "--max-rows", + "50", + "--max-bytes", + "4096", + ]) + .expect("result page must parse"); + assert!(matches!( + cli.command, + Command::Result { + offset: 20, + max_rows: 50, + max_bytes: 4096, + .. + } + )); + } + + #[test] + fn parses_explicitly_confirmed_write() { + let cli = Cli::try_parse_from([ + "chat2db", + "write", + "execute", + "--datasource-id", + "datasource-1", + "--sql", + "update sample set value = 1", + "--confirm-write", + ]) + .expect("write must parse"); + assert!(matches!( + cli.command, + Command::Write { + command: WriteCommand::Execute { + confirm_write: true, + .. + } + } + )); + } + + #[test] + fn command_line_data_directory_wins_over_environment() { + let selected = attachment_data_dir( + Some(PathBuf::from("/command-line")), + Some(OsString::from("/environment")), + ) + .expect("data directory must resolve"); + assert_eq!(selected, Some(PathBuf::from("/command-line"))); + } + + #[test] + fn empty_data_directory_sources_are_rejected() { + assert!(attachment_data_dir(Some(PathBuf::new()), None).is_err()); + assert!(attachment_data_dir(None, Some(OsString::new())).is_err()); + } + + #[test] + fn runtime_idle_timeout_is_positive() { + assert_eq!(runtime_idle_seconds(None).expect("default timeout"), 60); + assert_eq!( + runtime_idle_seconds(Some(OsString::from("7"))).expect("custom timeout"), + 7 + ); + assert!(runtime_idle_seconds(Some(OsString::from("0"))).is_err()); + assert!(runtime_idle_seconds(Some(OsString::from("invalid"))).is_err()); + } + + #[test] + fn short_idle_interval_does_not_underflow() { + assert_eq!( + super::IDLE_PROBE_INTERVAL.min(Duration::from_millis(1)), + Duration::from_millis(1) + ); + } +} diff --git a/apps/chat2db-cli/src/main.rs b/apps/chat2db-cli/src/main.rs index f524d87..4dd3c12 100644 --- a/apps/chat2db-cli/src/main.rs +++ b/apps/chat2db-cli/src/main.rs @@ -1,296 +1,3 @@ -use std::{env, ffi::OsString, path::PathBuf}; - -use chat2db_contract::{ - DatabaseWriteState, ExecuteDatabaseWriteRequest, QueryLimits, ResultPageRequest, - StartQueryRequest, -}; -use chat2db_local::LocalClient; -use clap::{Parser, Subcommand}; - -const DATA_DIR_ENV: &str = "CHAT2DB_DATA_DIR"; - -#[derive(Debug, Parser)] -#[command(name = "chat2db", version, about = "Chat2DB Rust command line")] -struct Cli { - /// Override the per-user `Chat2DB` data directory used for local attachment. - #[arg(long, global = true)] - data_dir: Option, - #[command(subcommand)] - command: Command, -} - -#[derive(Debug, Subcommand)] -enum Command { - /// Print health from the running local product host. - Status, - /// List secret-free datasource metadata. - Datasources, - /// Start, inspect, or cancel a forced-read-only database query. - Query { - #[command(subcommand)] - command: QueryCommand, - }, - /// Execute one explicitly confirmed `MySQL` write statement. - Write { - #[command(subcommand)] - command: WriteCommand, - }, - /// Read one bounded page from a retained query result. - Result { - result_id: String, - #[arg(long, default_value_t = 0)] - offset: u64, - #[arg(long, default_value_t = 100)] - max_rows: u32, - #[arg(long, default_value_t = 262_144)] - max_bytes: u64, - }, -} - -#[derive(Debug, Subcommand)] -enum QueryCommand { - /// Start a forced-read-only query and return its operation id. - Start { - #[arg(long)] - datasource_id: String, - #[arg(long)] - sql: String, - #[arg(long, default_value_t = 10_000)] - max_rows: u64, - #[arg(long, default_value_t = 16_777_216)] - max_result_bytes: u64, - #[arg(long, default_value_t = 900)] - result_ttl_seconds: u32, - }, - /// Read the current state of a query operation. - Status { operation_id: String }, - /// Request idempotent cancellation of a query operation. - Cancel { operation_id: String }, -} - -#[derive(Debug, Subcommand)] -enum WriteCommand { - /// Execute exactly one write. Only `not_started` is safe to retry after correction. - Execute { - #[arg(long)] - datasource_id: String, - #[arg(long)] - sql: String, - /// Explicitly confirm that this statement may change the database. - #[arg(long)] - confirm_write: bool, - }, -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let cli = Cli::parse(); - let client = local_client(cli.data_dir)?; - let mut command_succeeded = true; - - let output = match cli.command { - Command::Status => serde_json::to_value(client.health().await?)?, - Command::Datasources => serde_json::to_value(client.list_datasources().await?)?, - Command::Query { command } => match command { - QueryCommand::Start { - datasource_id, - sql, - max_rows, - max_result_bytes, - result_ttl_seconds, - } => serde_json::to_value( - client - .start_read_query(StartQueryRequest { - datasource_id, - sql, - parameters: Vec::new(), - limits: QueryLimits { - max_rows: max_rows.to_string(), - max_result_bytes: max_result_bytes.to_string(), - batch_rows: 256, - batch_bytes: 1024 * 1024, - result_ttl_seconds, - }, - }) - .await?, - )?, - QueryCommand::Status { operation_id } => { - serde_json::to_value(client.operation_snapshot(operation_id).await?)? - } - QueryCommand::Cancel { operation_id } => { - serde_json::to_value(client.cancel_operation(operation_id).await?)? - } - }, - Command::Write { command } => match command { - WriteCommand::Execute { - datasource_id, - sql, - confirm_write, - } => { - let result = client - .execute_database_write(ExecuteDatabaseWriteRequest { - datasource_id, - sql, - confirmed: confirm_write, - }) - .await; - command_succeeded = result.state == DatabaseWriteState::Succeeded; - serde_json::to_value(result)? - } - }, - Command::Result { - result_id, - offset, - max_rows, - max_bytes, - } => serde_json::to_value( - client - .result_page( - result_id, - ResultPageRequest { - offset: offset.to_string(), - max_rows: max_rows.to_string(), - max_bytes: max_bytes.to_string(), - }, - ) - .await?, - )?, - }; - println!("{}", serde_json::to_string_pretty(&output)?); - if !command_succeeded { - return Err(std::io::Error::other("database write did not succeed").into()); - } - Ok(()) -} - -fn local_client(data_dir: Option) -> Result> { - match attachment_data_dir(data_dir, env::var_os(DATA_DIR_ENV))? { - Some(path) => Ok(LocalClient::new(path)), - None => Ok(LocalClient::discover_default()?), - } -} - -fn attachment_data_dir( - command_line: Option, - environment: Option, -) -> Result, String> { - let selected = command_line.or_else(|| environment.map(PathBuf::from)); - if selected - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - return Err(format!("{DATA_DIR_ENV} must not be empty")); - } - Ok(selected) -} - -#[cfg(test)] -mod tests { - use std::{ffi::OsString, path::PathBuf}; - - use clap::Parser; - - use super::{Cli, Command, QueryCommand, WriteCommand, attachment_data_dir}; - - #[test] - fn parses_status_command() { - let cli = Cli::try_parse_from(["chat2db", "status"]).expect("status must parse"); - assert!(matches!(cli.command, Command::Status)); - } - - #[test] - fn parses_read_query_lifecycle_commands() { - let start = Cli::try_parse_from([ - "chat2db", - "query", - "start", - "--datasource-id", - "datasource-1", - "--sql", - "select 1", - ]) - .expect("query start must parse"); - assert!(matches!( - start.command, - Command::Query { - command: QueryCommand::Start { .. } - } - )); - - let cancel = Cli::try_parse_from(["chat2db", "query", "cancel", "operation-1"]) - .expect("query cancel must parse"); - assert!(matches!( - cancel.command, - Command::Query { - command: QueryCommand::Cancel { .. } - } - )); - } - - #[test] - fn parses_bounded_result_page() { - let cli = Cli::try_parse_from([ - "chat2db", - "--data-dir", - "/tmp/chat2db-test", - "result", - "result-1", - "--offset", - "20", - "--max-rows", - "50", - ]) - .expect("result page must parse"); - assert!(matches!(cli.command, Command::Result { .. })); - } - - #[test] - fn database_write_requires_an_explicit_confirmation_flag_value() { - let confirmed = Cli::try_parse_from([ - "chat2db", - "write", - "execute", - "--datasource-id", - "datasource-1", - "--sql", - "UPDATE items SET label = 'changed' WHERE id = 1", - "--confirm-write", - ]) - .expect("confirmed write must parse"); - assert!(matches!( - confirmed.command, - Command::Write { - command: WriteCommand::Execute { - confirm_write: true, - .. - } - } - )); - - let unconfirmed = Cli::try_parse_from([ - "chat2db", - "write", - "execute", - "--datasource-id", - "datasource-1", - "--sql", - "DELETE FROM items WHERE id = 1", - ]) - .expect("unconfirmed write parses so the runtime can fail closed"); - assert!(matches!( - unconfirmed.command, - Command::Write { - command: WriteCommand::Execute { - confirm_write: false, - .. - } - } - )); - } - - #[test] - fn rejects_empty_data_directory_sources() { - assert!(attachment_data_dir(Some(PathBuf::new()), None).is_err()); - assert!(attachment_data_dir(None, Some(OsString::new())).is_err()); - } +fn main() -> Result<(), Box> { + chat2db_cli::run() } diff --git a/apps/chat2db-cli/tests/headless_runtime.rs b/apps/chat2db-cli/tests/headless_runtime.rs new file mode 100644 index 0000000..6960ca0 --- /dev/null +++ b/apps/chat2db-cli/tests/headless_runtime.rs @@ -0,0 +1,60 @@ +use std::{fs::File, process::Command, time::Duration}; + +use chat2db_local::LocalClient; +use serde_json::Value; + +const TEST_VAULT_MASTER_KEY: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + +#[tokio::test] +async fn auto_mode_starts_a_no_gui_host_and_reaps_it_after_idle() { + let directory = tempfile::tempdir().expect("temporary product data directory"); + let engine_jar = directory.path().join("compatibility-engine.jar"); + File::create(&engine_jar).expect("placeholder engine JAR"); + + let output = Command::new(env!("CARGO_BIN_EXE_chat2db")) + .arg("--data-dir") + .arg(directory.path()) + .arg("status") + .env("CHAT2DB_JAVA_ENGINE_JAR", &engine_jar) + .env("CHAT2DB_VAULT_MASTER_KEY", TEST_VAULT_MASTER_KEY) + .env("CHAT2DB_CLI_RUNTIME_IDLE_SECONDS", "1") + .output() + .expect("auto CLI command must execute"); + assert!( + output.status.success(), + "auto CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let health: Value = serde_json::from_slice(&output.stdout).expect("health JSON"); + assert_eq!(health["status"], "ready"); + let engine = health["components"] + .as_array() + .expect("health components") + .iter() + .find(|component| component["id"] == "database-engine") + .expect("database engine health"); + assert!( + engine["detail"] + .as_str() + .is_some_and(|detail| detail.contains("Java is not running")) + ); + + let client = LocalClient::new(directory.path()); + client + .health() + .await + .expect("headless host remains attached"); + let endpoint_metadata = directory.path().join("local-attachment-v1.json"); + + tokio::time::timeout(Duration::from_secs(10), async { + loop { + if !endpoint_metadata.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }) + .await + .expect("headless host must exit after its idle timeout"); +} diff --git a/apps/chat2db-desktop/Cargo.toml b/apps/chat2db-desktop/Cargo.toml index fd026a3..4d03427 100644 --- a/apps/chat2db-desktop/Cargo.toml +++ b/apps/chat2db-desktop/Cargo.toml @@ -19,8 +19,8 @@ tauri-build.workspace = true cap-std = "4.0.2" chat2db-contract = { path = "../../crates/chat2db-contract" } chat2db-core = { path = "../../crates/chat2db-core" } -chat2db-java-bridge = { path = "../../crates/chat2db-java-bridge" } chat2db-local = { path = "../../crates/chat2db-local" } +chat2db-runtime = { path = "../../crates/chat2db-runtime" } chat2db-web = { path = "../chat2db-web" } encoding_rs = "0.8.35" serde.workspace = true diff --git a/apps/chat2db-desktop/src/lib.rs b/apps/chat2db-desktop/src/lib.rs index 67dfaca..c40d007 100644 --- a/apps/chat2db-desktop/src/lib.rs +++ b/apps/chat2db-desktop/src/lib.rs @@ -1,11 +1,13 @@ //! Tauri IPC delivery adapter for the `Chat2DB` desktop product. +// Tauri serializes the stable ApiError value directly at every command boundary. +#![allow(clippy::result_large_err)] + mod legacy_files; use std::{ collections::HashMap, - env, - ffi::{OsStr, OsString}, + ffi::OsStr, fs, path::{Path, PathBuf}, sync::{ @@ -40,12 +42,9 @@ use chat2db_contract::{ StartCommunityTablePreviewRequest, StartQueryRequest, UpdateAgentSessionRequest, UpdateDatasourceRequest, UpdateProviderProfileRequest, ValidateCommunitySqlRequest, }; -use chat2db_core::{ - AppError, Application, NativeConsoleCancellation, RuntimeConfig, RuntimeHost, - load_fixed_community_classpath, -}; -use chat2db_java_bridge::{BridgeError, EngineCommand, EngineConfig}; +use chat2db_core::{AppError, Application, NativeConsoleCancellation, RuntimeConfig, RuntimeHost}; use chat2db_local::{LocalError, LocalServer}; +use chat2db_runtime::{RuntimeConfigError, RuntimeOptions}; use legacy_files::{ LegacyCreateSqlDirectoryChildRequest, LegacyOpenSqlDirectoryRequest, LegacyReadFileRequest, LegacyRenameSqlDirectoryChildRequest, LegacySaveFileRequest, LegacySaveSqlDirectoryFileRequest, @@ -57,84 +56,10 @@ use tauri::{Emitter, Manager, State, WebviewWindow, ipc::Channel}; use tauri_plugin_dialog::{DialogExt, FilePath, MessageDialogKind}; use tokio::sync::{Mutex, oneshot, watch}; -const DATA_DIR_ENV: &str = "CHAT2DB_DATA_DIR"; -const DRIVER_PACK_DIR_ENV: &str = "CHAT2DB_DRIVER_PACK_DIR"; -const COMMUNITY_CLASSPATH_DIR_ENV: &str = "CHAT2DB_COMMUNITY_CLASSPATH_DIR"; -const JAVA_BIN_ENV: &str = "CHAT2DB_JAVA_BIN"; -const JAVA_ENGINE_JAR_ENV: &str = "CHAT2DB_JAVA_ENGINE_JAR"; -const VAULT_MASTER_KEY_ENV: &str = "CHAT2DB_VAULT_MASTER_KEY"; - -const BUNDLED_JAVA_BIN: &str = "Java binary"; -const BUNDLED_JAVA_ENGINE_JAR: &str = "compatibility-engine JAR"; -const BUNDLED_COMMUNITY_CLASSPATH: &str = "Community classpath"; -const BUNDLED_DRIVER_PACKS: &str = "driver packs"; const COMMUNITY_JAVA_MESSAGE_EVENT: &str = "chat2db://java-message"; const DESKTOP_RUNTIME_READY_EVENT: &str = "chat2db://runtime-ready"; const DESKTOP_RUNTIME_FAILED_EVENT: &str = "chat2db://runtime-failed"; -#[derive(Debug, Default)] -struct RuntimeResourceOverrides { - java_bin: Option, - java_engine_jar: Option, - community_classpath_dir: Option, - driver_pack_dir: Option, -} - -#[derive(Debug, PartialEq, Eq)] -struct RuntimeResourcePaths { - java_bin: OsString, - java_engine_jar: PathBuf, - community_classpath_dir: Option, - driver_pack_dir: Option, -} - -#[derive(Debug)] -struct BundledRuntimeResources { - java_bin: PathBuf, - java_engine_jar: PathBuf, - community_classpath_dir: PathBuf, - driver_pack_dir: PathBuf, -} - -impl BundledRuntimeResources { - fn from_resource_dir(resource_dir: &Path) -> Option { - if !resource_dir.is_absolute() { - return None; - } - Some(Self::from_resource_root(resource_dir.join("chat2db"))) - } - - fn from_resource_root(resource_root: PathBuf) -> Self { - Self { - java_bin: resource_root.join("java").join("bin").join("java"), - java_engine_jar: resource_root - .join("engine") - .join("chat2db-compat-runtime.jar"), - community_classpath_dir: resource_root.join("community-classpath"), - driver_pack_dir: resource_root.join("driver-packs"), - } - } - - fn from_executable(executable: &Path) -> Option { - let macos_dir = executable.parent()?; - if macos_dir.file_name() != Some(OsStr::new("MacOS")) { - return None; - } - let contents_dir = macos_dir.parent()?; - if contents_dir.file_name() != Some(OsStr::new("Contents")) { - return None; - } - let app_dir = contents_dir.parent()?; - if app_dir.extension() != Some(OsStr::new("app")) { - return None; - } - - Some(Self::from_resource_root( - contents_dir.join("Resources").join("chat2db"), - )) - } -} - struct DesktopState { application: Application, local_server: Mutex>, @@ -399,20 +324,7 @@ where /// Startup or shutdown failure for the desktop host. #[derive(Debug)] pub enum DesktopError { - MissingJavaEngineJar, - EmptyEnvironmentVariable(&'static str), - InvalidJavaEngineJar(PathBuf), - JavaEngineJarMetadata { - path: PathBuf, - source: std::io::Error, - }, - InvalidBundledResource { - resource: &'static str, - expected: &'static str, - path: PathBuf, - }, - InvalidVaultMasterKeyEncoding, - CommunityClasspath(Box), + RuntimeConfiguration(Box), Local(Box), Runtime(Box), Tauri(Box), @@ -435,42 +347,7 @@ impl DesktopError { impl std::fmt::Display for DesktopError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::MissingJavaEngineJar => write!( - formatter, - "{JAVA_ENGINE_JAR_ENV} is required and must point to the compatibility-engine JAR" - ), - Self::EmptyEnvironmentVariable(name) => { - write!(formatter, "{name} must not be empty when configured") - } - Self::InvalidJavaEngineJar(path) => write!( - formatter, - "{JAVA_ENGINE_JAR_ENV} does not point to a regular file: {}", - path.display() - ), - Self::JavaEngineJarMetadata { path, source } => write!( - formatter, - "unable to inspect {JAVA_ENGINE_JAR_ENV} at {}: {source}", - path.display() - ), - Self::InvalidBundledResource { - resource, - expected, - path, - } => write!( - formatter, - "bundled {resource} is missing or is not a {expected}: {}", - path.display() - ), - Self::InvalidVaultMasterKeyEncoding => write!( - formatter, - "{VAULT_MASTER_KEY_ENV} must be UTF-8 standard base64 for exactly 32 bytes" - ), - Self::CommunityClasspath(error) => { - write!( - formatter, - "fixed Community classpath failed validation: {error}" - ) - } + Self::RuntimeConfiguration(error) => write!(formatter, "{error}"), Self::Local(error) => write!(formatter, "local attachment failed: {error}"), Self::Runtime(error) => write!(formatter, "Chat2DB runtime failed: {error}"), Self::Tauri(error) => write!(formatter, "Tauri desktop failed: {error}"), @@ -481,16 +358,10 @@ impl std::fmt::Display for DesktopError { impl std::error::Error for DesktopError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { - Self::JavaEngineJarMetadata { source, .. } => Some(source), - Self::CommunityClasspath(error) => Some(error.as_ref()), + Self::RuntimeConfiguration(error) => Some(error.as_ref()), Self::Local(error) => Some(error.as_ref()), Self::Runtime(error) => Some(error.as_ref()), Self::Tauri(error) => Some(error.as_ref()), - Self::MissingJavaEngineJar - | Self::EmptyEnvironmentVariable(_) - | Self::InvalidJavaEngineJar(_) - | Self::InvalidBundledResource { .. } - | Self::InvalidVaultMasterKeyEncoding => None, } } } @@ -645,158 +516,13 @@ pub fn run() -> Result { fn runtime_config_from_environment( resource_dir: Option<&Path>, ) -> Result { - let resource_overrides = RuntimeResourceOverrides { - java_engine_jar: optional_nonempty_os_env(JAVA_ENGINE_JAR_ENV)?, - java_bin: optional_nonempty_os_env(JAVA_BIN_ENV)?, - community_classpath_dir: optional_nonempty_os_env(COMMUNITY_CLASSPATH_DIR_ENV)?, - driver_pack_dir: optional_nonempty_os_env(DRIVER_PACK_DIR_ENV)?, - }; - let current_executable = env::current_exe().ok(); - let resources = resolve_runtime_resource_paths( - current_executable.as_deref(), + let current_executable = std::env::current_exe().ok(); + chat2db_runtime::runtime_config_from_environment(RuntimeOptions { + data_dir: None, + executable: current_executable.as_deref(), resource_dir, - resource_overrides, - )?; - let mut engine = EngineConfig::new(EngineCommand::java_jar( - resources.java_bin, - resources.java_engine_jar, - )); - if let Some(community_classpath_dir) = resources.community_classpath_dir { - let classpath = load_fixed_community_classpath(community_classpath_dir) - .map_err(|error| DesktopError::CommunityClasspath(Box::new(error)))?; - engine = engine.with_community_classpath(classpath); - } - let mut config = RuntimeConfig::new(engine); - - if let Some(data_dir) = optional_nonempty_os_env(DATA_DIR_ENV)? { - config = config.with_data_dir(PathBuf::from(data_dir)); - } - if let Some(driver_pack_dir) = resources.driver_pack_dir { - config = config.with_driver_pack_dir(driver_pack_dir); - } - match env::var(VAULT_MASTER_KEY_ENV) { - Ok(master_key) => config = config.with_vault_master_key_base64(master_key), - Err(env::VarError::NotPresent) => {} - Err(env::VarError::NotUnicode(_)) => { - return Err(DesktopError::InvalidVaultMasterKeyEncoding); - } - } - Ok(config) -} - -fn resolve_runtime_resource_paths( - executable: Option<&Path>, - resource_dir: Option<&Path>, - overrides: RuntimeResourceOverrides, -) -> Result { - let bundled = resource_dir - .and_then(BundledRuntimeResources::from_resource_dir) - .or_else(|| executable.and_then(BundledRuntimeResources::from_executable)); - - let java_bin = match overrides.java_bin { - Some(java_bin) => java_bin, - None => match bundled.as_ref() { - Some(resources) => { - validate_bundled_file(BUNDLED_JAVA_BIN, &resources.java_bin)?; - resources.java_bin.clone().into_os_string() - } - None => OsString::from("java"), - }, - }; - let java_engine_jar = match overrides.java_engine_jar { - Some(java_engine_jar) => { - let path = PathBuf::from(java_engine_jar); - validate_java_engine_jar(&path)?; - path - } - None => match bundled.as_ref() { - Some(resources) => { - validate_bundled_file(BUNDLED_JAVA_ENGINE_JAR, &resources.java_engine_jar)?; - resources.java_engine_jar.clone() - } - None => return Err(DesktopError::MissingJavaEngineJar), - }, - }; - let community_classpath_dir = match overrides.community_classpath_dir { - Some(directory) => Some(PathBuf::from(directory)), - None => match bundled.as_ref() { - Some(resources) => { - validate_bundled_directory( - BUNDLED_COMMUNITY_CLASSPATH, - &resources.community_classpath_dir, - )?; - Some(resources.community_classpath_dir.clone()) - } - None => None, - }, - }; - let driver_pack_dir = match overrides.driver_pack_dir { - Some(directory) => Some(PathBuf::from(directory)), - None => match bundled.as_ref() { - Some(resources) => { - validate_bundled_directory(BUNDLED_DRIVER_PACKS, &resources.driver_pack_dir)?; - Some(resources.driver_pack_dir.clone()) - } - None => None, - }, - }; - - Ok(RuntimeResourcePaths { - java_bin, - java_engine_jar, - community_classpath_dir, - driver_pack_dir, }) -} - -fn optional_nonempty_os_env(name: &'static str) -> Result, DesktopError> { - validate_optional_os_env(name, env::var_os(name)) -} - -fn validate_optional_os_env( - name: &'static str, - value: Option, -) -> Result, DesktopError> { - match value { - Some(value) if value.is_empty() => Err(DesktopError::EmptyEnvironmentVariable(name)), - value => Ok(value), - } -} - -fn validate_java_engine_jar(path: &Path) -> Result<(), DesktopError> { - match fs::metadata(path) { - Ok(metadata) if metadata.is_file() => Ok(()), - Ok(_) => Err(DesktopError::InvalidJavaEngineJar(path.to_path_buf())), - Err(source) if source.kind() == std::io::ErrorKind::NotFound => { - Err(DesktopError::InvalidJavaEngineJar(path.to_path_buf())) - } - Err(source) => Err(DesktopError::JavaEngineJarMetadata { - path: path.to_path_buf(), - source, - }), - } -} - -fn validate_bundled_file(resource: &'static str, path: &Path) -> Result<(), DesktopError> { - match fs::metadata(path) { - Ok(metadata) if metadata.is_file() => Ok(()), - Ok(_) | Err(_) => Err(DesktopError::InvalidBundledResource { - resource, - expected: "regular file", - path: path.to_path_buf(), - }), - } -} - -fn validate_bundled_directory(resource: &'static str, path: &Path) -> Result<(), DesktopError> { - match fs::metadata(path) { - Ok(metadata) if metadata.is_dir() => Ok(()), - Ok(_) | Err(_) => Err(DesktopError::InvalidBundledResource { - resource, - expected: "directory", - path: path.to_path_buf(), - }), - } + .map_err(|error| DesktopError::RuntimeConfiguration(Box::new(error))) } fn api_error(error: &AppError) -> ApiError { @@ -2786,9 +2512,7 @@ async fn result_page( #[cfg(test)] mod tests { use std::{ - ffi::OsString, fs::{self, File}, - path::PathBuf, sync::{Arc, atomic::AtomicU64}, }; @@ -2804,17 +2528,14 @@ mod tests { use tokio::sync::{Mutex, oneshot}; use super::{ - BUNDLED_COMMUNITY_CLASSPATH, BUNDLED_DRIVER_PACKS, BUNDLED_JAVA_BIN, - BUNDLED_JAVA_ENGINE_JAR, BundledRuntimeResources, DesktopError, DesktopStartup, - DesktopState, FilePath, LegacySqlCancellationRegistry, LegacySqlDirectoryRegistry, - RuntimeResourceOverrides, SubscriptionRegistry, agent_stream_message, + DesktopStartup, DesktopState, FilePath, LegacySqlCancellationRegistry, + LegacySqlDirectoryRegistry, SubscriptionRegistry, agent_stream_message, build_community_dml_for, build_community_namespace_sql_for, client_command_response, complete_community_sql_for, decode_client_message, format_community_sql_for, legacy_ai_push_message, legacy_file_extensions, legacy_request_for, legacy_selected_file, legacy_sql_push_message, legacy_sql_rowless_payload, operation_stream_message, - parse_after_sequence, resolve_runtime_resource_paths, spawn_engine_prewarm, - start_community_table_preview_for, validate_community_sql_for, validate_java_engine_jar, - validate_optional_os_env, + parse_after_sequence, spawn_engine_prewarm, start_community_table_preview_for, + validate_community_sql_for, }; fn test_desktop_state() -> Arc { @@ -2830,38 +2551,6 @@ mod tests { }) } - fn complete_app_bundle() -> (tempfile::TempDir, PathBuf, BundledRuntimeResources) { - let directory = tempfile::tempdir().expect("temporary app bundle"); - let executable = directory - .path() - .join("Chat2DB.app") - .join("Contents") - .join("MacOS") - .join("chat2db-desktop"); - fs::create_dir_all(executable.parent().expect("bundle executable parent")) - .expect("bundle executable directory"); - File::create(&executable).expect("bundle executable"); - - let resources = BundledRuntimeResources::from_executable(&executable) - .expect("synthetic executable must be recognized as an app bundle"); - fs::create_dir_all(resources.java_bin.parent().expect("Java binary parent")) - .expect("bundled Java directory"); - File::create(&resources.java_bin).expect("bundled Java binary"); - fs::create_dir_all( - resources - .java_engine_jar - .parent() - .expect("engine JAR parent"), - ) - .expect("bundled engine directory"); - File::create(&resources.java_engine_jar).expect("bundled engine JAR"); - fs::create_dir_all(&resources.community_classpath_dir) - .expect("bundled Community classpath"); - fs::create_dir_all(&resources.driver_pack_dir).expect("bundled driver packs"); - - (directory, executable, resources) - } - #[tokio::test] async fn desktop_startup_failure_wakes_pending_and_late_requests() { let startup = Arc::new(DesktopStartup::new()); @@ -3333,190 +3022,6 @@ mod tests { assert_eq!(error.code, "invalid_last_event_id"); } - #[test] - fn java_engine_path_must_be_a_regular_file() { - let directory = tempfile::tempdir().expect("temporary directory"); - assert!(matches!( - validate_java_engine_jar(directory.path()), - Err(DesktopError::InvalidJavaEngineJar(_)) - )); - - let jar = directory.path().join("engine.jar"); - File::create(&jar).expect("engine fixture"); - validate_java_engine_jar(&jar).expect("regular file must pass"); - - assert!(matches!( - validate_java_engine_jar(&directory.path().join("missing-engine.jar")), - Err(DesktopError::InvalidJavaEngineJar(_)) - )); - } - - #[test] - fn macos_app_bundle_supplies_all_default_runtime_resources() { - let (_directory, executable, bundled) = complete_app_bundle(); - - let resolved = resolve_runtime_resource_paths( - Some(&executable), - None, - RuntimeResourceOverrides::default(), - ) - .expect("complete app bundle must resolve"); - - assert_eq!(resolved.java_bin, bundled.java_bin.into_os_string()); - assert_eq!(resolved.java_engine_jar, bundled.java_engine_jar); - assert_eq!( - resolved.community_classpath_dir, - Some(bundled.community_classpath_dir) - ); - assert_eq!(resolved.driver_pack_dir, Some(bundled.driver_pack_dir)); - } - - #[test] - fn tauri_resource_directory_supplies_non_macos_runtime_resources() { - let directory = tempfile::tempdir().expect("temporary resource directory"); - let resource_dir = directory.path().join("resources"); - let bundled = BundledRuntimeResources::from_resource_dir(&resource_dir) - .expect("absolute resource directory must resolve"); - fs::create_dir_all(bundled.java_bin.parent().expect("Java binary parent")) - .expect("bundled Java directory"); - File::create(&bundled.java_bin).expect("bundled Java binary"); - fs::create_dir_all(bundled.java_engine_jar.parent().expect("engine JAR parent")) - .expect("bundled engine directory"); - File::create(&bundled.java_engine_jar).expect("bundled engine JAR"); - fs::create_dir_all(&bundled.community_classpath_dir).expect("bundled Community classpath"); - fs::create_dir_all(&bundled.driver_pack_dir).expect("bundled driver packs"); - - let resolved = resolve_runtime_resource_paths( - None, - Some(&resource_dir), - RuntimeResourceOverrides::default(), - ) - .expect("Tauri resource directory must resolve"); - - assert_eq!(resolved.java_bin, bundled.java_bin.into_os_string()); - assert_eq!(resolved.java_engine_jar, bundled.java_engine_jar); - assert_eq!( - resolved.community_classpath_dir, - Some(bundled.community_classpath_dir) - ); - assert_eq!(resolved.driver_pack_dir, Some(bundled.driver_pack_dir)); - } - - #[test] - fn environment_paths_override_missing_app_bundle_resources() { - let directory = tempfile::tempdir().expect("temporary app bundle"); - let executable = directory - .path() - .join("Chat2DB.app") - .join("Contents") - .join("MacOS") - .join("chat2db-desktop"); - fs::create_dir_all(executable.parent().expect("bundle executable parent")) - .expect("bundle executable directory"); - File::create(&executable).expect("bundle executable"); - - let overrides_root = directory.path().join("overrides"); - let java_bin = overrides_root.join("java"); - let java_engine_jar = overrides_root.join("engine.jar"); - let community_classpath_dir = overrides_root.join("community-classpath"); - let driver_pack_dir = overrides_root.join("driver-packs"); - fs::create_dir_all(&overrides_root).expect("override root"); - File::create(&java_bin).expect("override Java binary"); - File::create(&java_engine_jar).expect("override engine JAR"); - fs::create_dir_all(&community_classpath_dir).expect("override Community classpath"); - fs::create_dir_all(&driver_pack_dir).expect("override driver packs"); - - let resolved = resolve_runtime_resource_paths( - Some(&executable), - None, - RuntimeResourceOverrides { - java_bin: Some(java_bin.clone().into_os_string()), - java_engine_jar: Some(java_engine_jar.clone().into_os_string()), - community_classpath_dir: Some(community_classpath_dir.clone().into_os_string()), - driver_pack_dir: Some(driver_pack_dir.clone().into_os_string()), - }, - ) - .expect("environment overrides must not require bundled fallbacks"); - - assert_eq!(resolved.java_bin, java_bin.into_os_string()); - assert_eq!(resolved.java_engine_jar, java_engine_jar); - assert_eq!( - resolved.community_classpath_dir, - Some(community_classpath_dir) - ); - assert_eq!(resolved.driver_pack_dir, Some(driver_pack_dir)); - } - - #[test] - fn app_bundle_reports_each_missing_runtime_resource() { - for missing_resource in [ - BUNDLED_JAVA_BIN, - BUNDLED_JAVA_ENGINE_JAR, - BUNDLED_COMMUNITY_CLASSPATH, - BUNDLED_DRIVER_PACKS, - ] { - let (_directory, executable, bundled) = complete_app_bundle(); - let (missing_path, is_directory) = match missing_resource { - BUNDLED_JAVA_BIN => (bundled.java_bin, false), - BUNDLED_JAVA_ENGINE_JAR => (bundled.java_engine_jar, false), - BUNDLED_COMMUNITY_CLASSPATH => (bundled.community_classpath_dir, true), - BUNDLED_DRIVER_PACKS => (bundled.driver_pack_dir, true), - _ => unreachable!("all bundled resources are covered"), - }; - if is_directory { - fs::remove_dir_all(&missing_path).expect("remove bundled directory"); - } else { - fs::remove_file(&missing_path).expect("remove bundled file"); - } - - let error = resolve_runtime_resource_paths( - Some(&executable), - None, - RuntimeResourceOverrides::default(), - ) - .expect_err("missing bundled resource must fail closed"); - assert!(matches!( - error, - DesktopError::InvalidBundledResource { resource, path, .. } - if resource == missing_resource && path == missing_path - )); - } - } - - #[test] - fn development_executable_still_requires_java_engine_environment() { - let directory = tempfile::tempdir().expect("temporary development layout"); - let executable = directory - .path() - .join("target") - .join("debug") - .join("chat2db-desktop"); - - assert!(matches!( - resolve_runtime_resource_paths( - Some(&executable), - None, - RuntimeResourceOverrides::default(), - ), - Err(DesktopError::MissingJavaEngineJar) - )); - } - - #[test] - fn optional_path_environment_rejects_explicit_empty_values() { - assert!(matches!( - validate_optional_os_env("CHAT2DB_DRIVER_PACK_DIR", Some(OsString::new())), - Err(DesktopError::EmptyEnvironmentVariable( - "CHAT2DB_DRIVER_PACK_DIR" - )) - )); - assert_eq!( - validate_optional_os_env("CHAT2DB_DRIVER_PACK_DIR", None) - .expect("missing optional variable must be accepted"), - None - ); - } - #[test] fn stream_result_maps_events_errors_and_clean_end() { let event = OperationEventEnvelope { diff --git a/apps/chat2db-desktop/tauri.linux.package.conf.json b/apps/chat2db-desktop/tauri.linux.package.conf.json index e8a3653..7aaf387 100644 --- a/apps/chat2db-desktop/tauri.linux.package.conf.json +++ b/apps/chat2db-desktop/tauri.linux.package.conf.json @@ -12,6 +12,7 @@ "../../java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar": "chat2db/engine/chat2db-compat-runtime.jar", "../../target/community-h2-classpath": "chat2db/community-classpath", "../../target/linux-driver-packs": "chat2db/driver-packs", + "../../target/linux-cli/chat2db": "chat2db/bin/chat2db", "../../target/linux-license-resources/Chat2DB-Rust-LICENSE.txt": "chat2db/licenses/Chat2DB-Rust-LICENSE.txt", "../../target/linux-license-resources/Chat2DB-Community-LICENSE.txt": "chat2db/licenses/Chat2DB-Community-LICENSE.txt", "../../packaging/macos/THIRD_PARTY_NOTICES.md": "chat2db/licenses/THIRD_PARTY_NOTICES.md" diff --git a/apps/chat2db-desktop/tauri.package.conf.json b/apps/chat2db-desktop/tauri.package.conf.json index 051e6b5..6243d9b 100644 --- a/apps/chat2db-desktop/tauri.package.conf.json +++ b/apps/chat2db-desktop/tauri.package.conf.json @@ -12,6 +12,7 @@ "../../java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar": "chat2db/engine/chat2db-compat-runtime.jar", "../../target/community-h2-classpath": "chat2db/community-classpath", "../../target/macos-driver-packs": "chat2db/driver-packs", + "../../target/macos-cli/chat2db": "chat2db/bin/chat2db", "../../LICENSE": "chat2db/licenses/Chat2DB-Rust-LICENSE.txt", "../../third_party/chat2db-community/LICENSE": "chat2db/licenses/Chat2DB-Community-LICENSE.txt", "../../packaging/macos/THIRD_PARTY_NOTICES.md": "chat2db/licenses/THIRD_PARTY_NOTICES.md" diff --git a/apps/chat2db-desktop/tauri.windows.package.conf.json b/apps/chat2db-desktop/tauri.windows.package.conf.json index 36e8829..f4eff76 100644 --- a/apps/chat2db-desktop/tauri.windows.package.conf.json +++ b/apps/chat2db-desktop/tauri.windows.package.conf.json @@ -12,6 +12,7 @@ "../../java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar": "chat2db/engine/chat2db-compat-runtime.jar", "../../target/community-h2-classpath": "chat2db/community-classpath", "../../target/windows-driver-packs": "chat2db/driver-packs", + "../../target/windows-cli/chat2db.exe": "chat2db/bin/chat2db.exe", "../../target/windows-license-resources/Chat2DB-Rust-LICENSE.txt": "chat2db/licenses/Chat2DB-Rust-LICENSE.txt", "../../target/windows-license-resources/Chat2DB-Community-LICENSE.txt": "chat2db/licenses/Chat2DB-Community-LICENSE.txt", "../../packaging/macos/THIRD_PARTY_NOTICES.md": "chat2db/licenses/THIRD_PARTY_NOTICES.md" diff --git a/apps/chat2db-mcp/src/lib.rs b/apps/chat2db-mcp/src/lib.rs index f66db9d..dc2ae37 100644 --- a/apps/chat2db-mcp/src/lib.rs +++ b/apps/chat2db-mcp/src/lib.rs @@ -440,6 +440,8 @@ impl McpServer { } } +// rmcp 2.2 generates an immediately-ready async trait method here. +#[allow(unknown_lints, clippy::unused_async_trait_impl)] #[tool_handler] impl ServerHandler for McpServer { fn get_info(&self) -> ServerInfo { @@ -732,51 +734,58 @@ mod tests { info } - async fn create_elicitation( + fn create_elicitation( &self, request: ElicitRequestParams, _context: RequestContext, - ) -> Result { - let ElicitRequestParams::FormElicitationParams { - message, - requested_schema, - .. - } = request - else { - return Err(rmcp::ErrorData::invalid_params( - "database write approval requires form elicitation", - None, - )); - }; - assert!(requested_schema.properties.contains_key("confirm")); - assert!( - requested_schema - .required - .as_ref() - .is_some_and(|required| required.iter().any(|field| field == "confirm")) - ); - self.messages.lock().expect("messages lock").push(message); - let decision = self - .decisions - .lock() - .expect("decisions lock") - .pop_front() - .unwrap_or(ApprovalDecision::Cancel); - if matches!(decision, ApprovalDecision::ProtocolError) { - return Err(rmcp::ErrorData::internal_error( - "elicitation transport failed", - None, - )); - } - Ok(match decision { - ApprovalDecision::Confirm(confirm) => ElicitResult::new(ElicitationAction::Accept) - .with_content(serde_json::json!({ "confirm": confirm })), - ApprovalDecision::Decline => ElicitResult::new(ElicitationAction::Decline), - ApprovalDecision::Cancel => ElicitResult::new(ElicitationAction::Cancel), - ApprovalDecision::InvalidContent => ElicitResult::new(ElicitationAction::Accept) - .with_content(serde_json::json!({ "confirm": "not-a-boolean" })), - ApprovalDecision::ProtocolError => unreachable!("returned above"), - }) + ) -> impl std::future::Future> + Send + { + std::future::ready((|| { + let ElicitRequestParams::FormElicitationParams { + message, + requested_schema, + .. + } = request + else { + return Err(rmcp::ErrorData::invalid_params( + "database write approval requires form elicitation", + None, + )); + }; + assert!(requested_schema.properties.contains_key("confirm")); + assert!( + requested_schema + .required + .as_ref() + .is_some_and(|required| required.iter().any(|field| field == "confirm")) + ); + self.messages.lock().expect("messages lock").push(message); + let decision = self + .decisions + .lock() + .expect("decisions lock") + .pop_front() + .unwrap_or(ApprovalDecision::Cancel); + if matches!(decision, ApprovalDecision::ProtocolError) { + return Err(rmcp::ErrorData::internal_error( + "elicitation transport failed", + None, + )); + } + Ok(match decision { + ApprovalDecision::Confirm(confirm) => { + ElicitResult::new(ElicitationAction::Accept) + .with_content(serde_json::json!({ "confirm": confirm })) + } + ApprovalDecision::Decline => ElicitResult::new(ElicitationAction::Decline), + ApprovalDecision::Cancel => ElicitResult::new(ElicitationAction::Cancel), + ApprovalDecision::InvalidContent => { + ElicitResult::new(ElicitationAction::Accept) + .with_content(serde_json::json!({ "confirm": "not-a-boolean" })) + } + ApprovalDecision::ProtocolError => unreachable!("returned above"), + }) + })()) } } diff --git a/apps/chat2db-web/Cargo.toml b/apps/chat2db-web/Cargo.toml index 5943fa1..9c00790 100644 --- a/apps/chat2db-web/Cargo.toml +++ b/apps/chat2db-web/Cargo.toml @@ -16,6 +16,7 @@ chat2db-contract = { path = "../../crates/chat2db-contract" } chat2db-core = { path = "../../crates/chat2db-core" } chat2db-java-bridge = { path = "../../crates/chat2db-java-bridge" } chat2db-local = { path = "../../crates/chat2db-local" } +chat2db-runtime = { path = "../../crates/chat2db-runtime" } chat2db-storage = { path = "../../crates/chat2db-storage" } chrono.workspace = true futures-util.workspace = true diff --git a/apps/chat2db-web/src/legacy.rs b/apps/chat2db-web/src/legacy.rs index f59d7c7..1084c75 100644 --- a/apps/chat2db-web/src/legacy.rs +++ b/apps/chat2db-web/src/legacy.rs @@ -10435,7 +10435,7 @@ async fn large_cell_download_handler( #[cfg(test)] mod tests { - use std::{io::Write as _, sync::Arc}; + use std::sync::Arc; use axum::{ body::Body, diff --git a/apps/chat2db-web/src/legacy_ai.rs b/apps/chat2db-web/src/legacy_ai.rs index a7e9387..7e2e060 100644 --- a/apps/chat2db-web/src/legacy_ai.rs +++ b/apps/chat2db-web/src/legacy_ai.rs @@ -1753,7 +1753,9 @@ fn extract_binary_text(bytes: &[u8]) -> String { output.push_str(&String::from_utf8_lossy(&ascii)); } let utf16 = bytes - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) .collect::>(); let decoded = String::from_utf16_lossy(&utf16); diff --git a/apps/chat2db-web/src/main.rs b/apps/chat2db-web/src/main.rs index 0a9fa9a..3582842 100644 --- a/apps/chat2db-web/src/main.rs +++ b/apps/chat2db-web/src/main.rs @@ -1,15 +1,14 @@ use std::{env, ffi::OsString, io, net::SocketAddr, path::PathBuf, process::ExitCode}; -use chat2db_core::{RuntimeConfig, RuntimeHost, load_fixed_community_classpath}; -use chat2db_java_bridge::{EngineCommand, EngineConfig}; +use chat2db_core::{RuntimeConfig, RuntimeHost}; use chat2db_local::LocalServer; +use chat2db_runtime::RuntimeOptions; use tokio::net::TcpListener; use tracing::info; use tracing_subscriber::EnvFilter; const DEFAULT_BIND_ADDRESS: &str = "127.0.0.1:4200"; const DEFAULT_FRONTEND_DIR: &str = "apps/frontend/dist"; -const COMMUNITY_CLASSPATH_DIR_ENV: &str = "CHAT2DB_COMMUNITY_CLASSPATH_DIR"; #[tokio::main] async fn main() -> ExitCode { @@ -85,37 +84,13 @@ async fn run() -> Result<(), Box> { } fn runtime_config_from_env() -> Result> { - let engine_jar = PathBuf::from(required_nonempty_os_env("CHAT2DB_JAVA_ENGINE_JAR")?); - let java = - optional_nonempty_os_env("CHAT2DB_JAVA_BIN")?.unwrap_or_else(|| OsString::from("java")); - let mut engine = EngineConfig::new(EngineCommand::java_jar(java, engine_jar)); - if let Some(community_classpath_dir) = optional_nonempty_os_env(COMMUNITY_CLASSPATH_DIR_ENV)? { - engine = engine.with_community_classpath(load_fixed_community_classpath(PathBuf::from( - community_classpath_dir, - ))?); - } - let mut config = RuntimeConfig::new(engine); - - if let Some(data_dir) = optional_nonempty_os_env("CHAT2DB_DATA_DIR")? { - config = config.with_data_dir(PathBuf::from(data_dir)); - } - if let Some(driver_pack_dir) = optional_nonempty_os_env("CHAT2DB_DRIVER_PACK_DIR")? { - config = config.with_driver_pack_dir(PathBuf::from(driver_pack_dir)); - } - if let Some(master_key) = optional_unicode_env("CHAT2DB_VAULT_MASTER_KEY")? { - config = config.with_vault_master_key_base64(master_key); - } - - Ok(config) -} - -fn required_nonempty_os_env(name: &'static str) -> Result { - optional_nonempty_os_env(name)?.ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("{name} is required and must not be empty"), - ) + let executable = env::current_exe().ok(); + chat2db_runtime::runtime_config_from_environment(RuntimeOptions { + data_dir: None, + executable: executable.as_deref(), + resource_dir: None, }) + .map_err(Into::into) } fn optional_nonempty_os_env(name: &'static str) -> Result, io::Error> { @@ -129,14 +104,6 @@ fn optional_nonempty_os_env(name: &'static str) -> Result, io:: } } -fn optional_unicode_env(name: &'static str) -> Result, env::VarError> { - match env::var(name) { - Ok(value) => Ok(Some(value)), - Err(env::VarError::NotPresent) => Ok(None), - Err(error @ env::VarError::NotUnicode(_)) => Err(error), - } -} - async fn shutdown_signal() { #[cfg(unix)] { diff --git a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs index 7323e53..b9960b1 100644 --- a/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs +++ b/apps/chat2db-web/tests/native_mysql_editable_ddl_docker.rs @@ -42,8 +42,7 @@ struct MysqlTestConfig { impl MysqlTestConfig { fn from_environment() -> Option { let required = std::env::var("MYSQL_TEST_REQUIRED") - .ok() - .is_some_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE")); + .is_ok_and(|value| matches!(value.as_str(), "1" | "true" | "TRUE")); let configured = REQUIRED_MYSQL_ENV .iter() .filter(|name| std::env::var_os(name).is_some()) diff --git a/crates/chat2db-core/src/agent/execution.rs b/crates/chat2db-core/src/agent/execution.rs index 8e73217..e20753c 100644 --- a/crates/chat2db-core/src/agent/execution.rs +++ b/crates/chat2db-core/src/agent/execution.rs @@ -2257,7 +2257,7 @@ fn parse_hex_digest(value: &str) -> Result<[u8; 32], AppError> { )); } let mut digest = [0_u8; 32]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() { digest[index] = (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1]); } Ok(digest) diff --git a/crates/chat2db-core/src/lib.rs b/crates/chat2db-core/src/lib.rs index 2abe97a..9f458cc 100644 --- a/crates/chat2db-core/src/lib.rs +++ b/crates/chat2db-core/src/lib.rs @@ -799,6 +799,11 @@ impl Application { self.inner.operations.cancel(id).await } + /// Returns the number of process-local database operations still running. + pub async fn active_operation_count(&self) -> usize { + self.inner.operations.active_count().await + } + /// Atomically obtains replay events and a live operation subscription. /// /// # Errors diff --git a/crates/chat2db-core/src/native_oracle.rs b/crates/chat2db-core/src/native_oracle.rs index ae16b50..eaa0759 100644 --- a/crates/chat2db-core/src/native_oracle.rs +++ b/crates/chat2db-core/src/native_oracle.rs @@ -619,7 +619,7 @@ fn parse_jdbc_oracle_target( if target.is_empty() || target.starts_with('(') { return Err(invalid_connection_url()); } - let (target, query) = target.split_once('?').map_or((target, ""), |parts| parts); + let (target, query) = target.split_once('?').unwrap_or((target, "")); let target = target.trim_start_matches('/'); if target.is_empty() || (!target.contains('/') && target.matches(':').count() != 2) { return Err(invalid_connection_url()); diff --git a/crates/chat2db-core/src/native_postgres.rs b/crates/chat2db-core/src/native_postgres.rs index bb88085..944c402 100644 --- a/crates/chat2db-core/src/native_postgres.rs +++ b/crates/chat2db-core/src/native_postgres.rs @@ -3470,8 +3470,10 @@ fn decode_postgres_numeric(raw: &[u8]) -> Result { .ok_or_else(|| postgres_scalar_too_large(raw.len()))?; ensure_postgres_scalar_size(maximum_display_bytes)?; let digits = raw[8..] - .chunks_exact(2) - .map(read_u16) + .as_chunks::<2>() + .0 + .iter() + .map(|pair| read_u16(pair)) .collect::, _>>()?; if digits.iter().any(|digit| *digit > 9_999) { return Err(result_decode_error()); diff --git a/crates/chat2db-core/src/operation.rs b/crates/chat2db-core/src/operation.rs index 7866dd1..d84c6ec 100644 --- a/crates/chat2db-core/src/operation.rs +++ b/crates/chat2db-core/src/operation.rs @@ -239,6 +239,24 @@ impl OperationHub { } } + pub(crate) async fn active_count(&self) -> usize { + let entries = self + .inner + .operations + .read() + .await + .values() + .cloned() + .collect::>(); + let mut active = 0; + for entry in entries { + if entry.state.lock().await.status == OperationStatus::Running { + active += 1; + } + } + active + } + pub(crate) async fn started(&self, id: &str) -> Result<(), AppError> { self.emit(id, OperationEvent::Started).await } diff --git a/crates/chat2db-core/src/ssh.rs b/crates/chat2db-core/src/ssh.rs index 4ad0aef..11c4300 100644 --- a/crates/chat2db-core/src/ssh.rs +++ b/crates/chat2db-core/src/ssh.rs @@ -245,11 +245,13 @@ struct HostKeyHandler { impl client::Handler for HostKeyHandler { type Error = russh::Error; - async fn check_server_key( + fn check_server_key( &mut self, server_public_key: &ssh_key::PublicKey, - ) -> Result { - keys::check_known_hosts(&self.host, self.port, server_public_key).map_err(Into::into) + ) -> impl std::future::Future> + Send { + std::future::ready( + keys::check_known_hosts(&self.host, self.port, server_public_key).map_err(Into::into), + ) } } diff --git a/crates/chat2db-java-bridge/src/supervisor/community.rs b/crates/chat2db-java-bridge/src/supervisor/community.rs index 83d4903..427962b 100644 --- a/crates/chat2db-java-bridge/src/supervisor/community.rs +++ b/crates/chat2db-java-bridge/src/supervisor/community.rs @@ -476,7 +476,7 @@ fn decode_lock_sha256(value: &str) -> Result<[u8; 32], BridgeError> { )); } let mut digest = [0_u8; 32]; - for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() { digest[index] = (hex_nibble(pair[0]) << 4) | hex_nibble(pair[1]); } Ok(digest) diff --git a/crates/chat2db-java-bridge/src/supervisor/jdbc.rs b/crates/chat2db-java-bridge/src/supervisor/jdbc.rs index 3d237f2..b2feaa0 100644 --- a/crates/chat2db-java-bridge/src/supervisor/jdbc.rs +++ b/crates/chat2db-java-bridge/src/supervisor/jdbc.rs @@ -1147,9 +1147,8 @@ impl Session { &self.binding.engine_instance_id } - #[allow(clippy::unused_async)] - pub async fn state(&self) -> SessionState { - self.state.get() + pub fn state(&self) -> std::future::Ready { + std::future::ready(self.state.get()) } /// Closes the session, rolling back any active local transaction. diff --git a/crates/chat2db-local/src/client.rs b/crates/chat2db-local/src/client.rs index a4b3877..1758239 100644 --- a/crates/chat2db-local/src/client.rs +++ b/crates/chat2db-local/src/client.rs @@ -33,6 +33,12 @@ impl LocalClient { } } + /// Returns the product data directory used for endpoint discovery. + #[must_use] + pub fn data_dir(&self) -> &std::path::Path { + &self.data_dir + } + /// Discovers the operating system's standard `Chat2DB` data directory. /// /// # Errors diff --git a/crates/chat2db-local/src/server.rs b/crates/chat2db-local/src/server.rs index ff7d274..5317158 100644 --- a/crates/chat2db-local/src/server.rs +++ b/crates/chat2db-local/src/server.rs @@ -1,4 +1,12 @@ -use std::{fs, io, path::Path, time::Duration}; +use std::{ + fs, io, + path::Path, + sync::{ + Arc, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, + time::Duration, +}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chat2db_contract::ApiError; @@ -25,6 +33,13 @@ pub struct LocalServer { data_dir: std::path::PathBuf, metadata: EndpointMetadata, lock: Option, + activity: Arc, +} + +#[derive(Debug, Default)] +struct LocalActivity { + revision: AtomicU64, + active_requests: AtomicUsize, } impl std::fmt::Debug for LocalServer { @@ -75,6 +90,7 @@ impl LocalServer { let cancellation = CancellationToken::new(); let task_cancellation = cancellation.clone(); let task_metadata = metadata.clone(); + let activity = Arc::new(LocalActivity::default()); let task = runtime.spawn(run( listener, application, @@ -82,6 +98,7 @@ impl LocalServer { task_cancellation, data_dir.clone(), task_metadata, + Arc::clone(&activity), )); Ok(Self { cancellation, @@ -89,9 +106,22 @@ impl LocalServer { data_dir, metadata, lock: Some(lock), + activity, }) } + /// Monotonically increases whenever a local client request is accepted. + #[must_use] + pub fn activity_revision(&self) -> u64 { + self.activity.revision.load(Ordering::Acquire) + } + + /// Returns local attachment requests that have not finished responding. + #[must_use] + pub fn active_request_count(&self) -> usize { + self.activity.active_requests.load(Ordering::Acquire) + } + /// Stops accepting clients, terminates active attachment requests, and /// removes discovery material. /// @@ -140,6 +170,7 @@ async fn run( cancellation: CancellationToken, data_dir: std::path::PathBuf, metadata: EndpointMetadata, + activity: Arc, ) -> Result<(), LocalError> { let _discovery = DiscoveryGuard { data_dir, metadata }; let mut connections = JoinSet::new(); @@ -154,8 +185,12 @@ async fn run( () = cancellation.cancelled() => break, accepted = listener.accept() => accepted, }?; + activity.revision.fetch_add(1, Ordering::AcqRel); + activity.active_requests.fetch_add(1, Ordering::AcqRel); let application = application.clone(); + let request_counter = Arc::clone(&activity); connections.spawn(async move { + let _request_guard = ActiveRequestGuard(request_counter); if let Err(error) = handle_connection(accepted, application, token).await { tracing::warn!(%error, "local attachment request failed"); } @@ -167,6 +202,14 @@ async fn run( Ok(()) } +struct ActiveRequestGuard(Arc); + +impl Drop for ActiveRequestGuard { + fn drop(&mut self) { + self.0.active_requests.fetch_sub(1, Ordering::AcqRel); + } +} + async fn handle_connection( mut io: transport::BoxedIo, application: Application, diff --git a/crates/chat2db-runtime/Cargo.toml b/crates/chat2db-runtime/Cargo.toml new file mode 100644 index 0000000..8dfb555 --- /dev/null +++ b/crates/chat2db-runtime/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "chat2db-runtime" +description = "Shared runtime resource and host configuration for Chat2DB Rust delivery modes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license-file.workspace = true +repository.workspace = true + +[dependencies] +chat2db-core = { path = "../chat2db-core" } +chat2db-java-bridge = { path = "../chat2db-java-bridge" } +thiserror.workspace = true + +[dev-dependencies] +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/chat2db-runtime/src/lib.rs b/crates/chat2db-runtime/src/lib.rs new file mode 100644 index 0000000..6a3d3ee --- /dev/null +++ b/crates/chat2db-runtime/src/lib.rs @@ -0,0 +1,545 @@ +//! Shared runtime resource discovery for desktop, Web, and headless hosts. + +use std::{ + env, + ffi::OsString, + fs, + path::{Path, PathBuf}, +}; + +use chat2db_core::{RuntimeConfig, load_fixed_community_classpath}; +use chat2db_java_bridge::{BridgeError, EngineCommand, EngineConfig}; +use thiserror::Error; + +pub const DATA_DIR_ENV: &str = "CHAT2DB_DATA_DIR"; +pub const DRIVER_PACK_DIR_ENV: &str = "CHAT2DB_DRIVER_PACK_DIR"; +pub const COMMUNITY_CLASSPATH_DIR_ENV: &str = "CHAT2DB_COMMUNITY_CLASSPATH_DIR"; +pub const JAVA_BIN_ENV: &str = "CHAT2DB_JAVA_BIN"; +pub const JAVA_ENGINE_JAR_ENV: &str = "CHAT2DB_JAVA_ENGINE_JAR"; +pub const VAULT_MASTER_KEY_ENV: &str = "CHAT2DB_VAULT_MASTER_KEY"; + +const BUNDLED_JAVA_BIN: &str = "Java binary"; +const BUNDLED_JAVA_ENGINE_JAR: &str = "compatibility-engine JAR"; +const BUNDLED_COMMUNITY_CLASSPATH: &str = "Community classpath"; +const BUNDLED_DRIVER_PACKS: &str = "driver packs"; + +/// Explicit host inputs layered over process environment and packaged resources. +#[derive(Debug, Default)] +pub struct RuntimeOptions<'a> { + pub data_dir: Option, + pub executable: Option<&'a Path>, + pub resource_dir: Option<&'a Path>, +} + +/// Runtime resource lookup or validation failure. +#[derive(Debug, Error)] +pub enum RuntimeConfigError { + #[error("{JAVA_ENGINE_JAR_ENV} is required and must point to the compatibility-engine JAR")] + MissingJavaEngineJar, + #[error("{0} must not be empty when configured")] + EmptyEnvironmentVariable(&'static str), + #[error("{JAVA_ENGINE_JAR_ENV} does not point to a regular file: {}", .0.display())] + InvalidJavaEngineJar(PathBuf), + #[error("unable to inspect {JAVA_ENGINE_JAR_ENV} at {}: {source}", path.display())] + JavaEngineJarMetadata { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("bundled {resource} is missing or is not a {expected}: {}", path.display())] + InvalidBundledResource { + resource: &'static str, + expected: &'static str, + path: PathBuf, + }, + #[error("{VAULT_MASTER_KEY_ENV} must contain valid UTF-8")] + InvalidVaultMasterKeyEncoding, + #[error("fixed Community classpath failed validation: {0}")] + CommunityClasspath(#[source] Box), +} + +#[derive(Debug, Default)] +struct RuntimeResourceOverrides { + java_bin: Option, + java_engine_jar: Option, + community_classpath_dir: Option, + driver_pack_dir: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct RuntimeResourcePaths { + java_bin: OsString, + java_engine_jar: PathBuf, + community_classpath_dir: Option, + driver_pack_dir: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct BundledRuntimeResources { + java_bin: PathBuf, + java_engine_jar: PathBuf, + community_classpath_dir: PathBuf, + driver_pack_dir: PathBuf, +} + +impl BundledRuntimeResources { + fn from_resource_dir(resource_dir: &Path) -> Option { + resource_dir + .is_absolute() + .then(|| Self::from_resource_root(&resource_dir.join("chat2db"))) + } + + fn from_resource_root(resource_root: &Path) -> Self { + Self { + java_bin: resource_root + .join("java") + .join("bin") + .join(if cfg!(windows) { "java.exe" } else { "java" }), + java_engine_jar: resource_root + .join("engine") + .join("chat2db-compat-runtime.jar"), + community_classpath_dir: resource_root.join("community-classpath"), + driver_pack_dir: resource_root.join("driver-packs"), + } + } + + fn from_executable(executable: &Path) -> Option { + let executable_dir = executable.parent()?; + + if executable_dir.file_name() == Some(std::ffi::OsStr::new("MacOS")) { + let contents_dir = executable_dir.parent()?; + if contents_dir.file_name() == Some(std::ffi::OsStr::new("Contents")) + && contents_dir + .parent() + .is_some_and(|path| path.extension() == Some(std::ffi::OsStr::new("app"))) + { + return Some(Self::from_resource_root( + &contents_dir.join("Resources").join("chat2db"), + )); + } + } + + [ + executable_dir.parent().map(Path::to_path_buf), + Some(executable_dir.join("resources").join("chat2db")), + executable_dir + .parent() + .map(|path| path.join("resources").join("chat2db")), + executable_dir + .parent() + .map(|path| path.join("lib").join("chat2db")), + ] + .into_iter() + .flatten() + .find(|root| { + root.join("engine") + .join("chat2db-compat-runtime.jar") + .is_file() + }) + .map(|root| Self::from_resource_root(&root)) + } +} + +/// Builds one lazy-Java runtime configuration shared by every delivery mode. +/// +/// Explicit `data_dir` wins over `CHAT2DB_DATA_DIR`. Resource environment +/// overrides win over packaged resource discovery. +/// +/// # Errors +/// +/// Returns a validation error when configured or packaged runtime resources +/// are missing, unsafe, or incompatible. +pub fn runtime_config_from_environment( + options: RuntimeOptions<'_>, +) -> Result { + let overrides = RuntimeResourceOverrides { + java_engine_jar: optional_nonempty_os_env(JAVA_ENGINE_JAR_ENV)?, + java_bin: optional_nonempty_os_env(JAVA_BIN_ENV)?, + community_classpath_dir: optional_nonempty_os_env(COMMUNITY_CLASSPATH_DIR_ENV)?, + driver_pack_dir: optional_nonempty_os_env(DRIVER_PACK_DIR_ENV)?, + }; + let resources = + resolve_runtime_resource_paths(options.executable, options.resource_dir, overrides)?; + let mut engine = EngineConfig::new(EngineCommand::java_jar( + resources.java_bin, + resources.java_engine_jar, + )); + if let Some(community_classpath_dir) = resources.community_classpath_dir { + let classpath = load_fixed_community_classpath(community_classpath_dir) + .map_err(|error| RuntimeConfigError::CommunityClasspath(Box::new(error)))?; + engine = engine.with_community_classpath(classpath); + } + let mut config = RuntimeConfig::new(engine); + + let data_dir = match options.data_dir { + Some(data_dir) => Some(data_dir), + None => optional_nonempty_os_env(DATA_DIR_ENV)?.map(PathBuf::from), + }; + if let Some(data_dir) = data_dir { + config = config.with_data_dir(data_dir); + } + if let Some(driver_pack_dir) = resources.driver_pack_dir { + config = config.with_driver_pack_dir(driver_pack_dir); + } + match env::var(VAULT_MASTER_KEY_ENV) { + Ok(master_key) => config = config.with_vault_master_key_base64(master_key), + Err(env::VarError::NotPresent) => {} + Err(env::VarError::NotUnicode(_)) => { + return Err(RuntimeConfigError::InvalidVaultMasterKeyEncoding); + } + } + Ok(config) +} + +fn resolve_runtime_resource_paths( + executable: Option<&Path>, + resource_dir: Option<&Path>, + overrides: RuntimeResourceOverrides, +) -> Result { + let bundled = resource_dir + .and_then(BundledRuntimeResources::from_resource_dir) + .or_else(|| executable.and_then(BundledRuntimeResources::from_executable)); + + let java_bin = match overrides.java_bin { + Some(java_bin) => java_bin, + None => match bundled.as_ref() { + Some(resources) => { + validate_bundled_file(BUNDLED_JAVA_BIN, &resources.java_bin)?; + resources.java_bin.clone().into_os_string() + } + None => OsString::from(if cfg!(windows) { "java.exe" } else { "java" }), + }, + }; + let java_engine_jar = match overrides.java_engine_jar { + Some(java_engine_jar) => { + let path = PathBuf::from(java_engine_jar); + validate_java_engine_jar(&path)?; + path + } + None => match bundled.as_ref() { + Some(resources) => { + validate_bundled_file(BUNDLED_JAVA_ENGINE_JAR, &resources.java_engine_jar)?; + resources.java_engine_jar.clone() + } + None => return Err(RuntimeConfigError::MissingJavaEngineJar), + }, + }; + let community_classpath_dir = resolve_directory_override( + overrides.community_classpath_dir, + bundled + .as_ref() + .map(|resources| &resources.community_classpath_dir), + BUNDLED_COMMUNITY_CLASSPATH, + )?; + let driver_pack_dir = resolve_directory_override( + overrides.driver_pack_dir, + bundled.as_ref().map(|resources| &resources.driver_pack_dir), + BUNDLED_DRIVER_PACKS, + )?; + + Ok(RuntimeResourcePaths { + java_bin, + java_engine_jar, + community_classpath_dir, + driver_pack_dir, + }) +} + +fn resolve_directory_override( + override_path: Option, + bundled_path: Option<&PathBuf>, + resource: &'static str, +) -> Result, RuntimeConfigError> { + match override_path { + Some(directory) => Ok(Some(PathBuf::from(directory))), + None => match bundled_path { + Some(directory) => { + validate_bundled_directory(resource, directory)?; + Ok(Some((*directory).clone())) + } + None => Ok(None), + }, + } +} + +fn optional_nonempty_os_env(name: &'static str) -> Result, RuntimeConfigError> { + validate_optional_os_env(name, env::var_os(name)) +} + +fn validate_optional_os_env( + name: &'static str, + value: Option, +) -> Result, RuntimeConfigError> { + match value { + Some(value) if value.is_empty() => Err(RuntimeConfigError::EmptyEnvironmentVariable(name)), + value => Ok(value), + } +} + +fn validate_java_engine_jar(path: &Path) -> Result<(), RuntimeConfigError> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) => Err(RuntimeConfigError::InvalidJavaEngineJar(path.to_path_buf())), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + Err(RuntimeConfigError::InvalidJavaEngineJar(path.to_path_buf())) + } + Err(source) => Err(RuntimeConfigError::JavaEngineJarMetadata { + path: path.to_path_buf(), + source, + }), + } +} + +fn validate_bundled_file(resource: &'static str, path: &Path) -> Result<(), RuntimeConfigError> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) | Err(_) => Err(RuntimeConfigError::InvalidBundledResource { + resource, + expected: "regular file", + path: path.to_path_buf(), + }), + } +} + +fn validate_bundled_directory( + resource: &'static str, + path: &Path, +) -> Result<(), RuntimeConfigError> { + match fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) | Err(_) => Err(RuntimeConfigError::InvalidBundledResource { + resource, + expected: "directory", + path: path.to_path_buf(), + }), + } +} + +#[cfg(test)] +mod tests { + use std::{ + ffi::OsString, + fs::{self, File}, + path::PathBuf, + }; + + use super::{ + BUNDLED_COMMUNITY_CLASSPATH, BUNDLED_DRIVER_PACKS, BUNDLED_JAVA_BIN, + BUNDLED_JAVA_ENGINE_JAR, BundledRuntimeResources, RuntimeConfigError, + RuntimeResourceOverrides, resolve_runtime_resource_paths, validate_java_engine_jar, + validate_optional_os_env, + }; + + fn complete_app_bundle() -> (tempfile::TempDir, PathBuf, BundledRuntimeResources) { + let directory = tempfile::tempdir().expect("temporary app bundle"); + let executable = directory + .path() + .join("Chat2DB.app") + .join("Contents") + .join("MacOS") + .join("chat2db"); + fs::create_dir_all(executable.parent().expect("bundle executable parent")) + .expect("bundle executable directory"); + File::create(&executable).expect("bundle executable"); + + let resources = BundledRuntimeResources::from_executable(&executable) + .expect("synthetic executable must be recognized as an app bundle"); + fs::create_dir_all(resources.java_bin.parent().expect("Java binary parent")) + .expect("bundled Java directory"); + File::create(&resources.java_bin).expect("bundled Java binary"); + fs::create_dir_all( + resources + .java_engine_jar + .parent() + .expect("engine JAR parent"), + ) + .expect("bundled engine directory"); + File::create(&resources.java_engine_jar).expect("bundled engine JAR"); + fs::create_dir_all(&resources.community_classpath_dir) + .expect("bundled Community classpath"); + fs::create_dir_all(&resources.driver_pack_dir).expect("bundled driver packs"); + + (directory, executable, resources) + } + + #[test] + fn macos_app_bundle_supplies_all_default_runtime_resources() { + let (_directory, executable, bundled) = complete_app_bundle(); + let resolved = resolve_runtime_resource_paths( + Some(&executable), + None, + RuntimeResourceOverrides::default(), + ) + .expect("complete app bundle must resolve"); + + assert_eq!(resolved.java_bin, bundled.java_bin.into_os_string()); + assert_eq!(resolved.java_engine_jar, bundled.java_engine_jar); + assert_eq!( + resolved.community_classpath_dir, + Some(bundled.community_classpath_dir) + ); + assert_eq!(resolved.driver_pack_dir, Some(bundled.driver_pack_dir)); + } + + #[test] + fn resource_directory_supplies_non_macos_runtime_resources() { + let directory = tempfile::tempdir().expect("temporary resource directory"); + let resource_dir = directory.path().join("resources"); + let bundled = BundledRuntimeResources::from_resource_dir(&resource_dir) + .expect("absolute resource directory must resolve"); + fs::create_dir_all(bundled.java_bin.parent().expect("Java binary parent")) + .expect("bundled Java directory"); + File::create(&bundled.java_bin).expect("bundled Java binary"); + fs::create_dir_all(bundled.java_engine_jar.parent().expect("engine JAR parent")) + .expect("bundled engine directory"); + File::create(&bundled.java_engine_jar).expect("bundled engine JAR"); + fs::create_dir_all(&bundled.community_classpath_dir).expect("bundled Community classpath"); + fs::create_dir_all(&bundled.driver_pack_dir).expect("bundled driver packs"); + + let resolved = resolve_runtime_resource_paths( + None, + Some(&resource_dir), + RuntimeResourceOverrides::default(), + ) + .expect("resource directory must resolve"); + assert_eq!(resolved.java_bin, bundled.java_bin.into_os_string()); + assert_eq!(resolved.java_engine_jar, bundled.java_engine_jar); + } + + #[test] + fn embedded_cli_discovers_its_sibling_runtime_resources() { + let directory = tempfile::tempdir().expect("temporary resource root"); + let resource_root = directory.path().join("chat2db"); + let executable = resource_root.join("bin").join("chat2db"); + let bundled = BundledRuntimeResources::from_resource_root(&resource_root); + fs::create_dir_all(executable.parent().expect("CLI parent")).expect("CLI directory"); + File::create(&executable).expect("CLI executable"); + fs::create_dir_all(bundled.java_bin.parent().expect("Java binary parent")) + .expect("bundled Java directory"); + File::create(&bundled.java_bin).expect("bundled Java binary"); + fs::create_dir_all(bundled.java_engine_jar.parent().expect("engine JAR parent")) + .expect("bundled engine directory"); + File::create(&bundled.java_engine_jar).expect("bundled engine JAR"); + fs::create_dir_all(&bundled.community_classpath_dir).expect("Community classpath"); + fs::create_dir_all(&bundled.driver_pack_dir).expect("driver packs"); + + let resolved = resolve_runtime_resource_paths( + Some(&executable), + None, + RuntimeResourceOverrides::default(), + ) + .expect("embedded CLI layout must resolve"); + assert_eq!(resolved.java_engine_jar, bundled.java_engine_jar); + assert_eq!(resolved.driver_pack_dir, Some(bundled.driver_pack_dir)); + } + + #[test] + fn app_bundle_reports_each_missing_runtime_resource() { + for missing_resource in [ + BUNDLED_JAVA_BIN, + BUNDLED_JAVA_ENGINE_JAR, + BUNDLED_COMMUNITY_CLASSPATH, + BUNDLED_DRIVER_PACKS, + ] { + let (_directory, executable, bundled) = complete_app_bundle(); + let (missing_path, is_directory) = match missing_resource { + BUNDLED_JAVA_BIN => (bundled.java_bin, false), + BUNDLED_JAVA_ENGINE_JAR => (bundled.java_engine_jar, false), + BUNDLED_COMMUNITY_CLASSPATH => (bundled.community_classpath_dir, true), + BUNDLED_DRIVER_PACKS => (bundled.driver_pack_dir, true), + _ => unreachable!("all bundled resources are covered"), + }; + if is_directory { + fs::remove_dir_all(&missing_path).expect("remove bundled directory"); + } else { + fs::remove_file(&missing_path).expect("remove bundled file"); + } + + let error = resolve_runtime_resource_paths( + Some(&executable), + None, + RuntimeResourceOverrides::default(), + ) + .expect_err("missing bundled resource must fail closed"); + assert!(matches!( + error, + RuntimeConfigError::InvalidBundledResource { resource, path, .. } + if resource == missing_resource && path == missing_path + )); + } + } + + #[test] + fn development_executable_still_requires_java_engine_environment() { + let directory = tempfile::tempdir().expect("temporary development layout"); + let executable = directory + .path() + .join("target") + .join("debug") + .join("chat2db"); + + assert!(matches!( + resolve_runtime_resource_paths( + Some(&executable), + None, + RuntimeResourceOverrides::default(), + ), + Err(RuntimeConfigError::MissingJavaEngineJar) + )); + } + + #[test] + fn optional_path_environment_rejects_explicit_empty_values() { + assert!(matches!( + validate_optional_os_env("CHAT2DB_DRIVER_PACK_DIR", Some(OsString::new())), + Err(RuntimeConfigError::EmptyEnvironmentVariable( + "CHAT2DB_DRIVER_PACK_DIR" + )) + )); + assert_eq!( + validate_optional_os_env("CHAT2DB_DRIVER_PACK_DIR", None) + .expect("missing optional variable must be accepted"), + None + ); + } + + #[test] + fn environment_paths_override_missing_bundle_resources() { + let directory = tempfile::tempdir().expect("temporary directory"); + let java_bin = directory.path().join("java"); + let engine_jar = directory.path().join("engine.jar"); + File::create(&java_bin).expect("Java binary"); + File::create(&engine_jar).expect("engine JAR"); + + let resolved = resolve_runtime_resource_paths( + None, + None, + RuntimeResourceOverrides { + java_bin: Some(java_bin.clone().into_os_string()), + java_engine_jar: Some(engine_jar.clone().into_os_string()), + community_classpath_dir: Some(OsString::from("community")), + driver_pack_dir: Some(OsString::from("drivers")), + }, + ) + .expect("explicit overrides must resolve"); + assert_eq!(resolved.java_bin, java_bin.into_os_string()); + assert_eq!(resolved.java_engine_jar, engine_jar); + } + + #[test] + fn missing_engine_jar_is_rejected() { + assert!(matches!( + resolve_runtime_resource_paths(None, None, RuntimeResourceOverrides::default(),), + Err(RuntimeConfigError::MissingJavaEngineJar) + )); + } + + #[test] + fn engine_jar_must_be_a_regular_file() { + let directory = tempfile::tempdir().expect("temporary directory"); + assert!(matches!( + validate_java_engine_jar(directory.path()), + Err(RuntimeConfigError::InvalidJavaEngineJar(_)) + )); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index ff8b790..1329ed3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,9 @@ reaps Java. A later request starts a new generation and reloads every staged driver pack. Axum serves JSON, SSE, and the exact pinned original Community Umi/React SPA; Tauri exposes commands and per-subscription channels without a localhost product server. Both hosts also publish an owner-only local endpoint -for the CLI and MCP process. That same `Application` owns query and Agent run +for the CLI and MCP process. When neither host is running, the CLI can launch a +headless `RuntimeHost` plus `LocalServer` from the same binary without Tauri, +WebKit, or HTTP. That same `Application` owns query and Agent run execution, replay, cancellation, and write-permission decisions. Strict local managed driver packs and immutable inventory are implemented. A fixed Community 5.3.0 submodule now supplies a real H2 compatibility slice for plugin discovery, @@ -62,8 +64,9 @@ list, so an ENUM/SET member containing `UNSIGNED` is not misclassified. Its view metadata route returns the original six-field creation form without querying an existing view. Signing, distribution, the remaining dialect estate, broader historical API -coverage, and packaging remain target components. CLI and MCP attach to a -running host rather than composing a second product runtime. +coverage, and packaging remain target components. MCP attaches to a running +host. CLI defaults to attach-or-start and owns a temporary headless host only +when no compatible local host exists. Runtime-tested: yes for the Stage 7M MySQL product vertical. On 2026-07-27 the complete stored-datasource path passed against MySQL 8.4, from real Community @@ -124,6 +127,12 @@ React in system WebView React in browser -> DM metadata and preview adapter -> generic JDBC session bridge -> official dmJdbcDriver JAR + +Headless CLI + -> reuse owner-only local attachment when available + -> otherwise spawn chat2db runtime serve + -> RuntimeHost + LocalServer + -> no Tauri / WebKit / HTTP listener -> Java Community compatibility for unregistered database types -> Protobuf stdin/stdout -> fixed Community plugin registry @@ -639,8 +648,9 @@ preview slice are not implemented. ## Local attachment and MCP boundary -Web and desktop start `LocalServer` with the same `Application` used by their -primary transport and fail startup if the local endpoint cannot be secured. +Web, desktop, and the headless CLI host start `LocalServer` with the same +`Application` used by their delivery adapter and fail startup if the local +endpoint cannot be secured. Unix uses an owner-only Unix-domain socket and peer credentials. Windows uses an owner-only named pipe plus owner-validated endpoint metadata. Both platforms publish a versioned endpoint record in the process-owned data directory, @@ -651,8 +661,13 @@ The local protocol exposes health, secret-free datasource listing, forced-read-only query start, operation snapshot, idempotent cancellation, row/byte-bounded result paging, and one explicitly confirmed MySQL write. The CLI maps these operations to structured JSON commands and requires -`--confirm-write` for the write command. It does not start another product -runtime or contact Java directly. +`--confirm-write` for the write command. Its default `--host auto` mode first +probes the owner-only endpoint, then spawns its own `runtime serve` child only +when no host responds. `--host attach` disables automatic startup. The headless +host uses the shared runtime resource resolver, protects active requests and +queries from idle shutdown, and exits after 60 idle seconds by default. It does +not create a GUI and starts Java only if the selected operation requires a Java +compatibility capability. `chat2db-mcp` uses `rmcp` 2.2 over standard stdio and maps six tools onto the same `LocalClient`: `list_datasources`, `query_database`, @@ -711,10 +726,10 @@ Agent-run tool or JDBC bind-parameter input. ## Packaging target -The desktop package contains the Tauri/Rust product, React assets, a private Java -compatibility JAR, a jlink-minimized Java 17 runtime, and a small signed core -driver pack. Long-tail driver packs are signed, versioned, downloaded on demand, -and independently rollback-capable. +The desktop package contains the Tauri/Rust product, the matching headless +`chat2db` CLI, React assets, a private Java compatibility JAR, a jlink-minimized +Java 17 runtime, and a small signed core driver pack. Long-tail driver packs are +signed, versioned, downloaded on demand, and independently rollback-capable. The installed-size target is 30% to 45% below the equivalent Community package. This is an acceptance target, not a measured current result. diff --git a/docs/stages.md b/docs/stages.md index f726f01..dd724fa 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -19,8 +19,10 @@ Stage 3 completion means the versioned Rust-Java bridge can load an external JDBC driver, own sessions and local transactions, execute updates, and stream typed query batches under explicit limits, credits, deadlines, and cancellation. Stage 5 composes that bridge into the Web and desktop product -hosts. Stage 6 adds CLI and MCP adapters that attach to one of those running -hosts and do not own another product runtime. +hosts. Stage 6 adds CLI and MCP adapters around the owner-only local endpoint. +The current CLI extends that foundation with attach-or-start behavior: it owns +the same `RuntimeHost` in a temporary headless child when Web and desktop are +absent. MCP remains attachment-only. The current production host supersedes the original eager Stage 5 bootstrap. `RuntimeHost::open` now opens storage and verifies/stages driver packs without @@ -72,8 +74,8 @@ bound to the exact tool call and argument digest. Axum exposes the lifecycle as JSON plus replay/live SSE; Tauri exposes matching commands and independent channels; the frontend has matching HTTP/Tauri observers with bounded recovery. -Web and desktop start the same owner-only local attachment around their shared -`Application`. The JSON CLI exposes health, datasource listing, +Web, desktop, and the CLI-owned headless host start the same owner-only local +attachment around their shared `Application`. The JSON CLI exposes health, datasource listing, forced-read-only query start/status/cancel, bounded retained-result pages, and one MySQL write command gated by `--confirm-write`. The `rmcp` stdio server exposes the matching five datasource/query lifecycle tools plus @@ -86,6 +88,12 @@ capped at 10,000 rows, 16 MiB, and 900 seconds; pages are capped at 1,000 rows and 512 KiB. MCP accepts no JDBC bind-parameter input and exposes no Agent-run tool. +CLI defaults to `--host auto`: it attaches to a compatible Web, desktop, or +headless host and spawns `runtime serve` when none exists. The headless process +does not initialize Tauri, WebKit, or Axum, remains alive while local requests +or queries are active, and exits after 60 idle seconds. `--host attach` keeps +the previous attachment-only contract. + Stage 7A implements strict local JDBC driver-pack discovery, bounded artifact hashing, immutable inventory through Core, Axum, Tauri, and generated frontend contracts, plus repeatable preload into each lazily started Java generation. diff --git a/scripts/build-linux-package.sh b/scripts/build-linux-package.sh index 2c4441e..0cff0c9 100755 --- a/scripts/build-linux-package.sh +++ b/scripts/build-linux-package.sh @@ -68,6 +68,15 @@ esac rm -rf -- "${build_target}" mkdir -p "${build_target}" +cli_build_target="${repository_root}/target/linux-cli-build" +cli_resource_directory="${repository_root}/target/linux-cli" +rm -rf -- "${cli_build_target}" +rm -rf -- "${cli_resource_directory}" +mkdir -p -- "${cli_resource_directory}" +CARGO_TARGET_DIR="${cli_build_target}" RUSTUP_TOOLCHAIN="${rust_toolchain}" \ + cargo build -p chat2db-cli --release --locked +cp -- "${cli_build_target}/release/chat2db" "${cli_resource_directory}/chat2db" +chmod 755 "${cli_resource_directory}/chat2db" ( cd "${desktop_root}" # GitHub's ARM64 runners do not expose FUSE; force AppImage tools to extract @@ -110,6 +119,7 @@ cp -- "${appimage_artifacts[0]}" "${deb_artifacts[0]}" "${rpm_artifacts[0]}" "${ echo "target=linux" echo "rust_toolchain=${rust_toolchain}" echo "tauri_cli=$(cargo tauri --version)" + echo "embedded_cli=chat2db/bin/chat2db" echo "appimage=$(basename "${appimage_artifacts[0]}")" echo "deb=$(basename "${deb_artifacts[0]}")" echo "rpm=$(basename "${rpm_artifacts[0]}")" diff --git a/scripts/build-macos-package.sh b/scripts/build-macos-package.sh index a4598cd..10429d5 100755 --- a/scripts/build-macos-package.sh +++ b/scripts/build-macos-package.sh @@ -96,6 +96,16 @@ if [[ "${rust_version}" != rustc\ 1.88.0\ * ]]; then exit 1 fi +cli_build_target="${repository_root}/target/macos-cli-build" +cli_resource_directory="${repository_root}/target/macos-cli" +rm -rf -- "${cli_build_target}" +rm -rf -- "${cli_resource_directory}" +mkdir -p -- "${cli_resource_directory}" +CARGO_TARGET_DIR="${cli_build_target}" RUSTUP_TOOLCHAIN="${rust_toolchain}" \ + cargo build -p chat2db-cli --release --locked +cp -- "${cli_build_target}/release/chat2db" "${cli_resource_directory}/chat2db" +chmod 755 "${cli_resource_directory}/chat2db" + staged_resource_root="${build_target}/release/chat2db" if [[ -L "${staged_resource_root}" || ( -e "${staged_resource_root}" && ! -d "${staged_resource_root}" ) ]]; then echo "refusing to refresh unsafe staged resource directory: ${staged_resource_root}" >&2 @@ -336,6 +346,7 @@ signing_authority=${signing_authority} signing_team_id=${signing_team_id} notarization_status=${notarization_status} distribution_status=${distribution_status} +embedded_cli=chat2db/bin/chat2db EOF echo "Built self-contained macOS app: ${app_path}" diff --git a/scripts/build-windows-package.sh b/scripts/build-windows-package.sh index 6c7b2f8..c02ff23 100755 --- a/scripts/build-windows-package.sh +++ b/scripts/build-windows-package.sh @@ -71,6 +71,14 @@ fi rm -rf -- "${build_target}" mkdir -p "${build_target}" +cli_build_target="${repository_root}/target/windows-cli-build" +cli_resource_directory="${repository_root}/target/windows-cli" +rm -rf -- "${cli_build_target}" +rm -rf -- "${cli_resource_directory}" +mkdir -p -- "${cli_resource_directory}" +CARGO_TARGET_DIR="${cli_build_target}" RUSTUP_TOOLCHAIN="${rust_toolchain}" \ + cargo build -p chat2db-cli --release --locked +cp -- "${cli_build_target}/release/chat2db.exe" "${cli_resource_directory}/chat2db.exe" ( cd "${desktop_root}" CARGO_TARGET_DIR="${build_target}" \ @@ -113,6 +121,7 @@ cp -- "${msi_artifacts[0]}" "${package_directory}/" echo "target=windows" echo "rust_toolchain=${rust_toolchain}" echo "tauri_cli=$(cargo tauri --version)" + echo "embedded_cli=chat2db/bin/chat2db.exe" echo "nsis=$(basename "${nsis_artifacts[0]}")" echo "msi=$(basename "${msi_artifacts[0]}")" } > BUILD-MANIFEST.txt diff --git a/scripts/verify-macos-package.sh b/scripts/verify-macos-package.sh index 9faa30a..d461251 100755 --- a/scripts/verify-macos-package.sh +++ b/scripts/verify-macos-package.sh @@ -8,6 +8,7 @@ java_bin="${resource_root}/java/bin/java" engine_jar="${resource_root}/engine/chat2db-compat-runtime.jar" community_classpath="${resource_root}/community-classpath" driver_root="${resource_root}/driver-packs" +cli_binary="${resource_root}/bin/chat2db" binary="${app_path}/Contents/MacOS/chat2db-desktop" module_file="${repository_root}/packaging/macos/jlink-modules.txt" @@ -37,12 +38,13 @@ require_file "${java_bin}" require_file "${engine_jar}" require_directory "${community_classpath}" require_directory "${driver_root}" +require_file "${cli_binary}" require_file "${resource_root}/licenses/Chat2DB-Rust-LICENSE.txt" require_file "${resource_root}/licenses/Chat2DB-Community-LICENSE.txt" require_file "${resource_root}/licenses/THIRD_PARTY_NOTICES.md" -if [[ ! -x "${binary}" || ! -x "${java_bin}" ]]; then - echo "packaged desktop and Java binaries must be executable" >&2 +if [[ ! -x "${binary}" || ! -x "${java_bin}" || ! -x "${cli_binary}" ]]; then + echo "packaged desktop, CLI, and Java binaries must be executable" >&2 exit 1 fi @@ -121,6 +123,10 @@ if ! lipo -archs "${java_bin}" | tr ' ' '\n' | grep -Fxq "${host_arch}"; then echo "Java runtime does not contain host architecture ${host_arch}" >&2 exit 1 fi +if ! lipo -archs "${cli_binary}" | tr ' ' '\n' | grep -Fxq "${host_arch}"; then + echo "embedded CLI does not contain host architecture ${host_arch}" >&2 + exit 1 +fi codesign --verify --deep --strict --verbose=2 "${app_path}" if [[ "${CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE:-false}" == true ]]; then @@ -167,6 +173,7 @@ if [[ "${CHAT2DB_REQUIRE_DEVELOPER_ID_SIGNATURE:-false}" == true ]]; then } verify_developer_id_code "${app_path}" "package" "${APPLE_TEAM_ID:-}" + verify_developer_id_code "${cli_binary}" "embedded CLI" "${APPLE_TEAM_ID:-}" runtime_macho_count=0 while IFS= read -r -d '' runtime_file; do