Mayo commited on
fix: add bootstrap step
Browse files- koharu-app/src/app.rs +33 -3
- koharu-app/src/lib.rs +1 -1
- koharu-rpc/src/api.rs +39 -8
- koharu-rpc/src/bootstrap.rs +78 -0
- koharu-rpc/src/events.rs +8 -5
- koharu-rpc/src/lib.rs +3 -1
- koharu-rpc/src/mcp/mod.rs +31 -19
- koharu-rpc/src/routes/downloads.rs +3 -2
- koharu-rpc/src/routes/operations.rs +3 -2
- koharu-rpc/src/server.rs +5 -5
- koharu/src/app.rs +47 -36
- tests/integration-tests/src/harness.rs +15 -4
koharu-app/src/app.rs
CHANGED
|
@@ -36,6 +36,23 @@ use crate::session::ProjectSession;
|
|
| 36 |
/// cover a minute of pipeline chatter at ~4 events/sec.
|
| 37 |
const EVENT_BUS_CAPACITY: usize = 256;
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
/// Top-level app.
|
| 40 |
pub struct App {
|
| 41 |
pub config: Arc<ArcSwap<AppConfig>>,
|
|
@@ -59,6 +76,19 @@ impl App {
|
|
| 59 |
runtime: Arc<RuntimeManager>,
|
| 60 |
cpu: bool,
|
| 61 |
version: &'static str,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
) -> Result<Self> {
|
| 63 |
let backend = shared_llama_backend(&runtime)?;
|
| 64 |
let llm = Arc::new(llm::Model::new((*runtime).clone(), cpu, backend));
|
|
@@ -68,9 +98,9 @@ impl App {
|
|
| 68 |
runtime,
|
| 69 |
registry: Arc::new(Registry::new()),
|
| 70 |
session: Arc::new(ArcSwapOption::empty()),
|
| 71 |
-
jobs:
|
| 72 |
-
downloads:
|
| 73 |
-
bus:
|
| 74 |
llm,
|
| 75 |
renderer,
|
| 76 |
autosave: Mutex::new(None),
|
|
|
|
| 36 |
/// cover a minute of pipeline chatter at ~4 events/sec.
|
| 37 |
const EVENT_BUS_CAPACITY: usize = 256;
|
| 38 |
|
| 39 |
+
#[derive(Clone)]
|
| 40 |
+
pub struct AppSharedState {
|
| 41 |
+
pub jobs: Arc<DashMap<String, JobSummary>>,
|
| 42 |
+
pub downloads: Arc<DashMap<String, DownloadProgress>>,
|
| 43 |
+
pub bus: Arc<EventBus>,
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
impl Default for AppSharedState {
|
| 47 |
+
fn default() -> Self {
|
| 48 |
+
Self {
|
| 49 |
+
jobs: Arc::new(DashMap::new()),
|
| 50 |
+
downloads: Arc::new(DashMap::new()),
|
| 51 |
+
bus: EventBus::new(EVENT_BUS_CAPACITY),
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
/// Top-level app.
|
| 57 |
pub struct App {
|
| 58 |
pub config: Arc<ArcSwap<AppConfig>>,
|
|
|
|
| 76 |
runtime: Arc<RuntimeManager>,
|
| 77 |
cpu: bool,
|
| 78 |
version: &'static str,
|
| 79 |
+
) -> Result<Self> {
|
| 80 |
+
Self::new_with_shared_state(config, runtime, cpu, AppSharedState::default(), version)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/// Construct with caller-provided shared registries/event bus. This is
|
| 84 |
+
/// used by the HTTP bootstrap flow so the server can expose downloads and
|
| 85 |
+
/// SSE state before the full `App` is ready.
|
| 86 |
+
pub fn new_with_shared_state(
|
| 87 |
+
config: AppConfig,
|
| 88 |
+
runtime: Arc<RuntimeManager>,
|
| 89 |
+
cpu: bool,
|
| 90 |
+
shared: AppSharedState,
|
| 91 |
+
version: &'static str,
|
| 92 |
) -> Result<Self> {
|
| 93 |
let backend = shared_llama_backend(&runtime)?;
|
| 94 |
let llm = Arc::new(llm::Model::new((*runtime).clone(), cpu, backend));
|
|
|
|
| 98 |
runtime,
|
| 99 |
registry: Arc::new(Registry::new()),
|
| 100 |
session: Arc::new(ArcSwapOption::empty()),
|
| 101 |
+
jobs: shared.jobs,
|
| 102 |
+
downloads: shared.downloads,
|
| 103 |
+
bus: shared.bus,
|
| 104 |
llm,
|
| 105 |
renderer,
|
| 106 |
autosave: Mutex::new(None),
|
koharu-app/src/lib.rs
CHANGED
|
@@ -18,7 +18,7 @@ pub mod renderer;
|
|
| 18 |
pub mod session;
|
| 19 |
pub mod utils;
|
| 20 |
|
| 21 |
-
pub use app::App;
|
| 22 |
pub use blobs::BlobStore;
|
| 23 |
pub use config::AppConfig;
|
| 24 |
pub use pipeline::{
|
|
|
|
| 18 |
pub mod session;
|
| 19 |
pub mod utils;
|
| 20 |
|
| 21 |
+
pub use app::{App, AppSharedState};
|
| 22 |
pub use blobs::BlobStore;
|
| 23 |
pub use config::AppConfig;
|
| 24 |
pub use pipeline::{
|
koharu-rpc/src/api.rs
CHANGED
|
@@ -5,6 +5,11 @@
|
|
| 5 |
|
| 6 |
use axum::Router;
|
| 7 |
use axum::extract::DefaultBodyLimit;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
use utoipa_axum::router::OpenApiRouter;
|
| 9 |
|
| 10 |
use crate::AppState;
|
|
@@ -16,8 +21,14 @@ const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024;
|
|
| 16 |
/// State threaded through every `State<ApiState>` extractor.
|
| 17 |
pub type ApiState = AppState;
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
OpenApiRouter::default()
|
| 22 |
.merge(routes::history::router())
|
| 23 |
.merge(routes::pages::router())
|
|
@@ -27,18 +38,38 @@ pub fn api() -> (Router<ApiState>, utoipa::openapi::OpenApi) {
|
|
| 27 |
.merge(routes::fonts::router())
|
| 28 |
.merge(routes::llm::router())
|
| 29 |
.merge(routes::pipelines::router())
|
| 30 |
-
.merge(routes::downloads::router())
|
| 31 |
-
.merge(routes::operations::router())
|
| 32 |
-
.merge(events::router())
|
| 33 |
.merge(binary::router())
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
}
|
| 36 |
|
| 37 |
/// Ready-to-serve router. `app` becomes shared state. All routes live under
|
| 38 |
/// `/api/v1` so the UI can reach them through a single proxy prefix.
|
| 39 |
pub fn router(app: ApiState) -> Router {
|
| 40 |
-
let (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
Router::new()
|
| 42 |
-
.nest("/api/v1",
|
| 43 |
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
|
| 44 |
}
|
|
|
|
| 5 |
|
| 6 |
use axum::Router;
|
| 7 |
use axum::extract::DefaultBodyLimit;
|
| 8 |
+
use axum::extract::{Request, State};
|
| 9 |
+
use axum::http::StatusCode;
|
| 10 |
+
use axum::middleware;
|
| 11 |
+
use axum::middleware::Next;
|
| 12 |
+
use axum::response::{IntoResponse, Response};
|
| 13 |
use utoipa_axum::router::OpenApiRouter;
|
| 14 |
|
| 15 |
use crate::AppState;
|
|
|
|
| 21 |
/// State threaded through every `State<ApiState>` extractor.
|
| 22 |
pub type ApiState = AppState;
|
| 23 |
|
| 24 |
+
fn bootstrap_api() -> OpenApiRouter<ApiState> {
|
| 25 |
+
OpenApiRouter::default()
|
| 26 |
+
.merge(routes::downloads::router())
|
| 27 |
+
.merge(routes::operations::router())
|
| 28 |
+
.merge(events::router())
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
fn app_api() -> OpenApiRouter<ApiState> {
|
| 32 |
OpenApiRouter::default()
|
| 33 |
.merge(routes::history::router())
|
| 34 |
.merge(routes::pages::router())
|
|
|
|
| 38 |
.merge(routes::fonts::router())
|
| 39 |
.merge(routes::llm::router())
|
| 40 |
.merge(routes::pipelines::router())
|
|
|
|
|
|
|
|
|
|
| 41 |
.merge(binary::router())
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/// Build the router + OpenAPI doc. Called by the bin and by `router()`.
|
| 45 |
+
pub fn api() -> (Router<ApiState>, utoipa::openapi::OpenApi) {
|
| 46 |
+
bootstrap_api().merge(app_api()).split_for_parts()
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async fn require_ready(State(app): State<ApiState>, request: Request, next: Next) -> Response {
|
| 50 |
+
if !app.is_ready() {
|
| 51 |
+
return (
|
| 52 |
+
StatusCode::SERVICE_UNAVAILABLE,
|
| 53 |
+
crate::ApiError::new(
|
| 54 |
+
StatusCode::SERVICE_UNAVAILABLE,
|
| 55 |
+
"app is still bootstrapping",
|
| 56 |
+
),
|
| 57 |
+
)
|
| 58 |
+
.into_response();
|
| 59 |
+
}
|
| 60 |
+
next.run(request).await
|
| 61 |
}
|
| 62 |
|
| 63 |
/// Ready-to-serve router. `app` becomes shared state. All routes live under
|
| 64 |
/// `/api/v1` so the UI can reach them through a single proxy prefix.
|
| 65 |
pub fn router(app: ApiState) -> Router {
|
| 66 |
+
let (bootstrap, _) = bootstrap_api().split_for_parts();
|
| 67 |
+
let (guarded, _) = app_api().split_for_parts();
|
| 68 |
+
let bootstrap = bootstrap.with_state(app.clone());
|
| 69 |
+
let guarded = guarded
|
| 70 |
+
.with_state(app.clone())
|
| 71 |
+
.layer(middleware::from_fn_with_state(app, require_ready));
|
| 72 |
Router::new()
|
| 73 |
+
.nest("/api/v1", bootstrap.merge(guarded))
|
| 74 |
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE))
|
| 75 |
}
|
koharu-rpc/src/bootstrap.rs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use std::ops::Deref;
|
| 2 |
+
use std::sync::{Arc, OnceLock};
|
| 3 |
+
|
| 4 |
+
use dashmap::DashMap;
|
| 5 |
+
use koharu_app::bus::EventBus;
|
| 6 |
+
use koharu_app::{App, AppSharedState};
|
| 7 |
+
use koharu_core::{AppEvent, DownloadProgress, JobSummary};
|
| 8 |
+
use koharu_runtime::RuntimeManager;
|
| 9 |
+
|
| 10 |
+
pub struct BootstrapManager {
|
| 11 |
+
app: OnceLock<Arc<App>>,
|
| 12 |
+
runtime: Arc<RuntimeManager>,
|
| 13 |
+
shared: AppSharedState,
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
impl BootstrapManager {
|
| 17 |
+
pub fn new(runtime: Arc<RuntimeManager>) -> Arc<Self> {
|
| 18 |
+
Arc::new(Self {
|
| 19 |
+
app: OnceLock::new(),
|
| 20 |
+
runtime,
|
| 21 |
+
shared: AppSharedState::default(),
|
| 22 |
+
})
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
pub fn app(&self) -> Option<Arc<App>> {
|
| 26 |
+
self.app.get().cloned()
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
pub fn is_ready(&self) -> bool {
|
| 30 |
+
self.app.get().is_some()
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
pub fn set_app(&self, app: Arc<App>) -> Result<(), Arc<App>> {
|
| 34 |
+
self.app.set(app)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
pub fn runtime(&self) -> Arc<RuntimeManager> {
|
| 38 |
+
self.runtime.clone()
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
pub fn shared_state(&self) -> AppSharedState {
|
| 42 |
+
self.shared.clone()
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
pub fn jobs(&self) -> Arc<DashMap<String, JobSummary>> {
|
| 46 |
+
self.shared.jobs.clone()
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
pub fn downloads(&self) -> Arc<DashMap<String, DownloadProgress>> {
|
| 50 |
+
self.shared.downloads.clone()
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
pub fn bus(&self) -> Arc<EventBus> {
|
| 54 |
+
self.shared.bus.clone()
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
pub fn spawn_download_forwarder(&self) {
|
| 58 |
+
let mut rx = self.runtime.subscribe_downloads();
|
| 59 |
+
let downloads = self.downloads();
|
| 60 |
+
let bus = self.bus();
|
| 61 |
+
tokio::spawn(async move {
|
| 62 |
+
while let Ok(progress) = rx.recv().await {
|
| 63 |
+
downloads.insert(progress.id.clone(), progress.clone());
|
| 64 |
+
bus.publish(AppEvent::DownloadProgress(progress));
|
| 65 |
+
}
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
impl Deref for BootstrapManager {
|
| 71 |
+
type Target = App;
|
| 72 |
+
|
| 73 |
+
fn deref(&self) -> &Self::Target {
|
| 74 |
+
self.app
|
| 75 |
+
.get()
|
| 76 |
+
.expect("bootstrap routes must guard app access until ready")
|
| 77 |
+
}
|
| 78 |
+
}
|
koharu-rpc/src/events.rs
CHANGED
|
@@ -36,9 +36,11 @@ pub fn router() -> OpenApiRouter<AppState> {
|
|
| 36 |
|
| 37 |
/// Build the current registry snapshot.
|
| 38 |
fn snapshot_from(app: &AppState) -> SnapshotEvent {
|
| 39 |
-
let
|
|
|
|
|
|
|
| 40 |
let downloads: Vec<DownloadProgress> =
|
| 41 |
-
|
| 42 |
SnapshotEvent { jobs, downloads }
|
| 43 |
}
|
| 44 |
|
|
@@ -56,7 +58,8 @@ async fn events(
|
|
| 56 |
headers: HeaderMap,
|
| 57 |
) -> Sse<impl Stream<Item = Result<Event, axum::Error>>> {
|
| 58 |
let resume_from = last_event_id(&headers);
|
| 59 |
-
let
|
|
|
|
| 60 |
let app_for_stream = app.clone();
|
| 61 |
|
| 62 |
let stream = try_stream! {
|
|
@@ -66,10 +69,10 @@ async fn events(
|
|
| 66 |
// in the ring buffer. If there's a gap (the buffer no longer
|
| 67 |
// contains `last_id + 1`), emit a snapshot first so the client
|
| 68 |
// can rebuild state — then stream whatever tail we still have.
|
| 69 |
-
let replay =
|
| 70 |
let has_gap = match replay.first() {
|
| 71 |
Some(first) => first.seq > last_id + 1,
|
| 72 |
-
None =>
|
| 73 |
};
|
| 74 |
if has_gap {
|
| 75 |
yield snapshot_frame(&app)?;
|
|
|
|
| 36 |
|
| 37 |
/// Build the current registry snapshot.
|
| 38 |
fn snapshot_from(app: &AppState) -> SnapshotEvent {
|
| 39 |
+
let jobs_state = app.jobs();
|
| 40 |
+
let downloads_state = app.downloads();
|
| 41 |
+
let jobs: Vec<JobSummary> = jobs_state.iter().map(|e| e.value().clone()).collect();
|
| 42 |
let downloads: Vec<DownloadProgress> =
|
| 43 |
+
downloads_state.iter().map(|e| e.value().clone()).collect();
|
| 44 |
SnapshotEvent { jobs, downloads }
|
| 45 |
}
|
| 46 |
|
|
|
|
| 58 |
headers: HeaderMap,
|
| 59 |
) -> Sse<impl Stream<Item = Result<Event, axum::Error>>> {
|
| 60 |
let resume_from = last_event_id(&headers);
|
| 61 |
+
let bus = app.bus();
|
| 62 |
+
let mut rx = BroadcastStream::new(bus.subscribe());
|
| 63 |
let app_for_stream = app.clone();
|
| 64 |
|
| 65 |
let stream = try_stream! {
|
|
|
|
| 69 |
// in the ring buffer. If there's a gap (the buffer no longer
|
| 70 |
// contains `last_id + 1`), emit a snapshot first so the client
|
| 71 |
// can rebuild state — then stream whatever tail we still have.
|
| 72 |
+
let replay = bus.replay_since(last_id);
|
| 73 |
let has_gap = match replay.first() {
|
| 74 |
Some(first) => first.seq > last_id + 1,
|
| 75 |
+
None => bus.latest_seq().map(|s| s > last_id).unwrap_or(false),
|
| 76 |
};
|
| 77 |
if has_gap {
|
| 78 |
yield snapshot_frame(&app)?;
|
koharu-rpc/src/lib.rs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
| 11 |
|
| 12 |
pub mod api;
|
| 13 |
pub mod binary;
|
|
|
|
| 14 |
pub mod error;
|
| 15 |
pub mod events;
|
| 16 |
pub mod mcp;
|
|
@@ -21,7 +22,8 @@ pub mod server;
|
|
| 21 |
use std::sync::Arc;
|
| 22 |
|
| 23 |
pub use api::{ApiState, api, router};
|
|
|
|
| 24 |
pub use error::{ApiError, ApiResult};
|
| 25 |
|
| 26 |
/// Concrete state threaded through every `State<ApiState>` extractor.
|
| 27 |
-
pub type AppState = Arc<
|
|
|
|
| 11 |
|
| 12 |
pub mod api;
|
| 13 |
pub mod binary;
|
| 14 |
+
pub mod bootstrap;
|
| 15 |
pub mod error;
|
| 16 |
pub mod events;
|
| 17 |
pub mod mcp;
|
|
|
|
| 22 |
use std::sync::Arc;
|
| 23 |
|
| 24 |
pub use api::{ApiState, api, router};
|
| 25 |
+
pub use bootstrap::BootstrapManager;
|
| 26 |
pub use error::{ApiError, ApiResult};
|
| 27 |
|
| 28 |
/// Concrete state threaded through every `State<ApiState>` extractor.
|
| 29 |
+
pub type AppState = Arc<BootstrapManager>;
|
koharu-rpc/src/mcp/mod.rs
CHANGED
|
@@ -30,15 +30,23 @@ use serde::{Deserialize, Serialize};
|
|
| 30 |
use std::sync::atomic::AtomicBool;
|
| 31 |
use uuid::Uuid;
|
| 32 |
|
|
|
|
|
|
|
| 33 |
/// Server state handed to each tool call. Carries the shared `App`.
|
| 34 |
#[derive(Clone)]
|
| 35 |
pub struct KoharuServer {
|
| 36 |
-
|
| 37 |
}
|
| 38 |
|
| 39 |
impl KoharuServer {
|
| 40 |
-
pub fn new(
|
| 41 |
-
Self {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
}
|
| 44 |
|
|
@@ -107,20 +115,23 @@ impl KoharuServer {
|
|
| 107 |
&self,
|
| 108 |
Parameters(input): Parameters<ApplyInput>,
|
| 109 |
) -> Result<JsonOutput<ApplyOutput>, rmcp::ErrorData> {
|
|
|
|
| 110 |
let op: Op = serde_json::from_value(input.op).map_err(err)?;
|
| 111 |
-
let epoch =
|
| 112 |
Ok(JsonOutput(ApplyOutput { epoch }))
|
| 113 |
}
|
| 114 |
|
| 115 |
#[tool(name = "koharu.undo", description = "Undo the most recent op")]
|
| 116 |
async fn undo(&self) -> Result<JsonOutput<UndoOutput>, rmcp::ErrorData> {
|
| 117 |
-
let
|
|
|
|
| 118 |
Ok(JsonOutput(UndoOutput { epoch }))
|
| 119 |
}
|
| 120 |
|
| 121 |
#[tool(name = "koharu.redo", description = "Redo the most recent undo")]
|
| 122 |
async fn redo(&self) -> Result<JsonOutput<UndoOutput>, rmcp::ErrorData> {
|
| 123 |
-
let
|
|
|
|
| 124 |
Ok(JsonOutput(UndoOutput { epoch }))
|
| 125 |
}
|
| 126 |
|
|
@@ -132,9 +143,9 @@ impl KoharuServer {
|
|
| 132 |
&self,
|
| 133 |
Parameters(input): Parameters<OpenProjectInput>,
|
| 134 |
) -> Result<JsonOutput<OpenProjectOutput>, rmcp::ErrorData> {
|
|
|
|
| 135 |
let path = Utf8PathBuf::from(input.path);
|
| 136 |
-
let session =
|
| 137 |
-
.app
|
| 138 |
.open_project(path, input.create_name)
|
| 139 |
.await
|
| 140 |
.map_err(err)?;
|
|
@@ -149,7 +160,8 @@ impl KoharuServer {
|
|
| 149 |
description = "Close the active project"
|
| 150 |
)]
|
| 151 |
async fn close_project(&self) -> Result<JsonOutput<serde_json::Value>, rmcp::ErrorData> {
|
| 152 |
-
self.app
|
|
|
|
| 153 |
Ok(JsonOutput(serde_json::Value::Null))
|
| 154 |
}
|
| 155 |
|
|
@@ -161,8 +173,8 @@ impl KoharuServer {
|
|
| 161 |
&self,
|
| 162 |
Parameters(input): Parameters<StartPipelineInput>,
|
| 163 |
) -> Result<JsonOutput<StartPipelineOutput>, rmcp::ErrorData> {
|
| 164 |
-
let
|
| 165 |
-
|
| 166 |
.current_session()
|
| 167 |
.ok_or_else(|| rmcp::ErrorData::invalid_request("no project open", None))?;
|
| 168 |
let spec = PipelineSpec {
|
|
@@ -180,11 +192,11 @@ impl KoharuServer {
|
|
| 180 |
};
|
| 181 |
let job_id = Uuid::new_v4().to_string();
|
| 182 |
let cancel = Arc::new(AtomicBool::new(false));
|
| 183 |
-
let registry =
|
| 184 |
-
let runtime =
|
| 185 |
-
let llm =
|
| 186 |
-
let renderer =
|
| 187 |
-
let cpu =
|
| 188 |
tokio::spawn(async move {
|
| 189 |
let _ = koharu_app::pipeline::run(
|
| 190 |
session, registry, runtime, cpu, llm, renderer, spec, cancel, None,
|
|
@@ -217,11 +229,11 @@ impl ServerHandler for KoharuServer {
|
|
| 217 |
// ---------------------------------------------------------------------------
|
| 218 |
|
| 219 |
/// Mount the MCP endpoint at `/mcp` on `router`.
|
| 220 |
-
pub fn mount(router: axum::Router,
|
| 221 |
let manager = Arc::new(LocalSessionManager::default());
|
| 222 |
let factory = {
|
| 223 |
-
let
|
| 224 |
-
move || -> Result<KoharuServer, std::io::Error> { Ok(KoharuServer::new(
|
| 225 |
};
|
| 226 |
let service =
|
| 227 |
StreamableHttpService::new(factory, manager, StreamableHttpServerConfig::default());
|
|
|
|
| 30 |
use std::sync::atomic::AtomicBool;
|
| 31 |
use uuid::Uuid;
|
| 32 |
|
| 33 |
+
use crate::AppState;
|
| 34 |
+
|
| 35 |
/// Server state handed to each tool call. Carries the shared `App`.
|
| 36 |
#[derive(Clone)]
|
| 37 |
pub struct KoharuServer {
|
| 38 |
+
state: AppState,
|
| 39 |
}
|
| 40 |
|
| 41 |
impl KoharuServer {
|
| 42 |
+
pub fn new(state: AppState) -> Self {
|
| 43 |
+
Self { state }
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
fn app(&self) -> Result<Arc<App>, rmcp::ErrorData> {
|
| 47 |
+
self.state
|
| 48 |
+
.app()
|
| 49 |
+
.ok_or_else(|| rmcp::ErrorData::internal_error("app is still bootstrapping", None))
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
|
|
|
| 115 |
&self,
|
| 116 |
Parameters(input): Parameters<ApplyInput>,
|
| 117 |
) -> Result<JsonOutput<ApplyOutput>, rmcp::ErrorData> {
|
| 118 |
+
let app = self.app()?;
|
| 119 |
let op: Op = serde_json::from_value(input.op).map_err(err)?;
|
| 120 |
+
let epoch = app.apply(op).map_err(err)?;
|
| 121 |
Ok(JsonOutput(ApplyOutput { epoch }))
|
| 122 |
}
|
| 123 |
|
| 124 |
#[tool(name = "koharu.undo", description = "Undo the most recent op")]
|
| 125 |
async fn undo(&self) -> Result<JsonOutput<UndoOutput>, rmcp::ErrorData> {
|
| 126 |
+
let app = self.app()?;
|
| 127 |
+
let epoch = app.undo().map_err(err)?;
|
| 128 |
Ok(JsonOutput(UndoOutput { epoch }))
|
| 129 |
}
|
| 130 |
|
| 131 |
#[tool(name = "koharu.redo", description = "Redo the most recent undo")]
|
| 132 |
async fn redo(&self) -> Result<JsonOutput<UndoOutput>, rmcp::ErrorData> {
|
| 133 |
+
let app = self.app()?;
|
| 134 |
+
let epoch = app.redo().map_err(err)?;
|
| 135 |
Ok(JsonOutput(UndoOutput { epoch }))
|
| 136 |
}
|
| 137 |
|
|
|
|
| 143 |
&self,
|
| 144 |
Parameters(input): Parameters<OpenProjectInput>,
|
| 145 |
) -> Result<JsonOutput<OpenProjectOutput>, rmcp::ErrorData> {
|
| 146 |
+
let app = self.app()?;
|
| 147 |
let path = Utf8PathBuf::from(input.path);
|
| 148 |
+
let session = app
|
|
|
|
| 149 |
.open_project(path, input.create_name)
|
| 150 |
.await
|
| 151 |
.map_err(err)?;
|
|
|
|
| 160 |
description = "Close the active project"
|
| 161 |
)]
|
| 162 |
async fn close_project(&self) -> Result<JsonOutput<serde_json::Value>, rmcp::ErrorData> {
|
| 163 |
+
let app = self.app()?;
|
| 164 |
+
app.close_project().await.map_err(err)?;
|
| 165 |
Ok(JsonOutput(serde_json::Value::Null))
|
| 166 |
}
|
| 167 |
|
|
|
|
| 173 |
&self,
|
| 174 |
Parameters(input): Parameters<StartPipelineInput>,
|
| 175 |
) -> Result<JsonOutput<StartPipelineOutput>, rmcp::ErrorData> {
|
| 176 |
+
let app = self.app()?;
|
| 177 |
+
let session = app
|
| 178 |
.current_session()
|
| 179 |
.ok_or_else(|| rmcp::ErrorData::invalid_request("no project open", None))?;
|
| 180 |
let spec = PipelineSpec {
|
|
|
|
| 192 |
};
|
| 193 |
let job_id = Uuid::new_v4().to_string();
|
| 194 |
let cancel = Arc::new(AtomicBool::new(false));
|
| 195 |
+
let registry = app.registry.clone();
|
| 196 |
+
let runtime = app.runtime.clone();
|
| 197 |
+
let llm = app.llm.clone();
|
| 198 |
+
let renderer = app.renderer.clone();
|
| 199 |
+
let cpu = app.cpu_only();
|
| 200 |
tokio::spawn(async move {
|
| 201 |
let _ = koharu_app::pipeline::run(
|
| 202 |
session, registry, runtime, cpu, llm, renderer, spec, cancel, None,
|
|
|
|
| 229 |
// ---------------------------------------------------------------------------
|
| 230 |
|
| 231 |
/// Mount the MCP endpoint at `/mcp` on `router`.
|
| 232 |
+
pub fn mount(router: axum::Router, state: AppState) -> axum::Router {
|
| 233 |
let manager = Arc::new(LocalSessionManager::default());
|
| 234 |
let factory = {
|
| 235 |
+
let state = state.clone();
|
| 236 |
+
move || -> Result<KoharuServer, std::io::Error> { Ok(KoharuServer::new(state.clone())) }
|
| 237 |
};
|
| 238 |
let service =
|
| 239 |
StreamableHttpService::new(factory, manager, StreamableHttpServerConfig::default());
|
koharu-rpc/src/routes/downloads.rs
CHANGED
|
@@ -37,7 +37,8 @@ pub struct ListDownloadsResponse {
|
|
| 37 |
responses((status = 200, body = ListDownloadsResponse))
|
| 38 |
)]
|
| 39 |
async fn list_downloads(State(app): State<AppState>) -> ApiResult<Json<ListDownloadsResponse>> {
|
| 40 |
-
let
|
|
|
|
| 41 |
Ok(Json(ListDownloadsResponse { downloads }))
|
| 42 |
}
|
| 43 |
|
|
@@ -72,7 +73,7 @@ async fn start_download(
|
|
| 72 |
.all()
|
| 73 |
.find(|p| p.id == req.model_id)
|
| 74 |
.ok_or_else(|| ApiError::not_found(format!("unknown package {}", req.model_id)))?;
|
| 75 |
-
let runtime = app.runtime
|
| 76 |
tokio::spawn(async move {
|
| 77 |
if let Err(e) = (pkg.ensure)(&runtime).await {
|
| 78 |
tracing::error!(package = pkg.id, "download failed: {e:#}");
|
|
|
|
| 37 |
responses((status = 200, body = ListDownloadsResponse))
|
| 38 |
)]
|
| 39 |
async fn list_downloads(State(app): State<AppState>) -> ApiResult<Json<ListDownloadsResponse>> {
|
| 40 |
+
let downloads_state = app.downloads();
|
| 41 |
+
let downloads = downloads_state.iter().map(|e| e.value().clone()).collect();
|
| 42 |
Ok(Json(ListDownloadsResponse { downloads }))
|
| 43 |
}
|
| 44 |
|
|
|
|
| 73 |
.all()
|
| 74 |
.find(|p| p.id == req.model_id)
|
| 75 |
.ok_or_else(|| ApiError::not_found(format!("unknown package {}", req.model_id)))?;
|
| 76 |
+
let runtime = app.runtime();
|
| 77 |
tokio::spawn(async move {
|
| 78 |
if let Err(e) = (pkg.ensure)(&runtime).await {
|
| 79 |
tracing::error!(package = pkg.id, "download failed: {e:#}");
|
koharu-rpc/src/routes/operations.rs
CHANGED
|
@@ -57,7 +57,8 @@ pub struct ListOperationsResponse {
|
|
| 57 |
responses((status = 200, body = ListOperationsResponse))
|
| 58 |
)]
|
| 59 |
async fn list_operations(State(app): State<AppState>) -> ApiResult<Json<ListOperationsResponse>> {
|
| 60 |
-
let
|
|
|
|
| 61 |
Ok(Json(ListOperationsResponse { operations }))
|
| 62 |
}
|
| 63 |
|
|
@@ -75,6 +76,6 @@ async fn cancel_operation(
|
|
| 75 |
flag.store(true, Ordering::Relaxed);
|
| 76 |
}
|
| 77 |
// Best-effort download cancel: drop the registry row.
|
| 78 |
-
app.downloads.remove(&id);
|
| 79 |
Ok(StatusCode::NO_CONTENT)
|
| 80 |
}
|
|
|
|
| 57 |
responses((status = 200, body = ListOperationsResponse))
|
| 58 |
)]
|
| 59 |
async fn list_operations(State(app): State<AppState>) -> ApiResult<Json<ListOperationsResponse>> {
|
| 60 |
+
let jobs = app.jobs();
|
| 61 |
+
let operations = jobs.iter().map(|e| e.value().clone()).collect();
|
| 62 |
Ok(Json(ListOperationsResponse { operations }))
|
| 63 |
}
|
| 64 |
|
|
|
|
| 76 |
flag.store(true, Ordering::Relaxed);
|
| 77 |
}
|
| 78 |
// Best-effort download cancel: drop the registry row.
|
| 79 |
+
app.downloads().remove(&id);
|
| 80 |
Ok(StatusCode::NO_CONTENT)
|
| 81 |
}
|
koharu-rpc/src/server.rs
CHANGED
|
@@ -11,10 +11,10 @@ use axum::body::Body;
|
|
| 11 |
use axum::extract::Request;
|
| 12 |
use axum::http::{HeaderValue, StatusCode, header::CONTENT_TYPE};
|
| 13 |
use axum::response::{IntoResponse, Response};
|
| 14 |
-
use koharu_app::App;
|
| 15 |
use tokio::net::TcpListener;
|
| 16 |
use tower_http::cors::CorsLayer;
|
| 17 |
|
|
|
|
| 18 |
use crate::api;
|
| 19 |
|
| 20 |
/// Function that maps a URL path (e.g. `"/index.html"`) to `(bytes, mime)`.
|
|
@@ -22,14 +22,14 @@ use crate::api;
|
|
| 22 |
pub type AssetResolver = Arc<dyn Fn(&str) -> Option<(Vec<u8>, String)> + Send + Sync>;
|
| 23 |
|
| 24 |
/// Wrap `router(app)` with CORS + mount MCP at `/mcp`.
|
| 25 |
-
pub fn router_for(app:
|
| 26 |
let base = api::router(app.clone()).layer(CorsLayer::very_permissive());
|
| 27 |
crate::mcp::mount(base, app)
|
| 28 |
}
|
| 29 |
|
| 30 |
/// Same as `router_for` but installs `resolver` as a fallback, serving
|
| 31 |
/// embedded frontend assets for unmatched GET requests.
|
| 32 |
-
pub fn router_with_assets(app:
|
| 33 |
router_for(app).fallback(move |req: Request<Body>| {
|
| 34 |
let resolver = resolver.clone();
|
| 35 |
async move { serve_asset(resolver, req).await }
|
|
@@ -53,7 +53,7 @@ async fn serve_asset(resolver: AssetResolver, req: Request<Body>) -> Response {
|
|
| 53 |
}
|
| 54 |
|
| 55 |
/// Serve HTTP on an already-bound listener. Tauri-friendly.
|
| 56 |
-
pub async fn serve_with_listener(listener: TcpListener, app:
|
| 57 |
axum::serve(listener, router_for(app)).await?;
|
| 58 |
Ok(())
|
| 59 |
}
|
|
@@ -62,7 +62,7 @@ pub async fn serve_with_listener(listener: TcpListener, app: Arc<App>) -> Result
|
|
| 62 |
/// production build to serve the bundled UI.
|
| 63 |
pub async fn serve_with_listener_and_assets(
|
| 64 |
listener: TcpListener,
|
| 65 |
-
app:
|
| 66 |
resolver: AssetResolver,
|
| 67 |
) -> Result<()> {
|
| 68 |
axum::serve(listener, router_with_assets(app, resolver)).await?;
|
|
|
|
| 11 |
use axum::extract::Request;
|
| 12 |
use axum::http::{HeaderValue, StatusCode, header::CONTENT_TYPE};
|
| 13 |
use axum::response::{IntoResponse, Response};
|
|
|
|
| 14 |
use tokio::net::TcpListener;
|
| 15 |
use tower_http::cors::CorsLayer;
|
| 16 |
|
| 17 |
+
use crate::AppState;
|
| 18 |
use crate::api;
|
| 19 |
|
| 20 |
/// Function that maps a URL path (e.g. `"/index.html"`) to `(bytes, mime)`.
|
|
|
|
| 22 |
pub type AssetResolver = Arc<dyn Fn(&str) -> Option<(Vec<u8>, String)> + Send + Sync>;
|
| 23 |
|
| 24 |
/// Wrap `router(app)` with CORS + mount MCP at `/mcp`.
|
| 25 |
+
pub fn router_for(app: AppState) -> Router {
|
| 26 |
let base = api::router(app.clone()).layer(CorsLayer::very_permissive());
|
| 27 |
crate::mcp::mount(base, app)
|
| 28 |
}
|
| 29 |
|
| 30 |
/// Same as `router_for` but installs `resolver` as a fallback, serving
|
| 31 |
/// embedded frontend assets for unmatched GET requests.
|
| 32 |
+
pub fn router_with_assets(app: AppState, resolver: AssetResolver) -> Router {
|
| 33 |
router_for(app).fallback(move |req: Request<Body>| {
|
| 34 |
let resolver = resolver.clone();
|
| 35 |
async move { serve_asset(resolver, req).await }
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
/// Serve HTTP on an already-bound listener. Tauri-friendly.
|
| 56 |
+
pub async fn serve_with_listener(listener: TcpListener, app: AppState) -> Result<()> {
|
| 57 |
axum::serve(listener, router_for(app)).await?;
|
| 58 |
Ok(())
|
| 59 |
}
|
|
|
|
| 62 |
/// production build to serve the bundled UI.
|
| 63 |
pub async fn serve_with_listener_and_assets(
|
| 64 |
listener: TcpListener,
|
| 65 |
+
app: AppState,
|
| 66 |
resolver: AssetResolver,
|
| 67 |
) -> Result<()> {
|
| 68 |
axum::serve(listener, router_with_assets(app, resolver)).await?;
|
koharu/src/app.rs
CHANGED
|
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
|
| 6 |
use anyhow::{Context, Result};
|
| 7 |
use clap::Parser;
|
| 8 |
use koharu_app::{App, AppConfig, config as app_config};
|
| 9 |
-
use koharu_rpc::server;
|
| 10 |
use koharu_runtime::{ComputePolicy, RuntimeHttpConfig, RuntimeManager};
|
| 11 |
use tokio::net::TcpListener;
|
| 12 |
use tracing_subscriber::layer::SubscriberExt;
|
|
@@ -14,10 +14,35 @@ use tracing_subscriber::util::SubscriberInitExt;
|
|
| 14 |
|
| 15 |
use crate::cli::Cli;
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
pub async fn run() -> Result<()> {
|
| 18 |
let cli = Cli::parse();
|
| 19 |
|
| 20 |
-
// ── Platform & logging ───────────────────────────────────────────
|
| 21 |
#[cfg(target_os = "windows")]
|
| 22 |
{
|
| 23 |
let attached = crate::windows::attach_parent_console();
|
|
@@ -41,7 +66,6 @@ pub async fn run() -> Result<()> {
|
|
| 41 |
koharu_llm::providers::disable_keyring();
|
| 42 |
}
|
| 43 |
|
| 44 |
-
// ── Config ───────────────────────────────────────────────────────
|
| 45 |
let config: AppConfig = app_config::load()?;
|
| 46 |
let http = RuntimeHttpConfig {
|
| 47 |
connect_timeout_secs: config.http.connect_timeout.max(1),
|
|
@@ -61,27 +85,16 @@ pub async fn run() -> Result<()> {
|
|
| 61 |
.context("failed to download runtime packages");
|
| 62 |
}
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
|
| 71 |
#[cfg(target_os = "windows")]
|
| 72 |
crate::windows::register_khr().ok();
|
| 73 |
|
| 74 |
-
let app = Arc::new(App::new(
|
| 75 |
-
config,
|
| 76 |
-
Arc::new(runtime),
|
| 77 |
-
cli.cpu,
|
| 78 |
-
crate::version::current(),
|
| 79 |
-
)?);
|
| 80 |
-
koharu_llm::suppress_native_logs();
|
| 81 |
-
app.spawn_download_forwarder();
|
| 82 |
-
app.spawn_llm_forwarder();
|
| 83 |
-
|
| 84 |
-
// ── Server ───────────────────────────────────────────────────────
|
| 85 |
let default_port = if cfg!(debug_assertions) { 9999 } else { 0 };
|
| 86 |
let bind_host = cli.host.as_deref().unwrap_or("127.0.0.1");
|
| 87 |
let bind_port = cli.port.unwrap_or(default_port);
|
|
@@ -89,31 +102,23 @@ pub async fn run() -> Result<()> {
|
|
| 89 |
let port = listener.local_addr()?.port();
|
| 90 |
tracing::info!(port, "starting server");
|
| 91 |
|
| 92 |
-
// Extract the embedded UI assets up-front so both headless and GUI modes
|
| 93 |
-
// can serve them on the HTTP fallback. Headless deployments point a
|
| 94 |
-
// browser at the listener port and get the full app; the GUI build uses
|
| 95 |
-
// the same bundle inside Tauri's webview.
|
| 96 |
let mut context = tauri::generate_context!();
|
| 97 |
let assets = crate::assets::from_context(&mut context);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
if cli.headless {
|
| 100 |
-
tauri::async_runtime::spawn(async move {
|
| 101 |
-
if let Err(e) = server::serve_with_listener_and_assets(listener, app, assets).await {
|
| 102 |
-
tracing::error!("server error: {e:#}");
|
| 103 |
-
}
|
| 104 |
-
});
|
| 105 |
tracing::info!(port, "headless: open http://127.0.0.1:{port}/ in a browser");
|
|
|
|
| 106 |
tokio::signal::ctrl_c().await?;
|
| 107 |
return Ok(());
|
| 108 |
}
|
| 109 |
|
| 110 |
-
// ── GUI ──────────────────────────────────────────────────────────
|
| 111 |
-
tauri::async_runtime::spawn(async move {
|
| 112 |
-
if let Err(e) = server::serve_with_listener_and_assets(listener, app, assets).await {
|
| 113 |
-
tracing::error!("server error: {e:#}");
|
| 114 |
-
}
|
| 115 |
-
});
|
| 116 |
-
|
| 117 |
tauri::Builder::default()
|
| 118 |
.plugin(tauri_plugin_dialog::init())
|
| 119 |
.plugin(tauri_plugin_fs::init())
|
|
@@ -121,6 +126,12 @@ pub async fn run() -> Result<()> {
|
|
| 121 |
.plugin(tauri_plugin_updater::Builder::new().build())
|
| 122 |
.plugin(tauri_plugin_process::init())
|
| 123 |
.setup(move |handle| {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
let cfg = handle.config();
|
| 125 |
let url: tauri::Url = if cfg!(debug_assertions) {
|
| 126 |
cfg.build
|
|
|
|
| 6 |
use anyhow::{Context, Result};
|
| 7 |
use clap::Parser;
|
| 8 |
use koharu_app::{App, AppConfig, config as app_config};
|
| 9 |
+
use koharu_rpc::{BootstrapManager, server};
|
| 10 |
use koharu_runtime::{ComputePolicy, RuntimeHttpConfig, RuntimeManager};
|
| 11 |
use tokio::net::TcpListener;
|
| 12 |
use tracing_subscriber::layer::SubscriberExt;
|
|
|
|
| 14 |
|
| 15 |
use crate::cli::Cli;
|
| 16 |
|
| 17 |
+
async fn bootstrap_app(
|
| 18 |
+
state: Arc<BootstrapManager>,
|
| 19 |
+
config: AppConfig,
|
| 20 |
+
cpu_only: bool,
|
| 21 |
+
) -> Result<()> {
|
| 22 |
+
let runtime = state.runtime();
|
| 23 |
+
runtime
|
| 24 |
+
.prepare()
|
| 25 |
+
.await
|
| 26 |
+
.context("failed to prepare runtime")?;
|
| 27 |
+
|
| 28 |
+
let app = Arc::new(App::new_with_shared_state(
|
| 29 |
+
config,
|
| 30 |
+
runtime,
|
| 31 |
+
cpu_only,
|
| 32 |
+
state.shared_state(),
|
| 33 |
+
crate::version::current(),
|
| 34 |
+
)?);
|
| 35 |
+
koharu_llm::suppress_native_logs();
|
| 36 |
+
app.spawn_llm_forwarder();
|
| 37 |
+
state
|
| 38 |
+
.set_app(app)
|
| 39 |
+
.map_err(|_| anyhow::anyhow!("app already initialized"))?;
|
| 40 |
+
Ok(())
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
pub async fn run() -> Result<()> {
|
| 44 |
let cli = Cli::parse();
|
| 45 |
|
|
|
|
| 46 |
#[cfg(target_os = "windows")]
|
| 47 |
{
|
| 48 |
let attached = crate::windows::attach_parent_console();
|
|
|
|
| 66 |
koharu_llm::providers::disable_keyring();
|
| 67 |
}
|
| 68 |
|
|
|
|
| 69 |
let config: AppConfig = app_config::load()?;
|
| 70 |
let http = RuntimeHttpConfig {
|
| 71 |
connect_timeout_secs: config.http.connect_timeout.max(1),
|
|
|
|
| 85 |
.context("failed to download runtime packages");
|
| 86 |
}
|
| 87 |
|
| 88 |
+
let state = BootstrapManager::new(Arc::new(RuntimeManager::new_with_http(
|
| 89 |
+
config.data.path.as_std_path(),
|
| 90 |
+
compute,
|
| 91 |
+
http,
|
| 92 |
+
)?));
|
| 93 |
+
state.spawn_download_forwarder();
|
| 94 |
|
| 95 |
#[cfg(target_os = "windows")]
|
| 96 |
crate::windows::register_khr().ok();
|
| 97 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
let default_port = if cfg!(debug_assertions) { 9999 } else { 0 };
|
| 99 |
let bind_host = cli.host.as_deref().unwrap_or("127.0.0.1");
|
| 100 |
let bind_port = cli.port.unwrap_or(default_port);
|
|
|
|
| 102 |
let port = listener.local_addr()?.port();
|
| 103 |
tracing::info!(port, "starting server");
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
let mut context = tauri::generate_context!();
|
| 106 |
let assets = crate::assets::from_context(&mut context);
|
| 107 |
+
let server_state = state.clone();
|
| 108 |
+
tauri::async_runtime::spawn(async move {
|
| 109 |
+
if let Err(e) = server::serve_with_listener_and_assets(listener, server_state, assets).await
|
| 110 |
+
{
|
| 111 |
+
tracing::error!("server error: {e:#}");
|
| 112 |
+
}
|
| 113 |
+
});
|
| 114 |
|
| 115 |
if cli.headless {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
tracing::info!(port, "headless: open http://127.0.0.1:{port}/ in a browser");
|
| 117 |
+
bootstrap_app(state, config, cli.cpu).await?;
|
| 118 |
tokio::signal::ctrl_c().await?;
|
| 119 |
return Ok(());
|
| 120 |
}
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
tauri::Builder::default()
|
| 123 |
.plugin(tauri_plugin_dialog::init())
|
| 124 |
.plugin(tauri_plugin_fs::init())
|
|
|
|
| 126 |
.plugin(tauri_plugin_updater::Builder::new().build())
|
| 127 |
.plugin(tauri_plugin_process::init())
|
| 128 |
.setup(move |handle| {
|
| 129 |
+
tauri::async_runtime::spawn(async move {
|
| 130 |
+
if let Err(e) = bootstrap_app(state, config, cli.cpu).await {
|
| 131 |
+
tracing::error!("bootstrap error: {e:#}");
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
let cfg = handle.config();
|
| 136 |
let url: tauri::Url = if cfg!(debug_assertions) {
|
| 137 |
cfg.build
|
tests/integration-tests/src/harness.rs
CHANGED
|
@@ -17,7 +17,7 @@ use anyhow::{Context, Result};
|
|
| 17 |
use camino::Utf8PathBuf;
|
| 18 |
use koharu_app::{App, AppConfig};
|
| 19 |
use koharu_client::apis::configuration::Configuration;
|
| 20 |
-
use koharu_rpc::server;
|
| 21 |
use koharu_runtime::{ComputePolicy, RuntimeHttpConfig, RuntimeManager};
|
| 22 |
use tokio::net::TcpListener;
|
| 23 |
use tokio::sync::OnceCell;
|
|
@@ -93,15 +93,26 @@ impl TestApp {
|
|
| 93 |
|
| 94 |
// Shared runtime (prepared once per process).
|
| 95 |
let runtime = shared_runtime().await?;
|
| 96 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
app.spawn_llm_forwarder();
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
| 100 |
let addr = listener.local_addr()?;
|
| 101 |
let base_url = format!("http://{addr}/api/v1");
|
| 102 |
let server = tokio::spawn({
|
| 103 |
-
let
|
| 104 |
-
async move { server::serve_with_listener(listener,
|
| 105 |
});
|
| 106 |
|
| 107 |
let client_config = Configuration {
|
|
|
|
| 17 |
use camino::Utf8PathBuf;
|
| 18 |
use koharu_app::{App, AppConfig};
|
| 19 |
use koharu_client::apis::configuration::Configuration;
|
| 20 |
+
use koharu_rpc::{BootstrapManager, server};
|
| 21 |
use koharu_runtime::{ComputePolicy, RuntimeHttpConfig, RuntimeManager};
|
| 22 |
use tokio::net::TcpListener;
|
| 23 |
use tokio::sync::OnceCell;
|
|
|
|
| 93 |
|
| 94 |
// Shared runtime (prepared once per process).
|
| 95 |
let runtime = shared_runtime().await?;
|
| 96 |
+
let state = BootstrapManager::new(runtime.clone());
|
| 97 |
+
state.spawn_download_forwarder();
|
| 98 |
+
let app = Arc::new(App::new_with_shared_state(
|
| 99 |
+
config,
|
| 100 |
+
runtime,
|
| 101 |
+
true,
|
| 102 |
+
state.shared_state(),
|
| 103 |
+
"test",
|
| 104 |
+
)?);
|
| 105 |
app.spawn_llm_forwarder();
|
| 106 |
+
state
|
| 107 |
+
.set_app(app.clone())
|
| 108 |
+
.map_err(|_| anyhow::anyhow!("test app already initialized"))?;
|
| 109 |
|
| 110 |
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
| 111 |
let addr = listener.local_addr()?;
|
| 112 |
let base_url = format!("http://{addr}/api/v1");
|
| 113 |
let server = tokio::spawn({
|
| 114 |
+
let state = state.clone();
|
| 115 |
+
async move { server::serve_with_listener(listener, state).await }
|
| 116 |
});
|
| 117 |
|
| 118 |
let client_config = Configuration {
|