create-ekka-desktop-app 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +137 -0
  2. package/bin/cli.js +72 -0
  3. package/package.json +23 -0
  4. package/template/branding/app.json +6 -0
  5. package/template/branding/icon.icns +0 -0
  6. package/template/eslint.config.js +98 -0
  7. package/template/index.html +29 -0
  8. package/template/package.json +40 -0
  9. package/template/src/app/App.tsx +24 -0
  10. package/template/src/demo/DemoApp.tsx +260 -0
  11. package/template/src/demo/components/Banner.tsx +82 -0
  12. package/template/src/demo/components/EmptyState.tsx +61 -0
  13. package/template/src/demo/components/InfoPopover.tsx +171 -0
  14. package/template/src/demo/components/InfoTooltip.tsx +76 -0
  15. package/template/src/demo/components/LearnMore.tsx +98 -0
  16. package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
  17. package/template/src/demo/components/SetupWizard.tsx +48 -0
  18. package/template/src/demo/components/StatusBadge.tsx +83 -0
  19. package/template/src/demo/components/index.ts +10 -0
  20. package/template/src/demo/hooks/index.ts +6 -0
  21. package/template/src/demo/hooks/useAuditEvents.ts +30 -0
  22. package/template/src/demo/layout/Shell.tsx +110 -0
  23. package/template/src/demo/layout/Sidebar.tsx +192 -0
  24. package/template/src/demo/pages/AuditLogPage.tsx +235 -0
  25. package/template/src/demo/pages/DocGenPage.tsx +874 -0
  26. package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
  27. package/template/src/demo/pages/LoginPage.tsx +192 -0
  28. package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
  29. package/template/src/demo/pages/RunnerPage.tsx +445 -0
  30. package/template/src/demo/pages/SystemPage.tsx +557 -0
  31. package/template/src/demo/pages/VaultPage.tsx +805 -0
  32. package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
  33. package/template/src/ekka/audit/index.ts +7 -0
  34. package/template/src/ekka/audit/store.ts +68 -0
  35. package/template/src/ekka/audit/types.ts +22 -0
  36. package/template/src/ekka/auth/client.ts +212 -0
  37. package/template/src/ekka/auth/index.ts +30 -0
  38. package/template/src/ekka/auth/storage.ts +114 -0
  39. package/template/src/ekka/auth/types.ts +67 -0
  40. package/template/src/ekka/backend/demo.ts +151 -0
  41. package/template/src/ekka/backend/interface.ts +36 -0
  42. package/template/src/ekka/config.ts +48 -0
  43. package/template/src/ekka/constants.ts +143 -0
  44. package/template/src/ekka/errors.ts +54 -0
  45. package/template/src/ekka/index.ts +516 -0
  46. package/template/src/ekka/internal/backend.ts +156 -0
  47. package/template/src/ekka/internal/index.ts +7 -0
  48. package/template/src/ekka/ops/auth.ts +29 -0
  49. package/template/src/ekka/ops/debug.ts +68 -0
  50. package/template/src/ekka/ops/home.ts +101 -0
  51. package/template/src/ekka/ops/index.ts +16 -0
  52. package/template/src/ekka/ops/nodeCredentials.ts +131 -0
  53. package/template/src/ekka/ops/nodeSession.ts +145 -0
  54. package/template/src/ekka/ops/paths.ts +183 -0
  55. package/template/src/ekka/ops/runner.ts +86 -0
  56. package/template/src/ekka/ops/runtime.ts +31 -0
  57. package/template/src/ekka/ops/setup.ts +47 -0
  58. package/template/src/ekka/ops/vault.ts +459 -0
  59. package/template/src/ekka/ops/workflowRuns.ts +116 -0
  60. package/template/src/ekka/types.ts +82 -0
  61. package/template/src/ekka/utils/idempotency.ts +14 -0
  62. package/template/src/ekka/utils/index.ts +7 -0
  63. package/template/src/ekka/utils/time.ts +77 -0
  64. package/template/src/main.tsx +12 -0
  65. package/template/src/vite-env.d.ts +12 -0
  66. package/template/src-tauri/Cargo.toml +41 -0
  67. package/template/src-tauri/build.rs +3 -0
  68. package/template/src-tauri/capabilities/default.json +11 -0
  69. package/template/src-tauri/icons/icon.icns +0 -0
  70. package/template/src-tauri/icons/icon.png +0 -0
  71. package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
  72. package/template/src-tauri/src/bootstrap.rs +37 -0
  73. package/template/src-tauri/src/commands.rs +1215 -0
  74. package/template/src-tauri/src/device_secret.rs +111 -0
  75. package/template/src-tauri/src/engine_process.rs +538 -0
  76. package/template/src-tauri/src/grants.rs +129 -0
  77. package/template/src-tauri/src/handlers/home.rs +65 -0
  78. package/template/src-tauri/src/handlers/mod.rs +7 -0
  79. package/template/src-tauri/src/handlers/paths.rs +128 -0
  80. package/template/src-tauri/src/handlers/vault.rs +680 -0
  81. package/template/src-tauri/src/main.rs +243 -0
  82. package/template/src-tauri/src/node_auth.rs +858 -0
  83. package/template/src-tauri/src/node_credentials.rs +541 -0
  84. package/template/src-tauri/src/node_runner.rs +882 -0
  85. package/template/src-tauri/src/node_vault_crypto.rs +113 -0
  86. package/template/src-tauri/src/node_vault_store.rs +267 -0
  87. package/template/src-tauri/src/ops/auth.rs +50 -0
  88. package/template/src-tauri/src/ops/home.rs +251 -0
  89. package/template/src-tauri/src/ops/mod.rs +7 -0
  90. package/template/src-tauri/src/ops/runtime.rs +21 -0
  91. package/template/src-tauri/src/state.rs +639 -0
  92. package/template/src-tauri/src/types.rs +84 -0
  93. package/template/src-tauri/tauri.conf.json +41 -0
  94. package/template/tsconfig.json +26 -0
  95. package/template/tsconfig.tsbuildinfo +1 -0
  96. package/template/vite.config.ts +34 -0
@@ -0,0 +1,111 @@
1
+ //! Device Secret Management
2
+ //!
3
+ //! Generates and stores a device-bound secret for encrypting node-level data.
4
+ //! The device secret is stored as a 32-byte file with 0600 permissions.
5
+ //! This secret never leaves the device and is used to derive encryption keys.
6
+
7
+ use rand::RngCore;
8
+ use std::fs;
9
+ use std::io::{Read, Write};
10
+ use std::path::{Path, PathBuf};
11
+
12
+ #[cfg(unix)]
13
+ use std::os::unix::fs::PermissionsExt;
14
+
15
+ /// Device secret filename
16
+ const DEVICE_SECRET_FILENAME: &str = ".ekka-device-secret";
17
+
18
+ /// Device secret size in bytes (256 bits)
19
+ const DEVICE_SECRET_SIZE: usize = 32;
20
+
21
+ /// Get the path to the device secret file
22
+ pub fn device_secret_path(home: &Path) -> PathBuf {
23
+ home.join(DEVICE_SECRET_FILENAME)
24
+ }
25
+
26
+ /// Load or create the device secret
27
+ ///
28
+ /// If the secret file exists, reads exactly 32 bytes.
29
+ /// If not, generates 32 random bytes and writes with 0600 permissions.
30
+ ///
31
+ /// # Returns
32
+ /// * `Ok([u8; 32])` - The device secret bytes
33
+ /// * `Err` - If file operations fail
34
+ pub fn load_or_create_device_secret(home: &Path) -> anyhow::Result<[u8; 32]> {
35
+ let path = device_secret_path(home);
36
+
37
+ if path.exists() {
38
+ // Load existing secret
39
+ let mut file = fs::File::open(&path)?;
40
+ let mut secret = [0u8; DEVICE_SECRET_SIZE];
41
+ file.read_exact(&mut secret)?;
42
+
43
+ tracing::info!(
44
+ op = "device_secret.ready",
45
+ created = false,
46
+ "Device secret loaded"
47
+ );
48
+
49
+ Ok(secret)
50
+ } else {
51
+ // Generate new secret
52
+ let mut secret = [0u8; DEVICE_SECRET_SIZE];
53
+ rand::thread_rng().fill_bytes(&mut secret);
54
+
55
+ // Write with secure permissions
56
+ let mut file = fs::File::create(&path)?;
57
+ file.write_all(&secret)?;
58
+
59
+ // Set 0600 permissions on Unix
60
+ #[cfg(unix)]
61
+ {
62
+ let perms = fs::Permissions::from_mode(0o600);
63
+ fs::set_permissions(&path, perms)?;
64
+ }
65
+
66
+ tracing::info!(
67
+ op = "device_secret.ready",
68
+ created = true,
69
+ "Device secret created"
70
+ );
71
+
72
+ Ok(secret)
73
+ }
74
+ }
75
+
76
+ #[cfg(test)]
77
+ mod tests {
78
+ use super::*;
79
+ use tempfile::TempDir;
80
+
81
+ #[test]
82
+ fn test_load_or_create_device_secret_creates_new() {
83
+ let temp_dir = TempDir::new().unwrap();
84
+ let secret = load_or_create_device_secret(temp_dir.path()).unwrap();
85
+
86
+ // Should be 32 bytes
87
+ assert_eq!(secret.len(), 32);
88
+
89
+ // File should exist
90
+ assert!(device_secret_path(temp_dir.path()).exists());
91
+ }
92
+
93
+ #[test]
94
+ fn test_load_or_create_device_secret_loads_existing() {
95
+ let temp_dir = TempDir::new().unwrap();
96
+
97
+ // Create first
98
+ let secret1 = load_or_create_device_secret(temp_dir.path()).unwrap();
99
+
100
+ // Load again - should be same
101
+ let secret2 = load_or_create_device_secret(temp_dir.path()).unwrap();
102
+
103
+ assert_eq!(secret1, secret2);
104
+ }
105
+
106
+ #[test]
107
+ fn test_device_secret_path() {
108
+ let path = device_secret_path(Path::new("/home/user/.ekka-desktop"));
109
+ assert_eq!(path, PathBuf::from("/home/user/.ekka-desktop/.ekka-device-secret"));
110
+ }
111
+ }
@@ -0,0 +1,538 @@
1
+ //! Engine Process Management
2
+ //!
3
+ //! Handles spawning, readiness checking, and routing for the external ekka-engine binary.
4
+ //!
5
+ //! ═══════════════════════════════════════════════════════════════════════════════════════════
6
+ //! DRIFT GUARD - ARCHITECTURE FREEZE
7
+ //! ═══════════════════════════════════════════════════════════════════════════════════════════
8
+ //! DO NOT extend this without revisiting the Desktop–Engine architecture decision.
9
+ //!
10
+ //! This module is FROZEN as of Phase 3G. Responsibilities are:
11
+ //! - Spawn ekka-engine binary on startup
12
+ //! - Check readiness via health endpoint
13
+ //! - Route requests to engine (or fallback to stub)
14
+ //! - One-way disable on failure
15
+ //! - Clean shutdown on Desktop exit
16
+ //! - Read-only status visibility (installed, running, available, pid, version, build)
17
+ //! - Log streaming (stdout/stderr forwarding)
18
+ //!
19
+ //! Any changes require explicit architecture review.
20
+ //! ═══════════════════════════════════════════════════════════════════════════════════════════
21
+
22
+ use crate::bootstrap::resolve_home_path;
23
+ use crate::node_credentials;
24
+ use crate::types::{EngineRequest, EngineResponse};
25
+ use serde::Serialize;
26
+ use std::fs;
27
+ use std::io::{BufRead, BufReader, Write};
28
+ use std::path::PathBuf;
29
+ use std::process::{Child, Command, Stdio};
30
+ use std::sync::Mutex;
31
+ use std::time::{Duration, Instant};
32
+
33
+ // =============================================================================
34
+ // Engine Environment Builder
35
+ // =============================================================================
36
+
37
+ /// Build environment variables for engine process spawn.
38
+ /// Returns Err with missing key name if a required var is missing/invalid.
39
+ ///
40
+ /// Order of precedence for node credentials:
41
+ /// 1. OS Keychain (stored via nodeCredentials.set)
42
+ /// 2. Environment variables (EKKA_NODE_ID, EKKA_NODE_SECRET)
43
+ fn build_engine_env() -> Result<Vec<(&'static str, String)>, &'static str> {
44
+ let mut env = Vec::new();
45
+
46
+ // EKKA_RUNNER_MODE=engine (always set)
47
+ env.push(("EKKA_RUNNER_MODE", "engine".to_string()));
48
+
49
+ // EKKA_ENGINE_URL - pass through from process env or use default
50
+ if let Ok(url) = std::env::var("EKKA_ENGINE_URL") {
51
+ env.push(("EKKA_ENGINE_URL", url));
52
+ }
53
+
54
+ // EKKA_INTERNAL_SERVICE_KEY - required for engine mode
55
+ let internal_key = std::env::var("EKKA_INTERNAL_SERVICE_KEY")
56
+ .or_else(|_| std::env::var("INTERNAL_SERVICE_KEY"))
57
+ .map_err(|_| "EKKA_INTERNAL_SERVICE_KEY")?;
58
+ env.push(("EKKA_INTERNAL_SERVICE_KEY", internal_key));
59
+
60
+ // EKKA_TENANT_ID - required, must be valid UUID
61
+ let tenant_id = std::env::var("EKKA_TENANT_ID")
62
+ .map_err(|_| "EKKA_TENANT_ID")?;
63
+ uuid::Uuid::parse_str(&tenant_id)
64
+ .map_err(|_| "EKKA_TENANT_ID")?;
65
+ env.push(("EKKA_TENANT_ID", tenant_id));
66
+
67
+ // EKKA_WORKSPACE_ID - required, must be valid UUID
68
+ let workspace_id = std::env::var("EKKA_WORKSPACE_ID")
69
+ .map_err(|_| "EKKA_WORKSPACE_ID")?;
70
+ uuid::Uuid::parse_str(&workspace_id)
71
+ .map_err(|_| "EKKA_WORKSPACE_ID")?;
72
+ env.push(("EKKA_WORKSPACE_ID", workspace_id));
73
+
74
+ // Node credentials: Try keychain first, fall back to env vars
75
+ // This enables headless engine startup without manual env exports
76
+ if let Ok((node_id, node_secret)) = node_credentials::load_credentials() {
77
+ env.push(("EKKA_NODE_ID", node_id.to_string()));
78
+ env.push(("EKKA_NODE_SECRET", node_secret));
79
+
80
+ tracing::info!(
81
+ op = "desktop.node.identity.loaded",
82
+ keys = ?["node_id", "node_secret"],
83
+ node_id = %node_id,
84
+ "Node credentials loaded from keychain for engine spawn"
85
+ );
86
+ } else {
87
+ // Fallback to environment variables
88
+ if let Ok(node_id) = std::env::var("EKKA_NODE_ID") {
89
+ env.push(("EKKA_NODE_ID", node_id));
90
+ }
91
+ if let Ok(node_secret) = std::env::var("EKKA_NODE_SECRET") {
92
+ env.push(("EKKA_NODE_SECRET", node_secret));
93
+ }
94
+ }
95
+
96
+ Ok(env)
97
+ }
98
+
99
+ /// Engine status for diagnostics (read-only)
100
+ #[derive(Debug, Clone, Serialize)]
101
+ pub struct EngineStatus {
102
+ pub installed: bool,
103
+ pub running: bool,
104
+ pub available: bool,
105
+ pub pid: Option<u32>,
106
+ pub version: Option<String>,
107
+ pub build: Option<String>,
108
+ }
109
+
110
+ /// Engine process holder
111
+ pub struct EngineProcess {
112
+ child: Mutex<Option<Child>>,
113
+ available: Mutex<bool>,
114
+ installed: Mutex<bool>,
115
+ }
116
+
117
+ impl EngineProcess {
118
+ pub fn new() -> Self {
119
+ Self {
120
+ child: Mutex::new(None),
121
+ available: Mutex::new(false),
122
+ installed: Mutex::new(false),
123
+ }
124
+ }
125
+
126
+ /// Check if engine is available
127
+ pub fn is_available(&self) -> bool {
128
+ self.available.lock().map(|g| *g).unwrap_or(false)
129
+ }
130
+
131
+ /// Check if engine binary is installed
132
+ pub fn is_installed(&self) -> bool {
133
+ self.installed.lock().map(|g| *g).unwrap_or(false)
134
+ }
135
+
136
+ /// Check if engine process is running (has child and not exited)
137
+ pub fn is_running(&self) -> bool {
138
+ if let Ok(mut guard) = self.child.lock() {
139
+ if let Some(ref mut child) = *guard {
140
+ // try_wait returns Ok(Some(_)) if exited, Ok(None) if still running
141
+ return child.try_wait().ok().flatten().is_none();
142
+ }
143
+ }
144
+ false
145
+ }
146
+
147
+ /// Get engine process ID (if running)
148
+ pub fn get_pid(&self) -> Option<u32> {
149
+ if let Ok(guard) = self.child.lock() {
150
+ if let Some(ref child) = *guard {
151
+ return Some(child.id());
152
+ }
153
+ }
154
+ None
155
+ }
156
+
157
+ /// Get engine status (read-only diagnostics)
158
+ pub fn get_status(&self) -> EngineStatus {
159
+ EngineStatus {
160
+ installed: self.is_installed(),
161
+ running: self.is_running(),
162
+ available: self.is_available(),
163
+ pid: self.get_pid(),
164
+ // Version/build require engine info endpoint (not yet available)
165
+ version: None,
166
+ build: None,
167
+ }
168
+ }
169
+
170
+ /// Set availability
171
+ fn set_available(&self, available: bool) {
172
+ if let Ok(mut guard) = self.available.lock() {
173
+ *guard = available;
174
+ }
175
+ }
176
+
177
+ /// Set installed flag
178
+ fn set_installed(&self, installed: bool) {
179
+ if let Ok(mut guard) = self.installed.lock() {
180
+ *guard = installed;
181
+ }
182
+ }
183
+
184
+ /// Permanently disable engine for this session (one-way switch)
185
+ pub fn disable(&self) {
186
+ self.set_available(false);
187
+ }
188
+ }
189
+
190
+ impl Default for EngineProcess {
191
+ fn default() -> Self {
192
+ Self::new()
193
+ }
194
+ }
195
+
196
+ /// Compute engine binary path using ekka_home_folder
197
+ fn compute_engine_path() -> Result<PathBuf, String> {
198
+ let home = resolve_home_path()?;
199
+ Ok(home.join("engine").join("ekka-engine"))
200
+ }
201
+
202
+ /// Ensure bootstrap engine is installed from embedded resources
203
+ ///
204
+ /// Extracts the bundled ekka-engine-bootstrap binary to ekka_home_folder/engine/ekka-engine
205
+ /// if it doesn't already exist.
206
+ ///
207
+ /// Returns Ok(true) if installed, Ok(false) if already present.
208
+ pub fn ensure_bootstrap_installed_from_resources(resource_path: Option<PathBuf>) -> Result<bool, String> {
209
+ let engine_path = compute_engine_path()?;
210
+
211
+ // Already installed - return silently
212
+ if engine_path.exists() {
213
+ return Ok(false);
214
+ }
215
+
216
+ // Get resource bytes - try packaged path first, fall back to dev path
217
+ let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
218
+ .join("resources")
219
+ .join("ekka-engine-bootstrap");
220
+
221
+ let effective_path = match &resource_path {
222
+ Some(path) if path.exists() => path.clone(),
223
+ _ => dev_path.clone(),
224
+ };
225
+
226
+ if !effective_path.exists() {
227
+ tracing::warn!(
228
+ op = "engine.bootstrap.resource_missing",
229
+ resource = %effective_path.display(),
230
+ "Bootstrap resource not found"
231
+ );
232
+ return Err("Bootstrap resource not found".to_string());
233
+ }
234
+
235
+ let resource_bytes = fs::read(&effective_path).map_err(|e| {
236
+ tracing::warn!(
237
+ op = "engine.bootstrap.resource_missing",
238
+ resource = %effective_path.display(),
239
+ error = %e,
240
+ "Bootstrap resource not readable"
241
+ );
242
+ format!("Failed to read resource: {}", e)
243
+ })?;
244
+
245
+ // Create engine directory
246
+ let engine_dir = engine_path.parent().ok_or("Invalid engine path")?;
247
+ fs::create_dir_all(engine_dir)
248
+ .map_err(|e| format!("Failed to create engine directory: {}", e))?;
249
+
250
+ // Write atomically: tmp file -> chmod +x -> rename
251
+ let tmp_path = engine_path.with_extension("tmp");
252
+
253
+ let mut file = fs::File::create(&tmp_path)
254
+ .map_err(|e| format!("Failed to create temp file: {}", e))?;
255
+ file.write_all(&resource_bytes)
256
+ .map_err(|e| format!("Failed to write bootstrap binary: {}", e))?;
257
+ file.sync_all()
258
+ .map_err(|e| format!("Failed to sync file: {}", e))?;
259
+ drop(file);
260
+
261
+ // Set executable permission (Unix only)
262
+ #[cfg(unix)]
263
+ {
264
+ use std::os::unix::fs::PermissionsExt;
265
+ let mut perms = fs::metadata(&tmp_path)
266
+ .map_err(|e| format!("Failed to get permissions: {}", e))?
267
+ .permissions();
268
+ perms.set_mode(0o755);
269
+ fs::set_permissions(&tmp_path, perms)
270
+ .map_err(|e| format!("Failed to set executable: {}", e))?;
271
+ }
272
+
273
+ // Atomic rename
274
+ fs::rename(&tmp_path, &engine_path)
275
+ .map_err(|e| format!("Failed to rename to final path: {}", e))?;
276
+
277
+ tracing::info!(
278
+ op = "engine.bootstrap.install",
279
+ path = %engine_path.display(),
280
+ "Bootstrap engine installed from resources"
281
+ );
282
+
283
+ Ok(true)
284
+ }
285
+
286
+ /// Spawn and wait for engine readiness
287
+ ///
288
+ /// Returns true if engine is ready, false otherwise.
289
+ /// Stores result in EngineProcess.available.
290
+ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
291
+ let engine_path = match compute_engine_path() {
292
+ Ok(p) => p,
293
+ Err(e) => {
294
+ tracing::warn!(
295
+ op = "engine.path.error",
296
+ error = %e,
297
+ "Failed to compute engine path"
298
+ );
299
+ engine.set_available(false);
300
+ return false;
301
+ }
302
+ };
303
+
304
+ // Check if binary exists
305
+ let installed = engine_path.exists();
306
+ engine.set_installed(installed);
307
+
308
+ if !installed {
309
+ tracing::info!(
310
+ op = "engine.spawn.missing",
311
+ path = %engine_path.display(),
312
+ "Engine binary not found, using stub backend"
313
+ );
314
+ engine.set_available(false);
315
+ return false;
316
+ }
317
+
318
+ tracing::info!(
319
+ op = "engine.spawn.start",
320
+ path = %engine_path.display(),
321
+ "Spawning engine process"
322
+ );
323
+
324
+ // Build engine environment from process env
325
+ let engine_env = match build_engine_env() {
326
+ Ok(env) => env,
327
+ Err(missing_key) => {
328
+ tracing::error!(
329
+ op = "engine.spawn.missing_required_env",
330
+ key = %missing_key,
331
+ "Required environment variable missing or invalid"
332
+ );
333
+ engine.set_available(false);
334
+ return false;
335
+ }
336
+ };
337
+
338
+ tracing::info!(
339
+ op = "engine.spawn.env",
340
+ keys = ?engine_env.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
341
+ "Setting engine environment"
342
+ );
343
+
344
+ // Spawn the engine process with piped stdout/stderr
345
+ let mut child = match Command::new(&engine_path)
346
+ .envs(engine_env)
347
+ .stdout(Stdio::piped())
348
+ .stderr(Stdio::piped())
349
+ .spawn()
350
+ {
351
+ Ok(c) => c,
352
+ Err(e) => {
353
+ tracing::warn!(
354
+ op = "engine.spawn.failed",
355
+ error = %e,
356
+ "Failed to spawn engine process"
357
+ );
358
+ engine.set_available(false);
359
+ return false;
360
+ }
361
+ };
362
+
363
+ // Spawn log reader threads (best-effort, ignore errors)
364
+ if let Some(stdout) = child.stdout.take() {
365
+ std::thread::spawn(move || {
366
+ let reader = BufReader::new(stdout);
367
+ for line in reader.lines() {
368
+ match line {
369
+ Ok(l) => tracing::info!(op = "engine.stdout", "{}", l),
370
+ Err(_) => break,
371
+ }
372
+ }
373
+ tracing::debug!(op = "engine.stdout.closed", "Engine stdout stream closed");
374
+ });
375
+ }
376
+
377
+ if let Some(stderr) = child.stderr.take() {
378
+ std::thread::spawn(move || {
379
+ let reader = BufReader::new(stderr);
380
+ for line in reader.lines() {
381
+ match line {
382
+ Ok(l) => tracing::info!(op = "engine.stderr", "{}", l),
383
+ Err(_) => break,
384
+ }
385
+ }
386
+ tracing::debug!(op = "engine.stderr.closed", "Engine stderr stream closed");
387
+ });
388
+ }
389
+
390
+ // Get pid for logging
391
+ let pid = child.id();
392
+
393
+ // Store child process
394
+ if let Ok(mut guard) = engine.child.lock() {
395
+ *guard = Some(child);
396
+ }
397
+
398
+ tracing::info!(op = "engine.spawn.success", pid = pid, "Engine process spawned");
399
+
400
+ // Allow bootstrap time to exec into real engine or fail
401
+ // Bootstrap either: execs real engine (same PID), or exits with error code
402
+ std::thread::sleep(Duration::from_secs(2));
403
+
404
+ // Check if bootstrap exited early (indicates failure)
405
+ let bootstrap_failed = if let Ok(mut guard) = engine.child.lock() {
406
+ if let Some(ref mut c) = *guard {
407
+ match c.try_wait() {
408
+ Ok(Some(status)) => {
409
+ let code = status.code();
410
+ tracing::warn!(
411
+ op = "engine.bootstrap.failed",
412
+ pid = pid,
413
+ exit_code = ?code,
414
+ "Bootstrap exited early - real engine not started"
415
+ );
416
+ true
417
+ }
418
+ Ok(None) => false, // Still running = bootstrap exec'd into real engine
419
+ Err(_) => true,
420
+ }
421
+ } else {
422
+ true
423
+ }
424
+ } else {
425
+ true
426
+ };
427
+
428
+ if bootstrap_failed {
429
+ engine.set_available(false);
430
+ return false;
431
+ }
432
+
433
+ tracing::debug!(op = "engine.bootstrap.exec", pid = pid, "Bootstrap exec'd into real engine, starting readiness check");
434
+
435
+ // Wait for readiness (real engine should now be running)
436
+ let ready = wait_for_ready(15);
437
+ engine.set_available(ready);
438
+
439
+ if ready {
440
+ tracing::info!(op = "engine.ready", "Engine is ready");
441
+ } else {
442
+ tracing::warn!(op = "engine.ready.timeout", "Engine readiness timeout");
443
+ }
444
+
445
+ ready
446
+ }
447
+
448
+ /// Wait for engine to become ready by checking health endpoint
449
+ fn wait_for_ready(timeout_secs: u64) -> bool {
450
+ // Default port for engine health check
451
+ let port: u16 = std::env::var("EKKA_ENGINE_PORT")
452
+ .ok()
453
+ .and_then(|s| s.parse().ok())
454
+ .unwrap_or(9473);
455
+
456
+ let url = format!("http://127.0.0.1:{}/health", port);
457
+
458
+ let client = match reqwest::blocking::Client::builder()
459
+ .timeout(Duration::from_secs(2))
460
+ .build()
461
+ {
462
+ Ok(c) => c,
463
+ Err(_) => return false,
464
+ };
465
+
466
+ let start = Instant::now();
467
+ let timeout = Duration::from_secs(timeout_secs);
468
+
469
+ while start.elapsed() < timeout {
470
+ if let Ok(resp) = client.get(&url).send() {
471
+ if resp.status().is_success() {
472
+ return true;
473
+ }
474
+ }
475
+ std::thread::sleep(Duration::from_millis(500));
476
+ }
477
+
478
+ false
479
+ }
480
+
481
+ /// Shutdown engine process (called on app exit)
482
+ pub fn shutdown(engine: &EngineProcess) {
483
+ if let Ok(mut guard) = engine.child.lock() {
484
+ if let Some(mut child) = guard.take() {
485
+ tracing::info!(op = "engine.shutdown", "Shutting down engine process");
486
+ let _ = child.kill();
487
+ let _ = child.wait();
488
+ }
489
+ }
490
+ engine.set_available(false);
491
+ }
492
+
493
+ /// Route a request to the real engine
494
+ ///
495
+ /// Returns Some(response) if engine handled the request, None on failure.
496
+ /// On failure, caller should fall back to stub.
497
+ pub fn route_to_engine(req: &EngineRequest) -> Option<EngineResponse> {
498
+ let port: u16 = std::env::var("EKKA_ENGINE_PORT")
499
+ .ok()
500
+ .and_then(|s| s.parse().ok())
501
+ .unwrap_or(9473);
502
+
503
+ let url = format!("http://127.0.0.1:{}/request", port);
504
+
505
+ let client = match reqwest::blocking::Client::builder()
506
+ .timeout(Duration::from_secs(30))
507
+ .build()
508
+ {
509
+ Ok(c) => c,
510
+ Err(e) => {
511
+ tracing::warn!(op = "engine.route.client_error", error = %e, "Failed to create HTTP client");
512
+ return None;
513
+ }
514
+ };
515
+
516
+ match client.post(&url).json(req).send() {
517
+ Ok(resp) if resp.status().is_success() => {
518
+ match resp.json::<EngineResponse>() {
519
+ Ok(engine_resp) => {
520
+ tracing::debug!(op = "engine.route.success", op_name = %req.op, "Routed to engine");
521
+ Some(engine_resp)
522
+ }
523
+ Err(e) => {
524
+ tracing::warn!(op = "engine.route.parse_error", error = %e, "Failed to parse engine response");
525
+ None
526
+ }
527
+ }
528
+ }
529
+ Ok(resp) => {
530
+ tracing::warn!(op = "engine.route.error", status = %resp.status(), "Engine returned error");
531
+ None
532
+ }
533
+ Err(e) => {
534
+ tracing::warn!(op = "engine.route.failed", error = %e, "Failed to route to engine");
535
+ None
536
+ }
537
+ }
538
+ }