1use crate::api::openai_compat::AppState;
2use crate::app::InstanceLock;
3use axum::extract::{Path, State};
4use axum::http::StatusCode;
5use axum::Json;
6use serde_json::json;
7
8fn derived_source_index(app: &crate::app::ResolvedApp) -> serde_json::Value {
9 json!({
10 "name": app.name,
11 "app_version": app.version,
12 "manifest_path": app.sources.manifest_path,
13 "config_path": app.sources.config_path,
14 "lock_path": app.sources.lock_path,
15 "trusted": true,
16 "signature": "builtin:app-source",
17 "source_authority": "product-package-instance",
18 "source_public_keys": [],
19 })
20}
21
22fn active_lock_for_app(app: &crate::app::ResolvedApp) -> Option<InstanceLock> {
23 let lock_path = app.sources.lock_path.as_ref()?;
24 crate::app::load_instance_lock_from_path(std::path::Path::new(lock_path)).ok()
25}
26
27pub async fn get_app(
28 Path(name): Path<String>,
29 State(state): State<AppState>,
30) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
31 let apps = state.resolved_apps.read().await;
32 if let Some(app) = apps.get(&name) {
33 Ok(Json(
34 serde_json::json!({ "app": app, "source_index": derived_source_index(app) }),
35 ))
36 } else {
37 Err((
38 StatusCode::NOT_FOUND,
39 Json(serde_json::json!({ "error": format!("App '{}' not found", name) })),
40 ))
41 }
42}
43
44pub async fn app_health(
45 Path(name): Path<String>,
46 State(state): State<AppState>,
47) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
48 let apps = state.resolved_apps.read().await;
49 let app = apps.get(&name).ok_or_else(|| {
50 (
51 StatusCode::NOT_FOUND,
52 Json(serde_json::json!({"error": format!("App '{}' not found", name)})),
53 )
54 })?;
55
56 let resolved = app.status == crate::app::ResolvedAppStatus::Resolved;
57 let has_bindings = !app.bindings.is_empty();
58 let no_errors = app.errors.is_empty();
59
60 let store = state.generation_store.read().await;
61 let has_active_generation = store.get(&name).and_then(|s| s.active.as_ref()).is_some();
62
63 let healthy = resolved && has_bindings && no_errors;
64
65 Ok(Json(serde_json::json!({
66 "app": name,
67 "healthy": healthy,
68 "resolved": resolved,
69 "has_bindings": has_bindings,
70 "no_errors": no_errors,
71 "has_active_generation": has_active_generation,
72 "profile": state.active_profile.read().await.as_str(),
73 })))
74}
75
76pub async fn app_run(
77 Path(name): Path<String>,
78 State(state): State<AppState>,
79 Json(body): Json<serde_json::Value>,
80) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
81 let apps = state.resolved_apps.read().await;
82 let app = apps.get(&name).ok_or_else(|| {
83 (
84 StatusCode::NOT_FOUND,
85 Json(serde_json::json!({"error": format!("App '{}' not found", name)})),
86 )
87 })?;
88
89 if app.status != crate::app::ResolvedAppStatus::Resolved {
90 return Err((
91 StatusCode::CONFLICT,
92 Json(serde_json::json!({
93 "error": format!("App '{}' is not resolved", name),
94 "status": app.status,
95 })),
96 ));
97 }
98
99 let capability = body["capability"]
100 .as_str()
101 .ok_or_else(|| {
102 (
103 StatusCode::BAD_REQUEST,
104 Json(serde_json::json!({"error": "Missing 'capability' field"})),
105 )
106 })?
107 .to_string();
108
109 let active_generation = {
110 let store = state.generation_store.read().await;
111 store
112 .get(&name)
113 .and_then(|app_store| app_store.active.clone())
114 }
115 .ok_or_else(|| {
116 (
117 StatusCode::CONFLICT,
118 Json(serde_json::json!({
119 "error": format!("App '{}' has no active generation", name),
120 "reason": "active_generation_required",
121 })),
122 )
123 })?;
124
125 let active_binding = active_generation
126 .bindings
127 .iter()
128 .find(|binding| binding.capability == capability)
129 .ok_or_else(|| {
130 (
131 StatusCode::BAD_REQUEST,
132 Json(serde_json::json!({
133 "error": format!(
134 "Active generation for app '{}' has no binding for capability '{}'",
135 name, capability
136 ),
137 "reason": "capability_not_in_active_generation",
138 })),
139 )
140 })?;
141
142 let app_binding = app.bindings.iter().find(|b| b.capability == capability).ok_or_else(|| {
143 (
144 StatusCode::BAD_REQUEST,
145 Json(serde_json::json!({
146 "error": format!("App '{}' has no binding for capability '{}'", name, capability),
147 })),
148 )
149 })?;
150
151 if app_binding.provider != active_binding.provider {
152 return Err((
153 StatusCode::CONFLICT,
154 Json(serde_json::json!({
155 "error": format!(
156 "App '{}' binding for capability '{}' does not match active generation provider '{}'",
157 name, capability, active_binding.provider
158 ),
159 "resolved_provider": app_binding.provider,
160 "active_provider": active_binding.provider,
161 "reason": "active_generation_provider_mismatch",
162 })),
163 ));
164 }
165
166 if !active_generation
167 .capabilities
168 .iter()
169 .any(|cap| cap == &capability)
170 {
171 return Err((
172 StatusCode::CONFLICT,
173 Json(serde_json::json!({
174 "error": format!(
175 "Active generation for app '{}' does not include capability '{}'",
176 name, capability
177 ),
178 "reason": "capability_not_active",
179 })),
180 ));
181 }
182
183 let active_lock = active_lock_for_app(app).ok_or_else(|| {
184 (
185 StatusCode::CONFLICT,
186 Json(serde_json::json!({
187 "error": format!("App '{}' has no active lock assembly", name),
188 "reason": "active_lock_required",
189 })),
190 )
191 })?;
192
193 if !active_lock.bindings.is_empty() {
194 let lock_binding = active_lock
195 .bindings
196 .iter()
197 .find(|binding| binding.capability == capability)
198 .ok_or_else(|| {
199 (
200 StatusCode::CONFLICT,
201 Json(serde_json::json!({
202 "error": format!(
203 "Active lock for app '{}' does not include capability '{}'",
204 name, capability
205 ),
206 "reason": "capability_not_locked",
207 })),
208 )
209 })?;
210
211 if lock_binding.provider != active_binding.provider {
212 return Err((
213 StatusCode::CONFLICT,
214 Json(serde_json::json!({
215 "error": format!(
216 "Active lock for app '{}' does not match active generation provider '{}' for capability '{}'",
217 name, active_binding.provider, capability
218 ),
219 "locked_provider": lock_binding.provider,
220 "active_provider": active_binding.provider,
221 "reason": "active_lock_provider_mismatch",
222 })),
223 ));
224 }
225 }
226
227 let provider = active_binding.provider.clone();
228 drop(apps);
229
230 let action = body["action"].as_str().unwrap_or("call").to_string();
231 let data = body["data"].clone();
232
233 let call_payload = serde_json::json!({
234 "action": action,
235 "data": data,
236 "provider": provider,
237 "app": name,
238 });
239
240 crate::api::capabilities::capability_call(
241 Path(capability.clone()),
242 State(state),
243 Json(call_payload),
244 )
245 .await
246 .map(|Json(result)| {
247 Json(serde_json::json!({
248 "app": name,
249 "capability": capability,
250 "result": result,
251 }))
252 })
253}
254
255#[cfg(test)]
256mod tests {
257 use crate::api::build_router;
258 use crate::api::openai_compat::AppState;
259 use crate::app::{
260 AppBindingResolution, AppGeneration, AppGenerationStore, AppProfile,
261 CapabilityProviderRecord, CapabilityRegistry, CapabilityRegistryEntry, CorePolicy,
262 GenerationStatus, GenerationStoreMap, PackageIndex, ResolvedApp, ResolvedAppMap,
263 ResolvedAppSources, ResolvedAppStatus,
264 };
265 use crate::config::{
266 AppConfig, CoreConfig, FallbackConfig, KeyStrategyConfig, RegistryConfig, RoutingConfig,
267 };
268 use crate::defaults::{
269 error_handler::DefaultErrorHandler, key_selectors::FailoverSelector, router::DefaultRouter,
270 };
271 use crate::pipeline::Pipeline;
272 use crate::process::ProcessManager;
273 use crate::vkeys::VirtualKeyStore;
274 use axum::body::Body;
275 use axum::http::{Request, StatusCode as HttpStatusCode};
276 use serde_json::Value;
277 use std::collections::HashMap;
278 use std::sync::{Arc, Mutex as StdMutex};
279 use tokio::sync::RwLock;
280 use tower::util::ServiceExt;
281
282 fn test_state(
283 repo_root: std::path::PathBuf,
284 apps: ResolvedAppMap,
285 capability_registry: CapabilityRegistry,
286 ) -> AppState {
287 AppState {
288 config: Arc::new(RwLock::new(AppConfig {
289 core: CoreConfig::default(),
290 providers: vec![],
291 routing: RoutingConfig::default(),
292 key_strategy: KeyStrategyConfig::default(),
293 fallback: FallbackConfig::default(),
294 virtual_keys: vec![],
295 services: vec![],
296 packages: vec![],
297 registry: RegistryConfig::default(),
298 package_aliases: HashMap::new(),
299 web_search: Default::default(),
300 team: Default::default(),
301 })),
302 config_path: repo_root.join("config").join("config.toml"),
303 pipeline: Arc::new(Pipeline {
304 router: Arc::new(DefaultRouter {
305 default_provider: "".into(),
306 }),
307 key_selector: Arc::new(FailoverSelector),
308 transforms: Arc::new(crate::defaults::transforms::TransformRegistry::with_defaults()),
309 error_handler: Arc::new(DefaultErrorHandler { max_retries: 0 }),
310 http_client: reqwest::Client::new(),
311 }),
312 process_manager: Arc::new(ProcessManager::new()),
313 vkey_store: Arc::new(VirtualKeyStore::new()),
314 package_manager: Arc::new(RwLock::new(crate::package::PackageManager::new())),
315 wasm_handle: Arc::new(RwLock::new(None)),
316 native_handle: Arc::new(RwLock::new(None)),
317 resolved_apps: Arc::new(RwLock::new(apps)),
318 capability_registry: Arc::new(RwLock::new(capability_registry)),
319 active_profile: Arc::new(RwLock::new(AppProfile::Developer)),
320 core_policy: Arc::new(CorePolicy::default_policy()),
321 generation_store: Arc::new(RwLock::new(GenerationStoreMap::new())),
322 package_index: Arc::new(PackageIndex::default()),
323 repo_root,
324 data_dir: std::path::PathBuf::from("data"),
325 runtime_token: None,
326 runtime_token_path: None,
327 chat_providers: Arc::new(RwLock::new(vec![])),
328 shutdown_tx: Arc::new(StdMutex::new(None)),
329 stream_buffer: Arc::new(StdMutex::new(std::collections::HashMap::new())),
330 }
331 }
332
333 fn weft_claw_app() -> ResolvedApp {
334 let test_lock_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
335 .join("..")
336 .join(".tmp-test-locks")
337 .join("weft-claw");
338 let _ = std::fs::create_dir_all(&test_lock_dir);
339 let test_lock_path = test_lock_dir.join("lock.toml");
340 let _ = std::fs::write(
341 &test_lock_path,
342 "lock_version = 2\napp='weft-claw'\ngeneration=1\nstatus='active'\nprofile='developer'\n[assembly]\nenabled_features=[]\nselected_packages=[]\n[[bindings]]\ncapability='core.execution'\nprovider='core'\nmutable=false\n",
343 );
344
345 ResolvedApp {
346 name: "weft-claw".into(),
347 version: "0.1.0".into(),
348 display_name: "Weft Claw".into(),
349 description: "AI 多角色协作开发助手".into(),
350 capabilities: vec!["ui.surface".into()],
351 enabled_features: vec![],
352 bindings: vec![],
353 validation_checks: vec![],
354 config_path: None,
355 status: ResolvedAppStatus::Resolved,
356 errors: vec![],
357 sources: ResolvedAppSources {
358 manifest_path: std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
359 .join("..")
360 .join("packages")
361 .join("weft-claw")
362 .join("package.toml")
363 .display()
364 .to_string(),
365 config_path: Some(
366 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
367 .join("..")
368 .join(".weft")
369 .join("weft-claw")
370 .join("config.toml")
371 .display()
372 .to_string(),
373 ),
374 lock_path: Some(test_lock_path.display().to_string()),
375 },
376 }
377 }
378
379 fn capability_registry_with_core_execution() -> CapabilityRegistry {
380 let mut registry = CapabilityRegistry::new();
381 registry.insert(
382 "core.execution".into(),
383 CapabilityRegistryEntry {
384 capability: "core.execution".into(),
385 providers: vec![CapabilityProviderRecord {
386 provider: "core".into(),
387 runtime: "core".into(),
388 priority: 0,
389 }],
390 bindings: vec![],
391 },
392 );
393 registry
394 }
395
396 fn test_binding(capability: &str, provider: &str) -> AppBindingResolution {
397 AppBindingResolution {
398 capability: capability.into(),
399 provider: provider.into(),
400 mutable: false,
401 source: "test".into(),
402 }
403 }
404
405 fn test_generation(
406 capability: &str,
407 provider: &str,
408 status: GenerationStatus,
409 ) -> AppGeneration {
410 AppGeneration {
411 id: 1,
412 app_name: "weft-claw".into(),
413 version: "0.1.0".into(),
414 bindings: vec![test_binding(capability, provider)],
415 capabilities: vec![capability.into()],
416 enabled_features: vec![],
417 scene: String::new(),
418 profile: "developer".into(),
419 binding_set_id: String::new(),
420 closure_id: String::new(),
421 lock_digest: String::new(),
422 lock_path: String::new(),
423 parent_generation: None,
424 created_by: String::new(),
425 status,
426 validation_results: vec![],
427 created_at: 0,
428 }
429 }
430
431 fn active_generation_store(capability: &str, provider: &str) -> AppGenerationStore {
432 AppGenerationStore {
433 active: Some(test_generation(
434 capability,
435 provider,
436 GenerationStatus::Active,
437 )),
438 candidate: None,
439 rollback: None,
440 next_id: 2,
441 }
442 }
443
444 #[tokio::test]
445 async fn get_app_returns_ui_metadata() {
446 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
447 let mut apps = ResolvedAppMap::new();
448 apps.insert("weft-claw".into(), weft_claw_app());
449 let app = build_router(test_state(repo_root, apps, CapabilityRegistry::new()));
450
451 let response = app
452 .oneshot(
453 Request::builder()
454 .uri("/api/apps/weft-claw")
455 .body(Body::empty())
456 .unwrap(),
457 )
458 .await
459 .unwrap();
460
461 assert_eq!(response.status(), HttpStatusCode::OK);
462
463 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
464 .await
465 .unwrap();
466 let payload: Value = serde_json::from_slice(&body).unwrap();
467 assert_eq!(payload["source_index"]["name"], "weft-claw");
468 }
469
470 #[tokio::test]
471 async fn app_run_routes_weft_claw_execution_to_core_execution() {
472 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
473 let mut apps = ResolvedAppMap::new();
474 let mut weft_claw = weft_claw_app();
475 weft_claw.capabilities.push("core.execution".into());
476 weft_claw
477 .bindings
478 .push(test_binding("core.execution", "core"));
479 apps.insert("weft-claw".into(), weft_claw);
480 let state = test_state(repo_root, apps, capability_registry_with_core_execution());
481 {
482 let mut store = state.generation_store.write().await;
483 store.insert(
484 "weft-claw".into(),
485 active_generation_store("core.execution", "core"),
486 );
487 }
488 let app = build_router(state);
489
490 let response = app
491 .oneshot(
492 Request::builder()
493 .method("POST")
494 .uri("/api/apps/weft-claw/run")
495 .header("content-type", "application/json")
496 .body(Body::from(
497 serde_json::json!({
498 "capability": "core.execution",
499 "action": "describe",
500 "data": {}
501 })
502 .to_string(),
503 ))
504 .unwrap(),
505 )
506 .await
507 .unwrap();
508
509 assert_eq!(response.status(), HttpStatusCode::OK);
510
511 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
512 .await
513 .unwrap();
514 let payload: Value = serde_json::from_slice(&body).unwrap();
515 assert_eq!(payload["app"], "weft-claw");
516 assert_eq!(payload["capability"], "core.execution");
517 assert_eq!(payload["result"]["provider"], "core");
518 assert_eq!(payload["result"]["mode"], "core");
519 assert_eq!(payload["result"]["status"], "executed");
520 assert_eq!(
521 payload["result"]["response"]["capability"],
522 "core.execution"
523 );
524 assert_eq!(payload["result"]["response"]["runtime"], "core");
525 }
526
527 #[tokio::test]
528 async fn app_run_rejects_when_active_generation_missing() {
529 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
530 let mut apps = ResolvedAppMap::new();
531 let mut weft_claw = weft_claw_app();
532 weft_claw.capabilities.push("core.execution".into());
533 weft_claw
534 .bindings
535 .push(test_binding("core.execution", "core"));
536 apps.insert("weft-claw".into(), weft_claw);
537 let app = build_router(test_state(
538 repo_root,
539 apps,
540 capability_registry_with_core_execution(),
541 ));
542
543 let response = app
544 .oneshot(
545 Request::builder()
546 .method("POST")
547 .uri("/api/apps/weft-claw/run")
548 .header("content-type", "application/json")
549 .body(Body::from(
550 serde_json::json!({
551 "capability": "core.execution",
552 "action": "describe",
553 "data": {}
554 })
555 .to_string(),
556 ))
557 .unwrap(),
558 )
559 .await
560 .unwrap();
561
562 assert_eq!(response.status(), HttpStatusCode::CONFLICT);
563 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
564 .await
565 .unwrap();
566 let payload: Value = serde_json::from_slice(&body).unwrap();
567 assert_eq!(payload["reason"], "active_generation_required");
568 }
569
570 #[tokio::test]
571 async fn app_run_rejects_when_active_generation_binding_differs() {
572 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
573 let mut apps = ResolvedAppMap::new();
574 let mut weft_claw = weft_claw_app();
575 weft_claw.capabilities.push("core.execution".into());
576 weft_claw
577 .bindings
578 .push(test_binding("core.execution", "core"));
579 apps.insert("weft-claw".into(), weft_claw);
580 let state = test_state(repo_root, apps, capability_registry_with_core_execution());
581 {
582 let mut store = state.generation_store.write().await;
583 store.insert(
584 "weft-claw".into(),
585 active_generation_store("core.execution", "other-provider"),
586 );
587 }
588 let app = build_router(state);
589
590 let response = app
591 .oneshot(
592 Request::builder()
593 .method("POST")
594 .uri("/api/apps/weft-claw/run")
595 .header("content-type", "application/json")
596 .body(Body::from(
597 serde_json::json!({
598 "capability": "core.execution",
599 "action": "describe",
600 "data": {}
601 })
602 .to_string(),
603 ))
604 .unwrap(),
605 )
606 .await
607 .unwrap();
608
609 assert_eq!(response.status(), HttpStatusCode::CONFLICT);
610 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
611 .await
612 .unwrap();
613 let payload: Value = serde_json::from_slice(&body).unwrap();
614 assert_eq!(payload["reason"], "active_generation_provider_mismatch");
615 }
616}