create-ekka-desktop-app 0.3.12 → 0.4.0

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.
@@ -1,27 +1,30 @@
1
1
  //! Engine Process Management
2
2
  //!
3
- //! Handles spawning, readiness checking, and routing for the external ekka-engine binary.
3
+ //! Handles spawning and readiness checking for the external ekka-engine binary.
4
4
  //!
5
5
  //! ═══════════════════════════════════════════════════════════════════════════════════════════
6
- //! DRIFT GUARD - ARCHITECTURE FREEZE
6
+ //! ARCHITECTURE NOTE
7
7
  //! ═══════════════════════════════════════════════════════════════════════════════════════════
8
- //! DO NOT extend this without revisiting the Desktop–Engine architecture decision.
8
+ //! The spawned engine is a RUNNER RUNTIME (ekka-runner-local), NOT a request router.
9
+ //! It provides:
10
+ //! - /health endpoint for readiness check
11
+ //! - Task execution via runner_tasks_v2 polling
9
12
  //!
10
- //! This module is FROZEN as of Phase 3G. Responsibilities are:
13
+ //! It does NOT provide:
14
+ //! - /request endpoint (removed - never existed in runner)
15
+ //! - Command routing (all commands go to local Rust handlers + cloud API)
16
+ //!
17
+ //! Responsibilities:
11
18
  //! - 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
19
+ //! - Check readiness via /health endpoint
15
20
  //! - Clean shutdown on Desktop exit
16
- //! - Read-only status visibility (installed, running, available, pid, version, build)
21
+ //! - Read-only status visibility (installed, running, available, pid)
17
22
  //! - Log streaming (stdout/stderr forwarding)
18
- //!
19
- //! Any changes require explicit architecture review.
20
23
  //! ═══════════════════════════════════════════════════════════════════════════════════════════
21
24
 
22
25
  use crate::bootstrap::resolve_home_path;
23
26
  use crate::node_credentials;
24
- use crate::types::{EngineRequest, EngineResponse};
27
+ use regex::Regex;
25
28
  use serde::Serialize;
26
29
  use std::fs;
27
30
  use std::io::{BufRead, BufReader, Write};
@@ -30,6 +33,44 @@ use std::process::{Child, Command, Stdio};
30
33
  use std::sync::Mutex;
31
34
  use std::time::{Duration, Instant};
32
35
 
36
+ // =============================================================================
37
+ // Log Cleaning Utilities
38
+ // =============================================================================
39
+
40
+ /// Strip ANSI escape codes from a string (colors, formatting, etc.)
41
+ fn strip_ansi_codes(s: &str) -> String {
42
+ // Match ANSI escape sequences: ESC [ ... m (and other variants)
43
+ lazy_static::lazy_static! {
44
+ static ref ANSI_RE: Regex = Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]").unwrap();
45
+ }
46
+ ANSI_RE.replace_all(s, "").to_string()
47
+ }
48
+
49
+ /// Log engine output with clean formatting
50
+ ///
51
+ /// Bootstrap lines are prefixed with [BOOTSTRAP] and logged as bootstrap op.
52
+ /// Engine lines with ERROR/error are logged as errors.
53
+ /// Everything else is logged as engine info.
54
+ fn log_engine_output(line: &str, stream: &str) {
55
+ let clean = strip_ansi_codes(line);
56
+ let trimmed = clean.trim();
57
+
58
+ if trimmed.is_empty() {
59
+ return;
60
+ }
61
+
62
+ if trimmed.starts_with("[BOOTSTRAP]") {
63
+ // Bootstrap output - log with bootstrap op
64
+ tracing::info!(op = "bootstrap", stream = stream, "{}", trimmed);
65
+ } else if trimmed.contains("ERROR") || trimmed.contains("error:") {
66
+ // Error line
67
+ tracing::error!(op = "engine", stream = stream, "{}", trimmed);
68
+ } else {
69
+ // Regular engine output
70
+ tracing::info!(op = "engine", stream = stream, "{}", trimmed);
71
+ }
72
+ }
73
+
33
74
  // =============================================================================
34
75
  // Engine Environment Builder
35
76
  // =============================================================================
@@ -51,43 +92,6 @@ fn build_engine_env() -> Result<Vec<(&'static str, String)>, &'static str> {
51
92
  env.push(("EKKA_ENGINE_URL", url.to_string()));
52
93
  }
53
94
 
54
- // EKKA_INTERNAL_SERVICE_KEY - optional for desktop (only needed for engine-bootstrap binary)
55
- // Desktop runner uses node session auth, not internal key
56
- let internal_key = std::env::var("EKKA_INTERNAL_SERVICE_KEY")
57
- .or_else(|_| std::env::var("INTERNAL_SERVICE_KEY"))
58
- .ok();
59
-
60
- if let Some(ref key) = internal_key {
61
- env.push(("EKKA_INTERNAL_SERVICE_KEY", key.clone()));
62
- tracing::info!(
63
- op = "desktop.internal_key.optional",
64
- present = true,
65
- "Internal service key configured (for engine-bootstrap)"
66
- );
67
- } else {
68
- tracing::info!(
69
- op = "desktop.internal_key.optional",
70
- present = false,
71
- "Internal service key not set (desktop runner uses node session auth)"
72
- );
73
- }
74
-
75
- // EKKA_TENANT_ID - optional for desktop (only needed for engine-bootstrap binary)
76
- // Desktop runner gets tenant_id from node session
77
- if let Ok(tenant_id) = std::env::var("EKKA_TENANT_ID") {
78
- if uuid::Uuid::parse_str(&tenant_id).is_ok() {
79
- env.push(("EKKA_TENANT_ID", tenant_id));
80
- }
81
- }
82
-
83
- // EKKA_WORKSPACE_ID - optional for desktop (only needed for engine-bootstrap binary)
84
- // Desktop runner gets workspace_id from node session
85
- if let Ok(workspace_id) = std::env::var("EKKA_WORKSPACE_ID") {
86
- if uuid::Uuid::parse_str(&workspace_id).is_ok() {
87
- env.push(("EKKA_WORKSPACE_ID", workspace_id));
88
- }
89
- }
90
-
91
95
  // Node credentials: Try keychain first, fall back to env vars
92
96
  // This enables headless engine startup without manual env exports
93
97
  if let Ok((node_id, node_secret)) = node_credentials::load_credentials() {
@@ -198,10 +202,6 @@ impl EngineProcess {
198
202
  }
199
203
  }
200
204
 
201
- /// Permanently disable engine for this session (one-way switch)
202
- pub fn disable(&self) {
203
- self.set_available(false);
204
- }
205
205
  }
206
206
 
207
207
  impl Default for EngineProcess {
@@ -210,7 +210,15 @@ impl Default for EngineProcess {
210
210
  }
211
211
  }
212
212
 
213
+ /// Compute bootstrap binary path using ekka_home_folder
214
+ /// Bootstrap is installed from resources and always runs first
215
+ fn compute_bootstrap_path() -> Result<PathBuf, String> {
216
+ let home = resolve_home_path()?;
217
+ Ok(home.join("engine").join("ekka-engine-bootstrap"))
218
+ }
219
+
213
220
  /// Compute engine binary path using ekka_home_folder
221
+ /// Engine is downloaded by bootstrap and exec'd into
214
222
  fn compute_engine_path() -> Result<PathBuf, String> {
215
223
  let home = resolve_home_path()?;
216
224
  Ok(home.join("engine").join("ekka-engine"))
@@ -218,15 +226,15 @@ fn compute_engine_path() -> Result<PathBuf, String> {
218
226
 
219
227
  /// Ensure bootstrap engine is installed from embedded resources
220
228
  ///
221
- /// Extracts the bundled ekka-engine-bootstrap binary to ekka_home_folder/engine/ekka-engine
222
- /// if it doesn't already exist.
229
+ /// Extracts the bundled ekka-engine-bootstrap binary to ekka_home_folder/engine/ekka-engine-bootstrap
230
+ /// if it doesn't already exist. The bootstrap will then download the real engine to ekka-engine.
223
231
  ///
224
232
  /// Returns Ok(true) if installed, Ok(false) if already present.
225
233
  pub fn ensure_bootstrap_installed_from_resources(resource_path: Option<PathBuf>) -> Result<bool, String> {
226
- let engine_path = compute_engine_path()?;
234
+ let bootstrap_path = compute_bootstrap_path()?;
227
235
 
228
236
  // Already installed - return silently
229
- if engine_path.exists() {
237
+ if bootstrap_path.exists() {
230
238
  return Ok(false);
231
239
  }
232
240
 
@@ -260,12 +268,12 @@ pub fn ensure_bootstrap_installed_from_resources(resource_path: Option<PathBuf>)
260
268
  })?;
261
269
 
262
270
  // Create engine directory
263
- let engine_dir = engine_path.parent().ok_or("Invalid engine path")?;
271
+ let engine_dir = bootstrap_path.parent().ok_or("Invalid bootstrap path")?;
264
272
  fs::create_dir_all(engine_dir)
265
273
  .map_err(|e| format!("Failed to create engine directory: {}", e))?;
266
274
 
267
275
  // Write atomically: tmp file -> chmod +x -> rename
268
- let tmp_path = engine_path.with_extension("tmp");
276
+ let tmp_path = bootstrap_path.with_extension("tmp");
269
277
 
270
278
  let mut file = fs::File::create(&tmp_path)
271
279
  .map_err(|e| format!("Failed to create temp file: {}", e))?;
@@ -288,13 +296,13 @@ pub fn ensure_bootstrap_installed_from_resources(resource_path: Option<PathBuf>)
288
296
  }
289
297
 
290
298
  // Atomic rename
291
- fs::rename(&tmp_path, &engine_path)
299
+ fs::rename(&tmp_path, &bootstrap_path)
292
300
  .map_err(|e| format!("Failed to rename to final path: {}", e))?;
293
301
 
294
302
  tracing::info!(
295
303
  op = "engine.bootstrap.install",
296
- path = %engine_path.display(),
297
- "Bootstrap engine installed from resources"
304
+ path = %bootstrap_path.display(),
305
+ "Bootstrap installed from resources"
298
306
  );
299
307
 
300
308
  Ok(true)
@@ -302,9 +310,29 @@ pub fn ensure_bootstrap_installed_from_resources(resource_path: Option<PathBuf>)
302
310
 
303
311
  /// Spawn and wait for engine readiness
304
312
  ///
313
+ /// Two-binary architecture:
314
+ /// 1. Desktop spawns ekka-engine-bootstrap
315
+ /// 2. Bootstrap checks release service and downloads ekka-engine if needed
316
+ /// 3. Bootstrap execs into ekka-engine (same PID)
317
+ /// 4. Desktop waits for engine health check
318
+ ///
305
319
  /// Returns true if engine is ready, false otherwise.
306
320
  /// Stores result in EngineProcess.available.
307
321
  pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
322
+ // Get both paths
323
+ let bootstrap_path = match compute_bootstrap_path() {
324
+ Ok(p) => p,
325
+ Err(e) => {
326
+ tracing::warn!(
327
+ op = "engine.path.error",
328
+ error = %e,
329
+ "Failed to compute bootstrap path"
330
+ );
331
+ engine.set_available(false);
332
+ return false;
333
+ }
334
+ };
335
+
308
336
  let engine_path = match compute_engine_path() {
309
337
  Ok(p) => p,
310
338
  Err(e) => {
@@ -318,24 +346,25 @@ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
318
346
  }
319
347
  };
320
348
 
321
- // Check if binary exists
322
- let installed = engine_path.exists();
349
+ // Check if bootstrap binary exists (must be installed from resources)
350
+ let installed = bootstrap_path.exists();
323
351
  engine.set_installed(installed);
324
352
 
325
353
  if !installed {
326
354
  tracing::info!(
327
355
  op = "engine.spawn.missing",
328
- path = %engine_path.display(),
329
- "Engine binary not found, using stub backend"
356
+ path = %bootstrap_path.display(),
357
+ "Bootstrap binary not found, using local handlers"
330
358
  );
331
359
  engine.set_available(false);
332
360
  return false;
333
361
  }
334
362
 
335
363
  tracing::info!(
336
- op = "engine.spawn.start",
337
- path = %engine_path.display(),
338
- "Spawning engine process"
364
+ op = "desktop.bootstrap.start",
365
+ path = %bootstrap_path.display(),
366
+ exists = bootstrap_path.exists(),
367
+ "Starting bootstrap"
339
368
  );
340
369
 
341
370
  // Build engine environment from process env
@@ -352,14 +381,14 @@ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
352
381
  }
353
382
  };
354
383
 
355
- tracing::info!(
384
+ tracing::debug!(
356
385
  op = "engine.spawn.env",
357
386
  keys = ?engine_env.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
358
387
  "Setting engine environment"
359
388
  );
360
389
 
361
- // Spawn the engine process with piped stdout/stderr
362
- let mut child = match Command::new(&engine_path)
390
+ // Spawn the BOOTSTRAP process (not engine directly) with piped stdout/stderr
391
+ let mut child = match Command::new(&bootstrap_path)
363
392
  .envs(engine_env)
364
393
  .stdout(Stdio::piped())
365
394
  .stderr(Stdio::piped())
@@ -370,20 +399,21 @@ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
370
399
  tracing::warn!(
371
400
  op = "engine.spawn.failed",
372
401
  error = %e,
373
- "Failed to spawn engine process"
402
+ path = %bootstrap_path.display(),
403
+ "Failed to spawn bootstrap process"
374
404
  );
375
405
  engine.set_available(false);
376
406
  return false;
377
407
  }
378
408
  };
379
409
 
380
- // Spawn log reader threads (best-effort, ignore errors)
410
+ // Spawn log reader threads with clean formatting
381
411
  if let Some(stdout) = child.stdout.take() {
382
412
  std::thread::spawn(move || {
383
413
  let reader = BufReader::new(stdout);
384
414
  for line in reader.lines() {
385
415
  match line {
386
- Ok(l) => tracing::info!(op = "engine.stdout", "{}", l),
416
+ Ok(l) => log_engine_output(&l, "stdout"),
387
417
  Err(_) => break,
388
418
  }
389
419
  }
@@ -396,7 +426,7 @@ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
396
426
  let reader = BufReader::new(stderr);
397
427
  for line in reader.lines() {
398
428
  match line {
399
- Ok(l) => tracing::info!(op = "engine.stderr", "{}", l),
429
+ Ok(l) => log_engine_output(&l, "stderr"),
400
430
  Err(_) => break,
401
431
  }
402
432
  }
@@ -412,9 +442,9 @@ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
412
442
  *guard = Some(child);
413
443
  }
414
444
 
415
- tracing::info!(op = "engine.spawn.success", pid = pid, "Engine process spawned");
445
+ tracing::info!(op = "engine.spawn.success", pid = pid, "Bootstrap process spawned");
416
446
 
417
- // Allow bootstrap time to exec into real engine or fail
447
+ // Allow bootstrap time to download engine (if needed) and exec into it
418
448
  // Bootstrap either: execs real engine (same PID), or exits with error code
419
449
  std::thread::sleep(Duration::from_secs(2));
420
450
 
@@ -443,18 +473,28 @@ pub fn spawn_and_wait(engine: &EngineProcess) -> bool {
443
473
  };
444
474
 
445
475
  if bootstrap_failed {
476
+ tracing::error!(
477
+ op = "engine.bootstrap.fatal",
478
+ pid = pid,
479
+ "FATAL: Bootstrap failed to start engine. Check logs above for error details."
480
+ );
446
481
  engine.set_available(false);
447
482
  return false;
448
483
  }
449
484
 
450
- tracing::debug!(op = "engine.bootstrap.exec", pid = pid, "Bootstrap exec'd into real engine, starting readiness check");
485
+ tracing::debug!(
486
+ op = "engine.bootstrap.exec",
487
+ pid = pid,
488
+ engine_path = %engine_path.display(),
489
+ "Bootstrap exec'd into real engine, starting readiness check"
490
+ );
451
491
 
452
492
  // Wait for readiness (real engine should now be running)
453
493
  let ready = wait_for_ready(15);
454
494
  engine.set_available(ready);
455
495
 
456
496
  if ready {
457
- tracing::info!(op = "engine.ready", "Engine is ready");
497
+ tracing::info!(op = "desktop.engine.ready", "Engine is ready");
458
498
  } else {
459
499
  tracing::warn!(op = "engine.ready.timeout", "Engine readiness timeout");
460
500
  }
@@ -507,49 +547,7 @@ pub fn shutdown(engine: &EngineProcess) {
507
547
  engine.set_available(false);
508
548
  }
509
549
 
510
- /// Route a request to the real engine
511
- ///
512
- /// Returns Some(response) if engine handled the request, None on failure.
513
- /// On failure, caller should fall back to stub.
514
- pub fn route_to_engine(req: &EngineRequest) -> Option<EngineResponse> {
515
- let port: u16 = std::env::var("EKKA_ENGINE_PORT")
516
- .ok()
517
- .and_then(|s| s.parse().ok())
518
- .unwrap_or(9473);
519
-
520
- let url = format!("http://127.0.0.1:{}/request", port);
521
-
522
- let client = match reqwest::blocking::Client::builder()
523
- .timeout(Duration::from_secs(30))
524
- .build()
525
- {
526
- Ok(c) => c,
527
- Err(e) => {
528
- tracing::warn!(op = "engine.route.client_error", error = %e, "Failed to create HTTP client");
529
- return None;
530
- }
531
- };
532
-
533
- match client.post(&url).json(req).send() {
534
- Ok(resp) if resp.status().is_success() => {
535
- match resp.json::<EngineResponse>() {
536
- Ok(engine_resp) => {
537
- tracing::debug!(op = "engine.route.success", op_name = %req.op, "Routed to engine");
538
- Some(engine_resp)
539
- }
540
- Err(e) => {
541
- tracing::warn!(op = "engine.route.parse_error", error = %e, "Failed to parse engine response");
542
- None
543
- }
544
- }
545
- }
546
- Ok(resp) => {
547
- tracing::warn!(op = "engine.route.error", status = %resp.status(), "Engine returned error");
548
- None
549
- }
550
- Err(e) => {
551
- tracing::warn!(op = "engine.route.failed", error = %e, "Failed to route to engine");
552
- None
553
- }
554
- }
555
- }
550
+ // NOTE: route_to_engine() was removed.
551
+ // The spawned engine is a runner runtime (ekka-runner-local), not a request router.
552
+ // All commands go directly to local Rust handlers + cloud API.
553
+ // See commit history for removed code if needed.