1use crate::api::openai_compat::AppState;
2use crate::app::{
3 binding_set_id_from_lock_bindings, closure_digest_from_lock_packages,
4 closure_id_from_lock_packages, scene_digest, AppGenerationSummaryMetadata,
5};
6use axum::extract::{Path, State};
7use axum::http::StatusCode;
8use axum::Json;
9use serde::Serialize;
10use sha2::{Digest, Sha512};
11use std::collections::{BTreeMap, BTreeSet};
12use std::io::{ErrorKind, Write};
13use std::path::{Path as FsPath, PathBuf};
14
15const WEFT_CLAW_REQUIRED_REAL_PACKAGES: &[&str] = &[
16 "prompt-system",
17 "workflow-orchestrator",
18 "tool-runtime-core",
19 "tool-shell",
20 "tool-files",
21 "tool-web",
22 "tool-git",
23];
24
25#[derive(Debug, Clone, Serialize)]
26struct GenerationPointerSnapshot {
27 path: String,
28 generation_id: Option<u64>,
29 read_error: Option<String>,
30}
31
32#[derive(Debug, Clone, Serialize)]
33struct GenerationIndexSnapshot {
34 path: String,
35 present: bool,
36 index: Option<crate::app::AppGenerationIndex>,
37 read_error: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize)]
41struct InstanceGenerationDiagnostics {
42 instance_dir: String,
43 active_pointer: GenerationPointerSnapshot,
44 previous_pointer: GenerationPointerSnapshot,
45 generation_index: GenerationIndexSnapshot,
46 consistency_report: Option<crate::app::GenerationIndexConsistencyReport>,
47}
48
49#[derive(Debug, Clone, Serialize)]
50struct GenerationDiagnosticsResponse {
51 app: String,
52 store_present: bool,
53 store_summary: crate::app::AppGenerationIndex,
54 instance: Option<InstanceGenerationDiagnostics>,
55}
56
57#[derive(Debug, Clone, Serialize)]
58struct ScalarDiff<T>
59where
60 T: Serialize,
61{
62 from: T,
63 to: T,
64 changed: bool,
65}
66
67#[derive(Debug, Clone, Serialize)]
68struct CollectionDiff<T>
69where
70 T: Serialize,
71{
72 from: Vec<T>,
73 to: Vec<T>,
74 added: Vec<T>,
75 removed: Vec<T>,
76 changed: bool,
77}
78
79#[derive(Debug, Clone, Serialize)]
80struct BindingChange<T>
81where
82 T: Serialize,
83{
84 capability: String,
85 from: T,
86 to: T,
87}
88
89#[derive(Debug, Clone, Serialize)]
90struct BindingCollectionDiff<T>
91where
92 T: Serialize,
93{
94 from: Vec<T>,
95 to: Vec<T>,
96 added: Vec<T>,
97 removed: Vec<T>,
98 changed_entries: Vec<BindingChange<T>>,
99 changed: bool,
100}
101
102#[derive(Debug, Clone, Serialize)]
103struct GenerationSummarySnapshot {
104 id: u64,
105 status: crate::app::GenerationStatus,
106 scene: String,
107 profile: String,
108 binding_set_id: String,
109 closure_id: String,
110 enabled_features: Vec<String>,
111 capabilities: Vec<String>,
112 bindings: Vec<crate::app::AppBindingResolution>,
113}
114
115#[derive(Debug, Clone, Serialize)]
116struct GenerationSummaryDiff {
117 id: ScalarDiff<u64>,
118 status: ScalarDiff<crate::app::GenerationStatus>,
119 scene: ScalarDiff<String>,
120 profile: ScalarDiff<String>,
121 binding_set_id: ScalarDiff<String>,
122 closure_id: ScalarDiff<String>,
123 enabled_features: CollectionDiff<String>,
124 capabilities: CollectionDiff<String>,
125 bindings: BindingCollectionDiff<crate::app::AppBindingResolution>,
126}
127
128#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
129struct LockBindingSnapshot {
130 capability: String,
131 provider: String,
132}
133
134#[derive(Debug, Clone, Serialize)]
135struct ImmutableLockDiff {
136 scene: ScalarDiff<String>,
137 profile: ScalarDiff<String>,
138 closure_id: ScalarDiff<String>,
139 packages: CollectionDiff<String>,
140 bindings: BindingCollectionDiff<LockBindingSnapshot>,
141}
142
143#[derive(Debug, Clone, Serialize)]
144struct ImmutableLockComparison {
145 available: bool,
146 from_path: Option<String>,
147 to_path: Option<String>,
148 diff: Option<ImmutableLockDiff>,
149}
150
151#[derive(Debug, Clone, Serialize)]
152struct GenerationDiffWarning {
153 warning_type: String,
154 generation: u64,
155 path: Option<String>,
156 message: String,
157}
158
159#[derive(Debug, Clone, Serialize)]
160struct GenerationDiffResponse {
161 app: String,
162 from: GenerationSummarySnapshot,
163 to: GenerationSummarySnapshot,
164 summary_diff: GenerationSummaryDiff,
165 immutable_lock: ImmutableLockComparison,
166 warnings: Vec<GenerationDiffWarning>,
167}
168
169fn resolved_app_instance_dir(app: &crate::app::ResolvedApp) -> Option<PathBuf> {
170 app.sources
171 .lock_path
172 .as_deref()
173 .and_then(|path| FsPath::new(path).parent().map(FsPath::to_path_buf))
174 .or_else(|| {
175 app.sources
176 .config_path
177 .as_deref()
178 .and_then(|path| FsPath::new(path).parent().map(FsPath::to_path_buf))
179 })
180}
181
182fn scalar_diff<T>(from: T, to: T) -> ScalarDiff<T>
183where
184 T: Serialize + PartialEq,
185{
186 let changed = from != to;
187 ScalarDiff { from, to, changed }
188}
189
190fn sorted_strings(values: &[String]) -> Vec<String> {
191 let mut values = values.to_vec();
192 values.sort();
193 values.dedup();
194 values
195}
196
197fn collection_diff(values_from: &[String], values_to: &[String]) -> CollectionDiff<String> {
198 let from = sorted_strings(values_from);
199 let to = sorted_strings(values_to);
200 let from_set = from.iter().cloned().collect::<BTreeSet<_>>();
201 let to_set = to.iter().cloned().collect::<BTreeSet<_>>();
202 let added = to_set.difference(&from_set).cloned().collect::<Vec<_>>();
203 let removed = from_set.difference(&to_set).cloned().collect::<Vec<_>>();
204 let changed = !added.is_empty() || !removed.is_empty();
205
206 CollectionDiff {
207 from,
208 to,
209 added,
210 removed,
211 changed,
212 }
213}
214
215fn normalized_bindings(
216 bindings: &[crate::app::AppBindingResolution],
217) -> Vec<crate::app::AppBindingResolution> {
218 let mut bindings = bindings.to_vec();
219 bindings.sort_by(|left, right| {
220 left.capability
221 .cmp(&right.capability)
222 .then(left.provider.cmp(&right.provider))
223 .then(left.mutable.cmp(&right.mutable))
224 .then(left.source.cmp(&right.source))
225 });
226 bindings
227}
228
229fn binding_collection_diff(
230 from_bindings: &[crate::app::AppBindingResolution],
231 to_bindings: &[crate::app::AppBindingResolution],
232) -> BindingCollectionDiff<crate::app::AppBindingResolution> {
233 let from = normalized_bindings(from_bindings);
234 let to = normalized_bindings(to_bindings);
235
236 let from_by_capability = from
237 .iter()
238 .cloned()
239 .map(|binding| (binding.capability.clone(), binding))
240 .collect::<BTreeMap<_, _>>();
241 let to_by_capability = to
242 .iter()
243 .cloned()
244 .map(|binding| (binding.capability.clone(), binding))
245 .collect::<BTreeMap<_, _>>();
246
247 let added = to_by_capability
248 .iter()
249 .filter(|(capability, _)| !from_by_capability.contains_key(*capability))
250 .map(|(_, binding)| binding.clone())
251 .collect::<Vec<_>>();
252 let removed = from_by_capability
253 .iter()
254 .filter(|(capability, _)| !to_by_capability.contains_key(*capability))
255 .map(|(_, binding)| binding.clone())
256 .collect::<Vec<_>>();
257 let changed_entries = from_by_capability
258 .iter()
259 .filter_map(|(capability, from_binding)| {
260 let to_binding = to_by_capability.get(capability)?;
261 if from_binding == to_binding {
262 None
263 } else {
264 Some(BindingChange {
265 capability: capability.clone(),
266 from: from_binding.clone(),
267 to: to_binding.clone(),
268 })
269 }
270 })
271 .collect::<Vec<_>>();
272 let changed = !added.is_empty() || !removed.is_empty() || !changed_entries.is_empty();
273
274 BindingCollectionDiff {
275 from,
276 to,
277 added,
278 removed,
279 changed_entries,
280 changed,
281 }
282}
283
284fn generation_summary_snapshot(
285 generation: &crate::app::AppGeneration,
286) -> GenerationSummarySnapshot {
287 GenerationSummarySnapshot {
288 id: generation.id,
289 status: generation.status,
290 scene: generation.scene.clone(),
291 profile: generation.profile.clone(),
292 binding_set_id: generation.binding_set_id.clone(),
293 closure_id: generation.closure_id.clone(),
294 enabled_features: sorted_strings(&generation.enabled_features),
295 capabilities: sorted_strings(&generation.capabilities),
296 bindings: normalized_bindings(&generation.bindings),
297 }
298}
299
300fn generation_summary_diff(
301 from: &crate::app::AppGeneration,
302 to: &crate::app::AppGeneration,
303) -> GenerationSummaryDiff {
304 GenerationSummaryDiff {
305 id: scalar_diff(from.id, to.id),
306 status: scalar_diff(from.status, to.status),
307 scene: scalar_diff(from.scene.clone(), to.scene.clone()),
308 profile: scalar_diff(from.profile.clone(), to.profile.clone()),
309 binding_set_id: scalar_diff(from.binding_set_id.clone(), to.binding_set_id.clone()),
310 closure_id: scalar_diff(from.closure_id.clone(), to.closure_id.clone()),
311 enabled_features: collection_diff(&from.enabled_features, &to.enabled_features),
312 capabilities: collection_diff(&from.capabilities, &to.capabilities),
313 bindings: binding_collection_diff(&from.bindings, &to.bindings),
314 }
315}
316
317fn normalized_lock_bindings(bindings: &[crate::app::AppLockBinding]) -> Vec<LockBindingSnapshot> {
318 let mut bindings = bindings
319 .iter()
320 .map(|binding| LockBindingSnapshot {
321 capability: binding.capability.clone(),
322 provider: binding.provider.clone(),
323 })
324 .collect::<Vec<_>>();
325 bindings.sort_by(|left, right| {
326 left.capability
327 .cmp(&right.capability)
328 .then(left.provider.cmp(&right.provider))
329 });
330 bindings
331}
332
333fn lock_binding_collection_diff(
334 from_bindings: &[crate::app::AppLockBinding],
335 to_bindings: &[crate::app::AppLockBinding],
336) -> BindingCollectionDiff<LockBindingSnapshot> {
337 let from = normalized_lock_bindings(from_bindings);
338 let to = normalized_lock_bindings(to_bindings);
339
340 let from_by_capability = from
341 .iter()
342 .cloned()
343 .map(|binding| (binding.capability.clone(), binding))
344 .collect::<BTreeMap<_, _>>();
345 let to_by_capability = to
346 .iter()
347 .cloned()
348 .map(|binding| (binding.capability.clone(), binding))
349 .collect::<BTreeMap<_, _>>();
350
351 let added = to_by_capability
352 .iter()
353 .filter(|(capability, _)| !from_by_capability.contains_key(*capability))
354 .map(|(_, binding)| binding.clone())
355 .collect::<Vec<_>>();
356 let removed = from_by_capability
357 .iter()
358 .filter(|(capability, _)| !to_by_capability.contains_key(*capability))
359 .map(|(_, binding)| binding.clone())
360 .collect::<Vec<_>>();
361 let changed_entries = from_by_capability
362 .iter()
363 .filter_map(|(capability, from_binding)| {
364 let to_binding = to_by_capability.get(capability)?;
365 if from_binding == to_binding {
366 None
367 } else {
368 Some(BindingChange {
369 capability: capability.clone(),
370 from: from_binding.clone(),
371 to: to_binding.clone(),
372 })
373 }
374 })
375 .collect::<Vec<_>>();
376 let changed = !added.is_empty() || !removed.is_empty() || !changed_entries.is_empty();
377
378 BindingCollectionDiff {
379 from,
380 to,
381 added,
382 removed,
383 changed_entries,
384 changed,
385 }
386}
387
388fn immutable_lock_diff(
389 from: &crate::app::InstanceLock,
390 to: &crate::app::InstanceLock,
391) -> ImmutableLockDiff {
392 let from_packages = from
393 .packages
394 .iter()
395 .map(crate::app::AppLockPackage::identity)
396 .collect::<Vec<_>>();
397 let to_packages = to
398 .packages
399 .iter()
400 .map(crate::app::AppLockPackage::identity)
401 .collect::<Vec<_>>();
402
403 ImmutableLockDiff {
404 scene: scalar_diff(from.scene.clone(), to.scene.clone()),
405 profile: scalar_diff(from.profile.clone(), to.profile.clone()),
406 closure_id: scalar_diff(from.closure_id.clone(), to.closure_id.clone()),
407 packages: collection_diff(&from_packages, &to_packages),
408 bindings: lock_binding_collection_diff(&from.bindings, &to.bindings),
409 }
410}
411
412fn generation_diff_not_found(
413 app_name: &str,
414 generation_id: u64,
415 role: &str,
416) -> (StatusCode, Json<serde_json::Value>) {
417 (
418 StatusCode::NOT_FOUND,
419 Json(serde_json::json!({
420 "error": format!(
421 "Generation {} not found for app '{}'",
422 generation_id, app_name
423 ),
424 "reason": "generation_not_found",
425 "app": app_name,
426 "generation": generation_id,
427 "role": role,
428 })),
429 )
430}
431
432fn load_generation_lock_for_diff(
433 generation: &crate::app::AppGeneration,
434 instance_dir: Option<&FsPath>,
435) -> Result<GenerationLockDiffData, anyhow::Error> {
436 let Some(instance_dir) = instance_dir else {
437 return Ok((
438 None,
439 None,
440 Some(GenerationDiffWarning {
441 warning_type: "instance_dir_unavailable".into(),
442 generation: generation.id,
443 path: None,
444 message: format!(
445 "Resolved instance directory is unavailable for generation {}; immutable lock diff was skipped",
446 generation.id
447 ),
448 }),
449 ));
450 };
451
452 if generation.lock_path.trim().is_empty() {
453 return Ok((
454 None,
455 None,
456 Some(GenerationDiffWarning {
457 warning_type: "missing_lock_path_metadata".into(),
458 generation: generation.id,
459 path: None,
460 message: format!(
461 "Generation {} is missing immutable lock_path metadata; immutable lock diff was skipped",
462 generation.id
463 ),
464 }),
465 ));
466 }
467
468 let lock_path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
469 let lock_path_display = lock_path.display().to_string();
470 match crate::app::load_instance_lock_from_path(&lock_path) {
471 Ok(lock) => Ok((Some(lock), Some(lock_path_display), None)),
472 Err(error) if is_not_found_error(&error) => Ok((
473 None,
474 Some(lock_path_display.clone()),
475 Some(GenerationDiffWarning {
476 warning_type: "immutable_lock_missing".into(),
477 generation: generation.id,
478 path: Some(lock_path_display),
479 message: format!(
480 "Immutable generation lock for generation {} is missing; returning summary diff only",
481 generation.id
482 ),
483 }),
484 )),
485 Err(error) => Err(error),
486 }
487}
488
489type GenerationLockDiffData = (
490 Option<crate::app::InstanceLock>,
491 Option<String>,
492 Option<GenerationDiffWarning>,
493);
494
495fn persist_generation_index_best_effort(
496 app_name: &str,
497 app_store: &crate::app::AppGenerationStore,
498 instance_dir: &FsPath,
499) -> (bool, Option<String>) {
500 let index_path = crate::app::generation_index_path(instance_dir);
501 let index = crate::app::AppGenerationIndex::from_store(app_store);
502 match crate::app::save_generation_index(instance_dir, &index) {
503 Ok(()) => (true, None),
504 Err(error) => {
505 let message = format!(
506 "Failed to persist repairable generation index for '{}' at '{}': {error:#}",
507 app_name,
508 index_path.display()
509 );
510 tracing::warn!("{}", message);
511 (false, Some(message))
512 }
513 }
514}
515
516fn write_generation_pointers(
517 app_name: &str,
518 instance_dir: &FsPath,
519 previous_generation_id: Option<u64>,
520 active_generation_id: u64,
521 generation_lock_written: bool,
522 failed_status: &str,
523 operation_label: &str,
524) -> Result<(), (StatusCode, Json<serde_json::Value>)> {
525 if let Err(error) =
526 crate::app::write_previous_generation_pointer(instance_dir, previous_generation_id)
527 {
528 let message = format!(
529 "Failed to write previous generation pointer for '{}' at '{}': {error:#}",
530 app_name,
531 crate::app::previous_generation_pointer_path(instance_dir).display()
532 );
533 tracing::warn!("{}", message);
534 return Err((
535 StatusCode::INTERNAL_SERVER_ERROR,
536 Json(serde_json::json!({
537 "error": message,
538 "reason": "pointer_write_failed",
539 "status": failed_status,
540 "generation_lock_written": generation_lock_written,
541 "operation": operation_label,
542 "lock_written": true,
543 "pointer_written": false,
544 "pointer_error": {
545 "stage": "previous",
546 "repair_needed": false,
547 "message": format!(
548 "Previous generation pointer write failed before active pointer update for '{}' during {}",
549 app_name,
550 operation_label
551 ),
552 "details": format!("{error:#}"),
553 },
554 "index_written": false,
555 "index_error": serde_json::Value::Null,
556 })),
557 ));
558 }
559
560 if let Err(error) =
561 crate::app::write_active_generation_pointer(instance_dir, Some(active_generation_id))
562 {
563 let message = format!(
564 "Failed to write active generation pointer for '{}' at '{}': {error:#}",
565 app_name,
566 crate::app::active_generation_pointer_path(instance_dir).display()
567 );
568 tracing::warn!("{}", message);
569 return Err((
570 StatusCode::INTERNAL_SERVER_ERROR,
571 Json(serde_json::json!({
572 "error": message,
573 "reason": "pointer_write_failed",
574 "status": failed_status,
575 "generation_lock_written": generation_lock_written,
576 "operation": operation_label,
577 "lock_written": true,
578 "pointer_written": false,
579 "pointer_error": {
580 "stage": "active",
581 "repair_needed": true,
582 "message": format!(
583 "Active generation pointer write failed after previous pointer update for '{}' during {}; repair is required before relying on pointer files",
584 app_name,
585 operation_label
586 ),
587 "details": format!("{error:#}"),
588 },
589 "index_written": false,
590 "index_error": serde_json::Value::Null,
591 })),
592 ));
593 }
594
595 Ok(())
596}
597
598fn generation_pointer_snapshot(path: PathBuf) -> GenerationPointerSnapshot {
599 match crate::app::read_generation_pointer(&path) {
600 Ok(generation_id) => GenerationPointerSnapshot {
601 path: path.display().to_string(),
602 generation_id,
603 read_error: None,
604 },
605 Err(error) => GenerationPointerSnapshot {
606 path: path.display().to_string(),
607 generation_id: None,
608 read_error: Some(format!("{error:#}")),
609 },
610 }
611}
612
613fn generation_index_snapshot(instance_dir: &FsPath) -> GenerationIndexSnapshot {
614 let path = crate::app::generation_index_path(instance_dir);
615 match crate::app::load_generation_index_from_path(&path) {
616 Ok(index) => GenerationIndexSnapshot {
617 path: path.display().to_string(),
618 present: path.exists(),
619 index,
620 read_error: None,
621 },
622 Err(error) => GenerationIndexSnapshot {
623 path: path.display().to_string(),
624 present: path.exists(),
625 index: None,
626 read_error: Some(format!("{error:#}")),
627 },
628 }
629}
630
631fn instance_generation_diagnostics(instance_dir: PathBuf) -> InstanceGenerationDiagnostics {
632 let active_pointer =
633 generation_pointer_snapshot(crate::app::active_generation_pointer_path(&instance_dir));
634 let previous_pointer =
635 generation_pointer_snapshot(crate::app::previous_generation_pointer_path(&instance_dir));
636 let generation_index = generation_index_snapshot(&instance_dir);
637 let consistency_report =
638 generation_index.index.as_ref().and_then(|index| {
639 if active_pointer.read_error.is_none() && previous_pointer.read_error.is_none() {
640 Some(index.consistency_report(
641 active_pointer.generation_id,
642 previous_pointer.generation_id,
643 ))
644 } else {
645 None
646 }
647 });
648
649 InstanceGenerationDiagnostics {
650 instance_dir: instance_dir.display().to_string(),
651 active_pointer,
652 previous_pointer,
653 generation_index,
654 consistency_report,
655 }
656}
657
658async fn generation_diagnostics_snapshot(
659 state: &AppState,
660 name: &str,
661) -> Result<GenerationDiagnosticsResponse, (StatusCode, Json<serde_json::Value>)> {
662 let app = {
663 let apps = state.resolved_apps.read().await;
664 apps.get(name).cloned().ok_or_else(|| {
665 (
666 StatusCode::NOT_FOUND,
667 Json(serde_json::json!({"error": format!("App '{}' not found", name)})),
668 )
669 })?
670 };
671
672 let (store_present, store_summary) = {
673 let store = state.generation_store.read().await;
674 match store.get(name) {
675 Some(app_store) => (true, crate::app::AppGenerationIndex::from_store(app_store)),
676 None => (false, crate::app::AppGenerationIndex::default()),
677 }
678 };
679
680 Ok(GenerationDiagnosticsResponse {
681 app: name.to_string(),
682 store_present,
683 store_summary,
684 instance: resolved_app_instance_dir(&app).map(instance_generation_diagnostics),
685 })
686}
687
688fn requires_real_package_source(package_name: &str) -> bool {
689 WEFT_CLAW_REQUIRED_REAL_PACKAGES.contains(&package_name)
690}
691
692fn package_matches_required_real_source(pkg: &crate::app::PackageSource) -> bool {
693 !requires_real_package_source(&pkg.name) || pkg.current_source.starts_with("packages/official/")
694}
695
696fn generation_declares_real_package(
697 state: &AppState,
698 generation: &crate::app::AppGeneration,
699 provider: &str,
700) -> bool {
701 let Some(pkg) = state.package_index.get(provider) else {
702 return provider == "core";
703 };
704
705 if !requires_real_package_source(&pkg.name) {
706 return true;
707 }
708
709 if !package_matches_required_real_source(pkg) {
710 return false;
711 }
712
713 generation.bindings.iter().any(|binding| {
714 binding.provider == provider
715 || state
716 .package_index
717 .get(&binding.provider)
718 .map(|binding_pkg| binding_pkg.name == pkg.name)
719 .unwrap_or(false)
720 })
721}
722
723fn candidate_wasm_package_dirs(state: &AppState, provider: &str) -> Vec<std::path::PathBuf> {
724 crate::package::discover_runtime_package(&state.repo_root, &state.package_index, provider)
725 .map(|package| vec![package.dir])
726 .unwrap_or_default()
727}
728
729fn build_wasm_package_artifact(package_dir: &FsPath) -> Result<(), String> {
730 let manifest_path = package_dir.join("Cargo.toml");
731 if !manifest_path.exists() {
732 return Err(format!(
733 "No Cargo.toml found for wasm provider source at '{}'",
734 package_dir.display()
735 ));
736 }
737
738 let output = std::process::Command::new("cargo")
739 .arg("build")
740 .arg("--target")
741 .arg("wasm32-wasip1")
742 .current_dir(package_dir)
743 .output();
744
745 match output {
746 Ok(result) if result.status.success() => Ok(()),
747 Ok(result) => Err(format!(
748 "cargo build failed for '{}': {}",
749 package_dir.display(),
750 String::from_utf8_lossy(&result.stderr)
751 )),
752 Err(error) => Err(format!(
753 "Failed to spawn cargo build for '{}': {}",
754 package_dir.display(),
755 error
756 )),
757 }
758}
759
760fn locate_built_wasm(package_dir: &FsPath) -> Option<std::path::PathBuf> {
761 let target_dir = package_dir
762 .join("target")
763 .join("wasm32-wasip1")
764 .join("debug");
765 if !target_dir.exists() {
766 return None;
767 }
768 let entries = std::fs::read_dir(target_dir).ok()?;
769 let mut wasm_files: Vec<_> = entries
770 .filter_map(|entry| entry.ok())
771 .map(|entry| entry.path())
772 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("wasm"))
773 .collect();
774 wasm_files.sort();
775 wasm_files.into_iter().next()
776}
777
778async fn probe_wasm_provider_via_temp_load(
779 state: &AppState,
780 provider: &str,
781 capability: &str,
782 app_name: &str,
783) -> crate::app::ValidationResult {
784 let package_dirs = candidate_wasm_package_dirs(state, provider);
785 if package_dirs.is_empty() {
786 return crate::app::ValidationResult {
787 check: format!("probe:{}", capability),
788 passed: true,
789 message: "Live probe skipped for wasm runtime because provider directory could not be located".into(),
790 };
791 }
792
793 let handle_guard = state.wasm_handle.write().await;
794 let Some(handle) = handle_guard.as_ref().cloned() else {
795 return crate::app::ValidationResult {
796 check: format!("probe:{}", capability),
797 passed: true,
798 message: "Live probe skipped for wasm runtime because no wasm runtime is active".into(),
799 };
800 };
801
802 let already_loaded = handle.has_package(provider);
803 let mut loaded_by_probe = false;
804
805 if !already_loaded {
806 let mut loaded = false;
807 let mut build_failures = Vec::new();
808 for package_dir in package_dirs {
809 let Ok(manifest) = crate::package::config::load_manifest(&package_dir) else {
810 continue;
811 };
812 let entry = manifest
813 .resolved_entry()
814 .map(|entry| package_dir.join(entry))
815 .filter(|path| path.exists())
816 .or_else(|| match build_wasm_package_artifact(&package_dir) {
817 Ok(()) => locate_built_wasm(&package_dir),
818 Err(error) => {
819 build_failures.push(error);
820 None
821 }
822 });
823 let Some(wasm_path) = entry else {
824 continue;
825 };
826 let load_info = crate::package::bridge::PackageLoadInfo {
827 name: provider.to_string(),
828 dir: package_dir.clone(),
829 wasm_path,
830 startup_mode: crate::package::resolve_wasm_startup_mode(&manifest),
831 permissions: manifest.permissions.clone(),
832 };
833 if handle.load_package(&load_info).is_ok() {
834 loaded = true;
835 loaded_by_probe = true;
836 break;
837 }
838 }
839
840 if !loaded {
841 return crate::app::ValidationResult {
842 check: format!("probe:{}", capability),
843 passed: build_failures.is_empty(),
844 message: if build_failures.is_empty() {
845 "Live probe skipped for wasm runtime because provider could not be temporarily loaded".into()
846 } else {
847 format!(
848 "Live probe failed because wasm provider could not be built or loaded: {}",
849 build_failures.join(" | ")
850 )
851 },
852 };
853 }
854 }
855
856 drop(handle_guard);
857
858 let probe_payload = serde_json::json!({
859 "action": "describe",
860 "data": {},
861 "app": app_name,
862 "provider": provider,
863 });
864 let result =
865 crate::api::capabilities::execute_capability_call(state, capability, probe_payload).await;
866
867 if loaded_by_probe {
868 let handle_guard = state.wasm_handle.read().await;
869 if let Some(handle) = handle_guard.as_ref() {
870 let _ = handle.unload_package(provider);
871 }
872 }
873
874 match result {
875 Ok(_) => crate::app::ValidationResult {
876 check: format!("probe:{}", capability),
877 passed: true,
878 message: "Provider responded to live wasm probe".into(),
879 },
880 Err((status, value)) => {
881 let body = value.to_string();
882 let skipped_unknown_action = status == StatusCode::BAD_GATEWAY
883 && body.to_lowercase().contains("unknown action")
884 && body.contains("describe");
885 crate::app::ValidationResult {
886 check: format!("probe:{}", capability),
887 passed: skipped_unknown_action,
888 message: if skipped_unknown_action {
889 "Live probe skipped because provider does not implement generic describe action"
890 .into()
891 } else {
892 format!("Live probe failed with {}: {}", status, value)
893 },
894 }
895 }
896 }
897}
898
899pub fn package_digest(repo_root: &FsPath, source: &str) -> String {
900 let path = repo_root.join(source);
901 let mut hasher = Sha512::new();
902
903 fn update_dir(hasher: &mut Sha512, root: &FsPath, path: &FsPath) -> Result<(), std::io::Error> {
904 let mut entries = std::fs::read_dir(path)?
905 .filter_map(|e| e.ok())
906 .map(|e| e.path())
907 .collect::<Vec<_>>();
908 entries.sort();
909 for entry in entries {
910 let name = entry.file_name().and_then(|s| s.to_str()).unwrap_or("");
911 if matches!(name, "target" | ".git" | "node_modules" | ".sisyphus") {
912 continue;
913 }
914 let relative = entry
915 .strip_prefix(root)
916 .unwrap_or(&entry)
917 .to_string_lossy()
918 .replace('\\', "/");
919 if entry.is_dir() {
920 hasher.update(relative.as_bytes());
921 update_dir(hasher, root, &entry)?;
922 } else if entry.is_file() {
923 hasher.update(relative.as_bytes());
924 hasher.update(std::fs::read(&entry)?);
925 }
926 }
927 Ok(())
928 }
929
930 if path.is_file() {
931 match std::fs::read(&path) {
932 Ok(bytes) => hasher.update(bytes),
933 Err(_) => hasher.update(format!("missing:{}", source).as_bytes()),
934 }
935 } else if path.is_dir() {
936 if update_dir(&mut hasher, &path, &path).is_err() {
937 hasher.update(format!("unreadable:{}", source).as_bytes());
938 }
939 } else {
940 hasher.update(format!("missing:{}", source).as_bytes());
941 }
942
943 format!("{:x}", hasher.finalize())
944}
945
946fn packages_for_generation(
947 state: &AppState,
948 generation: &crate::app::AppGeneration,
949) -> Vec<crate::app::AppLockPackage> {
950 generation
951 .bindings
952 .iter()
953 .filter_map(|binding| {
954 state.package_index.get(&binding.provider).map(|pkg| {
955 let digest = package_digest(&state.repo_root, &pkg.current_source);
956 crate::app::AppLockPackage {
957 name: pkg.name.clone(),
958 version: "current".into(),
959 runtime: pkg.kind.clone(),
960 sha512: digest.clone(),
961 source: pkg.current_source.clone(),
962 trusted: pkg.trusted,
963 signature: pkg.signature.clone(),
964 source_authority: pkg.source_authority.clone(),
965 source_public_keys: pkg.source_public_keys.clone(),
966 package_kind: pkg.package_kind.clone(),
967 entry_kind: pkg.kind.clone(),
968 runtime_provider: pkg.runtime_provider_name(),
969 provides: pkg.provides.clone(),
970 requires: pkg.requires.clone(),
971 capabilities: pkg.provides.clone(),
972 manifest_digest: digest.clone(),
973 artifact_digest: digest.clone(),
974 artifact_set_id: String::new(),
975 store_object_id: format!("sha512-{}-{}-current", digest, pkg.name),
976 store_path: String::new(),
977 closure_id: String::new(),
978 features: vec![],
979 default_enabled_features: vec![],
980 roles: pkg
981 .package_kind
982 .split(',')
983 .map(|role| role.trim().to_string())
984 .filter(|role| !role.is_empty())
985 .collect(),
986 evidence: crate::app::AppLockEvidence {
987 digest,
988 signature: pkg.signature.clone(),
989 source_authority: pkg.source_authority.clone(),
990 source_public_keys: pkg.source_public_keys.clone(),
991 },
992 reasons: vec![],
993 }
994 })
995 })
996 .fold(Vec::<crate::app::AppLockPackage>::new(), |mut acc, pkg| {
997 if !acc
998 .iter()
999 .any(|existing| existing.identity() == pkg.identity())
1000 {
1001 acc.push(pkg);
1002 }
1003 acc
1004 })
1005}
1006
1007fn generation_lock_path(generation_id: u64) -> String {
1008 format!("generations/{generation_id}.lock.toml")
1009}
1010
1011fn resolved_generation_lock_path(instance_dir: &FsPath, lock_path: &str) -> PathBuf {
1012 let lock_path = FsPath::new(lock_path);
1013 if lock_path.is_absolute() {
1014 lock_path.to_path_buf()
1015 } else {
1016 instance_dir.join(lock_path)
1017 }
1018}
1019
1020#[derive(Debug)]
1021enum ImmutableGenerationLockError {
1022 MissingLockPath,
1023 Serialize(String),
1024 ReadExisting { path: String, details: String },
1025 CreateParent { path: String, details: String },
1026 WriteNew { path: String, details: String },
1027 Conflict { path: String },
1028}
1029
1030fn write_immutable_generation_lock(
1031 instance_dir: &FsPath,
1032 generation: &crate::app::AppGeneration,
1033 lock_file: &crate::app::AppLockFile,
1034) -> Result<(), ImmutableGenerationLockError> {
1035 if generation.lock_path.trim().is_empty() {
1036 return Err(ImmutableGenerationLockError::MissingLockPath);
1037 }
1038
1039 let path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
1040 let path_display = path.display().to_string();
1041 let content = toml::to_string_pretty(lock_file).map_err(|error| {
1042 ImmutableGenerationLockError::Serialize(format!(
1043 "Failed to serialize immutable generation lock for generation {}: {error}",
1044 generation.id
1045 ))
1046 })?;
1047
1048 if let Some(parent) = path.parent() {
1049 std::fs::create_dir_all(parent).map_err(|error| {
1050 ImmutableGenerationLockError::CreateParent {
1051 path: parent.display().to_string(),
1052 details: error.to_string(),
1053 }
1054 })?;
1055 }
1056
1057 match std::fs::OpenOptions::new()
1058 .write(true)
1059 .create_new(true)
1060 .open(&path)
1061 {
1062 Ok(mut file) => file.write_all(content.as_bytes()).map_err(|error| {
1063 ImmutableGenerationLockError::WriteNew {
1064 path: path_display,
1065 details: error.to_string(),
1066 }
1067 }),
1068 Err(error) if error.kind() == ErrorKind::AlreadyExists => {
1069 let existing = std::fs::read_to_string(&path).map_err(|read_error| {
1070 ImmutableGenerationLockError::ReadExisting {
1071 path: path.display().to_string(),
1072 details: read_error.to_string(),
1073 }
1074 })?;
1075
1076 if existing == content {
1077 Ok(())
1078 } else {
1079 Err(ImmutableGenerationLockError::Conflict {
1080 path: path.display().to_string(),
1081 })
1082 }
1083 }
1084 Err(error) => Err(ImmutableGenerationLockError::WriteNew {
1085 path: path.display().to_string(),
1086 details: error.to_string(),
1087 }),
1088 }
1089}
1090
1091fn immutable_generation_lock_failure_response(
1092 app_name: &str,
1093 generation_id: u64,
1094 error: ImmutableGenerationLockError,
1095) -> (StatusCode, Json<serde_json::Value>) {
1096 match error {
1097 ImmutableGenerationLockError::MissingLockPath => (
1098 StatusCode::INTERNAL_SERVER_ERROR,
1099 Json(serde_json::json!({
1100 "error": format!(
1101 "Generation {} is missing lock_path metadata required to persist immutable activation lock",
1102 generation_id
1103 ),
1104 "reason": "generation_lock_write_failed",
1105 "status": "activation_failed",
1106 "lock_written": false,
1107 "pointer_written": false,
1108 "index_written": false,
1109 "generation_lock_error": {
1110 "type": "missing_lock_path",
1111 "message": format!(
1112 "Generation {} for '{}' is missing lock_path metadata",
1113 generation_id, app_name
1114 ),
1115 }
1116 })),
1117 ),
1118 ImmutableGenerationLockError::Serialize(details) => (
1119 StatusCode::INTERNAL_SERVER_ERROR,
1120 Json(serde_json::json!({
1121 "error": format!(
1122 "Failed to serialize immutable generation lock for '{}' generation {}",
1123 app_name, generation_id
1124 ),
1125 "reason": "generation_lock_write_failed",
1126 "status": "activation_failed",
1127 "lock_written": false,
1128 "pointer_written": false,
1129 "index_written": false,
1130 "generation_lock_error": {
1131 "type": "serialize_failed",
1132 "message": format!(
1133 "Immutable generation lock serialization failed for '{}' generation {}",
1134 app_name, generation_id
1135 ),
1136 "details": details,
1137 }
1138 })),
1139 ),
1140 ImmutableGenerationLockError::ReadExisting { path, details } => (
1141 StatusCode::INTERNAL_SERVER_ERROR,
1142 Json(serde_json::json!({
1143 "error": format!(
1144 "Failed to read existing immutable generation lock for '{}' at '{}'",
1145 app_name, path
1146 ),
1147 "reason": "generation_lock_write_failed",
1148 "status": "activation_failed",
1149 "lock_written": false,
1150 "pointer_written": false,
1151 "index_written": false,
1152 "generation_lock_error": {
1153 "type": "read_existing_failed",
1154 "path": path,
1155 "message": format!(
1156 "Existing immutable generation lock for '{}' could not be read before activation",
1157 app_name
1158 ),
1159 "details": details,
1160 }
1161 })),
1162 ),
1163 ImmutableGenerationLockError::CreateParent { path, details } => (
1164 StatusCode::INTERNAL_SERVER_ERROR,
1165 Json(serde_json::json!({
1166 "error": format!(
1167 "Failed to prepare immutable generation lock directory for '{}' at '{}'",
1168 app_name, path
1169 ),
1170 "reason": "generation_lock_write_failed",
1171 "status": "activation_failed",
1172 "lock_written": false,
1173 "pointer_written": false,
1174 "index_written": false,
1175 "generation_lock_error": {
1176 "type": "prepare_parent_failed",
1177 "path": path,
1178 "message": format!(
1179 "Immutable generation lock directory setup failed for '{}'",
1180 app_name
1181 ),
1182 "details": details,
1183 }
1184 })),
1185 ),
1186 ImmutableGenerationLockError::WriteNew { path, details } => (
1187 StatusCode::INTERNAL_SERVER_ERROR,
1188 Json(serde_json::json!({
1189 "error": format!(
1190 "Failed to write immutable generation lock for '{}' at '{}'",
1191 app_name, path
1192 ),
1193 "reason": "generation_lock_write_failed",
1194 "status": "activation_failed",
1195 "lock_written": false,
1196 "pointer_written": false,
1197 "index_written": false,
1198 "generation_lock_error": {
1199 "type": "write_failed",
1200 "path": path,
1201 "message": format!(
1202 "Immutable generation lock write failed for '{}'",
1203 app_name
1204 ),
1205 "details": details,
1206 }
1207 })),
1208 ),
1209 ImmutableGenerationLockError::Conflict { path } => (
1210 StatusCode::CONFLICT,
1211 Json(serde_json::json!({
1212 "error": format!(
1213 "Immutable generation lock for '{}' generation {} already exists with different content at '{}'",
1214 app_name, generation_id, path
1215 ),
1216 "reason": "generation_lock_conflict",
1217 "status": "activation_failed",
1218 "lock_written": false,
1219 "pointer_written": false,
1220 "index_written": false,
1221 "generation_lock_error": {
1222 "type": "content_conflict",
1223 "path": path,
1224 "message": format!(
1225 "Immutable generation lock content for '{}' generation {} differs from the activation lock payload",
1226 app_name, generation_id
1227 ),
1228 }
1229 })),
1230 ),
1231 }
1232}
1233
1234fn rollback_fallback_lock(
1235 existing_lock: crate::app::AppLockFile,
1236 generation: &crate::app::AppGeneration,
1237) -> crate::app::AppLockFile {
1238 crate::app::AppLockFile {
1239 generation: generation.id as u32,
1240 status: "active".into(),
1241 profile: generation.profile.clone(),
1242 scene: existing_lock.scene.clone(),
1243 scene_digest: existing_lock.scene_digest.clone(),
1244 binding_set_id: existing_lock.binding_set_id.clone(),
1245 closure_id: existing_lock.closure_id.clone(),
1246 closure_digest: existing_lock.closure_digest.clone(),
1247 assembly: crate::app::AppLockAssembly {
1248 enabled_features: generation.enabled_features.clone(),
1249 selected_packages: existing_lock.assembly.selected_packages.clone(),
1250 scene: existing_lock.assembly.scene.clone(),
1251 scene_digest: existing_lock.assembly.scene_digest.clone(),
1252 binding_set_id: existing_lock.assembly.binding_set_id.clone(),
1253 closure_id: existing_lock.assembly.closure_id.clone(),
1254 closure_digest: existing_lock.assembly.closure_digest.clone(),
1255 },
1256 bindings: generation
1257 .bindings
1258 .iter()
1259 .map(|binding| crate::app::AppLockBinding {
1260 capability: binding.capability.clone(),
1261 provider: binding.provider.clone(),
1262 package: binding.provider.clone(),
1263 mutable: binding.mutable,
1264 package_version: String::new(),
1265 package_sha512: String::new(),
1266 binding_source: binding.source.clone(),
1267 })
1268 .collect(),
1269 binding_sources: generation
1270 .bindings
1271 .iter()
1272 .map(|binding| crate::app::AppLockBindingSource {
1273 capability: binding.capability.clone(),
1274 source: binding.source.clone(),
1275 package: binding.provider.clone(),
1276 })
1277 .collect(),
1278 notes: crate::app::AppLockNotes {
1279 message: format!("Rolled back to generation {} via API", generation.id),
1280 },
1281 ..existing_lock
1282 }
1283}
1284
1285fn is_not_found_error(error: &anyhow::Error) -> bool {
1286 error.chain().any(|cause| {
1287 cause
1288 .downcast_ref::<std::io::Error>()
1289 .is_some_and(|io_error| io_error.kind() == ErrorKind::NotFound)
1290 })
1291}
1292
1293struct RollbackRootLockResolution {
1294 lock_file: crate::app::AppLockFile,
1295 generation_lock_replayed: bool,
1296 generation_lock_warning: Option<serde_json::Value>,
1297}
1298
1299fn rollback_generation_lock_failure_response(
1300 app_name: &str,
1301 generation: &crate::app::AppGeneration,
1302 error_type: &str,
1303 message: String,
1304 path: Option<String>,
1305 details: Option<String>,
1306) -> (StatusCode, Json<serde_json::Value>) {
1307 (
1308 StatusCode::CONFLICT,
1309 Json(serde_json::json!({
1310 "error": message,
1311 "reason": "generation_lock_replay_failed",
1312 "status": "rollback_failed",
1313 "lock_written": false,
1314 "pointer_written": false,
1315 "pointer_error": serde_json::Value::Null,
1316 "index_written": false,
1317 "index_error": serde_json::Value::Null,
1318 "generation_lock_replayed": false,
1319 "generation_lock_error": {
1320 "type": error_type,
1321 "app": app_name,
1322 "generation": generation.id,
1323 "path": path,
1324 "details": details,
1325 }
1326 })),
1327 )
1328}
1329
1330fn activation_generation_lock_failure_response(
1331 app_name: &str,
1332 generation: &crate::app::AppGeneration,
1333 error_type: &str,
1334 message: String,
1335 path: Option<String>,
1336 details: Option<String>,
1337) -> (StatusCode, Json<serde_json::Value>) {
1338 (
1339 StatusCode::CONFLICT,
1340 Json(serde_json::json!({
1341 "error": message,
1342 "reason": "generation_lock_replay_failed",
1343 "status": "activation_failed",
1344 "lock_written": false,
1345 "pointer_written": false,
1346 "pointer_error": serde_json::Value::Null,
1347 "index_written": false,
1348 "index_error": serde_json::Value::Null,
1349 "generation_lock_replayed": false,
1350 "generation_lock_error": {
1351 "type": error_type,
1352 "app": app_name,
1353 "generation": generation.id,
1354 "path": path,
1355 "details": details,
1356 }
1357 })),
1358 )
1359}
1360
1361fn resolve_rollback_root_lock(
1362 app_name: &str,
1363 generation: &crate::app::AppGeneration,
1364 instance_dir: Option<&FsPath>,
1365 existing_lock: crate::app::AppLockFile,
1366) -> Result<RollbackRootLockResolution, (StatusCode, Json<serde_json::Value>)> {
1367 let fallback_lock = rollback_fallback_lock(existing_lock, generation);
1368 let Some(instance_dir) = instance_dir else {
1369 return Ok(RollbackRootLockResolution {
1370 lock_file: fallback_lock,
1371 generation_lock_replayed: false,
1372 generation_lock_warning: None,
1373 });
1374 };
1375
1376 if generation.lock_path.trim().is_empty() {
1377 return Ok(RollbackRootLockResolution {
1378 lock_file: fallback_lock,
1379 generation_lock_replayed: false,
1380 generation_lock_warning: Some(serde_json::json!({
1381 "type": "missing_lock_path_metadata",
1382 "message": format!(
1383 "Rollback generation {} for '{}' is missing immutable lock_path metadata; using compatibility fallback root lock rebuild",
1384 generation.id, app_name
1385 ),
1386 })),
1387 });
1388 }
1389
1390 let immutable_lock_path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
1391 let immutable_lock_path_display = immutable_lock_path.display().to_string();
1392 match crate::app::load_instance_lock_from_path(&immutable_lock_path) {
1393 Ok(mut immutable_lock) => {
1394 let expected_app = if generation.app_name.trim().is_empty() {
1395 app_name
1396 } else {
1397 generation.app_name.as_str()
1398 };
1399
1400 if immutable_lock.generation != generation.id as u32 {
1401 return Err(rollback_generation_lock_failure_response(
1402 app_name,
1403 generation,
1404 "generation_mismatch",
1405 format!(
1406 "Immutable rollback generation lock at '{}' belongs to generation {} instead of {}",
1407 immutable_lock_path_display, immutable_lock.generation, generation.id
1408 ),
1409 Some(immutable_lock_path_display),
1410 None,
1411 ));
1412 }
1413
1414 if immutable_lock.app != expected_app {
1415 return Err(rollback_generation_lock_failure_response(
1416 app_name,
1417 generation,
1418 "app_mismatch",
1419 format!(
1420 "Immutable rollback generation lock at '{}' belongs to app '{}' instead of '{}'",
1421 immutable_lock_path_display, immutable_lock.app, expected_app
1422 ),
1423 Some(immutable_lock_path_display),
1424 None,
1425 ));
1426 }
1427
1428 immutable_lock.status = "active".into();
1429 Ok(RollbackRootLockResolution {
1430 lock_file: immutable_lock,
1431 generation_lock_replayed: true,
1432 generation_lock_warning: None,
1433 })
1434 }
1435 Err(error) if is_not_found_error(&error) => Ok(RollbackRootLockResolution {
1436 lock_file: fallback_lock,
1437 generation_lock_replayed: false,
1438 generation_lock_warning: Some(serde_json::json!({
1439 "type": "immutable_lock_missing",
1440 "path": immutable_lock_path_display,
1441 "message": format!(
1442 "Immutable rollback generation lock for '{}' generation {} is missing; using compatibility fallback root lock rebuild",
1443 app_name, generation.id
1444 ),
1445 })),
1446 }),
1447 Err(error) => Err(rollback_generation_lock_failure_response(
1448 app_name,
1449 generation,
1450 "load_failed",
1451 format!(
1452 "Failed to load immutable rollback generation lock for '{}' generation {} from '{}': {error:#}",
1453 app_name, generation.id, immutable_lock_path_display
1454 ),
1455 Some(immutable_lock_path_display),
1456 Some(format!("{error:#}")),
1457 )),
1458 }
1459}
1460
1461fn resolve_activation_root_lock(
1462 app_name: &str,
1463 generation: &crate::app::AppGeneration,
1464 instance_dir: Option<&FsPath>,
1465 existing_lock: crate::app::AppLockFile,
1466) -> Result<RollbackRootLockResolution, (StatusCode, Json<serde_json::Value>)> {
1467 let fallback_lock = rollback_fallback_lock(existing_lock, generation);
1468 let Some(instance_dir) = instance_dir else {
1469 return Ok(RollbackRootLockResolution {
1470 lock_file: fallback_lock,
1471 generation_lock_replayed: false,
1472 generation_lock_warning: None,
1473 });
1474 };
1475
1476 if generation.lock_path.trim().is_empty() {
1477 return Ok(RollbackRootLockResolution {
1478 lock_file: fallback_lock,
1479 generation_lock_replayed: false,
1480 generation_lock_warning: Some(serde_json::json!({
1481 "type": "missing_lock_path_metadata",
1482 "message": format!(
1483 "Activation target generation {} for '{}' is missing immutable lock_path metadata; using compatibility fallback root lock rebuild",
1484 generation.id, app_name
1485 ),
1486 })),
1487 });
1488 }
1489
1490 let immutable_lock_path = resolved_generation_lock_path(instance_dir, &generation.lock_path);
1491 let immutable_lock_path_display = immutable_lock_path.display().to_string();
1492 match crate::app::load_instance_lock_from_path(&immutable_lock_path) {
1493 Ok(mut immutable_lock) => {
1494 let expected_app = if generation.app_name.trim().is_empty() {
1495 app_name
1496 } else {
1497 generation.app_name.as_str()
1498 };
1499
1500 if immutable_lock.generation != generation.id as u32 {
1501 return Err(activation_generation_lock_failure_response(
1502 app_name,
1503 generation,
1504 "generation_mismatch",
1505 format!(
1506 "Immutable activation generation lock at '{}' belongs to generation {} instead of {}",
1507 immutable_lock_path_display, immutable_lock.generation, generation.id
1508 ),
1509 Some(immutable_lock_path_display),
1510 None,
1511 ));
1512 }
1513
1514 if immutable_lock.app != expected_app {
1515 return Err(activation_generation_lock_failure_response(
1516 app_name,
1517 generation,
1518 "app_mismatch",
1519 format!(
1520 "Immutable activation generation lock at '{}' belongs to app '{}' instead of '{}'",
1521 immutable_lock_path_display, immutable_lock.app, expected_app
1522 ),
1523 Some(immutable_lock_path_display),
1524 None,
1525 ));
1526 }
1527
1528 immutable_lock.status = "active".into();
1529 Ok(RollbackRootLockResolution {
1530 lock_file: immutable_lock,
1531 generation_lock_replayed: true,
1532 generation_lock_warning: None,
1533 })
1534 }
1535 Err(error) if is_not_found_error(&error) => Ok(RollbackRootLockResolution {
1536 lock_file: fallback_lock,
1537 generation_lock_replayed: false,
1538 generation_lock_warning: Some(serde_json::json!({
1539 "type": "immutable_lock_missing",
1540 "path": immutable_lock_path_display,
1541 "message": format!(
1542 "Immutable activation generation lock for '{}' generation {} is missing; using compatibility fallback root lock rebuild",
1543 app_name, generation.id
1544 ),
1545 })),
1546 }),
1547 Err(error) => Err(activation_generation_lock_failure_response(
1548 app_name,
1549 generation,
1550 "load_failed",
1551 format!(
1552 "Failed to load immutable activation generation lock for '{}' generation {} from '{}': {error:#}",
1553 app_name, generation.id, immutable_lock_path_display
1554 ),
1555 Some(immutable_lock_path_display),
1556 Some(format!("{error:#}")),
1557 )),
1558 }
1559}
1560
1561fn active_scene_config(
1562 app_config: &crate::app::AppConfigFile,
1563) -> Option<&crate::app::AppSceneConfig> {
1564 let active_scene = app_config.active_scene.trim();
1565 if active_scene.is_empty() {
1566 return None;
1567 }
1568
1569 app_config.scenes.get(active_scene)
1570}
1571
1572fn overlay_scene_enabled_features(
1573 enabled_features: &[String],
1574 scene: Option<&crate::app::AppSceneConfig>,
1575) -> Vec<String> {
1576 let Some(scene) = scene else {
1577 return enabled_features.to_vec();
1578 };
1579
1580 let mut merged = enabled_features.iter().cloned().collect::<BTreeSet<_>>();
1581 for feature in &scene.enabled_features {
1582 merged.insert(feature.clone());
1583 }
1584 for feature in &scene.disabled_features {
1585 merged.remove(feature);
1586 }
1587
1588 merged.into_iter().collect()
1589}
1590
1591fn generation_scene_digest(app_config: &crate::app::AppConfigFile, scene_name: &str) -> String {
1592 if scene_name.trim().is_empty() {
1593 return String::new();
1594 }
1595
1596 app_config
1597 .scenes
1598 .values()
1599 .find(|scene| scene.name == scene_name)
1600 .or_else(|| app_config.scenes.get(scene_name))
1601 .map(scene_digest)
1602 .unwrap_or_default()
1603}
1604
1605#[derive(Debug, Clone, Serialize)]
1606struct ScenePinConflictDetail {
1607 conflict_type: &'static str,
1608 scene: String,
1609 capability: Option<String>,
1610 package: Option<String>,
1611 indexes: Vec<usize>,
1612 message: String,
1613}
1614
1615fn normalized_pin_field(value: &str) -> String {
1616 value.trim().to_string()
1617}
1618
1619fn non_empty_pin_field(value: &str) -> Option<String> {
1620 let trimmed = value.trim();
1621 if trimmed.is_empty() {
1622 None
1623 } else {
1624 Some(trimmed.to_string())
1625 }
1626}
1627
1628fn scene_pin_conflict_response(
1629 detail: ScenePinConflictDetail,
1630) -> (StatusCode, Json<serde_json::Value>) {
1631 let ScenePinConflictDetail {
1632 conflict_type,
1633 scene,
1634 capability,
1635 package,
1636 indexes,
1637 message,
1638 } = detail;
1639
1640 (
1641 StatusCode::UNPROCESSABLE_ENTITY,
1642 Json(serde_json::json!({
1643 "error": message,
1644 "reason": "scene_pin_conflict",
1645 "conflict": {
1646 "type": conflict_type,
1647 "scene": scene,
1648 "capability": capability,
1649 "package": package,
1650 "indexes": indexes,
1651 }
1652 })),
1653 )
1654}
1655
1656fn scene_package_pin_unresolved_response(
1657 scene: &str,
1658 package: &str,
1659 index: usize,
1660) -> (StatusCode, Json<serde_json::Value>) {
1661 (
1662 StatusCode::UNPROCESSABLE_ENTITY,
1663 Json(serde_json::json!({
1664 "error": format!(
1665 "Scene package pin for '{}' cannot be resolved from the local package index",
1666 package
1667 ),
1668 "reason": "scene_package_pin_unresolved",
1669 "scene": scene,
1670 "package": package,
1671 "index": index,
1672 })),
1673 )
1674}
1675
1676fn validate_scene_pin_conflicts(
1677 scene_name: &str,
1678 scene: &crate::app::AppSceneConfig,
1679) -> Result<(), Box<ScenePinConflictDetail>> {
1680 let resolved_scene_name = if scene.name.trim().is_empty() {
1681 scene_name.trim().to_string()
1682 } else {
1683 scene.name.trim().to_string()
1684 };
1685
1686 let mut binding_by_capability =
1687 BTreeMap::<String, (usize, (String, String, String, String))>::new();
1688 for (index, binding_pin) in scene.binding_pins.iter().enumerate() {
1689 let capability = normalized_pin_field(&binding_pin.capability);
1690 if capability.is_empty() {
1691 continue;
1692 }
1693
1694 let identity = (
1695 normalized_pin_field(&binding_pin.package),
1696 normalized_pin_field(&binding_pin.provider),
1697 normalized_pin_field(&binding_pin.version),
1698 normalized_pin_field(&binding_pin.sha512),
1699 );
1700
1701 if let Some((existing_index, existing_identity)) = binding_by_capability.get(&capability) {
1702 if existing_identity != &identity {
1703 return Err(Box::new(ScenePinConflictDetail {
1704 conflict_type: "duplicate_binding_pin",
1705 scene: resolved_scene_name,
1706 capability: Some(capability),
1707 package: None,
1708 indexes: vec![*existing_index, index],
1709 message: format!(
1710 "Scene binding pins for capability '{}' declare conflicting package/provider/version/hash identity",
1711 binding_pin.capability.trim()
1712 ),
1713 }));
1714 }
1715 } else {
1716 binding_by_capability.insert(capability, (index, identity));
1717 }
1718 }
1719
1720 let mut package_by_name = BTreeMap::<String, (usize, (String, String, String))>::new();
1721 for (index, package_pin) in scene.package_pins.iter().enumerate() {
1722 let package = normalized_pin_field(&package_pin.package);
1723 if package.is_empty() {
1724 continue;
1725 }
1726
1727 let identity = (
1728 normalized_pin_field(&package_pin.version),
1729 normalized_pin_field(&package_pin.sha512),
1730 normalized_pin_field(&package_pin.source),
1731 );
1732
1733 if let Some((existing_index, existing_identity)) = package_by_name.get(&package) {
1734 if existing_identity != &identity {
1735 return Err(Box::new(ScenePinConflictDetail {
1736 conflict_type: "duplicate_package_pin",
1737 scene: resolved_scene_name,
1738 capability: None,
1739 package: Some(package),
1740 indexes: vec![*existing_index, index],
1741 message: format!(
1742 "Scene package pins for package '{}' declare conflicting version/hash/source identity",
1743 package_pin.package.trim()
1744 ),
1745 }));
1746 }
1747 } else {
1748 package_by_name.insert(package, (index, identity));
1749 }
1750 }
1751
1752 let package_pin_indexes = scene.package_pins.iter().enumerate().fold(
1753 BTreeMap::<String, Vec<usize>>::new(),
1754 |mut acc, (index, package_pin)| {
1755 let package = normalized_pin_field(&package_pin.package);
1756 if !package.is_empty() {
1757 acc.entry(package).or_default().push(index);
1758 }
1759 acc
1760 },
1761 );
1762
1763 for (binding_index, binding_pin) in scene.binding_pins.iter().enumerate() {
1764 let package = normalized_pin_field(&binding_pin.package);
1765 if package.is_empty() {
1766 continue;
1767 }
1768
1769 let Some(package_indexes) = package_pin_indexes.get(&package) else {
1770 continue;
1771 };
1772
1773 for package_index in package_indexes {
1774 let package_pin = &scene.package_pins[*package_index];
1775 let field_conflicts = [
1776 (
1777 "version",
1778 non_empty_pin_field(&binding_pin.version),
1779 non_empty_pin_field(&package_pin.version),
1780 ),
1781 (
1782 "sha512",
1783 non_empty_pin_field(&binding_pin.sha512),
1784 non_empty_pin_field(&package_pin.sha512),
1785 ),
1786 (
1787 "source",
1788 non_empty_pin_field(&binding_pin.source),
1789 non_empty_pin_field(&package_pin.source),
1790 ),
1791 ];
1792
1793 if let Some((field_name, _, _)) = field_conflicts.into_iter().find(
1794 |(_, binding_value, package_value)| {
1795 matches!((binding_value, package_value), (Some(left), Some(right)) if left != right)
1796 },
1797 ) {
1798 return Err(Box::new(ScenePinConflictDetail {
1799 conflict_type: "binding_package_mismatch",
1800 scene: resolved_scene_name.clone(),
1801 capability: Some(binding_pin.capability.trim().to_string()),
1802 package: Some(package.clone()),
1803 indexes: vec![binding_index, *package_index],
1804 message: format!(
1805 "Scene binding pin for capability '{}' conflicts with package pin for '{}' on field '{}'",
1806 binding_pin.capability.trim(),
1807 package_pin.package.trim(),
1808 field_name,
1809 ),
1810 }));
1811 }
1812 }
1813 }
1814
1815 Ok(())
1816}
1817
1818fn resolved_scene_name(scene_name: &str, scene: &crate::app::AppSceneConfig) -> String {
1819 if scene.name.trim().is_empty() {
1820 scene_name.trim().to_string()
1821 } else {
1822 scene.name.trim().to_string()
1823 }
1824}
1825
1826fn apply_scene_binding_pins_to_candidate_bindings(
1827 scene_name: &str,
1828 scene: Option<&crate::app::AppSceneConfig>,
1829 capabilities: &[String],
1830 proposed_bindings: Vec<crate::app::AppBindingResolution>,
1831) -> Vec<crate::app::AppBindingResolution> {
1832 let Some(scene) = scene else {
1833 return proposed_bindings;
1834 };
1835
1836 let capability_set = capabilities
1837 .iter()
1838 .map(|capability| capability.trim())
1839 .filter(|capability| !capability.is_empty())
1840 .collect::<BTreeSet<_>>();
1841 let scene_name = resolved_scene_name(scene_name, scene);
1842 let mut bindings = proposed_bindings;
1843
1844 for binding_pin in &scene.binding_pins {
1845 let capability = binding_pin.capability.trim();
1846 if capability.is_empty() {
1847 continue;
1848 }
1849
1850 let pinned_provider = if binding_pin.provider.trim().is_empty() {
1851 binding_pin.package.trim()
1852 } else {
1853 binding_pin.provider.trim()
1854 };
1855 if pinned_provider.is_empty() {
1856 continue;
1857 }
1858
1859 let binding_source = format!("scene:{scene_name}");
1860 if let Some(existing_binding) = bindings
1861 .iter_mut()
1862 .find(|binding| binding.capability == capability)
1863 {
1864 existing_binding.provider = pinned_provider.to_string();
1865 existing_binding.source = binding_source;
1866 continue;
1867 }
1868
1869 if capability_set.contains(capability) {
1870 bindings.push(crate::app::AppBindingResolution {
1871 capability: capability.to_string(),
1872 provider: pinned_provider.to_string(),
1873 mutable: false,
1874 source: binding_source,
1875 });
1876 }
1877 }
1878
1879 bindings
1880}
1881
1882fn summary_lock_bindings(
1883 bindings: &[crate::app::AppBindingResolution],
1884 packages: &[crate::app::AppLockPackage],
1885) -> Vec<crate::app::AppLockBinding> {
1886 bindings
1887 .iter()
1888 .map(|binding| {
1889 let package = packages.iter().find(|pkg| {
1890 pkg.name == binding.provider || pkg.runtime_provider == binding.provider
1891 });
1892 crate::app::AppLockBinding {
1893 capability: binding.capability.clone(),
1894 provider: binding.provider.clone(),
1895 package: package
1896 .map(|pkg| pkg.name.clone())
1897 .unwrap_or_else(|| binding.provider.clone()),
1898 mutable: binding.mutable,
1899 package_version: package.map(|pkg| pkg.version.clone()).unwrap_or_default(),
1900 package_sha512: package.map(|pkg| pkg.sha512.clone()).unwrap_or_default(),
1901 binding_source: binding.source.clone(),
1902 }
1903 })
1904 .collect()
1905}
1906
1907fn binding_package_names(
1908 bindings: &[crate::app::AppBindingResolution],
1909 packages: &[crate::app::AppLockPackage],
1910) -> std::collections::HashMap<String, String> {
1911 bindings
1912 .iter()
1913 .map(|binding| {
1914 let package_name = packages
1915 .iter()
1916 .find(|pkg| {
1917 pkg.name == binding.provider || pkg.runtime_provider == binding.provider
1918 })
1919 .map(|pkg| pkg.name.clone())
1920 .unwrap_or_else(|| binding.provider.clone());
1921 (binding.capability.clone(), package_name)
1922 })
1923 .collect()
1924}
1925
1926fn lock_package_from_source(
1927 state: &AppState,
1928 pkg: &crate::app::PackageSource,
1929 selected_names: &BTreeSet<String>,
1930) -> crate::app::AppLockPackage {
1931 let digest = package_digest(&state.repo_root, &pkg.current_source);
1932 crate::app::AppLockPackage {
1933 name: pkg.name.clone(),
1934 version: "current".into(),
1935 runtime: pkg.kind.clone(),
1936 sha512: digest.clone(),
1937 source: pkg.current_source.clone(),
1938 trusted: pkg.trusted,
1939 signature: pkg.signature.clone(),
1940 source_authority: pkg.source_authority.clone(),
1941 source_public_keys: pkg.source_public_keys.clone(),
1942 package_kind: pkg.package_kind.clone(),
1943 manifest_digest: digest.clone(),
1944 artifact_digest: digest.clone(),
1945 artifact_set_id: String::new(),
1946 store_object_id: format!("sha512-{}-{}-current", digest, pkg.name),
1947 store_path: String::new(),
1948 closure_id: String::new(),
1949 entry_kind: pkg.kind.clone(),
1950 runtime_provider: pkg.runtime_provider_name(),
1951 provides: pkg.provides.clone(),
1952 requires: pkg.requires.clone(),
1953 roles: pkg
1954 .package_kind
1955 .split(',')
1956 .map(|role| role.trim().to_string())
1957 .filter(|role| !role.is_empty())
1958 .collect(),
1959 capabilities: pkg.provides.clone(),
1960 features: if selected_names.contains(&pkg.name) && pkg.package_kind.contains("feature") {
1961 vec![pkg.name.clone()]
1962 } else {
1963 vec![]
1964 },
1965 default_enabled_features: vec![],
1966 evidence: crate::app::AppLockEvidence {
1967 digest,
1968 signature: pkg.signature.clone(),
1969 source_authority: pkg.source_authority.clone(),
1970 source_public_keys: pkg.source_public_keys.clone(),
1971 },
1972 reasons: vec![],
1973 }
1974}
1975
1976fn selected_summary_packages(
1977 state: &AppState,
1978 app_config: &crate::app::AppConfigFile,
1979 bindings: &[crate::app::AppBindingResolution],
1980) -> Result<Vec<crate::app::AppLockPackage>, (StatusCode, Json<serde_json::Value>)> {
1981 let mut selected_names = app_config.packages.enabled.clone();
1982 for binding in bindings {
1983 if !selected_names.contains(&binding.provider) {
1984 selected_names.push(binding.provider.clone());
1985 }
1986 }
1987
1988 let active_scene = active_scene_config(app_config);
1989 let resolved_scene = active_scene
1990 .map(|scene| resolved_scene_name(app_config.active_scene.as_str(), scene))
1991 .unwrap_or_else(|| app_config.active_scene.clone());
1992
1993 if let Some(scene) = active_scene {
1994 for (index, package_pin) in scene.package_pins.iter().enumerate() {
1995 let package = package_pin.package.trim();
1996 if package.is_empty() {
1997 continue;
1998 }
1999
2000 if state.package_index.get(package).is_none() {
2001 return Err(scene_package_pin_unresolved_response(
2002 &resolved_scene,
2003 package,
2004 index,
2005 ));
2006 }
2007
2008 if !selected_names.iter().any(|name| name == package) {
2009 selected_names.push(package.to_string());
2010 }
2011 }
2012 }
2013
2014 let mut packages = packages_for_selected_names(state, &selected_names);
2015 if let Some(scene) = active_scene {
2016 for package_pin in &scene.package_pins {
2017 let package = package_pin.package.trim();
2018 if package.is_empty() {
2019 continue;
2020 }
2021
2022 if let Some(summary_package) = packages.iter_mut().find(|pkg| pkg.name == package) {
2023 let mut source_overridden = false;
2024 let mut sha512_overridden = false;
2025 if let Some(version) = non_empty_pin_field(&package_pin.version) {
2026 summary_package.version = version;
2027 }
2028 if let Some(sha512) = non_empty_pin_field(&package_pin.sha512) {
2029 summary_package.sha512 = sha512.clone();
2030 summary_package.artifact_digest = sha512.clone();
2031 summary_package.manifest_digest = sha512.clone();
2032 summary_package.evidence.digest = sha512;
2033 sha512_overridden = true;
2034 }
2035 if let Some(source) = non_empty_pin_field(&package_pin.source) {
2036 summary_package.source = source;
2037 source_overridden = true;
2038 }
2039
2040 if source_overridden && !sha512_overridden {
2041 summary_package.sha512.clear();
2044 summary_package.manifest_digest.clear();
2045 summary_package.artifact_digest.clear();
2046 summary_package.evidence.digest.clear();
2047 summary_package.store_object_id.clear();
2048 }
2049 }
2050 }
2051 }
2052
2053 Ok(packages)
2054}
2055
2056async fn verification_packages_for_candidate(
2057 state: &AppState,
2058 app_name: &str,
2059 generation: &crate::app::AppGeneration,
2060) -> Vec<crate::app::AppLockPackage> {
2061 let app = {
2062 let apps = state.resolved_apps.read().await;
2063 apps.get(app_name).cloned()
2064 };
2065
2066 let Some(app) = app else {
2067 return packages_for_generation(state, generation);
2068 };
2069
2070 let Some(config_path) = app.sources.config_path.as_deref() else {
2071 return packages_for_generation(state, generation);
2072 };
2073
2074 let app_config = crate::app::load_instance_config_from_path(std::path::Path::new(config_path))
2075 .unwrap_or_default();
2076
2077 selected_summary_packages(state, &app_config, &generation.bindings)
2078 .unwrap_or_else(|_| packages_for_generation(state, generation))
2079}
2080
2081async fn identity_consistency_results(
2082 state: &AppState,
2083 app_name: &str,
2084 generation: &crate::app::AppGeneration,
2085) -> Vec<crate::app::ValidationResult> {
2086 let packages = verification_packages_for_candidate(state, app_name, generation).await;
2087 let lock_bindings = summary_lock_bindings(&generation.bindings, &packages);
2088 let recomputed_binding_set_id = binding_set_id_from_lock_bindings(&lock_bindings);
2089 let recomputed_closure_id = closure_id_from_lock_packages(&packages);
2090
2091 let mut results = Vec::with_capacity(2);
2092
2093 let recorded_binding_set_id = generation.binding_set_id.trim();
2094 results.push(if recorded_binding_set_id.is_empty() {
2095 crate::app::ValidationResult {
2096 check: "identity-consistency:binding_set_id".into(),
2097 passed: true,
2098 message: format!(
2099 "Warning: candidate generation summary is missing binding_set_id metadata; recomputed identity '{}' was used for verification compatibility",
2100 recomputed_binding_set_id
2101 ),
2102 }
2103 } else {
2104 let matches = recorded_binding_set_id == recomputed_binding_set_id;
2105 crate::app::ValidationResult {
2106 check: "identity-consistency:binding_set_id".into(),
2107 passed: matches,
2108 message: if matches {
2109 format!(
2110 "Candidate binding_set_id matches the recomputed binding identity '{}' from current bindings and selected package summary",
2111 recomputed_binding_set_id
2112 )
2113 } else {
2114 format!(
2115 "Candidate binding_set_id '{}' does not match recomputed binding identity '{}' from current bindings and selected package summary",
2116 recorded_binding_set_id, recomputed_binding_set_id
2117 )
2118 },
2119 }
2120 });
2121
2122 let recorded_closure_id = generation.closure_id.trim();
2123 results.push(if recorded_closure_id.is_empty() {
2124 crate::app::ValidationResult {
2125 check: "identity-consistency:closure_id".into(),
2126 passed: true,
2127 message: format!(
2128 "Warning: candidate generation summary is missing closure_id metadata; recomputed identity '{}' was used for verification compatibility",
2129 recomputed_closure_id
2130 ),
2131 }
2132 } else {
2133 let matches = recorded_closure_id == recomputed_closure_id;
2134 crate::app::ValidationResult {
2135 check: "identity-consistency:closure_id".into(),
2136 passed: matches,
2137 message: if matches {
2138 format!(
2139 "Candidate closure_id matches the recomputed closure identity '{}' from the selected package summary",
2140 recomputed_closure_id
2141 )
2142 } else {
2143 format!(
2144 "Candidate closure_id '{}' does not match recomputed closure identity '{}' from the selected package summary",
2145 recorded_closure_id, recomputed_closure_id
2146 )
2147 },
2148 }
2149 });
2150
2151 results
2152}
2153
2154async fn binding_coverage_results(
2155 state: &AppState,
2156 app_name: &str,
2157 generation: &crate::app::AppGeneration,
2158) -> Vec<crate::app::ValidationResult> {
2159 let packages = verification_packages_for_candidate(state, app_name, generation).await;
2160 let included_packages = packages
2161 .iter()
2162 .flat_map(|pkg| [pkg.name.as_str(), pkg.runtime_provider.as_str()])
2163 .filter(|value| !value.trim().is_empty())
2164 .collect::<BTreeSet<_>>();
2165 let bindings_by_capability = generation
2166 .bindings
2167 .iter()
2168 .map(|binding| (binding.capability.as_str(), binding))
2169 .collect::<BTreeMap<_, _>>();
2170 let mut results = Vec::new();
2171
2172 for capability in &generation.capabilities {
2173 let check_name = format!("binding-coverage:{capability}");
2174 let Some(binding) = bindings_by_capability.get(capability.as_str()) else {
2175 results.push(crate::app::ValidationResult {
2176 check: check_name,
2177 passed: false,
2178 message: format!(
2179 "Required capability '{}' has no provider binding in candidate generation",
2180 capability
2181 ),
2182 });
2183 continue;
2184 };
2185
2186 if binding.provider == "core" {
2187 results.push(crate::app::ValidationResult {
2188 check: check_name,
2189 passed: true,
2190 message: format!(
2191 "Required capability '{}' is bound to core; selected package coverage is not required",
2192 capability
2193 ),
2194 });
2195 continue;
2196 }
2197
2198 let included = included_packages.contains(binding.provider.as_str());
2199 results.push(crate::app::ValidationResult {
2200 check: check_name,
2201 passed: included,
2202 message: if included {
2203 format!(
2204 "Required capability '{}' is bound to provider '{}' with an included selected package summary entry",
2205 capability, binding.provider
2206 )
2207 } else {
2208 format!(
2209 "Required capability '{}' is bound to provider '{}' but no included selected package summary entry matches that binding",
2210 capability, binding.provider
2211 )
2212 },
2213 });
2214 }
2215
2216 results
2217}
2218
2219fn is_valid_sha512_hex(value: &str) -> bool {
2220 value.len() == 128 && value.chars().all(|c| c.is_ascii_hexdigit())
2221}
2222
2223fn has_usable_digest_metadata(pkg: &crate::app::AppLockPackage) -> bool {
2224 is_valid_sha512_hex(pkg.sha512.trim())
2225 || !pkg.artifact_digest.trim().is_empty()
2226 || !pkg.evidence.digest.trim().is_empty()
2227}
2228
2229async fn digest_policy_results(
2230 state: &AppState,
2231 app_name: &str,
2232 generation: &crate::app::AppGeneration,
2233) -> (Vec<crate::app::ValidationResult>, bool) {
2234 let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2235 let packages = verification_packages_for_candidate(state, app_name, generation).await;
2236 let mut dev_unsealed = false;
2237
2238 let results = packages
2239 .into_iter()
2240 .map(|pkg| {
2241 let digest_ok = has_usable_digest_metadata(&pkg);
2242 match profile {
2243 crate::app::AppProfile::Developer if !digest_ok => {
2244 dev_unsealed = true;
2245 crate::app::ValidationResult {
2246 check: format!("digest-policy:{}", pkg.name),
2247 passed: true,
2248 message: format!(
2249 "Warning: package '{}' is missing usable digest metadata for source '{}' under profile 'developer'; candidate remains verified as dev-unsealed",
2250 pkg.name, pkg.source
2251 ),
2252 }
2253 }
2254 _ => crate::app::ValidationResult {
2255 check: format!("digest-policy:{}", pkg.name),
2256 passed: digest_ok,
2257 message: if digest_ok {
2258 format!(
2259 "Package '{}' has usable digest metadata for source '{}' under profile '{}'",
2260 pkg.name, pkg.source, generation.profile
2261 )
2262 } else {
2263 format!(
2264 "Package '{}' is missing usable sha512/artifact/content digest metadata for source '{}' under profile '{}'",
2265 pkg.name, pkg.source, generation.profile
2266 )
2267 },
2268 },
2269 }
2270 })
2271 .collect();
2272
2273 (results, dev_unsealed)
2274}
2275
2276fn generation_is_dev_unsealed(generation: &crate::app::AppGeneration) -> bool {
2277 crate::app::AppProfile::from_str_loose(&generation.profile) == crate::app::AppProfile::Developer
2278 && generation_has_dev_unsealed_digest_policy_validation(generation)
2279}
2280
2281async fn store_policy_results(
2282 state: &AppState,
2283 app_name: &str,
2284 generation: &crate::app::AppGeneration,
2285) -> Vec<crate::app::ValidationResult> {
2286 let packages = verification_packages_for_candidate(state, app_name, generation).await;
2287 let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2288 let instance_dir = {
2289 let apps = state.resolved_apps.read().await;
2290 apps.get(app_name).and_then(resolved_app_instance_dir)
2291 };
2292
2293 crate::app::verify_local_store_packages(instance_dir.as_deref(), &packages, profile)
2294}
2295
2296fn activation_policy_error(
2297 app_name: &str,
2298 generation: &crate::app::AppGeneration,
2299 instance_dir: Option<&FsPath>,
2300) -> Option<(StatusCode, Json<serde_json::Value>)> {
2301 if generation_has_dev_unsealed_digest_policy_validation(generation)
2302 && crate::app::AppProfile::from_str_loose(&generation.profile)
2303 != crate::app::AppProfile::Developer
2304 {
2305 return Some((
2306 StatusCode::CONFLICT,
2307 Json(serde_json::json!({
2308 "error": format!(
2309 "Generation {} uses developer-only dev-unsealed validation but profile '{}' is not allowed to activate it",
2310 generation.id, generation.profile
2311 ),
2312 "reason": "dev_unsealed_profile_mismatch",
2313 "status": "activation_failed",
2314 "generation": generation,
2315 "lock_written": false,
2316 "pointer_written": false,
2317 "pointer_error": serde_json::Value::Null,
2318 "index_written": false,
2319 "index_error": serde_json::Value::Null,
2320 })),
2321 ));
2322 }
2323
2324 let needs_store_verification = generation
2325 .validation_results
2326 .iter()
2327 .any(crate::app::is_store_check);
2328 if needs_store_verification {
2329 let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2330 let packages = if let Some(instance_dir) = instance_dir {
2331 let immutable_lock_path =
2332 resolved_generation_lock_path(instance_dir, &generation.lock_path);
2333 crate::app::load_instance_lock_from_path(&immutable_lock_path)
2334 .map(|lock| lock.packages)
2335 .unwrap_or_default()
2336 } else {
2337 Vec::new()
2338 };
2339 let store_results =
2340 crate::app::verify_local_store_packages(instance_dir, &packages, profile);
2341 if let Some(result) = store_results.into_iter().find(|result| !result.passed) {
2342 return Some((
2343 StatusCode::CONFLICT,
2344 Json(serde_json::json!({
2345 "error": format!(
2346 "Generation {} failed offline store verification required for activation: {}",
2347 generation.id, result.message
2348 ),
2349 "reason": "store_object_missing",
2350 "status": "activation_failed",
2351 "generation": generation,
2352 "app": app_name,
2353 "lock_written": false,
2354 "pointer_written": false,
2355 "pointer_error": serde_json::Value::Null,
2356 "index_written": false,
2357 "index_error": serde_json::Value::Null,
2358 "store_validation": result,
2359 })),
2360 ));
2361 }
2362 }
2363
2364 None
2365}
2366
2367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2368enum StoredGenerationSlot {
2369 Active,
2370 Candidate,
2371 Rollback,
2372}
2373
2374fn stored_generation_slot(
2375 app_store: &crate::app::AppGenerationStore,
2376 generation_id: u64,
2377) -> Option<StoredGenerationSlot> {
2378 if app_store
2379 .active
2380 .as_ref()
2381 .is_some_and(|generation| generation.id == generation_id)
2382 {
2383 Some(StoredGenerationSlot::Active)
2384 } else if app_store
2385 .candidate
2386 .as_ref()
2387 .is_some_and(|generation| generation.id == generation_id)
2388 {
2389 Some(StoredGenerationSlot::Candidate)
2390 } else if app_store
2391 .rollback
2392 .as_ref()
2393 .is_some_and(|generation| generation.id == generation_id)
2394 {
2395 Some(StoredGenerationSlot::Rollback)
2396 } else {
2397 None
2398 }
2399}
2400
2401fn activation_target_error(
2402 app_name: &str,
2403 generation_id: u64,
2404 status: Option<crate::app::GenerationStatus>,
2405) -> (StatusCode, Json<serde_json::Value>) {
2406 match status {
2407 None => (
2408 StatusCode::NOT_FOUND,
2409 Json(serde_json::json!({
2410 "error": format!(
2411 "Generation {} not found for app '{}'",
2412 generation_id, app_name
2413 ),
2414 "reason": "generation_not_found",
2415 "status": "activation_failed",
2416 "generation": generation_id,
2417 "lock_written": false,
2418 "pointer_written": false,
2419 "index_written": false,
2420 })),
2421 ),
2422 Some(crate::app::GenerationStatus::Candidate) => (
2423 StatusCode::CONFLICT,
2424 Json(serde_json::json!({
2425 "error": format!(
2426 "Generation {} must be verified before activation",
2427 generation_id
2428 ),
2429 "reason": "generation_not_verified",
2430 "status": "activation_failed",
2431 "generation": generation_id,
2432 "lock_written": false,
2433 "pointer_written": false,
2434 "index_written": false,
2435 })),
2436 ),
2437 Some(crate::app::GenerationStatus::Failed) => (
2438 StatusCode::CONFLICT,
2439 Json(serde_json::json!({
2440 "error": format!(
2441 "Generation {} failed verification and cannot be activated",
2442 generation_id
2443 ),
2444 "reason": "generation_failed",
2445 "status": "activation_failed",
2446 "generation": generation_id,
2447 "lock_written": false,
2448 "pointer_written": false,
2449 "index_written": false,
2450 })),
2451 ),
2452 Some(crate::app::GenerationStatus::Archived) => (
2453 StatusCode::CONFLICT,
2454 Json(serde_json::json!({
2455 "error": format!(
2456 "Generation {} is archived and cannot be activated",
2457 generation_id
2458 ),
2459 "reason": "generation_archived",
2460 "status": "activation_failed",
2461 "generation": generation_id,
2462 "lock_written": false,
2463 "pointer_written": false,
2464 "index_written": false,
2465 })),
2466 ),
2467 Some(_) => (
2468 StatusCode::CONFLICT,
2469 Json(serde_json::json!({
2470 "error": format!(
2471 "Generation {} cannot be activated from its current state",
2472 generation_id
2473 ),
2474 "reason": "activation_failed",
2475 "status": "activation_failed",
2476 "generation": generation_id,
2477 "lock_written": false,
2478 "pointer_written": false,
2479 "index_written": false,
2480 })),
2481 ),
2482 }
2483}
2484
2485fn generation_has_dev_unsealed_digest_policy_validation(
2486 generation: &crate::app::AppGeneration,
2487) -> bool {
2488 generation.validation_results.iter().any(|result| {
2489 result.check == "digest-policy:dev-unsealed"
2490 && result.passed
2491 && result
2492 .message
2493 .contains("developer-only dev-unsealed digest policy exceptions")
2494 })
2495}
2496
2497fn packages_for_selected_names(
2498 state: &AppState,
2499 names: &[String],
2500) -> Vec<crate::app::AppLockPackage> {
2501 let selected = names.iter().cloned().collect::<BTreeSet<_>>();
2502 state
2503 .package_index
2504 .package_sources
2505 .iter()
2506 .filter(|pkg| selected.contains(&pkg.name) && package_matches_required_real_source(pkg))
2507 .map(|pkg| lock_package_from_source(state, pkg, &selected))
2508 .collect()
2509}
2510
2511struct ActivationTargetSelection {
2512 generation: crate::app::AppGeneration,
2513 previous_generation_id: Option<u64>,
2514}
2515
2516fn select_activation_target(
2517 app_name: &str,
2518 app_store: &mut crate::app::AppGenerationStore,
2519 target_generation_id: Option<u64>,
2520) -> Result<ActivationTargetSelection, (StatusCode, Json<serde_json::Value>)> {
2521 match target_generation_id {
2522 None => {
2523 if let Some(error) = app_store
2524 .candidate
2525 .as_ref()
2526 .and_then(|generation| activation_policy_error(app_name, generation, None))
2527 {
2528 return Err(error);
2529 }
2530
2531 let previous_generation_id = app_store.active.as_ref().map(|generation| generation.id);
2532 let generation = app_store.activate().cloned().map_err(|err| {
2533 (
2534 StatusCode::CONFLICT,
2535 Json(serde_json::json!({"error": err})),
2536 )
2537 })?;
2538
2539 Ok(ActivationTargetSelection {
2540 generation,
2541 previous_generation_id,
2542 })
2543 }
2544 Some(target_generation_id) => {
2545 let Some(slot) = stored_generation_slot(app_store, target_generation_id) else {
2546 return Err(activation_target_error(
2547 app_name,
2548 target_generation_id,
2549 None,
2550 ));
2551 };
2552
2553 let target_snapshot = app_store
2554 .generation(target_generation_id)
2555 .cloned()
2556 .ok_or_else(|| activation_target_error(app_name, target_generation_id, None))?;
2557
2558 match target_snapshot.status {
2559 crate::app::GenerationStatus::Verified | crate::app::GenerationStatus::Rollback => {
2560 }
2561 status => {
2562 return Err(activation_target_error(
2563 app_name,
2564 target_generation_id,
2565 Some(status),
2566 ));
2567 }
2568 }
2569
2570 if let Some(error) = activation_policy_error(app_name, &target_snapshot, None) {
2571 return Err(error);
2572 }
2573
2574 let previous_generation_id = match slot {
2575 StoredGenerationSlot::Active => {
2576 app_store.rollback.as_ref().map(|generation| generation.id)
2577 }
2578 StoredGenerationSlot::Candidate | StoredGenerationSlot::Rollback => {
2579 app_store.active.as_ref().map(|generation| generation.id)
2580 }
2581 };
2582
2583 let generation = app_store
2584 .switch_to_existing(target_generation_id)
2585 .cloned()
2586 .map_err(|_| {
2587 activation_target_error(
2588 app_name,
2589 target_generation_id,
2590 Some(target_snapshot.status),
2591 )
2592 })?;
2593
2594 Ok(ActivationTargetSelection {
2595 generation,
2596 previous_generation_id,
2597 })
2598 }
2599 }
2600}
2601
2602pub fn preview_runtime_inputs(
2603 manifest: &crate::app::AppManifest,
2604 app_config: &crate::app::AppConfigFile,
2605 default_bindings: &[crate::app::AppBindingResolution],
2606) -> (
2607 Vec<String>,
2608 Vec<String>,
2609 std::collections::HashMap<String, crate::app::AppBindingOverride>,
2610) {
2611 let mut enabled_features = if app_config.features.enabled.is_empty() {
2612 manifest.features.default_enabled.clone()
2613 } else {
2614 app_config.features.enabled.clone()
2615 };
2616 let disabled_features = app_config
2617 .features
2618 .disabled
2619 .iter()
2620 .cloned()
2621 .collect::<BTreeSet<_>>();
2622 enabled_features.retain(|feature| !disabled_features.contains(feature));
2623 enabled_features.sort();
2624 enabled_features.dedup();
2625
2626 let mut selected_packages = app_config.packages.enabled.clone();
2627 for feature in &enabled_features {
2628 if let Some(binding) = manifest.feature_bindings.get(feature) {
2629 for package in &binding.packages {
2630 if !selected_packages.contains(package) {
2631 selected_packages.push(package.clone());
2632 }
2633 }
2634 }
2635 }
2636 for binding in default_bindings {
2637 if !selected_packages.contains(&binding.provider) {
2638 selected_packages.push(binding.provider.clone());
2639 }
2640 }
2641 selected_packages.retain(|package| !app_config.packages.disabled.contains(package));
2642 selected_packages.sort();
2643 selected_packages.dedup();
2644
2645 let mut overrides = std::collections::HashMap::new();
2646 for (capability, binding) in &app_config.binding_overrides {
2647 if !binding.provider.is_empty() {
2648 overrides.insert(capability.clone(), binding.clone());
2649 }
2650 }
2651
2652 (enabled_features, selected_packages, overrides)
2653}
2654
2655pub fn preview_runtime_capabilities(
2656 manifest: &crate::app::AppManifest,
2657 enabled_features: &[String],
2658) -> Vec<String> {
2659 let mut capabilities = manifest.requires.capabilities.clone();
2660 let mut seen = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2661
2662 for feature in enabled_features {
2663 if let Some(binding) = manifest.feature_bindings.get(feature) {
2664 for capability in &binding.requires {
2665 if seen.insert(capability.clone()) {
2666 capabilities.push(capability.clone());
2667 }
2668 }
2669 }
2670 }
2671
2672 capabilities
2673}
2674
2675pub fn preview_runtime_bindings(
2676 app: &crate::app::ResolvedApp,
2677 allowed_capabilities: &[String],
2678 package_index: &crate::app::PackageIndex,
2679 config_overrides: &std::collections::HashMap<String, crate::app::AppBindingOverride>,
2680) -> Vec<crate::app::AppBindingResolution> {
2681 let allowed = allowed_capabilities
2682 .iter()
2683 .cloned()
2684 .collect::<BTreeSet<_>>();
2685
2686 app.bindings
2687 .iter()
2688 .filter_map(|binding| {
2689 if !allowed.contains(&binding.capability) {
2690 return None;
2691 }
2692
2693 let provider = config_overrides
2694 .get(&binding.capability)
2695 .map(|override_binding| override_binding.provider.clone())
2696 .unwrap_or_else(|| binding.provider.clone());
2697
2698 if provider != "core" && package_index.get(&provider).is_none() {
2699 return None;
2700 }
2701
2702 Some(crate::app::AppBindingResolution {
2703 capability: binding.capability.clone(),
2704 provider,
2705 mutable: binding.mutable,
2706 source: if config_overrides.contains_key(&binding.capability) {
2707 "config-override".into()
2708 } else {
2709 binding.source.clone()
2710 },
2711 })
2712 })
2713 .collect()
2714}
2715
2716pub fn preview_runtime_bindings_from_manifest(
2717 manifest: &crate::app::AppManifest,
2718 allowed_capabilities: &[String],
2719 package_index: &crate::app::PackageIndex,
2720 config_overrides: &std::collections::HashMap<String, crate::app::AppBindingOverride>,
2721) -> Vec<crate::app::AppBindingResolution> {
2722 let allowed = allowed_capabilities
2723 .iter()
2724 .cloned()
2725 .collect::<BTreeSet<_>>();
2726 let flattened = manifest.flattened_bindings();
2727
2728 allowed_capabilities
2729 .iter()
2730 .filter_map(|capability| {
2731 let binding = flattened.get(capability)?;
2732 let provider = config_overrides
2733 .get(capability)
2734 .map(|override_binding| override_binding.provider.clone())
2735 .unwrap_or_else(|| binding.provider.clone());
2736
2737 if provider != "core" && package_index.get(&provider).is_none() {
2738 return None;
2739 }
2740
2741 Some(crate::app::AppBindingResolution {
2742 capability: capability.clone(),
2743 provider,
2744 mutable: binding.mutable,
2745 source: if config_overrides.contains_key(capability) {
2746 "config-override".into()
2747 } else {
2748 "declaration-default".into()
2749 },
2750 })
2751 })
2752 .filter(|binding| allowed.contains(&binding.capability))
2753 .collect()
2754}
2755
2756fn default_runtime_bindings(
2757 app: &crate::app::ResolvedApp,
2758) -> Vec<crate::app::AppBindingResolution> {
2759 app.bindings
2760 .iter()
2761 .filter(|binding| {
2762 binding.provider == "core" || app.capabilities.contains(&binding.capability)
2763 })
2764 .cloned()
2765 .collect()
2766}
2767
2768fn lock_features_for_generation(
2769 manifest: &crate::app::AppManifest,
2770 generation: &crate::app::AppGeneration,
2771) -> Vec<crate::app::AppLockFeature> {
2772 let enabled = generation.capabilities.iter().collect::<BTreeSet<_>>();
2773
2774 manifest
2775 .features
2776 .default_enabled
2777 .iter()
2778 .chain(manifest.features.optional.iter())
2779 .cloned()
2780 .collect::<BTreeSet<_>>()
2781 .into_iter()
2782 .map(|feature_name| {
2783 let binding = manifest.feature_bindings.get(&feature_name);
2784 let capabilities = binding
2785 .map(|binding| binding.requires.clone())
2786 .unwrap_or_default();
2787 let resolved = capabilities
2788 .iter()
2789 .all(|capability| enabled.contains(capability));
2790 crate::app::AppLockFeature {
2791 name: feature_name,
2792 resolved,
2793 packages: binding
2794 .map(|binding| binding.packages.clone())
2795 .unwrap_or_default(),
2796 capabilities,
2797 }
2798 })
2799 .collect()
2800}
2801
2802fn trust_results(
2803 state: &AppState,
2804 generation: &crate::app::AppGeneration,
2805) -> Vec<crate::app::ValidationResult> {
2806 let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2807 packages_for_generation(state, generation)
2808 .into_iter()
2809 .map(|pkg| {
2810 let allowed = match profile {
2811 crate::app::AppProfile::Safe => pkg.trusted && pkg.runtime != "native",
2812 crate::app::AppProfile::Developer => pkg.runtime != "native" || pkg.trusted,
2813 crate::app::AppProfile::Trusted => true,
2814 };
2815 crate::app::ValidationResult {
2816 check: format!("trust:{}", pkg.name),
2817 passed: allowed,
2818 message: if allowed {
2819 format!(
2820 "Package '{}' is allowed under profile '{}' (runtime={}, trusted={})",
2821 pkg.name, generation.profile, pkg.runtime, pkg.trusted
2822 )
2823 } else {
2824 format!(
2825 "Package '{}' is not allowed under profile '{}' (runtime={}, trusted={})",
2826 pkg.name, generation.profile, pkg.runtime, pkg.trusted
2827 )
2828 },
2829 }
2830 })
2831 .collect()
2832}
2833
2834fn signature_results(
2835 state: &AppState,
2836 generation: &crate::app::AppGeneration,
2837) -> Vec<crate::app::ValidationResult> {
2838 let profile = crate::app::AppProfile::from_str_loose(&generation.profile);
2839 packages_for_generation(state, generation)
2840 .into_iter()
2841 .map(|pkg| {
2842 let crypto_ok = if pkg.signature.starts_with("ed25519:") {
2843 crate::app::verify_package_signature_for_source(
2844 &pkg.signature,
2845 &crate::app::signature_message(
2846 &pkg.name,
2847 &pkg.version,
2848 &pkg.sha512,
2849 &pkg.source,
2850 ),
2851 &pkg.source_authority,
2852 &pkg.source_public_keys,
2853 )
2854 .is_ok()
2855 } else {
2856 false
2857 };
2858 let signature_ok = match profile {
2859 crate::app::AppProfile::Safe => pkg.signature.starts_with("builtin:") || crypto_ok,
2860 crate::app::AppProfile::Developer => {
2861 (!pkg.signature.is_empty() && pkg.signature != "unsigned") || crypto_ok
2862 }
2863 crate::app::AppProfile::Trusted => true,
2864 };
2865 crate::app::ValidationResult {
2866 check: format!("signature:{}", pkg.name),
2867 passed: signature_ok,
2868 message: if signature_ok {
2869 format!(
2870 "Package '{}' signature '{}' accepted under profile '{}'{}",
2871 pkg.name,
2872 pkg.signature,
2873 generation.profile,
2874 if crypto_ok {
2875 " (cryptographically verified)"
2876 } else {
2877 ""
2878 }
2879 )
2880 } else {
2881 format!(
2882 "Package '{}' signature '{}' rejected under profile '{}'",
2883 pkg.name, pkg.signature, generation.profile
2884 )
2885 },
2886 }
2887 })
2888 .collect()
2889}
2890
2891async fn live_probe_generation(
2892 state: &AppState,
2893 generation: &crate::app::AppGeneration,
2894) -> Vec<crate::app::ValidationResult> {
2895 let mut results = Vec::new();
2896 let registry = state.capability_registry.read().await;
2897
2898 for capability in &generation.capabilities {
2899 let binding = generation
2900 .bindings
2901 .iter()
2902 .find(|binding| binding.capability == *capability);
2903 let Some(binding) = binding else {
2904 results.push(crate::app::ValidationResult {
2905 check: format!("probe:{}", capability),
2906 passed: false,
2907 message: "No provider binding found for capability during live probe".into(),
2908 });
2909 continue;
2910 };
2911
2912 let registry_provider = registry.get(capability).and_then(|entry| {
2913 entry
2914 .providers
2915 .iter()
2916 .find(|provider| provider.provider == binding.provider)
2917 });
2918 let Some(registry_provider) = registry_provider else {
2919 results.push(crate::app::ValidationResult {
2920 check: format!("probe:{}", capability),
2921 passed: false,
2922 message: format!(
2923 "Live probe failed because registry has no runtime entry for capability '{}' bound provider '{}'",
2924 capability, binding.provider
2925 ),
2926 });
2927 continue;
2928 };
2929
2930 if !generation_declares_real_package(state, generation, &binding.provider) {
2931 results.push(crate::app::ValidationResult {
2932 check: format!("probe:{}", capability),
2933 passed: false,
2934 message: format!(
2935 "Live probe failed because capability '{}' is bound to '{}' without an assembled real package source",
2936 capability, binding.provider
2937 ),
2938 });
2939 continue;
2940 }
2941
2942 let runtime = registry_provider.runtime.as_str();
2943
2944 if runtime == "native" {
2945 results.push(crate::app::ValidationResult {
2946 check: format!("probe:{}", capability),
2947 passed: true,
2948 message: format!(
2949 "Live probe skipped for {} runtime until direct probe support is available",
2950 runtime
2951 ),
2952 });
2953 continue;
2954 }
2955
2956 if runtime == "wasm" {
2957 results.push(
2958 probe_wasm_provider_via_temp_load(
2959 state,
2960 &binding.provider,
2961 capability,
2962 &generation.app_name,
2963 )
2964 .await,
2965 );
2966 continue;
2967 }
2968
2969 if runtime == "metadata" {
2970 results.push(crate::app::ValidationResult {
2971 check: format!("probe:{}", capability),
2972 passed: !requires_real_package_source(&binding.provider),
2973 message: if requires_real_package_source(&binding.provider) {
2974 format!(
2975 "Live probe failed because capability '{}' requires a real package-backed runtime, not metadata provider '{}'",
2976 capability, binding.provider
2977 )
2978 } else {
2979 "Metadata provider validated structurally; live probe skipped".into()
2980 },
2981 });
2982 continue;
2983 }
2984
2985 let probe_payload = serde_json::json!({
2986 "action": "describe",
2987 "data": {},
2988 "app": generation.app_name,
2989 });
2990 let result =
2991 crate::api::capabilities::execute_capability_call(state, capability, probe_payload)
2992 .await;
2993 match result {
2994 Ok(_) => results.push(crate::app::ValidationResult {
2995 check: format!("probe:{}", capability),
2996 passed: true,
2997 message: format!("Provider responded to live {} probe", runtime),
2998 }),
2999 Err((status, value)) => {
3000 let body = value.to_string();
3001 let skipped_unknown_action = status == StatusCode::BAD_GATEWAY
3002 && body.to_lowercase().contains("unknown action")
3003 && body.contains("describe");
3004 results.push(crate::app::ValidationResult {
3005 check: format!("probe:{}", capability),
3006 passed: skipped_unknown_action,
3007 message: if skipped_unknown_action {
3008 "Live probe skipped because provider does not implement generic describe action"
3009 .into()
3010 } else {
3011 format!("Live probe failed with {}: {}", status, value)
3012 },
3013 });
3014 }
3015 }
3016 }
3017
3018 results
3019}
3020
3021fn integrity_results(
3022 state: &AppState,
3023 generation: &crate::app::AppGeneration,
3024) -> Vec<crate::app::ValidationResult> {
3025 packages_for_generation(state, generation)
3026 .into_iter()
3027 .map(|pkg| {
3028 let digest_ok = is_valid_sha512_hex(&pkg.sha512);
3029 crate::app::ValidationResult {
3030 check: format!("integrity:{}", pkg.name),
3031 passed: digest_ok,
3032 message: if digest_ok {
3033 format!(
3034 "Package '{}' has valid SHA-512 digest from {}",
3035 pkg.name, pkg.source
3036 )
3037 } else {
3038 format!("Package '{}' has invalid digest", pkg.name)
3039 },
3040 }
3041 })
3042 .collect()
3043}
3044
3045pub async fn propose_generation(
3046 Path(name): Path<String>,
3047 State(state): State<AppState>,
3048 Json(body): Json<serde_json::Value>,
3049) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3050 let apps = state.resolved_apps.read().await;
3051 let app = apps.get(&name).ok_or_else(|| {
3052 (
3053 StatusCode::NOT_FOUND,
3054 Json(serde_json::json!({"error": format!("App '{}' not found", name)})),
3055 )
3056 })?;
3057
3058 let profile = state.active_profile.read().await;
3059 let manifest = crate::app::load_product_package_declaration_from_path(std::path::Path::new(
3060 &app.sources.manifest_path,
3061 ))
3062 .unwrap_or_default();
3063 let app_config = app
3064 .sources
3065 .config_path
3066 .as_deref()
3067 .map(std::path::Path::new)
3068 .map(|path| crate::app::load_instance_config_from_path(path).unwrap_or_default())
3069 .unwrap_or_default();
3070 let manifest_is_available =
3071 !app.sources.manifest_path.is_empty() && !manifest.app.name.is_empty();
3072 let manifest_bindings = manifest.flattened_bindings();
3073 let (config_enabled_features, _selected_packages, config_overrides) = if manifest_is_available {
3074 preview_runtime_inputs(&manifest, &app_config, &app.bindings)
3075 } else {
3076 (
3077 app.enabled_features.clone(),
3078 vec![],
3079 std::collections::HashMap::new(),
3080 )
3081 };
3082 let default_runtime_capabilities = if manifest_is_available {
3083 preview_runtime_capabilities(&manifest, &config_enabled_features)
3084 } else {
3085 app.capabilities.clone()
3086 };
3087 let default_runtime_bindings = if manifest_is_available {
3088 preview_runtime_bindings_from_manifest(
3089 &manifest,
3090 &default_runtime_capabilities,
3091 &state.package_index,
3092 &config_overrides,
3093 )
3094 } else {
3095 default_runtime_bindings(app)
3096 };
3097
3098 let proposed_bindings: Vec<crate::app::AppBindingResolution> = body["bindings"]
3099 .as_array()
3100 .map(|arr| {
3101 arr.iter()
3102 .filter_map(|b| {
3103 Some(crate::app::AppBindingResolution {
3104 capability: b["capability"].as_str()?.into(),
3105 provider: b["provider"].as_str()?.into(),
3106 mutable: b["mutable"].as_bool().unwrap_or(false),
3107 source: b["source"].as_str().unwrap_or("request").into(),
3108 })
3109 })
3110 .collect()
3111 })
3112 .unwrap_or_else(|| default_runtime_bindings.clone());
3113
3114 for binding in &proposed_bindings {
3115 if manifest_is_available {
3116 if let Some(decl) = manifest_bindings.get(&binding.capability) {
3117 if !decl.mutable && binding.provider != decl.provider {
3118 return Err((
3119 StatusCode::UNPROCESSABLE_ENTITY,
3120 Json(serde_json::json!({
3121 "error": format!(
3122 "Binding '{}' is immutable — cannot change provider from '{}' to '{}'",
3123 binding.capability, decl.provider, binding.provider
3124 ),
3125 })),
3126 ));
3127 }
3128 if decl.mutable
3129 && !decl.allowed.is_empty()
3130 && !decl.allowed.contains(&binding.provider)
3131 {
3132 return Err((
3133 StatusCode::UNPROCESSABLE_ENTITY,
3134 Json(serde_json::json!({
3135 "error": format!(
3136 "Provider '{}' is not in the allowed list for capability '{}'. Allowed: {:?}",
3137 binding.provider, binding.capability, decl.allowed
3138 ),
3139 })),
3140 ));
3141 }
3142 }
3143 }
3144 }
3145
3146 let capabilities = body["capabilities"]
3147 .as_array()
3148 .map(|arr| {
3149 arr.iter()
3150 .filter_map(|v| v.as_str().map(String::from))
3151 .collect()
3152 })
3153 .unwrap_or_else(|| default_runtime_capabilities.clone());
3154
3155 let version = body["version"].as_str().unwrap_or(&app.version).to_string();
3156 let parent_generation = {
3157 let store = state.generation_store.read().await;
3158 store
3159 .get(&name)
3160 .and_then(|entry| entry.active.as_ref())
3161 .map(|generation| generation.id)
3162 };
3163 let created_by = body["created_by"].as_str().unwrap_or("api").to_string();
3164 let active_scene = active_scene_config(&app_config);
3165 if let Some(scene_config) = active_scene {
3166 validate_scene_pin_conflicts(app_config.active_scene.as_str(), scene_config)
3167 .map_err(|detail| scene_pin_conflict_response(*detail))?;
3168 }
3169 let scene = active_scene
3170 .map(|scene| resolved_scene_name(app_config.active_scene.as_str(), scene))
3171 .unwrap_or_else(|| app_config.active_scene.clone());
3172 let effective_profile = active_scene
3173 .and_then(|scene| {
3174 if scene.profile.trim().is_empty() {
3175 None
3176 } else {
3177 Some(scene.profile.as_str())
3178 }
3179 })
3180 .unwrap_or(profile.as_str());
3181 let parent_generation = active_scene
3182 .and_then(|scene| scene.base_generation)
3183 .or(parent_generation);
3184 let enabled_features = overlay_scene_enabled_features(&config_enabled_features, active_scene);
3185
3186 let proposed_bindings = apply_scene_binding_pins_to_candidate_bindings(
3187 app_config.active_scene.as_str(),
3188 active_scene,
3189 &capabilities,
3190 proposed_bindings,
3191 );
3192
3193 let selected_packages = selected_summary_packages(&state, &app_config, &proposed_bindings)?;
3194 let lock_bindings = summary_lock_bindings(&proposed_bindings, &selected_packages);
3195 let binding_set_id = binding_set_id_from_lock_bindings(&lock_bindings);
3196 let closure_id = closure_id_from_lock_packages(&selected_packages);
3197
3198 let mut store = state.generation_store.write().await;
3199 let app_store = store.entry(name.clone()).or_default();
3200 let candidate_id = app_store.next_generation_id();
3201 let gen = app_store.propose(crate::app::generation::AppGenerationProposal {
3202 app_name: name.clone(),
3203 version: version.clone(),
3204 bindings: proposed_bindings,
3205 capabilities,
3206 enabled_features,
3207 profile: effective_profile.to_string(),
3208 metadata: AppGenerationSummaryMetadata {
3209 scene,
3210 binding_set_id,
3211 closure_id,
3212 lock_digest: String::new(),
3213 lock_path: generation_lock_path(candidate_id),
3214 parent_generation,
3215 created_by,
3216 },
3217 });
3218
3219 Ok(Json(serde_json::json!({
3220 "generation": gen,
3221 "status": "proposed",
3222 })))
3223}
3224
3225pub async fn verify_generation(
3226 Path(name): Path<String>,
3227 State(state): State<AppState>,
3228) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3229 let mut store = state.generation_store.write().await;
3230 let app_store = store.get_mut(&name).ok_or_else(|| {
3231 (
3232 StatusCode::NOT_FOUND,
3233 Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3234 )
3235 })?;
3236
3237 let registry = state.capability_registry.read().await;
3238 let store_verification_error = app_store.verify_candidate(Some(®istry)).err();
3239 let generation_snapshot = match app_store.candidate.clone() {
3240 Some(candidate) => candidate,
3241 None => {
3242 let err = store_verification_error
3243 .unwrap_or_else(|| "No candidate generation to verify".to_string());
3244 return Err((
3245 StatusCode::UNPROCESSABLE_ENTITY,
3246 Json(serde_json::json!({
3247 "error": err,
3248 "candidate": serde_json::Value::Null,
3249 })),
3250 ));
3251 }
3252 };
3253 let store_verification_ok = store_verification_error.is_none();
3254 drop(registry);
3255 drop(store);
3256
3257 let binding_coverage = binding_coverage_results(&state, &name, &generation_snapshot).await;
3258 let (digest_policy, dev_unsealed) =
3259 digest_policy_results(&state, &name, &generation_snapshot).await;
3260 let store_policy = store_policy_results(&state, &name, &generation_snapshot).await;
3261 let identity_consistency =
3262 identity_consistency_results(&state, &name, &generation_snapshot).await;
3263 let integrity = integrity_results(&state, &generation_snapshot);
3264 let trust = trust_results(&state, &generation_snapshot);
3265 let signatures = signature_results(&state, &generation_snapshot);
3266 let probe_results = live_probe_generation(&state, &generation_snapshot).await;
3267 let binding_coverage_ok = binding_coverage.iter().all(|r| r.passed);
3268 let all_probes_passed = probe_results.iter().all(|r| r.passed);
3269 let digest_policy_ok = digest_policy.iter().all(|r| r.passed);
3270 let store_policy_ok = store_policy.iter().all(|r| r.passed);
3271 let identity_consistency_ok = identity_consistency.iter().all(|r| r.passed);
3272 let integrity_ok = integrity.iter().all(|r| r.passed);
3273 let trust_ok = trust.iter().all(|r| r.passed);
3274 let signatures_ok = signatures.iter().all(|r| r.passed);
3275
3276 let mut store = state.generation_store.write().await;
3277 let app_store = store.get_mut(&name).ok_or_else(|| {
3278 (
3279 StatusCode::NOT_FOUND,
3280 Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3281 )
3282 })?;
3283 let candidate = app_store.candidate.as_mut().ok_or_else(|| {
3284 (
3285 StatusCode::NOT_FOUND,
3286 Json(serde_json::json!({"error": "Candidate generation disappeared during verification"})),
3287 )
3288 })?;
3289 candidate.validation_results.extend(binding_coverage);
3290 candidate.validation_results.extend(digest_policy);
3291 candidate.validation_results.extend(store_policy);
3292 candidate.validation_results.extend(identity_consistency);
3293 candidate.validation_results.extend(integrity);
3294 candidate.validation_results.extend(trust);
3295 candidate.validation_results.extend(signatures);
3296 candidate.validation_results.extend(probe_results);
3297 if dev_unsealed {
3298 candidate.validation_results.push(crate::app::ValidationResult {
3299 check: "digest-policy:dev-unsealed".into(),
3300 passed: true,
3301 message: "Warning: candidate verification succeeded with developer-only dev-unsealed digest policy exceptions".into(),
3302 });
3303 }
3304 if !store_verification_ok
3305 || !binding_coverage_ok
3306 || !all_probes_passed
3307 || !digest_policy_ok
3308 || !store_policy_ok
3309 || !identity_consistency_ok
3310 || !integrity_ok
3311 || !trust_ok
3312 || !signatures_ok
3313 {
3314 candidate.status = crate::app::GenerationStatus::Failed;
3315 let candidate_snapshot = candidate.clone();
3316 return Err((
3317 StatusCode::UNPROCESSABLE_ENTITY,
3318 Json(serde_json::json!({
3319 "error": "Verification failed",
3320 "reason": "generation_verify_failed",
3321 "candidate": candidate_snapshot,
3322 })),
3323 ));
3324 }
3325
3326 let candidate_snapshot = candidate.clone();
3327 Ok(Json(serde_json::json!({
3328 "generation": candidate_snapshot,
3329 "status": "verified",
3330 "dev_unsealed": generation_is_dev_unsealed(&candidate_snapshot),
3331 })))
3332}
3333
3334pub async fn activate_generation(
3335 Path(name): Path<String>,
3336 State(state): State<AppState>,
3337) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3338 let mut store = state.generation_store.write().await;
3339 let app_store = store.get_mut(&name).ok_or_else(|| {
3340 (
3341 StatusCode::NOT_FOUND,
3342 Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3343 )
3344 })?;
3345
3346 match select_activation_target(&name, app_store, None) {
3347 Ok(ActivationTargetSelection {
3348 generation,
3349 previous_generation_id,
3350 }) => {
3351 let active_generation_id = generation.id;
3352 let mut packages = packages_for_generation(&state, &generation);
3353 let apps = state.resolved_apps.read().await;
3354 let app = apps.get(&name).ok_or_else(|| {
3355 (
3356 StatusCode::NOT_FOUND,
3357 Json(serde_json::json!({"error": format!("App '{}' not found during activation", name)})),
3358 )
3359 })?;
3360 let instance_dir = resolved_app_instance_dir(app);
3361 if let Some(error) =
3362 activation_policy_error(&name, &generation, instance_dir.as_deref())
3363 {
3364 return Err(error);
3365 }
3366 let manifest_path = std::path::Path::new(&app.sources.manifest_path);
3367 let manifest = crate::app::load_product_package_declaration_from_path(manifest_path)
3368 .unwrap_or_default();
3369 let app_config = app
3370 .sources
3371 .config_path
3372 .as_deref()
3373 .map(std::path::Path::new)
3374 .map(|path| crate::app::load_instance_config_from_path(path).unwrap_or_default())
3375 .unwrap_or_default();
3376 let manifest_is_available =
3377 !app.sources.manifest_path.is_empty() && !manifest.app.name.is_empty();
3378 let declaration_path = manifest_path.to_path_buf();
3379 let manifest_rel = declaration_path
3380 .strip_prefix(&state.repo_root)
3381 .ok()
3382 .map(|path| path.to_string_lossy().replace('\\', "/"))
3383 .unwrap_or_else(|| declaration_path.display().to_string().replace('\\', "/"));
3384 let config_path = app
3385 .sources
3386 .config_path
3387 .as_deref()
3388 .map(std::path::PathBuf::from)
3389 .unwrap_or_else(|| std::path::PathBuf::from("config.toml"));
3390 let config_rel = config_path
3391 .strip_prefix(&state.repo_root)
3392 .ok()
3393 .map(|path| path.to_string_lossy().replace('\\', "/"))
3394 .unwrap_or_else(|| config_path.display().to_string().replace('\\', "/"));
3395 let scene_digest = generation_scene_digest(&app_config, &generation.scene);
3396 let features = if manifest_is_available {
3397 lock_features_for_generation(&manifest, &generation)
3398 } else {
3399 generation
3400 .enabled_features
3401 .iter()
3402 .cloned()
3403 .map(|name| crate::app::AppLockFeature {
3404 name,
3405 resolved: true,
3406 packages: vec![],
3407 capabilities: vec![],
3408 })
3409 .collect()
3410 };
3411 let (_, selected_packages, _) = if manifest_is_available {
3412 preview_runtime_inputs(&manifest, &app_config, &generation.bindings)
3413 } else {
3414 (
3415 generation.enabled_features.clone(),
3416 vec![],
3417 std::collections::HashMap::new(),
3418 )
3419 };
3420 let selected_packages = selected_packages
3421 .into_iter()
3422 .filter(|package_name| {
3423 state
3424 .package_index
3425 .get(package_name)
3426 .map(package_matches_required_real_source)
3427 .unwrap_or(true)
3428 })
3429 .collect::<Vec<_>>();
3430 let mut selected_only_packages =
3431 packages_for_selected_names(&state, &selected_packages);
3432 for package in selected_only_packages.drain(..) {
3433 if !packages
3434 .iter()
3435 .any(|existing| existing.name == package.name)
3436 {
3437 packages.push(package);
3438 }
3439 }
3440 let feature_only_packages = packages_for_selected_names(
3441 &state,
3442 &features
3443 .iter()
3444 .flat_map(|feature| feature.packages.clone())
3445 .collect::<Vec<_>>(),
3446 );
3447 for package in feature_only_packages {
3448 if !packages
3449 .iter()
3450 .any(|existing| existing.name == package.name)
3451 {
3452 packages.push(package);
3453 }
3454 }
3455 let binding_package_names = binding_package_names(&generation.bindings, &packages);
3456 let lock_bindings = summary_lock_bindings(&generation.bindings, &packages);
3457 let closure_digest = closure_digest_from_lock_packages(&packages);
3458 let dev_unsealed = generation_is_dev_unsealed(&generation);
3459
3460 let lock_file = crate::app::AppLockFile {
3461 lock_version: 2,
3462 app: generation.app_name.clone(),
3463 generation: generation.id as u32,
3464 status: "active".into(),
3465 profile: generation.profile.clone(),
3466 scene: generation.scene.clone(),
3467 scene_digest: scene_digest.clone(),
3468 binding_set_id: if generation.binding_set_id.is_empty() {
3469 binding_set_id_from_lock_bindings(&lock_bindings)
3470 } else {
3471 generation.binding_set_id.clone()
3472 },
3473 closure_id: if generation.closure_id.is_empty() {
3474 closure_id_from_lock_packages(&packages)
3475 } else {
3476 generation.closure_id.clone()
3477 },
3478 closure_digest: closure_digest.clone(),
3479 store_generation_id: String::new(),
3480 trust_level: if dev_unsealed {
3481 "dev-unsealed".into()
3482 } else {
3483 String::new()
3484 },
3485 dev_unsealed,
3486 features,
3487 inputs: crate::app::AppLockInputSnapshot {
3488 declaration_schema_version: manifest.schema_version,
3489 config_schema_version: app_config.schema_version,
3490 declaration_digest: if manifest_is_available {
3491 package_digest(&state.repo_root, &manifest_rel)
3492 } else {
3493 String::new()
3494 },
3495 config_digest: if manifest_is_available {
3496 package_digest(&state.repo_root, &config_rel)
3497 } else {
3498 String::new()
3499 },
3500 scene_digest: scene_digest.clone(),
3501 package_index_digest: String::new(),
3502 package_server_snapshot_digest: String::new(),
3503 },
3504 assembly: crate::app::AppLockAssembly {
3505 enabled_features: generation.enabled_features.clone(),
3506 selected_packages,
3507 scene: generation.scene.clone(),
3508 scene_digest,
3509 binding_set_id: if generation.binding_set_id.is_empty() {
3510 binding_set_id_from_lock_bindings(&lock_bindings)
3511 } else {
3512 generation.binding_set_id.clone()
3513 },
3514 closure_id: if generation.closure_id.is_empty() {
3515 closure_id_from_lock_packages(&packages)
3516 } else {
3517 generation.closure_id.clone()
3518 },
3519 closure_digest,
3520 },
3521 packages,
3522 bindings: lock_bindings,
3523 binding_sources: generation
3524 .bindings
3525 .iter()
3526 .map(|binding| crate::app::AppLockBindingSource {
3527 capability: binding.capability.clone(),
3528 source: binding.source.clone(),
3529 package: binding_package_names
3530 .get(&binding.capability)
3531 .cloned()
3532 .unwrap_or_else(|| binding.provider.clone()),
3533 })
3534 .collect(),
3535 notes: crate::app::AppLockNotes {
3536 message: format!("Activated generation {} via API", generation.id),
3537 },
3538 };
3539
3540 let generation_lock_written = if let Some(ref instance_dir) = instance_dir {
3541 write_immutable_generation_lock(instance_dir, &generation, &lock_file).map_err(
3542 |error| {
3543 tracing::warn!(
3544 "Failed to write immutable generation lock for '{}' generation {}: {:?}",
3545 name,
3546 generation.id,
3547 error
3548 );
3549 immutable_generation_lock_failure_response(&name, generation.id, error)
3550 },
3551 )?;
3552 true
3553 } else {
3554 false
3555 };
3556
3557 let mut lock_written = false;
3558 if let Some(ref lock_path) = app.sources.lock_path {
3559 if let Err(e) = crate::app::save_instance_lock_to_path(
3560 std::path::Path::new(lock_path),
3561 &lock_file,
3562 ) {
3563 tracing::warn!("Failed to write lock file for '{}': {}", name, e);
3564 return Err((
3565 StatusCode::INTERNAL_SERVER_ERROR,
3566 Json(serde_json::json!({
3567 "error": format!("Failed to write lock file for '{}': {}", name, e),
3568 "reason": "lock_write_failed",
3569 "generation": generation,
3570 "status": "activation_failed",
3571 "lock_written": false,
3572 "generation_lock_written": generation_lock_written,
3573 })),
3574 ));
3575 } else {
3576 lock_written = true;
3577 }
3578 }
3579
3580 let (pointer_written, pointer_error, index_written, index_error) = if let Some(
3581 instance_dir,
3582 ) = instance_dir
3583 {
3584 write_generation_pointers(
3585 &name,
3586 &instance_dir,
3587 previous_generation_id,
3588 active_generation_id,
3589 generation_lock_written,
3590 "activation_failed",
3591 "activation",
3592 )?;
3593
3594 let (index_written, index_error) =
3595 persist_generation_index_best_effort(&name, app_store, &instance_dir);
3596 (true, serde_json::Value::Null, index_written, index_error)
3597 } else {
3598 (
3599 false,
3600 serde_json::json!({
3601 "stage": "instance_dir",
3602 "repair_needed": false,
3603 "message": format!(
3604 "No resolved instance directory available for '{}' to write activation pointers",
3605 name
3606 ),
3607 "details": "resolved_app_instance_dir returned None",
3608 }),
3609 false,
3610 Some(format!(
3611 "No resolved instance directory available for '{}' to persist repairable generation index",
3612 name
3613 )),
3614 )
3615 };
3616 Ok(Json(serde_json::json!({
3617 "generation": generation,
3618 "status": "activated",
3619 "generation_lock_written": generation_lock_written,
3620 "lock_written": lock_written,
3621 "pointer_written": pointer_written,
3622 "pointer_error": pointer_error,
3623 "index_written": index_written,
3624 "index_error": index_error,
3625 })))
3626 }
3627 Err(error) => Err(error),
3628 }
3629}
3630
3631pub async fn activate_existing_generation(
3632 Path((name, generation_id)): Path<(String, u64)>,
3633 State(state): State<AppState>,
3634) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3635 let mut store = state.generation_store.write().await;
3636 let app_store = store.get_mut(&name).ok_or_else(|| {
3637 (
3638 StatusCode::NOT_FOUND,
3639 Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3640 )
3641 })?;
3642
3643 match select_activation_target(&name, app_store, Some(generation_id)) {
3644 Ok(ActivationTargetSelection {
3645 generation,
3646 previous_generation_id,
3647 }) => {
3648 let active_generation_id = generation.id;
3649 let apps = state.resolved_apps.read().await;
3650 let app = apps.get(&name).ok_or_else(|| {
3651 (
3652 StatusCode::NOT_FOUND,
3653 Json(serde_json::json!({"error": format!("App '{}' not found during activation", name)})),
3654 )
3655 })?;
3656 let instance_dir = resolved_app_instance_dir(app);
3657 if let Some(error) =
3658 activation_policy_error(&name, &generation, instance_dir.as_deref())
3659 {
3660 return Err(error);
3661 }
3662 let mut generation_lock_replayed = false;
3663 let mut generation_lock_warning = serde_json::Value::Null;
3664 let mut lock_written = false;
3665
3666 if let Some(ref lock_path) = app.sources.lock_path {
3667 let existing_lock =
3668 crate::app::load_instance_lock_from_path(std::path::Path::new(lock_path))
3669 .unwrap_or_default();
3670 let rollback_root_lock = resolve_activation_root_lock(
3671 &name,
3672 &generation,
3673 instance_dir.as_deref(),
3674 existing_lock,
3675 )?;
3676 generation_lock_replayed = rollback_root_lock.generation_lock_replayed;
3677 if let Some(warning) = rollback_root_lock.generation_lock_warning {
3678 generation_lock_warning = warning;
3679 }
3680 if let Err(e) = crate::app::save_instance_lock_to_path(
3681 std::path::Path::new(lock_path),
3682 &rollback_root_lock.lock_file,
3683 ) {
3684 return Err((
3685 StatusCode::INTERNAL_SERVER_ERROR,
3686 Json(serde_json::json!({
3687 "error": format!("Failed to write lock file for '{}' during activation: {}", name, e),
3688 "reason": "lock_write_failed",
3689 "status": "activation_failed",
3690 "lock_written": false,
3691 "pointer_written": false,
3692 "pointer_error": serde_json::Value::Null,
3693 "index_written": false,
3694 "index_error": serde_json::Value::Null,
3695 "generation_lock_replayed": generation_lock_replayed,
3696 "generation_lock_warning": generation_lock_warning,
3697 })),
3698 ));
3699 } else {
3700 lock_written = true;
3701 }
3702 }
3703
3704 let (pointer_written, pointer_error, index_written, index_error) = if let Some(
3705 instance_dir,
3706 ) = instance_dir
3707 {
3708 write_generation_pointers(
3709 &name,
3710 &instance_dir,
3711 previous_generation_id,
3712 active_generation_id,
3713 lock_written,
3714 "activation_failed",
3715 "activation",
3716 )?;
3717
3718 let (index_written, index_error) =
3719 persist_generation_index_best_effort(&name, app_store, &instance_dir);
3720 (true, serde_json::Value::Null, index_written, index_error)
3721 } else {
3722 (
3723 false,
3724 serde_json::json!({
3725 "stage": "instance_dir",
3726 "repair_needed": false,
3727 "message": format!(
3728 "No resolved instance directory available for '{}' to write activation pointers",
3729 name
3730 ),
3731 "details": "resolved_app_instance_dir returned None",
3732 }),
3733 false,
3734 Some(format!(
3735 "No resolved instance directory available for '{}' to persist repairable generation index",
3736 name
3737 )),
3738 )
3739 };
3740
3741 Ok(Json(serde_json::json!({
3742 "generation": generation,
3743 "status": "activated",
3744 "lock_written": lock_written,
3745 "generation_lock_replayed": generation_lock_replayed,
3746 "generation_lock_warning": generation_lock_warning,
3747 "pointer_written": pointer_written,
3748 "pointer_error": pointer_error,
3749 "index_written": index_written,
3750 "index_error": index_error,
3751 })))
3752 }
3753 Err(error) => Err(error),
3754 }
3755}
3756
3757pub async fn rollback_generation(
3758 Path(name): Path<String>,
3759 State(state): State<AppState>,
3760) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3761 let mut store = state.generation_store.write().await;
3762 let app_store = store.get_mut(&name).ok_or_else(|| {
3763 (
3764 StatusCode::NOT_FOUND,
3765 Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3766 )
3767 })?;
3768
3769 match app_store.rollback() {
3770 Ok(gen) => {
3771 let generation = gen.clone();
3772 let apps = state.resolved_apps.read().await;
3773 let app = apps.get(&name).ok_or_else(|| {
3774 (
3775 StatusCode::NOT_FOUND,
3776 Json(serde_json::json!({"error": format!("App '{}' not found during rollback", name)})),
3777 )
3778 })?;
3779 let instance_dir = resolved_app_instance_dir(app);
3780 let mut lock_written = false;
3781 let mut generation_lock_replayed = false;
3782 let mut generation_lock_warning = serde_json::Value::Null;
3783 if let Some(ref lock_path) = app.sources.lock_path {
3784 let existing_lock =
3785 crate::app::load_instance_lock_from_path(std::path::Path::new(lock_path))
3786 .unwrap_or_default();
3787 let rollback_root_lock = resolve_rollback_root_lock(
3788 &name,
3789 &generation,
3790 instance_dir.as_deref(),
3791 existing_lock,
3792 )?;
3793 generation_lock_replayed = rollback_root_lock.generation_lock_replayed;
3794 if let Some(warning) = rollback_root_lock.generation_lock_warning {
3795 generation_lock_warning = warning;
3796 }
3797 if let Err(e) = crate::app::save_instance_lock_to_path(
3798 std::path::Path::new(lock_path),
3799 &rollback_root_lock.lock_file,
3800 ) {
3801 return Err((
3802 StatusCode::INTERNAL_SERVER_ERROR,
3803 Json(serde_json::json!({
3804 "error": format!("Failed to write lock file for '{}' during rollback: {}", name, e),
3805 "reason": "lock_write_failed",
3806 "status": "rollback_failed",
3807 "lock_written": false,
3808 "pointer_written": false,
3809 "pointer_error": serde_json::Value::Null,
3810 "index_written": false,
3811 "index_error": serde_json::Value::Null,
3812 "generation_lock_replayed": generation_lock_replayed,
3813 "generation_lock_warning": generation_lock_warning,
3814 })),
3815 ));
3816 } else {
3817 lock_written = true;
3818 }
3819 }
3820
3821 let active_generation_id = generation.id;
3822 let previous_generation_id = app_store.candidate.as_ref().map(|candidate| candidate.id);
3823 let (pointer_written, pointer_error, index_written, index_error) = if let Some(
3824 instance_dir,
3825 ) = instance_dir
3826 {
3827 write_generation_pointers(
3828 &name,
3829 &instance_dir,
3830 previous_generation_id,
3831 active_generation_id,
3832 lock_written,
3833 "rollback_failed",
3834 "rollback",
3835 )?;
3836
3837 let (index_written, index_error) =
3838 persist_generation_index_best_effort(&name, app_store, &instance_dir);
3839 (true, serde_json::Value::Null, index_written, index_error)
3840 } else {
3841 (
3842 false,
3843 serde_json::json!({
3844 "stage": "instance_dir",
3845 "repair_needed": false,
3846 "message": format!(
3847 "No resolved instance directory available for '{}' to write rollback pointers",
3848 name
3849 ),
3850 "details": "resolved_app_instance_dir returned None",
3851 }),
3852 false,
3853 Some(format!(
3854 "No resolved instance directory available for '{}' to persist repairable generation index",
3855 name
3856 )),
3857 )
3858 };
3859 Ok(Json(serde_json::json!({
3860 "generation": generation,
3861 "status": "rolled_back",
3862 "lock_written": lock_written,
3863 "generation_lock_replayed": generation_lock_replayed,
3864 "generation_lock_warning": generation_lock_warning,
3865 "pointer_written": pointer_written,
3866 "pointer_error": pointer_error,
3867 "index_written": index_written,
3868 "index_error": index_error,
3869 })))
3870 }
3871 Err(err) => Err((
3872 StatusCode::CONFLICT,
3873 Json(serde_json::json!({"error": err})),
3874 )),
3875 }
3876}
3877
3878pub async fn get_generation(
3879 Path(name): Path<String>,
3880 State(state): State<AppState>,
3881) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3882 let store = state.generation_store.read().await;
3883 let app_store = store.get(&name).ok_or_else(|| {
3884 (
3885 StatusCode::NOT_FOUND,
3886 Json(serde_json::json!({"error": format!("No generation store for app '{}'", name)})),
3887 )
3888 })?;
3889
3890 Ok(Json(serde_json::json!({
3891 "active": app_store.active,
3892 "candidate": app_store.candidate,
3893 "rollback": app_store.rollback,
3894 })))
3895}
3896
3897pub async fn get_generation_diff(
3898 Path((name, from_id, to_id)): Path<(String, u64, u64)>,
3899 State(state): State<AppState>,
3900) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
3901 let resolved_app = {
3902 let apps = state.resolved_apps.read().await;
3903 apps.get(&name).cloned()
3904 };
3905
3906 let (from_generation, to_generation) = {
3907 let store = state.generation_store.read().await;
3908 let app_store = store.get(&name).ok_or_else(|| {
3909 (
3910 StatusCode::NOT_FOUND,
3911 Json(
3912 serde_json::json!({"error": format!("No generation store for app '{}'", name)}),
3913 ),
3914 )
3915 })?;
3916
3917 let from_generation = app_store
3918 .generation(from_id)
3919 .cloned()
3920 .ok_or_else(|| generation_diff_not_found(&name, from_id, "from"))?;
3921 let to_generation = app_store
3922 .generation(to_id)
3923 .cloned()
3924 .ok_or_else(|| generation_diff_not_found(&name, to_id, "to"))?;
3925 (from_generation, to_generation)
3926 };
3927
3928 let instance_dir = resolved_app.as_ref().and_then(resolved_app_instance_dir);
3929 let (from_lock, from_path, from_warning) = load_generation_lock_for_diff(&from_generation, instance_dir.as_deref())
3930 .map_err(|error| {
3931 (
3932 StatusCode::INTERNAL_SERVER_ERROR,
3933 Json(serde_json::json!({
3934 "error": format!(
3935 "Failed to load immutable generation lock for app '{}' generation {}: {error:#}",
3936 name, from_generation.id
3937 ),
3938 "reason": "generation_lock_load_failed",
3939 "app": name,
3940 "generation": from_generation.id,
3941 "role": "from",
3942 })),
3943 )
3944 })?;
3945 let (to_lock, to_path, to_warning) = load_generation_lock_for_diff(&to_generation, instance_dir.as_deref())
3946 .map_err(|error| {
3947 (
3948 StatusCode::INTERNAL_SERVER_ERROR,
3949 Json(serde_json::json!({
3950 "error": format!(
3951 "Failed to load immutable generation lock for app '{}' generation {}: {error:#}",
3952 name, to_generation.id
3953 ),
3954 "reason": "generation_lock_load_failed",
3955 "app": name,
3956 "generation": to_generation.id,
3957 "role": "to",
3958 })),
3959 )
3960 })?;
3961
3962 let mut warnings = Vec::new();
3963 if let Some(warning) = from_warning {
3964 warnings.push(warning);
3965 }
3966 if let Some(warning) = to_warning {
3967 warnings.push(warning);
3968 }
3969
3970 let immutable_lock = match (from_lock.as_ref(), to_lock.as_ref()) {
3971 (Some(from_lock), Some(to_lock)) => ImmutableLockComparison {
3972 available: true,
3973 from_path,
3974 to_path,
3975 diff: Some(immutable_lock_diff(from_lock, to_lock)),
3976 },
3977 _ => ImmutableLockComparison {
3978 available: false,
3979 from_path,
3980 to_path,
3981 diff: None,
3982 },
3983 };
3984
3985 let response = GenerationDiffResponse {
3986 app: name,
3987 from: generation_summary_snapshot(&from_generation),
3988 to: generation_summary_snapshot(&to_generation),
3989 summary_diff: generation_summary_diff(&from_generation, &to_generation),
3990 immutable_lock,
3991 warnings,
3992 };
3993
3994 Ok(Json(serde_json::json!(response)))
3995}
3996
3997pub async fn get_generation_diagnostics(
3998 Path(name): Path<String>,
3999 State(state): State<AppState>,
4000) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
4001 let diagnostics = generation_diagnostics_snapshot(&state, &name).await?;
4002 Ok(Json(serde_json::json!(diagnostics)))
4003}
4004
4005#[cfg(test)]
4006mod tests {
4007 use super::{
4008 activate_existing_generation, activate_generation, generation_diagnostics_snapshot,
4009 generation_lock_path, get_generation_diff, live_probe_generation,
4010 packages_for_selected_names, propose_generation, rollback_generation,
4011 selected_summary_packages, summary_lock_bindings,
4012 };
4013 use crate::api::build_router;
4014 use crate::api::openai_compat::AppState;
4015 use crate::app::{
4016 active_generation_pointer_path, binding_set_id_from_lock_bindings,
4017 closure_id_from_lock_packages, previous_generation_pointer_path, save_generation_index,
4018 sign_package_message, signature_message, write_active_generation_pointer,
4019 write_previous_generation_pointer, AppBindingResolution, AppGeneration, AppGenerationIndex,
4020 AppGenerationStore, AppProfile, CapabilityProviderRecord, CapabilityRegistry,
4021 CapabilityRegistryEntry, CorePolicy, GenerationStatus, GenerationStoreMap, PackageIndex,
4022 PackageSource, ResolvedApp, ResolvedAppMap, ResolvedAppSources,
4023 };
4024 use crate::config::{
4025 AppConfig, CoreConfig, FallbackConfig, KeyStrategyConfig, RegistryConfig, RoutingConfig,
4026 };
4027 use crate::defaults::{
4028 error_handler::DefaultErrorHandler, key_selectors::FailoverSelector, router::DefaultRouter,
4029 };
4030 use crate::pipeline::Pipeline;
4031 use crate::process::ProcessManager;
4032 use crate::vkeys::VirtualKeyStore;
4033 use axum::body::Body;
4034 use axum::extract::{Path, State};
4035 use axum::http::{Request, StatusCode as HttpStatusCode};
4036 use axum::Json;
4037 use ed25519_dalek::SigningKey;
4038 use std::collections::HashMap;
4039 use std::fs;
4040 use std::sync::{Arc, Mutex as StdMutex};
4041 use tempfile::tempdir;
4042 use tokio::sync::RwLock;
4043 use tower::util::ServiceExt;
4044
4045 fn generation_with_diff_fields(
4046 id: u64,
4047 status: GenerationStatus,
4048 scene: &str,
4049 profile: &str,
4050 features: Vec<&str>,
4051 capabilities: Vec<&str>,
4052 bindings: Vec<AppBindingResolution>,
4053 ) -> AppGeneration {
4054 AppGeneration {
4055 id,
4056 app_name: "weft-claw".into(),
4057 version: "0.1.0".into(),
4058 bindings,
4059 capabilities: capabilities.into_iter().map(String::from).collect(),
4060 enabled_features: features.into_iter().map(String::from).collect(),
4061 scene: scene.into(),
4062 profile: profile.into(),
4063 binding_set_id: format!("binding-set:sha256:{id}"),
4064 closure_id: format!("closure:sha256:{id}"),
4065 lock_digest: format!("sha256:lock-{id}"),
4066 lock_path: format!("generations/{id}.lock.toml"),
4067 parent_generation: id.checked_sub(1),
4068 created_by: "api".into(),
4069 status,
4070 validation_results: vec![],
4071 created_at: 1710000000 + id,
4072 }
4073 }
4074
4075 fn write_generation_lock_for_diff(
4076 instance_dir: &std::path::Path,
4077 generation_id: u64,
4078 scene: &str,
4079 profile: &str,
4080 closure_id: &str,
4081 packages: &[&str],
4082 bindings: &[(&str, &str)],
4083 ) {
4084 let package_entries = packages
4085 .iter()
4086 .map(|name| {
4087 format!(
4088 "[[packages]]\nname = '{name}'\nversion = '0.1.0'\nruntime_provider = '{name}'\n"
4089 )
4090 })
4091 .collect::<Vec<_>>()
4092 .join("\n");
4093 let binding_entries = bindings
4094 .iter()
4095 .map(|(capability, provider)| {
4096 format!(
4097 "[[bindings]]\ncapability = '{capability}'\nprovider = '{provider}'\npackage = '{provider}'\n"
4098 )
4099 })
4100 .collect::<Vec<_>>()
4101 .join("\n");
4102
4103 write_text_file(
4104 &instance_dir
4105 .join("generations")
4106 .join(format!("{generation_id}.lock.toml")),
4107 &format!(
4108 "lock_version = 2\napp = 'weft-claw'\ngeneration = {generation_id}\nstatus = 'verified'\nprofile = '{profile}'\nscene = '{scene}'\nclosure_id = '{closure_id}'\n\n{package_entries}\n{binding_entries}\n"
4109 ),
4110 );
4111 }
4112
4113 fn test_state(
4114 repo_root: std::path::PathBuf,
4115 capability_registry: CapabilityRegistry,
4116 ) -> AppState {
4117 AppState {
4118 config: Arc::new(RwLock::new(AppConfig {
4119 core: CoreConfig::default(),
4120 providers: vec![],
4121 routing: RoutingConfig::default(),
4122 key_strategy: KeyStrategyConfig::default(),
4123 fallback: FallbackConfig::default(),
4124 virtual_keys: vec![],
4125 services: vec![],
4126 packages: vec![],
4127 registry: RegistryConfig::default(),
4128 package_aliases: HashMap::new(),
4129 web_search: Default::default(),
4130 team: Default::default(),
4131 })),
4132 config_path: repo_root.join("config").join("config.toml"),
4133 pipeline: Arc::new(Pipeline {
4134 router: Arc::new(DefaultRouter {
4135 default_provider: "".into(),
4136 }),
4137 key_selector: Arc::new(FailoverSelector),
4138 transforms: Arc::new(crate::defaults::transforms::TransformRegistry::with_defaults()),
4139 error_handler: Arc::new(DefaultErrorHandler { max_retries: 0 }),
4140 http_client: reqwest::Client::new(),
4141 }),
4142 process_manager: Arc::new(ProcessManager::new()),
4143 vkey_store: Arc::new(VirtualKeyStore::new()),
4144 package_manager: Arc::new(RwLock::new(crate::package::PackageManager::new())),
4145 wasm_handle: Arc::new(RwLock::new(None)),
4146 native_handle: Arc::new(RwLock::new(None)),
4147 resolved_apps: Arc::new(RwLock::new(Default::default())),
4148 capability_registry: Arc::new(RwLock::new(capability_registry)),
4149 active_profile: Arc::new(RwLock::new(AppProfile::Developer)),
4150 core_policy: Arc::new(CorePolicy::default_policy()),
4151 generation_store: Arc::new(RwLock::new(GenerationStoreMap::new())),
4152 package_index: Arc::new(PackageIndex::default()),
4153 repo_root,
4154 data_dir: std::path::PathBuf::from("data"),
4155 runtime_token: None,
4156 runtime_token_path: None,
4157 chat_providers: Arc::new(RwLock::new(vec![])),
4158 shutdown_tx: Arc::new(StdMutex::new(None)),
4159 stream_buffer: Arc::new(StdMutex::new(std::collections::HashMap::new())),
4160 }
4161 }
4162
4163 fn test_binding(capability: &str, provider: &str) -> AppBindingResolution {
4164 AppBindingResolution {
4165 capability: capability.into(),
4166 provider: provider.into(),
4167 mutable: false,
4168 source: "test".into(),
4169 }
4170 }
4171
4172 fn test_generation(
4173 capability: &str,
4174 provider: &str,
4175 profile: &str,
4176 status: GenerationStatus,
4177 ) -> AppGeneration {
4178 AppGeneration {
4179 id: 1,
4180 app_name: "weft-claw".into(),
4181 version: "0.1.0".into(),
4182 bindings: vec![test_binding(capability, provider)],
4183 capabilities: vec![capability.into()],
4184 enabled_features: vec![],
4185 scene: String::new(),
4186 profile: profile.into(),
4187 binding_set_id: String::new(),
4188 closure_id: String::new(),
4189 lock_digest: String::new(),
4190 lock_path: String::new(),
4191 parent_generation: None,
4192 created_by: String::new(),
4193 status,
4194 validation_results: vec![],
4195 created_at: 0,
4196 }
4197 }
4198
4199 fn registry_with_provider(capability: &str, provider: &str) -> CapabilityRegistry {
4200 let mut registry = CapabilityRegistry::new();
4201 registry.insert(
4202 capability.into(),
4203 CapabilityRegistryEntry {
4204 capability: capability.into(),
4205 providers: vec![CapabilityProviderRecord {
4206 provider: provider.into(),
4207 runtime: "metadata".into(),
4208 priority: 0,
4209 }],
4210 bindings: vec![],
4211 },
4212 );
4213 registry
4214 }
4215
4216 fn candidate_with_verified_identities(
4217 state: &AppState,
4218 config_path: &std::path::Path,
4219 mut candidate: AppGeneration,
4220 ) -> AppGeneration {
4221 let app_config =
4222 crate::app::load_instance_config_from_path(config_path).expect("config loads");
4223 let selected_packages = selected_summary_packages(state, &app_config, &candidate.bindings)
4224 .expect("selected packages resolve");
4225 let lock_bindings = summary_lock_bindings(&candidate.bindings, &selected_packages);
4226 candidate.binding_set_id = binding_set_id_from_lock_bindings(&lock_bindings);
4227 candidate.closure_id = closure_id_from_lock_packages(&selected_packages);
4228 candidate
4229 }
4230
4231 fn test_resolved_app(name: &str, instance_dir: Option<&std::path::Path>) -> ResolvedApp {
4232 ResolvedApp {
4233 name: name.into(),
4234 version: "0.1.0".into(),
4235 capabilities: vec!["core.execution".into()],
4236 bindings: vec![test_binding("core.execution", "core")],
4237 sources: ResolvedAppSources {
4238 manifest_path: String::new(),
4239 config_path: instance_dir.map(|dir| dir.join("config.toml").display().to_string()),
4240 lock_path: instance_dir.map(|dir| dir.join("lock.toml").display().to_string()),
4241 },
4242 ..ResolvedApp::default()
4243 }
4244 }
4245
4246 fn write_text_file(path: &std::path::Path, contents: &str) {
4247 if let Some(parent) = path.parent() {
4248 fs::create_dir_all(parent).expect("parent dir created");
4249 }
4250 fs::write(path, contents).expect("file written");
4251 }
4252
4253 fn test_package_source(
4254 name: &str,
4255 package_kind: &str,
4256 provides: Vec<&str>,
4257 requires: Vec<&str>,
4258 ) -> PackageSource {
4259 PackageSource {
4260 name: name.into(),
4261 kind: if package_kind.contains("feature") {
4262 "embedded".into()
4263 } else {
4264 "wasm".into()
4265 },
4266 package_kind: package_kind.into(),
4267 runtime_provider: name.into(),
4268 current_source: format!("packages/official/{name}"),
4269 trusted: true,
4270 signature: "builtin:test".into(),
4271 source_authority: "test".into(),
4272 source_public_keys: vec![],
4273 provides: provides.into_iter().map(String::from).collect(),
4274 requires: requires.into_iter().map(String::from).collect(),
4275 }
4276 }
4277
4278 fn state_for_propose_generation(
4279 repo_root: std::path::PathBuf,
4280 app: ResolvedApp,
4281 package_sources: Vec<PackageSource>,
4282 ) -> AppState {
4283 let mut apps = ResolvedAppMap::new();
4284 apps.insert(app.name.clone(), app);
4285
4286 AppState {
4287 resolved_apps: Arc::new(RwLock::new(apps)),
4288 package_index: Arc::new(PackageIndex {
4289 version: 1,
4290 revision: "test-rev".into(),
4291 source_url: "local://packages".into(),
4292 package_sources,
4293 }),
4294 ..test_state(repo_root, CapabilityRegistry::new())
4295 }
4296 }
4297
4298 fn state_for_verify_generation(
4299 repo_root: std::path::PathBuf,
4300 app: ResolvedApp,
4301 package_sources: Vec<PackageSource>,
4302 capability_registry: CapabilityRegistry,
4303 candidate: AppGeneration,
4304 ) -> AppState {
4305 let mut stores = GenerationStoreMap::new();
4306 stores.insert(
4307 app.name.clone(),
4308 AppGenerationStore {
4309 active: None,
4310 candidate: Some(candidate),
4311 rollback: None,
4312 next_id: 2,
4313 },
4314 );
4315
4316 AppState {
4317 resolved_apps: Arc::new(RwLock::new({
4318 let mut apps = ResolvedAppMap::new();
4319 apps.insert(app.name.clone(), app);
4320 apps
4321 })),
4322 generation_store: Arc::new(RwLock::new(stores)),
4323 package_index: Arc::new(PackageIndex {
4324 version: 1,
4325 revision: "test-rev".into(),
4326 source_url: "local://packages".into(),
4327 package_sources,
4328 }),
4329 ..test_state(repo_root, capability_registry)
4330 }
4331 }
4332
4333 fn write_test_manifest_and_config(
4334 repo_root: &std::path::Path,
4335 active_scene: Option<&str>,
4336 scene_block: &str,
4337 ) -> (std::path::PathBuf, std::path::PathBuf, std::path::PathBuf) {
4338 let package_dir = repo_root.join("packages").join("weft-claw");
4339 let instance_dir = repo_root.join(".weft").join("weft-claw");
4340 let manifest_path = package_dir.join("package.toml");
4341 let config_path = instance_dir.join("config.toml");
4342
4343 write_text_file(
4344 &manifest_path,
4345 r#"schema_version = 1
4346[identity]
4347name = 'weft-claw'
4348version = '0.1.0'
4349display_name = 'WEFT Claw'
4350description = 'test product'
4351[package]
4352kind = 'product'
4353[requires]
4354capabilities = ['agent.runtime']
4355[bindings."agent.runtime"]
4356provider = 'agent-runtime'
4357mutable = false
4358[features]
4359default_enabled = ['feature-default']
4360optional = ['feature-scene-on', 'feature-off', 'feature-extra']
4361[feature_bindings.feature-default]
4362requires = []
4363packages = ['feature-default']
4364[feature_bindings.feature-scene-on]
4365requires = []
4366packages = ['feature-scene-on']
4367[feature_bindings.feature-off]
4368requires = []
4369packages = ['feature-off']
4370[feature_bindings.feature-extra]
4371requires = []
4372packages = ['feature-extra']
4373"#,
4374 );
4375
4376 let active_scene_line = active_scene
4377 .map(|value| format!("active_scene = '{value}'\n"))
4378 .unwrap_or_default();
4379 write_text_file(
4380 &config_path,
4381 &format!(
4382 "schema_version = 1\n{active_scene_line}\n[app_runtime]\nprofile = 'developer'\n\n[features]\nenabled = ['feature-default', 'feature-off']\ndisabled = []\n\n[packages]\nenabled = ['feature-default', 'feature-off']\ndisabled = []\n\n{scene_block}"
4383 ),
4384 );
4385
4386 (package_dir, instance_dir, manifest_path)
4387 }
4388
4389 fn generation_with_id(id: u64, status: GenerationStatus) -> AppGeneration {
4390 AppGeneration {
4391 id,
4392 app_name: "weft-claw".into(),
4393 version: "0.1.0".into(),
4394 bindings: vec![test_binding("core.execution", "core")],
4395 capabilities: vec!["core.execution".into()],
4396 enabled_features: vec![],
4397 scene: "stable".into(),
4398 profile: "developer".into(),
4399 binding_set_id: format!("binding-set:sha256:{id}"),
4400 closure_id: format!("closure:sha256:{id}"),
4401 lock_digest: format!("sha256:lock-{id}"),
4402 lock_path: format!("generations/{id}.lock.toml"),
4403 parent_generation: id.checked_sub(1),
4404 created_by: "api".into(),
4405 status,
4406 validation_results: vec![],
4407 created_at: 1710000000 + id,
4408 }
4409 }
4410
4411 fn generation_with_validation_results(
4412 id: u64,
4413 status: GenerationStatus,
4414 profile: &str,
4415 validation_results: Vec<crate::app::ValidationResult>,
4416 ) -> AppGeneration {
4417 AppGeneration {
4418 profile: profile.into(),
4419 validation_results,
4420 ..generation_with_id(id, status)
4421 }
4422 }
4423
4424 #[tokio::test]
4425 async fn propose_generation_uses_active_scene_summary_metadata() {
4426 let root = tempdir().expect("temp dir");
4427 let repo_root = root.path().to_path_buf();
4428 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4429 &repo_root,
4430 Some("team"),
4431 r#"[scenes.team]
4432name = 'team-scene'
4433profile = 'safe'
4434base_generation = 41
4435enabled_features = ['feature-extra']
4436disabled_features = []
4437"#,
4438 );
4439
4440 let app = ResolvedApp {
4441 name: "weft-claw".into(),
4442 version: "0.1.0".into(),
4443 capabilities: vec!["agent.runtime".into()],
4444 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4445 sources: ResolvedAppSources {
4446 manifest_path: manifest_path.display().to_string(),
4447 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4448 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4449 },
4450 ..ResolvedApp::default()
4451 };
4452 let state = state_for_propose_generation(
4453 repo_root,
4454 app,
4455 vec![
4456 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4457 test_package_source("feature-default", "feature", vec![], vec![]),
4458 test_package_source("feature-off", "feature", vec![], vec![]),
4459 test_package_source("feature-extra", "feature", vec![], vec![]),
4460 ],
4461 );
4462
4463 let response = propose_generation(
4464 Path("weft-claw".into()),
4465 State(state.clone()),
4466 Json(serde_json::json!({})),
4467 )
4468 .await
4469 .expect("proposal succeeds");
4470
4471 let generation = &response.0["generation"];
4472 assert_eq!(generation["scene"], "team-scene");
4473 assert_eq!(generation["profile"], "safe");
4474 assert_eq!(generation["parent_generation"], 41);
4475 assert_eq!(generation["status"], "candidate");
4476 }
4477
4478 #[tokio::test]
4479 async fn propose_generation_overlays_scene_feature_selection_on_candidate() {
4480 let root = tempdir().expect("temp dir");
4481 let repo_root = root.path().to_path_buf();
4482 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4483 &repo_root,
4484 Some("team"),
4485 r#"[scenes.team]
4486name = 'team'
4487enabled_features = ['feature-scene-on']
4488disabled_features = ['feature-off']
4489
4490[[scenes.team.binding_pins]]
4491capability = 'agent.runtime'
4492provider = 'scene-provider'
4493package = 'scene-provider'
4494
4495[[scenes.team.package_pins]]
4496package = 'scene-package'
4497version = '0.1.0'
4498source = 'local-index'
4499"#,
4500 );
4501
4502 let app = ResolvedApp {
4503 name: "weft-claw".into(),
4504 version: "0.1.0".into(),
4505 capabilities: vec!["agent.runtime".into()],
4506 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4507 sources: ResolvedAppSources {
4508 manifest_path: manifest_path.display().to_string(),
4509 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4510 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4511 },
4512 ..ResolvedApp::default()
4513 };
4514 let state = state_for_propose_generation(
4515 repo_root,
4516 app,
4517 vec![
4518 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4519 test_package_source("feature-default", "feature", vec![], vec![]),
4520 test_package_source("feature-off", "feature", vec![], vec![]),
4521 test_package_source("feature-scene-on", "feature", vec![], vec![]),
4522 test_package_source("feature-extra", "feature", vec![], vec![]),
4523 test_package_source("scene-package", "feature", vec![], vec![]),
4524 ],
4525 );
4526
4527 let response = propose_generation(
4528 Path("weft-claw".into()),
4529 State(state.clone()),
4530 Json(serde_json::json!({})),
4531 )
4532 .await
4533 .expect("proposal succeeds");
4534
4535 let generation = &response.0["generation"];
4536 let enabled_features = generation["enabled_features"]
4537 .as_array()
4538 .expect("enabled feature array")
4539 .iter()
4540 .map(|value| value.as_str().expect("feature string"))
4541 .collect::<Vec<_>>();
4542 assert_eq!(
4543 enabled_features,
4544 vec!["feature-default", "feature-scene-on"]
4545 );
4546
4547 let bindings = generation["bindings"].as_array().expect("bindings array");
4548 assert_eq!(bindings.len(), 1);
4549 assert_eq!(bindings[0]["provider"], "scene-provider");
4550 assert_eq!(bindings[0]["source"], "scene:team");
4551 }
4552
4553 #[tokio::test]
4554 async fn propose_generation_scene_binding_pin_adds_binding_for_requested_capability() {
4555 let root = tempdir().expect("temp dir");
4556 let repo_root = root.path().to_path_buf();
4557 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4558 &repo_root,
4559 Some("team"),
4560 r#"[scenes.team]
4561name = 'team'
4562
4563[[scenes.team.binding_pins]]
4564capability = 'team.delegate'
4565package = 'team-runtime'
4566"#,
4567 );
4568
4569 let app = ResolvedApp {
4570 name: "weft-claw".into(),
4571 version: "0.1.0".into(),
4572 capabilities: vec!["agent.runtime".into()],
4573 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4574 sources: ResolvedAppSources {
4575 manifest_path: manifest_path.display().to_string(),
4576 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4577 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4578 },
4579 ..ResolvedApp::default()
4580 };
4581 let state = state_for_propose_generation(
4582 repo_root,
4583 app,
4584 vec![
4585 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4586 test_package_source("team-runtime", "provider", vec!["team.delegate"], vec![]),
4587 test_package_source("feature-default", "feature", vec![], vec![]),
4588 test_package_source("feature-off", "feature", vec![], vec![]),
4589 ],
4590 );
4591
4592 let response = propose_generation(
4593 Path("weft-claw".into()),
4594 State(state.clone()),
4595 Json(serde_json::json!({
4596 "capabilities": ["agent.runtime", "team.delegate"]
4597 })),
4598 )
4599 .await
4600 .expect("proposal succeeds");
4601
4602 let bindings = response.0["generation"]["bindings"]
4603 .as_array()
4604 .expect("bindings array");
4605 assert_eq!(bindings.len(), 2);
4606 assert!(bindings.iter().any(|binding| {
4607 binding["capability"] == "team.delegate"
4608 && binding["provider"] == "team-runtime"
4609 && binding["mutable"] == false
4610 && binding["source"] == "scene:team"
4611 }));
4612 }
4613
4614 #[tokio::test]
4615 async fn propose_generation_scene_binding_pin_ignores_absent_capability() {
4616 let root = tempdir().expect("temp dir");
4617 let repo_root = root.path().to_path_buf();
4618 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4619 &repo_root,
4620 Some("team"),
4621 r#"[scenes.team]
4622name = 'team'
4623
4624[[scenes.team.binding_pins]]
4625capability = 'team.delegate'
4626provider = 'team-runtime'
4627package = 'team-runtime'
4628"#,
4629 );
4630
4631 let app = ResolvedApp {
4632 name: "weft-claw".into(),
4633 version: "0.1.0".into(),
4634 capabilities: vec!["agent.runtime".into()],
4635 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4636 sources: ResolvedAppSources {
4637 manifest_path: manifest_path.display().to_string(),
4638 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4639 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4640 },
4641 ..ResolvedApp::default()
4642 };
4643 let state = state_for_propose_generation(
4644 repo_root,
4645 app,
4646 vec![
4647 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4648 test_package_source("team-runtime", "provider", vec!["team.delegate"], vec![]),
4649 test_package_source("feature-default", "feature", vec![], vec![]),
4650 test_package_source("feature-off", "feature", vec![], vec![]),
4651 ],
4652 );
4653
4654 let response = propose_generation(
4655 Path("weft-claw".into()),
4656 State(state.clone()),
4657 Json(serde_json::json!({})),
4658 )
4659 .await
4660 .expect("proposal succeeds");
4661
4662 let bindings = response.0["generation"]["bindings"]
4663 .as_array()
4664 .expect("bindings array");
4665 assert_eq!(bindings.len(), 1);
4666 assert_eq!(bindings[0]["capability"], "agent.runtime");
4667 assert_eq!(bindings[0]["provider"], "agent-runtime");
4668 assert!(!bindings
4669 .iter()
4670 .any(|binding| binding["capability"] == "team.delegate"));
4671 }
4672
4673 #[tokio::test]
4674 async fn propose_generation_keeps_legacy_behavior_without_matching_active_scene() {
4675 let root = tempdir().expect("temp dir");
4676 let repo_root = root.path().to_path_buf();
4677 let (_, instance_dir, manifest_path) =
4678 write_test_manifest_and_config(&repo_root, Some("missing-scene"), "");
4679
4680 let app = ResolvedApp {
4681 name: "weft-claw".into(),
4682 version: "0.1.0".into(),
4683 capabilities: vec!["agent.runtime".into()],
4684 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4685 sources: ResolvedAppSources {
4686 manifest_path: manifest_path.display().to_string(),
4687 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4688 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4689 },
4690 ..ResolvedApp::default()
4691 };
4692 let state = state_for_propose_generation(
4693 repo_root,
4694 app,
4695 vec![
4696 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4697 test_package_source("feature-default", "feature", vec![], vec![]),
4698 test_package_source("feature-off", "feature", vec![], vec![]),
4699 ],
4700 );
4701
4702 let response = propose_generation(
4703 Path("weft-claw".into()),
4704 State(state.clone()),
4705 Json(serde_json::json!({})),
4706 )
4707 .await
4708 .expect("proposal succeeds");
4709
4710 let generation = &response.0["generation"];
4711 assert_eq!(generation["scene"], "missing-scene");
4712 assert_eq!(generation["profile"], "developer");
4713 assert!(generation["parent_generation"].is_null());
4714 let enabled_features = generation["enabled_features"]
4715 .as_array()
4716 .expect("enabled feature array")
4717 .iter()
4718 .map(|value| value.as_str().expect("feature string"))
4719 .collect::<Vec<_>>();
4720 assert_eq!(enabled_features, vec!["feature-default", "feature-off"]);
4721 }
4722
4723 #[tokio::test]
4724 async fn propose_generation_rejects_duplicate_binding_pin_conflicts() {
4725 let root = tempdir().expect("temp dir");
4726 let repo_root = root.path().to_path_buf();
4727 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4728 &repo_root,
4729 Some("team"),
4730 r#"[scenes.team]
4731name = 'team'
4732
4733[[scenes.team.binding_pins]]
4734capability = 'agent.runtime'
4735provider = 'agent-runtime'
4736package = 'agent-runtime'
4737version = '0.1.0'
4738sha512 = 'sha512:111'
4739
4740[[scenes.team.binding_pins]]
4741capability = 'agent.runtime'
4742provider = 'agent-runtime-alt'
4743package = 'agent-runtime-alt'
4744version = '0.2.0'
4745sha512 = 'sha512:222'
4746"#,
4747 );
4748
4749 let app = ResolvedApp {
4750 name: "weft-claw".into(),
4751 version: "0.1.0".into(),
4752 capabilities: vec!["agent.runtime".into()],
4753 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4754 sources: ResolvedAppSources {
4755 manifest_path: manifest_path.display().to_string(),
4756 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4757 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4758 },
4759 ..ResolvedApp::default()
4760 };
4761 let state = state_for_propose_generation(
4762 repo_root,
4763 app,
4764 vec![test_package_source(
4765 "agent-runtime",
4766 "provider",
4767 vec!["agent.runtime"],
4768 vec![],
4769 )],
4770 );
4771
4772 let (status, body) = propose_generation(
4773 Path("weft-claw".into()),
4774 State(state.clone()),
4775 Json(serde_json::json!({})),
4776 )
4777 .await
4778 .expect_err("proposal should fail for conflicting binding pins");
4779
4780 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
4781 let payload = body.0;
4782 assert_eq!(payload["reason"], "scene_pin_conflict");
4783 assert_eq!(payload["conflict"]["type"], "duplicate_binding_pin");
4784 assert_eq!(payload["conflict"]["scene"], "team");
4785 assert_eq!(payload["conflict"]["capability"], "agent.runtime");
4786 assert_eq!(payload["conflict"]["indexes"], serde_json::json!([0, 1]));
4787
4788 let store = state.generation_store.read().await;
4789 assert!(store.get("weft-claw").is_none());
4790 }
4791
4792 #[tokio::test]
4793 async fn propose_generation_rejects_duplicate_package_pin_conflicts() {
4794 let root = tempdir().expect("temp dir");
4795 let repo_root = root.path().to_path_buf();
4796 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4797 &repo_root,
4798 Some("team"),
4799 r#"[scenes.team]
4800name = 'team'
4801
4802[[scenes.team.package_pins]]
4803package = 'agent-runtime'
4804version = '0.1.0'
4805sha512 = 'sha512:111'
4806source = 'local-index'
4807
4808[[scenes.team.package_pins]]
4809package = 'agent-runtime'
4810version = '0.2.0'
4811sha512 = 'sha512:222'
4812source = 'local-index'
4813"#,
4814 );
4815
4816 let app = ResolvedApp {
4817 name: "weft-claw".into(),
4818 version: "0.1.0".into(),
4819 capabilities: vec!["agent.runtime".into()],
4820 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4821 sources: ResolvedAppSources {
4822 manifest_path: manifest_path.display().to_string(),
4823 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4824 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4825 },
4826 ..ResolvedApp::default()
4827 };
4828 let state = state_for_propose_generation(
4829 repo_root,
4830 app,
4831 vec![test_package_source(
4832 "agent-runtime",
4833 "provider",
4834 vec!["agent.runtime"],
4835 vec![],
4836 )],
4837 );
4838
4839 let (status, body) = propose_generation(
4840 Path("weft-claw".into()),
4841 State(state.clone()),
4842 Json(serde_json::json!({})),
4843 )
4844 .await
4845 .expect_err("proposal should fail for conflicting package pins");
4846
4847 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
4848 let payload = body.0;
4849 assert_eq!(payload["reason"], "scene_pin_conflict");
4850 assert_eq!(payload["conflict"]["type"], "duplicate_package_pin");
4851 assert_eq!(payload["conflict"]["scene"], "team");
4852 assert_eq!(payload["conflict"]["package"], "agent-runtime");
4853 assert_eq!(payload["conflict"]["indexes"], serde_json::json!([0, 1]));
4854
4855 let store = state.generation_store.read().await;
4856 assert!(store.get("weft-claw").is_none());
4857 }
4858
4859 #[tokio::test]
4860 async fn propose_generation_rejects_binding_package_pin_mismatch() {
4861 let root = tempdir().expect("temp dir");
4862 let repo_root = root.path().to_path_buf();
4863 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4864 &repo_root,
4865 Some("team"),
4866 r#"[scenes.team]
4867name = 'team'
4868
4869[[scenes.team.binding_pins]]
4870capability = 'agent.runtime'
4871provider = 'agent-runtime'
4872package = 'agent-runtime'
4873version = '0.1.0'
4874sha512 = 'sha512:111'
4875source = 'local-index'
4876
4877[[scenes.team.package_pins]]
4878package = 'agent-runtime'
4879version = '0.2.0'
4880sha512 = 'sha512:111'
4881source = 'local-index'
4882"#,
4883 );
4884
4885 let app = ResolvedApp {
4886 name: "weft-claw".into(),
4887 version: "0.1.0".into(),
4888 capabilities: vec!["agent.runtime".into()],
4889 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4890 sources: ResolvedAppSources {
4891 manifest_path: manifest_path.display().to_string(),
4892 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4893 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4894 },
4895 ..ResolvedApp::default()
4896 };
4897 let state = state_for_propose_generation(
4898 repo_root,
4899 app,
4900 vec![test_package_source(
4901 "agent-runtime",
4902 "provider",
4903 vec!["agent.runtime"],
4904 vec![],
4905 )],
4906 );
4907
4908 let (status, body) = propose_generation(
4909 Path("weft-claw".into()),
4910 State(state.clone()),
4911 Json(serde_json::json!({})),
4912 )
4913 .await
4914 .expect_err("proposal should fail for binding/package mismatch");
4915
4916 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
4917 let payload = body.0;
4918 assert_eq!(payload["reason"], "scene_pin_conflict");
4919 assert_eq!(payload["conflict"]["type"], "binding_package_mismatch");
4920 assert_eq!(payload["conflict"]["scene"], "team");
4921 assert_eq!(payload["conflict"]["capability"], "agent.runtime");
4922 assert_eq!(payload["conflict"]["package"], "agent-runtime");
4923 assert_eq!(payload["conflict"]["indexes"], serde_json::json!([0, 0]));
4924
4925 let store = state.generation_store.read().await;
4926 assert!(store.get("weft-claw").is_none());
4927 }
4928
4929 #[tokio::test]
4930 async fn propose_generation_accepts_non_conflicting_scene_pins_metadata_only() {
4931 let root = tempdir().expect("temp dir");
4932 let repo_root = root.path().to_path_buf();
4933 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
4934 &repo_root,
4935 Some("team"),
4936 r#"[scenes.team]
4937name = 'team'
4938profile = 'developer'
4939enabled_features = ['feature-extra']
4940
4941[[scenes.team.binding_pins]]
4942capability = 'agent.runtime'
4943provider = 'agent-runtime'
4944package = 'agent-runtime'
4945version = '0.1.0'
4946sha512 = 'sha512:111'
4947source = 'local-index'
4948
4949[[scenes.team.package_pins]]
4950package = 'agent-runtime'
4951version = '0.1.0'
4952sha512 = 'sha512:111'
4953source = 'local-index'
4954"#,
4955 );
4956
4957 let app = ResolvedApp {
4958 name: "weft-claw".into(),
4959 version: "0.1.0".into(),
4960 capabilities: vec!["agent.runtime".into()],
4961 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
4962 sources: ResolvedAppSources {
4963 manifest_path: manifest_path.display().to_string(),
4964 config_path: Some(instance_dir.join("config.toml").display().to_string()),
4965 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
4966 },
4967 ..ResolvedApp::default()
4968 };
4969 let state = state_for_propose_generation(
4970 repo_root,
4971 app,
4972 vec![
4973 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
4974 test_package_source("feature-default", "feature", vec![], vec![]),
4975 test_package_source("feature-off", "feature", vec![], vec![]),
4976 test_package_source("feature-extra", "feature", vec![], vec![]),
4977 ],
4978 );
4979
4980 let response = propose_generation(
4981 Path("weft-claw".into()),
4982 State(state.clone()),
4983 Json(serde_json::json!({})),
4984 )
4985 .await
4986 .expect("proposal succeeds for non-conflicting scene pins");
4987
4988 let generation = &response.0["generation"];
4989 assert_eq!(response.0["status"], "proposed");
4990 assert_eq!(generation["scene"], "team");
4991 assert_eq!(generation["profile"], "developer");
4992 assert_eq!(generation["status"], "candidate");
4993 let bindings = generation["bindings"].as_array().expect("bindings array");
4994 assert_eq!(bindings.len(), 1);
4995 assert_eq!(bindings[0]["provider"], "agent-runtime");
4996 assert_eq!(bindings[0]["source"], "scene:team");
4997
4998 let store = state.generation_store.read().await;
4999 let app_store = store.get("weft-claw").expect("store entry present");
5000 assert!(app_store.candidate.is_some());
5001 assert_eq!(app_store.next_generation_id(), 2);
5002 }
5003
5004 #[tokio::test]
5005 async fn propose_generation_scene_package_pin_adds_package_to_candidate_closure_summary() {
5006 let root = tempdir().expect("temp dir");
5007 let repo_root = root.path().to_path_buf();
5008 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
5009 &repo_root,
5010 Some("team"),
5011 r#"[scenes.team]
5012name = 'team'
5013
5014[[scenes.team.package_pins]]
5015package = 'scene-package'
5016"#,
5017 );
5018
5019 let app = ResolvedApp {
5020 name: "weft-claw".into(),
5021 version: "0.1.0".into(),
5022 capabilities: vec!["agent.runtime".into()],
5023 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
5024 sources: ResolvedAppSources {
5025 manifest_path: manifest_path.display().to_string(),
5026 config_path: Some(instance_dir.join("config.toml").display().to_string()),
5027 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
5028 },
5029 ..ResolvedApp::default()
5030 };
5031 let state = state_for_propose_generation(
5032 repo_root,
5033 app,
5034 vec![
5035 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
5036 test_package_source("feature-default", "feature", vec![], vec![]),
5037 test_package_source("feature-off", "feature", vec![], vec![]),
5038 test_package_source("scene-package", "provider", vec!["scene.extra"], vec![]),
5039 ],
5040 );
5041
5042 let _ = propose_generation(
5043 Path("weft-claw".into()),
5044 State(state.clone()),
5045 Json(serde_json::json!({})),
5046 )
5047 .await
5048 .expect("proposal succeeds");
5049
5050 let candidate = {
5051 let store = state.generation_store.read().await;
5052 store
5053 .get("weft-claw")
5054 .and_then(|app_store| app_store.candidate.clone())
5055 .expect("candidate stored")
5056 };
5057
5058 let app_config =
5059 crate::app::load_instance_config_from_path(&instance_dir.join("config.toml"))
5060 .expect("config loads");
5061 let selected_packages = selected_summary_packages(&state, &app_config, &candidate.bindings)
5062 .expect("selected packages resolve");
5063
5064 assert!(selected_packages
5065 .iter()
5066 .any(|package| package.name == "scene-package"));
5067 assert_eq!(
5068 candidate.closure_id,
5069 closure_id_from_lock_packages(&selected_packages)
5070 );
5071 }
5072
5073 #[tokio::test]
5074 async fn propose_generation_scene_package_pin_copies_exact_metadata_into_summary() {
5075 let root = tempdir().expect("temp dir");
5076 let repo_root = root.path().to_path_buf();
5077 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
5078 &repo_root,
5079 Some("team"),
5080 r#"[scenes.team]
5081name = 'team'
5082
5083[[scenes.team.package_pins]]
5084package = 'scene-package'
5085version = '1.2.3'
5086sha512 = 'sha512:scene-package'
5087source = 'local-index'
5088"#,
5089 );
5090
5091 let app = ResolvedApp {
5092 name: "weft-claw".into(),
5093 version: "0.1.0".into(),
5094 capabilities: vec!["agent.runtime".into()],
5095 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
5096 sources: ResolvedAppSources {
5097 manifest_path: manifest_path.display().to_string(),
5098 config_path: Some(instance_dir.join("config.toml").display().to_string()),
5099 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
5100 },
5101 ..ResolvedApp::default()
5102 };
5103 let state = state_for_propose_generation(
5104 repo_root,
5105 app,
5106 vec![
5107 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
5108 test_package_source("feature-default", "feature", vec![], vec![]),
5109 test_package_source("feature-off", "feature", vec![], vec![]),
5110 test_package_source("scene-package", "provider", vec!["scene.extra"], vec![]),
5111 ],
5112 );
5113
5114 let response = propose_generation(
5115 Path("weft-claw".into()),
5116 State(state.clone()),
5117 Json(serde_json::json!({})),
5118 )
5119 .await
5120 .expect("proposal succeeds");
5121
5122 let candidate = {
5123 let store = state.generation_store.read().await;
5124 store
5125 .get("weft-claw")
5126 .and_then(|app_store| app_store.candidate.clone())
5127 .expect("candidate stored")
5128 };
5129
5130 let app_config =
5131 crate::app::load_instance_config_from_path(&instance_dir.join("config.toml"))
5132 .expect("config loads");
5133 let selected_packages = selected_summary_packages(&state, &app_config, &candidate.bindings)
5134 .expect("selected packages resolve");
5135 let scene_package = selected_packages
5136 .iter()
5137 .find(|package| package.name == "scene-package")
5138 .expect("scene package present");
5139
5140 assert_eq!(scene_package.version, "1.2.3");
5141 assert_eq!(scene_package.sha512, "sha512:scene-package");
5142 assert_eq!(scene_package.source, "local-index");
5143 assert_eq!(
5144 response.0["generation"]["closure_id"],
5145 candidate.closure_id.as_str()
5146 );
5147 }
5148
5149 #[tokio::test]
5150 async fn propose_generation_rejects_unresolved_scene_package_pin_without_replacing_candidate() {
5151 let root = tempdir().expect("temp dir");
5152 let repo_root = root.path().to_path_buf();
5153 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
5154 &repo_root,
5155 Some("team"),
5156 r#"[scenes.team]
5157name = 'team'
5158
5159[[scenes.team.package_pins]]
5160package = 'missing-package'
5161"#,
5162 );
5163
5164 let app = ResolvedApp {
5165 name: "weft-claw".into(),
5166 version: "0.1.0".into(),
5167 capabilities: vec!["agent.runtime".into()],
5168 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
5169 sources: ResolvedAppSources {
5170 manifest_path: manifest_path.display().to_string(),
5171 config_path: Some(instance_dir.join("config.toml").display().to_string()),
5172 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
5173 },
5174 ..ResolvedApp::default()
5175 };
5176
5177 let existing_candidate = generation_with_id(9, GenerationStatus::Candidate);
5178 let state = state_for_propose_generation_with_store(
5179 repo_root,
5180 app,
5181 vec![
5182 test_package_source("agent-runtime", "provider", vec!["agent.runtime"], vec![]),
5183 test_package_source("feature-default", "feature", vec![], vec![]),
5184 test_package_source("feature-off", "feature", vec![], vec![]),
5185 ],
5186 AppGenerationStore {
5187 active: None,
5188 candidate: Some(existing_candidate.clone()),
5189 rollback: None,
5190 next_id: 10,
5191 },
5192 );
5193
5194 let (status, body) = propose_generation(
5195 Path("weft-claw".into()),
5196 State(state.clone()),
5197 Json(serde_json::json!({})),
5198 )
5199 .await
5200 .expect_err("proposal should fail for unresolved package pin");
5201
5202 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
5203 let payload = body.0;
5204 assert_eq!(payload["reason"], "scene_package_pin_unresolved");
5205 assert_eq!(payload["scene"], "team");
5206 assert_eq!(payload["package"], "missing-package");
5207 assert_eq!(payload["index"], 0);
5208
5209 let store = state.generation_store.read().await;
5210 let candidate = store
5211 .get("weft-claw")
5212 .and_then(|app_store| app_store.candidate.as_ref())
5213 .expect("existing candidate preserved");
5214 assert_eq!(candidate.id, existing_candidate.id);
5215 assert_eq!(candidate.closure_id, existing_candidate.closure_id);
5216 }
5217
5218 fn state_with_generation_store(
5219 repo_root: std::path::PathBuf,
5220 app: ResolvedApp,
5221 app_store: AppGenerationStore,
5222 ) -> AppState {
5223 let mut apps = ResolvedAppMap::new();
5224 apps.insert(app.name.clone(), app);
5225
5226 let mut stores = GenerationStoreMap::new();
5227 stores.insert("weft-claw".into(), app_store);
5228
5229 AppState {
5230 resolved_apps: Arc::new(RwLock::new(apps)),
5231 generation_store: Arc::new(RwLock::new(stores)),
5232 ..test_state(repo_root, CapabilityRegistry::new())
5233 }
5234 }
5235
5236 fn state_for_propose_generation_with_store(
5237 repo_root: std::path::PathBuf,
5238 app: ResolvedApp,
5239 package_sources: Vec<PackageSource>,
5240 app_store: AppGenerationStore,
5241 ) -> AppState {
5242 let mut state = state_for_propose_generation(repo_root, app, package_sources);
5243 state.generation_store = Arc::new(RwLock::new({
5244 let mut stores = GenerationStoreMap::new();
5245 stores.insert("weft-claw".into(), app_store);
5246 stores
5247 }));
5248 state
5249 }
5250
5251 #[tokio::test]
5252 async fn activate_generation_persists_repairable_generation_index_after_success() {
5253 let root = tempdir().expect("temp dir");
5254 let repo_root = root.path().to_path_buf();
5255 let instance_dir = repo_root.join(".weft").join("weft-claw");
5256 fs::create_dir_all(&instance_dir).expect("instance dir created");
5257 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5258 .expect("lock file seeded");
5259
5260 let state = state_with_generation_store(
5261 repo_root,
5262 test_resolved_app("weft-claw", Some(&instance_dir)),
5263 AppGenerationStore {
5264 active: Some(generation_with_id(1, GenerationStatus::Active)),
5265 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5266 rollback: None,
5267 next_id: 3,
5268 },
5269 );
5270
5271 let response = activate_generation(Path("weft-claw".into()), State(state))
5272 .await
5273 .expect("activation succeeds");
5274
5275 let payload = response.0;
5276 assert_eq!(payload["status"], "activated");
5277 assert_eq!(payload["generation_lock_written"], true);
5278 assert_eq!(payload["lock_written"], true);
5279 assert_eq!(payload["pointer_written"], true);
5280 assert!(payload["pointer_error"].is_null());
5281 assert_eq!(payload["index_written"], true);
5282 assert!(payload["index_error"].is_null());
5283
5284 let generation_lock_path = instance_dir.join("generations").join("2.lock.toml");
5285 assert!(generation_lock_path.exists());
5286 let generation_lock = crate::app::load_instance_lock_from_path(&generation_lock_path)
5287 .expect("generation lock loads");
5288 assert_eq!(generation_lock.generation, 2);
5289 assert_eq!(generation_lock.status, "active");
5290
5291 let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5292 .expect("root lock loads");
5293 assert_eq!(root_lock.generation, 2);
5294 assert_eq!(root_lock.status, "active");
5295
5296 assert_eq!(
5297 crate::app::read_previous_generation_pointer(&instance_dir)
5298 .expect("previous pointer read succeeds"),
5299 Some(1)
5300 );
5301 assert_eq!(
5302 crate::app::read_active_generation_pointer(&instance_dir)
5303 .expect("active pointer read succeeds"),
5304 Some(2)
5305 );
5306
5307 let index = crate::app::load_generation_index(&instance_dir)
5308 .expect("index load succeeds")
5309 .expect("index present");
5310 assert_eq!(index.active, Some(2));
5311 assert_eq!(index.previous, Some(1));
5312 assert_eq!(index.candidate, None);
5313 assert_eq!(index.next_id, 3);
5314 assert_eq!(index.generations.len(), 2);
5315 }
5316
5317 #[tokio::test]
5318 async fn activate_generation_succeeds_when_generation_index_persist_fails() {
5319 let root = tempdir().expect("temp dir");
5320 let repo_root = root.path().to_path_buf();
5321 let instance_dir = repo_root.join(".weft").join("weft-claw");
5322 fs::create_dir_all(&instance_dir).expect("instance dir created");
5323 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5324 .expect("lock file seeded");
5325 fs::write(
5326 instance_dir.join("generation-store.toml"),
5327 "schema_version = 1\nnext_id = 1\ngenerations = []\n",
5328 )
5329 .expect("existing generation index seeded");
5330 fs::create_dir_all(instance_dir.join("generation-store.toml.bak"))
5331 .expect("backup dir created to force index write failure");
5332
5333 let state = state_with_generation_store(
5334 repo_root,
5335 test_resolved_app("weft-claw", Some(&instance_dir)),
5336 AppGenerationStore {
5337 active: Some(generation_with_id(1, GenerationStatus::Active)),
5338 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5339 rollback: None,
5340 next_id: 3,
5341 },
5342 );
5343
5344 let response = activate_generation(Path("weft-claw".into()), State(state))
5345 .await
5346 .expect("activation still succeeds");
5347
5348 let payload = response.0;
5349 assert_eq!(payload["status"], "activated");
5350 assert_eq!(payload["generation_lock_written"], true);
5351 assert_eq!(payload["lock_written"], true);
5352 assert_eq!(payload["pointer_written"], true);
5353 assert!(payload["pointer_error"].is_null());
5354 assert_eq!(payload["index_written"], false);
5355 assert!(payload["index_error"]
5356 .as_str()
5357 .expect("index error string")
5358 .contains("Failed to persist repairable generation index"));
5359 }
5360
5361 #[tokio::test]
5362 async fn activate_generation_fails_when_previous_pointer_write_fails() {
5363 let root = tempdir().expect("temp dir");
5364 let repo_root = root.path().to_path_buf();
5365 let instance_dir = repo_root.join(".weft").join("weft-claw");
5366 fs::create_dir_all(&instance_dir).expect("instance dir created");
5367 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5368 .expect("lock file seeded");
5369 fs::write(previous_generation_pointer_path(&instance_dir), "0\n")
5370 .expect("previous pointer seeded");
5371 fs::create_dir_all(instance_dir.join("previous.bak"))
5372 .expect("backup dir created to force previous pointer write failure");
5373
5374 let state = state_with_generation_store(
5375 repo_root,
5376 test_resolved_app("weft-claw", Some(&instance_dir)),
5377 AppGenerationStore {
5378 active: Some(generation_with_id(1, GenerationStatus::Active)),
5379 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5380 rollback: None,
5381 next_id: 3,
5382 },
5383 );
5384
5385 let (status, body) = activate_generation(Path("weft-claw".into()), State(state))
5386 .await
5387 .expect_err("activation should fail when previous pointer write fails");
5388
5389 assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5390 let payload = body.0;
5391 assert_eq!(payload["status"], "activation_failed");
5392 assert_eq!(payload["reason"], "pointer_write_failed");
5393 assert_eq!(payload["generation_lock_written"], true);
5394 assert_eq!(payload["lock_written"], true);
5395 assert_eq!(payload["pointer_written"], false);
5396 assert_eq!(payload["pointer_error"]["stage"], "previous");
5397 assert_eq!(payload["pointer_error"]["repair_needed"], false);
5398 assert_eq!(payload["index_written"], false);
5399 assert!(payload["index_error"].is_null());
5400 assert!(payload["error"]
5401 .as_str()
5402 .expect("error string")
5403 .contains("Failed to write previous generation pointer"));
5404
5405 assert_eq!(
5406 crate::app::read_active_generation_pointer(&instance_dir)
5407 .expect("active pointer read succeeds"),
5408 None
5409 );
5410 assert_eq!(
5411 crate::app::read_previous_generation_pointer(&instance_dir)
5412 .expect("previous pointer read succeeds"),
5413 Some(0)
5414 );
5415 assert!(crate::app::load_generation_index(&instance_dir)
5416 .expect("index load succeeds")
5417 .is_none());
5418 }
5419
5420 #[tokio::test]
5421 async fn activate_generation_fails_when_active_pointer_write_fails_and_skips_index() {
5422 let root = tempdir().expect("temp dir");
5423 let repo_root = root.path().to_path_buf();
5424 let instance_dir = repo_root.join(".weft").join("weft-claw");
5425 fs::create_dir_all(&instance_dir).expect("instance dir created");
5426 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5427 .expect("lock file seeded");
5428 fs::write(active_generation_pointer_path(&instance_dir), "1\n")
5429 .expect("active pointer seeded");
5430 fs::create_dir_all(instance_dir.join("active.bak"))
5431 .expect("backup dir created to force active pointer write failure");
5432
5433 let state = state_with_generation_store(
5434 repo_root,
5435 test_resolved_app("weft-claw", Some(&instance_dir)),
5436 AppGenerationStore {
5437 active: Some(generation_with_id(1, GenerationStatus::Active)),
5438 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5439 rollback: None,
5440 next_id: 3,
5441 },
5442 );
5443
5444 let (status, body) = activate_generation(Path("weft-claw".into()), State(state))
5445 .await
5446 .expect_err("activation should fail when active pointer write fails");
5447
5448 assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5449 let payload = body.0;
5450 assert_eq!(payload["status"], "activation_failed");
5451 assert_eq!(payload["reason"], "pointer_write_failed");
5452 assert_eq!(payload["generation_lock_written"], true);
5453 assert_eq!(payload["lock_written"], true);
5454 assert_eq!(payload["pointer_written"], false);
5455 assert_eq!(payload["pointer_error"]["stage"], "active");
5456 assert_eq!(payload["pointer_error"]["repair_needed"], true);
5457 assert_eq!(payload["index_written"], false);
5458 assert!(payload["index_error"].is_null());
5459 assert!(payload["pointer_error"]["message"]
5460 .as_str()
5461 .expect("pointer error message")
5462 .contains("repair is required"));
5463
5464 assert_eq!(
5465 crate::app::read_previous_generation_pointer(&instance_dir)
5466 .expect("previous pointer read succeeds"),
5467 Some(1)
5468 );
5469 assert_eq!(
5470 crate::app::read_active_generation_pointer(&instance_dir)
5471 .expect("active pointer read succeeds"),
5472 Some(1)
5473 );
5474 assert!(crate::app::load_generation_index(&instance_dir)
5475 .expect("index load succeeds")
5476 .is_none());
5477 }
5478
5479 #[tokio::test]
5480 async fn activate_generation_allows_existing_same_generation_lock() {
5481 let root = tempdir().expect("temp dir");
5482 let instance_dir = root.path().join(".weft").join("weft-claw");
5483 fs::create_dir_all(&instance_dir).expect("instance dir created");
5484 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5485 .expect("root lock seeded");
5486
5487 let first_state = state_with_generation_store(
5488 root.path().to_path_buf(),
5489 test_resolved_app("weft-claw", Some(&instance_dir)),
5490 AppGenerationStore {
5491 active: Some(generation_with_id(1, GenerationStatus::Active)),
5492 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5493 rollback: None,
5494 next_id: 3,
5495 },
5496 );
5497
5498 let first_response = activate_generation(Path("weft-claw".into()), State(first_state))
5499 .await
5500 .expect("first activation succeeds");
5501 assert_eq!(first_response.0["generation_lock_written"], true);
5502
5503 let second_state = state_with_generation_store(
5504 root.path().to_path_buf(),
5505 test_resolved_app("weft-claw", Some(&instance_dir)),
5506 AppGenerationStore {
5507 active: Some(generation_with_id(1, GenerationStatus::Active)),
5508 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5509 rollback: None,
5510 next_id: 3,
5511 },
5512 );
5513
5514 let second_response = activate_generation(Path("weft-claw".into()), State(second_state))
5515 .await
5516 .expect("second activation succeeds with same immutable lock");
5517 let payload = second_response.0;
5518 assert_eq!(payload["status"], "activated");
5519 assert_eq!(payload["generation_lock_written"], true);
5520 assert_eq!(payload["lock_written"], true);
5521 assert_eq!(payload["pointer_written"], true);
5522 assert_eq!(payload["index_written"], true);
5523
5524 let generation_lock = crate::app::load_instance_lock_from_path(
5525 &instance_dir.join("generations").join("2.lock.toml"),
5526 )
5527 .expect("generation lock still loads");
5528 assert_eq!(generation_lock.generation, 2);
5529
5530 let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5531 .expect("root lock loads after idempotent activation");
5532 assert_eq!(root_lock.generation, 2);
5533 }
5534
5535 #[tokio::test]
5536 async fn activate_generation_fails_when_existing_generation_lock_differs_and_skips_root_pointers_and_index(
5537 ) {
5538 let root = tempdir().expect("temp dir");
5539 let repo_root = root.path().to_path_buf();
5540 let instance_dir = repo_root.join(".weft").join("weft-claw");
5541 fs::create_dir_all(instance_dir.join("generations")).expect("generations dir created");
5542 fs::write(instance_dir.join("lock.toml"), "app = \"stale-root\"\n")
5543 .expect("root lock seeded");
5544 fs::write(
5545 instance_dir.join("generations").join("2.lock.toml"),
5546 "lock_version = 2\napp = 'weft-claw'\ngeneration = 2\nstatus = 'verified'\nprofile = 'developer'\nscene = 'conflict'\n",
5547 )
5548 .expect("conflicting generation lock seeded");
5549
5550 let state = state_with_generation_store(
5551 repo_root,
5552 test_resolved_app("weft-claw", Some(&instance_dir)),
5553 AppGenerationStore {
5554 active: Some(generation_with_id(1, GenerationStatus::Active)),
5555 candidate: Some(generation_with_id(2, GenerationStatus::Verified)),
5556 rollback: None,
5557 next_id: 3,
5558 },
5559 );
5560
5561 let (status, body) = activate_generation(Path("weft-claw".into()), State(state))
5562 .await
5563 .expect_err("activation should fail on immutable generation lock conflict");
5564
5565 assert_eq!(status, HttpStatusCode::CONFLICT);
5566 let payload = body.0;
5567 assert_eq!(payload["status"], "activation_failed");
5568 assert_eq!(payload["reason"], "generation_lock_conflict");
5569 assert_eq!(payload["lock_written"], false);
5570 assert_eq!(payload["pointer_written"], false);
5571 assert_eq!(payload["index_written"], false);
5572 assert_eq!(payload["generation_lock_error"]["type"], "content_conflict");
5573
5574 let root_lock_contents =
5575 fs::read_to_string(instance_dir.join("lock.toml")).expect("root lock still readable");
5576 assert_eq!(root_lock_contents, "app = \"stale-root\"\n");
5577 assert_eq!(
5578 crate::app::read_active_generation_pointer(&instance_dir)
5579 .expect("active pointer read succeeds"),
5580 None
5581 );
5582 assert_eq!(
5583 crate::app::read_previous_generation_pointer(&instance_dir)
5584 .expect("previous pointer read succeeds"),
5585 None
5586 );
5587 assert!(crate::app::load_generation_index(&instance_dir)
5588 .expect("index load succeeds")
5589 .is_none());
5590 }
5591
5592 #[tokio::test]
5593 async fn rollback_generation_persists_repairable_generation_index_after_success() {
5594 let root = tempdir().expect("temp dir");
5595 let repo_root = root.path().to_path_buf();
5596 let instance_dir = repo_root.join(".weft").join("weft-claw");
5597 fs::create_dir_all(&instance_dir).expect("instance dir created");
5598 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5599 .expect("lock file seeded");
5600
5601 let state = state_with_generation_store(
5602 repo_root,
5603 test_resolved_app("weft-claw", Some(&instance_dir)),
5604 AppGenerationStore {
5605 active: Some(generation_with_id(2, GenerationStatus::Active)),
5606 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5607 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5608 next_id: 4,
5609 },
5610 );
5611
5612 let response = rollback_generation(Path("weft-claw".into()), State(state))
5613 .await
5614 .expect("rollback succeeds");
5615
5616 let payload = response.0;
5617 assert_eq!(payload["status"], "rolled_back");
5618 assert_eq!(payload["lock_written"], true);
5619 assert_eq!(payload["pointer_written"], true);
5620 assert!(payload["pointer_error"].is_null());
5621 assert_eq!(payload["index_written"], true);
5622 assert!(payload["index_error"].is_null());
5623
5624 assert_eq!(
5625 crate::app::read_previous_generation_pointer(&instance_dir)
5626 .expect("previous pointer read succeeds"),
5627 Some(2)
5628 );
5629 assert_eq!(
5630 crate::app::read_active_generation_pointer(&instance_dir)
5631 .expect("active pointer read succeeds"),
5632 Some(1)
5633 );
5634
5635 let index = crate::app::load_generation_index(&instance_dir)
5636 .expect("index load succeeds")
5637 .expect("index present");
5638 assert_eq!(index.active, Some(1));
5639 assert_eq!(index.previous, None);
5640 assert_eq!(index.candidate, Some(2));
5641 assert_eq!(index.next_id, 4);
5642 assert_eq!(index.generations.len(), 2);
5643 }
5644
5645 #[tokio::test]
5646 async fn rollback_generation_replays_immutable_lock_into_root_mirror() {
5647 let root = tempdir().expect("temp dir");
5648 let repo_root = root.path().to_path_buf();
5649 let instance_dir = repo_root.join(".weft").join("weft-claw");
5650 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
5651 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5652 .expect("lock file seeded");
5653 write_text_file(
5654 &instance_dir.join("generations").join("1.lock.toml"),
5655 r#"lock_version = 2
5656app = 'weft-claw'
5657generation = 1
5658status = 'verified'
5659profile = 'developer'
5660scene = 'rollback-scene'
5661scene_digest = 'sha256:scene-1'
5662binding_set_id = 'binding-set:sha256:1'
5663closure_id = 'closure:sha256:1'
5664closure_digest = 'sha256:closure-1'
5665trust_level = 'verified'
5666
5667[assembly]
5668enabled_features = ['immutable-feature']
5669selected_packages = ['immutable-package']
5670scene = 'rollback-scene'
5671scene_digest = 'sha256:scene-1'
5672binding_set_id = 'binding-set:sha256:1'
5673closure_id = 'closure:sha256:1'
5674closure_digest = 'sha256:closure-1'
5675
5676[notes]
5677message = 'immutable lock payload'
5678"#,
5679 );
5680
5681 let state = state_with_generation_store(
5682 repo_root,
5683 test_resolved_app("weft-claw", Some(&instance_dir)),
5684 AppGenerationStore {
5685 active: Some(generation_with_id(2, GenerationStatus::Active)),
5686 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5687 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5688 next_id: 4,
5689 },
5690 );
5691
5692 let response = rollback_generation(Path("weft-claw".into()), State(state))
5693 .await
5694 .expect("rollback succeeds");
5695
5696 let payload = response.0;
5697 assert_eq!(payload["status"], "rolled_back");
5698 assert_eq!(payload["generation_lock_replayed"], true);
5699 assert!(payload["generation_lock_warning"].is_null());
5700
5701 let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5702 .expect("root lock loads");
5703 assert_eq!(root_lock.generation, 1);
5704 assert_eq!(root_lock.status, "active");
5705 assert_eq!(root_lock.scene, "rollback-scene");
5706 assert_eq!(root_lock.scene_digest, "sha256:scene-1");
5707 assert_eq!(root_lock.binding_set_id, "binding-set:sha256:1");
5708 assert_eq!(root_lock.closure_id, "closure:sha256:1");
5709 assert_eq!(root_lock.closure_digest, "sha256:closure-1");
5710 assert_eq!(
5711 root_lock.assembly.enabled_features,
5712 vec!["immutable-feature"]
5713 );
5714 assert_eq!(
5715 root_lock.assembly.selected_packages,
5716 vec!["immutable-package".to_string()]
5717 );
5718 assert_eq!(root_lock.notes.message, "immutable lock payload");
5719 }
5720
5721 #[tokio::test]
5722 async fn rollback_generation_falls_back_when_immutable_lock_is_missing() {
5723 let root = tempdir().expect("temp dir");
5724 let repo_root = root.path().to_path_buf();
5725 let instance_dir = repo_root.join(".weft").join("weft-claw");
5726 fs::create_dir_all(&instance_dir).expect("instance dir created");
5727 write_text_file(
5728 &instance_dir.join("lock.toml"),
5729 r#"lock_version = 2
5730app = 'weft-claw'
5731generation = 2
5732status = 'active'
5733profile = 'developer'
5734scene = 'existing-scene'
5735scene_digest = 'sha256:existing-scene'
5736binding_set_id = 'binding-set:sha256:existing'
5737closure_id = 'closure:sha256:existing'
5738closure_digest = 'sha256:closure-existing'
5739
5740[assembly]
5741enabled_features = ['existing-feature']
5742selected_packages = ['existing-package']
5743scene = 'existing-scene'
5744scene_digest = 'sha256:existing-scene'
5745binding_set_id = 'binding-set:sha256:existing'
5746closure_id = 'closure:sha256:existing'
5747closure_digest = 'sha256:closure-existing'
5748
5749[notes]
5750message = 'existing root lock'
5751"#,
5752 );
5753
5754 let state = state_with_generation_store(
5755 repo_root,
5756 test_resolved_app("weft-claw", Some(&instance_dir)),
5757 AppGenerationStore {
5758 active: Some(generation_with_id(2, GenerationStatus::Active)),
5759 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5760 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5761 next_id: 4,
5762 },
5763 );
5764
5765 let response = rollback_generation(Path("weft-claw".into()), State(state))
5766 .await
5767 .expect("rollback succeeds with fallback");
5768
5769 let payload = response.0;
5770 assert_eq!(payload["status"], "rolled_back");
5771 assert_eq!(payload["generation_lock_replayed"], false);
5772 assert_eq!(
5773 payload["generation_lock_warning"]["type"],
5774 "immutable_lock_missing"
5775 );
5776
5777 let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
5778 .expect("root lock loads");
5779 assert_eq!(root_lock.generation, 1);
5780 assert_eq!(root_lock.status, "active");
5781 assert_eq!(root_lock.scene, "existing-scene");
5782 assert_eq!(root_lock.scene_digest, "sha256:existing-scene");
5783 assert_eq!(root_lock.binding_set_id, "binding-set:sha256:existing");
5784 assert_eq!(root_lock.closure_id, "closure:sha256:existing");
5785 assert_eq!(root_lock.closure_digest, "sha256:closure-existing");
5786 assert_eq!(root_lock.assembly.enabled_features, Vec::<String>::new());
5787 assert_eq!(
5788 root_lock.assembly.selected_packages,
5789 vec!["existing-package".to_string()]
5790 );
5791 assert_eq!(
5792 root_lock.notes.message,
5793 "Rolled back to generation 1 via API"
5794 );
5795 }
5796
5797 #[tokio::test]
5798 async fn rollback_generation_fails_before_writes_when_immutable_lock_generation_mismatches() {
5799 let root = tempdir().expect("temp dir");
5800 let repo_root = root.path().to_path_buf();
5801 let instance_dir = repo_root.join(".weft").join("weft-claw");
5802 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
5803 fs::write(instance_dir.join("lock.toml"), "app = \"stale-root\"\n")
5804 .expect("lock file seeded");
5805 write_text_file(
5806 &instance_dir.join("generations").join("1.lock.toml"),
5807 r#"lock_version = 2
5808app = 'weft-claw'
5809generation = 99
5810status = 'verified'
5811profile = 'developer'
5812scene = 'wrong-generation'
5813"#,
5814 );
5815
5816 let state = state_with_generation_store(
5817 repo_root,
5818 test_resolved_app("weft-claw", Some(&instance_dir)),
5819 AppGenerationStore {
5820 active: Some(generation_with_id(2, GenerationStatus::Active)),
5821 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5822 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5823 next_id: 4,
5824 },
5825 );
5826
5827 let (status, body) = rollback_generation(Path("weft-claw".into()), State(state))
5828 .await
5829 .expect_err("rollback should fail on mismatched immutable lock");
5830
5831 assert_eq!(status, HttpStatusCode::CONFLICT);
5832 let payload = body.0;
5833 assert_eq!(payload["reason"], "generation_lock_replay_failed");
5834 assert_eq!(payload["status"], "rollback_failed");
5835 assert_eq!(payload["generation_lock_replayed"], false);
5836 assert_eq!(
5837 payload["generation_lock_error"]["type"],
5838 "generation_mismatch"
5839 );
5840 assert_eq!(payload["lock_written"], false);
5841 assert_eq!(payload["pointer_written"], false);
5842 assert_eq!(payload["index_written"], false);
5843
5844 let root_lock_contents =
5845 fs::read_to_string(instance_dir.join("lock.toml")).expect("root lock still readable");
5846 assert_eq!(root_lock_contents, "app = \"stale-root\"\n");
5847 assert_eq!(
5848 crate::app::read_active_generation_pointer(&instance_dir)
5849 .expect("active pointer read succeeds"),
5850 None
5851 );
5852 assert_eq!(
5853 crate::app::read_previous_generation_pointer(&instance_dir)
5854 .expect("previous pointer read succeeds"),
5855 None
5856 );
5857 assert!(crate::app::load_generation_index(&instance_dir)
5858 .expect("index load succeeds")
5859 .is_none());
5860 }
5861
5862 #[tokio::test]
5863 async fn rollback_generation_fails_when_previous_pointer_write_fails() {
5864 let root = tempdir().expect("temp dir");
5865 let repo_root = root.path().to_path_buf();
5866 let instance_dir = repo_root.join(".weft").join("weft-claw");
5867 fs::create_dir_all(&instance_dir).expect("instance dir created");
5868 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5869 .expect("lock file seeded");
5870 fs::write(previous_generation_pointer_path(&instance_dir), "0\n")
5871 .expect("previous pointer seeded");
5872 fs::create_dir_all(instance_dir.join("previous.bak"))
5873 .expect("backup dir created to force previous pointer write failure");
5874
5875 let state = state_with_generation_store(
5876 repo_root,
5877 test_resolved_app("weft-claw", Some(&instance_dir)),
5878 AppGenerationStore {
5879 active: Some(generation_with_id(2, GenerationStatus::Active)),
5880 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5881 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5882 next_id: 4,
5883 },
5884 );
5885
5886 let (status, body) = rollback_generation(Path("weft-claw".into()), State(state))
5887 .await
5888 .expect_err("rollback should fail when previous pointer write fails");
5889
5890 assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5891 let payload = body.0;
5892 assert_eq!(payload["status"], "rollback_failed");
5893 assert_eq!(payload["reason"], "pointer_write_failed");
5894 assert_eq!(payload["operation"], "rollback");
5895 assert_eq!(payload["lock_written"], true);
5896 assert_eq!(payload["pointer_written"], false);
5897 assert_eq!(payload["pointer_error"]["stage"], "previous");
5898 assert_eq!(payload["pointer_error"]["repair_needed"], false);
5899 assert_eq!(payload["index_written"], false);
5900 assert!(payload["index_error"].is_null());
5901
5902 assert_eq!(
5903 crate::app::read_previous_generation_pointer(&instance_dir)
5904 .expect("previous pointer read succeeds"),
5905 Some(0)
5906 );
5907 assert_eq!(
5908 crate::app::read_active_generation_pointer(&instance_dir)
5909 .expect("active pointer read succeeds"),
5910 None
5911 );
5912 assert!(crate::app::load_generation_index(&instance_dir)
5913 .expect("index load succeeds")
5914 .is_none());
5915 }
5916
5917 #[tokio::test]
5918 async fn rollback_generation_fails_when_active_pointer_write_fails_and_skips_index() {
5919 let root = tempdir().expect("temp dir");
5920 let repo_root = root.path().to_path_buf();
5921 let instance_dir = repo_root.join(".weft").join("weft-claw");
5922 fs::create_dir_all(&instance_dir).expect("instance dir created");
5923 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
5924 .expect("lock file seeded");
5925 fs::write(active_generation_pointer_path(&instance_dir), "2\n")
5926 .expect("active pointer seeded");
5927 fs::create_dir_all(instance_dir.join("active.bak"))
5928 .expect("backup dir created to force active pointer write failure");
5929
5930 let state = state_with_generation_store(
5931 repo_root,
5932 test_resolved_app("weft-claw", Some(&instance_dir)),
5933 AppGenerationStore {
5934 active: Some(generation_with_id(2, GenerationStatus::Active)),
5935 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
5936 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
5937 next_id: 4,
5938 },
5939 );
5940
5941 let (status, body) = rollback_generation(Path("weft-claw".into()), State(state))
5942 .await
5943 .expect_err("rollback should fail when active pointer write fails");
5944
5945 assert_eq!(status, HttpStatusCode::INTERNAL_SERVER_ERROR);
5946 let payload = body.0;
5947 assert_eq!(payload["status"], "rollback_failed");
5948 assert_eq!(payload["reason"], "pointer_write_failed");
5949 assert_eq!(payload["operation"], "rollback");
5950 assert_eq!(payload["lock_written"], true);
5951 assert_eq!(payload["pointer_written"], false);
5952 assert_eq!(payload["pointer_error"]["stage"], "active");
5953 assert_eq!(payload["pointer_error"]["repair_needed"], true);
5954 assert_eq!(payload["index_written"], false);
5955 assert!(payload["index_error"].is_null());
5956
5957 assert_eq!(
5958 crate::app::read_previous_generation_pointer(&instance_dir)
5959 .expect("previous pointer read succeeds"),
5960 Some(2)
5961 );
5962 assert_eq!(
5963 crate::app::read_active_generation_pointer(&instance_dir)
5964 .expect("active pointer read succeeds"),
5965 Some(2)
5966 );
5967 assert!(crate::app::load_generation_index(&instance_dir)
5968 .expect("index load succeeds")
5969 .is_none());
5970 }
5971
5972 #[tokio::test]
5973 async fn activate_existing_generation_switches_to_verified_rollback_generation_and_replays_lock(
5974 ) {
5975 let root = tempdir().expect("temp dir");
5976 let repo_root = root.path().to_path_buf();
5977 let instance_dir = repo_root.join(".weft").join("weft-claw");
5978 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
5979 write_text_file(
5980 &instance_dir.join("lock.toml"),
5981 r#"lock_version = 2
5982app = 'weft-claw'
5983generation = 2
5984status = 'active'
5985profile = 'developer'
5986scene = 'current-scene'
5987scene_digest = 'sha256:current-scene'
5988binding_set_id = 'binding-set:sha256:current'
5989closure_id = 'closure:sha256:current'
5990closure_digest = 'sha256:closure-current'
5991
5992[assembly]
5993enabled_features = ['current-feature']
5994selected_packages = ['current-package']
5995scene = 'current-scene'
5996scene_digest = 'sha256:current-scene'
5997binding_set_id = 'binding-set:sha256:current'
5998closure_id = 'closure:sha256:current'
5999closure_digest = 'sha256:closure-current'
6000
6001[notes]
6002message = 'current active root lock'
6003"#,
6004 );
6005 write_text_file(
6006 &instance_dir.join("generations").join("1.lock.toml"),
6007 r#"lock_version = 2
6008app = 'weft-claw'
6009generation = 1
6010status = 'verified'
6011profile = 'developer'
6012scene = 'rollback-scene'
6013scene_digest = 'sha256:scene-1'
6014binding_set_id = 'binding-set:sha256:1'
6015closure_id = 'closure:sha256:1'
6016closure_digest = 'sha256:closure-1'
6017trust_level = 'verified'
6018
6019[assembly]
6020enabled_features = ['immutable-feature']
6021selected_packages = ['immutable-package']
6022scene = 'rollback-scene'
6023scene_digest = 'sha256:scene-1'
6024binding_set_id = 'binding-set:sha256:1'
6025closure_id = 'closure:sha256:1'
6026closure_digest = 'sha256:closure-1'
6027
6028[notes]
6029message = 'immutable rollback payload'
6030"#,
6031 );
6032
6033 let state = state_with_generation_store(
6034 repo_root,
6035 test_resolved_app("weft-claw", Some(&instance_dir)),
6036 AppGenerationStore {
6037 active: Some(generation_with_id(2, GenerationStatus::Active)),
6038 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6039 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6040 next_id: 4,
6041 },
6042 );
6043
6044 let response = activate_existing_generation(Path(("weft-claw".into(), 1)), State(state))
6045 .await
6046 .expect("switch succeeds");
6047
6048 let payload = response.0;
6049 assert_eq!(payload["status"], "activated");
6050 assert_eq!(payload["generation"]["id"], 1);
6051 assert_eq!(payload["lock_written"], true);
6052 assert_eq!(payload["generation_lock_replayed"], true);
6053 assert!(payload["generation_lock_warning"].is_null());
6054 assert_eq!(payload["pointer_written"], true);
6055 assert_eq!(payload["index_written"], true);
6056
6057 let root_lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
6058 .expect("root lock loads");
6059 assert_eq!(root_lock.generation, 1);
6060 assert_eq!(root_lock.status, "active");
6061 assert_eq!(root_lock.scene, "rollback-scene");
6062 assert_eq!(root_lock.notes.message, "immutable rollback payload");
6063 assert_eq!(
6064 crate::app::read_previous_generation_pointer(&instance_dir)
6065 .expect("previous pointer read succeeds"),
6066 Some(2)
6067 );
6068 assert_eq!(
6069 crate::app::read_active_generation_pointer(&instance_dir)
6070 .expect("active pointer read succeeds"),
6071 Some(1)
6072 );
6073
6074 let index = crate::app::load_generation_index(&instance_dir)
6075 .expect("index load succeeds")
6076 .expect("index present");
6077 assert_eq!(index.active, Some(1));
6078 assert_eq!(index.previous, Some(2));
6079 assert_eq!(index.candidate, Some(3));
6080 }
6081
6082 #[tokio::test]
6083 async fn activate_existing_generation_fails_when_target_is_missing() {
6084 let root = tempdir().expect("temp dir");
6085 let repo_root = root.path().to_path_buf();
6086 let instance_dir = repo_root.join(".weft").join("weft-claw");
6087 fs::create_dir_all(&instance_dir).expect("instance dir created");
6088 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
6089 .expect("lock file seeded");
6090
6091 let state = state_with_generation_store(
6092 repo_root,
6093 test_resolved_app("weft-claw", Some(&instance_dir)),
6094 AppGenerationStore {
6095 active: Some(generation_with_id(2, GenerationStatus::Active)),
6096 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6097 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6098 next_id: 4,
6099 },
6100 );
6101
6102 let (status, body) =
6103 activate_existing_generation(Path(("weft-claw".into(), 99)), State(state))
6104 .await
6105 .expect_err("missing generation should fail");
6106
6107 assert_eq!(status, HttpStatusCode::NOT_FOUND);
6108 let payload = body.0;
6109 assert_eq!(payload["reason"], "generation_not_found");
6110 assert_eq!(payload["status"], "activation_failed");
6111 assert_eq!(payload["generation"], 99);
6112 assert_eq!(payload["lock_written"], false);
6113 assert_eq!(payload["pointer_written"], false);
6114 assert_eq!(payload["index_written"], false);
6115 }
6116
6117 #[tokio::test]
6118 async fn get_generation_diff_returns_summary_diff_for_active_and_rollback() {
6119 let root = tempdir().expect("temp dir");
6120 let repo_root = root.path().to_path_buf();
6121 let instance_dir = repo_root.join(".weft").join("weft-claw");
6122 fs::create_dir_all(&instance_dir).expect("instance dir created");
6123
6124 let rollback = generation_with_diff_fields(
6125 1,
6126 GenerationStatus::Rollback,
6127 "stable",
6128 "safe",
6129 vec!["feature-a"],
6130 vec!["cap.a"],
6131 vec![test_binding("cap.a", "provider-a")],
6132 );
6133 let active = generation_with_diff_fields(
6134 2,
6135 GenerationStatus::Active,
6136 "team",
6137 "developer",
6138 vec!["feature-a", "feature-b"],
6139 vec!["cap.a", "cap.b"],
6140 vec![
6141 test_binding("cap.a", "provider-b"),
6142 test_binding("cap.b", "provider-c"),
6143 ],
6144 );
6145
6146 let response = get_generation_diff(
6147 Path(("weft-claw".into(), 2, 1)),
6148 State(state_with_generation_store(
6149 repo_root,
6150 test_resolved_app("weft-claw", Some(&instance_dir)),
6151 AppGenerationStore {
6152 active: Some(active),
6153 candidate: None,
6154 rollback: Some(rollback),
6155 next_id: 3,
6156 },
6157 )),
6158 )
6159 .await
6160 .expect("diff succeeds")
6161 .0;
6162
6163 assert_eq!(response["app"], "weft-claw");
6164 assert_eq!(response["from"]["id"], 2);
6165 assert_eq!(response["to"]["id"], 1);
6166 assert_eq!(response["summary_diff"]["status"]["from"], "active");
6167 assert_eq!(response["summary_diff"]["status"]["to"], "rollback");
6168 assert_eq!(response["summary_diff"]["status"]["changed"], true);
6169 assert_eq!(response["summary_diff"]["scene"]["from"], "team");
6170 assert_eq!(response["summary_diff"]["scene"]["to"], "stable");
6171 assert_eq!(
6172 response["summary_diff"]["enabled_features"]["removed"],
6173 serde_json::json!(["feature-b"])
6174 );
6175 assert_eq!(
6176 response["summary_diff"]["capabilities"]["removed"],
6177 serde_json::json!(["cap.b"])
6178 );
6179 assert_eq!(response["summary_diff"]["bindings"]["changed"], true);
6180 assert_eq!(
6181 response["summary_diff"]["bindings"]["removed"][0]["capability"],
6182 "cap.b"
6183 );
6184 assert_eq!(
6185 response["summary_diff"]["bindings"]["changed_entries"][0]["capability"],
6186 "cap.a"
6187 );
6188 assert_eq!(response["immutable_lock"]["available"], false);
6189 assert!(!response["warnings"]
6190 .as_array()
6191 .expect("warnings array")
6192 .is_empty());
6193 }
6194
6195 #[tokio::test]
6196 async fn get_generation_diff_returns_404_for_missing_generation() {
6197 let root = tempdir().expect("temp dir");
6198 let repo_root = root.path().to_path_buf();
6199 let instance_dir = repo_root.join(".weft").join("weft-claw");
6200 fs::create_dir_all(&instance_dir).expect("instance dir created");
6201
6202 let (status, body) = get_generation_diff(
6203 Path(("weft-claw".into(), 2, 99)),
6204 State(state_with_generation_store(
6205 repo_root,
6206 test_resolved_app("weft-claw", Some(&instance_dir)),
6207 AppGenerationStore {
6208 active: Some(generation_with_id(2, GenerationStatus::Active)),
6209 candidate: None,
6210 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6211 next_id: 3,
6212 },
6213 )),
6214 )
6215 .await
6216 .expect_err("missing generation should fail");
6217
6218 assert_eq!(status, HttpStatusCode::NOT_FOUND);
6219 assert_eq!(body.0["reason"], "generation_not_found");
6220 assert_eq!(body.0["generation"], 99);
6221 assert_eq!(body.0["role"], "to");
6222 }
6223
6224 #[tokio::test]
6225 async fn get_generation_diff_includes_immutable_lock_package_and_binding_diff() {
6226 let root = tempdir().expect("temp dir");
6227 let repo_root = root.path().to_path_buf();
6228 let instance_dir = repo_root.join(".weft").join("weft-claw");
6229 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6230
6231 write_generation_lock_for_diff(
6232 &instance_dir,
6233 1,
6234 "stable",
6235 "safe",
6236 "closure:sha256:1",
6237 &["package-a"],
6238 &[("cap.a", "provider-a")],
6239 );
6240 write_generation_lock_for_diff(
6241 &instance_dir,
6242 2,
6243 "team",
6244 "developer",
6245 "closure:sha256:2",
6246 &["package-a", "package-b"],
6247 &[("cap.a", "provider-b"), ("cap.b", "provider-c")],
6248 );
6249
6250 let response = get_generation_diff(
6251 Path(("weft-claw".into(), 1, 2)),
6252 State(state_with_generation_store(
6253 repo_root,
6254 test_resolved_app("weft-claw", Some(&instance_dir)),
6255 AppGenerationStore {
6256 active: Some(generation_with_id(2, GenerationStatus::Active)),
6257 candidate: None,
6258 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6259 next_id: 3,
6260 },
6261 )),
6262 )
6263 .await
6264 .expect("diff succeeds")
6265 .0;
6266
6267 assert_eq!(response["immutable_lock"]["available"], true);
6268 assert_eq!(
6269 response["immutable_lock"]["diff"]["packages"]["added"],
6270 serde_json::json!(["package-b"])
6271 );
6272 assert_eq!(
6273 response["immutable_lock"]["diff"]["bindings"]["added"][0]["capability"],
6274 "cap.b"
6275 );
6276 assert_eq!(
6277 response["immutable_lock"]["diff"]["bindings"]["changed_entries"][0]["capability"],
6278 "cap.a"
6279 );
6280 assert_eq!(response["immutable_lock"]["diff"]["scene"]["changed"], true);
6281 assert_eq!(
6282 response["immutable_lock"]["diff"]["profile"]["changed"],
6283 true
6284 );
6285 assert!(response["warnings"]
6286 .as_array()
6287 .expect("warnings array")
6288 .is_empty());
6289 }
6290
6291 #[tokio::test]
6292 async fn get_generation_diff_returns_summary_diff_with_warning_when_immutable_lock_missing() {
6293 let root = tempdir().expect("temp dir");
6294 let repo_root = root.path().to_path_buf();
6295 let instance_dir = repo_root.join(".weft").join("weft-claw");
6296 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6297
6298 write_generation_lock_for_diff(
6299 &instance_dir,
6300 1,
6301 "stable",
6302 "safe",
6303 "closure:sha256:1",
6304 &["package-a"],
6305 &[("cap.a", "provider-a")],
6306 );
6307
6308 let response = get_generation_diff(
6309 Path(("weft-claw".into(), 1, 2)),
6310 State(state_with_generation_store(
6311 repo_root,
6312 test_resolved_app("weft-claw", Some(&instance_dir)),
6313 AppGenerationStore {
6314 active: Some(generation_with_id(2, GenerationStatus::Active)),
6315 candidate: None,
6316 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6317 next_id: 3,
6318 },
6319 )),
6320 )
6321 .await
6322 .expect("diff succeeds")
6323 .0;
6324
6325 assert_eq!(response["summary_diff"]["id"]["changed"], true);
6326 assert_eq!(response["immutable_lock"]["available"], false);
6327 assert!(response["immutable_lock"]["diff"].is_null());
6328 let warnings = response["warnings"].as_array().expect("warnings array");
6329 assert_eq!(warnings.len(), 1);
6330 assert_eq!(warnings[0]["warning_type"], "immutable_lock_missing");
6331 assert_eq!(warnings[0]["generation"], 2);
6332 }
6333
6334 #[tokio::test]
6335 async fn get_generation_diff_route_is_wired() {
6336 let root = tempdir().expect("temp dir");
6337 let repo_root = root.path().to_path_buf();
6338 let instance_dir = repo_root.join(".weft").join("weft-claw");
6339 fs::create_dir_all(&instance_dir).expect("instance dir created");
6340
6341 let router = build_router(state_with_generation_store(
6342 repo_root,
6343 test_resolved_app("weft-claw", Some(&instance_dir)),
6344 AppGenerationStore {
6345 active: Some(generation_with_id(2, GenerationStatus::Active)),
6346 candidate: None,
6347 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6348 next_id: 3,
6349 },
6350 ));
6351
6352 let response = router
6353 .oneshot(
6354 Request::builder()
6355 .uri("/api/apps/weft-claw/generations/2/diff/1")
6356 .method("GET")
6357 .body(Body::empty())
6358 .expect("request built"),
6359 )
6360 .await
6361 .expect("router response");
6362
6363 assert_eq!(response.status(), HttpStatusCode::OK);
6364 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6365 .await
6366 .expect("body bytes");
6367 let payload: serde_json::Value = serde_json::from_slice(&body).expect("json payload");
6368 assert_eq!(payload["from"]["id"], 2);
6369 assert_eq!(payload["to"]["id"], 1);
6370 }
6371
6372 #[tokio::test]
6373 async fn activate_existing_generation_fails_when_target_is_not_verified() {
6374 let root = tempdir().expect("temp dir");
6375 let repo_root = root.path().to_path_buf();
6376 let instance_dir = repo_root.join(".weft").join("weft-claw");
6377 fs::create_dir_all(&instance_dir).expect("instance dir created");
6378 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
6379 .expect("lock file seeded");
6380
6381 let state = state_with_generation_store(
6382 repo_root,
6383 test_resolved_app("weft-claw", Some(&instance_dir)),
6384 AppGenerationStore {
6385 active: Some(generation_with_id(2, GenerationStatus::Active)),
6386 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6387 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6388 next_id: 4,
6389 },
6390 );
6391
6392 let (status, body) =
6393 activate_existing_generation(Path(("weft-claw".into(), 3)), State(state))
6394 .await
6395 .expect_err("candidate generation should fail");
6396
6397 assert_eq!(status, HttpStatusCode::CONFLICT);
6398 let payload = body.0;
6399 assert_eq!(payload["reason"], "generation_not_verified");
6400 assert_eq!(payload["status"], "activation_failed");
6401 assert_eq!(payload["generation"], 3);
6402 assert_eq!(payload["lock_written"], false);
6403 assert_eq!(payload["pointer_written"], false);
6404 assert_eq!(payload["index_written"], false);
6405 }
6406
6407 #[tokio::test]
6408 async fn activate_existing_generation_fails_before_pointer_and_index_writes_when_immutable_lock_mismatches(
6409 ) {
6410 let root = tempdir().expect("temp dir");
6411 let repo_root = root.path().to_path_buf();
6412 let instance_dir = repo_root.join(".weft").join("weft-claw");
6413 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6414 fs::write(instance_dir.join("lock.toml"), "app = \"stale-root\"\n")
6415 .expect("lock file seeded");
6416 write_text_file(
6417 &instance_dir.join("generations").join("1.lock.toml"),
6418 r#"lock_version = 2
6419app = 'weft-claw'
6420generation = 99
6421status = 'verified'
6422profile = 'developer'
6423scene = 'wrong-generation'
6424"#,
6425 );
6426
6427 let state = state_with_generation_store(
6428 repo_root,
6429 test_resolved_app("weft-claw", Some(&instance_dir)),
6430 AppGenerationStore {
6431 active: Some(generation_with_id(2, GenerationStatus::Active)),
6432 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6433 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6434 next_id: 4,
6435 },
6436 );
6437
6438 let (status, body) =
6439 activate_existing_generation(Path(("weft-claw".into(), 1)), State(state))
6440 .await
6441 .expect_err("mismatched immutable lock should fail");
6442
6443 assert_eq!(status, HttpStatusCode::CONFLICT);
6444 let payload = body.0;
6445 assert_eq!(payload["reason"], "generation_lock_replay_failed");
6446 assert_eq!(payload["status"], "activation_failed");
6447 assert_eq!(payload["generation_lock_replayed"], false);
6448 assert_eq!(
6449 payload["generation_lock_error"]["type"],
6450 "generation_mismatch"
6451 );
6452 assert_eq!(payload["lock_written"], false);
6453 assert_eq!(payload["pointer_written"], false);
6454 assert_eq!(payload["index_written"], false);
6455
6456 let root_lock_contents =
6457 fs::read_to_string(instance_dir.join("lock.toml")).expect("root lock still readable");
6458 assert_eq!(root_lock_contents, "app = \"stale-root\"\n");
6459 assert_eq!(
6460 crate::app::read_active_generation_pointer(&instance_dir)
6461 .expect("active pointer read succeeds"),
6462 None
6463 );
6464 assert_eq!(
6465 crate::app::read_previous_generation_pointer(&instance_dir)
6466 .expect("previous pointer read succeeds"),
6467 None
6468 );
6469 assert!(crate::app::load_generation_index(&instance_dir)
6470 .expect("index load succeeds")
6471 .is_none());
6472 }
6473
6474 #[tokio::test]
6475 async fn activate_existing_generation_blocks_safe_profile_when_verified_store_object_is_missing(
6476 ) {
6477 let root = tempdir().expect("temp dir");
6478 let repo_root = root.path().to_path_buf();
6479 let instance_dir = repo_root.join(".weft").join("weft-claw");
6480 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6481 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
6482 .expect("lock file seeded");
6483 write_text_file(
6484 &instance_dir.join("generations").join("1.lock.toml"),
6485 r#"lock_version = 2
6486app = 'weft-claw'
6487generation = 1
6488status = 'verified'
6489profile = 'safe'
6490scene = 'safe-scene'
6491
6492[[packages]]
6493name = 'agent-runtime'
6494version = '0.1.0'
6495store_object_id = 'store:sha512:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
6496store_path = '.weft/store/missing-agent-runtime-0.1.0'
6497"#,
6498 );
6499
6500 let state = state_with_generation_store(
6501 repo_root,
6502 test_resolved_app("weft-claw", Some(&instance_dir)),
6503 AppGenerationStore {
6504 active: Some(generation_with_id(2, GenerationStatus::Active)),
6505 candidate: None,
6506 rollback: Some(generation_with_validation_results(
6507 1,
6508 GenerationStatus::Rollback,
6509 "safe",
6510 vec![crate::app::ValidationResult {
6511 check: "store-policy:agent-runtime".into(),
6512 passed: true,
6513 message: "Store metadata captured during prior verification".into(),
6514 }],
6515 )),
6516 next_id: 3,
6517 },
6518 );
6519
6520 let (status, body) =
6521 activate_existing_generation(Path(("weft-claw".into(), 1)), State(state))
6522 .await
6523 .expect_err("missing verified store object should block safe activation");
6524
6525 assert_eq!(status, HttpStatusCode::CONFLICT);
6526 let payload = body.0;
6527 assert_eq!(payload["reason"], "store_object_missing");
6528 assert_eq!(payload["status"], "activation_failed");
6529 assert_eq!(payload["lock_written"], false);
6530 assert_eq!(payload["pointer_written"], false);
6531 assert_eq!(payload["index_written"], false);
6532 assert_eq!(
6533 payload["store_validation"]["check"],
6534 "store-policy:agent-runtime"
6535 );
6536 assert!(payload["store_validation"]["message"]
6537 .as_str()
6538 .unwrap_or_default()
6539 .contains("path is missing"));
6540 }
6541
6542 #[tokio::test]
6543 async fn activate_existing_generation_route_is_wired() {
6544 let root = tempdir().expect("temp dir");
6545 let repo_root = root.path().to_path_buf();
6546 let instance_dir = repo_root.join(".weft").join("weft-claw");
6547 fs::create_dir_all(instance_dir.join("generations")).expect("instance dir created");
6548 write_text_file(
6549 &instance_dir.join("lock.toml"),
6550 r#"lock_version = 2
6551app = 'weft-claw'
6552generation = 2
6553status = 'active'
6554profile = 'developer'
6555scene = 'current-scene'
6556scene_digest = 'sha256:current-scene'
6557binding_set_id = 'binding-set:sha256:current'
6558closure_id = 'closure:sha256:current'
6559closure_digest = 'sha256:closure-current'
6560
6561[assembly]
6562enabled_features = ['current-feature']
6563selected_packages = ['current-package']
6564scene = 'current-scene'
6565scene_digest = 'sha256:current-scene'
6566binding_set_id = 'binding-set:sha256:current'
6567closure_id = 'closure:sha256:current'
6568closure_digest = 'sha256:closure-current'
6569
6570[notes]
6571message = 'current active root lock'
6572"#,
6573 );
6574 write_text_file(
6575 &instance_dir.join("generations").join("1.lock.toml"),
6576 r#"lock_version = 2
6577app = 'weft-claw'
6578generation = 1
6579status = 'verified'
6580profile = 'developer'
6581scene = 'rollback-scene'
6582scene_digest = 'sha256:scene-1'
6583binding_set_id = 'binding-set:sha256:1'
6584closure_id = 'closure:sha256:1'
6585closure_digest = 'sha256:closure-1'
6586
6587[assembly]
6588enabled_features = ['immutable-feature']
6589selected_packages = ['immutable-package']
6590scene = 'rollback-scene'
6591scene_digest = 'sha256:scene-1'
6592binding_set_id = 'binding-set:sha256:1'
6593closure_id = 'closure:sha256:1'
6594closure_digest = 'sha256:closure-1'
6595
6596[notes]
6597message = 'immutable rollback payload'
6598"#,
6599 );
6600
6601 let state = state_with_generation_store(
6602 repo_root,
6603 test_resolved_app("weft-claw", Some(&instance_dir)),
6604 AppGenerationStore {
6605 active: Some(generation_with_id(2, GenerationStatus::Active)),
6606 candidate: Some(generation_with_id(3, GenerationStatus::Candidate)),
6607 rollback: Some(generation_with_id(1, GenerationStatus::Rollback)),
6608 next_id: 4,
6609 },
6610 );
6611 let app = build_router(state);
6612
6613 let response = app
6614 .oneshot(
6615 Request::builder()
6616 .method("POST")
6617 .uri("/api/apps/weft-claw/generations/1/activate")
6618 .body(Body::empty())
6619 .expect("request built"),
6620 )
6621 .await
6622 .expect("router response");
6623
6624 assert_eq!(response.status(), HttpStatusCode::OK);
6625 }
6626
6627 #[tokio::test]
6628 async fn generation_diagnostics_returns_store_summary_without_instance_sources() {
6629 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
6630 let mut apps = ResolvedAppMap::new();
6631 apps.insert("weft-claw".into(), test_resolved_app("weft-claw", None));
6632
6633 let state = AppState {
6634 resolved_apps: Arc::new(RwLock::new(apps)),
6635 ..test_state(repo_root, CapabilityRegistry::new())
6636 };
6637
6638 let diagnostics = generation_diagnostics_snapshot(&state, "weft-claw")
6639 .await
6640 .expect("diagnostics snapshot");
6641
6642 assert_eq!(diagnostics.app, "weft-claw");
6643 assert!(!diagnostics.store_present);
6644 assert_eq!(diagnostics.store_summary.next_id, 1);
6645 assert!(diagnostics.store_summary.generations.is_empty());
6646 assert!(diagnostics.instance.is_none());
6647 }
6648
6649 #[tokio::test]
6650 async fn generation_diagnostics_reports_pointer_mismatch_from_instance_files() {
6651 let root = tempdir().expect("temp dir");
6652 let repo_root = root.path().to_path_buf();
6653 let instance_dir = repo_root.join(".weft").join("weft-claw");
6654 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
6655
6656 write_active_generation_pointer(&instance_dir, Some(20)).expect("active pointer written");
6657 write_previous_generation_pointer(&instance_dir, Some(19))
6658 .expect("previous pointer written");
6659 save_generation_index(
6660 &instance_dir,
6661 &AppGenerationIndex {
6662 schema_version: crate::app::GENERATION_INDEX_SCHEMA_VERSION,
6663 active: Some(21),
6664 previous: Some(19),
6665 candidate: None,
6666 next_id: 22,
6667 generations: vec![
6668 AppGeneration {
6669 id: 19,
6670 ..test_generation(
6671 "core.execution",
6672 "core",
6673 "developer",
6674 GenerationStatus::Rollback,
6675 )
6676 },
6677 AppGeneration {
6678 id: 20,
6679 ..test_generation(
6680 "core.execution",
6681 "core",
6682 "developer",
6683 GenerationStatus::Active,
6684 )
6685 },
6686 AppGeneration {
6687 id: 21,
6688 ..test_generation(
6689 "core.execution",
6690 "core",
6691 "developer",
6692 GenerationStatus::Verified,
6693 )
6694 },
6695 ],
6696 },
6697 )
6698 .expect("index saved");
6699
6700 let mut apps = ResolvedAppMap::new();
6701 apps.insert(
6702 "weft-claw".into(),
6703 test_resolved_app("weft-claw", Some(&instance_dir)),
6704 );
6705
6706 let mut stores = GenerationStoreMap::new();
6707 stores.insert(
6708 "weft-claw".into(),
6709 AppGenerationStore {
6710 active: Some(AppGeneration {
6711 id: 20,
6712 ..test_generation(
6713 "core.execution",
6714 "core",
6715 "developer",
6716 GenerationStatus::Active,
6717 )
6718 }),
6719 candidate: None,
6720 rollback: Some(AppGeneration {
6721 id: 19,
6722 ..test_generation(
6723 "core.execution",
6724 "core",
6725 "developer",
6726 GenerationStatus::Rollback,
6727 )
6728 }),
6729 next_id: 21,
6730 },
6731 );
6732
6733 let state = AppState {
6734 generation_store: Arc::new(RwLock::new(stores)),
6735 resolved_apps: Arc::new(RwLock::new(apps)),
6736 ..test_state(repo_root, CapabilityRegistry::new())
6737 };
6738
6739 let diagnostics = generation_diagnostics_snapshot(&state, "weft-claw")
6740 .await
6741 .expect("diagnostics snapshot");
6742 let instance = diagnostics.instance.expect("instance diagnostics present");
6743 let report = instance
6744 .consistency_report
6745 .expect("consistency report present");
6746
6747 assert!(diagnostics.store_present);
6748 assert_eq!(diagnostics.store_summary.active, Some(20));
6749 assert_eq!(instance.active_pointer.generation_id, Some(20));
6750 assert_eq!(instance.previous_pointer.generation_id, Some(19));
6751 assert!(instance.generation_index.present);
6752 assert!(report.repair_recommended);
6753 assert!(report
6754 .diagnostics
6755 .iter()
6756 .any(|diagnostic| diagnostic.code == "pointer_mismatch"
6757 && diagnostic.pointer.as_deref() == Some("active")));
6758 }
6759
6760 #[tokio::test]
6761 async fn generation_diagnostics_endpoint_returns_read_only_instance_health() {
6762 let root = tempdir().expect("temp dir");
6763 let repo_root = root.path().to_path_buf();
6764 let instance_dir = repo_root.join(".weft").join("weft-claw");
6765 std::fs::create_dir_all(&instance_dir).expect("instance dir created");
6766
6767 write_active_generation_pointer(&instance_dir, Some(7)).expect("active pointer written");
6768 write_previous_generation_pointer(&instance_dir, Some(6))
6769 .expect("previous pointer written");
6770 save_generation_index(
6771 &instance_dir,
6772 &AppGenerationIndex {
6773 schema_version: crate::app::GENERATION_INDEX_SCHEMA_VERSION,
6774 active: Some(7),
6775 previous: Some(6),
6776 candidate: None,
6777 next_id: 8,
6778 generations: vec![
6779 AppGeneration {
6780 id: 6,
6781 ..test_generation(
6782 "core.execution",
6783 "core",
6784 "developer",
6785 GenerationStatus::Rollback,
6786 )
6787 },
6788 AppGeneration {
6789 id: 7,
6790 lock_path: "generations/7.lock.toml".into(),
6791 scene: "stable".into(),
6792 binding_set_id: "binding-set:sha256:7".into(),
6793 closure_id: "closure:sha256:7".into(),
6794 created_by: "api".into(),
6795 lock_digest: "sha256:lock-7".into(),
6796 ..test_generation(
6797 "core.execution",
6798 "core",
6799 "developer",
6800 GenerationStatus::Active,
6801 )
6802 },
6803 ],
6804 },
6805 )
6806 .expect("index saved");
6807
6808 let mut apps = ResolvedAppMap::new();
6809 apps.insert(
6810 "weft-claw".into(),
6811 test_resolved_app("weft-claw", Some(&instance_dir)),
6812 );
6813
6814 let state = AppState {
6815 resolved_apps: Arc::new(RwLock::new(apps)),
6816 ..test_state(repo_root, CapabilityRegistry::new())
6817 };
6818 let app = build_router(state);
6819
6820 let response = app
6821 .oneshot(
6822 Request::builder()
6823 .uri("/api/apps/weft-claw/generations/diagnostics")
6824 .body(Body::empty())
6825 .unwrap(),
6826 )
6827 .await
6828 .expect("request handled");
6829
6830 assert_eq!(response.status(), HttpStatusCode::OK);
6831
6832 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
6833 .await
6834 .expect("body bytes");
6835 let payload: serde_json::Value = serde_json::from_slice(&body).expect("json payload");
6836
6837 assert_eq!(payload["app"], "weft-claw");
6838 assert_eq!(payload["store_present"], false);
6839 assert_eq!(payload["store_summary"]["next_id"], 1);
6840 assert_eq!(payload["instance"]["active_pointer"]["generation_id"], 7);
6841 assert_eq!(payload["instance"]["previous_pointer"]["generation_id"], 6);
6842 assert_eq!(payload["instance"]["generation_index"]["present"], true);
6843 assert_eq!(
6844 payload["instance"]["consistency_report"]["is_consistent"],
6845 true
6846 );
6847 assert_eq!(
6848 payload["instance"]["generation_index"]["index"]["active"],
6849 7
6850 );
6851 }
6852
6853 #[tokio::test]
6854 async fn live_probe_reports_core_execution_success() {
6855 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
6856 let mut registry = CapabilityRegistry::new();
6857 registry.insert(
6858 "core.execution".into(),
6859 CapabilityRegistryEntry {
6860 capability: "core.execution".into(),
6861 providers: vec![CapabilityProviderRecord {
6862 provider: "core".into(),
6863 runtime: "core".into(),
6864 priority: 0,
6865 }],
6866 bindings: vec![],
6867 },
6868 );
6869 let state = test_state(repo_root, registry);
6870
6871 let generation = test_generation(
6872 "core.execution",
6873 "core",
6874 "developer",
6875 GenerationStatus::Candidate,
6876 );
6877
6878 let results = live_probe_generation(&state, &generation).await;
6879 assert_eq!(results.len(), 1);
6880 assert_eq!(results[0].check, "probe:core.execution");
6881 assert!(results[0].passed);
6882 assert!(results[0]
6883 .message
6884 .contains("Provider responded to live core probe"));
6885 }
6886
6887 #[tokio::test]
6888 async fn live_probe_reports_provider_rejection_for_mismatched_digest_signature() {
6889 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
6890 .join("..")
6891 .join("target")
6892 .join("test-live-probe-signature-rejection");
6893 let provider = "cap-provider";
6894 let source = "fixtures/cap-provider";
6895 let source_dir = repo_root.join(source);
6896 std::fs::create_dir_all(&source_dir).expect("source directory created");
6897 std::fs::write(source_dir.join("package.toml"), "name = 'cap-provider'\n")
6898 .expect("marker file written");
6899
6900 let signing_key = SigningKey::from_bytes(&[11; 32]);
6901 let bad_message = signature_message(provider, "current", "deadbeef", source);
6902 let signature = sign_package_message(&signing_key, &bad_message);
6903 let source_public_key = signature
6904 .split(':')
6905 .nth(1)
6906 .expect("public key segment exists")
6907 .to_string();
6908
6909 let mut registry = CapabilityRegistry::new();
6910 registry.insert(
6911 "cap.test".into(),
6912 CapabilityRegistryEntry {
6913 capability: "cap.test".into(),
6914 providers: vec![CapabilityProviderRecord {
6915 provider: provider.into(),
6916 runtime: "service".into(),
6917 priority: 0,
6918 }],
6919 bindings: vec![],
6920 },
6921 );
6922
6923 let state = AppState {
6924 package_index: Arc::new(PackageIndex {
6925 version: 1,
6926 revision: "test-rev".into(),
6927 source_url: "local://packages".into(),
6928 package_sources: vec![PackageSource {
6929 name: provider.into(),
6930 kind: "service".into(),
6931 package_kind: String::new(),
6932 runtime_provider: provider.into(),
6933 current_source: source.into(),
6934 trusted: true,
6935 signature,
6936 source_authority: "test-authority".into(),
6937 source_public_keys: vec![source_public_key],
6938 provides: vec![],
6939 requires: vec![],
6940 }],
6941 }),
6942 ..test_state(repo_root, registry)
6943 };
6944
6945 let generation = test_generation("cap.test", provider, "safe", GenerationStatus::Candidate);
6946
6947 *state.active_profile.write().await = AppProfile::Safe;
6948
6949 let results = live_probe_generation(&state, &generation).await;
6950 assert_eq!(results.len(), 1);
6951 assert_eq!(results[0].check, "probe:cap.test");
6952 assert!(!results[0].passed);
6953 assert!(results[0]
6954 .message
6955 .contains("Live probe failed with 403 Forbidden"));
6956 assert!(results[0]
6957 .message
6958 .contains("is not accepted under profile 'safe'"));
6959 }
6960
6961 #[tokio::test]
6962 async fn live_probe_rejects_required_real_package_when_registry_marks_it_metadata() {
6963 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
6964 let mut registry = CapabilityRegistry::new();
6965 registry.insert(
6966 "prompt.system".into(),
6967 CapabilityRegistryEntry {
6968 capability: "prompt.system".into(),
6969 providers: vec![CapabilityProviderRecord {
6970 provider: "prompt-system".into(),
6971 runtime: "metadata".into(),
6972 priority: 0,
6973 }],
6974 bindings: vec![],
6975 },
6976 );
6977 let state = AppState {
6978 package_index: Arc::new(PackageIndex {
6979 version: 1,
6980 revision: "test-rev".into(),
6981 source_url: "local://packages".into(),
6982 package_sources: vec![PackageSource {
6983 name: "prompt-system".into(),
6984 kind: "metadata".into(),
6985 package_kind: "foundation".into(),
6986 runtime_provider: "prompt-system".into(),
6987 current_source: "packages/weft-claw".into(),
6988 trusted: true,
6989 signature: "builtin:product-package".into(),
6990 source_authority: "product-package-instance".into(),
6991 source_public_keys: vec![],
6992 provides: vec!["prompt.system".into()],
6993 requires: vec![],
6994 }],
6995 }),
6996 ..test_state(repo_root, registry)
6997 };
6998
6999 let generation = test_generation(
7000 "prompt.system",
7001 "prompt-system",
7002 "developer",
7003 GenerationStatus::Candidate,
7004 );
7005
7006 let results = live_probe_generation(&state, &generation).await;
7007 assert_eq!(results.len(), 1);
7008 assert_eq!(results[0].check, "probe:prompt.system");
7009 assert!(!results[0].passed);
7010 assert!(results[0]
7011 .message
7012 .contains("without an assembled real package source"));
7013 }
7014
7015 #[tokio::test]
7016 async fn verify_generation_rejects_safe_candidate_when_selected_package_digest_metadata_missing(
7017 ) {
7018 let root = tempdir().expect("temp dir");
7019 let repo_root = root.path().to_path_buf();
7020 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7021 &repo_root,
7022 Some("team"),
7023 r#"[scenes.team]
7024name = 'team'
7025profile = 'safe'
7026
7027[[scenes.team.package_pins]]
7028package = 'agent-runtime'
7029source = 'remote://candidate-without-digest'
7030"#,
7031 );
7032
7033 let app = ResolvedApp {
7034 name: "weft-claw".into(),
7035 version: "0.1.0".into(),
7036 capabilities: vec!["agent.runtime".into()],
7037 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7038 sources: ResolvedAppSources {
7039 manifest_path: manifest_path.display().to_string(),
7040 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7041 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7042 },
7043 ..ResolvedApp::default()
7044 };
7045 let candidate = test_generation(
7046 "agent.runtime",
7047 "agent-runtime",
7048 "safe",
7049 GenerationStatus::Candidate,
7050 );
7051 let mut registry = CapabilityRegistry::new();
7052 registry.insert(
7053 "agent.runtime".into(),
7054 CapabilityRegistryEntry {
7055 capability: "agent.runtime".into(),
7056 providers: vec![CapabilityProviderRecord {
7057 provider: "agent-runtime".into(),
7058 runtime: "metadata".into(),
7059 priority: 0,
7060 }],
7061 bindings: vec![],
7062 },
7063 );
7064 let state = state_for_verify_generation(
7065 repo_root,
7066 app,
7067 vec![test_package_source(
7068 "agent-runtime",
7069 "provider",
7070 vec!["agent.runtime"],
7071 vec![],
7072 )],
7073 registry,
7074 candidate,
7075 );
7076
7077 let (status, body) =
7078 super::verify_generation(Path("weft-claw".into()), State(state.clone()))
7079 .await
7080 .expect_err("safe verification should fail");
7081
7082 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7083 assert_eq!(body.0["reason"], "generation_verify_failed");
7084 let validations = body.0["candidate"]["validation_results"]
7085 .as_array()
7086 .expect("validation result array");
7087 assert!(validations.iter().any(|result| {
7088 result["check"] == "digest-policy:agent-runtime"
7089 && result["passed"] == false
7090 && result["message"]
7091 .as_str()
7092 .unwrap_or_default()
7093 .contains("missing usable sha512/artifact/content digest metadata")
7094 }));
7095 }
7096
7097 #[tokio::test]
7098 async fn verify_generation_rejects_trusted_candidate_when_selected_package_digest_metadata_missing(
7099 ) {
7100 let root = tempdir().expect("temp dir");
7101 let repo_root = root.path().to_path_buf();
7102 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7103 &repo_root,
7104 Some("team"),
7105 r#"[scenes.team]
7106name = 'team'
7107profile = 'trusted'
7108
7109[[scenes.team.package_pins]]
7110package = 'agent-runtime'
7111source = 'remote://candidate-without-digest'
7112"#,
7113 );
7114
7115 let app = ResolvedApp {
7116 name: "weft-claw".into(),
7117 version: "0.1.0".into(),
7118 capabilities: vec!["agent.runtime".into()],
7119 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7120 sources: ResolvedAppSources {
7121 manifest_path: manifest_path.display().to_string(),
7122 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7123 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7124 },
7125 ..ResolvedApp::default()
7126 };
7127 let candidate = test_generation(
7128 "agent.runtime",
7129 "agent-runtime",
7130 "trusted",
7131 GenerationStatus::Candidate,
7132 );
7133 let mut registry = CapabilityRegistry::new();
7134 registry.insert(
7135 "agent.runtime".into(),
7136 CapabilityRegistryEntry {
7137 capability: "agent.runtime".into(),
7138 providers: vec![CapabilityProviderRecord {
7139 provider: "agent-runtime".into(),
7140 runtime: "metadata".into(),
7141 priority: 0,
7142 }],
7143 bindings: vec![],
7144 },
7145 );
7146 let state = state_for_verify_generation(
7147 repo_root,
7148 app,
7149 vec![test_package_source(
7150 "agent-runtime",
7151 "provider",
7152 vec!["agent.runtime"],
7153 vec![],
7154 )],
7155 registry,
7156 candidate,
7157 );
7158
7159 let (status, body) =
7160 super::verify_generation(Path("weft-claw".into()), State(state.clone()))
7161 .await
7162 .expect_err("trusted verification should fail");
7163
7164 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7165 assert_eq!(body.0["reason"], "generation_verify_failed");
7166 let validations = body.0["candidate"]["validation_results"]
7167 .as_array()
7168 .expect("validation result array");
7169 assert!(validations.iter().any(|result| {
7170 result["check"] == "digest-policy:agent-runtime"
7171 && result["passed"] == false
7172 && result["message"]
7173 .as_str()
7174 .unwrap_or_default()
7175 .contains("under profile 'trusted'")
7176 }));
7177 }
7178
7179 #[tokio::test]
7180 async fn verify_generation_allows_developer_candidate_with_dev_unsealed_warning() {
7181 let root = tempdir().expect("temp dir");
7182 let repo_root = root.path().to_path_buf();
7183 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7184 &repo_root,
7185 Some("team"),
7186 r#"[scenes.team]
7187name = 'team'
7188profile = 'developer'
7189
7190[[scenes.team.package_pins]]
7191package = 'agent-runtime'
7192source = 'remote://candidate-without-digest'
7193"#,
7194 );
7195
7196 let app = ResolvedApp {
7197 name: "weft-claw".into(),
7198 version: "0.1.0".into(),
7199 capabilities: vec!["agent.runtime".into()],
7200 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7201 sources: ResolvedAppSources {
7202 manifest_path: manifest_path.display().to_string(),
7203 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7204 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7205 },
7206 ..ResolvedApp::default()
7207 };
7208 let candidate = test_generation(
7209 "agent.runtime",
7210 "agent-runtime",
7211 "developer",
7212 GenerationStatus::Candidate,
7213 );
7214 let candidate = AppGeneration {
7215 lock_path: generation_lock_path(1),
7216 ..candidate
7217 };
7218 let mut registry = CapabilityRegistry::new();
7219 registry.insert(
7220 "agent.runtime".into(),
7221 CapabilityRegistryEntry {
7222 capability: "agent.runtime".into(),
7223 providers: vec![CapabilityProviderRecord {
7224 provider: "agent-runtime".into(),
7225 runtime: "metadata".into(),
7226 priority: 0,
7227 }],
7228 bindings: vec![],
7229 },
7230 );
7231 let state = state_for_verify_generation(
7232 repo_root,
7233 app,
7234 vec![test_package_source(
7235 "agent-runtime",
7236 "provider",
7237 vec!["agent.runtime"],
7238 vec![],
7239 )],
7240 registry,
7241 candidate,
7242 );
7243
7244 let response = super::verify_generation(Path("weft-claw".into()), State(state.clone()))
7245 .await
7246 .expect("developer verification should succeed");
7247 assert_eq!(response.0["status"], "verified");
7248 assert_eq!(response.0["dev_unsealed"], true);
7249 let generation = &response.0["generation"];
7250 let validations = generation["validation_results"]
7251 .as_array()
7252 .expect("validation result array");
7253 assert!(validations.iter().any(|result| {
7254 result["check"] == "digest-policy:agent-runtime"
7255 && result["passed"] == true
7256 && result["message"]
7257 .as_str()
7258 .unwrap_or_default()
7259 .contains("dev-unsealed")
7260 }));
7261 assert!(validations.iter().any(|result| {
7262 result["check"] == "digest-policy:dev-unsealed" && result["passed"] == true
7263 }));
7264
7265 let activated = activate_generation(Path("weft-claw".into()), State(state.clone()))
7266 .await
7267 .expect("activation should succeed for verified developer candidate");
7268 assert_eq!(activated.0["status"], "activated");
7269
7270 let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
7271 .expect("lock file written");
7272 assert!(lock.dev_unsealed);
7273 assert_eq!(lock.trust_level, "dev-unsealed");
7274 }
7275
7276 #[tokio::test]
7277 async fn verify_generation_warns_when_store_metadata_is_absent_for_backward_compatibility() {
7278 let root = tempdir().expect("temp dir");
7279 let repo_root = root.path().to_path_buf();
7280 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7281 &repo_root,
7282 Some("team"),
7283 "[scenes.team]\nname = 'team'\nprofile = 'safe'\n",
7284 );
7285
7286 let app = ResolvedApp {
7287 name: "weft-claw".into(),
7288 version: "0.1.0".into(),
7289 capabilities: vec!["agent.runtime".into()],
7290 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7291 sources: ResolvedAppSources {
7292 manifest_path: manifest_path.display().to_string(),
7293 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7294 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7295 },
7296 ..ResolvedApp::default()
7297 };
7298 let app_config_path = instance_dir.join("config.toml");
7299 let state = state_for_propose_generation(
7300 repo_root.clone(),
7301 app.clone(),
7302 vec![test_package_source(
7303 "agent-runtime",
7304 "provider",
7305 vec!["agent.runtime"],
7306 vec![],
7307 )],
7308 );
7309 let candidate = candidate_with_verified_identities(
7310 &state,
7311 &app_config_path,
7312 test_generation(
7313 "agent.runtime",
7314 "agent-runtime",
7315 "safe",
7316 GenerationStatus::Candidate,
7317 ),
7318 );
7319 let state = state_for_verify_generation(
7320 repo_root,
7321 app,
7322 vec![test_package_source(
7323 "agent-runtime",
7324 "provider",
7325 vec!["agent.runtime"],
7326 vec![],
7327 )],
7328 registry_with_provider("agent.runtime", "agent-runtime"),
7329 candidate,
7330 );
7331
7332 let response = super::verify_generation(Path("weft-claw".into()), State(state))
7333 .await
7334 .expect("verification should succeed when store metadata is absent");
7335 let validations = response.0["generation"]["validation_results"]
7336 .as_array()
7337 .expect("validation array");
7338
7339 assert!(validations.iter().any(|result| {
7340 result["check"] == "store-policy:agent-runtime"
7341 && result["passed"] == true
7342 && result["message"]
7343 .as_str()
7344 .unwrap_or_default()
7345 .contains("backward compatibility")
7346 }));
7347 }
7348
7349 #[tokio::test]
7350 async fn verify_generation_passes_when_summary_identities_match_recomputed_values() {
7351 let root = tempdir().expect("temp dir");
7352 let repo_root = root.path().to_path_buf();
7353 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7354 &repo_root,
7355 Some("team"),
7356 "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7357 );
7358
7359 let app = ResolvedApp {
7360 name: "weft-claw".into(),
7361 version: "0.1.0".into(),
7362 capabilities: vec!["agent.runtime".into()],
7363 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7364 sources: ResolvedAppSources {
7365 manifest_path: manifest_path.display().to_string(),
7366 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7367 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7368 },
7369 ..ResolvedApp::default()
7370 };
7371 let app_config_path = instance_dir.join("config.toml");
7372 let state = state_for_propose_generation(
7373 repo_root.clone(),
7374 app.clone(),
7375 vec![test_package_source(
7376 "agent-runtime",
7377 "provider",
7378 vec!["agent.runtime"],
7379 vec![],
7380 )],
7381 );
7382 let candidate = candidate_with_verified_identities(
7383 &state,
7384 &app_config_path,
7385 test_generation(
7386 "agent.runtime",
7387 "agent-runtime",
7388 "developer",
7389 GenerationStatus::Candidate,
7390 ),
7391 );
7392 let state = state_for_verify_generation(
7393 repo_root,
7394 app,
7395 vec![test_package_source(
7396 "agent-runtime",
7397 "provider",
7398 vec!["agent.runtime"],
7399 vec![],
7400 )],
7401 registry_with_provider("agent.runtime", "agent-runtime"),
7402 candidate,
7403 );
7404
7405 let response = super::verify_generation(Path("weft-claw".into()), State(state))
7406 .await
7407 .expect("verification should succeed");
7408
7409 assert_eq!(response.0["status"], "verified");
7410 let validations = response.0["generation"]["validation_results"]
7411 .as_array()
7412 .expect("validation result array");
7413 assert!(validations.iter().any(|result| {
7414 result["check"] == "identity-consistency:binding_set_id"
7415 && result["passed"] == true
7416 && result["message"]
7417 .as_str()
7418 .unwrap_or_default()
7419 .contains("matches the recomputed binding identity")
7420 }));
7421 assert!(validations.iter().any(|result| {
7422 result["check"] == "identity-consistency:closure_id"
7423 && result["passed"] == true
7424 && result["message"]
7425 .as_str()
7426 .unwrap_or_default()
7427 .contains("matches the recomputed closure identity")
7428 }));
7429 }
7430
7431 #[tokio::test]
7432 async fn verify_generation_rejects_stale_binding_set_id_metadata() {
7433 let root = tempdir().expect("temp dir");
7434 let repo_root = root.path().to_path_buf();
7435 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7436 &repo_root,
7437 Some("team"),
7438 "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7439 );
7440
7441 let app = ResolvedApp {
7442 name: "weft-claw".into(),
7443 version: "0.1.0".into(),
7444 capabilities: vec!["agent.runtime".into()],
7445 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7446 sources: ResolvedAppSources {
7447 manifest_path: manifest_path.display().to_string(),
7448 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7449 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7450 },
7451 ..ResolvedApp::default()
7452 };
7453 let app_config_path = instance_dir.join("config.toml");
7454 let proposal_state = state_for_propose_generation(
7455 repo_root.clone(),
7456 app.clone(),
7457 vec![test_package_source(
7458 "agent-runtime",
7459 "provider",
7460 vec!["agent.runtime"],
7461 vec![],
7462 )],
7463 );
7464 let mut candidate = candidate_with_verified_identities(
7465 &proposal_state,
7466 &app_config_path,
7467 test_generation(
7468 "agent.runtime",
7469 "agent-runtime",
7470 "developer",
7471 GenerationStatus::Candidate,
7472 ),
7473 );
7474 candidate.binding_set_id = "binding-set:sha256:stale".into();
7475 let state = state_for_verify_generation(
7476 repo_root,
7477 app,
7478 vec![test_package_source(
7479 "agent-runtime",
7480 "provider",
7481 vec!["agent.runtime"],
7482 vec![],
7483 )],
7484 registry_with_provider("agent.runtime", "agent-runtime"),
7485 candidate,
7486 );
7487
7488 let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7489 .await
7490 .expect_err("verification should fail for stale binding_set_id");
7491
7492 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7493 assert_eq!(body.0["reason"], "generation_verify_failed");
7494 let validations = body.0["candidate"]["validation_results"]
7495 .as_array()
7496 .expect("validation result array");
7497 assert!(validations.iter().any(|result| {
7498 result["check"] == "identity-consistency:binding_set_id"
7499 && result["passed"] == false
7500 && result["message"]
7501 .as_str()
7502 .unwrap_or_default()
7503 .contains("does not match recomputed binding identity")
7504 }));
7505 }
7506
7507 #[tokio::test]
7508 async fn verify_generation_rejects_stale_closure_id_metadata() {
7509 let root = tempdir().expect("temp dir");
7510 let repo_root = root.path().to_path_buf();
7511 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7512 &repo_root,
7513 Some("team"),
7514 "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7515 );
7516
7517 let app = ResolvedApp {
7518 name: "weft-claw".into(),
7519 version: "0.1.0".into(),
7520 capabilities: vec!["agent.runtime".into()],
7521 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7522 sources: ResolvedAppSources {
7523 manifest_path: manifest_path.display().to_string(),
7524 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7525 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7526 },
7527 ..ResolvedApp::default()
7528 };
7529 let app_config_path = instance_dir.join("config.toml");
7530 let proposal_state = state_for_propose_generation(
7531 repo_root.clone(),
7532 app.clone(),
7533 vec![test_package_source(
7534 "agent-runtime",
7535 "provider",
7536 vec!["agent.runtime"],
7537 vec![],
7538 )],
7539 );
7540 let mut candidate = candidate_with_verified_identities(
7541 &proposal_state,
7542 &app_config_path,
7543 test_generation(
7544 "agent.runtime",
7545 "agent-runtime",
7546 "developer",
7547 GenerationStatus::Candidate,
7548 ),
7549 );
7550 candidate.closure_id = "closure:sha256:stale".into();
7551 let state = state_for_verify_generation(
7552 repo_root,
7553 app,
7554 vec![test_package_source(
7555 "agent-runtime",
7556 "provider",
7557 vec!["agent.runtime"],
7558 vec![],
7559 )],
7560 registry_with_provider("agent.runtime", "agent-runtime"),
7561 candidate,
7562 );
7563
7564 let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7565 .await
7566 .expect_err("verification should fail for stale closure_id");
7567
7568 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7569 assert_eq!(body.0["reason"], "generation_verify_failed");
7570 let validations = body.0["candidate"]["validation_results"]
7571 .as_array()
7572 .expect("validation result array");
7573 assert!(validations.iter().any(|result| {
7574 result["check"] == "identity-consistency:closure_id"
7575 && result["passed"] == false
7576 && result["message"]
7577 .as_str()
7578 .unwrap_or_default()
7579 .contains("does not match recomputed closure identity")
7580 }));
7581 }
7582
7583 #[tokio::test]
7584 async fn verify_generation_allows_empty_identity_metadata_with_warnings() {
7585 let root = tempdir().expect("temp dir");
7586 let repo_root = root.path().to_path_buf();
7587 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7588 &repo_root,
7589 Some("team"),
7590 "[scenes.team]\nname = 'team'\nprofile = 'developer'\n",
7591 );
7592
7593 let app = ResolvedApp {
7594 name: "weft-claw".into(),
7595 version: "0.1.0".into(),
7596 capabilities: vec!["agent.runtime".into()],
7597 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7598 sources: ResolvedAppSources {
7599 manifest_path: manifest_path.display().to_string(),
7600 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7601 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7602 },
7603 ..ResolvedApp::default()
7604 };
7605 let candidate = test_generation(
7606 "agent.runtime",
7607 "agent-runtime",
7608 "developer",
7609 GenerationStatus::Candidate,
7610 );
7611 let state = state_for_verify_generation(
7612 repo_root,
7613 app,
7614 vec![test_package_source(
7615 "agent-runtime",
7616 "provider",
7617 vec!["agent.runtime"],
7618 vec![],
7619 )],
7620 registry_with_provider("agent.runtime", "agent-runtime"),
7621 candidate,
7622 );
7623
7624 let response = super::verify_generation(Path("weft-claw".into()), State(state))
7625 .await
7626 .expect("verification should allow empty legacy identities");
7627
7628 assert_eq!(response.0["status"], "verified");
7629 let validations = response.0["generation"]["validation_results"]
7630 .as_array()
7631 .expect("validation result array");
7632 assert!(validations.iter().any(|result| {
7633 result["check"] == "identity-consistency:binding_set_id"
7634 && result["passed"] == true
7635 && result["message"]
7636 .as_str()
7637 .unwrap_or_default()
7638 .contains("missing binding_set_id metadata")
7639 }));
7640 assert!(validations.iter().any(|result| {
7641 result["check"] == "identity-consistency:closure_id"
7642 && result["passed"] == true
7643 && result["message"]
7644 .as_str()
7645 .unwrap_or_default()
7646 .contains("missing closure_id metadata")
7647 }));
7648 }
7649
7650 #[tokio::test]
7651 async fn verify_generation_rejects_candidate_when_required_capability_binding_missing() {
7652 let root = tempdir().expect("temp dir");
7653 let repo_root = root.path().to_path_buf();
7654 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(&repo_root, None, "");
7655
7656 let app = ResolvedApp {
7657 name: "weft-claw".into(),
7658 version: "0.1.0".into(),
7659 capabilities: vec!["agent.runtime".into()],
7660 bindings: vec![],
7661 sources: ResolvedAppSources {
7662 manifest_path: manifest_path.display().to_string(),
7663 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7664 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7665 },
7666 ..ResolvedApp::default()
7667 };
7668 let candidate = AppGeneration {
7669 bindings: vec![],
7670 capabilities: vec!["agent.runtime".into()],
7671 profile: "developer".into(),
7672 status: GenerationStatus::Candidate,
7673 ..test_generation(
7674 "agent.runtime",
7675 "agent-runtime",
7676 "developer",
7677 GenerationStatus::Candidate,
7678 )
7679 };
7680 let mut registry = CapabilityRegistry::new();
7681 registry.insert(
7682 "agent.runtime".into(),
7683 CapabilityRegistryEntry {
7684 capability: "agent.runtime".into(),
7685 providers: vec![],
7686 bindings: vec![],
7687 },
7688 );
7689 let state = state_for_verify_generation(repo_root, app, vec![], registry, candidate);
7690
7691 let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7692 .await
7693 .expect_err("verification should fail without required binding");
7694
7695 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7696 assert_eq!(body.0["reason"], "generation_verify_failed");
7697 let validations = body.0["candidate"]["validation_results"]
7698 .as_array()
7699 .expect("validation result array");
7700 assert!(validations.iter().any(|result| {
7701 result["check"] == "binding-coverage:agent.runtime"
7702 && result["passed"] == false
7703 && result["message"]
7704 .as_str()
7705 .unwrap_or_default()
7706 .contains("has no provider binding")
7707 }));
7708 }
7709
7710 #[tokio::test]
7711 async fn verify_generation_rejects_candidate_when_binding_provider_not_in_selected_package_summary(
7712 ) {
7713 let root = tempdir().expect("temp dir");
7714 let repo_root = root.path().to_path_buf();
7715 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(
7716 &repo_root,
7717 Some("team"),
7718 r#"[scenes.team]
7719name = 'team'
7720profile = 'developer'
7721"#,
7722 );
7723
7724 let app = ResolvedApp {
7725 name: "weft-claw".into(),
7726 version: "0.1.0".into(),
7727 capabilities: vec!["agent.runtime".into()],
7728 bindings: vec![test_binding("agent.runtime", "agent-runtime")],
7729 sources: ResolvedAppSources {
7730 manifest_path: manifest_path.display().to_string(),
7731 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7732 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7733 },
7734 ..ResolvedApp::default()
7735 };
7736 let candidate = test_generation(
7737 "agent.runtime",
7738 "agent-runtime",
7739 "developer",
7740 GenerationStatus::Candidate,
7741 );
7742 let mut registry = CapabilityRegistry::new();
7743 registry.insert(
7744 "agent.runtime".into(),
7745 CapabilityRegistryEntry {
7746 capability: "agent.runtime".into(),
7747 providers: vec![CapabilityProviderRecord {
7748 provider: "agent-runtime".into(),
7749 runtime: "metadata".into(),
7750 priority: 0,
7751 }],
7752 bindings: vec![],
7753 },
7754 );
7755 let state = state_for_verify_generation(
7756 repo_root,
7757 app,
7758 vec![PackageSource {
7759 name: "prompt-system".into(),
7760 kind: "metadata".into(),
7761 package_kind: "provider".into(),
7762 runtime_provider: "prompt-system".into(),
7763 current_source: "packages/weft-claw".into(),
7764 trusted: true,
7765 signature: "builtin:test".into(),
7766 source_authority: "test".into(),
7767 source_public_keys: vec![],
7768 provides: vec!["agent.runtime".into()],
7769 requires: vec![],
7770 }],
7771 registry,
7772 candidate,
7773 );
7774
7775 let (status, body) = super::verify_generation(Path("weft-claw".into()), State(state))
7776 .await
7777 .expect_err("verification should fail when binding provider is excluded");
7778
7779 assert_eq!(status, HttpStatusCode::UNPROCESSABLE_ENTITY);
7780 assert_eq!(body.0["reason"], "generation_verify_failed");
7781 let validations = body.0["candidate"]["validation_results"]
7782 .as_array()
7783 .expect("validation result array");
7784 assert!(validations.iter().any(|result| {
7785 result["check"] == "binding-coverage:agent.runtime"
7786 && result["passed"] == false
7787 && result["message"]
7788 .as_str()
7789 .unwrap_or_default()
7790 .contains("no included selected package summary entry matches")
7791 }));
7792 }
7793
7794 #[tokio::test]
7795 async fn verify_generation_allows_core_provider_binding_for_coverage() {
7796 let root = tempdir().expect("temp dir");
7797 let repo_root = root.path().to_path_buf();
7798 let (_, instance_dir, manifest_path) = write_test_manifest_and_config(&repo_root, None, "");
7799
7800 let app = ResolvedApp {
7801 name: "weft-claw".into(),
7802 version: "0.1.0".into(),
7803 capabilities: vec!["core.execution".into()],
7804 bindings: vec![test_binding("core.execution", "core")],
7805 sources: ResolvedAppSources {
7806 manifest_path: manifest_path.display().to_string(),
7807 config_path: Some(instance_dir.join("config.toml").display().to_string()),
7808 lock_path: Some(instance_dir.join("lock.toml").display().to_string()),
7809 },
7810 ..ResolvedApp::default()
7811 };
7812 let candidate = AppGeneration {
7813 bindings: vec![test_binding("core.execution", "core")],
7814 capabilities: vec!["core.execution".into()],
7815 profile: "developer".into(),
7816 status: GenerationStatus::Candidate,
7817 ..test_generation(
7818 "core.execution",
7819 "core",
7820 "developer",
7821 GenerationStatus::Candidate,
7822 )
7823 };
7824 let mut registry = CapabilityRegistry::new();
7825 registry.insert(
7826 "core.execution".into(),
7827 CapabilityRegistryEntry {
7828 capability: "core.execution".into(),
7829 providers: vec![CapabilityProviderRecord {
7830 provider: "core".into(),
7831 runtime: "native".into(),
7832 priority: 0,
7833 }],
7834 bindings: vec![],
7835 },
7836 );
7837 let state = state_for_verify_generation(repo_root, app, vec![], registry, candidate);
7838
7839 let response = super::verify_generation(Path("weft-claw".into()), State(state))
7840 .await
7841 .expect("core provider binding should pass coverage verification");
7842
7843 assert_eq!(response.0["status"], "verified");
7844 let validations = response.0["generation"]["validation_results"]
7845 .as_array()
7846 .expect("validation result array");
7847 assert!(validations.iter().any(|result| {
7848 result["check"] == "binding-coverage:core.execution"
7849 && result["passed"] == true
7850 && result["message"]
7851 .as_str()
7852 .unwrap_or_default()
7853 .contains("bound to core")
7854 }));
7855 }
7856
7857 #[tokio::test]
7858 async fn activate_generation_sets_dev_unsealed_from_structured_validation_check() {
7859 let root = tempdir().expect("temp dir");
7860 let repo_root = root.path().to_path_buf();
7861 let instance_dir = repo_root.join(".weft").join("weft-claw");
7862 fs::create_dir_all(&instance_dir).expect("instance dir created");
7863 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
7864 .expect("lock file seeded");
7865
7866 let state = state_with_generation_store(
7867 repo_root,
7868 test_resolved_app("weft-claw", Some(&instance_dir)),
7869 AppGenerationStore {
7870 active: Some(generation_with_id(1, GenerationStatus::Active)),
7871 candidate: Some(generation_with_validation_results(
7872 2,
7873 GenerationStatus::Verified,
7874 "developer",
7875 vec![crate::app::ValidationResult {
7876 check: "digest-policy:dev-unsealed".into(),
7877 passed: true,
7878 message: "Warning: candidate verification succeeded with developer-only dev-unsealed digest policy exceptions".into(),
7879 }],
7880 )),
7881 rollback: None,
7882 next_id: 3,
7883 },
7884 );
7885
7886 let response = activate_generation(Path("weft-claw".into()), State(state))
7887 .await
7888 .expect("activation succeeds");
7889
7890 assert_eq!(response.0["status"], "activated");
7891 let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
7892 .expect("lock file written");
7893 assert!(lock.dev_unsealed);
7894 assert_eq!(lock.trust_level, "dev-unsealed");
7895 }
7896
7897 #[tokio::test]
7898 async fn activate_generation_rejects_failed_candidate_before_writes() {
7899 let root = tempdir().expect("temp dir");
7900 let repo_root = root.path().to_path_buf();
7901 let instance_dir = repo_root.join(".weft").join("weft-claw");
7902 fs::create_dir_all(&instance_dir).expect("instance dir created");
7903 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
7904 .expect("lock file seeded");
7905
7906 let state = state_with_generation_store(
7907 repo_root,
7908 test_resolved_app("weft-claw", Some(&instance_dir)),
7909 AppGenerationStore {
7910 active: Some(generation_with_id(1, GenerationStatus::Active)),
7911 candidate: Some(generation_with_id(2, GenerationStatus::Failed)),
7912 rollback: None,
7913 next_id: 3,
7914 },
7915 );
7916
7917 let (status, body) = activate_generation(Path("weft-claw".into()), State(state.clone()))
7918 .await
7919 .expect_err("failed candidate activation should be rejected");
7920
7921 assert_eq!(status, HttpStatusCode::CONFLICT);
7922 let payload = body.0;
7923 assert_eq!(
7924 payload["error"],
7925 "Candidate must be verified before activation"
7926 );
7927 assert!(payload["reason"].is_null());
7928 assert!(payload["status"].is_null());
7929 assert!(payload["lock_written"].is_null());
7930 assert!(payload["pointer_written"].is_null());
7931 assert!(payload["pointer_error"].is_null());
7932 assert!(payload["index_written"].is_null());
7933 assert!(payload["index_error"].is_null());
7934
7935 let store = state.generation_store.read().await;
7936 let app_store = store.get("weft-claw").expect("store present");
7937 assert_eq!(
7938 app_store.active.as_ref().map(|generation| generation.id),
7939 Some(1)
7940 );
7941 assert_eq!(
7942 app_store
7943 .candidate
7944 .as_ref()
7945 .map(|generation| generation.status),
7946 Some(GenerationStatus::Failed)
7947 );
7948 assert_eq!(
7949 crate::app::read_active_generation_pointer(&instance_dir)
7950 .expect("active pointer read succeeds"),
7951 None
7952 );
7953 assert_eq!(
7954 crate::app::read_previous_generation_pointer(&instance_dir)
7955 .expect("previous pointer read succeeds"),
7956 None
7957 );
7958 assert!(crate::app::load_generation_index(&instance_dir)
7959 .expect("index load succeeds")
7960 .is_none());
7961 let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
7962 .expect("lock remains readable");
7963 assert_eq!(lock.generation, 0);
7964 assert_eq!(lock.status, "");
7965 }
7966
7967 #[tokio::test]
7968 async fn activate_generation_rejects_dev_unsealed_marker_under_safe_profile_before_writes() {
7969 let root = tempdir().expect("temp dir");
7970 let repo_root = root.path().to_path_buf();
7971 let instance_dir = repo_root.join(".weft").join("weft-claw");
7972 fs::create_dir_all(&instance_dir).expect("instance dir created");
7973 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
7974 .expect("lock file seeded");
7975
7976 let state = state_with_generation_store(
7977 repo_root,
7978 test_resolved_app("weft-claw", Some(&instance_dir)),
7979 AppGenerationStore {
7980 active: Some(generation_with_id(1, GenerationStatus::Active)),
7981 candidate: Some(generation_with_validation_results(
7982 2,
7983 GenerationStatus::Verified,
7984 "safe",
7985 vec![crate::app::ValidationResult {
7986 check: "digest-policy:dev-unsealed".into(),
7987 passed: true,
7988 message: "Warning: candidate verification succeeded with developer-only dev-unsealed digest policy exceptions".into(),
7989 }],
7990 )),
7991 rollback: None,
7992 next_id: 3,
7993 },
7994 );
7995
7996 let (status, body) = activate_generation(Path("weft-claw".into()), State(state.clone()))
7997 .await
7998 .expect_err("safe-profile dev-unsealed activation should be rejected");
7999
8000 assert_eq!(status, HttpStatusCode::CONFLICT);
8001 let payload = body.0;
8002 assert_eq!(payload["reason"], "dev_unsealed_profile_mismatch");
8003 assert_eq!(payload["status"], "activation_failed");
8004 assert_eq!(payload["lock_written"], false);
8005 assert_eq!(payload["pointer_written"], false);
8006 assert!(payload["pointer_error"].is_null());
8007 assert_eq!(payload["index_written"], false);
8008 assert!(payload["index_error"].is_null());
8009 assert!(payload["error"]
8010 .as_str()
8011 .expect("error string")
8012 .contains("developer-only dev-unsealed validation"));
8013
8014 let store = state.generation_store.read().await;
8015 let app_store = store.get("weft-claw").expect("store present");
8016 assert_eq!(
8017 app_store.active.as_ref().map(|generation| generation.id),
8018 Some(1)
8019 );
8020 assert_eq!(
8021 app_store.candidate.as_ref().map(|generation| generation.id),
8022 Some(2)
8023 );
8024 assert_eq!(
8025 crate::app::read_active_generation_pointer(&instance_dir)
8026 .expect("active pointer read succeeds"),
8027 None
8028 );
8029 assert_eq!(
8030 crate::app::read_previous_generation_pointer(&instance_dir)
8031 .expect("previous pointer read succeeds"),
8032 None
8033 );
8034 assert!(crate::app::load_generation_index(&instance_dir)
8035 .expect("index load succeeds")
8036 .is_none());
8037 let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
8038 .expect("lock remains readable");
8039 assert_eq!(lock.generation, 0);
8040 assert_eq!(lock.status, "");
8041 }
8042
8043 #[tokio::test]
8044 async fn activate_generation_does_not_set_dev_unsealed_without_structured_validation_check() {
8045 let root = tempdir().expect("temp dir");
8046 let repo_root = root.path().to_path_buf();
8047 let instance_dir = repo_root.join(".weft").join("weft-claw");
8048 fs::create_dir_all(&instance_dir).expect("instance dir created");
8049 fs::write(instance_dir.join("lock.toml"), "app = \"weft-claw\"\n")
8050 .expect("lock file seeded");
8051
8052 let state = state_with_generation_store(
8053 repo_root,
8054 test_resolved_app("weft-claw", Some(&instance_dir)),
8055 AppGenerationStore {
8056 active: Some(generation_with_id(1, GenerationStatus::Active)),
8057 candidate: Some(generation_with_validation_results(
8058 2,
8059 GenerationStatus::Verified,
8060 "developer",
8061 vec![crate::app::ValidationResult {
8062 check: "digest-policy:agent-runtime".into(),
8063 passed: true,
8064 message: "Warning: package 'agent-runtime' is missing usable digest metadata for source 'packages/official/agent-runtime' under profile 'developer'; candidate remains verified as dev-unsealed".into(),
8065 }],
8066 )),
8067 rollback: None,
8068 next_id: 3,
8069 },
8070 );
8071
8072 let response = activate_generation(Path("weft-claw".into()), State(state))
8073 .await
8074 .expect("activation succeeds");
8075
8076 assert_eq!(response.0["status"], "activated");
8077 let lock = crate::app::load_instance_lock_from_path(&instance_dir.join("lock.toml"))
8078 .expect("lock file written");
8079 assert!(!lock.dev_unsealed);
8080 assert_eq!(lock.trust_level, "");
8081 }
8082
8083 #[test]
8084 fn selected_packages_skip_metadata_only_weft_claw_required_sources() {
8085 let repo_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
8086 let state = AppState {
8087 package_index: Arc::new(PackageIndex {
8088 version: 1,
8089 revision: "test-rev".into(),
8090 source_url: "local://packages".into(),
8091 package_sources: vec![
8092 PackageSource {
8093 name: "prompt-system".into(),
8094 kind: "metadata".into(),
8095 package_kind: "foundation".into(),
8096 runtime_provider: "agent-core".into(),
8097 current_source: "packages/weft-claw".into(),
8098 trusted: true,
8099 signature: "builtin:product-package".into(),
8100 source_authority: String::new(),
8101 source_public_keys: vec![],
8102 provides: vec!["prompt.system".into()],
8103 requires: vec![],
8104 },
8105 PackageSource {
8106 name: "agent-runtime".into(),
8107 kind: "embedded".into(),
8108 package_kind: "feature".into(),
8109 runtime_provider: "agent-runtime".into(),
8110 current_source: "packages/official/agent-core".into(),
8111 trusted: true,
8112 signature: "builtin:official".into(),
8113 source_authority: "official".into(),
8114 source_public_keys: vec![],
8115 provides: vec![
8116 "agent.runtime".into(),
8117 "prompt.system".into(),
8118 "memory.store".into(),
8119 ],
8120 requires: vec![
8121 "agent-runtime".into(),
8122 "prompt-system".into(),
8123 "memory-store".into(),
8124 ],
8125 },
8126 PackageSource {
8127 name: "memory-store".into(),
8128 kind: "wasm".into(),
8129 package_kind: "provider".into(),
8130 runtime_provider: "memory-store".into(),
8131 current_source: "packages/official/memory".into(),
8132 trusted: true,
8133 signature: "builtin:official".into(),
8134 source_authority: "official".into(),
8135 source_public_keys: vec![],
8136 provides: vec!["memory.store".into()],
8137 requires: vec![],
8138 },
8139 ],
8140 }),
8141 ..test_state(repo_root, CapabilityRegistry::new())
8142 };
8143
8144 let packages = packages_for_selected_names(
8145 &state,
8146 &[
8147 "prompt-system".to_string(),
8148 "agent-runtime".to_string(),
8149 "memory-store".to_string(),
8150 ],
8151 );
8152
8153 assert!(packages.iter().all(|pkg| pkg.name != "prompt-system"));
8154 assert!(packages.iter().any(|pkg| pkg.name == "agent-runtime"));
8155 assert!(packages.iter().any(|pkg| pkg.name == "memory-store"));
8156 }
8157}