Mayo commited on
Commit
a3a7dce
·
unverified ·
1 Parent(s): b829c5e

fix: add bootstrap step

Browse files
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: Arc::new(DashMap::new()),
72
- downloads: Arc::new(DashMap::new()),
73
- bus: EventBus::new(EVENT_BUS_CAPACITY),
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
- /// Build the router + OpenAPI doc. Called by the bin and by `router()`.
20
- pub fn api() -> (Router<ApiState>, utoipa::openapi::OpenApi) {
 
 
 
 
 
 
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
- .split_for_parts()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (inner, _) = api();
 
 
 
 
 
41
  Router::new()
42
- .nest("/api/v1", inner.with_state(app))
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 jobs: Vec<JobSummary> = app.jobs.iter().map(|e| e.value().clone()).collect();
 
 
40
  let downloads: Vec<DownloadProgress> =
41
- app.downloads.iter().map(|e| e.value().clone()).collect();
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 mut rx = BroadcastStream::new(app.bus.subscribe());
 
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 = app.bus.replay_since(last_id);
70
  let has_gap = match replay.first() {
71
  Some(first) => first.seq > last_id + 1,
72
- None => app.bus.latest_seq().map(|s| s > last_id).unwrap_or(false),
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<koharu_app::App>;
 
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
- app: Arc<App>,
37
  }
38
 
39
  impl KoharuServer {
40
- pub fn new(app: Arc<App>) -> Self {
41
- Self { app }
 
 
 
 
 
 
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 = self.app.apply(op).map_err(err)?;
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 epoch = self.app.undo().map_err(err)?;
 
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 epoch = self.app.redo().map_err(err)?;
 
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 = self
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.close_project().await.map_err(err)?;
 
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 session = self
165
- .app
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 = self.app.registry.clone();
184
- let runtime = self.app.runtime.clone();
185
- let llm = self.app.llm.clone();
186
- let renderer = self.app.renderer.clone();
187
- let cpu = self.app.cpu_only();
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, app: Arc<App>) -> axum::Router {
221
  let manager = Arc::new(LocalSessionManager::default());
222
  let factory = {
223
- let app = app.clone();
224
- move || -> Result<KoharuServer, std::io::Error> { Ok(KoharuServer::new(app.clone())) }
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 downloads = app.downloads.iter().map(|e| e.value().clone()).collect();
 
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.clone();
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 operations = app.jobs.iter().map(|e| e.value().clone()).collect();
 
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: Arc<App>) -> 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: Arc<App>, 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,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: Arc<App>) -> Result<()> {
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: Arc<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
- // ── Runtime + App ────────────────────────────────────────────────
65
- let runtime = RuntimeManager::new_with_http(config.data.path.as_std_path(), compute, http)?;
66
- runtime
67
- .prepare()
68
- .await
69
- .context("failed to prepare runtime")?;
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 app = Arc::new(App::new(config, runtime, true, "test")?);
 
 
 
 
 
 
 
 
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 app = app.clone();
104
- async move { server::serve_with_listener(listener, app).await }
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 {