Skip to main content

weft_core/api/
app_detail.rs

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}