orcs-app 0.1.1

ORCS Application Layer - Re-exports and AppError
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Builder for [`OrcsApp`].

use super::OrcsApp;
use crate::AppError;
use mlua::Lua;
use orcs_component::{ChildConfig, Component, RunnableChild, SpawnError};
use orcs_lua::{LuaChild, ScriptLoader};
use orcs_runtime::auth::{DefaultGrantStore, DefaultPolicy, GrantPolicy, PermissionChecker};
use orcs_runtime::session::SessionStore;
use orcs_runtime::LuaChildLoader;
use orcs_runtime::{
    ChannelConfig, ConfigResolver, IOPort, LocalFileStore, OrcsEngine, Session, SessionAsset, World,
};
use orcs_types::{Principal, PrincipalId};
use parking_lot::Mutex;
use std::collections::HashMap;
use std::sync::Arc;

/// LuaChildLoader implementation for spawning Lua children.
struct AppLuaChildLoader {
    sandbox: Arc<dyn orcs_runtime::sandbox::SandboxPolicy>,
}

impl LuaChildLoader for AppLuaChildLoader {
    fn load(&self, config: &ChildConfig) -> Result<Box<dyn RunnableChild>, SpawnError> {
        // Get script from config (prefer inline over path)
        let script = config
            .script_inline
            .as_ref()
            .ok_or_else(|| SpawnError::Internal("no script_inline in config".into()))?;

        // Create new Lua instance for this child
        let lua = Lua::new();
        let lua_arc = Arc::new(Mutex::new(lua));

        // Create LuaChild from script
        let child = LuaChild::from_script(lua_arc, script, Arc::clone(&self.sandbox))
            .map_err(|e| SpawnError::Internal(format!("failed to load Lua child: {}", e)))?;

        Ok(Box::new(child))
    }
}

/// Builder for [`OrcsApp`].
///
/// Accepts a [`ConfigResolver`] that encapsulates all config resolution
/// logic (file loading, env vars, CLI overrides). The builder only
/// handles launch parameters (e.g., session resume).
///
/// # Example
///
/// ```ignore
/// use orcs_runtime::{ConfigResolver, OrcsConfig, ConfigError};
///
/// struct MyResolver;
/// impl ConfigResolver for MyResolver {
///     fn resolve(&self) -> Result<OrcsConfig, ConfigError> {
///         Ok(OrcsConfig::default())
///     }
/// }
///
/// let app = OrcsApp::builder(MyResolver)
///     .build()
///     .await?;
/// ```
pub struct OrcsAppBuilder {
    /// Configuration resolver (owned, moved into OrcsApp).
    resolver: Box<dyn ConfigResolver>,
    /// Session ID to resume from (launch parameter).
    resume_session_id: Option<String>,
    /// Sandbox policy for file operations.
    sandbox: Option<Arc<dyn orcs_runtime::sandbox::SandboxPolicy>>,
    /// Shared printer slot for tracing + IOOutput routing.
    printer_slot: crate::SharedPrinterSlot,
}

impl OrcsAppBuilder {
    /// Creates a new builder with the given resolver.
    #[must_use]
    pub fn new(resolver: impl ConfigResolver + 'static) -> Self {
        Self {
            resolver: Box::new(resolver),
            resume_session_id: None,
            sandbox: None,
            printer_slot: crate::SharedPrinterSlot::new(),
        }
    }

    /// Sets a shared printer slot for tracing and IOOutput routing.
    ///
    /// The same slot must be passed to the tracing `MakeWriter` so that
    /// log output routes through ExternalPrinter during interactive mode.
    #[must_use]
    pub fn with_printer_slot(mut self, slot: crate::SharedPrinterSlot) -> Self {
        self.printer_slot = slot;
        self
    }

    /// Sets the sandbox policy for file operations.
    ///
    /// Required before calling [`build()`](Self::build).
    #[must_use]
    pub fn with_sandbox(mut self, sandbox: Arc<dyn orcs_runtime::sandbox::SandboxPolicy>) -> Self {
        self.sandbox = Some(sandbox);
        self
    }

    /// Sets a session ID to resume from.
    #[must_use]
    pub fn resume(mut self, session_id: impl Into<String>) -> Self {
        self.resume_session_id = Some(session_id.into());
        self
    }

    /// Builds the application.
    ///
    /// Resolves configuration via the provided [`ConfigResolver`],
    /// creates the World, Engine, and spawns component runners.
    ///
    /// # Errors
    ///
    /// Returns [`AppError::Config`] if configuration resolution fails.
    pub async fn build(self) -> Result<OrcsApp, AppError> {
        // Resolve configuration (all layers handled by the resolver)
        let config = self
            .resolver
            .resolve()
            .map_err(|e| AppError::Config(e.to_string()))?;

        // Sandbox is required for file operations
        let sandbox = self.sandbox.ok_or_else(|| {
            AppError::Config("sandbox policy not set (use .with_sandbox())".into())
        })?;

        // Create session store
        let session_path = config.paths.session_dir_or_default();
        let store = LocalFileStore::new(session_path)
            .map_err(|e| AppError::Config(format!("Failed to create session store: {e}")))?;

        // Expand builtins to versioned directory on disk
        let builtins_base = config.components.resolved_builtins_dir();
        crate::builtins::ensure_expanded(&builtins_base)
            .map_err(|e| AppError::Config(format!("Failed to expand builtins: {e}")))?;
        let versioned_builtins = crate::builtins::versioned_dir(&builtins_base);

        // Build component loader: user paths (higher priority) + builtins (fallback)
        let component_loader = ScriptLoader::new(Arc::clone(&sandbox))
            .with_paths(config.components.resolved_paths())
            .with_path(&versioned_builtins);

        // Create World with IO channel + pre-allocated component channels
        let component_names = &config.components.load;
        let mut world = World::new();
        let io = world.create_channel(ChannelConfig::interactive());
        let builtin_channels: Vec<_> = component_names
            .iter()
            .map(|_| world.create_channel(ChannelConfig::default()))
            .collect();

        // Create engine with IO channel (required)
        let mut engine = OrcsEngine::new(world, io);

        // Initialize hook registry and load hooks from config
        if !config.hooks.hooks.is_empty() {
            let registry = orcs_runtime::shared_hook_registry();
            let result = orcs_lua::hook_helpers::load_hooks_from_config(
                &config.hooks,
                &registry,
                sandbox.root(),
            );
            for err in &result.errors {
                tracing::warn!(hook = %err.hook_label, error = %err.error, "Hook load failed");
            }
            if result.loaded > 0 {
                tracing::info!(
                    loaded = result.loaded,
                    skipped = result.skipped,
                    errors = result.errors.len(),
                    "Hooks initialized"
                );
                engine.set_hook_registry(registry);
            }
        }

        // Connect to configured MCP servers (before component spawning so
        // IntentDefs are available when components initialize).
        if !config.mcp.is_empty() {
            let manager = std::sync::Arc::new(orcs_mcp::McpClientManager::new(config.mcp.clone()));
            let defs = manager.connect_all().await;
            if !defs.is_empty() {
                tracing::info!(
                    tool_count = defs.len(),
                    "MCP servers connected, {} tool(s) discovered",
                    defs.len()
                );
            }
            engine.set_mcp_manager(manager);
        }

        // Auth context: principal + non-elevated session + checker.
        // Created before user script loading so user scripts get proper HIL.
        let principal = Principal::User(PrincipalId::new());
        let non_elevated_session: Arc<Session> = Arc::new(Session::new(principal.clone()));
        let auth_checker: Arc<dyn PermissionChecker> = Arc::new(DefaultPolicy);

        // Load user scripts from configured directories (auto_load)
        if config.scripts.auto_load {
            let script_dirs = config.scripts.resolve_dirs(Some(sandbox.root()));
            if !script_dirs.is_empty() {
                let script_loader = ScriptLoader::new(Arc::clone(&sandbox)).with_paths(script_dirs);
                let result = script_loader.load_all();

                let loaded_count = result.loaded_count();
                let warning_count = result.warning_count();

                for warn in &result.warnings {
                    tracing::warn!(
                        path = %warn.path.display(),
                        error = %warn.error,
                        "Failed to load user script"
                    );
                }

                for (name, component) in result.loaded {
                    let world_ref = engine.world_read();
                    let channel_id = {
                        let mut w = world_ref.blocking_write();
                        w.create_channel(ChannelConfig::default())
                    };
                    let component_id = component.id().clone();
                    let user_grants: Arc<dyn GrantPolicy> = Arc::new(DefaultGrantStore::new());
                    engine.spawn_runner_full_auth(
                        channel_id,
                        Box::new(component),
                        None,
                        None,
                        None,
                        Arc::clone(&non_elevated_session),
                        Arc::clone(&auth_checker),
                        user_grants,
                    );
                    tracing::info!(
                        name = %name,
                        channel = %channel_id,
                        component = %component_id.fqn(),
                        "User script loaded"
                    );
                }

                if loaded_count > 0 {
                    tracing::info!(
                        "Loaded {} user script(s), {} warning(s)",
                        loaded_count,
                        warning_count
                    );
                }
            }
        }

        // Create IO port for ClientRunner
        let (io_port, io_input, io_output) = IOPort::with_defaults(io);
        // Spawn ClientRunner for IO channel (no component - bridge only)
        let (_io_handle, io_event_tx) = engine.spawn_client_runner(io, io_port, principal.clone());
        tracing::info!("ClientRunner spawned: channel={} (IO bridge)", io);

        // Elevated session + shared grants for builtin components.
        let elevated_session: Arc<Session> =
            Arc::new(Session::new(principal.clone()).elevate(std::time::Duration::from_secs(3600)));
        // Elevated components share grants; non-elevated get their own store.
        // Concrete type so save_session/resume can call restore_grants directly.
        let shared_grants = Arc::new(DefaultGrantStore::new());
        tracing::info!(
            "Auth context created: principal={:?}, elevated={}",
            elevated_session.principal(),
            elevated_session.is_elevated()
        );

        // Load or create session (before component spawn so snapshots are available)
        let (session_asset, is_resumed, restored_count) =
            if let Some(session_id) = &self.resume_session_id {
                tracing::info!("Resuming session: {}", session_id);
                let asset = store.load(session_id).await?;
                let count = asset.component_snapshots.len();

                // Restore grants from previous session
                let saved_grants = asset.granted_commands();
                if !saved_grants.is_empty() {
                    match shared_grants.restore_grants(saved_grants) {
                        Ok(()) => {
                            tracing::info!("Restored {} grant(s) from session", saved_grants.len());
                        }
                        Err(e) => {
                            tracing::error!("Failed to restore grants: {e}");
                        }
                    }
                }

                (asset, true, count)
            } else {
                (SessionAsset::new(), false, 0)
            };

        // Build builtins map from embedded FILES for spawn_runner({ builtin = "..." })
        // Wrapped in Arc to share across components without per-component HashMap clone.
        let builtins_map: Arc<HashMap<String, String>> = Arc::new(
            crate::builtins::FILES
                .iter()
                .map(|(path, content)| ((*path).to_string(), (*content).to_string()))
                .collect(),
        );

        // Spawn components driven by RuntimeHints
        let mut component_routes = HashMap::new();

        for (name, &channel_id) in component_names.iter().zip(builtin_channels.iter()) {
            let component = component_loader
                .load(name)
                .map_err(|e| AppError::Config(format!("Failed to load component '{name}': {e}")))?;
            let hints = component.runtime_hints();
            let component_id = component.id().clone();

            // Derive spawn config from hints
            let output_tx = if hints.output_to_io {
                Some(io_event_tx.clone())
            } else {
                None
            };
            let session = if hints.elevated {
                Arc::clone(&elevated_session)
            } else {
                Arc::clone(&non_elevated_session)
            };
            let grants: Arc<dyn GrantPolicy> = if hints.elevated {
                Arc::clone(&shared_grants) as Arc<dyn GrantPolicy>
            } else {
                Arc::new(DefaultGrantStore::new())
            };
            let lua_loader: Option<Arc<dyn LuaChildLoader>> = if hints.child_spawner {
                Some(Arc::new(AppLuaChildLoader {
                    sandbox: Arc::clone(&sandbox),
                }))
            } else {
                None
            };
            let component_loader: Option<Arc<dyn orcs_component::ComponentLoader>> =
                if hints.child_spawner {
                    Some(Arc::new(
                        orcs_lua::LuaComponentLoader::new(Arc::clone(&sandbox))
                            .with_builtins(Arc::clone(&builtins_map)),
                    ))
                } else {
                    None
                };

            // Look up initial snapshot for this component (session resume)
            let initial_snapshot = session_asset.get_snapshot(&component_id.fqn()).cloned();

            if initial_snapshot.is_some() {
                tracing::info!(
                    component = %component_id.fqn(),
                    "Restoring snapshot for component"
                );
            }

            // Per-component settings from [components.settings.<name>]
            // with global config injected as cfg._global for Lua access.
            let mut component_config = config.components.component_settings(name);
            if let serde_json::Value::Object(ref mut map) = component_config {
                map.insert("_global".into(), config.global_config_for_lua());
            }

            engine.spawn_runner_full_auth_with_snapshot(
                channel_id,
                Box::new(component),
                output_tx,
                lua_loader,
                component_loader,
                session,
                Arc::clone(&auth_checker),
                grants,
                initial_snapshot,
                component_config,
            );

            let short_name = component_id.name.clone();
            component_routes.insert(short_name, channel_id);

            tracing::info!(
                channel = %channel_id,
                component = %component_id.fqn(),
                output_to_io = hints.output_to_io,
                elevated = hints.elevated,
                child_spawner = hints.child_spawner,
                "Spawned component"
            );
        }

        tracing::info!(
            "Component routes registered: {:?}",
            component_routes.keys().collect::<Vec<_>>()
        );

        // Create console renderer based on config
        let renderer = if config.ui.verbose {
            orcs_runtime::io::ConsoleRenderer::verbose()
        } else {
            orcs_runtime::io::ConsoleRenderer::new()
        };

        tracing::debug!("OrcsApp created with config: debug={}", config.debug);

        Ok(OrcsApp {
            config,
            resolver: self.resolver,
            engine,
            principal,
            renderer,
            io_input,
            io_output,
            pending_approval: None,
            store,
            session: session_asset,
            is_resumed,
            restored_count,
            shared_grants,
            component_routes,
            printer_slot: self.printer_slot,
        })
    }
}