Skip to content

Commit 8ee9c4f

Browse files
committed
feat: preload managed JDBC driver packs
1 parent 61e4bcf commit 8ee9c4f

34 files changed

Lines changed: 2336 additions & 149 deletions

File tree

.github/workflows/ci.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,31 @@ jobs:
176176
run: cargo test -p chat2db-storage --locked
177177
- name: Test local attachment
178178
run: cargo test -p chat2db-local --locked
179+
- name: Test Windows Java bridge path contracts
180+
if: runner.os == 'Windows'
181+
run: cargo test -p chat2db-java-bridge --lib --locked
182+
- uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.7.1
183+
if: runner.os == 'Windows'
184+
with:
185+
distribution: temurin
186+
java-version: "17"
187+
cache: maven
188+
cache-dependency-path: |
189+
java/pom.xml
190+
java/compat-runtime/pom.xml
191+
- name: Package Java engine for Windows managed-driver test
192+
if: runner.os == 'Windows'
193+
working-directory: java
194+
shell: pwsh
195+
run: .\mvnw.cmd -B -DskipTests package
196+
- name: Verify Windows managed H2 driver lifecycle
197+
if: runner.os == 'Windows'
198+
env:
199+
CHAT2DB_JAVA_ENGINE_JAR: "${{ github.workspace }}/java/compat-runtime/target/chat2db-compat-runtime-0.1.0-SNAPSHOT.jar"
200+
CHAT2DB_H2_DRIVER_JAR: "${{ github.workspace }}/java/compat-runtime/target/test-drivers/h2-2.3.232.jar"
201+
run: >-
202+
cargo test -p chat2db-core --features java-integration
203+
--test java_h2_product --locked
179204
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
180205
with:
181206
node-version: "22.22.2"

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,10 @@ Stage 6 is complete. Web and desktop own the product runtime and publish its
5656
owner-only local endpoint; CLI and MCP attach to that host and never contact
5757
Java directly. The current MCP surface is deliberately read-only and does not
5858
accept JDBC bind parameters. A complete end-user Agent workspace and
59-
CLI-started headless host remain follow-on product work. Signed driver packs and
60-
the existing Chat2DB plugin/ANTLR estate remain Stage 7; driver loading is
61-
currently proven through the internal H2 product fixture.
59+
CLI-started headless host remain follow-on product work. The first Stage 7
60+
slice adds strict local driver-pack manifests, bounded hash verification,
61+
startup preload, and Core/Axum/Tauri inventory. Signing, downloading, updating,
62+
rollback, and the existing Chat2DB plugin/ANTLR estate remain Stage 7 work.
6263

6364
## Architecture
6465

@@ -76,9 +77,10 @@ React / TypeScript CLI / MCP client
7677
-> database
7778
```
7879

79-
See [`docs/architecture.md`](docs/architecture.md) for ownership and protocol
80-
decisions and [`docs/protocol.md`](docs/protocol.md) for the implemented 1.0
81-
process contract.
80+
See [`docs/architecture.md`](docs/architecture.md) for ownership,
81+
[`docs/protocol.md`](docs/protocol.md) for the implemented 1.0 process contract,
82+
and [`docs/driver-packs.md`](docs/driver-packs.md) for the local manifest and
83+
startup contract.
8284

8385
## Build
8486

apps/chat2db-desktop/src/lib.rs

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,11 @@ use chat2db_contract::{
1717
AgentRunSnapshot, AgentSession, AgentSessionList, AgentStreamMessage,
1818
AgentSubscriptionAccepted, ApiError, CancelAgentRunResponse, CancelOperationResponse,
1919
CreateAgentSessionRequest, CreateDatasourceRequest, CreateProviderProfileRequest, Datasource,
20-
DatasourceList, DecideAgentPermissionRequest, HealthResponse, OperationEventEnvelope,
21-
OperationSnapshot, OperationStreamMessage, OperationSubscriptionAccepted, ProviderProfile,
22-
ProviderProfileList, QueryAccepted, ResultPage, ResultPageRequest, StartAgentRunRequest,
23-
StartQueryRequest, UpdateAgentSessionRequest, UpdateDatasourceRequest,
24-
UpdateProviderProfileRequest,
20+
DatasourceList, DecideAgentPermissionRequest, HealthResponse, JdbcDriverList,
21+
OperationEventEnvelope, OperationSnapshot, OperationStreamMessage,
22+
OperationSubscriptionAccepted, ProviderProfile, ProviderProfileList, QueryAccepted, ResultPage,
23+
ResultPageRequest, StartAgentRunRequest, StartQueryRequest, UpdateAgentSessionRequest,
24+
UpdateDatasourceRequest, UpdateProviderProfileRequest,
2525
};
2626
use chat2db_core::{AppError, Application, RuntimeConfig, RuntimeHost};
2727
use chat2db_java_bridge::{EngineCommand, EngineConfig};
@@ -30,6 +30,7 @@ use tauri::{State, ipc::Channel};
3030
use tokio::sync::{Mutex, oneshot};
3131

3232
const DATA_DIR_ENV: &str = "CHAT2DB_DATA_DIR";
33+
const DRIVER_PACK_DIR_ENV: &str = "CHAT2DB_DRIVER_PACK_DIR";
3334
const JAVA_BIN_ENV: &str = "CHAT2DB_JAVA_BIN";
3435
const JAVA_ENGINE_JAR_ENV: &str = "CHAT2DB_JAVA_ENGINE_JAR";
3536
const VAULT_MASTER_KEY_ENV: &str = "CHAT2DB_VAULT_MASTER_KEY";
@@ -154,6 +155,7 @@ impl DesktopState {
154155
#[derive(Debug)]
155156
pub enum DesktopError {
156157
MissingJavaEngineJar,
158+
EmptyEnvironmentVariable(&'static str),
157159
InvalidJavaEngineJar(PathBuf),
158160
JavaEngineJarMetadata {
159161
path: PathBuf,
@@ -186,6 +188,9 @@ impl std::fmt::Display for DesktopError {
186188
formatter,
187189
"{JAVA_ENGINE_JAR_ENV} is required and must point to the compatibility-engine JAR"
188190
),
191+
Self::EmptyEnvironmentVariable(name) => {
192+
write!(formatter, "{name} must not be empty when configured")
193+
}
189194
Self::InvalidJavaEngineJar(path) => write!(
190195
formatter,
191196
"{JAVA_ENGINE_JAR_ENV} does not point to a regular file: {}",
@@ -215,6 +220,7 @@ impl std::error::Error for DesktopError {
215220
Self::Runtime(error) => Some(error.as_ref()),
216221
Self::Tauri(error) => Some(error.as_ref()),
217222
Self::MissingJavaEngineJar
223+
| Self::EmptyEnvironmentVariable(_)
218224
| Self::InvalidJavaEngineJar(_)
219225
| Self::InvalidVaultMasterKeyEncoding => None,
220226
}
@@ -236,6 +242,7 @@ pub fn run() -> Result<i32, DesktopError> {
236242
.manage(managed_state)
237243
.invoke_handler(tauri::generate_handler![
238244
health,
245+
list_drivers,
239246
list_datasources,
240247
create_datasource,
241248
get_datasource,
@@ -281,13 +288,16 @@ pub fn run() -> Result<i32, DesktopError> {
281288

282289
fn runtime_config_from_environment() -> Result<RuntimeConfig, DesktopError> {
283290
let engine_jar = required_java_engine_jar()?;
284-
let java = env::var_os(JAVA_BIN_ENV).unwrap_or_else(|| OsString::from("java"));
291+
let java = optional_nonempty_os_env(JAVA_BIN_ENV)?.unwrap_or_else(|| OsString::from("java"));
285292
let engine = EngineConfig::new(EngineCommand::java_jar(java, engine_jar));
286293
let mut config = RuntimeConfig::new(engine);
287294

288-
if let Some(data_dir) = env::var_os(DATA_DIR_ENV).filter(|value| !value.is_empty()) {
295+
if let Some(data_dir) = optional_nonempty_os_env(DATA_DIR_ENV)? {
289296
config = config.with_data_dir(PathBuf::from(data_dir));
290297
}
298+
if let Some(driver_pack_dir) = optional_nonempty_os_env(DRIVER_PACK_DIR_ENV)? {
299+
config = config.with_driver_pack_dir(PathBuf::from(driver_pack_dir));
300+
}
291301
match env::var(VAULT_MASTER_KEY_ENV) {
292302
Ok(master_key) => config = config.with_vault_master_key_base64(master_key),
293303
Err(env::VarError::NotPresent) => {}
@@ -299,14 +309,27 @@ fn runtime_config_from_environment() -> Result<RuntimeConfig, DesktopError> {
299309
}
300310

301311
fn required_java_engine_jar() -> Result<PathBuf, DesktopError> {
302-
let path = env::var_os(JAVA_ENGINE_JAR_ENV)
303-
.filter(|value| !value.is_empty())
312+
let path = optional_nonempty_os_env(JAVA_ENGINE_JAR_ENV)?
304313
.map(PathBuf::from)
305314
.ok_or(DesktopError::MissingJavaEngineJar)?;
306315
validate_java_engine_jar(&path)?;
307316
Ok(path)
308317
}
309318

319+
fn optional_nonempty_os_env(name: &'static str) -> Result<Option<OsString>, DesktopError> {
320+
validate_optional_os_env(name, env::var_os(name))
321+
}
322+
323+
fn validate_optional_os_env(
324+
name: &'static str,
325+
value: Option<OsString>,
326+
) -> Result<Option<OsString>, DesktopError> {
327+
match value {
328+
Some(value) if value.is_empty() => Err(DesktopError::EmptyEnvironmentVariable(name)),
329+
value => Ok(value),
330+
}
331+
}
332+
310333
fn validate_java_engine_jar(path: &Path) -> Result<(), DesktopError> {
311334
match fs::metadata(path) {
312335
Ok(metadata) if metadata.is_file() => Ok(()),
@@ -344,6 +367,12 @@ fn health(state: State<'_, Arc<DesktopState>>) -> HealthResponse {
344367
state.application.health()
345368
}
346369

370+
#[tauri::command]
371+
#[allow(clippy::needless_pass_by_value)]
372+
fn list_drivers(state: State<'_, Arc<DesktopState>>) -> JdbcDriverList {
373+
state.application.list_drivers()
374+
}
375+
347376
#[tauri::command]
348377
async fn list_datasources(state: State<'_, Arc<DesktopState>>) -> Result<DatasourceList, ApiError> {
349378
state
@@ -823,7 +852,7 @@ async fn result_page(
823852

824853
#[cfg(test)]
825854
mod tests {
826-
use std::{fs::File, sync::Arc};
855+
use std::{ffi::OsString, fs::File, sync::Arc};
827856

828857
use chat2db_contract::{
829858
AgentEvent, AgentEventEnvelope, AgentStreamMessage, OperationEvent, OperationEventEnvelope,
@@ -834,7 +863,7 @@ mod tests {
834863

835864
use super::{
836865
DesktopError, SubscriptionRegistry, agent_stream_message, operation_stream_message,
837-
parse_after_sequence, validate_java_engine_jar,
866+
parse_after_sequence, validate_java_engine_jar, validate_optional_os_env,
838867
};
839868

840869
#[test]
@@ -867,6 +896,21 @@ mod tests {
867896
));
868897
}
869898

899+
#[test]
900+
fn optional_path_environment_rejects_explicit_empty_values() {
901+
assert!(matches!(
902+
validate_optional_os_env("CHAT2DB_DRIVER_PACK_DIR", Some(OsString::new())),
903+
Err(DesktopError::EmptyEnvironmentVariable(
904+
"CHAT2DB_DRIVER_PACK_DIR"
905+
))
906+
));
907+
assert_eq!(
908+
validate_optional_os_env("CHAT2DB_DRIVER_PACK_DIR", None)
909+
.expect("missing optional variable must be accepted"),
910+
None
911+
);
912+
}
913+
870914
#[test]
871915
fn stream_result_maps_events_errors_and_clean_end() {
872916
let event = OperationEventEnvelope {

apps/chat2db-web/src/api.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,12 @@ use chat2db_contract::{
1616
ContextCompactionStrategy, CreateAgentSessionRequest, CreateDatasourceRequest,
1717
CreateProviderProfileRequest, Datasource, DatasourceConnection, DatasourceConnectionProperty,
1818
DatasourceList, DatasourceSecretChange, DecideAgentPermissionRequest, HealthResponse,
19-
JdbcValue, JdbcValueType, OperationEvent, OperationEventEnvelope, OperationSnapshot,
20-
OperationStatus, OperationStreamMessage, OperationSubscriptionAccepted, ProductInfo,
21-
ProviderCredentials, ProviderKind, ProviderProfile, ProviderProfileList, ProviderSecretChange,
22-
QueryAccepted, QueryLimits, QueryParameter, ResultColumn, ResultMetadata, ResultPage,
23-
ResultPageRequest, ResultRow, RuntimeStatus, SqlPermissionMode, StartAgentRunRequest,
24-
StartQueryRequest, UpdateAgentSessionRequest, UpdateDatasourceRequest,
19+
JdbcDriver, JdbcDriverList, JdbcValue, JdbcValueType, OperationEvent, OperationEventEnvelope,
20+
OperationSnapshot, OperationStatus, OperationStreamMessage, OperationSubscriptionAccepted,
21+
ProductInfo, ProviderCredentials, ProviderKind, ProviderProfile, ProviderProfileList,
22+
ProviderSecretChange, QueryAccepted, QueryLimits, QueryParameter, ResultColumn, ResultMetadata,
23+
ResultPage, ResultPageRequest, ResultRow, RuntimeStatus, SqlPermissionMode,
24+
StartAgentRunRequest, StartQueryRequest, UpdateAgentSessionRequest, UpdateDatasourceRequest,
2525
UpdateProviderProfileRequest,
2626
};
2727
use chat2db_core::{AppError, Application};
@@ -46,6 +46,7 @@ const SSE_KEEP_ALIVE_SECONDS: u64 = 15;
4646
),
4747
tags(
4848
(name = "system", description = "Runtime identity and readiness"),
49+
(name = "drivers", description = "Hash-verified JDBC driver inventory"),
4950
(name = "datasources", description = "Secret-safe datasource lifecycle"),
5051
(name = "queries", description = "Asynchronous query submission"),
5152
(name = "operations", description = "Query progress, replay, and cancellation"),
@@ -93,6 +94,8 @@ const SSE_KEEP_ALIVE_SECONDS: u64 = 15;
9394
DatasourceSecretChange,
9495
DecideAgentPermissionRequest,
9596
HealthResponse,
97+
JdbcDriver,
98+
JdbcDriverList,
9699
JdbcValue,
97100
JdbcValueType,
98101
OperationEvent,
@@ -143,6 +146,7 @@ fn documented_router() -> OpenApiRouter<Application> {
143146
OpenApiRouter::<Application>::with_openapi(ApiDocument::openapi())
144147
.routes(routes!(health))
145148
.routes(routes!(info))
149+
.routes(routes!(list_drivers))
146150
.routes(routes!(list_datasources, create_datasource))
147151
.routes(routes!(
148152
get_datasource,
@@ -216,6 +220,16 @@ async fn info(State(application): State<Application>) -> Json<ProductInfo> {
216220
Json(application.health().product)
217221
}
218222

223+
#[utoipa::path(
224+
get,
225+
path = "/api/v1/drivers",
226+
tag = "drivers",
227+
responses((status = 200, description = "Loaded JDBC driver inventory", body = JdbcDriverList))
228+
)]
229+
async fn list_drivers(State(application): State<Application>) -> Json<JdbcDriverList> {
230+
Json(application.list_drivers())
231+
}
232+
219233
#[utoipa::path(
220234
get,
221235
path = "/api/v1/datasources",

apps/chat2db-web/src/lib.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,8 @@ mod tests {
192192
use chat2db_contract::{
193193
AgentMessageContent, AgentMessageList, AgentRunAccepted, AgentRunSnapshot, AgentRunStatus,
194194
AgentSession, AgentSessionList, ApiError, ApiErrorDetails, CancelAgentRunResponse,
195-
CancelDisposition, Datasource, DatasourceList, HealthResponse, ProviderProfile,
196-
ProviderProfileList, RuntimeStatus,
195+
CancelDisposition, Datasource, DatasourceList, HealthResponse, JdbcDriverList,
196+
ProviderProfile, ProviderProfileList, RuntimeStatus,
197197
};
198198
use chat2db_core::Application;
199199
use chat2db_storage::{
@@ -226,6 +226,18 @@ mod tests {
226226
assert_eq!(health.status, RuntimeStatus::Unavailable);
227227
}
228228

229+
#[tokio::test]
230+
async fn driver_inventory_route_uses_the_shared_contract() {
231+
let response = router(Application::new())
232+
.oneshot(request(Method::GET, "/api/v1/drivers"))
233+
.await
234+
.expect("router must respond");
235+
236+
assert_eq!(response.status(), StatusCode::OK);
237+
let inventory: JdbcDriverList = response_json(response).await;
238+
assert!(inventory.items.is_empty());
239+
}
240+
229241
#[tokio::test]
230242
async fn unknown_routes_use_the_error_contract() {
231243
let response = router(Application::new())
@@ -325,6 +337,7 @@ mod tests {
325337

326338
for path in [
327339
"/api/v1/system/health",
340+
"/api/v1/drivers",
328341
"/api/v1/datasources",
329342
"/api/v1/datasources/{datasource_id}",
330343
"/api/v1/agent/providers",

apps/chat2db-web/src/main.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ fn runtime_config_from_env() -> Result<RuntimeConfig, Box<dyn std::error::Error>
9393
if let Some(data_dir) = optional_nonempty_os_env("CHAT2DB_DATA_DIR")? {
9494
config = config.with_data_dir(PathBuf::from(data_dir));
9595
}
96+
if let Some(driver_pack_dir) = optional_nonempty_os_env("CHAT2DB_DRIVER_PACK_DIR")? {
97+
config = config.with_driver_pack_dir(PathBuf::from(driver_pack_dir));
98+
}
9699
if let Some(master_key) = optional_unicode_env("CHAT2DB_VAULT_MASTER_KEY")? {
97100
config = config.with_vault_master_key_base64(master_key);
98101
}

apps/frontend/src/backend/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export type DatasourceConnectionProperty = Schema<'DatasourceConnectionProperty'
1616
export type DatasourceList = Schema<'DatasourceList'>;
1717
export type DatasourceSecretChange = Schema<'DatasourceSecretChange'>;
1818
export type HealthResponse = Schema<'HealthResponse'>;
19+
export type JdbcDriver = Schema<'JdbcDriver'>;
20+
export type JdbcDriverList = Schema<'JdbcDriverList'>;
1921
export type JdbcValue = Schema<'JdbcValue'>;
2022
export type OperationEventEnvelope = Schema<'OperationEventEnvelope'>;
2123
export type OperationSnapshot = Schema<'OperationSnapshot'>;
@@ -78,6 +80,7 @@ export interface AgentSubscription {
7880
export interface BackendClient {
7981
readonly transport: 'http' | 'tauri';
8082
health(signal?: AbortSignal): Promise<HealthResponse>;
83+
listDrivers(signal?: AbortSignal): Promise<JdbcDriverList>;
8184
listDatasources(signal?: AbortSignal): Promise<DatasourceList>;
8285
createDatasource(
8386
request: CreateDatasourceRequest,

apps/frontend/src/backend/http.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ function agentEventStream(events: AgentEventEnvelope[]): Response {
150150
}
151151

152152
describe('HttpBackendClient', () => {
153+
it('loads the managed driver inventory from the HTTP contract', async () => {
154+
const inventory = {
155+
items: [{
156+
packId: 'h2',
157+
name: 'H2',
158+
version: '2.3.232',
159+
driverId: 'sha256:driver',
160+
driverClass: 'org.h2.Driver',
161+
artifactCount: 1,
162+
artifactBytes: '2614933',
163+
}],
164+
};
165+
const fetch = vi.fn(async () => jsonResponse(inventory, 200));
166+
const client = new HttpBackendClient({ baseUrl: 'http://127.0.0.1:10825/', fetch });
167+
168+
await expect(client.listDrivers()).resolves.toEqual(inventory);
169+
expect(fetch).toHaveBeenCalledWith(
170+
'http://127.0.0.1:10825/api/v1/drivers',
171+
expect.objectContaining({ method: 'GET' }),
172+
);
173+
});
174+
153175
it('maps every agent catalog method to its HTTP contract without narrowing integers', async () => {
154176
const updatedProvider = {
155177
...providerProfile,

0 commit comments

Comments
 (0)