Skip to main content

apcore/sys_modules/
control.rs

1// APCore Protocol — System control modules
2// Spec reference: system.control.update_config (F11), system.control.reload_module (F10),
3//                 system.control.toggle_feature (F19)
4
5use async_trait::async_trait;
6use serde_json::json;
7use std::sync::Arc;
8use tokio::sync::Mutex;
9
10use crate::config::Config;
11use crate::context::Context;
12use crate::errors::{ErrorCode, ModuleError};
13use crate::events::emitter::EventEmitter;
14use crate::module::Module;
15use crate::registry::registry::Registry;
16
17use super::{
18    emit_event, is_sensitive_key, missing_field_error, require_string, ToggleState, RESTRICTED_KEYS,
19};
20
21// ---------------------------------------------------------------------------
22// UpdateConfigModule (F11)
23// ---------------------------------------------------------------------------
24
25/// Update a runtime configuration value by dot-path key (F11).
26pub struct UpdateConfigModule {
27    config: Arc<Mutex<Config>>,
28    emitter: Arc<Mutex<EventEmitter>>,
29}
30
31impl UpdateConfigModule {
32    pub fn new(config: Arc<Mutex<Config>>, emitter: Arc<Mutex<EventEmitter>>) -> Self {
33        Self { config, emitter }
34    }
35}
36
37#[async_trait]
38impl Module for UpdateConfigModule {
39    fn description(&self) -> &str {
40        "Update a runtime configuration value by dot-path key"
41    }
42
43    fn input_schema(&self) -> serde_json::Value {
44        json!({
45            "type": "object",
46            "required": ["key", "value", "reason"],
47            "properties": {
48                "key":    {"type": "string"},
49                "value":  {},
50                "reason": {"type": "string"}
51            }
52        })
53    }
54
55    fn output_schema(&self) -> serde_json::Value {
56        json!({
57            "type": "object",
58            "properties": {
59                "success":   {"type": "boolean"},
60                "key":       {"type": "string"},
61                "old_value": {},
62                "new_value": {}
63            }
64        })
65    }
66
67    async fn execute(
68        &self,
69        inputs: serde_json::Value,
70        _ctx: &Context<serde_json::Value>,
71    ) -> Result<serde_json::Value, ModuleError> {
72        let key = require_string(&inputs, "key")?;
73        let reason = require_string(&inputs, "reason")?;
74        let value = inputs
75            .get("value")
76            .cloned()
77            .ok_or_else(|| missing_field_error("value"))?;
78
79        if RESTRICTED_KEYS.contains(&key.as_str()) {
80            return Err(ModuleError::new(
81                ErrorCode::ConfigInvalid,
82                format!("Configuration key '{}' cannot be changed at runtime", key),
83            )
84            .with_details([("key".to_string(), json!(key))].into_iter().collect()));
85        }
86
87        let old_value = {
88            let cfg = self.config.lock().await;
89            cfg.get(&key)
90        };
91
92        {
93            let mut cfg = self.config.lock().await;
94            cfg.set(&key, value.clone());
95        }
96
97        let timestamp = chrono::Utc::now().to_rfc3339();
98        let event_data = json!({
99            "key": key,
100            "old_value": old_value,
101            "new_value": value,
102        });
103
104        emit_event(
105            &self.emitter,
106            "apcore.config.updated",
107            "system.control.update_config",
108            &timestamp,
109            event_data.clone(),
110        )
111        .await;
112        // W-9: DEPRECATED alias — emitted for backward compatibility during 0.15.x transition.
113        emit_event(
114            &self.emitter,
115            "config_changed",
116            "system.control.update_config",
117            &timestamp,
118            event_data,
119        )
120        .await;
121
122        if is_sensitive_key(&key) {
123            tracing::info!(key = %key, reason = %reason, "Config updated: old_value=*** new_value=***");
124        } else {
125            tracing::info!(
126                key = %key,
127                old_value = ?old_value,
128                new_value = ?value,
129                reason = %reason,
130                "Config updated"
131            );
132        }
133
134        Ok(json!({
135            "success": true,
136            "key": key,
137            "old_value": old_value,
138            "new_value": value,
139        }))
140    }
141}
142
143// ---------------------------------------------------------------------------
144// ReloadModuleModule (F10)
145// ---------------------------------------------------------------------------
146
147/// Hot-reload a module via safe unregister (F10).
148///
149/// Full re-discovery is not supported in Rust (no dynamic loading). The module
150/// is unregistered and callers must re-register manually. The event is always
151/// emitted with new_version == previous_version.
152pub struct ReloadModuleModule {
153    registry: Arc<Mutex<Registry>>,
154    emitter: Arc<Mutex<EventEmitter>>,
155}
156
157impl ReloadModuleModule {
158    pub fn new(registry: Arc<Mutex<Registry>>, emitter: Arc<Mutex<EventEmitter>>) -> Self {
159        Self { registry, emitter }
160    }
161}
162
163#[async_trait]
164impl Module for ReloadModuleModule {
165    fn description(&self) -> &str {
166        "Hot-reload a module by safe unregister (re-registration must be done explicitly in Rust)"
167    }
168
169    fn input_schema(&self) -> serde_json::Value {
170        json!({
171            "type": "object",
172            "required": ["module_id", "reason"],
173            "properties": {
174                "module_id": {"type": "string"},
175                "reason":    {"type": "string"}
176            }
177        })
178    }
179
180    fn output_schema(&self) -> serde_json::Value {
181        json!({
182            "type": "object",
183            "properties": {
184                "success":            {"type": "boolean"},
185                "module_id":          {"type": "string"},
186                "previous_version":   {"type": "string"},
187                "new_version":        {"type": "string"},
188                "reload_duration_ms": {"type": "number"}
189            }
190        })
191    }
192
193    async fn execute(
194        &self,
195        inputs: serde_json::Value,
196        _ctx: &Context<serde_json::Value>,
197    ) -> Result<serde_json::Value, ModuleError> {
198        let module_id = require_string(&inputs, "module_id")?;
199        let reason = require_string(&inputs, "reason")?;
200
201        let start = std::time::Instant::now();
202
203        // W-5: Single lock for the check-then-unregister sequence to eliminate TOCTOU.
204        // W-1: Version is not tracked in the Rust registry descriptor; use "unknown".
205        let previous_version = {
206            let mut reg = self.registry.lock().await;
207            if !reg.has(&module_id) {
208                return Err(ModuleError::new(
209                    ErrorCode::ModuleNotFound,
210                    format!("Module '{}' not found", module_id),
211                ));
212            }
213            reg.safe_unregister(&module_id, 5000).await?;
214            "unknown".to_string()
215        };
216
217        let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
218        let new_version = previous_version.clone();
219        let timestamp = chrono::Utc::now().to_rfc3339();
220        let event_data = json!({
221            "previous_version": previous_version,
222            "new_version": new_version,
223        });
224
225        emit_event(
226            &self.emitter,
227            "apcore.module.reloaded",
228            &module_id,
229            &timestamp,
230            event_data.clone(),
231        )
232        .await;
233        // W-9: DEPRECATED alias — emitted for backward compatibility during 0.15.x transition.
234        emit_event(
235            &self.emitter,
236            "config_changed",
237            &module_id,
238            &timestamp,
239            event_data,
240        )
241        .await;
242
243        tracing::info!(
244            module_id = %module_id,
245            previous_version = %previous_version,
246            new_version = %new_version,
247            reason = %reason,
248            "Module reloaded"
249        );
250
251        Ok(json!({
252            "success": true,
253            "module_id": module_id,
254            "previous_version": previous_version,
255            "new_version": new_version,
256            "reload_duration_ms": elapsed_ms,
257        }))
258    }
259}
260
261// ---------------------------------------------------------------------------
262// ToggleFeatureModule (F19)
263// ---------------------------------------------------------------------------
264
265/// Disable or enable a module without unloading it from the Registry (F19).
266pub struct ToggleFeatureModule {
267    registry: Arc<Mutex<Registry>>,
268    emitter: Arc<Mutex<EventEmitter>>,
269    toggle_state: Arc<ToggleState>,
270}
271
272impl ToggleFeatureModule {
273    pub fn new(
274        registry: Arc<Mutex<Registry>>,
275        emitter: Arc<Mutex<EventEmitter>>,
276        toggle_state: Arc<ToggleState>,
277    ) -> Self {
278        Self {
279            registry,
280            emitter,
281            toggle_state,
282        }
283    }
284}
285
286#[async_trait]
287impl Module for ToggleFeatureModule {
288    fn description(&self) -> &str {
289        "Disable or enable a module without unloading it"
290    }
291
292    fn input_schema(&self) -> serde_json::Value {
293        json!({
294            "type": "object",
295            "required": ["module_id", "enabled", "reason"],
296            "properties": {
297                "module_id": {"type": "string"},
298                "enabled":   {"type": "boolean"},
299                "reason":    {"type": "string"}
300            }
301        })
302    }
303
304    fn output_schema(&self) -> serde_json::Value {
305        json!({
306            "type": "object",
307            "properties": {
308                "success":   {"type": "boolean"},
309                "module_id": {"type": "string"},
310                "enabled":   {"type": "boolean"}
311            }
312        })
313    }
314
315    async fn execute(
316        &self,
317        inputs: serde_json::Value,
318        _ctx: &Context<serde_json::Value>,
319    ) -> Result<serde_json::Value, ModuleError> {
320        let module_id = require_string(&inputs, "module_id")?;
321        let reason = require_string(&inputs, "reason")?;
322        let enabled = inputs
323            .get("enabled")
324            .and_then(|v| v.as_bool())
325            .ok_or_else(|| {
326                ModuleError::new(
327                    ErrorCode::GeneralInvalidInput,
328                    "'enabled' is required and must be a boolean",
329                )
330            })?;
331
332        {
333            let reg = self.registry.lock().await;
334            if !reg.has(&module_id) {
335                return Err(ModuleError::new(
336                    ErrorCode::ModuleNotFound,
337                    format!("Module '{}' not found", module_id),
338                ));
339            }
340        }
341
342        if enabled {
343            self.toggle_state.enable(&module_id);
344        } else {
345            self.toggle_state.disable(&module_id);
346        }
347
348        let timestamp = chrono::Utc::now().to_rfc3339();
349        let event_data = json!({"enabled": enabled});
350
351        emit_event(
352            &self.emitter,
353            "apcore.module.toggled",
354            &module_id,
355            &timestamp,
356            event_data.clone(),
357        )
358        .await;
359        // W-9: DEPRECATED alias — emitted for backward compatibility during 0.15.x transition.
360        emit_event(
361            &self.emitter,
362            "module_health_changed",
363            &module_id,
364            &timestamp,
365            event_data,
366        )
367        .await;
368
369        tracing::info!(
370            module_id = %module_id,
371            enabled = %enabled,
372            reason = %reason,
373            "Module toggled"
374        );
375
376        Ok(json!({
377            "success": true,
378            "module_id": module_id,
379            "enabled": enabled,
380        }))
381    }
382}