Skip to main content

weft_core/api/
plans.rs

1use crate::api::openai_compat::{AppState, ChatProviderInfo};
2use crate::package::config::load_manifest;
3use crate::package::{
4    build_service_config, native_library_candidates, DiscoveredPackage, PackageInfo, PackageRuntime,
5};
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::Json;
9use std::path::{Component, Path, PathBuf};
10
11#[derive(Debug, serde::Deserialize)]
12pub struct ActivationPlanRequest {
13    #[serde(default)]
14    materialized_path: Option<String>,
15    #[serde(default)]
16    package_path: Option<String>,
17    #[serde(default)]
18    path: Option<String>,
19    #[serde(default)]
20    apply: bool,
21    #[serde(default)]
22    confirm: bool,
23    #[serde(default)]
24    start_service: bool,
25}
26
27fn requested_package_path(request: &ActivationPlanRequest) -> Option<&str> {
28    request
29        .materialized_path
30        .as_deref()
31        .or(request.package_path.as_deref())
32        .or(request.path.as_deref())
33        .map(str::trim)
34        .filter(|value| !value.is_empty())
35}
36
37fn has_parent_dir_component(path: &std::path::Path) -> bool {
38    path.components()
39        .any(|component| matches!(component, Component::ParentDir))
40}
41
42fn resolve_package_path(repo_root: &std::path::Path, requested: &str) -> Result<PathBuf, String> {
43    let path = PathBuf::from(requested);
44    if has_parent_dir_component(&path) {
45        return Err("package path must not contain parent-directory components".to_string());
46    }
47
48    if path.is_absolute() {
49        Ok(path)
50    } else {
51        Ok(repo_root.join(path))
52    }
53}
54
55fn normalize_path_for_guard(path: &Path) -> PathBuf {
56    std::fs::canonicalize(path).unwrap_or_else(|_| {
57        if path.is_absolute() {
58            path.to_path_buf()
59        } else {
60            std::env::current_dir()
61                .map(|cwd| cwd.join(path))
62                .unwrap_or_else(|_| path.to_path_buf())
63        }
64    })
65}
66
67fn materialized_managed_root(repo_root: &Path) -> PathBuf {
68    repo_root.join(".weft").join("materialized")
69}
70
71fn is_under_explicit_temp_or_managed_root(repo_root: &Path, package_path: &Path) -> bool {
72    let package_path = normalize_path_for_guard(package_path);
73    let temp_root = normalize_path_for_guard(&std::env::temp_dir());
74    let managed_root = normalize_path_for_guard(&materialized_managed_root(repo_root));
75
76    package_path.starts_with(&temp_root) || package_path.starts_with(&managed_root)
77}
78
79fn blocked_response(
80    package_path: &Path,
81    manifest_found: bool,
82    checks: Vec<serde_json::Value>,
83    validation_issues: Vec<String>,
84) -> serde_json::Value {
85    serde_json::json!({
86        "status": "activation_plan_blocked",
87        "plan_only": true,
88        "metadata_only": true,
89        "activation_performed": false,
90        "mutation_performed": false,
91        "lock_mutation_performed": false,
92        "package_path": package_path.display().to_string(),
93        "manifest_found": manifest_found,
94        "activation_required": false,
95        "ready_for_activation": false,
96        "checks": checks,
97        "validation_issues": validation_issues,
98    })
99}
100
101/// POST /api/plans/activate — inspect a materialized local package, or explicitly apply a controlled metadata activation.
102pub async fn activation_plan(
103    State(state): State<AppState>,
104    Json(request): Json<ActivationPlanRequest>,
105) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
106    let requested_path = requested_package_path(&request).ok_or_else(|| {
107        (
108            StatusCode::BAD_REQUEST,
109            Json(serde_json::json!({
110                "error": "missing materialized_path/package_path/path",
111                "activation_performed": false,
112                "mutation_performed": false,
113            })),
114        )
115    })?;
116
117    let package_path = resolve_package_path(&state.repo_root, requested_path).map_err(|error| {
118        (
119            StatusCode::BAD_REQUEST,
120            Json(serde_json::json!({
121                "error": error,
122                "activation_performed": false,
123                "mutation_performed": false,
124            })),
125        )
126    })?;
127
128    let manifest_path = package_path.join("package.toml");
129    let path_exists = package_path.is_dir();
130    let manifest_found = manifest_path.is_file();
131
132    let mut checks = vec![
133        serde_json::json!({
134            "name": "package_directory_exists",
135            "ok": path_exists,
136            "path": package_path.display().to_string(),
137        }),
138        serde_json::json!({
139            "name": "plugin_manifest_found",
140            "ok": manifest_found,
141            "path": manifest_path.display().to_string(),
142        }),
143    ];
144
145    if !path_exists || !manifest_found {
146        return Ok(Json(blocked_response(
147            &package_path,
148            manifest_found,
149            checks,
150            vec!["materialized package directory and package.toml are required before activation can be planned".to_string()],
151        )));
152    }
153
154    let manifest = load_manifest(&package_path).map_err(|error| {
155        (
156            StatusCode::BAD_REQUEST,
157            Json(serde_json::json!({
158                "error": format!("failed to parse package.toml: {error}"),
159                "activation_performed": false,
160                "mutation_performed": false,
161            })),
162        )
163    })?;
164
165    let runtime = manifest.runtime_kind();
166    let package_runtime = PackageRuntime::from_manifest(&manifest);
167    let entry = manifest.resolved_entry();
168    let entry_path = entry.as_ref().map(|entry| package_path.join(entry));
169    let requires_entry = matches!(runtime.as_str(), "wasm" | "service" | "embedded");
170    let entry_exists = entry_path
171        .as_ref()
172        .map(|path| path.is_file())
173        .unwrap_or(false);
174    let dependencies = manifest.dependencies.clone();
175    let installed_packages = {
176        let pm = state.package_manager.read().await;
177        pm.list().into_iter().cloned().collect::<Vec<_>>()
178    };
179    let missing_dependencies = crate::package::check_dependencies(&manifest, &installed_packages);
180    let dependency_check_ok = missing_dependencies.is_empty();
181    let entry_check_ok = !requires_entry || entry_exists;
182    let manifest_identity_ok = !manifest.package_info.name.trim().is_empty();
183    let native_runtime_blocked = package_runtime == PackageRuntime::Native;
184    let native_allowed = manifest
185        .package
186        .as_ref()
187        .map(|package| package.native_allowed)
188        .unwrap_or(false);
189    let expected_digest = manifest
190        .package
191        .as_ref()
192        .and_then(|package| package.expected_digest.as_deref())
193        .map(str::trim)
194        .filter(|digest| !digest.is_empty());
195    let native_library_candidate = if package_runtime == PackageRuntime::Native {
196        entry_path.as_ref().and_then(|entry_path| {
197            native_library_candidates(entry_path)
198                .into_iter()
199                .find(|path| path.is_file())
200        })
201    } else {
202        None
203    };
204    let root_check_ok =
205        !request.apply || is_under_explicit_temp_or_managed_root(&state.repo_root, &package_path);
206    let profile = *state.active_profile.read().await;
207    let profile_allowed_for_apply = matches!(
208        profile,
209        crate::app::AppProfile::Developer | crate::app::AppProfile::Trusted
210    );
211    let profile_check_ok = !request.apply || profile_allowed_for_apply;
212    let trusted_native_policy = state.core_policy.check("core.native_execution", profile);
213    let trusted_native_profile_ok = profile == crate::app::AppProfile::Trusted;
214    let confirmation_check_ok = !request.apply || request.confirm;
215    let trusted_native_confirmation_ok = request.confirm;
216    let expected_digest_present = expected_digest.is_some();
217    let native_library_candidate_exists = native_library_candidate.is_some();
218    let ready_for_trusted_native_load = native_runtime_blocked
219        && manifest_identity_ok
220        && dependency_check_ok
221        && trusted_native_profile_ok
222        && trusted_native_policy.allowed
223        && trusted_native_confirmation_ok
224        && native_allowed
225        && expected_digest_present
226        && native_library_candidate_exists;
227    let ready_for_activation = manifest_identity_ok && entry_check_ok && dependency_check_ok;
228    let apply_allowed = ready_for_activation
229        && !native_runtime_blocked
230        && root_check_ok
231        && profile_check_ok
232        && confirmation_check_ok;
233    let runtime_safe_for_controlled_activation_ok = if request.apply {
234        !native_runtime_blocked
235    } else {
236        !native_runtime_blocked || ready_for_trusted_native_load
237    };
238
239    checks.push(serde_json::json!({
240        "name": "manifest_identity_present",
241        "ok": manifest_identity_ok,
242        "package_name": manifest.package_info.name,
243        "version": manifest.package_info.version,
244    }));
245    checks.push(serde_json::json!({
246        "name": "runtime_entry_available",
247        "ok": entry_check_ok,
248        "required": requires_entry,
249        "entry": entry,
250        "path": entry_path.as_ref().map(|path| path.display().to_string()),
251        "exists": entry_exists,
252    }));
253    checks.push(serde_json::json!({
254        "name": "dependencies_satisfied",
255        "ok": dependency_check_ok,
256        "missing": missing_dependencies,
257    }));
258    checks.push(serde_json::json!({
259        "name": "runtime_safe_for_controlled_activation",
260        "ok": runtime_safe_for_controlled_activation_ok,
261        "runtime": runtime,
262        "blocked_reason": if !runtime_safe_for_controlled_activation_ok { Some("native runtime requires trusted native execution flow and is not hot-loaded by this controlled endpoint") } else { None },
263    }));
264    if native_runtime_blocked {
265        checks.push(serde_json::json!({
266            "name": "trusted_native_profile_required",
267            "ok": trusted_native_profile_ok && trusted_native_policy.allowed,
268            "profile": profile.as_str(),
269            "policy_reason": trusted_native_policy.reason,
270        }));
271        checks.push(serde_json::json!({
272            "name": "trusted_native_explicit_confirmation_required",
273            "ok": trusted_native_confirmation_ok,
274            "confirmed": request.confirm,
275        }));
276        checks.push(serde_json::json!({
277            "name": "trusted_native_manifest_allows_native",
278            "ok": native_allowed,
279            "native_allowed": native_allowed,
280        }));
281        checks.push(serde_json::json!({
282            "name": "trusted_native_expected_digest_present",
283            "ok": expected_digest_present,
284            "expected_digest": expected_digest,
285        }));
286        checks.push(serde_json::json!({
287            "name": "trusted_native_library_candidate_exists",
288            "ok": native_library_candidate_exists,
289            "candidate": native_library_candidate.as_ref().map(|path| path.display().to_string()),
290            "entry_path": entry_path.as_ref().map(|path| path.display().to_string()),
291        }));
292    }
293    checks.push(serde_json::json!({
294        "name": "materialized_path_under_explicit_temp_or_managed_root",
295        "ok": root_check_ok,
296        "required_for_apply": true,
297        "temp_root": normalize_path_for_guard(&std::env::temp_dir()).display().to_string(),
298        "managed_root": materialized_managed_root(&state.repo_root).display().to_string(),
299    }));
300    checks.push(serde_json::json!({
301        "name": "developer_or_trusted_profile_required_for_apply",
302        "ok": profile_check_ok,
303        "profile": profile.as_str(),
304    }));
305    checks.push(serde_json::json!({
306        "name": "explicit_confirmation_required_for_apply",
307        "ok": confirmation_check_ok,
308        "confirmed": request.confirm,
309    }));
310
311    let validation_issues = checks
312        .iter()
313        .filter(|check| {
314            if request.apply {
315                return !check
316                    .get("ok")
317                    .and_then(serde_json::Value::as_bool)
318                    .unwrap_or(false);
319            }
320
321            match check.get("name").and_then(serde_json::Value::as_str) {
322                Some("materialized_path_under_explicit_temp_or_managed_root")
323                | Some("developer_or_trusted_profile_required_for_apply")
324                | Some("explicit_confirmation_required_for_apply") => false,
325                _ => !check
326                    .get("ok")
327                    .and_then(serde_json::Value::as_bool)
328                    .unwrap_or(false),
329            }
330        })
331        .filter_map(|check| check.get("name").and_then(serde_json::Value::as_str))
332        .map(|name| format!("activation check failed: {name}"))
333        .collect::<Vec<_>>();
334
335    if native_runtime_blocked && !request.apply {
336        return Ok(Json(serde_json::json!({
337            "status": if ready_for_trusted_native_load { "ready_for_trusted_native_load" } else { "activation_plan_blocked" },
338            "plan_only": true,
339            "metadata_only": true,
340            "activation_performed": false,
341            "mutation_performed": false,
342            "lock_mutation_performed": false,
343            "native_load_performed": false,
344            "package_path": package_path.display().to_string(),
345            "manifest_found": true,
346            "package": {
347                "name": manifest.package_info.name,
348                "version": manifest.package_info.version,
349                "description": manifest.package_info.description,
350                "runtime": runtime,
351                "provides": manifest.resolved_provides(),
352                "has_ui": false,
353            },
354            "activation_required": true,
355            "ready_for_activation": false,
356            "ready_for_trusted_native_load": ready_for_trusted_native_load,
357            "trusted_native": {
358                "native_allowed": native_allowed,
359                "expected_digest_present": expected_digest_present,
360                "expected_digest": expected_digest,
361                "library_candidate": native_library_candidate.as_ref().map(|path| path.display().to_string()),
362                "profile": profile.as_str(),
363                "confirmed": request.confirm,
364            },
365            "requirements": {
366                "profile": "trusted",
367                "confirm": true,
368                "native_allowed": true,
369                "expected_digest": "present",
370                "library_candidate": "exists",
371                "native_load": "not performed by this plan endpoint"
372            },
373            "checks": checks,
374            "validation_issues": validation_issues,
375            "notes": [
376                "This endpoint only plans trusted native activation and never loads native libraries.",
377                "apply=true remains blocked for native runtimes unless a separate trusted native load flow is added."
378            ],
379        })));
380    }
381
382    if request.apply && !apply_allowed {
383        return Ok(Json(serde_json::json!({
384            "status": "activation_apply_blocked",
385            "plan_only": false,
386            "metadata_only": true,
387            "activation_performed": false,
388            "mutation_performed": false,
389            "lock_mutation_performed": false,
390            "package_path": package_path.display().to_string(),
391            "manifest_found": true,
392            "activation_required": true,
393            "ready_for_activation": ready_for_activation,
394            "checks": checks,
395            "validation_issues": validation_issues,
396            "requirements": {
397                "apply_requires_confirm_true": true,
398                "apply_requires_profile": "developer_or_trusted",
399                "apply_requires_materialized_path_under": [
400                    normalize_path_for_guard(&std::env::temp_dir()).display().to_string(),
401                    materialized_managed_root(&state.repo_root).display().to_string()
402                ],
403                "native_runtime": "blocked; use trusted native execution flow",
404                "missing_entry": "provide the runtime entry declared by package.toml before activation"
405            }
406        })));
407    }
408
409    if request.apply {
410        let package = DiscoveredPackage {
411            manifest: manifest.clone(),
412            dir: normalize_path_for_guard(&package_path),
413            entry_path: entry_path.clone(),
414            runtime: package_runtime.clone(),
415        };
416
417        state.package_manager.write().await.register(PackageInfo {
418            name: manifest.package_info.name.clone(),
419            version: Some(manifest.package_info.version.clone()),
420            overrides: vec![],
421            enabled: true,
422            has_ui: false,
423            description: Some(manifest.package_info.description.clone()),
424        });
425
426        let mut service_registered = false;
427        let mut runtime_started = false;
428        let mut service_auto_start = false;
429        let mut service_start_error: Option<String> = None;
430        if package_runtime == PackageRuntime::Service {
431            let service_config = build_service_config(&package).map_err(|error| {
432                (
433                    StatusCode::BAD_REQUEST,
434                    Json(serde_json::json!({
435                        "error": format!("failed to build service metadata: {error}"),
436                        "activation_performed": false,
437                        "mutation_performed": false,
438                    })),
439                )
440            })?;
441            service_auto_start = service_config.auto_start;
442            state.process_manager.register(service_config).await;
443            service_registered = true;
444
445            if request.start_service {
446                if service_auto_start {
447                    service_start_error = Some(
448                        "service declares persistent auto-start; controlled activation will not duplicate implicit auto-start".to_string(),
449                    );
450                } else {
451                    match state
452                        .process_manager
453                        .start(&manifest.package_info.name)
454                        .await
455                    {
456                        Ok(()) => {
457                            runtime_started = true;
458                        }
459                        Err(error) => {
460                            service_start_error = Some(error.to_string());
461                        }
462                    }
463                }
464            }
465        }
466
467        if manifest
468            .resolved_provides()
469            .contains(&"chat_channel".to_string())
470        {
471            let mut providers = state.chat_providers.write().await;
472            providers.retain(|provider| provider.name != manifest.package_info.name);
473            providers.push(ChatProviderInfo {
474                name: manifest.package_info.name.clone(),
475                endpoint: manifest
476                    .resolved_chat_endpoint()
477                    .unwrap_or_else(|| "/chat".to_string()),
478                description: manifest.package_info.description.clone(),
479            });
480            providers.sort_by(|left, right| left.name.cmp(&right.name));
481        }
482
483        return Ok(Json(serde_json::json!({
484            "status": "activation_metadata_registered",
485            "plan_only": false,
486            "metadata_only": true,
487            "activation_performed": true,
488            "mutation_performed": true,
489            "lock_mutation_performed": false,
490            "native_load_performed": false,
491            "package_path": package_path.display().to_string(),
492            "manifest_found": true,
493            "package": {
494                "name": manifest.package_info.name,
495                "version": manifest.package_info.version,
496                "description": manifest.package_info.description,
497                "runtime": runtime,
498                "provides": manifest.resolved_provides(),
499                "has_ui": false,
500            },
501            "service_registered": service_registered,
502            "service_start_requested": request.start_service,
503            "service_auto_start": service_auto_start,
504            "runtime_started": runtime_started,
505            "service_start_error": service_start_error,
506            "checks": checks,
507            "validation_issues": validation_issues,
508            "notes": [
509                "Controlled activation registered package metadata only; it did not write lock files, copy packages, load native code, or start services unless start_service=true was explicitly requested for a non-auto-start service runtime.",
510                "Native runtime remains blocked pending the trusted native execution flow."
511            ],
512        })));
513    }
514
515    Ok(Json(serde_json::json!({
516        "status": if ready_for_activation { "activation_plan_ready" } else { "activation_plan_blocked" },
517        "plan_only": true,
518        "metadata_only": true,
519        "activation_performed": false,
520        "mutation_performed": false,
521        "lock_mutation_performed": false,
522        "package_path": package_path.display().to_string(),
523        "manifest_found": true,
524        "package": {
525            "name": manifest.package_info.name,
526            "version": manifest.package_info.version,
527            "description": manifest.package_info.description,
528            "runtime": runtime,
529            "provides": manifest.resolved_provides(),
530            "has_ui": false,
531        },
532        "activation_required": true,
533        "ready_for_activation": ready_for_activation,
534        "requirements": {
535            "core_verification_required": true,
536            "lock_mutation_required": true,
537            "reload_or_runtime_start_required": true,
538            "rollback_snapshot_required": true,
539            "apply_requires_confirm_true": true,
540            "apply_requires_profile": "developer_or_trusted",
541            "apply_requires_materialized_path_under": [
542                normalize_path_for_guard(&std::env::temp_dir()).display().to_string(),
543                materialized_managed_root(&state.repo_root).display().to_string()
544            ],
545            "dependencies": dependencies,
546        },
547        "checks": checks,
548        "validation_issues": validation_issues,
549        "notes": [
550            "apply defaults to false; without apply=true this endpoint is metadata-only and never loads, reloads, installs, activates, deletes, or writes package state.",
551            "apply=true requires confirm=true, developer profile, a materialized path under the explicit temp/managed root, and a non-native runtime."
552        ],
553    })))
554}