1use crate::api::openai_compat::AppState;
2use axum::extract::{Path, State};
3use axum::http::StatusCode;
4use axum::Json;
5use serde::Deserialize;
6
7#[derive(Debug, Deserialize)]
8pub struct CreateSceneRequest {
9 pub name: String,
10 #[serde(flatten)]
11 pub scene: crate::app::AppSceneConfig,
12}
13
14#[derive(Debug, Deserialize)]
15pub struct BindSceneRequest {
16 #[serde(default)]
17 pub scene: String,
18}
19
20fn app_not_found(name: &str) -> (StatusCode, Json<serde_json::Value>) {
21 (
22 StatusCode::NOT_FOUND,
23 Json(serde_json::json!({
24 "error": format!("App '{}' not found", name),
25 "reason": "app_not_found",
26 "app": name,
27 })),
28 )
29}
30
31fn scene_not_found(app_name: &str, scene_name: &str) -> (StatusCode, Json<serde_json::Value>) {
32 (
33 StatusCode::NOT_FOUND,
34 Json(serde_json::json!({
35 "error": format!("Scene '{}' not found for app '{}'", scene_name, app_name),
36 "reason": "scene_not_found",
37 "app": app_name,
38 "scene": scene_name,
39 })),
40 )
41}
42
43fn bad_scene_request(reason: &str, message: &str) -> (StatusCode, Json<serde_json::Value>) {
44 (
45 StatusCode::BAD_REQUEST,
46 Json(serde_json::json!({
47 "error": message,
48 "reason": reason,
49 })),
50 )
51}
52
53fn scene_conflict(app_name: &str, scene_name: &str) -> (StatusCode, Json<serde_json::Value>) {
54 (
55 StatusCode::CONFLICT,
56 Json(serde_json::json!({
57 "error": format!("Scene '{}' already exists for app '{}'", scene_name, app_name),
58 "reason": "scene_exists",
59 "app": app_name,
60 "scene": scene_name,
61 })),
62 )
63}
64
65fn scene_write_failed(error: anyhow::Error) -> (StatusCode, Json<serde_json::Value>) {
66 (
67 StatusCode::INTERNAL_SERVER_ERROR,
68 Json(serde_json::json!({
69 "error": error.to_string(),
70 "reason": "scene_write_failed",
71 })),
72 )
73}
74
75fn load_instance_config(app: &crate::app::ResolvedApp) -> crate::app::InstanceConfig {
76 app.sources
77 .config_path
78 .as_deref()
79 .map(std::path::Path::new)
80 .and_then(|path| crate::app::load_instance_config_from_path(path).ok())
81 .unwrap_or_default()
82}
83
84fn instance_config_path(
85 app: &crate::app::ResolvedApp,
86) -> Result<std::path::PathBuf, (StatusCode, Json<serde_json::Value>)> {
87 app.sources
88 .config_path
89 .as_deref()
90 .or(app.config_path.as_deref())
91 .filter(|path| !path.trim().is_empty())
92 .map(std::path::PathBuf::from)
93 .ok_or_else(|| {
94 bad_scene_request(
95 "config_path_missing",
96 "App has no writable instance config path",
97 )
98 })
99}
100
101fn scene_file_name(scene_name: &str) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
102 let trimmed = scene_name.trim();
103 if trimmed.is_empty() {
104 return Err(bad_scene_request(
105 "scene_name_empty",
106 "Scene name is required",
107 ));
108 }
109
110 if trimmed.contains('/') || trimmed.contains('\\') || trimmed == "." || trimmed == ".." {
111 return Err(bad_scene_request(
112 "scene_name_invalid",
113 "Scene name must be a simple file name",
114 ));
115 }
116
117 Ok(trimmed.to_string())
118}
119
120fn scene_response(
121 app_name: String,
122 active_scene: String,
123 scene: crate::app::AppSceneConfig,
124) -> Json<serde_json::Value> {
125 Json(serde_json::json!({
126 "app": app_name,
127 "active_scene": active_scene,
128 "scene": scene,
129 }))
130}
131
132pub async fn list_scenes(
133 Path(name): Path<String>,
134 State(state): State<AppState>,
135) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
136 let apps = state.resolved_apps.read().await;
137 let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
138 let config = load_instance_config(app);
139
140 let mut scenes = config
141 .scenes
142 .into_values()
143 .collect::<Vec<crate::app::AppSceneConfig>>();
144 scenes.sort_by(|left, right| left.name.cmp(&right.name));
145
146 Ok(Json(serde_json::json!({
147 "app": name,
148 "active_scene": config.active_scene,
149 "scenes": scenes,
150 })))
151}
152
153pub async fn get_scene(
154 Path((name, scene_name)): Path<(String, String)>,
155 State(state): State<AppState>,
156) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
157 let apps = state.resolved_apps.read().await;
158 let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
159 let config = load_instance_config(app);
160 let scene = config
161 .scenes
162 .get(&scene_name)
163 .cloned()
164 .ok_or_else(|| scene_not_found(&name, &scene_name))?;
165
166 Ok(Json(serde_json::json!({
167 "app": name,
168 "active_scene": config.active_scene,
169 "scene": scene,
170 })))
171}
172
173pub async fn create_scene(
174 Path(name): Path<String>,
175 State(state): State<AppState>,
176 Json(mut request): Json<CreateSceneRequest>,
177) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
178 let apps = state.resolved_apps.read().await;
179 let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
180 let config_path = instance_config_path(app)?;
181 let config = load_instance_config(app);
182 let scene_name = scene_file_name(&request.name)?;
183
184 if config.scenes.contains_key(&scene_name) {
185 return Err(scene_conflict(&name, &scene_name));
186 }
187
188 request.scene.name = scene_name.clone();
189 if request.scene.schema_version == 0 {
190 request.scene.schema_version = 1;
191 }
192
193 let scene_path = config_path
194 .parent()
195 .unwrap_or_else(|| std::path::Path::new("."))
196 .join("scenes")
197 .join(format!("{}.toml", scene_name));
198 crate::app::save_scene_config_to_path(&scene_path, &request.scene)
199 .map_err(scene_write_failed)?;
200
201 Ok(scene_response(name, config.active_scene, request.scene))
202}
203
204pub async fn bind_scene(
205 Path((name, scene_name)): Path<(String, String)>,
206 State(state): State<AppState>,
207 Json(request): Json<BindSceneRequest>,
208) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
209 let apps = state.resolved_apps.read().await;
210 let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
211 let config_path = instance_config_path(app)?;
212 let mut config = load_instance_config(app);
213 let requested_scene = if request.scene.trim().is_empty() {
214 scene_name
215 } else {
216 request.scene
217 };
218 let scene_name = scene_file_name(&requested_scene)?;
219 let scene = config
220 .scenes
221 .get(&scene_name)
222 .cloned()
223 .ok_or_else(|| scene_not_found(&name, &scene_name))?;
224
225 config.active_scene = scene_name.clone();
226 crate::app::save_instance_config_to_path(&config_path, &config).map_err(scene_write_failed)?;
227
228 if !scene.role_routing.is_empty() {
232 if let Ok(role_routing_json) = serde_json::to_string(&scene.role_routing) {
233 if let Some(handle) = state.wasm_handle.read().await.as_ref() {
234 handle.kv_set("team:role_routing", &role_routing_json);
235 tracing::info!(
236 "scene '{}' bound: applied {} role(s) to team:role_routing",
237 scene_name,
238 scene.role_routing.len()
239 );
240 }
241 }
242 }
243
244 Ok(scene_response(name, scene_name, scene))
245}
246
247pub async fn delete_scene(
248 Path((name, scene_name)): Path<(String, String)>,
249 State(state): State<AppState>,
250) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
251 let apps = state.resolved_apps.read().await;
252 let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
253 let config_path = instance_config_path(app)?;
254 let config = load_instance_config(app);
255 let scene_name = scene_file_name(&scene_name)?;
256
257 if !config.scenes.contains_key(&scene_name) {
258 return Err(scene_not_found(&name, &scene_name));
259 }
260 if config.active_scene == scene_name {
263 return Err((
264 StatusCode::CONFLICT,
265 Json(serde_json::json!({
266 "error": format!("Cannot delete active scene '{}'", scene_name),
267 "reason": "scene_active",
268 "app": name,
269 "scene": scene_name,
270 })),
271 ));
272 }
273
274 let scene_path = config_path
275 .parent()
276 .unwrap_or_else(|| std::path::Path::new("."))
277 .join("scenes")
278 .join(format!("{}.toml", scene_name));
279 if scene_path.exists() {
280 std::fs::remove_file(&scene_path)
281 .map_err(|e| scene_write_failed(anyhow::Error::from(e)))?;
282 }
283
284 Ok(Json(serde_json::json!({
285 "app": name,
286 "deleted": scene_name,
287 "active_scene": config.active_scene,
288 })))
289}
290
291#[cfg(test)]
292mod tests {
293 use crate::api::build_router;
294 use crate::api::openai_compat::AppState;
295 use crate::app::{
296 AppProfile, CapabilityRegistry, CorePolicy, GenerationStoreMap, PackageIndex,
297 PackageSource, ResolvedApp, ResolvedAppMap, ResolvedAppSources, ResolvedAppStatus,
298 };
299 use crate::config::{
300 AppConfig, CoreConfig, FallbackConfig, KeyStrategyConfig, RegistryConfig, RoutingConfig,
301 };
302 use crate::defaults::{
303 error_handler::DefaultErrorHandler, key_selectors::FailoverSelector, router::DefaultRouter,
304 };
305 use crate::pipeline::Pipeline;
306 use crate::process::ProcessManager;
307 use crate::vkeys::VirtualKeyStore;
308 use axum::body::Body;
309 use axum::http::{Request, StatusCode as HttpStatusCode};
310 use serde_json::Value;
311 use std::collections::HashMap;
312 use std::sync::{Arc, Mutex as StdMutex};
313 use tempfile::tempdir;
314 use tokio::sync::RwLock;
315 use tower::util::ServiceExt;
316
317 fn test_state(repo_root: std::path::PathBuf, apps: ResolvedAppMap) -> AppState {
318 AppState {
319 config: Arc::new(RwLock::new(AppConfig {
320 core: CoreConfig::default(),
321 providers: vec![],
322 routing: RoutingConfig::default(),
323 key_strategy: KeyStrategyConfig::default(),
324 fallback: FallbackConfig::default(),
325 virtual_keys: vec![],
326 services: vec![],
327 packages: vec![],
328 registry: RegistryConfig::default(),
329 package_aliases: HashMap::new(),
330 web_search: Default::default(),
331 team: Default::default(),
332 })),
333 config_path: repo_root.join("config").join("config.toml"),
334 pipeline: Arc::new(Pipeline {
335 router: Arc::new(DefaultRouter {
336 default_provider: "".into(),
337 }),
338 key_selector: Arc::new(FailoverSelector),
339 transforms: Arc::new(crate::defaults::transforms::TransformRegistry::with_defaults()),
340 error_handler: Arc::new(DefaultErrorHandler { max_retries: 0 }),
341 http_client: reqwest::Client::new(),
342 }),
343 process_manager: Arc::new(ProcessManager::new()),
344 vkey_store: Arc::new(VirtualKeyStore::new()),
345 package_manager: Arc::new(RwLock::new(crate::package::PackageManager::new())),
346 wasm_handle: Arc::new(RwLock::new(None)),
347 native_handle: Arc::new(RwLock::new(None)),
348 resolved_apps: Arc::new(RwLock::new(apps)),
349 capability_registry: Arc::new(RwLock::new(CapabilityRegistry::new())),
350 active_profile: Arc::new(RwLock::new(AppProfile::Developer)),
351 core_policy: Arc::new(CorePolicy::default_policy()),
352 generation_store: Arc::new(RwLock::new(GenerationStoreMap::new())),
353 package_index: Arc::new(PackageIndex {
354 version: 1,
355 revision: "test-rev".into(),
356 source_url: "local://packages".into(),
357 package_sources: vec![PackageSource {
358 name: "weft-claw-ui".into(),
359 kind: "embedded".into(),
360 package_kind: "provider".into(),
361 runtime_provider: "weft-claw-ui".into(),
362 current_source: "packages/installed/weft-claw".into(),
363 trusted: true,
364 signature: "builtin:test".into(),
365 source_authority: "test".into(),
366 source_public_keys: vec![],
367 provides: vec![],
368 requires: vec![],
369 }],
370 }),
371 repo_root,
372 data_dir: std::path::PathBuf::from("data"),
373 runtime_token: None,
374 runtime_token_path: None,
375 chat_providers: Arc::new(RwLock::new(vec![])),
376 shutdown_tx: Arc::new(StdMutex::new(None)),
377 stream_buffer: Arc::new(StdMutex::new(std::collections::HashMap::new())),
378 }
379 }
380
381 fn resolved_app(name: &str, config_path: Option<&std::path::Path>) -> ResolvedApp {
382 ResolvedApp {
383 name: name.into(),
384 version: "0.1.0".into(),
385 display_name: name.into(),
386 description: "test app".into(),
387 capabilities: vec![],
388 enabled_features: vec![],
389 bindings: vec![],
390 validation_checks: vec![],
391 config_path: config_path.map(|path| path.display().to_string()),
392 status: ResolvedAppStatus::Resolved,
393 errors: vec![],
394 sources: ResolvedAppSources {
395 manifest_path: String::new(),
396 config_path: config_path.map(|path| path.display().to_string()),
397 lock_path: None,
398 },
399 }
400 }
401
402 #[tokio::test]
403 async fn list_scenes_returns_merged_scene_files() {
404 let root = tempdir().expect("temp dir");
405 let repo_root = root.path().to_path_buf();
406 let instance_dir = repo_root.join(".weft").join("weft-claw");
407 let scenes_dir = instance_dir.join("scenes");
408 std::fs::create_dir_all(&scenes_dir).expect("scene dir created");
409 std::fs::write(
410 instance_dir.join("config.toml"),
411 r#"schema_version = 1
412active_scene = "team"
413
414[scenes.stable]
415name = "stable"
416description = "Stable embedded scene"
417"#,
418 )
419 .expect("config written");
420 std::fs::write(
421 scenes_dir.join("team.toml"),
422 r#"schema_version = 1
423description = "Team scene from file"
424profile = "developer"
425
426[features]
427enabled = ["team-mode"]
428disabled = ["legacy-mode"]
429"#,
430 )
431 .expect("scene file written");
432
433 let mut apps = ResolvedAppMap::new();
434 apps.insert(
435 "weft-claw".into(),
436 resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
437 );
438
439 let response = build_router(test_state(repo_root, apps))
440 .oneshot(
441 Request::builder()
442 .uri("/api/apps/weft-claw/scenes")
443 .body(Body::empty())
444 .unwrap(),
445 )
446 .await
447 .unwrap();
448
449 assert_eq!(response.status(), HttpStatusCode::OK);
450 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
451 .await
452 .unwrap();
453 let payload: Value = serde_json::from_slice(&body).unwrap();
454 assert_eq!(payload["app"], "weft-claw");
455 assert_eq!(payload["active_scene"], "team");
456 let scenes = payload["scenes"].as_array().expect("scenes array");
457 assert_eq!(scenes.len(), 2);
458 assert_eq!(scenes[0]["name"], "stable");
459 assert_eq!(scenes[1]["name"], "team");
460 assert_eq!(scenes[1]["description"], "Team scene from file");
461 assert_eq!(scenes[1]["profile"], "developer");
462 assert_eq!(
463 scenes[1]["features"]["enabled"],
464 serde_json::json!(["team-mode"])
465 );
466 assert_eq!(
467 scenes[1]["features"]["disabled"],
468 serde_json::json!(["legacy-mode"])
469 );
470 }
471
472 #[tokio::test]
473 async fn get_scene_returns_scene_from_config() {
474 let root = tempdir().expect("temp dir");
475 let repo_root = root.path().to_path_buf();
476 let instance_dir = repo_root.join(".weft").join("weft-claw");
477 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
478 std::fs::write(
479 instance_dir.join("config.toml"),
480 r#"schema_version = 1
481active_scene = "team"
482
483[scenes.team]
484name = "team"
485description = "Team scene"
486profile = "developer"
487base_generation = 7
488"#,
489 )
490 .expect("config written");
491
492 let mut apps = ResolvedAppMap::new();
493 apps.insert(
494 "weft-claw".into(),
495 resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
496 );
497
498 let response = build_router(test_state(repo_root, apps))
499 .oneshot(
500 Request::builder()
501 .uri("/api/apps/weft-claw/scenes/team")
502 .body(Body::empty())
503 .unwrap(),
504 )
505 .await
506 .unwrap();
507
508 assert_eq!(response.status(), HttpStatusCode::OK);
509 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
510 .await
511 .unwrap();
512 let payload: Value = serde_json::from_slice(&body).unwrap();
513 assert_eq!(payload["app"], "weft-claw");
514 assert_eq!(payload["active_scene"], "team");
515 assert_eq!(payload["scene"]["name"], "team");
516 assert_eq!(payload["scene"]["description"], "Team scene");
517 assert_eq!(payload["scene"]["base_generation"], 7);
518 }
519
520 #[tokio::test]
521 async fn get_scene_returns_structured_not_found_for_missing_scene() {
522 let root = tempdir().expect("temp dir");
523 let repo_root = root.path().to_path_buf();
524 let instance_dir = repo_root.join(".weft").join("weft-claw");
525 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
526 std::fs::write(instance_dir.join("config.toml"), "schema_version = 1\n")
527 .expect("config written");
528
529 let mut apps = ResolvedAppMap::new();
530 apps.insert(
531 "weft-claw".into(),
532 resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
533 );
534
535 let response = build_router(test_state(repo_root, apps))
536 .oneshot(
537 Request::builder()
538 .uri("/api/apps/weft-claw/scenes/missing")
539 .body(Body::empty())
540 .unwrap(),
541 )
542 .await
543 .unwrap();
544
545 assert_eq!(response.status(), HttpStatusCode::NOT_FOUND);
546 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
547 .await
548 .unwrap();
549 let payload: Value = serde_json::from_slice(&body).unwrap();
550 assert_eq!(payload["reason"], "scene_not_found");
551 assert_eq!(payload["app"], "weft-claw");
552 assert_eq!(payload["scene"], "missing");
553 }
554
555 #[tokio::test]
556 async fn create_scene_writes_scene_file() {
557 let root = tempdir().expect("temp dir");
558 let repo_root = root.path().to_path_buf();
559 let instance_dir = repo_root.join(".weft").join("weft-claw");
560 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
561 std::fs::write(instance_dir.join("config.toml"), "schema_version = 1\n")
562 .expect("config written");
563
564 let mut apps = ResolvedAppMap::new();
565 apps.insert(
566 "weft-claw".into(),
567 resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
568 );
569
570 let response = build_router(test_state(repo_root, apps))
571 .oneshot(
572 Request::builder()
573 .method("POST")
574 .uri("/api/apps/weft-claw/scenes")
575 .header("content-type", "application/json")
576 .body(Body::from(
577 r#"{"name":"team","description":"Team scene","profile":"developer","features":{"enabled":["team-mode"]}}"#,
578 ))
579 .unwrap(),
580 )
581 .await
582 .unwrap();
583
584 assert_eq!(response.status(), HttpStatusCode::OK);
585 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
586 .await
587 .unwrap();
588 let payload: Value = serde_json::from_slice(&body).unwrap();
589 assert_eq!(payload["scene"]["name"], "team");
590 assert_eq!(payload["scene"]["schema_version"], 1);
591 assert_eq!(
592 payload["scene"]["features"]["enabled"],
593 serde_json::json!(["team-mode"])
594 );
595 let scene_file = std::fs::read_to_string(instance_dir.join("scenes").join("team.toml"))
596 .expect("scene file written");
597 assert!(scene_file.contains("name = \"team\""));
598 assert!(scene_file.contains("profile = \"developer\""));
599 }
600
601 #[tokio::test]
602 async fn bind_scene_updates_active_scene_in_config() {
603 let root = tempdir().expect("temp dir");
604 let repo_root = root.path().to_path_buf();
605 let instance_dir = repo_root.join(".weft").join("weft-claw");
606 let scenes_dir = instance_dir.join("scenes");
607 std::fs::create_dir_all(&scenes_dir).expect("scene dir created");
608 std::fs::write(
609 instance_dir.join("config.toml"),
610 "schema_version = 1\nactive_scene = \"stable\"\n",
611 )
612 .expect("config written");
613 std::fs::write(
614 scenes_dir.join("team.toml"),
615 "schema_version = 1\nname = \"team\"\ndescription = \"Team scene\"\n",
616 )
617 .expect("scene file written");
618
619 let mut apps = ResolvedAppMap::new();
620 apps.insert(
621 "weft-claw".into(),
622 resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
623 );
624
625 let response = build_router(test_state(repo_root, apps))
626 .oneshot(
627 Request::builder()
628 .method("POST")
629 .uri("/api/apps/weft-claw/scenes/team/bind")
630 .header("content-type", "application/json")
631 .body(Body::from("{}"))
632 .unwrap(),
633 )
634 .await
635 .unwrap();
636
637 assert_eq!(response.status(), HttpStatusCode::OK);
638 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
639 .await
640 .unwrap();
641 let payload: Value = serde_json::from_slice(&body).unwrap();
642 assert_eq!(payload["active_scene"], "team");
643 assert_eq!(payload["scene"]["name"], "team");
644 let config_file =
645 std::fs::read_to_string(instance_dir.join("config.toml")).expect("config file updated");
646 assert!(config_file.contains("active_scene = \"team\""));
647 }
648
649 #[tokio::test]
650 async fn list_scenes_returns_empty_when_config_is_unavailable() {
651 let root = tempdir().expect("temp dir");
652 let repo_root = root.path().to_path_buf();
653 let missing_config = repo_root
654 .join(".weft")
655 .join("weft-claw")
656 .join("config.toml");
657
658 let mut apps = ResolvedAppMap::new();
659 apps.insert(
660 "weft-claw".into(),
661 resolved_app("weft-claw", Some(&missing_config)),
662 );
663
664 let response = build_router(test_state(repo_root, apps))
665 .oneshot(
666 Request::builder()
667 .uri("/api/apps/weft-claw/scenes")
668 .body(Body::empty())
669 .unwrap(),
670 )
671 .await
672 .unwrap();
673
674 assert_eq!(response.status(), HttpStatusCode::OK);
675 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
676 .await
677 .unwrap();
678 let payload: Value = serde_json::from_slice(&body).unwrap();
679 assert_eq!(payload["app"], "weft-claw");
680 assert_eq!(payload["scenes"], serde_json::json!([]));
681 assert_eq!(payload["active_scene"], "");
682 }
683}