Skip to main content

lenso_bootstrap/
lib.rs

1//! Composition root: the single place that knows which modules exist.
2//!
3//! Both the API and the worker assemble their module wiring from this crate, so
4//! a module is registered here once rather than in scattered per-app edits.
5//!
6//! A module's contributions are split by how they are consumed:
7//! - [`modules`]: context-bound bindings (runtime functions + event handlers)
8//!   and runtime config (API + worker), demo-default for context-local callers.
9//! - [`modules_for_config`] / [`load_modules`]: config-aware module loaders that
10//!   honor profile entry lists and configured remote sources for runtime apps.
11//! - [`module_manifests`]: context-free manifest data (no [`AppContext`]) for
12//!   read-only / `OpenAPI` paths, with profile-aware variants for runtime use.
13//! - [`merge_linked_http`]: context-free HTTP routes and their OpenAPI docs
14//!   (API only), assembled without a live [`AppContext`].
15//! - [`story_display_descriptors`]: console display metadata, sourced from the
16//!   context-free [`module_manifests`].
17//!
18//! When adding a module, register it in the appropriate profile entry lists and
19//! expose its config-aware loader contributions from this crate.
20
21use platform_admin_data::{
22    AdminModule, AdminModuleMetadata, AdminModuleSourceDiagnostics, AdminRemoteModuleDiagnostics,
23};
24use platform_core::error::ErrorDetail;
25use platform_core::{
26    ActorContext, AppContext, AppError, CorrelationId, ErrorCode, EventHandlerRegistry, Migration,
27    PLATFORM_MIGRATIONS, RuntimeConfigDescriptor, RuntimeConfigGroupDescriptor, RuntimeConfigScope,
28    RuntimeConfigType, StoryDisplayDescriptor, StoryDisplaySource, TraceContext,
29};
30use platform_http::ApiOpenApiRouter;
31use platform_module::{
32    AdminSchema, AdminSurface, EventHandlerRegistrationContext, LifecycleActivationRunPolicy,
33    LifecycleStartupCheckKind, LinkedBinding, Module, ModuleHttpMethod, ModuleLoadStatus,
34    ModuleManifest, ModuleSource,
35};
36use platform_module_remote::{RemoteHttpProxyRegistry, RemoteModuleConfig, RemoteModuleSource};
37use platform_runtime::{
38    EnqueueFunctionRequest, FunctionRegistry, RUNTIME_MIGRATIONS, RuntimeClient,
39};
40use std::fs::{self, OpenOptions};
41use std::io::Write as _;
42use std::path::{Path, PathBuf};
43use std::process::{Child, Command};
44use std::sync::Arc;
45use std::thread;
46use std::time::{Duration, Instant};
47
48const DEFAULT_MODULE_SERVICES_FILE: &str = ".lenso/module-services.json";
49const DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS: u64 = 10_000;
50const REMOTE_SERVICE_TERMINATE_GRACE_MS: u64 = 800;
51
52struct LinkedModuleEntry {
53    module_name: &'static str,
54    manifest: fn() -> ModuleManifest,
55    load: fn(&AppContext) -> Module,
56    http_binding: Option<fn() -> LinkedBinding>,
57}
58
59const MODULES_CONFIG_GROUP: RuntimeConfigGroupDescriptor = RuntimeConfigGroupDescriptor {
60    id: "modules",
61    label: "Modules",
62    description: "Module load toggles applied on service startup.",
63    order: 10,
64};
65
66#[derive(Debug, Clone, Copy)]
67pub struct HostLinkedModule {
68    pub module_name: &'static str,
69    pub manifest: fn() -> ModuleManifest,
70    pub load: Option<fn(&AppContext) -> Module>,
71    pub http_binding: Option<fn() -> LinkedBinding>,
72    pub migrations: &'static [Migration],
73}
74
75impl HostLinkedModule {
76    #[must_use]
77    pub const fn manifest_only(
78        module_name: &'static str,
79        manifest: fn() -> ModuleManifest,
80        migrations: &'static [Migration],
81    ) -> Self {
82        Self {
83            module_name,
84            manifest,
85            load: None,
86            http_binding: None,
87            migrations,
88        }
89    }
90
91    #[must_use]
92    pub const fn linked(
93        module_name: &'static str,
94        manifest: fn() -> ModuleManifest,
95        load: fn(&AppContext) -> Module,
96        migrations: &'static [Migration],
97    ) -> Self {
98        Self {
99            module_name,
100            manifest,
101            load: Some(load),
102            http_binding: None,
103            migrations,
104        }
105    }
106
107    #[must_use]
108    pub const fn with_http_binding(mut self, http_binding: fn() -> LinkedBinding) -> Self {
109        self.http_binding = Some(http_binding);
110        self
111    }
112}
113
114#[derive(Debug, Clone, Default)]
115pub struct HostComposition {
116    linked_modules: Vec<HostLinkedModule>,
117}
118
119impl HostComposition {
120    #[must_use]
121    pub fn new() -> Self {
122        Self::default()
123    }
124
125    #[must_use]
126    pub fn with_linked_module(mut self, module: HostLinkedModule) -> Self {
127        self.add_linked_module(module);
128        self
129    }
130
131    pub fn add_linked_module(&mut self, module: HostLinkedModule) {
132        self.linked_modules.push(module);
133    }
134
135    #[must_use]
136    pub fn linked_modules(&self) -> &[HostLinkedModule] {
137        &self.linked_modules
138    }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub enum CompositionProfile {
143    Core,
144    Demo,
145}
146
147impl CompositionProfile {
148    pub fn parse(value: &str) -> platform_core::AppResult<Self> {
149        match value.trim().to_ascii_lowercase().as_str() {
150            "core" => Ok(Self::Core),
151            "demo" => Ok(Self::Demo),
152            other => Err(AppError::validation(
153                "Invalid Lenso composition profile",
154                vec![ErrorDetail {
155                    field: Some("module_sources.linked_profile".to_owned()),
156                    reason: format!("expected `core` or `demo`, got `{other}`"),
157                }],
158            )),
159        }
160    }
161
162    pub fn from_config(config: &platform_core::AppConfig) -> platform_core::AppResult<Self> {
163        Self::parse(&config.module_sources.linked_profile)
164    }
165}
166
167impl Default for CompositionProfile {
168    fn default() -> Self {
169        Self::Demo
170    }
171}
172
173const CORE_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[LinkedModuleEntry {
174    module_name: "platform-story",
175    manifest: story::module::manifest,
176    load: story::module::module,
177    http_binding: Some(story::module::binding),
178}];
179
180const DEMO_LINKED_MODULE_ENTRIES: &[LinkedModuleEntry] = &[
181    LinkedModuleEntry {
182        module_name: "auth",
183        manifest: auth::module::manifest,
184        load: auth::module::module,
185        http_binding: Some(auth::module::binding),
186    },
187    LinkedModuleEntry {
188        module_name: "auth-password",
189        manifest: auth_password::module::manifest,
190        load: auth_password::module::module,
191        http_binding: Some(auth_password::module::binding),
192    },
193    LinkedModuleEntry {
194        module_name: "platform-story",
195        manifest: story::module::manifest,
196        load: story::module::module,
197        http_binding: Some(story::module::binding),
198    },
199];
200
201fn linked_module_entries(profile: CompositionProfile) -> &'static [LinkedModuleEntry] {
202    match profile {
203        CompositionProfile::Core => CORE_LINKED_MODULE_ENTRIES,
204        CompositionProfile::Demo => DEMO_LINKED_MODULE_ENTRIES,
205    }
206}
207
208#[must_use]
209pub const fn auth_linked_module() -> HostLinkedModule {
210    HostLinkedModule::linked(
211        auth::module::MODULE_NAME,
212        auth::module::manifest,
213        auth::module::module,
214        auth::migrations::AUTH_MIGRATIONS,
215    )
216    .with_http_binding(auth::module::binding)
217}
218
219#[must_use]
220pub const fn auth_password_linked_module() -> HostLinkedModule {
221    HostLinkedModule::linked(
222        auth_password::module::MODULE_NAME,
223        auth_password::module::manifest,
224        auth_password::module::module,
225        auth_password::migrations::AUTH_PASSWORD_MIGRATIONS,
226    )
227    .with_http_binding(auth_password::module::binding)
228}
229
230fn linked_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
231    config
232        .modules
233        .get(module_name)
234        .is_none_or(platform_core::ModuleConfig::is_enabled)
235}
236
237fn module_enabled_config_key(module_name: &str) -> String {
238    format!("modules.{module_name}.enabled")
239}
240
241fn linked_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
242    ctx.runtime_config
243        .snapshot()
244        .raw(&module_enabled_config_key(module_name))
245        .and_then(serde_json::Value::as_bool)
246        .unwrap_or_else(|| linked_module_enabled_from_config(&ctx.config, module_name))
247}
248
249fn first_disabled_dependency(ctx: &AppContext, manifest: fn() -> ModuleManifest) -> Option<String> {
250    (manifest)()
251        .dependencies
252        .into_iter()
253        .find(|dependency| !linked_module_enabled(ctx, dependency))
254}
255
256fn first_disabled_dependency_from_config(
257    config: &platform_core::AppConfig,
258    manifest: fn() -> ModuleManifest,
259) -> Option<String> {
260    (manifest)()
261        .dependencies
262        .into_iter()
263        .find(|dependency| !linked_module_enabled_from_config(config, dependency))
264}
265
266fn linked_module_with_dependencies_enabled(
267    ctx: &AppContext,
268    module_name: &str,
269    manifest: fn() -> ModuleManifest,
270) -> bool {
271    linked_module_enabled(ctx, module_name) && first_disabled_dependency(ctx, manifest).is_none()
272}
273
274fn linked_module_with_dependencies_enabled_from_config(
275    config: &platform_core::AppConfig,
276    module_name: &str,
277    manifest: fn() -> ModuleManifest,
278) -> bool {
279    linked_module_enabled_from_config(config, module_name)
280        && first_disabled_dependency_from_config(config, manifest).is_none()
281}
282
283fn linked_module_disabled_reason(
284    ctx: &AppContext,
285    module_name: &str,
286    manifest: fn() -> ModuleManifest,
287) -> Option<String> {
288    if !linked_module_enabled(ctx, module_name) {
289        return Some("module disabled by configuration".to_owned());
290    }
291    if let Some(dependency) = first_disabled_dependency(ctx, manifest) {
292        return Some(format!("module dependency disabled: {dependency}"));
293    }
294    None
295}
296
297fn remote_module_enabled_from_config(config: &platform_core::AppConfig, module_name: &str) -> bool {
298    config
299        .modules
300        .get(module_name)
301        .is_none_or(platform_core::ModuleConfig::is_enabled)
302}
303
304fn remote_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
305    ctx.runtime_config
306        .snapshot()
307        .raw(&module_enabled_config_key(module_name))
308        .and_then(serde_json::Value::as_bool)
309        .unwrap_or_else(|| remote_module_enabled_from_config(&ctx.config, module_name))
310}
311
312pub fn auth_actor_resolver_for_context(
313    ctx: &AppContext,
314) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
315    auth_actor_resolver_for_context_with_composition(ctx, &HostComposition::default())
316}
317
318pub fn auth_actor_resolver_for_context_with_composition(
319    ctx: &AppContext,
320    composition: &HostComposition,
321) -> platform_core::AppResult<Option<Arc<dyn platform_core::ActorResolver>>> {
322    let profile = CompositionProfile::from_config(&ctx.config)?;
323    let auth_in_profile = linked_module_entries(profile)
324        .iter()
325        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
326    let auth_in_composition = composition
327        .linked_modules()
328        .iter()
329        .any(|entry| entry.module_name == auth::module::MODULE_NAME);
330    if (!auth_in_profile && !auth_in_composition)
331        || !linked_module_enabled(ctx, auth::module::MODULE_NAME)
332    {
333        return Ok(None);
334    }
335
336    let auth_resolver: Arc<dyn platform_core::ActorResolver> = Arc::new(
337        auth::resolver::AuthActorResolver::new(ctx.db.clone(), ctx.actor_resolver.clone()),
338    );
339
340    let auth_password_enabled = linked_module_with_dependencies_enabled(
341        ctx,
342        auth_password::module::MODULE_NAME,
343        auth_password::module::manifest,
344    );
345    if auth_password_enabled {
346        if let Some(jwt_resolver) =
347            auth_password::module::jwt_actor_resolver(ctx, auth_resolver.clone())?
348        {
349            return Ok(Some(jwt_resolver));
350        }
351    }
352
353    Ok(Some(auth_resolver))
354}
355
356fn linked_module_entries_for_context(
357    ctx: &AppContext,
358) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
359    Ok(
360        linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
361            .iter()
362            .filter(|entry| {
363                linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
364            })
365            .collect(),
366    )
367}
368
369fn linked_module_entries_for_config(
370    config: &platform_core::AppConfig,
371) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
372    Ok(
373        linked_module_entries(CompositionProfile::from_config(config)?)
374            .iter()
375            .filter(|entry| {
376                linked_module_with_dependencies_enabled_from_config(
377                    config,
378                    entry.module_name,
379                    entry.manifest,
380                )
381            })
382            .collect(),
383    )
384}
385
386fn disabled_linked_module_entries_for_context(
387    ctx: &AppContext,
388) -> platform_core::AppResult<Vec<&'static LinkedModuleEntry>> {
389    Ok(
390        linked_module_entries(CompositionProfile::from_config(&ctx.config)?)
391            .iter()
392            .filter(|entry| {
393                linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
394            })
395            .collect(),
396    )
397}
398
399fn host_linked_modules_for_config(
400    config: &platform_core::AppConfig,
401    composition: &HostComposition,
402) -> Vec<HostLinkedModule> {
403    composition
404        .linked_modules()
405        .iter()
406        .copied()
407        .filter(|entry| {
408            linked_module_with_dependencies_enabled_from_config(
409                config,
410                entry.module_name,
411                entry.manifest,
412            )
413        })
414        .collect()
415}
416
417fn host_linked_modules_for_context(
418    ctx: &AppContext,
419    composition: &HostComposition,
420) -> Vec<HostLinkedModule> {
421    composition
422        .linked_modules()
423        .iter()
424        .copied()
425        .filter(|entry| {
426            linked_module_with_dependencies_enabled(ctx, entry.module_name, entry.manifest)
427        })
428        .collect()
429}
430
431fn disabled_host_linked_modules_for_context(
432    ctx: &AppContext,
433    composition: &HostComposition,
434) -> Vec<HostLinkedModule> {
435    composition
436        .linked_modules()
437        .iter()
438        .copied()
439        .filter(|entry| {
440            linked_module_disabled_reason(ctx, entry.module_name, entry.manifest).is_some()
441        })
442        .collect()
443}
444
445fn load_host_linked_module(ctx: &AppContext, entry: HostLinkedModule) -> Module {
446    match entry.load {
447        Some(load) => load(ctx),
448        None => Module::linked((entry.manifest)(), LinkedBinding::builder().build()),
449    }
450}
451
452/// Demo-default linked modules helper (context-bound: builds bindings).
453///
454/// Startup and config-aware paths should use [`modules_for_config`] or
455/// [`load_modules`] so `module_sources.linked_profile` is honored.
456#[must_use]
457pub fn modules(ctx: &AppContext) -> Vec<Module> {
458    modules_for_profile(ctx, CompositionProfile::default())
459}
460
461pub fn modules_for_config(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
462    Ok(linked_module_entries_for_context(ctx)?
463        .into_iter()
464        .map(|entry| (entry.load)(ctx))
465        .collect())
466}
467
468pub fn modules_for_config_with_composition(
469    ctx: &AppContext,
470    composition: &HostComposition,
471) -> platform_core::AppResult<Vec<Module>> {
472    let mut modules = modules_for_config(ctx)?;
473    modules.extend(
474        host_linked_modules_for_context(ctx, composition)
475            .into_iter()
476            .map(|entry| load_host_linked_module(ctx, entry)),
477    );
478    Ok(modules)
479}
480
481#[must_use]
482pub fn modules_for_profile(ctx: &AppContext, profile: CompositionProfile) -> Vec<Module> {
483    linked_module_entries(profile)
484        .iter()
485        .map(|entry| (entry.load)(ctx))
486        .collect()
487}
488
489/// Load every configured module, including out-of-process remote modules.
490///
491/// The synchronous [`modules`] function remains Linked-only for call sites that
492/// must stay context-local and infallible. Startup paths that can perform IO
493/// should use this async loader.
494pub async fn load_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
495    load_modules_with_composition(ctx, &HostComposition::default()).await
496}
497
498pub async fn load_modules_with_composition(
499    ctx: &AppContext,
500    composition: &HostComposition,
501) -> platform_core::AppResult<Vec<Module>> {
502    let mut loaded = modules_for_config_with_composition(ctx, composition)?;
503
504    for remote in &ctx.config.module_sources.remote {
505        if !remote_module_enabled(ctx, &remote.name) {
506            continue;
507        }
508        let source = RemoteModuleSource::new(remote_module_config(remote))?;
509        loaded.push(source.load().await?);
510    }
511
512    Ok(loaded)
513}
514
515pub fn migrations_for_config(
516    config: &platform_core::AppConfig,
517) -> platform_core::AppResult<Vec<Migration>> {
518    migrations_for_config_with_composition(config, &HostComposition::default())
519}
520
521pub fn migrations_for_config_with_composition(
522    config: &platform_core::AppConfig,
523    composition: &HostComposition,
524) -> platform_core::AppResult<Vec<Migration>> {
525    let mut migrations = PLATFORM_MIGRATIONS
526        .iter()
527        .chain(RUNTIME_MIGRATIONS)
528        .copied()
529        .collect::<Vec<_>>();
530
531    if CompositionProfile::from_config(config)? == CompositionProfile::Demo {
532        if linked_module_enabled_from_config(config, "auth") {
533            migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
534        }
535        if linked_module_with_dependencies_enabled_from_config(
536            config,
537            "auth-password",
538            auth_password::module::manifest,
539        ) {
540            migrations.extend(
541                auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
542                    .iter()
543                    .copied(),
544            );
545        }
546    }
547
548    for module in host_linked_modules_for_config(config, composition) {
549        migrations.extend(module.migrations.iter().copied());
550    }
551
552    Ok(migrations)
553}
554
555#[must_use]
556pub fn migrations_for_profile(profile: CompositionProfile) -> Vec<Migration> {
557    let mut migrations = PLATFORM_MIGRATIONS
558        .iter()
559        .chain(RUNTIME_MIGRATIONS)
560        .copied()
561        .collect::<Vec<_>>();
562
563    if profile == CompositionProfile::Demo {
564        migrations.extend(auth::migrations::AUTH_MIGRATIONS.iter().copied());
565        migrations.extend(
566            auth_password::migrations::AUTH_PASSWORD_MIGRATIONS
567                .iter()
568                .copied(),
569        );
570    }
571
572    migrations
573}
574
575/// Context-free module manifests for read-only / OpenAPI paths that have no
576/// [`AppContext`]. Kept in sync with [`modules`] by listing the same modules.
577#[must_use]
578pub fn module_manifests() -> Vec<ModuleManifest> {
579    module_manifests_for_profile(CompositionProfile::default())
580}
581
582#[must_use]
583pub fn module_manifests_for_profile(profile: CompositionProfile) -> Vec<ModuleManifest> {
584    linked_module_entries(profile)
585        .iter()
586        .map(|entry| (entry.manifest)())
587        .collect()
588}
589
590/// Runtime function declaration sources for context-free linked modules.
591#[must_use]
592pub fn linked_runtime_function_declaration_sources() -> Vec<(
593    String,
594    ModuleSource,
595    Option<platform_module::RuntimeSurface>,
596)> {
597    linked_runtime_function_declaration_sources_for_profile(CompositionProfile::default())
598}
599
600#[must_use]
601pub fn linked_runtime_function_declaration_sources_for_profile(
602    profile: CompositionProfile,
603) -> Vec<(
604    String,
605    ModuleSource,
606    Option<platform_module::RuntimeSurface>,
607)> {
608    module_manifests_for_profile(profile)
609        .into_iter()
610        .map(|manifest| (manifest.name, ModuleSource::Linked, manifest.runtime))
611        .collect()
612}
613
614pub fn linked_runtime_function_declaration_sources_for_config(
615    config: &platform_core::AppConfig,
616) -> platform_core::AppResult<
617    Vec<(
618        String,
619        ModuleSource,
620        Option<platform_module::RuntimeSurface>,
621    )>,
622> {
623    Ok(linked_module_entries_for_config(config)?
624        .into_iter()
625        .map(|entry| {
626            let manifest = (entry.manifest)();
627            (manifest.name, ModuleSource::Linked, manifest.runtime)
628        })
629        .collect())
630}
631
632pub fn linked_runtime_function_declaration_sources_for_context(
633    ctx: &AppContext,
634) -> platform_core::AppResult<
635    Vec<(
636        String,
637        ModuleSource,
638        Option<platform_module::RuntimeSurface>,
639    )>,
640> {
641    Ok(linked_module_entries_for_context(ctx)?
642        .into_iter()
643        .map(|entry| {
644            let manifest = (entry.manifest)();
645            (manifest.name, ModuleSource::Linked, manifest.runtime)
646        })
647        .collect())
648}
649
650pub fn linked_runtime_function_declaration_sources_for_context_with_composition(
651    ctx: &AppContext,
652    composition: &HostComposition,
653) -> platform_core::AppResult<
654    Vec<(
655        String,
656        ModuleSource,
657        Option<platform_module::RuntimeSurface>,
658    )>,
659> {
660    let mut sources = linked_runtime_function_declaration_sources_for_context(ctx)?;
661    sources.extend(
662        host_linked_modules_for_context(ctx, composition)
663            .into_iter()
664            .map(|entry| {
665                let manifest = (entry.manifest)();
666                (manifest.name, ModuleSource::Linked, manifest.runtime)
667            }),
668    );
669    Ok(sources)
670}
671
672/// Runtime function declaration sources from loaded module metadata, including
673/// configured remote modules.
674#[must_use]
675pub fn runtime_function_declaration_sources_from_metadata(
676    modules: &[AdminModuleMetadata],
677) -> Vec<(
678    String,
679    ModuleSource,
680    Option<platform_module::RuntimeSurface>,
681)> {
682    modules
683        .iter()
684        .filter(|module| matches!(module.load_status, ModuleLoadStatus::Loaded))
685        .map(|module| {
686            (
687                module.module_name.clone(),
688                module.source,
689                module.runtime.clone(),
690            )
691        })
692        .collect()
693}
694
695/// Public HTTP path ownership for linked modules.
696///
697/// Projected from context-free linked modules so OpenAPI guards and router
698/// assembly consume the same source-specific binding data.
699#[derive(Debug, Clone, PartialEq, Eq)]
700pub struct LinkedHttpRouteOwner {
701    pub module_name: String,
702    pub public_prefixes: &'static [&'static str],
703}
704
705#[must_use]
706pub fn linked_http_route_owners() -> Vec<LinkedHttpRouteOwner> {
707    linked_http_route_owners_for_profile(CompositionProfile::default())
708}
709
710#[must_use]
711pub fn linked_http_route_owners_for_profile(
712    profile: CompositionProfile,
713) -> Vec<LinkedHttpRouteOwner> {
714    linked_module_entries(profile)
715        .iter()
716        .filter_map(|entry| {
717            let http = entry.http_binding?().http?;
718            Some(LinkedHttpRouteOwner {
719                module_name: entry.module_name.to_owned(),
720                public_prefixes: http.public_prefixes,
721            })
722        })
723        .collect()
724}
725
726/// Context-free linked modules that contribute Axum/OpenAPI HTTP routers.
727#[must_use]
728pub fn linked_http_modules() -> Vec<Module> {
729    linked_http_modules_for_profile(CompositionProfile::default())
730}
731
732#[must_use]
733pub fn linked_http_modules_for_profile(profile: CompositionProfile) -> Vec<Module> {
734    linked_module_entries(profile)
735        .iter()
736        .filter_map(|entry| {
737            let http_binding = entry.http_binding?;
738            Some(Module::linked((entry.manifest)(), http_binding()))
739        })
740        .collect()
741}
742
743pub fn linked_http_modules_for_config(
744    config: &platform_core::AppConfig,
745) -> platform_core::AppResult<Vec<Module>> {
746    Ok(linked_module_entries_for_config(config)?
747        .into_iter()
748        .filter_map(|entry| {
749            let http_binding = entry.http_binding?;
750            Some(Module::linked((entry.manifest)(), http_binding()))
751        })
752        .collect())
753}
754
755pub fn linked_http_modules_for_context(ctx: &AppContext) -> platform_core::AppResult<Vec<Module>> {
756    Ok(linked_module_entries_for_context(ctx)?
757        .into_iter()
758        .filter_map(|entry| {
759            let http_binding = entry.http_binding?;
760            Some(Module::linked((entry.manifest)(), http_binding()))
761        })
762        .collect())
763}
764
765pub fn linked_http_modules_for_context_with_composition(
766    ctx: &AppContext,
767    composition: &HostComposition,
768) -> platform_core::AppResult<Vec<Module>> {
769    let mut modules = linked_http_modules_for_context(ctx)?;
770    modules.extend(
771        host_linked_modules_for_context(ctx, composition)
772            .into_iter()
773            .filter_map(|entry| {
774                let http_binding = entry.http_binding?;
775                Some(Module::linked((entry.manifest)(), http_binding()))
776            }),
777    );
778    Ok(modules)
779}
780
781/// Aggregate admin-capable modules: those declaring an admin surface and
782/// providing either an `AdminDataSource` or an `AdminActionSource`. Modules
783/// without an admin behavior source are filtered out — "optional capability"
784/// semantics.
785#[must_use]
786pub fn admin_modules(ctx: &AppContext) -> Vec<AdminModule> {
787    admin_modules_from_modules(modules(ctx))
788}
789
790/// Load schema-admin capable modules, including configured remotes.
791pub async fn load_admin_modules(ctx: &AppContext) -> platform_core::AppResult<Vec<AdminModule>> {
792    load_admin_modules_with_composition(ctx, &HostComposition::default()).await
793}
794
795pub async fn load_admin_modules_with_composition(
796    ctx: &AppContext,
797    composition: &HostComposition,
798) -> platform_core::AppResult<Vec<AdminModule>> {
799    let mut admin_modules =
800        admin_modules_from_modules(modules_for_config_with_composition(ctx, composition)?);
801
802    for remote in &ctx.config.module_sources.remote {
803        if !remote_module_enabled(ctx, &remote.name) {
804            continue;
805        }
806        let source = RemoteModuleSource::new(remote_module_config(remote))?;
807        match source.load().await {
808            Ok(module) => admin_modules.extend(admin_modules_from_modules(vec![module])),
809            Err(error) => admin_modules.push(failed_remote_admin_module(
810                remote.name.clone(),
811                error.public_message,
812            )),
813        }
814    }
815
816    Ok(admin_modules)
817}
818
819/// Load registry metadata for every configured module, including modules with
820/// no admin surface and custom surfaces not consumable by schema-admin
821/// list/detail.
822pub async fn load_admin_module_metadata(
823    ctx: &AppContext,
824) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
825    load_admin_module_metadata_with_composition(ctx, &HostComposition::default()).await
826}
827
828pub async fn load_admin_module_metadata_with_composition(
829    ctx: &AppContext,
830    composition: &HostComposition,
831) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
832    let mut metadata =
833        admin_metadata_from_modules(modules_for_config_with_composition(ctx, composition)?);
834    metadata.extend(disabled_linked_admin_metadata(ctx)?);
835    metadata.extend(disabled_host_linked_admin_metadata(ctx, composition));
836
837    for remote in &ctx.config.module_sources.remote {
838        let config = remote_module_config(remote);
839        if !remote_module_enabled(ctx, &remote.name) {
840            metadata.push(disabled_remote_admin_metadata(&config));
841            continue;
842        }
843        let checked_at = current_timestamp();
844        let source = RemoteModuleSource::new(config.clone())?;
845        let load_started = Instant::now();
846        match source.load().await {
847            Ok(module) => metadata.extend(remote_admin_metadata_from_module(
848                module,
849                &config,
850                checked_at,
851                Some(duration_ms(load_started)),
852                None,
853            )),
854            Err(error) => metadata.push(failed_remote_admin_metadata(
855                &config,
856                Some(checked_at),
857                Some(duration_ms(load_started)),
858                error.public_message,
859            )),
860        }
861    }
862
863    Ok(metadata)
864}
865
866pub async fn load_remote_http_proxy_registry(
867    ctx: &AppContext,
868) -> platform_core::AppResult<RemoteHttpProxyRegistry> {
869    let mut remote_modules = Vec::new();
870    let mut remote_configs = Vec::new();
871
872    for remote in &ctx.config.module_sources.remote {
873        if !remote_module_enabled(ctx, &remote.name) {
874            continue;
875        }
876        let config = remote_module_config(remote);
877        let source = RemoteModuleSource::new(config.clone())?;
878        if let Ok(module) = source.load().await {
879            remote_modules.push(module);
880            remote_configs.push(config);
881        }
882    }
883
884    Ok(RemoteHttpProxyRegistry::from_modules(
885        &remote_modules,
886        &remote_configs,
887    ))
888}
889
890fn admin_modules_from_modules(modules: Vec<Module>) -> Vec<AdminModule> {
891    modules
892        .into_iter()
893        .filter_map(|module| {
894            // `modules(ctx)` yields owned Modules — move the fields out.
895            let data_source = module.admin_data;
896            let action_source = module.admin_actions;
897            if data_source.is_none() && action_source.is_none() {
898                return None;
899            }
900            let ModuleManifest { name, admin, .. } = module.manifest;
901            let admin = admin?;
902            let (schema, listed_in_schema) = match &admin {
903                AdminSurface::Schema(schema) => (schema.clone(), true),
904                AdminSurface::DeclarativeCustom(surface) => (
905                    surface.fallback_schema.clone().unwrap_or(AdminSchema {
906                        entities: Vec::new(),
907                    }),
908                    false,
909                ),
910                AdminSurface::EmbeddedCustom(_) => return None,
911                _ => return None,
912            };
913            Some(AdminModule {
914                module_name: name,
915                source: module.source,
916                load_status: module.load_status,
917                schema,
918                admin: Some(admin),
919                listed_in_schema,
920                data_source,
921                action_source,
922            })
923        })
924        .collect()
925}
926
927fn admin_metadata_from_modules(modules: Vec<Module>) -> Vec<AdminModuleMetadata> {
928    modules
929        .into_iter()
930        .map(|module| {
931            let ModuleManifest {
932                name,
933                admin,
934                http_routes,
935                runtime,
936                events,
937                lifecycle,
938                console,
939                story_display,
940                capabilities,
941                dependencies,
942                ..
943            } = module.manifest;
944            AdminModuleMetadata {
945                module_name: name,
946                source: module.source,
947                load_status: module.load_status,
948                http_routes,
949                runtime,
950                events,
951                lifecycle,
952                console,
953                story_display,
954                capabilities,
955                dependencies,
956                admin,
957                source_diagnostics: None,
958            }
959        })
960        .collect()
961}
962
963fn failed_remote_admin_module(name: String, message: String) -> AdminModule {
964    AdminModule {
965        module_name: name,
966        source: ModuleSource::Remote,
967        load_status: ModuleLoadStatus::Error { message },
968        schema: AdminSchema {
969            entities: Vec::new(),
970        },
971        admin: None,
972        listed_in_schema: true,
973        data_source: None,
974        action_source: None,
975    }
976}
977
978fn remote_admin_metadata_from_module(
979    module: Module,
980    config: &RemoteModuleConfig,
981    checked_at: String,
982    load_duration_ms: Option<u64>,
983    load_error: Option<String>,
984) -> Vec<AdminModuleMetadata> {
985    admin_metadata_from_modules(vec![module])
986        .into_iter()
987        .map(|mut metadata| {
988            metadata.source_diagnostics = Some(remote_source_diagnostics(
989                config,
990                Some(checked_at.clone()),
991                load_duration_ms,
992                load_error.clone(),
993            ));
994            metadata
995        })
996        .collect()
997}
998
999fn failed_remote_admin_metadata(
1000    config: &RemoteModuleConfig,
1001    checked_at: Option<String>,
1002    load_duration_ms: Option<u64>,
1003    message: String,
1004) -> AdminModuleMetadata {
1005    AdminModuleMetadata {
1006        module_name: config.name.clone(),
1007        source: ModuleSource::Remote,
1008        load_status: ModuleLoadStatus::Error {
1009            message: message.clone(),
1010        },
1011        http_routes: Vec::new(),
1012        runtime: None,
1013        events: None,
1014        lifecycle: None,
1015        console: Vec::new(),
1016        story_display: Vec::new(),
1017        capabilities: Vec::new(),
1018        dependencies: Vec::new(),
1019        admin: None,
1020        source_diagnostics: Some(remote_source_diagnostics(
1021            config,
1022            checked_at,
1023            load_duration_ms,
1024            Some(message),
1025        )),
1026    }
1027}
1028
1029fn disabled_remote_admin_metadata(config: &RemoteModuleConfig) -> AdminModuleMetadata {
1030    AdminModuleMetadata {
1031        module_name: config.name.clone(),
1032        source: ModuleSource::Remote,
1033        load_status: ModuleLoadStatus::Error {
1034            message: "module disabled by configuration".to_owned(),
1035        },
1036        http_routes: Vec::new(),
1037        runtime: None,
1038        events: None,
1039        lifecycle: None,
1040        console: Vec::new(),
1041        story_display: Vec::new(),
1042        capabilities: Vec::new(),
1043        dependencies: Vec::new(),
1044        admin: None,
1045        source_diagnostics: Some(remote_source_diagnostics(config, None, None, None)),
1046    }
1047}
1048
1049fn disabled_linked_admin_metadata(
1050    ctx: &AppContext,
1051) -> platform_core::AppResult<Vec<AdminModuleMetadata>> {
1052    Ok(disabled_linked_module_entries_for_context(ctx)?
1053        .into_iter()
1054        .map(|entry| {
1055            let ModuleManifest {
1056                name,
1057                admin,
1058                http_routes,
1059                runtime,
1060                events,
1061                lifecycle,
1062                console,
1063                story_display,
1064                capabilities,
1065                dependencies,
1066                ..
1067            } = (entry.manifest)();
1068            AdminModuleMetadata {
1069                module_name: name,
1070                source: ModuleSource::Linked,
1071                load_status: ModuleLoadStatus::Error {
1072                    message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1073                        .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1074                },
1075                http_routes,
1076                runtime,
1077                events,
1078                lifecycle,
1079                console,
1080                story_display,
1081                capabilities,
1082                dependencies,
1083                admin,
1084                source_diagnostics: None,
1085            }
1086        })
1087        .collect())
1088}
1089
1090fn disabled_host_linked_admin_metadata(
1091    ctx: &AppContext,
1092    composition: &HostComposition,
1093) -> Vec<AdminModuleMetadata> {
1094    disabled_host_linked_modules_for_context(ctx, composition)
1095        .into_iter()
1096        .map(|entry| {
1097            let ModuleManifest {
1098                name,
1099                admin,
1100                http_routes,
1101                runtime,
1102                events,
1103                lifecycle,
1104                console,
1105                story_display,
1106                capabilities,
1107                dependencies,
1108                ..
1109            } = (entry.manifest)();
1110            AdminModuleMetadata {
1111                module_name: name,
1112                source: ModuleSource::Linked,
1113                load_status: ModuleLoadStatus::Error {
1114                    message: linked_module_disabled_reason(ctx, entry.module_name, entry.manifest)
1115                        .unwrap_or_else(|| "module disabled by configuration".to_owned()),
1116                },
1117                http_routes,
1118                runtime,
1119                events,
1120                lifecycle,
1121                console,
1122                story_display,
1123                capabilities,
1124                dependencies,
1125                admin,
1126                source_diagnostics: None,
1127            }
1128        })
1129        .collect()
1130}
1131
1132fn remote_source_diagnostics(
1133    config: &RemoteModuleConfig,
1134    checked_at: Option<String>,
1135    load_duration_ms: Option<u64>,
1136    load_error: Option<String>,
1137) -> AdminModuleSourceDiagnostics {
1138    let (transport, manifest_url) = match config.transport {
1139        platform_module_remote::RemoteModuleTransport::HttpJson => {
1140            ("http_json", format!("{}/manifest", config.base_url))
1141        }
1142        platform_module_remote::RemoteModuleTransport::Grpc => (
1143            "grpc",
1144            format!(
1145                "{}#lenso.remote.v1.RemoteModule/GetManifest",
1146                config.base_url
1147            ),
1148        ),
1149    };
1150    AdminModuleSourceDiagnostics::Remote(AdminRemoteModuleDiagnostics {
1151        transport: transport.to_owned(),
1152        base_url: config.base_url.clone(),
1153        manifest_url,
1154        timeout_ms: config.timeout_ms,
1155        auth_configured: config.auth_token.is_some(),
1156        load_duration_ms,
1157        last_checked_at: checked_at,
1158        last_load_error: load_error,
1159    })
1160}
1161
1162fn remote_module_config(source: &platform_core::RemoteModuleSourceConfig) -> RemoteModuleConfig {
1163    let mut config = RemoteModuleConfig::new(source.name.clone(), source.base_url.clone())
1164        .with_timeout_ms(source.timeout_ms);
1165
1166    if let Some(env_name) = &source.auth_token_env {
1167        if let Ok(token) = std::env::var(env_name) {
1168            config = config.with_auth_token(token);
1169        }
1170    }
1171
1172    config
1173}
1174
1175#[derive(Debug)]
1176pub struct RemoteModuleServiceSupervisor {
1177    services: Vec<RemoteModuleServiceHandle>,
1178}
1179
1180impl RemoteModuleServiceSupervisor {
1181    #[must_use]
1182    pub fn is_empty(&self) -> bool {
1183        self.services.is_empty()
1184    }
1185}
1186
1187impl Drop for RemoteModuleServiceSupervisor {
1188    fn drop(&mut self) {
1189        for service in &mut self.services {
1190            terminate_remote_module_service(&mut service.child);
1191            release_remote_module_service_state(&service.lock_file_path, &service.pid_file_path);
1192        }
1193    }
1194}
1195
1196#[derive(Debug)]
1197struct RemoteModuleServiceHandle {
1198    child: Child,
1199    lock_file_path: PathBuf,
1200    pid_file_path: PathBuf,
1201}
1202
1203#[derive(Debug, Clone, PartialEq, Eq)]
1204struct RemoteModuleServiceSpec {
1205    module_name: String,
1206    service_name: String,
1207    command: String,
1208    cwd: Option<PathBuf>,
1209    ready_url: String,
1210    ready_timeout_ms: u64,
1211    auto_start: bool,
1212}
1213
1214pub async fn start_installed_remote_module_services(
1215    ctx: &AppContext,
1216) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1217    start_installed_remote_module_services_from_path(ctx, Path::new(DEFAULT_MODULE_SERVICES_FILE))
1218        .await
1219}
1220
1221pub async fn start_installed_remote_module_services_from_path(
1222    ctx: &AppContext,
1223    services_file_path: &Path,
1224) -> platform_core::AppResult<RemoteModuleServiceSupervisor> {
1225    let specs = read_remote_module_service_specs(services_file_path)?;
1226    let services_state_dir = services_file_path
1227        .parent()
1228        .unwrap_or_else(|| Path::new("."));
1229    let client = reqwest::Client::builder()
1230        .timeout(Duration::from_millis(800))
1231        .build()
1232        .map_err(|source| {
1233            AppError::new(ErrorCode::Internal, "failed to build HTTP client").with_source(source)
1234        })?;
1235    let mut services = Vec::new();
1236
1237    for spec in specs {
1238        if !spec.auto_start || !remote_service_module_enabled(ctx, &spec.module_name) {
1239            continue;
1240        }
1241        if remote_service_ready(&client, &spec.ready_url).await {
1242            tracing::info!(
1243                module = %spec.module_name,
1244                service = %spec.service_name,
1245                ready_url = %spec.ready_url,
1246                "remote module service already ready"
1247            );
1248            continue;
1249        }
1250        let lock_file_path = remote_module_service_state_path(services_state_dir, &spec, "lock");
1251        let pid_file_path = remote_module_service_state_path(services_state_dir, &spec, "pid");
1252        if !claim_remote_module_service_lock(&client, &spec, &lock_file_path, &pid_file_path)
1253            .await?
1254        {
1255            continue;
1256        }
1257        let mut child = match spawn_remote_module_service(&spec) {
1258            Ok(child) => child,
1259            Err(error) => {
1260                release_remote_module_service_state(&lock_file_path, &pid_file_path);
1261                return Err(error);
1262            }
1263        };
1264        if let Err(error) = write_remote_module_service_pid(&pid_file_path, child.id()) {
1265            terminate_remote_module_service(&mut child);
1266            release_remote_module_service_state(&lock_file_path, &pid_file_path);
1267            return Err(error);
1268        }
1269        if let Err(error) = wait_for_remote_module_service(&client, &spec, &mut child).await {
1270            terminate_remote_module_service(&mut child);
1271            release_remote_module_service_state(&lock_file_path, &pid_file_path);
1272            return Err(error);
1273        }
1274        tracing::info!(
1275            module = %spec.module_name,
1276            service = %spec.service_name,
1277            ready_url = %spec.ready_url,
1278            "started remote module service"
1279        );
1280        services.push(RemoteModuleServiceHandle {
1281            child,
1282            lock_file_path,
1283            pid_file_path,
1284        });
1285    }
1286
1287    Ok(RemoteModuleServiceSupervisor { services })
1288}
1289
1290fn remote_service_module_enabled(ctx: &AppContext, module_name: &str) -> bool {
1291    ctx.config
1292        .module_sources
1293        .remote
1294        .iter()
1295        .any(|remote| remote.name == module_name)
1296        && remote_module_enabled(ctx, module_name)
1297}
1298
1299fn read_remote_module_service_specs(
1300    services_file_path: &Path,
1301) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1302    let source = match std::fs::read_to_string(services_file_path) {
1303        Ok(source) => source,
1304        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1305        Err(source) => {
1306            return Err(AppError::new(
1307                ErrorCode::ExternalDependency,
1308                format!("remote module services file could not be read: {source}"),
1309            ));
1310        }
1311    };
1312    let value = serde_json::from_str::<serde_json::Value>(&source).map_err(|source| {
1313        AppError::new(
1314            ErrorCode::Validation,
1315            format!("remote module services file could not be parsed: {source}"),
1316        )
1317    })?;
1318    parse_remote_module_service_specs(&value)
1319}
1320
1321fn parse_remote_module_service_specs(
1322    value: &serde_json::Value,
1323) -> platform_core::AppResult<Vec<RemoteModuleServiceSpec>> {
1324    let modules = value
1325        .get("modules")
1326        .and_then(serde_json::Value::as_array)
1327        .ok_or_else(|| {
1328            AppError::new(
1329                ErrorCode::Validation,
1330                "remote module services file modules must be an array",
1331            )
1332        })?;
1333    let mut specs = Vec::new();
1334    for module in modules {
1335        let module_name = json_string(module, "moduleName")?;
1336        let services = module
1337            .get("services")
1338            .and_then(serde_json::Value::as_array)
1339            .ok_or_else(|| {
1340                AppError::new(
1341                    ErrorCode::Validation,
1342                    format!("{module_name} services must be an array"),
1343                )
1344            })?;
1345        for service in services {
1346            let command = json_string(service, "command")?;
1347            let ready_url = json_string(service, "readyUrl")?;
1348            specs.push(RemoteModuleServiceSpec {
1349                module_name: module_name.clone(),
1350                service_name: service
1351                    .get("name")
1352                    .and_then(serde_json::Value::as_str)
1353                    .unwrap_or(&module_name)
1354                    .to_owned(),
1355                command,
1356                cwd: service
1357                    .get("cwd")
1358                    .and_then(serde_json::Value::as_str)
1359                    .map(PathBuf::from),
1360                ready_url,
1361                ready_timeout_ms: service
1362                    .get("readyTimeoutMs")
1363                    .and_then(serde_json::Value::as_u64)
1364                    .unwrap_or(DEFAULT_REMOTE_SERVICE_READY_TIMEOUT_MS),
1365                auto_start: service
1366                    .get("autoStart")
1367                    .and_then(serde_json::Value::as_bool)
1368                    .unwrap_or(true),
1369            });
1370        }
1371    }
1372    Ok(specs)
1373}
1374
1375fn json_string(value: &serde_json::Value, key: &str) -> platform_core::AppResult<String> {
1376    value
1377        .get(key)
1378        .and_then(serde_json::Value::as_str)
1379        .map(str::to_owned)
1380        .ok_or_else(|| AppError::new(ErrorCode::Validation, format!("{key} must be a string")))
1381}
1382
1383fn spawn_remote_module_service(spec: &RemoteModuleServiceSpec) -> platform_core::AppResult<Child> {
1384    let cwd = spec
1385        .cwd
1386        .clone()
1387        .unwrap_or(std::env::current_dir().map_err(|source| {
1388            AppError::new(ErrorCode::Internal, "failed to resolve current directory")
1389                .with_source(source)
1390        })?);
1391    let mut command = shell_command(&spec.command);
1392    command.current_dir(cwd);
1393    configure_remote_module_service_process(&mut command);
1394    command.spawn().map_err(|source| {
1395        AppError::new(
1396            ErrorCode::ExternalDependency,
1397            format!(
1398                "failed to start remote module service {}: {}",
1399                spec.module_name, spec.service_name
1400            ),
1401        )
1402        .with_source(source)
1403    })
1404}
1405
1406async fn wait_for_remote_module_service(
1407    client: &reqwest::Client,
1408    spec: &RemoteModuleServiceSpec,
1409    child: &mut Child,
1410) -> platform_core::AppResult<()> {
1411    let started = Instant::now();
1412    let timeout = Duration::from_millis(spec.ready_timeout_ms);
1413    loop {
1414        if remote_service_ready(client, &spec.ready_url).await {
1415            return Ok(());
1416        }
1417        if let Some(status) = child.try_wait().map_err(|source| {
1418            AppError::new(
1419                ErrorCode::ExternalDependency,
1420                format!(
1421                    "remote module service {} status could not be checked",
1422                    spec.service_name
1423                ),
1424            )
1425            .with_source(source)
1426        })? {
1427            return Err(AppError::new(
1428                ErrorCode::ExternalDependency,
1429                format!(
1430                    "remote module service {} exited before it became ready: {status}",
1431                    spec.service_name
1432                ),
1433            ));
1434        }
1435        if started.elapsed() >= timeout {
1436            return Err(AppError::new(
1437                ErrorCode::ExternalDependency,
1438                format!(
1439                    "remote module service {} did not become ready at {}",
1440                    spec.service_name, spec.ready_url
1441                ),
1442            ));
1443        }
1444        tokio::time::sleep(Duration::from_millis(200)).await;
1445    }
1446}
1447
1448async fn remote_service_ready(client: &reqwest::Client, ready_url: &str) -> bool {
1449    client
1450        .get(ready_url)
1451        .send()
1452        .await
1453        .is_ok_and(|response| response.status().is_success())
1454}
1455
1456async fn claim_remote_module_service_lock(
1457    client: &reqwest::Client,
1458    spec: &RemoteModuleServiceSpec,
1459    lock_file_path: &Path,
1460    pid_file_path: &Path,
1461) -> platform_core::AppResult<bool> {
1462    match create_remote_module_service_lock(lock_file_path) {
1463        Ok(()) => return Ok(true),
1464        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
1465        Err(source) => {
1466            return Err(AppError::new(
1467                ErrorCode::ExternalDependency,
1468                format!(
1469                    "remote module service {} lock could not be created: {source}",
1470                    spec.service_name
1471                ),
1472            ));
1473        }
1474    }
1475
1476    tracing::info!(
1477        module = %spec.module_name,
1478        service = %spec.service_name,
1479        ready_url = %spec.ready_url,
1480        "remote module service startup already claimed"
1481    );
1482    if wait_for_remote_module_service_ready(
1483        client,
1484        &spec.ready_url,
1485        Duration::from_millis(spec.ready_timeout_ms),
1486    )
1487    .await
1488    {
1489        return Ok(false);
1490    }
1491
1492    tracing::warn!(
1493        module = %spec.module_name,
1494        service = %spec.service_name,
1495        lock_file = %lock_file_path.display(),
1496        "remote module service lock did not become ready before timeout; treating it as stale"
1497    );
1498    terminate_stale_remote_module_service(pid_file_path);
1499    release_remote_module_service_state(lock_file_path, pid_file_path);
1500    match create_remote_module_service_lock(lock_file_path) {
1501        Ok(()) => Ok(true),
1502        Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
1503        Err(source) => Err(AppError::new(
1504            ErrorCode::ExternalDependency,
1505            format!(
1506                "stale remote module service {} lock could not be replaced: {source}",
1507                spec.service_name
1508            ),
1509        )),
1510    }
1511}
1512
1513fn create_remote_module_service_lock(lock_file_path: &Path) -> std::io::Result<()> {
1514    if let Some(parent) = lock_file_path.parent() {
1515        fs::create_dir_all(parent)?;
1516    }
1517    let mut file = OpenOptions::new()
1518        .write(true)
1519        .create_new(true)
1520        .open(lock_file_path)?;
1521    writeln!(file, "owner_pid={}", std::process::id())?;
1522    Ok(())
1523}
1524
1525fn write_remote_module_service_pid(
1526    pid_file_path: &Path,
1527    child_pid: u32,
1528) -> platform_core::AppResult<()> {
1529    if let Some(parent) = pid_file_path.parent() {
1530        fs::create_dir_all(parent).map_err(|source| {
1531            AppError::new(
1532                ErrorCode::ExternalDependency,
1533                format!("remote module service pid directory could not be created: {source}"),
1534            )
1535        })?;
1536    }
1537    fs::write(pid_file_path, format!("{child_pid}\n")).map_err(|source| {
1538        AppError::new(
1539            ErrorCode::ExternalDependency,
1540            format!("remote module service pid file could not be written: {source}"),
1541        )
1542    })
1543}
1544
1545fn release_remote_module_service_state(lock_file_path: &Path, pid_file_path: &Path) {
1546    let _ = fs::remove_file(pid_file_path);
1547    let _ = fs::remove_file(lock_file_path);
1548}
1549
1550#[cfg(unix)]
1551fn terminate_stale_remote_module_service(pid_file_path: &Path) {
1552    let Ok(source) = fs::read_to_string(pid_file_path) else {
1553        return;
1554    };
1555    let Ok(pid) = source.trim().parse::<u32>() else {
1556        return;
1557    };
1558
1559    let _ = Command::new("kill")
1560        .arg("-TERM")
1561        .arg(format!("-{pid}"))
1562        .status();
1563    thread::sleep(Duration::from_millis(100));
1564}
1565
1566#[cfg(not(unix))]
1567fn terminate_stale_remote_module_service(_pid_file_path: &Path) {}
1568
1569async fn wait_for_remote_module_service_ready(
1570    client: &reqwest::Client,
1571    ready_url: &str,
1572    timeout: Duration,
1573) -> bool {
1574    let started = Instant::now();
1575    loop {
1576        if remote_service_ready(client, ready_url).await {
1577            return true;
1578        }
1579        if started.elapsed() >= timeout {
1580            return false;
1581        }
1582        tokio::time::sleep(Duration::from_millis(200)).await;
1583    }
1584}
1585
1586fn remote_module_service_state_path(
1587    services_state_dir: &Path,
1588    spec: &RemoteModuleServiceSpec,
1589    extension: &str,
1590) -> PathBuf {
1591    services_state_dir.join(format!(
1592        "remote-{}-{}.{}",
1593        remote_module_service_state_segment(&spec.module_name),
1594        remote_module_service_state_segment(&spec.service_name),
1595        extension
1596    ))
1597}
1598
1599fn remote_module_service_state_segment(value: &str) -> String {
1600    let mut segment = String::new();
1601    let mut previous_dash = false;
1602    for character in value.chars() {
1603        if character.is_ascii_alphanumeric() {
1604            segment.push(character.to_ascii_lowercase());
1605            previous_dash = false;
1606        } else if !segment.is_empty() && !previous_dash {
1607            segment.push('-');
1608            previous_dash = true;
1609        }
1610    }
1611    while segment.ends_with('-') {
1612        segment.pop();
1613    }
1614    if segment.is_empty() {
1615        "service".to_owned()
1616    } else {
1617        segment
1618    }
1619}
1620
1621fn terminate_remote_module_service(child: &mut Child) {
1622    if matches!(child.try_wait(), Ok(Some(_))) {
1623        return;
1624    }
1625
1626    #[cfg(unix)]
1627    {
1628        let process_group_id = child.id();
1629        let _ = Command::new("kill")
1630            .arg("-TERM")
1631            .arg(format!("-{process_group_id}"))
1632            .status();
1633        if wait_for_remote_module_service_exit(
1634            child,
1635            Duration::from_millis(REMOTE_SERVICE_TERMINATE_GRACE_MS),
1636        ) {
1637            return;
1638        }
1639    }
1640
1641    let _ = child.kill();
1642    let _ = child.wait();
1643}
1644
1645fn wait_for_remote_module_service_exit(child: &mut Child, timeout: Duration) -> bool {
1646    let started = Instant::now();
1647    loop {
1648        match child.try_wait() {
1649            Ok(Some(_)) => return true,
1650            Ok(None) => {}
1651            Err(_) => return true,
1652        }
1653        if started.elapsed() >= timeout {
1654            return false;
1655        }
1656        thread::sleep(Duration::from_millis(50));
1657    }
1658}
1659
1660#[cfg(unix)]
1661fn configure_remote_module_service_process(command: &mut Command) {
1662    use std::os::unix::process::CommandExt;
1663    command.process_group(0);
1664}
1665
1666#[cfg(not(unix))]
1667fn configure_remote_module_service_process(_command: &mut Command) {}
1668
1669fn shell_command(command: &str) -> Command {
1670    if cfg!(windows) {
1671        let mut process = Command::new("cmd");
1672        process.arg("/C").arg(command);
1673        process
1674    } else {
1675        let mut process = Command::new("sh");
1676        process.arg("-c").arg(command);
1677        process
1678    }
1679}
1680
1681fn current_timestamp() -> String {
1682    use platform_core::Clock;
1683    platform_core::SystemClock.now().to_rfc3339()
1684}
1685
1686fn duration_ms(started: Instant) -> u64 {
1687    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
1688}
1689
1690/// Build a [`FunctionRegistry`] from every module's binding.
1691#[must_use]
1692pub fn function_registry(modules: &[Module]) -> FunctionRegistry {
1693    let mut registry = FunctionRegistry::default();
1694    for module in modules {
1695        module.binding.register_functions(&mut registry);
1696    }
1697    registry
1698}
1699
1700/// Validate and enqueue every startup activation job declared by loaded modules.
1701///
1702/// Lifecycle activation is host-owned: module manifests declare the work, and
1703/// the Lenso bootstrap validates those declarations against the runtime registry
1704/// before scheduling function runs.
1705pub async fn enqueue_lifecycle_activation_jobs(
1706    ctx: &AppContext,
1707    modules: &[Module],
1708    registry: &FunctionRegistry,
1709) -> platform_core::AppResult<Vec<String>> {
1710    validate_lifecycle_activation_jobs(modules, registry)?;
1711
1712    let client = RuntimeClient::new(ctx.db.clone());
1713    let mut run_ids = Vec::new();
1714
1715    for module in modules {
1716        let Some(lifecycle) = &module.manifest.lifecycle else {
1717            continue;
1718        };
1719
1720        for job in &lifecycle.activation_jobs {
1721            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1722                continue;
1723            }
1724            if !module_declares_runtime_function(module, &job.function_name) {
1725                continue;
1726            }
1727
1728            let Some(definition) = registry.get(&job.function_name) else {
1729                continue;
1730            };
1731
1732            let enqueue_result = client
1733                .enqueue_function(EnqueueFunctionRequest {
1734                    function_name: job.function_name.clone(),
1735                    input_json: job.input.clone(),
1736                    correlation_id: CorrelationId::new(ctx.ids.new_id("corr_lifecycle")),
1737                    actor: ActorContext::Service {
1738                        service_id: "worker".to_owned(),
1739                        scopes: vec!["runtime.functions.enqueue".to_owned()],
1740                    },
1741                    trace: TraceContext::default(),
1742                    causation_id: Some(format!(
1743                        "module_lifecycle:{}:{}",
1744                        module.manifest.name, job.name
1745                    )),
1746                    max_attempts: Some(runtime_max_attempts_for_enqueue(
1747                        definition.retry_policy.max_attempts,
1748                    )),
1749                })
1750                .await;
1751
1752            match enqueue_result {
1753                Ok(run_id) => run_ids.push(run_id),
1754                Err(error) if job.required => return Err(error),
1755                Err(error) => warn_optional_lifecycle_enqueue_failure(
1756                    &module.manifest.name,
1757                    &job.name,
1758                    &job.function_name,
1759                    &error,
1760                ),
1761            }
1762        }
1763    }
1764
1765    Ok(run_ids)
1766}
1767
1768fn validate_lifecycle_activation_jobs(
1769    modules: &[Module],
1770    registry: &FunctionRegistry,
1771) -> platform_core::AppResult<()> {
1772    for module in modules {
1773        let Some(lifecycle) = &module.manifest.lifecycle else {
1774            continue;
1775        };
1776
1777        for check in &lifecycle.startup_checks {
1778            match &check.check {
1779                LifecycleStartupCheckKind::FunctionRegistered { function_name } => {
1780                    if !module_declares_runtime_function(module, function_name) {
1781                        let reason = format!(
1782                            "startup check `{}` references function `{}` not declared by module `{}`",
1783                            check.name, function_name, module.manifest.name
1784                        );
1785                        if !check.required {
1786                            warn_optional_lifecycle_skip(
1787                                &module.manifest.name,
1788                                "startup_checks",
1789                                &check.name,
1790                                &reason,
1791                            );
1792                            continue;
1793                        }
1794                        return Err(lifecycle_validation_error(
1795                            &module.manifest.name,
1796                            "startup_checks",
1797                            &check.name,
1798                            format!("required {reason}"),
1799                        ));
1800                    }
1801                    if registry.get(function_name).is_none() {
1802                        let reason = format!(
1803                            "startup check `{}` references missing function `{}`",
1804                            check.name, function_name
1805                        );
1806                        if !check.required {
1807                            warn_optional_lifecycle_skip(
1808                                &module.manifest.name,
1809                                "startup_checks",
1810                                &check.name,
1811                                &reason,
1812                            );
1813                            continue;
1814                        }
1815                        return Err(lifecycle_validation_error(
1816                            &module.manifest.name,
1817                            "startup_checks",
1818                            &check.name,
1819                            format!("required {reason}"),
1820                        ));
1821                    }
1822                }
1823                LifecycleStartupCheckKind::CapabilityDeclared { capability } => {
1824                    if !module.manifest.capabilities.contains(capability) {
1825                        let reason = format!(
1826                            "startup check `{}` references missing capability `{}`",
1827                            check.name, capability
1828                        );
1829                        if !check.required {
1830                            warn_optional_lifecycle_skip(
1831                                &module.manifest.name,
1832                                "startup_checks",
1833                                &check.name,
1834                                &reason,
1835                            );
1836                            continue;
1837                        }
1838                        return Err(lifecycle_validation_error(
1839                            &module.manifest.name,
1840                            "startup_checks",
1841                            &check.name,
1842                            format!("required {reason}"),
1843                        ));
1844                    }
1845                }
1846                _ => {
1847                    let reason = format!(
1848                        "startup check `{}` uses an unsupported lifecycle check kind",
1849                        check.name
1850                    );
1851                    if !check.required {
1852                        warn_optional_lifecycle_skip(
1853                            &module.manifest.name,
1854                            "startup_checks",
1855                            &check.name,
1856                            &reason,
1857                        );
1858                        continue;
1859                    }
1860                    return Err(lifecycle_validation_error(
1861                        &module.manifest.name,
1862                        "startup_checks",
1863                        &check.name,
1864                        format!("required {reason}"),
1865                    ));
1866                }
1867            }
1868        }
1869
1870        for job in &lifecycle.activation_jobs {
1871            if job.run_policy != LifecycleActivationRunPolicy::EveryStartup {
1872                continue;
1873            }
1874
1875            if !module_declares_runtime_function(module, &job.function_name) {
1876                let reason = format!(
1877                    "activation job `{}` references function `{}` not declared by module `{}`",
1878                    job.name, job.function_name, module.manifest.name
1879                );
1880                if !job.required {
1881                    warn_optional_lifecycle_skip(
1882                        &module.manifest.name,
1883                        "activation_jobs",
1884                        &job.name,
1885                        &reason,
1886                    );
1887                    continue;
1888                }
1889                return Err(lifecycle_validation_error(
1890                    &module.manifest.name,
1891                    "activation_jobs",
1892                    &job.name,
1893                    format!("required {reason}"),
1894                ));
1895            }
1896            if registry.get(&job.function_name).is_none() {
1897                let reason = format!(
1898                    "activation job `{}` references missing function `{}`",
1899                    job.name, job.function_name
1900                );
1901                if !job.required {
1902                    warn_optional_lifecycle_skip(
1903                        &module.manifest.name,
1904                        "activation_jobs",
1905                        &job.name,
1906                        &reason,
1907                    );
1908                    continue;
1909                }
1910                return Err(lifecycle_validation_error(
1911                    &module.manifest.name,
1912                    "activation_jobs",
1913                    &job.name,
1914                    format!("required {reason}"),
1915                ));
1916            }
1917        }
1918    }
1919
1920    Ok(())
1921}
1922
1923fn module_declares_runtime_function(module: &Module, function_name: &str) -> bool {
1924    module.manifest.runtime.as_ref().is_some_and(|runtime| {
1925        runtime
1926            .functions
1927            .iter()
1928            .any(|function| function.name == function_name)
1929    })
1930}
1931
1932fn lifecycle_validation_error(
1933    module_name: &str,
1934    collection: &str,
1935    item_name: &str,
1936    reason: String,
1937) -> AppError {
1938    AppError::validation(
1939        "Module lifecycle declaration failed validation",
1940        vec![ErrorDetail {
1941            field: Some(format!(
1942                "module.{module_name}.lifecycle.{collection}.{item_name}"
1943            )),
1944            reason,
1945        }],
1946    )
1947}
1948
1949fn warn_optional_lifecycle_skip(
1950    module_name: &str,
1951    collection: &str,
1952    item_name: &str,
1953    reason: &str,
1954) {
1955    tracing::warn!(
1956        module_name = %module_name,
1957        lifecycle_collection = %collection,
1958        lifecycle_item = %item_name,
1959        reason = %reason,
1960        "optional module lifecycle declaration skipped"
1961    );
1962}
1963
1964fn warn_optional_lifecycle_enqueue_failure(
1965    module_name: &str,
1966    job_name: &str,
1967    function_name: &str,
1968    error: &AppError,
1969) {
1970    tracing::warn!(
1971        module_name = %module_name,
1972        lifecycle_collection = "activation_jobs",
1973        lifecycle_item = %job_name,
1974        function_name = %function_name,
1975        error_code = %error.code.as_str(),
1976        error_message = %error.public_message,
1977        "optional module lifecycle activation enqueue failed"
1978    );
1979}
1980
1981fn runtime_max_attempts_for_enqueue(max_attempts: u32) -> i32 {
1982    i32::try_from(max_attempts).unwrap_or(i32::MAX)
1983}
1984
1985/// Build an [`EventHandlerRegistry`] from every module's binding.
1986#[must_use]
1987pub fn event_handlers(modules: &[Module]) -> EventHandlerRegistry {
1988    event_handlers_with_context(modules, &EventHandlerRegistrationContext::empty())
1989}
1990
1991/// Build an [`EventHandlerRegistry`] with host runtime actions enabled for
1992/// remote event-handler result actions.
1993#[must_use]
1994pub fn event_handlers_with_runtime_actions(
1995    ctx: &AppContext,
1996    modules: &[Module],
1997    function_registry: Arc<FunctionRegistry>,
1998) -> EventHandlerRegistry {
1999    let context = EventHandlerRegistrationContext::with_runtime(
2000        RuntimeClient::new(ctx.db.clone()),
2001        function_registry,
2002    );
2003    event_handlers_with_context(modules, &context)
2004}
2005
2006fn event_handlers_with_context(
2007    modules: &[Module],
2008    context: &EventHandlerRegistrationContext,
2009) -> EventHandlerRegistry {
2010    let mut registry = EventHandlerRegistry::new();
2011    for module in modules {
2012        module
2013            .binding
2014            .register_event_handlers(&mut registry, context);
2015    }
2016    registry
2017}
2018
2019/// Merge every linked module's HTTP routes (and their `OpenAPI` docs) onto `base`.
2020///
2021/// Linked route builders are context-free, so this assembles the HTTP surface
2022/// without constructing the full module set (which requires an [`AppContext`])
2023/// — usable both for serving and for standalone `OpenAPI` document assembly.
2024/// This is the single source for linked API routes until HTTP joins the
2025/// [`platform_module::ModuleBinding`] seam.
2026pub fn merge_linked_http(base: ApiOpenApiRouter) -> ApiOpenApiRouter {
2027    merge_linked_http_for_profile(base, CompositionProfile::default())
2028}
2029
2030pub fn merge_linked_http_for_profile(
2031    base: ApiOpenApiRouter,
2032    profile: CompositionProfile,
2033) -> ApiOpenApiRouter {
2034    linked_http_modules_for_profile(profile)
2035        .into_iter()
2036        .filter_map(|module| module.linked_http)
2037        .fold(base, |router, contribution| (contribution.merge)(router))
2038}
2039
2040pub fn merge_linked_http_for_config(
2041    base: ApiOpenApiRouter,
2042    config: &platform_core::AppConfig,
2043) -> platform_core::AppResult<ApiOpenApiRouter> {
2044    Ok(linked_http_modules_for_config(config)?
2045        .into_iter()
2046        .filter_map(|module| module.linked_http)
2047        .fold(base, |router, contribution| (contribution.merge)(router)))
2048}
2049
2050pub fn merge_linked_http_for_context(
2051    base: ApiOpenApiRouter,
2052    ctx: &AppContext,
2053) -> platform_core::AppResult<ApiOpenApiRouter> {
2054    Ok(linked_http_modules_for_context(ctx)?
2055        .into_iter()
2056        .filter_map(|module| module.linked_http)
2057        .fold(base, |router, contribution| (contribution.merge)(router)))
2058}
2059
2060pub fn merge_linked_http_for_context_with_composition(
2061    base: ApiOpenApiRouter,
2062    ctx: &AppContext,
2063    composition: &HostComposition,
2064) -> platform_core::AppResult<ApiOpenApiRouter> {
2065    Ok(
2066        linked_http_modules_for_context_with_composition(ctx, composition)?
2067            .into_iter()
2068            .filter_map(|module| module.linked_http)
2069            .fold(base, |router, contribution| (contribution.merge)(router)),
2070    )
2071}
2072
2073/// Story-display descriptors for every module. Sourced from context-free
2074/// manifests so the `OpenAPI` path stays pure (no [`AppContext`]).
2075#[must_use]
2076pub fn story_display_descriptors() -> Vec<StoryDisplayDescriptor> {
2077    story_display_descriptors_for_profile(CompositionProfile::default())
2078}
2079
2080#[must_use]
2081pub fn story_display_descriptors_for_profile(
2082    profile: CompositionProfile,
2083) -> Vec<StoryDisplayDescriptor> {
2084    module_manifests_for_profile(profile)
2085        .into_iter()
2086        .flat_map(story_display_descriptors_from_manifest)
2087        .collect()
2088}
2089
2090pub fn story_display_descriptors_for_config(
2091    config: &platform_core::AppConfig,
2092) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2093    Ok(linked_module_entries_for_config(config)?
2094        .into_iter()
2095        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2096        .collect())
2097}
2098
2099pub fn story_display_descriptors_for_context(
2100    ctx: &AppContext,
2101) -> platform_core::AppResult<Vec<StoryDisplayDescriptor>> {
2102    Ok(linked_module_entries_for_context(ctx)?
2103        .into_iter()
2104        .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)()))
2105        .collect())
2106}
2107
2108pub fn install_default_story_display_catalog(ctx: &AppContext) -> platform_core::AppResult<()> {
2109    install_default_story_display_catalog_with_composition(ctx, &HostComposition::default())
2110}
2111
2112pub fn install_default_story_display_catalog_with_composition(
2113    ctx: &AppContext,
2114    composition: &HostComposition,
2115) -> platform_core::AppResult<()> {
2116    if !linked_module_enabled(ctx, story::module::MODULE_NAME) {
2117        story::backend::install_default_story_display(Vec::new());
2118        return Ok(());
2119    }
2120    let mut descriptors = story_display_descriptors_for_context(ctx)?;
2121    descriptors.extend(
2122        host_linked_modules_for_context(ctx, composition)
2123            .into_iter()
2124            .flat_map(|entry| story_display_descriptors_from_manifest((entry.manifest)())),
2125    );
2126    story::backend::install_default_story_display(descriptors);
2127    Ok(())
2128}
2129
2130pub fn install_story_display_catalog(metadata: &[AdminModuleMetadata]) {
2131    story::backend::install_story_display(
2132        metadata
2133            .iter()
2134            .flat_map(story_display_descriptors_from_metadata)
2135            .collect(),
2136    );
2137}
2138
2139fn story_display_descriptors_from_metadata(
2140    module: &AdminModuleMetadata,
2141) -> Vec<StoryDisplayDescriptor> {
2142    story_display_descriptors_from_manifest(
2143        ModuleManifest::builder(module.module_name.clone())
2144            .story_display(module.story_display.clone())
2145            .http_routes(module.http_routes.clone())
2146            .build(),
2147    )
2148}
2149
2150fn story_display_descriptors_from_manifest(
2151    manifest: ModuleManifest,
2152) -> Vec<StoryDisplayDescriptor> {
2153    let mut descriptors = manifest.story_display;
2154    let existing_http = descriptors
2155        .iter()
2156        .filter_map(|descriptor| match &descriptor.source {
2157            StoryDisplaySource::HttpRequest { method, path } => {
2158                Some((method.clone(), path.clone()))
2159            }
2160            StoryDisplaySource::ExecutionName { .. } => None,
2161        })
2162        .collect::<Vec<_>>();
2163
2164    descriptors.extend(manifest.http_routes.into_iter().filter_map(|route| {
2165        let display_name = route.display_name?;
2166        let method = http_method_label(route.method)?;
2167        if existing_http
2168            .iter()
2169            .any(|(existing_method, existing_path)| {
2170                existing_method == method && existing_path == &route.path
2171            })
2172        {
2173            return None;
2174        }
2175        Some(StoryDisplayDescriptor {
2176            source: StoryDisplaySource::HttpRequest {
2177                method: method.to_owned(),
2178                path: route.path,
2179            },
2180            display_name,
2181            story_title: route.story_title,
2182        })
2183    }));
2184    descriptors
2185}
2186
2187fn http_method_label(method: ModuleHttpMethod) -> Option<&'static str> {
2188    Some(match method {
2189        ModuleHttpMethod::Get => "GET",
2190        ModuleHttpMethod::Post => "POST",
2191        ModuleHttpMethod::Put => "PUT",
2192        ModuleHttpMethod::Patch => "PATCH",
2193        ModuleHttpMethod::Delete => "DELETE",
2194        _ => return None,
2195    })
2196}
2197
2198/// Every module's setting descriptors.
2199///
2200/// The single source for the editable configuration registry. Apps build a
2201/// `RuntimeConfigRegistry` from this list at startup.
2202pub fn runtime_config_descriptors(
2203    ctx: &AppContext,
2204) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2205    runtime_config_descriptors_with_composition(ctx, &HostComposition::default())
2206}
2207
2208pub fn runtime_config_descriptors_with_composition(
2209    ctx: &AppContext,
2210    composition: &HostComposition,
2211) -> platform_core::AppResult<Vec<RuntimeConfigDescriptor>> {
2212    let profile = CompositionProfile::from_config(&ctx.config)?;
2213    let module_enabled_descriptors =
2214        linked_module_entries(profile)
2215            .iter()
2216            .map(|entry| RuntimeConfigDescriptor {
2217                key: module_enabled_config_key(entry.module_name),
2218                scope: RuntimeConfigScope::Shared,
2219                group: Some("modules"),
2220                section: None,
2221                order: 10,
2222                visible_when: None,
2223                generated: None,
2224                value_type: RuntimeConfigType::Bool,
2225                default: serde_json::json!(linked_module_enabled_from_config(
2226                    &ctx.config,
2227                    entry.module_name
2228                )),
2229                editable: true,
2230                restart_only: true,
2231                description: "Whether this linked module is loaded on service startup.",
2232            });
2233    let host_module_enabled_descriptors =
2234        composition
2235            .linked_modules()
2236            .iter()
2237            .map(|entry| RuntimeConfigDescriptor {
2238                key: module_enabled_config_key(entry.module_name),
2239                scope: RuntimeConfigScope::Shared,
2240                group: Some("modules"),
2241                section: None,
2242                order: 10,
2243                visible_when: None,
2244                generated: None,
2245                value_type: RuntimeConfigType::Bool,
2246                default: serde_json::json!(linked_module_enabled_from_config(
2247                    &ctx.config,
2248                    entry.module_name
2249                )),
2250                editable: true,
2251                restart_only: true,
2252                description: "Whether this host linked module is loaded on service startup.",
2253            });
2254    let remote_module_enabled_descriptors =
2255        ctx.config
2256            .module_sources
2257            .remote
2258            .iter()
2259            .map(|source| RuntimeConfigDescriptor {
2260                key: module_enabled_config_key(&source.name),
2261                scope: RuntimeConfigScope::Shared,
2262                group: Some("modules"),
2263                section: None,
2264                order: 10,
2265                visible_when: None,
2266                generated: None,
2267                value_type: RuntimeConfigType::Bool,
2268                default: serde_json::json!(remote_module_enabled_from_config(
2269                    &ctx.config,
2270                    &source.name
2271                )),
2272                editable: true,
2273                restart_only: true,
2274                description: "Whether this remote module is loaded on service startup.",
2275            });
2276    let module_descriptors = linked_module_entries(profile)
2277        .iter()
2278        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2279        .map(|entry| (entry.load)(ctx))
2280        .chain(
2281            host_linked_modules_for_config(&ctx.config, composition)
2282                .into_iter()
2283                .map(|entry| load_host_linked_module(ctx, entry)),
2284        )
2285        .flat_map(|module| module.runtime_config.iter().cloned())
2286        .collect::<Vec<_>>();
2287    // Platform-owned descriptors (e.g. worker knobs) plus every module's; keys
2288    // are globally unique, so chain order is presentation-only.
2289    Ok(platform_core::worker_runtime_config::RUNTIME_CONFIG
2290        .iter()
2291        .cloned()
2292        .chain(module_enabled_descriptors)
2293        .chain(host_module_enabled_descriptors)
2294        .chain(remote_module_enabled_descriptors)
2295        .chain(module_descriptors)
2296        .collect())
2297}
2298
2299/// Every config presentation group known to the current composition.
2300pub fn runtime_config_group_descriptors(
2301    ctx: &AppContext,
2302) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2303    runtime_config_group_descriptors_with_composition(ctx, &HostComposition::default())
2304}
2305
2306pub fn runtime_config_group_descriptors_with_composition(
2307    ctx: &AppContext,
2308    composition: &HostComposition,
2309) -> platform_core::AppResult<Vec<RuntimeConfigGroupDescriptor>> {
2310    let profile = CompositionProfile::from_config(&ctx.config)?;
2311    let module_groups = linked_module_entries(profile)
2312        .iter()
2313        .filter(|entry| linked_module_enabled_from_config(&ctx.config, entry.module_name))
2314        .map(|entry| (entry.load)(ctx))
2315        .chain(
2316            host_linked_modules_for_config(&ctx.config, composition)
2317                .into_iter()
2318                .map(|entry| load_host_linked_module(ctx, entry)),
2319        )
2320        .flat_map(|module| module.runtime_config_groups.iter().cloned())
2321        .collect::<Vec<_>>();
2322
2323    Ok(std::iter::once(MODULES_CONFIG_GROUP.clone())
2324        .chain(
2325            platform_core::worker_runtime_config::RUNTIME_CONFIG_GROUPS
2326                .iter()
2327                .cloned(),
2328        )
2329        .chain(module_groups)
2330        .collect())
2331}
2332
2333#[cfg(test)]
2334mod tests {
2335    use super::*;
2336    use async_trait::async_trait;
2337    use platform_core::{
2338        AppConfig, AuthConfig, DatabaseConfig, ErrorCode, ExecutionContext, HttpConfig,
2339        LoggingEventPublisher, ModuleConfig, ModuleSourcesConfig, PLATFORM_MIGRATIONS,
2340        RemoteModuleSourceConfig, RuntimeConfigProvider, RuntimeConfigRegistry,
2341        RuntimeConfigSnapshot, ServiceConfig, TelemetryConfig, apply_migrations,
2342    };
2343    use platform_module::{
2344        ConsoleArea, LifecycleActivationJobDeclaration, LifecycleStartupCheckDeclaration,
2345        LifecycleSurface, ModuleManifestLintSeverity, RuntimeFunctionDeclaration, RuntimeSurface,
2346        lint_module_manifest,
2347    };
2348    use platform_runtime::{FunctionDefinition, FunctionHandler, RUNTIME_MIGRATIONS, RetryPolicy};
2349    use platform_testing::{SequentialIdGenerator, TestDatabase};
2350    use serde_json::{Value, json};
2351    use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
2352    use std::collections::BTreeMap;
2353    use std::sync::Arc;
2354    use std::time::Duration;
2355
2356    #[derive(Debug)]
2357    struct TestRuntimeConfigProvider {
2358        snapshot: Arc<RuntimeConfigSnapshot>,
2359    }
2360
2361    impl RuntimeConfigProvider for TestRuntimeConfigProvider {
2362        fn snapshot(&self) -> Arc<RuntimeConfigSnapshot> {
2363            Arc::clone(&self.snapshot)
2364        }
2365    }
2366
2367    #[test]
2368    fn linked_module_entry_names_match_manifests() {
2369        for profile in [CompositionProfile::Core, CompositionProfile::Demo] {
2370            for entry in linked_module_entries(profile) {
2371                assert_eq!(
2372                    entry.module_name,
2373                    (entry.manifest)().name,
2374                    "linked module entry name must match ModuleManifest::name"
2375                );
2376            }
2377        }
2378    }
2379
2380    #[test]
2381    fn core_profile_excludes_demo_linked_modules() {
2382        let names = module_manifests_for_profile(CompositionProfile::Core)
2383            .into_iter()
2384            .map(|manifest| manifest.name)
2385            .collect::<Vec<_>>();
2386
2387        assert_eq!(names, vec!["platform-story"]);
2388    }
2389
2390    #[test]
2391    fn demo_profile_includes_fixture_linked_modules() {
2392        let names = module_manifests_for_profile(CompositionProfile::Demo)
2393            .into_iter()
2394            .map(|manifest| manifest.name)
2395            .collect::<Vec<_>>();
2396
2397        assert_eq!(names, vec!["auth", "auth-password", "platform-story"]);
2398    }
2399
2400    #[test]
2401    fn http_route_metadata_contributes_story_display_descriptors() {
2402        let descriptors = story_display_descriptors_for_profile(CompositionProfile::Demo);
2403
2404        assert!(descriptors.iter().any(|descriptor| {
2405            matches!(
2406                &descriptor.source,
2407                StoryDisplaySource::HttpRequest { method, path }
2408                    if method == "POST" && path == "/v1/auth/dev/sessions"
2409            ) && descriptor.display_name == "Create Development Session"
2410        }));
2411    }
2412
2413    #[test]
2414    fn core_profile_migrations_exclude_demo_module_migrations() {
2415        let names = migrations_for_profile(CompositionProfile::Core)
2416            .into_iter()
2417            .map(|migration| migration.name)
2418            .collect::<Vec<_>>();
2419
2420        assert!(names.iter().any(|name| name.starts_with("platform/")));
2421        assert!(names.iter().any(|name| name.starts_with("runtime/")));
2422        assert!(!names.iter().any(|name| name.starts_with("auth/")));
2423        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
2424    }
2425
2426    #[test]
2427    fn demo_profile_migrations_include_fixture_module_migrations() {
2428        let names = migrations_for_profile(CompositionProfile::Demo)
2429            .into_iter()
2430            .map(|migration| migration.name)
2431            .collect::<Vec<_>>();
2432
2433        assert!(
2434            names
2435                .iter()
2436                .any(|name| name == &"auth/0001_create_auth_schema")
2437        );
2438        assert!(
2439            names
2440                .iter()
2441                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2442        );
2443    }
2444
2445    #[test]
2446    fn host_composition_migrations_include_enabled_host_linked_modules() {
2447        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2448        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2449
2450        let names = migrations_for_config_with_composition(&config, &composition)
2451            .expect("host composition migrations should load")
2452            .into_iter()
2453            .map(|migration| migration.name)
2454            .collect::<Vec<_>>();
2455
2456        assert!(names.iter().any(|name| name == &"billing/0001_init"));
2457    }
2458
2459    #[test]
2460    fn host_composition_can_install_auth_modules() {
2461        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2462        config.module_sources.linked_profile = "core".to_owned();
2463        let composition = HostComposition::new()
2464            .with_linked_module(auth_linked_module())
2465            .with_linked_module(auth_password_linked_module());
2466
2467        let names = migrations_for_config_with_composition(&config, &composition)
2468            .expect("host composition migrations should load")
2469            .into_iter()
2470            .map(|migration| migration.name)
2471            .collect::<Vec<_>>();
2472
2473        assert!(
2474            names
2475                .iter()
2476                .any(|name| name == &"auth/0001_create_auth_schema")
2477        );
2478        assert!(
2479            names
2480                .iter()
2481                .any(|name| name == &"auth-password/0001_create_auth_password_schema")
2482        );
2483    }
2484
2485    #[tokio::test]
2486    async fn host_composition_runtime_config_includes_host_module_toggle() {
2487        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2488            .expect("lazy pool should build");
2489        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2490        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2491        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2492
2493        let keys = runtime_config_descriptors_with_composition(&ctx, &composition)
2494            .expect("host composition descriptors should load")
2495            .into_iter()
2496            .map(|descriptor| descriptor.key)
2497            .collect::<Vec<_>>();
2498
2499        assert!(keys.iter().any(|key| key == "modules.billing.enabled"));
2500    }
2501
2502    #[tokio::test]
2503    async fn host_composition_modules_include_manifest_only_modules() {
2504        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2505            .expect("lazy pool should build");
2506        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2507        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2508        let composition = HostComposition::new().with_linked_module(test_host_linked_module());
2509
2510        let names = modules_for_config_with_composition(&ctx, &composition)
2511            .expect("host composition modules should load")
2512            .into_iter()
2513            .map(|module| module.manifest.name)
2514            .collect::<Vec<_>>();
2515
2516        assert!(names.iter().any(|name| name == "billing"));
2517    }
2518
2519    #[test]
2520    fn demo_profile_includes_every_core_entry() {
2521        let demo_names = linked_module_entries(CompositionProfile::Demo)
2522            .iter()
2523            .map(|entry| entry.module_name)
2524            .collect::<Vec<_>>();
2525
2526        for core_entry in linked_module_entries(CompositionProfile::Core) {
2527            assert!(
2528                demo_names.contains(&core_entry.module_name),
2529                "demo profile should include core linked module `{}`",
2530                core_entry.module_name
2531            );
2532        }
2533    }
2534
2535    #[test]
2536    fn default_module_manifests_use_demo_profile() {
2537        let names = module_manifests()
2538            .into_iter()
2539            .map(|manifest| manifest.name)
2540            .collect::<Vec<_>>();
2541
2542        assert_eq!(names, vec!["auth", "auth-password", "platform-story"]);
2543    }
2544
2545    #[test]
2546    fn linked_http_route_owners_are_profile_aware() {
2547        assert_eq!(
2548            linked_http_route_owners_for_profile(CompositionProfile::Core),
2549            vec![LinkedHttpRouteOwner {
2550                module_name: "platform-story".to_owned(),
2551                public_prefixes: &["/admin/runtime/stories"],
2552            }]
2553        );
2554        assert_eq!(
2555            linked_http_route_owners_for_profile(CompositionProfile::Demo),
2556            vec![
2557                LinkedHttpRouteOwner {
2558                    module_name: "auth".to_owned(),
2559                    public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
2560                },
2561                LinkedHttpRouteOwner {
2562                    module_name: "auth-password".to_owned(),
2563                    public_prefixes: &["/v1/auth/password/"],
2564                },
2565                LinkedHttpRouteOwner {
2566                    module_name: "platform-story".to_owned(),
2567                    public_prefixes: &["/admin/runtime/stories"],
2568                },
2569            ]
2570        );
2571    }
2572
2573    #[tokio::test]
2574    async fn modules_for_config_uses_core_linked_profile() {
2575        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2576            .expect("lazy pool should build");
2577        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2578        config.module_sources.linked_profile = "core".to_owned();
2579        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2580
2581        let names = modules_for_config(&ctx)
2582            .expect("core linked profile should parse")
2583            .into_iter()
2584            .map(|module| module.manifest.name)
2585            .collect::<Vec<_>>();
2586
2587        assert_eq!(names, vec!["platform-story"]);
2588    }
2589
2590    #[tokio::test]
2591    async fn auth_actor_resolver_is_profile_and_composition_aware() {
2592        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2593            .expect("lazy pool should build");
2594        let demo_ctx = AppContext::new(
2595            test_config_with_database_url("postgres://localhost/lenso_test"),
2596            db.clone(),
2597            Arc::new(LoggingEventPublisher),
2598        );
2599        assert!(
2600            auth_actor_resolver_for_context(&demo_ctx)
2601                .expect("demo profile")
2602                .is_some()
2603        );
2604
2605        let mut composition_config =
2606            test_config_with_database_url("postgres://localhost/lenso_test");
2607        composition_config.module_sources.linked_profile = "core".to_owned();
2608        let composition_ctx = AppContext::new(
2609            composition_config,
2610            db.clone(),
2611            Arc::new(LoggingEventPublisher),
2612        );
2613        let composition = HostComposition::new().with_linked_module(auth_linked_module());
2614        assert!(
2615            auth_actor_resolver_for_context_with_composition(&composition_ctx, &composition)
2616                .expect("auth composition")
2617                .is_some()
2618        );
2619
2620        let mut core_config = test_config_with_database_url("postgres://localhost/lenso_test");
2621        core_config.module_sources.linked_profile = "core".to_owned();
2622        let core_ctx = AppContext::new(core_config, db, Arc::new(LoggingEventPublisher));
2623        assert!(
2624            auth_actor_resolver_for_context(&core_ctx)
2625                .expect("core profile")
2626                .is_none()
2627        );
2628    }
2629
2630    #[tokio::test]
2631    async fn auth_actor_resolver_respects_disabled_auth_module() {
2632        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2633            .expect("lazy pool should build");
2634        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2635        config.modules.insert(
2636            auth::module::MODULE_NAME.to_owned(),
2637            ModuleConfig {
2638                enabled: Some(false),
2639                values: BTreeMap::new(),
2640            },
2641        );
2642        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2643
2644        assert!(
2645            auth_actor_resolver_for_context(&ctx)
2646                .expect("demo profile")
2647                .is_none()
2648        );
2649    }
2650
2651    #[tokio::test]
2652    async fn auth_password_requires_auth_module() {
2653        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2654            .expect("lazy pool should build");
2655        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2656        config.modules.insert(
2657            auth::module::MODULE_NAME.to_owned(),
2658            ModuleConfig {
2659                enabled: Some(false),
2660                values: BTreeMap::new(),
2661            },
2662        );
2663        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2664
2665        let names = modules_for_config(&ctx)
2666            .expect("demo profile")
2667            .into_iter()
2668            .map(|module| module.manifest.name)
2669            .collect::<Vec<_>>();
2670
2671        assert!(!names.iter().any(|name| name == "auth-password"));
2672    }
2673
2674    #[tokio::test]
2675    async fn auth_password_dependency_status_is_visible_in_metadata() {
2676        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2677            .expect("lazy pool should build");
2678        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2679        config.modules.insert(
2680            auth::module::MODULE_NAME.to_owned(),
2681            ModuleConfig {
2682                enabled: Some(false),
2683                values: BTreeMap::new(),
2684            },
2685        );
2686        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2687
2688        let metadata = load_admin_module_metadata(&ctx)
2689            .await
2690            .expect("module metadata should load");
2691        let auth_password = metadata
2692            .iter()
2693            .find(|module| module.module_name == "auth-password")
2694            .expect("dependency-disabled provider should remain visible in metadata");
2695
2696        assert_eq!(
2697            auth_password.dependencies,
2698            vec![auth::module::MODULE_NAME.to_owned()]
2699        );
2700        assert!(matches!(
2701            &auth_password.load_status,
2702            ModuleLoadStatus::Error { message }
2703                if message == "module dependency disabled: auth"
2704        ));
2705    }
2706
2707    #[tokio::test]
2708    async fn auth_actor_resolver_allows_jwt_strategy_without_secret() {
2709        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2710            .expect("lazy pool should build");
2711        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2712        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2713        let registry =
2714            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2715                .expect("registry");
2716        let mut stored = BTreeMap::new();
2717        stored.insert(
2718            ("*".to_owned(), "auth-password.token_strategy".to_owned()),
2719            json!("jwt"),
2720        );
2721        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2722        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2723            snapshot: Arc::new(snapshot),
2724        }));
2725
2726        assert!(
2727            auth_actor_resolver_for_context(&ctx)
2728                .expect("JWT resolver should be skipped until jwt_secret is configured")
2729                .is_some()
2730        );
2731    }
2732
2733    #[tokio::test]
2734    async fn modules_for_config_skips_disabled_linked_modules() {
2735        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2736            .expect("lazy pool should build");
2737        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2738        config.modules.insert(
2739            "auth-password".to_owned(),
2740            ModuleConfig {
2741                enabled: Some(false),
2742                values: BTreeMap::new(),
2743            },
2744        );
2745        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2746
2747        let names = modules_for_config(&ctx)
2748            .expect("demo linked profile should parse")
2749            .into_iter()
2750            .map(|module| module.manifest.name)
2751            .collect::<Vec<_>>();
2752
2753        assert_eq!(names, vec!["auth", "platform-story"]);
2754    }
2755
2756    #[tokio::test]
2757    async fn modules_for_config_uses_runtime_config_enabled_flag() {
2758        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2759            .expect("lazy pool should build");
2760        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2761        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2762        let registry =
2763            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2764                .expect("registry");
2765        let mut stored = BTreeMap::new();
2766        stored.insert(
2767            ("*".to_owned(), "modules.auth-password.enabled".to_owned()),
2768            json!(false),
2769        );
2770        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2771        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2772            snapshot: Arc::new(snapshot),
2773        }));
2774
2775        let names = modules_for_config(&ctx)
2776            .expect("demo linked profile should parse")
2777            .into_iter()
2778            .map(|module| module.manifest.name)
2779            .collect::<Vec<_>>();
2780
2781        assert_eq!(names, vec!["auth", "platform-story"]);
2782        let linked_http_names = linked_http_modules_for_context(&ctx)
2783            .expect("linked HTTP modules should load")
2784            .into_iter()
2785            .map(|module| module.manifest.name)
2786            .collect::<Vec<_>>();
2787
2788        assert_eq!(linked_http_names, vec!["auth", "platform-story"]);
2789    }
2790
2791    #[tokio::test]
2792    async fn story_module_runtime_config_disables_backend_metadata() {
2793        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2794            .expect("lazy pool should build");
2795        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2796        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2797        let registry =
2798            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2799                .expect("registry");
2800        let mut stored = BTreeMap::new();
2801        stored.insert(
2802            ("*".to_owned(), "modules.platform-story.enabled".to_owned()),
2803            json!(false),
2804        );
2805        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2806        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2807            snapshot: Arc::new(snapshot),
2808        }));
2809
2810        let linked_http_names = linked_http_modules_for_context(&ctx)
2811            .expect("linked HTTP modules should load")
2812            .into_iter()
2813            .map(|module| module.manifest.name)
2814            .collect::<Vec<_>>();
2815        assert_eq!(linked_http_names, vec!["auth", "auth-password"]);
2816
2817        let metadata = load_admin_module_metadata(&ctx)
2818            .await
2819            .expect("module metadata should load");
2820        let story = metadata
2821            .iter()
2822            .find(|module| module.module_name == "platform-story")
2823            .expect("disabled story module should remain visible in metadata");
2824
2825        assert!(matches!(
2826            &story.load_status,
2827            ModuleLoadStatus::Error { message }
2828                if message == "module disabled by configuration"
2829        ));
2830        assert_eq!(story.console.len(), 1);
2831        assert_eq!(story.http_routes.len(), story::module::http_routes().len());
2832    }
2833
2834    #[tokio::test]
2835    async fn runtime_config_descriptors_include_module_enabled_flags() {
2836        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2837            .expect("lazy pool should build");
2838        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2839        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2840
2841        let keys = runtime_config_descriptors(&ctx)
2842            .expect("descriptors should load")
2843            .into_iter()
2844            .map(|descriptor| {
2845                (
2846                    descriptor.key,
2847                    descriptor.group,
2848                    descriptor.restart_only,
2849                    descriptor.default,
2850                )
2851            })
2852            .collect::<Vec<_>>();
2853
2854        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2855            key == "modules.auth.enabled"
2856                && *group == Some("modules")
2857                && *restart_only
2858                && default == &json!(true)
2859        }));
2860        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2861            key == "modules.auth-password.enabled"
2862                && *group == Some("modules")
2863                && *restart_only
2864                && default == &json!(true)
2865        }));
2866        assert!(keys.iter().any(|(key, group, restart_only, default)| {
2867            key == "modules.platform-story.enabled"
2868                && *group == Some("modules")
2869                && *restart_only
2870                && default == &json!(true)
2871        }));
2872    }
2873
2874    #[tokio::test]
2875    async fn runtime_config_groups_include_module_owned_groups() {
2876        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2877            .expect("lazy pool should build");
2878        let config = test_config_with_database_url("postgres://localhost/lenso_test");
2879        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2880
2881        let groups = runtime_config_group_descriptors(&ctx)
2882            .expect("groups should load")
2883            .into_iter()
2884            .map(|group| (group.id, group.label))
2885            .collect::<Vec<_>>();
2886
2887        assert!(groups.contains(&("modules", "Modules")));
2888        assert!(groups.contains(&("auth-password.hashing", "Password Hashing")));
2889        assert!(groups.contains(&("auth-password.tokens", "Tokens")));
2890        assert!(!groups.iter().any(|(id, _)| *id == "auth-password.jwt"));
2891    }
2892
2893    #[tokio::test]
2894    async fn runtime_config_descriptors_include_remote_module_enabled_flags() {
2895        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2896            .expect("lazy pool should build");
2897        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2898        config.module_sources.remote.push(RemoteModuleSourceConfig {
2899            name: "remote-crm".to_owned(),
2900            base_url: "http://127.0.0.1:65535".to_owned(),
2901            auth_token_env: None,
2902            timeout_ms: 1,
2903        });
2904        config.modules.insert(
2905            "remote-crm".to_owned(),
2906            ModuleConfig {
2907                enabled: Some(false),
2908                values: BTreeMap::new(),
2909            },
2910        );
2911        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2912
2913        let keys = runtime_config_descriptors(&ctx)
2914            .expect("descriptors should load")
2915            .into_iter()
2916            .map(|descriptor| (descriptor.key, descriptor.restart_only, descriptor.default))
2917            .collect::<Vec<_>>();
2918
2919        assert!(keys.iter().any(|(key, restart_only, default)| {
2920            key == "modules.remote-crm.enabled" && *restart_only && default == &json!(false)
2921        }));
2922    }
2923
2924    #[tokio::test]
2925    async fn load_modules_skips_runtime_disabled_remote_modules() {
2926        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2927            .expect("lazy pool should build");
2928        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2929        config.module_sources.remote.push(RemoteModuleSourceConfig {
2930            name: "remote-crm".to_owned(),
2931            base_url: "http://127.0.0.1:65535".to_owned(),
2932            auth_token_env: None,
2933            timeout_ms: 1,
2934        });
2935        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2936        let registry =
2937            RuntimeConfigRegistry::try_new(runtime_config_descriptors(&ctx).expect("descriptors"))
2938                .expect("registry");
2939        let mut stored = BTreeMap::new();
2940        stored.insert(
2941            ("*".to_owned(), "modules.remote-crm.enabled".to_owned()),
2942            json!(false),
2943        );
2944        let snapshot = RuntimeConfigSnapshot::resolve(&registry, "api", &stored);
2945        let ctx = ctx.with_runtime_config_provider(Arc::new(TestRuntimeConfigProvider {
2946            snapshot: Arc::new(snapshot),
2947        }));
2948
2949        let names = load_modules(&ctx)
2950            .await
2951            .expect("disabled remote should not be loaded")
2952            .into_iter()
2953            .map(|module| module.manifest.name)
2954            .collect::<Vec<_>>();
2955
2956        assert!(!names.iter().any(|name| name == "remote-crm"));
2957    }
2958
2959    #[tokio::test]
2960    async fn module_metadata_reports_disabled_remote_modules() {
2961        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
2962            .expect("lazy pool should build");
2963        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
2964        config.module_sources.remote.push(RemoteModuleSourceConfig {
2965            name: "remote-grpc-crm".to_owned(),
2966            base_url: "grpc://127.0.0.1:65535".to_owned(),
2967            auth_token_env: None,
2968            timeout_ms: 1,
2969        });
2970        config.modules.insert(
2971            "remote-grpc-crm".to_owned(),
2972            ModuleConfig {
2973                enabled: Some(false),
2974                values: BTreeMap::new(),
2975            },
2976        );
2977        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
2978
2979        let metadata = load_admin_module_metadata(&ctx)
2980            .await
2981            .expect("module metadata should load");
2982        let remote = metadata
2983            .iter()
2984            .find(|module| module.module_name == "remote-grpc-crm")
2985            .expect("disabled remote module should remain visible in metadata");
2986
2987        assert_eq!(remote.source, ModuleSource::Remote);
2988        assert!(matches!(
2989            &remote.load_status,
2990            ModuleLoadStatus::Error { message }
2991                if message == "module disabled by configuration"
2992        ));
2993        assert!(matches!(
2994            &remote.source_diagnostics,
2995            Some(AdminModuleSourceDiagnostics::Remote(diagnostics))
2996                if diagnostics.transport == "grpc"
2997                    && diagnostics.base_url == "http://127.0.0.1:65535"
2998        ));
2999    }
3000
3001    #[test]
3002    fn migrations_for_config_skip_disabled_linked_module_migrations() {
3003        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3004        config.modules.insert(
3005            "auth-password".to_owned(),
3006            ModuleConfig {
3007                enabled: Some(false),
3008                values: BTreeMap::new(),
3009            },
3010        );
3011
3012        let names = migrations_for_config(&config)
3013            .expect("demo linked profile should parse")
3014            .into_iter()
3015            .map(|migration| migration.name)
3016            .collect::<Vec<_>>();
3017
3018        assert!(!names.iter().any(|name| name.starts_with("auth-password/")));
3019        assert!(
3020            names
3021                .iter()
3022                .any(|name| name == &"auth/0001_create_auth_schema")
3023        );
3024    }
3025
3026    #[test]
3027    fn linked_http_modules_for_config_skip_disabled_linked_routes() {
3028        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3029        config.modules.insert(
3030            "auth-password".to_owned(),
3031            ModuleConfig {
3032                enabled: Some(false),
3033                values: BTreeMap::new(),
3034            },
3035        );
3036
3037        let names = linked_http_modules_for_config(&config)
3038            .expect("demo linked profile should parse")
3039            .into_iter()
3040            .map(|module| module.manifest.name)
3041            .collect::<Vec<_>>();
3042
3043        assert_eq!(names, vec!["auth", "platform-story"]);
3044    }
3045
3046    #[test]
3047    fn linked_http_modules_for_config_skip_disabled_story_routes() {
3048        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3049        config.modules.insert(
3050            "platform-story".to_owned(),
3051            ModuleConfig {
3052                enabled: Some(false),
3053                values: BTreeMap::new(),
3054            },
3055        );
3056
3057        let names = linked_http_modules_for_config(&config)
3058            .expect("demo linked profile should parse")
3059            .into_iter()
3060            .map(|module| module.manifest.name)
3061            .collect::<Vec<_>>();
3062
3063        assert_eq!(names, vec!["auth", "auth-password"]);
3064    }
3065
3066    #[tokio::test]
3067    async fn disabled_story_module_omits_default_story_display_catalog() {
3068        story::backend::reset_catalogs_for_test();
3069        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3070        config.modules.insert(
3071            "platform-story".to_owned(),
3072            ModuleConfig {
3073                enabled: Some(false),
3074                values: BTreeMap::new(),
3075            },
3076        );
3077        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3078            .expect("lazy pool should build");
3079        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3080
3081        install_default_story_display_catalog(&ctx)
3082            .expect("story display catalog installation should succeed");
3083
3084        assert!(story::backend::story_display_catalog_snapshot().is_empty());
3085    }
3086
3087    #[tokio::test]
3088    async fn module_metadata_reports_disabled_linked_modules() {
3089        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3090            .expect("lazy pool should build");
3091        let mut config = test_config_with_database_url("postgres://localhost/lenso_test");
3092        config.modules.insert(
3093            "auth-password".to_owned(),
3094            ModuleConfig {
3095                enabled: Some(false),
3096                values: BTreeMap::new(),
3097            },
3098        );
3099        let ctx = AppContext::new(config, db, Arc::new(LoggingEventPublisher));
3100
3101        let metadata = load_admin_module_metadata(&ctx)
3102            .await
3103            .expect("module metadata should load");
3104        let auth_password = metadata
3105            .iter()
3106            .find(|module| module.module_name == "auth-password")
3107            .expect("disabled module should remain visible in metadata");
3108
3109        assert!(matches!(
3110            &auth_password.load_status,
3111            ModuleLoadStatus::Error { message }
3112                if message == "module disabled by configuration"
3113        ));
3114    }
3115
3116    #[test]
3117    fn composition_profile_rejects_unknown_values() {
3118        let error = CompositionProfile::parse("fixture")
3119            .expect_err("fixture is not a supported linked module profile");
3120
3121        assert_eq!(error.code, ErrorCode::Validation);
3122        assert!(
3123            error
3124                .details
3125                .iter()
3126                .any(|detail| detail.field.as_deref() == Some("module_sources.linked_profile"))
3127        );
3128    }
3129
3130    #[test]
3131    fn linked_http_route_owners_are_projected_from_modules() {
3132        assert_eq!(
3133            linked_http_route_owners(),
3134            vec![
3135                LinkedHttpRouteOwner {
3136                    module_name: "auth".to_owned(),
3137                    public_prefixes: &["/v1/auth/dev/", "/v1/auth/sessions/"],
3138                },
3139                LinkedHttpRouteOwner {
3140                    module_name: "auth-password".to_owned(),
3141                    public_prefixes: &["/v1/auth/password/"],
3142                },
3143                LinkedHttpRouteOwner {
3144                    module_name: "platform-story".to_owned(),
3145                    public_prefixes: &["/admin/runtime/stories"],
3146                },
3147            ]
3148        );
3149    }
3150
3151    #[test]
3152    fn linked_http_bindings_are_declared_in_manifests() {
3153        for module in linked_http_modules() {
3154            let http = module
3155                .linked_http
3156                .expect("linked HTTP module should carry HTTP contribution");
3157            assert!(
3158                !module.manifest.http_routes.is_empty(),
3159                "linked HTTP module `{}` must declare ModuleManifest::http_routes",
3160                module.manifest.name
3161            );
3162            for route in &module.manifest.http_routes {
3163                assert!(
3164                    http.public_prefixes
3165                        .iter()
3166                        .any(|prefix| route.path.starts_with(prefix)),
3167                    "linked HTTP module `{}` declares manifest route `{}` outside its public prefixes",
3168                    module.manifest.name,
3169                    route.path
3170                );
3171            }
3172        }
3173    }
3174
3175    #[test]
3176    fn linked_http_modules_are_registered_modules() {
3177        let manifests = module_manifests();
3178
3179        for module in linked_http_modules() {
3180            let registered_manifest = manifests
3181                .iter()
3182                .find(|manifest| manifest.name == module.manifest.name)
3183                .unwrap_or_else(|| {
3184                    panic!(
3185                        "linked HTTP module `{}` is missing from module_manifests",
3186                        module.manifest.name
3187                    )
3188                });
3189            assert_eq!(
3190                registered_manifest, &module.manifest,
3191                "linked HTTP module `{}` must use the registered ModuleManifest",
3192                module.manifest.name
3193            );
3194        }
3195    }
3196
3197    #[test]
3198    fn linked_http_routes_include_story_module_routes() {
3199        let document = merge_linked_http(platform_http::OpenApiRouter::new()).to_openapi();
3200        let value = serde_json::to_value(document).expect("OpenAPI document should serialize");
3201        let paths = value["paths"].as_object().expect("OpenAPI paths object");
3202
3203        assert!(paths.contains_key("/admin/runtime/stories"));
3204        assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}"));
3205        assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/heatmap"));
3206        assert!(paths.contains_key("/admin/runtime/stories/{correlation_id}/technical-operations"));
3207    }
3208
3209    #[test]
3210    fn platform_story_manifest_declares_story_console_surface() {
3211        let manifest = module_manifests()
3212            .into_iter()
3213            .find(|manifest| manifest.name == "platform-story")
3214            .expect("platform-story manifest should be registered");
3215        let console_surface_contract: Value = serde_json::from_str(include_str!(
3216            "../../../modules/story/console/console-surface.json"
3217        ))
3218        .expect("story console surface contract should be valid json");
3219
3220        assert_eq!(manifest.admin, None);
3221        assert_eq!(manifest.console.len(), 1);
3222        let surface = &manifest.console[0];
3223        let surface_json =
3224            serde_json::to_value(surface).expect("platform-story console surface should serialize");
3225
3226        assert_eq!(
3227            manifest.capabilities,
3228            required_capabilities_from_contract(&console_surface_contract)
3229        );
3230        assert_eq!(manifest.name, console_surface_contract["id"]);
3231        assert_eq!(surface.name, console_surface_contract["surfaceName"]);
3232        assert_eq!(surface.label, console_surface_contract["label"]);
3233        assert_eq!(surface.area, ConsoleArea::Runtime);
3234        assert_eq!(surface_json["area"], console_surface_contract["area"]);
3235        assert_eq!(surface.route, console_surface_contract["route"]);
3236        assert_eq!(
3237            surface.package.name,
3238            console_surface_contract["packageName"]
3239        );
3240        assert_eq!(
3241            surface.package.export,
3242            console_surface_contract["exportName"]
3243        );
3244        assert_eq!(surface_json["icon"], console_surface_contract["icon"]);
3245        assert_eq!(surface.navigation, None);
3246        assert!(console_surface_contract.get("navigation").is_none());
3247        assert_eq!(
3248            surface.required_capabilities,
3249            required_capabilities_from_contract(&console_surface_contract)
3250        );
3251
3252        let lints = lint_module_manifest(ModuleSource::Linked, &manifest);
3253        assert!(
3254            lints
3255                .iter()
3256                .all(|lint| lint.severity == ModuleManifestLintSeverity::Ok),
3257            "platform-story manifest should not have warning/error lints: {lints:?}"
3258        );
3259    }
3260
3261    fn required_capabilities_from_contract(contract: &Value) -> Vec<String> {
3262        contract["requiredCapabilities"]
3263            .as_array()
3264            .expect("requiredCapabilities should be an array")
3265            .iter()
3266            .map(|capability| {
3267                capability
3268                    .as_str()
3269                    .expect("requiredCapabilities should contain strings")
3270                    .to_owned()
3271            })
3272            .collect()
3273    }
3274
3275    #[tokio::test]
3276    async fn lifecycle_activation_enqueue_creates_function_run() {
3277        let Some(db) = TestDatabase::create().await else {
3278            return;
3279        };
3280        apply_runtime_stack_migrations(&db).await;
3281
3282        let mut ctx = AppContext::new(
3283            test_config(&db),
3284            db.pool.clone(),
3285            Arc::new(LoggingEventPublisher),
3286        );
3287        ctx.ids = Arc::new(SequentialIdGenerator::default());
3288        let modules = vec![
3289            test_lifecycle_module(lifecycle_activation_job(true, json!({ "warm": "cache" })))
3290                .into(),
3291        ];
3292        let registry = registry_with_lifecycle_function(7);
3293
3294        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3295            .await
3296            .expect("lifecycle activation job should enqueue");
3297
3298        assert_eq!(run_ids.len(), 1);
3299        let row = sqlx::query_as::<_, (String, Value, i32, String, Value)>(
3300            r#"
3301            select function_name, input_json, max_attempts, correlation_id, actor
3302            from runtime.function_runs
3303            where id = $1
3304            "#,
3305        )
3306        .bind(&run_ids[0])
3307        .fetch_one(&db.pool)
3308        .await
3309        .expect("function run should exist");
3310
3311        assert_eq!(row.0, LIFECYCLE_FUNCTION_NAME);
3312        assert_eq!(row.1["warm"], "cache");
3313        assert_eq!(
3314            row.1["_lenso_runtime"]["correlation_id"],
3315            "corr_lifecycle_1"
3316        );
3317        assert_eq!(
3318            row.1["_lenso_runtime"]["causation_id"],
3319            "module_lifecycle:test-module:warm cache"
3320        );
3321        assert_eq!(row.2, 7);
3322        assert_eq!(row.3, "corr_lifecycle_1");
3323        assert_eq!(row.4["kind"], "service");
3324        assert_eq!(row.4["service_id"], "worker");
3325        assert_eq!(row.4["scopes"][0], "runtime.functions.enqueue");
3326
3327        db.cleanup().await;
3328    }
3329
3330    #[test]
3331    fn lifecycle_activation_validation_rejects_required_missing_function() {
3332        let modules =
3333            vec![test_lifecycle_module(lifecycle_activation_job(true, Value::Null)).into()];
3334        let registry = FunctionRegistry::default();
3335
3336        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3337            .expect_err("required missing activation function should fail validation");
3338
3339        assert_eq!(error.code, ErrorCode::Validation);
3340        assert_eq!(
3341            error.details[0].field.as_deref(),
3342            Some("module.test-module.lifecycle.activation_jobs.warm cache")
3343        );
3344        assert!(
3345            error.details[0].reason.contains("missing function"),
3346            "validation detail should name the missing registry function"
3347        );
3348    }
3349
3350    #[test]
3351    fn lifecycle_activation_validation_rejects_required_startup_check_missing_function() {
3352        let modules = vec![test_lifecycle_module_with_lifecycle(
3353            LifecycleSurface {
3354                startup_checks: vec![LifecycleStartupCheckDeclaration {
3355                    name: "function registered".to_owned(),
3356                    required: true,
3357                    check: LifecycleStartupCheckKind::FunctionRegistered {
3358                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3359                    },
3360                }],
3361                activation_jobs: Vec::new(),
3362            },
3363            true,
3364            Vec::new(),
3365        )];
3366        let registry = FunctionRegistry::default();
3367
3368        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3369            .expect_err("required startup check should fail when function is missing");
3370
3371        assert_eq!(error.code, ErrorCode::Validation);
3372        assert_eq!(
3373            error.details[0].field.as_deref(),
3374            Some("module.test-module.lifecycle.startup_checks.function registered")
3375        );
3376        assert!(
3377            error.details[0].reason.contains("missing function"),
3378            "validation detail should name the missing registry function"
3379        );
3380    }
3381
3382    #[test]
3383    fn lifecycle_activation_validation_rejects_required_startup_check_function_not_declared() {
3384        let modules = vec![test_lifecycle_module_with_lifecycle(
3385            LifecycleSurface {
3386                startup_checks: vec![LifecycleStartupCheckDeclaration {
3387                    name: "function registered".to_owned(),
3388                    required: true,
3389                    check: LifecycleStartupCheckKind::FunctionRegistered {
3390                        function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3391                    },
3392                }],
3393                activation_jobs: Vec::new(),
3394            },
3395            false,
3396            Vec::new(),
3397        )];
3398        let registry = registry_with_lifecycle_function(3);
3399
3400        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3401            .expect_err("required startup check should fail when manifest does not declare it");
3402
3403        assert_eq!(error.code, ErrorCode::Validation);
3404        assert_eq!(
3405            error.details[0].field.as_deref(),
3406            Some("module.test-module.lifecycle.startup_checks.function registered")
3407        );
3408        assert!(
3409            error.details[0].reason.contains("not declared"),
3410            "validation detail should name the missing module runtime declaration"
3411        );
3412    }
3413
3414    #[test]
3415    fn lifecycle_activation_validation_rejects_required_startup_check_missing_capability() {
3416        let modules = vec![test_lifecycle_module_with_lifecycle(
3417            LifecycleSurface {
3418                startup_checks: vec![LifecycleStartupCheckDeclaration {
3419                    name: "capability declared".to_owned(),
3420                    required: true,
3421                    check: LifecycleStartupCheckKind::CapabilityDeclared {
3422                        capability: "test.cache.warm".to_owned(),
3423                    },
3424                }],
3425                activation_jobs: Vec::new(),
3426            },
3427            false,
3428            Vec::new(),
3429        )];
3430        let registry = FunctionRegistry::default();
3431
3432        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3433            .expect_err("required startup check should fail when capability is missing");
3434
3435        assert_eq!(error.code, ErrorCode::Validation);
3436        assert_eq!(
3437            error.details[0].field.as_deref(),
3438            Some("module.test-module.lifecycle.startup_checks.capability declared")
3439        );
3440        assert!(
3441            error.details[0].reason.contains("missing capability"),
3442            "validation detail should name the missing capability"
3443        );
3444    }
3445
3446    #[test]
3447    fn lifecycle_activation_optional_startup_checks_do_not_fail_validation() {
3448        let modules = vec![test_lifecycle_module_with_lifecycle(
3449            LifecycleSurface {
3450                startup_checks: vec![
3451                    LifecycleStartupCheckDeclaration {
3452                        name: "optional function".to_owned(),
3453                        required: false,
3454                        check: LifecycleStartupCheckKind::FunctionRegistered {
3455                            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3456                        },
3457                    },
3458                    LifecycleStartupCheckDeclaration {
3459                        name: "optional capability".to_owned(),
3460                        required: false,
3461                        check: LifecycleStartupCheckKind::CapabilityDeclared {
3462                            capability: "test.cache.warm".to_owned(),
3463                        },
3464                    },
3465                ],
3466                activation_jobs: Vec::new(),
3467            },
3468            false,
3469            Vec::new(),
3470        )];
3471        let registry = FunctionRegistry::default();
3472
3473        validate_lifecycle_activation_jobs(&modules, &registry)
3474            .expect("optional startup checks should not fail validation");
3475    }
3476
3477    #[test]
3478    fn lifecycle_activation_validation_rejects_required_job_not_declared_by_module() {
3479        let modules = vec![
3480            test_lifecycle_module(lifecycle_activation_job(true, Value::Null))
3481                .without_runtime_declaration()
3482                .into(),
3483        ];
3484        let registry = registry_with_lifecycle_function(3);
3485
3486        let error = validate_lifecycle_activation_jobs(&modules, &registry)
3487            .expect_err("required activation job should fail when manifest does not declare it");
3488
3489        assert_eq!(error.code, ErrorCode::Validation);
3490        assert_eq!(
3491            error.details[0].field.as_deref(),
3492            Some("module.test-module.lifecycle.activation_jobs.warm cache")
3493        );
3494        assert!(
3495            error.details[0].reason.contains("not declared"),
3496            "validation detail should name the missing module runtime declaration"
3497        );
3498    }
3499
3500    #[tokio::test]
3501    async fn optional_missing_lifecycle_activation_is_skipped() {
3502        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3503            .expect("lazy pool should build");
3504        let ctx = AppContext::new(
3505            test_config_with_database_url("postgres://localhost/lenso_test"),
3506            db,
3507            Arc::new(LoggingEventPublisher),
3508        );
3509        let modules =
3510            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
3511        let registry = FunctionRegistry::default();
3512
3513        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3514            .await
3515            .expect("optional missing activation function should be skipped");
3516
3517        assert!(run_ids.is_empty());
3518    }
3519
3520    #[tokio::test]
3521    async fn lifecycle_activation_optional_job_not_declared_is_skipped() {
3522        let db = platform_core::DbPool::connect_lazy("postgres://localhost/lenso_test")
3523            .expect("lazy pool should build");
3524        let ctx = AppContext::new(
3525            test_config_with_database_url("postgres://localhost/lenso_test"),
3526            db,
3527            Arc::new(LoggingEventPublisher),
3528        );
3529        let modules = vec![
3530            test_lifecycle_module(lifecycle_activation_job(false, Value::Null))
3531                .without_runtime_declaration()
3532                .into(),
3533        ];
3534        let registry = registry_with_lifecycle_function(3);
3535
3536        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3537            .await
3538            .expect("optional undeclared activation function should be skipped");
3539
3540        assert!(run_ids.is_empty());
3541    }
3542
3543    #[tokio::test]
3544    async fn lifecycle_activation_optional_enqueue_failure_is_skipped() {
3545        let db = PgPoolOptions::new()
3546            .max_connections(1)
3547            .acquire_timeout(Duration::from_millis(50))
3548            .connect_lazy_with(
3549                PgConnectOptions::new()
3550                    .host("127.0.0.1")
3551                    .port(1)
3552                    .username("postgres")
3553                    .database("lenso_test"),
3554            );
3555        let ctx = AppContext::new(
3556            test_config_with_database_url("postgres://localhost:1/lenso_test"),
3557            db,
3558            Arc::new(LoggingEventPublisher),
3559        );
3560        let modules =
3561            vec![test_lifecycle_module(lifecycle_activation_job(false, Value::Null)).into()];
3562        let registry = registry_with_lifecycle_function(3);
3563
3564        let run_ids = enqueue_lifecycle_activation_jobs(&ctx, &modules, &registry)
3565            .await
3566            .expect("optional enqueue failure should be skipped");
3567
3568        assert!(run_ids.is_empty());
3569    }
3570
3571    #[test]
3572    fn lifecycle_activation_max_attempts_conversion_saturates() {
3573        assert_eq!(runtime_max_attempts_for_enqueue(7), 7);
3574        assert_eq!(runtime_max_attempts_for_enqueue(u32::MAX), i32::MAX);
3575    }
3576
3577    const LIFECYCLE_FUNCTION_NAME: &str = "test.warm_cache.v1";
3578
3579    #[derive(Debug)]
3580    struct NoopFunctionHandler;
3581
3582    #[async_trait]
3583    impl FunctionHandler for NoopFunctionHandler {
3584        async fn call(
3585            &self,
3586            _ctx: ExecutionContext,
3587            _input: Value,
3588        ) -> platform_core::AppResult<Value> {
3589            Ok(Value::Null)
3590        }
3591    }
3592
3593    fn lifecycle_activation_job(required: bool, input: Value) -> LifecycleActivationJobDeclaration {
3594        LifecycleActivationJobDeclaration {
3595            name: "warm cache".to_owned(),
3596            function_name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3597            run_policy: LifecycleActivationRunPolicy::EveryStartup,
3598            input,
3599            required,
3600        }
3601    }
3602
3603    struct TestLifecycleModuleBuilder {
3604        lifecycle: LifecycleSurface,
3605        declare_runtime_function: bool,
3606        capabilities: Vec<String>,
3607    }
3608
3609    impl TestLifecycleModuleBuilder {
3610        fn without_runtime_declaration(mut self) -> Self {
3611            self.declare_runtime_function = false;
3612            self
3613        }
3614    }
3615
3616    impl From<TestLifecycleModuleBuilder> for Module {
3617        fn from(builder: TestLifecycleModuleBuilder) -> Self {
3618            let mut manifest = ModuleManifest::builder("test-module").lifecycle(builder.lifecycle);
3619            if builder.declare_runtime_function {
3620                manifest = manifest.runtime(RuntimeSurface {
3621                    functions: vec![RuntimeFunctionDeclaration {
3622                        name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3623                        version: 1,
3624                        queue: "test".to_owned(),
3625                        input_schema: None,
3626                        retry_policy: None,
3627                    }],
3628                });
3629            }
3630            if !builder.capabilities.is_empty() {
3631                manifest = manifest.capabilities(builder.capabilities);
3632            }
3633            Module::linked(manifest.build(), LinkedBinding::builder().build())
3634        }
3635    }
3636
3637    fn test_lifecycle_module(job: LifecycleActivationJobDeclaration) -> TestLifecycleModuleBuilder {
3638        TestLifecycleModuleBuilder {
3639            lifecycle: LifecycleSurface {
3640                startup_checks: Vec::new(),
3641                activation_jobs: vec![job],
3642            },
3643            declare_runtime_function: true,
3644            capabilities: Vec::new(),
3645        }
3646    }
3647
3648    fn test_lifecycle_module_with_lifecycle(
3649        lifecycle: LifecycleSurface,
3650        declare_runtime_function: bool,
3651        capabilities: Vec<String>,
3652    ) -> Module {
3653        TestLifecycleModuleBuilder {
3654            lifecycle,
3655            declare_runtime_function,
3656            capabilities,
3657        }
3658        .into()
3659    }
3660
3661    fn registry_with_lifecycle_function(max_attempts: u32) -> FunctionRegistry {
3662        let mut registry = FunctionRegistry::default();
3663        registry.register(FunctionDefinition {
3664            name: LIFECYCLE_FUNCTION_NAME.to_owned(),
3665            version: 1,
3666            queue: "test".to_owned(),
3667            retry_policy: RetryPolicy::fixed(max_attempts, Duration::ZERO),
3668            handler: Arc::new(NoopFunctionHandler),
3669        });
3670        registry
3671    }
3672
3673    #[test]
3674    fn remote_module_service_specs_parse() {
3675        let specs = parse_remote_module_service_specs(&serde_json::json!({
3676            "modules": [
3677                {
3678                    "moduleName": "crm",
3679                    "services": [
3680                        {
3681                            "name": "crm-api",
3682                            "command": "pnpm dev",
3683                            "cwd": "../crm",
3684                            "readyUrl": "http://127.0.0.1:4100/lenso/module/v1/manifest",
3685                            "readyTimeoutMs": 12000,
3686                            "autoStart": true
3687                        }
3688                    ]
3689                }
3690            ],
3691            "version": 1
3692        }))
3693        .expect("service specs parse");
3694
3695        assert_eq!(specs.len(), 1);
3696        assert_eq!(specs[0].module_name, "crm");
3697        assert_eq!(specs[0].service_name, "crm-api");
3698        assert_eq!(specs[0].ready_timeout_ms, 12000);
3699    }
3700
3701    #[test]
3702    fn remote_module_service_state_path_sanitizes_names() {
3703        let spec = RemoteModuleServiceSpec {
3704            module_name: "CRM Module".to_owned(),
3705            service_name: "API Worker!".to_owned(),
3706            command: "pnpm dev".to_owned(),
3707            cwd: None,
3708            ready_url: "http://127.0.0.1:4100/lenso/module/v1/manifest".to_owned(),
3709            ready_timeout_ms: 12000,
3710            auto_start: true,
3711        };
3712
3713        let path = remote_module_service_state_path(Path::new(".lenso"), &spec, "lock");
3714
3715        assert_eq!(
3716            path,
3717            PathBuf::from(".lenso/remote-crm-module-api-worker.lock")
3718        );
3719    }
3720
3721    #[test]
3722    fn remote_module_service_lock_is_exclusive_and_released() {
3723        let unique = std::time::SystemTime::now()
3724            .duration_since(std::time::UNIX_EPOCH)
3725            .expect("system time should be after Unix epoch")
3726            .as_nanos();
3727        let dir = std::env::temp_dir().join(format!(
3728            "lenso-bootstrap-service-lock-{}-{unique}",
3729            std::process::id()
3730        ));
3731        let lock_file_path = dir.join("service.lock");
3732        let pid_file_path = dir.join("service.pid");
3733
3734        let _ = std::fs::remove_dir_all(&dir);
3735        create_remote_module_service_lock(&lock_file_path)
3736            .expect("first lock claim should create the lock");
3737        let second_claim = create_remote_module_service_lock(&lock_file_path)
3738            .expect_err("second lock claim should fail while the file exists");
3739        assert_eq!(second_claim.kind(), std::io::ErrorKind::AlreadyExists);
3740        std::fs::write(&pid_file_path, "123\n").expect("pid file should write");
3741
3742        release_remote_module_service_state(&lock_file_path, &pid_file_path);
3743
3744        assert!(!lock_file_path.exists());
3745        assert!(!pid_file_path.exists());
3746        let _ = std::fs::remove_dir_all(&dir);
3747    }
3748
3749    const TEST_HOST_MIGRATIONS: &[Migration] = &[Migration {
3750        name: "billing/0001_init",
3751        sql: "select 1;",
3752    }];
3753
3754    fn test_host_manifest() -> ModuleManifest {
3755        ModuleManifest::builder("billing").build()
3756    }
3757
3758    fn test_host_linked_module() -> HostLinkedModule {
3759        HostLinkedModule::manifest_only("billing", test_host_manifest, TEST_HOST_MIGRATIONS)
3760    }
3761
3762    fn test_config(db: &TestDatabase) -> AppConfig {
3763        test_config_with_database_url(db.url.clone())
3764    }
3765
3766    fn test_config_with_database_url(database_url: impl Into<String>) -> AppConfig {
3767        AppConfig {
3768            service: ServiceConfig::default(),
3769            database: DatabaseConfig {
3770                url: database_url.into(),
3771                max_connections: 5,
3772            },
3773            http: HttpConfig::default(),
3774            telemetry: TelemetryConfig::default(),
3775            auth: AuthConfig::default(),
3776            console: Default::default(),
3777            module_sources: ModuleSourcesConfig::default(),
3778            modules: BTreeMap::new(),
3779        }
3780    }
3781
3782    async fn apply_runtime_stack_migrations(db: &TestDatabase) {
3783        let migrations = PLATFORM_MIGRATIONS
3784            .iter()
3785            .chain(RUNTIME_MIGRATIONS)
3786            .copied()
3787            .collect::<Vec<_>>();
3788        apply_migrations(&db.pool, &migrations)
3789            .await
3790            .expect("platform and runtime migrations should apply");
3791    }
3792}