@amd-gaia/agent-ui 0.25.3 → 0.25.4-rc.10

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 (57) hide show
  1. package/bin/gaia-ui.cjs +2 -23
  2. package/dist/assets/AdvancedSettings-DXf2pogI.js +1 -0
  3. package/dist/assets/ConnectorsPane-CzLYXGMl.css +1 -0
  4. package/dist/assets/ConnectorsPane-R1yvKMNT.js +1 -0
  5. package/dist/assets/DocumentLibrary-CM6o8j2T.css +1 -0
  6. package/dist/assets/DocumentLibrary-DSOiP7Ft.js +4 -0
  7. package/dist/assets/FileBrowser-BlVSPjD2.js +4 -0
  8. package/dist/assets/FileBrowser-Cna3BsT7.css +1 -0
  9. package/dist/assets/MemoryDashboard-D_ZVZbS_.js +1 -0
  10. package/dist/assets/MemoryDashboard-DwrWAXi6.css +1 -0
  11. package/dist/assets/MobileAccessModal-BkVDm3YG.css +1 -0
  12. package/dist/assets/MobileAccessModal-CPjDeIIk.js +2 -0
  13. package/dist/assets/ScheduleManager-CjHJ4NbD.css +1 -0
  14. package/dist/assets/ScheduleManager-D1DOYVQo.js +1 -0
  15. package/dist/assets/api-Co-B9E0t.js +4 -0
  16. package/dist/assets/index-CMVmIBZ8.js +78 -0
  17. package/dist/assets/index-Y93uvqe7.css +1 -0
  18. package/dist/assets/useT-tWXlG-cT.js +3 -0
  19. package/dist/assets/zap-D-WCZ6dX.js +1 -0
  20. package/dist/icons/apple-touch-icon.png +0 -0
  21. package/dist/icons/icon-192.png +0 -0
  22. package/dist/icons/icon-512.png +0 -0
  23. package/dist/icons/icon-maskable-192.png +0 -0
  24. package/dist/icons/icon-maskable-512.png +0 -0
  25. package/dist/index.html +8 -3
  26. package/dist/manifest.json +16 -0
  27. package/dist/sw.js +49 -0
  28. package/main-safety-net.cjs +106 -8
  29. package/main.cjs +145 -162
  30. package/package.json +5 -3
  31. package/preload.cjs +19 -9
  32. package/services/agent-seeder.cjs +22 -13
  33. package/services/auto-updater.cjs +780 -216
  34. package/services/backend-installer.cjs +523 -365
  35. package/services/engine-install.cjs +233 -0
  36. package/services/index-query.cjs +7 -1
  37. package/services/lock-holders.cjs +200 -0
  38. package/services/quit-guard.cjs +36 -3
  39. package/services/tray-manager.cjs +12 -7
  40. package/dist/assets/AdvancedSettings-DERzP9Jc.js +0 -1
  41. package/dist/assets/ConnectorsPane-Dzu2DRTc.css +0 -1
  42. package/dist/assets/ConnectorsPane-s0fFxmus.js +0 -1
  43. package/dist/assets/DocumentLibrary-CIKp6qld.css +0 -1
  44. package/dist/assets/DocumentLibrary-DqapN_Tr.js +0 -4
  45. package/dist/assets/FileBrowser-Dil1lZJ4.css +0 -1
  46. package/dist/assets/FileBrowser-vAJV41RT.js +0 -4
  47. package/dist/assets/MemoryDashboard-CP5wVWkY.js +0 -1
  48. package/dist/assets/MemoryDashboard-Mo721-BI.css +0 -1
  49. package/dist/assets/MobileAccessModal-B31mPs50.js +0 -2
  50. package/dist/assets/MobileAccessModal-DuVy9F1u.css +0 -1
  51. package/dist/assets/ScheduleManager-D5c4gVIt.css +0 -1
  52. package/dist/assets/ScheduleManager-b0gTAf2R.js +0 -2
  53. package/dist/assets/apiBase-CS6zLsJY.js +0 -1
  54. package/dist/assets/index-C-EM_5yl.css +0 -1
  55. package/dist/assets/index-Cae5Fje-.js +0 -78
  56. package/dist/assets/zap-BogJn9oN.js +0 -1
  57. package/services/backend-installer-progress-dialog.cjs +0 -510
@@ -16,10 +16,13 @@
16
16
  * - ensureUv() → Promise<void>
17
17
  * - installBackend(opts) → Promise<void>
18
18
  * - ensureBackend(opts) → Promise<string> (returns gaia bin path)
19
+ * - isBackendCurrent() → boolean (nothing to install or refresh)
20
+ * - stopRunningDaemon(opts) → Promise<void> (before the venv is touched)
19
21
  * - getInstalledVersion(gaiaBin) → string | null
20
22
  * - findGaiaBin() → string | null
21
23
  * - getState() / setState() → state machine helpers
22
24
  * - getLogPath() / getStatePath() → path helpers
25
+ * - readLogTail(maxLines) → string[]
23
26
  * - runPreChecks(opts) → Promise<PreCheckResult>
24
27
  * - STATES → state name constants
25
28
  *
@@ -54,10 +57,16 @@ const GAIA_PYTHON_BIN = IS_WINDOWS
54
57
 
55
58
  const STATE_FILE = path.join(GAIA_HOME, "electron-install-state.json");
56
59
  const LOG_FILE = path.join(GAIA_HOME, "electron-install.log");
57
-
58
- // Floor for uv, Python and the GAIA package only. The models, often 30 GB,
59
- // are checked by `gaia init` against Lemonade's model store, which it sizes.
60
- const MIN_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
60
+ // hub/installer.py writes this once the flagship is installed; first-run setup installs it.
61
+ const FLAGSHIP_SENTINEL = path.join(GAIA_HOME, "agents", "gaia", ".installed");
62
+ /** `gaia init --agent-only` kept the installed agent: the hub couldn't be reached. */
63
+ const AGENT_KEPT_EXIT = 5;
64
+
65
+ // Room for what this installer writes: Python 3.12, uv's wheel cache and the
66
+ // `amd-gaia[ui]` venv (0.3 GB on macOS arm64 for 0.25.1rc5, under 1 GB in
67
+ // all). Models are not installed here; first-run setup checks the disk for
68
+ // the download the user picks.
69
+ const MIN_DISK_SPACE_BYTES = 2 * 1024 * 1024 * 1024; // 2 GB
61
70
  const NETWORK_CHECK_HOSTS = Object.freeze([
62
71
  "https://pypi.org/simple/",
63
72
  "https://astral.sh",
@@ -131,24 +140,38 @@ const STAGES = Object.freeze({
131
140
  ENSURE_UV: "ensure-uv",
132
141
  CREATE_VENV: "create-venv",
133
142
  INSTALL_PACKAGE: "install-package",
134
- GAIA_INIT: "gaia-init",
135
143
  VERIFY: "verify",
144
+ // After the package, and outside the weighted bar: it has no percent to report.
145
+ AGENT_REFRESH: "agent-refresh",
146
+ });
147
+
148
+ // Why an install failed, for what the app tells the user to do about it.
149
+ const FAILURE_REASONS = Object.freeze({
150
+ DISK: "disk",
151
+ NETWORK: "network",
152
+ TLS: "tls",
153
+ LOCKED: "locked",
154
+ DAEMON: "daemon",
136
155
  });
137
156
 
138
- const GAIA_INIT_MESSAGE =
139
- "Setting up Lemonade Server and downloading models (this can take several minutes)";
140
- const WAITING_FOR_LEMONADE_CHOICE_MESSAGE =
141
- "Waiting for your answer: GAIA found a Lemonade Server already running " +
142
- "and is asking whether to use it. Setup continues once you choose.";
157
+ // `gaia daemon stop` can take about 90 s: up to 45 s for a graceful exit, then
158
+ // terminating the daemon and its agents, then up to 20 s for GAIA's Lemonade Server.
159
+ const DAEMON_STOP_TIMEOUT_MS = 120 * 1000;
160
+ /** `gaia daemon stop` exit code: the daemon stopped, GAIA's Lemonade Server did not. */
161
+ const DAEMON_STOP_LEMONADE_EXIT = 3;
162
+ const DAEMON_INSTANCE_FILE = path.join(GAIA_HOME, "host", "instance.json");
163
+
164
+ // Stage an older installer recorded while it ran `gaia init`; the package was
165
+ // already in place by then.
166
+ const LEGACY_SETUP_STAGE = "gaia-init";
143
167
 
144
168
  // Weight each stage contributes to the overall 0-100 progress.
145
169
  // Sum must equal 100.
146
170
  const STAGE_WEIGHTS = {
147
171
  [STAGES.PRE_CHECKS]: 2,
148
172
  [STAGES.ENSURE_UV]: 8,
149
- [STAGES.CREATE_VENV]: 10,
150
- [STAGES.INSTALL_PACKAGE]: 50,
151
- [STAGES.GAIA_INIT]: 28,
173
+ [STAGES.CREATE_VENV]: 15,
174
+ [STAGES.INSTALL_PACKAGE]: 73,
152
175
  [STAGES.VERIFY]: 2,
153
176
  };
154
177
 
@@ -157,7 +180,6 @@ const STAGE_ORDER = [
157
180
  STAGES.ENSURE_UV,
158
181
  STAGES.CREATE_VENV,
159
182
  STAGES.INSTALL_PACKAGE,
160
- STAGES.GAIA_INIT,
161
183
  STAGES.VERIFY,
162
184
  ];
163
185
 
@@ -175,10 +197,6 @@ let logStream = null;
175
197
  */
176
198
  let logRotatedThisSession = false;
177
199
 
178
- function isTruthyEnv(value) {
179
- return /^(1|true|yes|on)$/i.test(String(value || ""));
180
- }
181
-
182
200
  function ensureGaiaHome() {
183
201
  try {
184
202
  if (!fs.existsSync(GAIA_HOME)) {
@@ -321,6 +339,16 @@ function getStatePath() {
321
339
  return STATE_FILE;
322
340
  }
323
341
 
342
+ /** The end of the install log, or nothing when no install has written one. */
343
+ function readLogTail(maxLines = 40) {
344
+ try {
345
+ return fs.readFileSync(LOG_FILE, "utf8").split(/\r?\n/).filter(Boolean).slice(-maxLines);
346
+ } catch (err) {
347
+ if (err.code === "ENOENT") return [];
348
+ throw err;
349
+ }
350
+ }
351
+
324
352
  // ── State machine ────────────────────────────────────────────────────────────
325
353
 
326
354
  /**
@@ -396,7 +424,8 @@ function computeOverallPercent(stage, withinStagePercent) {
396
424
  function makeProgressReporter(onProgress) {
397
425
  const safe = typeof onProgress === "function" ? onProgress : () => {};
398
426
  return function report(stage, withinStagePercent, message) {
399
- const percent = computeOverallPercent(stage, withinStagePercent);
427
+ const percent =
428
+ withinStagePercent === null ? null : computeOverallPercent(stage, withinStagePercent);
400
429
  try {
401
430
  safe(stage, percent, message || "");
402
431
  } catch (err) {
@@ -405,148 +434,6 @@ function makeProgressReporter(onProgress) {
405
434
  };
406
435
  }
407
436
 
408
- // ── gaia init progress (`gaia init --progress-json`) ─────────────────────────
409
-
410
- // Where the plan and the end of the downloads land in the GAIA_INIT slice; the
411
- // rest of the slice is Lemonade setup before and verification after.
412
- const INIT_PLAN_PERCENT = 5;
413
- const INIT_DOWNLOADED_PERCENT = 95;
414
-
415
- /**
416
- * Read one line of `gaia init --progress-json` output. Returns `{ event }` for
417
- * a progress event, `{ error }` for a line that looks like one but is not, and
418
- * null for human output.
419
- */
420
- function parseInitEventLine(line) {
421
- const text = String(line || "").trim();
422
- if (!text.startsWith("{")) return null;
423
- let value;
424
- try {
425
- value = JSON.parse(text);
426
- } catch (err) {
427
- return { error: err.message };
428
- }
429
- if (!value || typeof value !== "object" || typeof value.event !== "string") {
430
- return { error: 'no "event" field' };
431
- }
432
- return { event: value };
433
- }
434
-
435
- function formatGB(bytes) {
436
- return `${(Math.max(0, bytes || 0) / 1e9).toFixed(1)} GB`;
437
- }
438
-
439
- function formatSpeed(bytesPerSecond) {
440
- return `${(bytesPerSecond / 1e6).toFixed(1)} MB/s`;
441
- }
442
-
443
- function formatTimeLeft(seconds) {
444
- if (seconds < 60) return "less than a minute left";
445
- const minutes = Math.round(seconds / 60);
446
- if (minutes < 60) return `about ${minutes} min left`;
447
- return `about ${Math.floor(minutes / 60)} h ${minutes % 60} min left`;
448
- }
449
-
450
- /**
451
- * Map `gaia init --progress-json` events onto the GAIA_INIT slice of the bar.
452
- * `report(withinStagePercent, message)` receives a percent that never goes
453
- * back. `onLine` returns true for the lines too frequent to log.
454
- */
455
- function createInitProgress(report, logFn = log) {
456
- const models = new Map();
457
- let percent = 0;
458
-
459
- const advance = (within, message) => {
460
- percent = Math.max(percent, Math.round(within));
461
- report(percent, message);
462
- };
463
- const totals = () => {
464
- let done = 0;
465
- let total = 0;
466
- for (const m of models.values()) {
467
- total += m.total;
468
- done += Math.min(m.done, m.total);
469
- }
470
- return { done, total };
471
- };
472
- const downloadPercent = () => {
473
- const { done, total } = totals();
474
- const share = total > 0 ? done / total : 0;
475
- return INIT_PLAN_PERCENT + (INIT_DOWNLOADED_PERCENT - INIT_PLAN_PERCENT) * share;
476
- };
477
- const model = (id) => {
478
- if (!models.has(id)) models.set(id, { done: 0, total: 0 });
479
- return models.get(id);
480
- };
481
-
482
- function handle(event) {
483
- switch (event.event) {
484
- case "step":
485
- advance(percent, `Step ${event.step} of ${event.total}: ${event.message}`);
486
- return true;
487
- case "plan":
488
- for (const m of event.models || []) {
489
- if (!m.downloaded) model(m.id).total = m.bytes_total || 0;
490
- }
491
- advance(
492
- INIT_PLAN_PERCENT,
493
- event.bytes_total > 0
494
- ? `Downloading ${formatGB(event.bytes_total)} of models`
495
- : "Models already downloaded"
496
- );
497
- return true;
498
- case "progress": {
499
- const m = model(event.model);
500
- m.done = event.bytes_done || 0;
501
- m.total = Math.max(event.bytes_total || 0, m.done);
502
- const parts = [
503
- `Downloading ${event.model} (${event.index} of ${event.count}): ` +
504
- `${formatGB(m.done)} of ${formatGB(m.total)}`,
505
- ];
506
- if (event.speed_bps > 0) {
507
- const { done, total } = totals();
508
- parts.push(
509
- `at ${formatSpeed(event.speed_bps)}, ` +
510
- formatTimeLeft((total - done) / event.speed_bps)
511
- );
512
- }
513
- advance(downloadPercent(), parts.join(" "));
514
- return true;
515
- }
516
- case "model_done": {
517
- const m = model(event.model);
518
- m.done = m.total;
519
- advance(downloadPercent(), `${event.model} ready`);
520
- return true;
521
- }
522
- case "done":
523
- advance(
524
- event.ok ? INIT_DOWNLOADED_PERCENT : percent,
525
- event.ok ? "Models downloaded, finishing setup" : "Model download did not finish"
526
- );
527
- return true;
528
- case "error":
529
- advance(percent, String(event.message || "gaia init reported an error").trim());
530
- return true;
531
- default:
532
- return false;
533
- }
534
- }
535
-
536
- function onLine(line) {
537
- const parsed = parseInitEventLine(line);
538
- if (!parsed) return false;
539
- if (parsed.error) {
540
- logFn(`Malformed gaia init progress line (${parsed.error}): ${line}`);
541
- return false;
542
- }
543
- handle(parsed.event);
544
- return parsed.event.event === "progress";
545
- }
546
-
547
- return { onLine, handle };
548
- }
549
-
550
437
  // ── Command helpers ──────────────────────────────────────────────────────────
551
438
 
552
439
  /**
@@ -581,9 +468,13 @@ function findGaiaBin() {
581
468
  *
582
469
  * `onLine(line)` sees each whole stdout line as it arrives; a line it returns
583
470
  * true for is left out of the log.
471
+ *
472
+ * When `signal` aborts, the child and everything it started are stopped and
473
+ * the Promise rejects with InstallCancelled once they are gone.
584
474
  */
585
- function runCommand(cmd, args, { env, stageLabel, onLine } = {}) {
586
- return new Promise((resolve) => {
475
+ function runCommand(cmd, args, { env, stageLabel, onLine, signal, timeoutMs } = {}) {
476
+ if (signal && signal.aborted) return Promise.reject(new InstallCancelled());
477
+ return new Promise((resolve, reject) => {
587
478
  log(`$ ${cmd} ${args.join(" ")}`);
588
479
  let proc;
589
480
  try {
@@ -600,6 +491,21 @@ function runCommand(cmd, args, { env, stageLabel, onLine } = {}) {
600
491
  return;
601
492
  }
602
493
 
494
+ let stopping = false;
495
+ const onAbort = () => {
496
+ stopping = true;
497
+ stopTimer();
498
+ log(`Stopping ${cmd}: GAIA is quitting`);
499
+ require("./process-tree.cjs")
500
+ .killProcessTree(proc, { label: stageLabel || cmd, log, logError })
501
+ .then(() => reject(new InstallCancelled()), reject);
502
+ };
503
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
504
+ const finish = (result) => {
505
+ if (signal) signal.removeEventListener("abort", onAbort);
506
+ if (!stopping) resolve(result);
507
+ };
508
+
603
509
  let stdout = "";
604
510
  let stderr = "";
605
511
  let partial = "";
@@ -647,18 +553,123 @@ function runCommand(cmd, args, { env, stageLabel, onLine } = {}) {
647
553
  });
648
554
  });
649
555
 
556
+ // From `timers`, not the globals: the installer tests run this file in a bare vm context.
557
+ let timer = null;
558
+ if (timeoutMs) {
559
+ timer = require("timers").setTimeout(() => {
560
+ logError(`${cmd} ${args.join(" ")} did not finish within ${Math.round(timeoutMs / 1000)}s — ending it`);
561
+ killTree(proc);
562
+ finish({ code: null, stdout, stderr, timedOut: true });
563
+ }, timeoutMs);
564
+ }
565
+ const stopTimer = () => {
566
+ if (timer) require("timers").clearTimeout(timer);
567
+ };
568
+
650
569
  proc.on("error", (err) => {
570
+ stopTimer();
651
571
  logError(`${cmd} error: ${err.message}`);
652
- resolve({ code: -1, stdout, stderr, error: err });
572
+ finish({ code: -1, stdout, stderr, error: err });
653
573
  });
654
574
 
655
575
  proc.on("exit", (code) => {
576
+ stopTimer();
656
577
  log(` exit code: ${code}`);
657
- resolve({ code, stdout, stderr });
578
+ finish({ code, stdout, stderr });
658
579
  });
659
580
  });
660
581
  }
661
582
 
583
+ /** End `proc` and what it started: a Windows venv launcher runs Python as a child. */
584
+ function killTree(proc) {
585
+ if (IS_WINDOWS) {
586
+ const r = spawnSync("taskkill", ["/PID", String(proc.pid), "/T", "/F"], {
587
+ windowsHide: true,
588
+ timeout: 30 * 1000,
589
+ });
590
+ if (r.error || r.status !== 0) {
591
+ logError(`taskkill /PID ${proc.pid} /T /F failed: ${r.error ? r.error.message : `exit ${r.status}`}`);
592
+ }
593
+ return;
594
+ }
595
+ try {
596
+ proc.kill("SIGKILL");
597
+ } catch (err) {
598
+ logError(`Could not end pid ${proc.pid}: ${err.message}`);
599
+ }
600
+ }
601
+
602
+ /** The pid of the GAIA daemon ~/.gaia/host/instance.json records, when that process is alive. */
603
+ function liveDaemonPid() {
604
+ let pid;
605
+ try {
606
+ pid = Number(JSON.parse(fs.readFileSync(DAEMON_INSTANCE_FILE, "utf8")).pid);
607
+ } catch (err) {
608
+ if (err.code !== "ENOENT") log(`Could not read ${DAEMON_INSTANCE_FILE}: ${err.message}`);
609
+ return null;
610
+ }
611
+ if (!Number.isInteger(pid) || pid <= 0) return null;
612
+ try {
613
+ process.kill(pid, 0);
614
+ return pid;
615
+ } catch (err) {
616
+ // EPERM: the process exists but belongs to someone else.
617
+ return err.code === "EPERM" ? pid : null;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Stop the GAIA daemon (and the agents it runs) that the existing venv may be
623
+ * running, so the install doesn't trip over its open files. Throws an
624
+ * InstallError naming the daemon only when one is still alive afterwards: a
625
+ * stop that fails with no daemon running (a broken venv, say) must not block
626
+ * the reinstall that repairs it.
627
+ */
628
+ async function stopRunningDaemon({ timeoutMs = DAEMON_STOP_TIMEOUT_MS, signal } = {}) {
629
+ if (!fs.existsSync(GAIA_BIN) || !fs.existsSync(GAIA_PYTHON_BIN)) return;
630
+ const result = await runCommand(GAIA_BIN, ["daemon", "stop"], {
631
+ stageLabel: "daemon",
632
+ timeoutMs,
633
+ signal,
634
+ // gaia prints emoji, which a Windows pipe's code page can't encode.
635
+ env: { ...process.env, PYTHONIOENCODING: "utf-8" },
636
+ });
637
+ if (result.code === 0) return;
638
+ const output = `${result.stdout || ""}\n${result.stderr || ""}`;
639
+ if (result.code === 2 && /invalid choice: '?daemon'?/.test(output)) {
640
+ log("The installed GAIA predates the daemon — nothing to stop");
641
+ return;
642
+ }
643
+ const pid = liveDaemonPid();
644
+ if (pid === null) {
645
+ if (result.code === DAEMON_STOP_LEMONADE_EXIT) {
646
+ log(
647
+ "The GAIA daemon stopped, but GAIA's Lemonade Server did not (see the lines above). " +
648
+ "It lives outside the backend's files, so the install goes on."
649
+ );
650
+ } else {
651
+ log(
652
+ `gaia daemon stop ${result.timedOut ? "timed out" : `exited ${result.code}`}, ` +
653
+ "but no GAIA daemon is running — continuing"
654
+ );
655
+ }
656
+ return;
657
+ }
658
+ const why = result.timedOut
659
+ ? `did not stop within ${Math.round(timeoutMs / 1000)} seconds`
660
+ : `could not be stopped (gaia daemon stop exit ${result.code})`;
661
+ log(`To stop it by hand: ${GAIA_BIN} daemon stop`);
662
+ throw new InstallError(
663
+ `GAIA's background service (the GAIA daemon, pid ${pid}) ${why}, so its files can't be updated.`,
664
+ {
665
+ stage: STAGES.CREATE_VENV,
666
+ code: result.code,
667
+ reason: FAILURE_REASONS.DAEMON,
668
+ suggestion: `It has no window to close. Restart your PC, then try again. The log at ${LOG_FILE} has the details.`,
669
+ }
670
+ );
671
+ }
672
+
662
673
  /**
663
674
  * Detect the Windows "file in use" signature in command output. An upgrade's
664
675
  * `uv pip install --refresh` can't replace gaia.exe while a previous GAIA
@@ -669,10 +680,95 @@ function isFileLockedError(output) {
669
680
  return /os error 32/i.test(output) || /being used by another process/i.test(output);
670
681
  }
671
682
 
683
+ /** The files uv could not replace, as absolute paths, in the order it named them. */
684
+ function lockedFiles(output) {
685
+ const files = [];
686
+ for (const m of String(output || "").matchAll(/failed to \w+ (?:file|directory) `([^`]+)`/gi)) {
687
+ const file = path.resolve(m[1]);
688
+ if (!files.includes(file)) files.push(file);
689
+ }
690
+ return files;
691
+ }
692
+
693
+ const MOVED_ASIDE_SUFFIX = /\.old-\d+$/i;
694
+
695
+ function sameFile(a, b) {
696
+ return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
697
+ }
698
+
699
+ /**
700
+ * Rename each locked launcher in the venv's Scripts folder to <name>.old-<ts>
701
+ * so uv can write a fresh one (#6570). A launcher something is running can't
702
+ * be renamed; it is left alone. Returns [{ file, aside }] for those moved.
703
+ */
704
+ function moveLockedAside(files) {
705
+ const scripts = path.join(GAIA_VENV, "Scripts");
706
+ const stamp = Date.now();
707
+ const moved = [];
708
+ for (const file of files) {
709
+ if (!/\.exe$/i.test(file) || !sameFile(path.dirname(file), scripts)) continue;
710
+ const aside = `${file}.old-${stamp}`;
711
+ try {
712
+ fs.renameSync(file, aside);
713
+ moved.push({ file, aside });
714
+ log(`Moved locked ${file} aside to ${path.basename(aside)}`);
715
+ } catch (err) {
716
+ log(`Could not move locked ${file} aside (${err.code || err.message}); it stays where it is`);
717
+ }
718
+ }
719
+ return moved;
720
+ }
721
+
722
+ /** Put back every moved launcher uv did not replace, so a failed upgrade leaves the venv whole. */
723
+ function putBackUnreplaced(moved) {
724
+ for (const { file, aside } of moved) {
725
+ if (fs.existsSync(file)) {
726
+ log(`${path.basename(file)} was reinstalled; ${path.basename(aside)} is deleted on a later install`);
727
+ continue;
728
+ }
729
+ try {
730
+ fs.renameSync(aside, file);
731
+ } catch (err) {
732
+ throw new Error(`Could not put ${aside} back at ${file}: ${err.message}`);
733
+ }
734
+ log(`Put ${path.basename(file)} back from ${path.basename(aside)}`);
735
+ }
736
+ }
737
+
738
+ /**
739
+ * Tidy what an earlier upgrade moved aside. A .old file whose original was
740
+ * reinstalled is deleted unless still in use; one whose original is missing
741
+ * is put back, newest first.
742
+ */
743
+ function removeMovedLaunchers() {
744
+ const dir = path.join(GAIA_VENV, "Scripts");
745
+ const stampOf = (name) => Number(/\.old-(\d+)$/i.exec(name)[1]);
746
+ const leftovers = listDir(dir)
747
+ .filter((name) => MOVED_ASIDE_SUFFIX.test(name))
748
+ .sort((a, b) => stampOf(b) - stampOf(a));
749
+ for (const name of leftovers) {
750
+ const aside = path.join(dir, name);
751
+ const file = aside.replace(MOVED_ASIDE_SUFFIX, "");
752
+ if (!fs.existsSync(file)) {
753
+ fs.renameSync(aside, file);
754
+ log(`Put ${path.basename(file)} back from ${name}: no newer copy was installed`);
755
+ continue;
756
+ }
757
+ try {
758
+ fs.unlinkSync(aside);
759
+ log(`Removed ${aside}, moved aside by an earlier upgrade`);
760
+ } catch (err) {
761
+ // Windows refuses to delete a program that is running (EPERM) or open (EBUSY).
762
+ if (err.code !== "EBUSY" && err.code !== "EPERM") throw err;
763
+ log(`Left ${aside} in place: it is still in use (${err.code})`);
764
+ }
765
+ }
766
+ }
767
+
672
768
  /**
673
769
  * Detect a transient network failure in `uv pip install` output so the
674
770
  * install stage can retry instead of failing the whole bootstrap. The
675
- * install stage downloads transitive deps (numpy, faiss, pymupdf, …) from
771
+ * install stage downloads transitive deps (numpy, faiss, pypdfium2, …) from
676
772
  * PyPI; a single mid-stream hiccup ("stream closed because of a broken pipe")
677
773
  * otherwise fails the entire backend install — and, in the release pipeline,
678
774
  * the whole AppImage smoke test that gates publishing.
@@ -1078,15 +1174,34 @@ async function runPreChecks(opts = {}) {
1078
1174
  // ── uv install ───────────────────────────────────────────────────────────────
1079
1175
 
1080
1176
  class InstallError extends Error {
1081
- constructor(message, { stage, code, suggestion } = {}) {
1177
+ constructor(message, { stage, code, suggestion, reason, lockedFiles } = {}) {
1082
1178
  super(message);
1083
1179
  this.name = "InstallError";
1084
1180
  this.stage = stage || null;
1085
1181
  this.code = code || null;
1086
1182
  this.suggestion = suggestion || null;
1183
+ this.reason = reason || null;
1184
+ // The files another program held open, for main to find who holds them.
1185
+ this.lockedFiles = lockedFiles || null;
1087
1186
  }
1088
1187
  }
1089
1188
 
1189
+ /** The caller's signal stopped the install: the app is quitting, nothing failed. */
1190
+ class InstallCancelled extends Error {
1191
+ constructor() {
1192
+ super("The install stopped because GAIA is quitting; it continues on the next launch.");
1193
+ this.name = "InstallCancelled";
1194
+ }
1195
+ }
1196
+
1197
+ /** Which FAILURE_REASONS a uv failure's output shows, or null. */
1198
+ function failureReason(output) {
1199
+ if (isFileLockedError(output)) return FAILURE_REASONS.LOCKED;
1200
+ if (isTlsCertError(output)) return FAILURE_REASONS.TLS;
1201
+ if (isTransientNetworkError(output)) return FAILURE_REASONS.NETWORK;
1202
+ return null;
1203
+ }
1204
+
1090
1205
  /**
1091
1206
  * Which `extraResources` subdirectory holds the bundled uv for this host.
1092
1207
  * Returns null for platforms we don't yet ship a binary for (falls through
@@ -1303,7 +1418,7 @@ function addManagedBinToPath() {
1303
1418
  *
1304
1419
  * Throws InstallError on failure.
1305
1420
  */
1306
- async function ensureUv({ onProgress, isPackaged } = {}) {
1421
+ async function ensureUv({ onProgress, isPackaged, signal } = {}) {
1307
1422
  const report = makeProgressReporter(onProgress);
1308
1423
  report(STAGES.ENSURE_UV, 0, "Checking uv (Python package manager)");
1309
1424
 
@@ -1402,13 +1517,13 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
1402
1517
  "-Command",
1403
1518
  "irm https://astral.sh/uv/install.ps1 | iex",
1404
1519
  ],
1405
- { stageLabel: "uv-install-dev" }
1520
+ { stageLabel: "uv-install-dev", signal }
1406
1521
  );
1407
1522
  } else {
1408
1523
  result = await runCommand(
1409
1524
  "bash",
1410
1525
  ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"],
1411
- { stageLabel: "uv-install-dev" }
1526
+ { stageLabel: "uv-install-dev", signal }
1412
1527
  );
1413
1528
  }
1414
1529
 
@@ -1469,7 +1584,7 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
1469
1584
  "-Command",
1470
1585
  "irm https://astral.sh/uv/install.ps1 | iex",
1471
1586
  ],
1472
- { stageLabel: "uv-install-packaged-rescue" }
1587
+ { stageLabel: "uv-install-packaged-rescue", signal }
1473
1588
  );
1474
1589
  if (rescue.code === 0) {
1475
1590
  // Ensure common install locations are present on PATH for this
@@ -1495,6 +1610,7 @@ async function ensureUv({ onProgress, isPackaged } = {}) {
1495
1610
  log(`Packaged Windows: automated uv installer exited ${rescue.code}`);
1496
1611
  }
1497
1612
  } catch (rescueErr) {
1613
+ if (rescueErr instanceof InstallCancelled) throw rescueErr;
1498
1614
  log(`Packaged Windows: rescue installer threw: ${rescueErr.message}`);
1499
1615
  }
1500
1616
  }
@@ -1545,6 +1661,11 @@ function resolveBackendVersion(opts = {}) {
1545
1661
  }
1546
1662
  }
1547
1663
 
1664
+ /** The version the app ships with, whatever backend this run was asked to install. */
1665
+ function appVersion() {
1666
+ return JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")).version;
1667
+ }
1668
+
1548
1669
  /**
1549
1670
  * The PEP 440 spelling of the app's npm version: `0.25.0-rc.1` -> `0.25.0rc1`.
1550
1671
  * pip pins and `gaia --version` use this form; a final version is unchanged.
@@ -1554,142 +1675,16 @@ function toPep440(version) {
1554
1675
  return version.replace(/^(\d+\.\d+\.\d+)-rc\.(\d+)$/, "$1rc$2");
1555
1676
  }
1556
1677
 
1557
- function shouldSkipGaiaInit(opts) {
1558
- return Boolean(opts.skipGaiaInit) || isTruthyEnv(process.env.GAIA_SKIP_GAIA_INIT);
1559
- }
1560
-
1561
- /**
1562
- * Run `gaia init` for the profile the app's own setup check asks about
1563
- * (setup_runner.PROFILE), so the first launch finds setup done instead of
1564
- * running it a second time.
1565
- *
1566
- * `chooseLemonade` is asked about a Lemonade Server already running here; see
1567
- * lemonadeInitArgs.
1568
- *
1569
- * Returns `{ init }`, "done", "failed" or "skipped", plus `initError` when it
1570
- * failed. The state file records both: a backend whose setup never finished is
1571
- * not installed, so the next launch runs it, and the app shows the error.
1572
- */
1573
- async function runGaiaInit(gaiaBin, report, version, skipGaiaInit, chooseLemonade) {
1574
- if (skipGaiaInit) {
1575
- log("Skipping gaia init (skipGaiaInit=true or GAIA_SKIP_GAIA_INIT set)");
1576
- return { init: "skipped" };
1577
- }
1578
- setState(STATES.INSTALLING, { stage: STAGES.GAIA_INIT, version });
1579
- report(STAGES.GAIA_INIT, 0, GAIA_INIT_MESSAGE);
1580
- const lemonadeArgs = await lemonadeInitArgs(gaiaBin, chooseLemonade, runCommand, report);
1581
- const progress = createInitProgress((within, message) =>
1582
- report(STAGES.GAIA_INIT, within, message)
1583
- );
1584
- const initResult = await runCommand(
1585
- gaiaBin,
1586
- ["init", "--profile", "gaia", "--yes", "--skip-webui-build", "--progress-json", ...lemonadeArgs],
1587
- // Unwrapped, so an error line reaches initFailureReason whole.
1588
- { stageLabel: "gaia-init", env: { ...process.env, COLUMNS: "1000" }, onLine: progress.onLine }
1589
- );
1590
- if (initResult.code !== 0) {
1591
- // Non-fatal: the app opens and its start screen shows initError, and the
1592
- // next launch runs setup again, because the state file records the failure.
1593
- log(
1594
- `Warning: gaia init exited with code ${initResult.code}. Continuing; setup runs again next launch.`
1595
- );
1596
- report(STAGES.GAIA_INIT, 100, "Lemonade Server setup did not finish");
1597
- return { init: "failed", initError: initFailureReason(initResult) };
1598
- }
1599
- report(STAGES.GAIA_INIT, 100, "Lemonade Server setup complete");
1600
- return { init: "done" };
1601
- }
1602
-
1603
- /** Why `gaia init` failed, in its own words: its last ❌ line, else its last line. */
1604
- function initFailureReason({ code, stdout, stderr }) {
1605
- const lines = `${stdout || ""}\n${stderr || ""}`
1606
- .split(/\r?\n/)
1607
- .map((line) => line.trim())
1608
- // --progress-json event lines are for the progress bar, not the user.
1609
- .filter((line) => line && !line.startsWith('{"event"'));
1610
- const marked = lines.filter((line) => line.startsWith("❌"));
1611
- const reason = marked.length
1612
- ? marked[marked.length - 1].replace(/^❌\s*/, "")
1613
- : lines[lines.length - 1];
1614
- return reason || `gaia init exited with code ${code} and printed nothing.`;
1615
- }
1616
-
1617
- /**
1618
- * Run a `gaia ... --json` command and parse what it printed.
1619
- * Returns undefined after logging a warning that names the failure.
1620
- */
1621
- async function readGaiaJson(run, gaiaBin, args) {
1622
- const shown = `gaia ${args.join(" ")}`;
1623
- const result = await run(gaiaBin, args, { stageLabel: "lemonade" });
1624
- if (result.code !== 0) {
1625
- log(`Warning: ${shown} exited with code ${result.code}: ${(result.stderr || result.stdout || "").trim().slice(-400)}`);
1626
- return undefined;
1627
- }
1628
- const lines = (result.stdout || "").trim().split(/\r?\n/);
1629
- try {
1630
- return JSON.parse(lines[lines.length - 1]);
1631
- } catch (err) {
1632
- log(`Warning: ${shown} printed no JSON (${err.message}): ${(result.stdout || "").trim().slice(-400)}`);
1633
- return undefined;
1634
- }
1635
- }
1636
-
1637
- /**
1638
- * Extra `gaia init` args for a Lemonade Server the user already runs.
1639
- *
1640
- * Asks `chooseLemonade(found)` only on a first setup (nothing saved, no
1641
- * LEMONADE_BASE_URL) when a usable server answers. "external" adds
1642
- * `--lemonade-url <url>`, "gaia" adds `--gaia-lemonade`, null adds nothing:
1643
- * init then sets up GAIA's own and prints how to switch, as it always has.
1644
- * A server that needs a key or is too old is left to the app's setup screen.
1645
- *
1646
- * `report` is the progress reporter: while the question is open the progress
1647
- * window says setup is waiting on the user, then goes back to GAIA_INIT_MESSAGE.
1648
- */
1649
- async function lemonadeInitArgs(gaiaBin, chooseLemonade, run = runCommand, report = () => {}) {
1650
- if (typeof chooseLemonade !== "function") return [];
1651
- const unchanged = "gaia init will set up GAIA's own Lemonade Server; to use yours later run `gaia lemonade connect <url>`.";
1652
- const status = await readGaiaJson(run, gaiaBin, ["lemonade", "status", "--json"]);
1653
- if (status === undefined || status === null) {
1654
- log(`Warning: could not read which Lemonade Server GAIA uses; ${unchanged}`);
1655
- return [];
1656
- }
1657
- if (status.saved_mode != null || status.source === "env") return [];
1658
- const found = await readGaiaJson(run, gaiaBin, ["lemonade", "detect", "--json"]);
1659
- if (found === undefined) {
1660
- log(`Warning: could not look for a Lemonade Server already running; ${unchanged}`);
1661
- return [];
1662
- }
1663
- if (!found) return [];
1664
- if (found.status !== "ok") {
1665
- log(`Found a Lemonade Server at ${found.url} (${found.status}); the app's setup screen offers it.`);
1666
- return [];
1667
- }
1668
- let answer;
1669
- report(STAGES.GAIA_INIT, 0, WAITING_FOR_LEMONADE_CHOICE_MESSAGE);
1670
- try {
1671
- answer = await chooseLemonade(found);
1672
- } catch (err) {
1673
- log(`Warning: could not ask whether to use the Lemonade Server at ${found.url}: ${err.message}; ${unchanged}`);
1674
- return [];
1675
- } finally {
1676
- report(STAGES.GAIA_INIT, 0, GAIA_INIT_MESSAGE);
1677
- }
1678
- log(`Lemonade Server at ${found.url}: user chose ${answer || "not to answer"}`);
1679
- if (answer === "external") return ["--lemonade-url", found.url];
1680
- if (answer === "gaia") return ["--gaia-lemonade"];
1681
- return [];
1682
- }
1683
-
1684
1678
  /**
1685
1679
  * Install the GAIA Python backend from scratch.
1686
1680
  *
1681
+ * Installs no model and runs no `gaia init`: the Lemonade Server and the models
1682
+ * are set up by the app's first-run setup, where the user chooses cloud or
1683
+ * local and sees what downloads.
1684
+ *
1687
1685
  * opts:
1688
1686
  * - onProgress(stage, percent, message)
1689
1687
  * - version: string — override the pinned version
1690
- * - skipGaiaInit: boolean — skip `gaia init` (for testing)
1691
- * - chooseLemonade(found): Promise<"external"|"gaia"|null> — asked when a
1692
- * Lemonade Server already runs here; see lemonadeInitArgs
1693
1688
  *
1694
1689
  * Throws InstallError on failure. The state file is updated to reflect
1695
1690
  * the current stage so a subsequent launch can recover.
@@ -1707,7 +1702,6 @@ async function installBackend(opts = {}) {
1707
1702
  const pipPackage = localWheel
1708
1703
  ? `${localWheel}[ui]`
1709
1704
  : `amd-gaia[ui]==${toPep440(version)}`;
1710
- const skipGaiaInit = shouldSkipGaiaInit(opts);
1711
1705
 
1712
1706
  log("================================================");
1713
1707
  log(" Installing GAIA backend");
@@ -1718,13 +1712,14 @@ async function installBackend(opts = {}) {
1718
1712
  setState(STATES.INSTALLING, { stage: STAGES.ENSURE_UV, version });
1719
1713
 
1720
1714
  // Stage 1: ensure uv
1721
- await ensureUv({ onProgress: opts.onProgress, isPackaged: opts.isPackaged });
1715
+ await ensureUv({ onProgress: opts.onProgress, isPackaged: opts.isPackaged, signal: opts.signal });
1722
1716
 
1723
1717
  // Stage 2: create venv
1724
1718
  setState(STATES.INSTALLING, { stage: STAGES.CREATE_VENV, version });
1725
1719
  report(STAGES.CREATE_VENV, 0, "Creating Python 3.12 environment");
1726
1720
 
1727
1721
  ensureGaiaHome();
1722
+ await stopRunningDaemon({ signal: opts.signal });
1728
1723
 
1729
1724
  // If the venv exists but the python binary is missing, treat as partial.
1730
1725
  const venvLooksValid =
@@ -1741,7 +1736,7 @@ async function installBackend(opts = {}) {
1741
1736
  }
1742
1737
 
1743
1738
  const venvArgs = ["venv", GAIA_VENV, "--python", "3.12"];
1744
- let venvResult = await runCommand("uv", venvArgs, { stageLabel: "venv" });
1739
+ let venvResult = await runCommand("uv", venvArgs, { stageLabel: "venv", signal: opts.signal });
1745
1740
  // uv downloads Python 3.12 when none is installed; behind a corporate
1746
1741
  // proxy that fails on uv's bundled roots exactly like the pip step below.
1747
1742
  let venvNativeTls = false;
@@ -1764,6 +1759,7 @@ async function installBackend(opts = {}) {
1764
1759
  }
1765
1760
  venvResult = await runCommand("uv", [...venvArgs, "--native-tls"], {
1766
1761
  stageLabel: "venv",
1762
+ signal: opts.signal,
1767
1763
  });
1768
1764
  }
1769
1765
  if (venvResult.code !== 0) {
@@ -1775,6 +1771,7 @@ async function installBackend(opts = {}) {
1775
1771
  {
1776
1772
  stage: STAGES.CREATE_VENV,
1777
1773
  code: venvResult.code,
1774
+ reason: failureReason(`${venvResult.stdout || ""}\n${venvResult.stderr || ""}`),
1778
1775
  suggestion: `Try creating it manually:\n ${manual}\nThen restart GAIA.`,
1779
1776
  }
1780
1777
  );
@@ -1787,6 +1784,7 @@ async function installBackend(opts = {}) {
1787
1784
  // Stage 3: pip install
1788
1785
  setState(STATES.INSTALLING, { stage: STAGES.INSTALL_PACKAGE, version });
1789
1786
  report(STAGES.INSTALL_PACKAGE, 0, `Installing ${pipPackage}`);
1787
+ if (IS_WINDOWS) removeMovedLaunchers();
1790
1788
 
1791
1789
  const pipArgs = [
1792
1790
  "pip",
@@ -1799,11 +1797,26 @@ async function installBackend(opts = {}) {
1799
1797
  GAIA_PYTHON_BIN,
1800
1798
  ];
1801
1799
  // Retry the install on transient PyPI/network failures. The transitive
1802
- // deps (numpy, faiss, pymupdf, …) are fetched live from PyPI even
1800
+ // deps (numpy, faiss, pypdfium2, …) are fetched live from PyPI even
1803
1801
  // when the gaia wheel itself is local, so a single broken-pipe mid-download
1804
1802
  // would otherwise fail the whole bootstrap (and block a release). File-lock
1805
- // failures (Windows os-error-32) are NOT retried — they need user action.
1803
+ // failures (Windows os-error-32) get their own single retry after this loop.
1806
1804
  let installResult;
1805
+ if (!localWheel && isBackendPrefetched(version)) {
1806
+ report(STAGES.INSTALL_PACKAGE, 0, `Installing ${pipPackage} from the downloaded update`);
1807
+ const offline = await runCommand(
1808
+ "uv",
1809
+ ["pip", "install", pipPackage, "--offline", "--compile-bytecode", "--python", GAIA_PYTHON_BIN],
1810
+ { stageLabel: "pip", signal: opts.signal }
1811
+ );
1812
+ fs.rmSync(_prefetchMarker(toPep440(version)), { force: true });
1813
+ if (offline.code === 0) {
1814
+ installResult = offline;
1815
+ } else {
1816
+ log(`The prefetched backend is no longer all in uv's cache (${_lastLines(offline)}) — installing from PyPI`);
1817
+ }
1818
+ }
1819
+ const installedOffline = Boolean(installResult);
1807
1820
  let useNativeTls = false;
1808
1821
  let nativeTlsAttempted = false;
1809
1822
  // The TLS branch flips useNativeTls and `continue`s, consuming an iteration.
@@ -1811,12 +1824,12 @@ async function installBackend(opts = {}) {
1811
1824
  // so the promised --native-tls retry actually runs (issue #1693 review).
1812
1825
  for (
1813
1826
  let attempt = 1;
1814
- attempt <= INSTALL_MAX_ATTEMPTS || (useNativeTls && !nativeTlsAttempted);
1827
+ !installedOffline && (attempt <= INSTALL_MAX_ATTEMPTS || (useNativeTls && !nativeTlsAttempted));
1815
1828
  attempt++
1816
1829
  ) {
1817
1830
  const attemptArgs = useNativeTls ? [...pipArgs, "--native-tls"] : pipArgs;
1818
1831
  if (useNativeTls) nativeTlsAttempted = true;
1819
- installResult = await runCommand("uv", attemptArgs, { stageLabel: "pip" });
1832
+ installResult = await runCommand("uv", attemptArgs, { stageLabel: "pip", signal: opts.signal });
1820
1833
  if (installResult.code === 0) break;
1821
1834
  const attemptOutput = `${installResult.stdout || ""}\n${installResult.stderr || ""}`;
1822
1835
  // A corporate proxy's custom root CA isn't in uv's bundled webpki roots, so
@@ -1853,16 +1866,41 @@ async function installBackend(opts = {}) {
1853
1866
  );
1854
1867
  await sleep(backoffMs);
1855
1868
  }
1869
+ // A launcher that is only open, not running, can be renamed out of uv's way (#6570).
1870
+ const firstOutput = `${installResult.stdout || ""}\n${installResult.stderr || ""}`;
1871
+ if (IS_WINDOWS && installResult.code !== 0 && isFileLockedError(firstOutput)) {
1872
+ const moved = moveLockedAside(lockedFiles(firstOutput));
1873
+ if (moved.length > 0) {
1874
+ report(STAGES.INSTALL_PACKAGE, 0, "Another program has GAIA's files open — retrying the install");
1875
+ try {
1876
+ installResult = await runCommand("uv", useNativeTls ? [...pipArgs, "--native-tls"] : pipArgs, {
1877
+ stageLabel: "pip",
1878
+ signal: opts.signal,
1879
+ });
1880
+ } finally {
1881
+ putBackUnreplaced(moved);
1882
+ }
1883
+ }
1884
+ }
1856
1885
  if (installResult.code !== 0) {
1857
- // Windows: an upgrade can't replace gaia.exe while a previous GAIA
1858
- // process still holds it open (issue #1388). The orphan cleanup in
1859
- // main.cjs should have killed it first; if the lock persists, point the
1860
- // user at the live process rather than the generic manual-install hint.
1861
1886
  const output = `${installResult.stdout || ""}\n${installResult.stderr || ""}`;
1862
1887
  let suggestion;
1863
1888
  if (isFileLockedError(output)) {
1864
- suggestion =
1865
- "A running GAIA process is locking the install. Close GAIA completely (including any leftover gaia.exe in Task Manager), then relaunch.";
1889
+ // pip's own line is already in the log, which the setup screen's details show.
1890
+ const files = lockedFiles(output);
1891
+ const names = files.map((file) => path.basename(file)).join(", ");
1892
+ throw new InstallError(
1893
+ `GAIA couldn't replace its own files because another program has them open${names ? ` (${names})` : ""}.`,
1894
+ {
1895
+ stage: STAGES.INSTALL_PACKAGE,
1896
+ code: installResult.code,
1897
+ reason: FAILURE_REASONS.LOCKED,
1898
+ lockedFiles: files,
1899
+ suggestion:
1900
+ "Close other GAIA apps and any terminal running gaia, or restart your PC, then try again. " +
1901
+ `The log at ${LOG_FILE} names the file.`,
1902
+ }
1903
+ );
1866
1904
  } else if (isTlsCertError(output)) {
1867
1905
  suggestion =
1868
1906
  "PyPI's certificate isn't trusted on this network — usually a corporate proxy presenting its own root CA. " +
@@ -1871,6 +1909,14 @@ async function installBackend(opts = {}) {
1871
1909
  : "") +
1872
1910
  "Ask IT to install the proxy's root CA in your system's trusted certificate store, then relaunch GAIA. " +
1873
1911
  "See https://amd-gaia.ai/docs/quickstart#cli-install";
1912
+ } else if (isTransientNetworkError(output)) {
1913
+ // The log file keeps pip's output; the box says what to do about it.
1914
+ throw new InstallError("GAIA couldn't reach the internet to finish installing.", {
1915
+ stage: STAGES.INSTALL_PACKAGE,
1916
+ code: installResult.code,
1917
+ suggestion:
1918
+ "Check your connection, then choose Retry. If it keeps failing, the log file below says why. Your chats and settings are safe.",
1919
+ });
1874
1920
  } else {
1875
1921
  suggestion = `Try installing manually:\n uv pip install ${pipPackage} --python ${
1876
1922
  IS_WINDOWS ? `${GAIA_VENV_DISPLAY}/Scripts/python.exe` : `${GAIA_VENV_DISPLAY}/bin/python`
@@ -1891,6 +1937,7 @@ async function installBackend(opts = {}) {
1891
1937
  {
1892
1938
  stage: STAGES.INSTALL_PACKAGE,
1893
1939
  code: installResult.code,
1940
+ reason: failureReason(output),
1894
1941
  suggestion,
1895
1942
  }
1896
1943
  );
@@ -1907,16 +1954,7 @@ async function installBackend(opts = {}) {
1907
1954
  }
1908
1955
  report(STAGES.INSTALL_PACKAGE, 100, "GAIA package installed");
1909
1956
 
1910
- // Stage 4: gaia init
1911
- const setup = await runGaiaInit(
1912
- GAIA_BIN,
1913
- report,
1914
- version,
1915
- skipGaiaInit,
1916
- opts.chooseLemonade
1917
- );
1918
-
1919
- // Stage 5: verify
1957
+ // Stage 4: verify
1920
1958
  setState(STATES.INSTALLING, { stage: STAGES.VERIFY, version });
1921
1959
  report(STAGES.VERIFY, 0, "Verifying installation");
1922
1960
 
@@ -1974,12 +2012,142 @@ async function installBackend(opts = {}) {
1974
2012
  log(`Shim creation check failed: ${err.message}`);
1975
2013
  }
1976
2014
 
1977
- setState(STATES.READY, { stage: null, version, installedVersion, ...setup });
2015
+ setState(STATES.READY, { stage: null, version, installedVersion });
1978
2016
  log("Backend install complete");
1979
2017
  }
1980
2018
 
2019
+ // ── Agent refresh ────────────────────────────────────────────────────────────
2020
+
2021
+ /**
2022
+ * Update the flagship to this release, without Lemonade or models. A PC with
2023
+ * no flagship is skipped: first-run setup installs it.
2024
+ *
2025
+ * The stage stays recorded until this succeeds, so an update that failed or was
2026
+ * quit part way through refreshes again on the next launch.
2027
+ */
2028
+ async function refreshAgent(gaiaBin, opts = {}) {
2029
+ const version = resolveBackendVersion(opts);
2030
+ if (!fs.existsSync(FLAGSHIP_SENTINEL)) {
2031
+ log("No GAIA agent installed yet — first-run setup installs it");
2032
+ // Nothing to refresh: a refresh recorded earlier is done, not pending forever.
2033
+ setState(STATES.READY, { version, installedVersion: getInstalledVersion(gaiaBin) });
2034
+ return;
2035
+ }
2036
+ setState(STATES.INSTALLING, { stage: STAGES.AGENT_REFRESH, version });
2037
+ makeProgressReporter(opts.onProgress)(STAGES.AGENT_REFRESH, null, "Updating the GAIA agent");
2038
+
2039
+ const result = await runCommand(gaiaBin, ["init", "--agent-only"], {
2040
+ stageLabel: "agent",
2041
+ env: { ...process.env, COLUMNS: "1000" },
2042
+ signal: opts.signal,
2043
+ });
2044
+ const lastLine = `${result.stderr || ""}`.trim().split(/\r?\n/).pop();
2045
+ if (result.code === AGENT_KEPT_EXIT) {
2046
+ // Offline: the working agent stays, and the next launch tries the update again.
2047
+ log(`Kept the installed GAIA agent; the update runs again next launch: ${lastLine}`);
2048
+ setState(STATES.PARTIAL, { stage: STAGES.AGENT_REFRESH, version, message: lastLine });
2049
+ return;
2050
+ }
2051
+ if (result.code !== 0) {
2052
+ throw new InstallError(
2053
+ lastLine ? lastLine.replace(/^Error:\s*/, "") : `The GAIA agent update exited ${result.code}.`,
2054
+ { stage: STAGES.AGENT_REFRESH, code: result.code }
2055
+ );
2056
+ }
2057
+ setState(STATES.READY, { version, installedVersion: getInstalledVersion(gaiaBin) });
2058
+ }
2059
+
2060
+ // ── Backend prefetch for an app update ──────────────────────────────────────
2061
+
2062
+ const PREFETCH_DIR = path.join(GAIA_HOME, "cache", "backend-prefetch");
2063
+
2064
+ function _prefetchMarker(pep440) {
2065
+ return path.join(PREFETCH_DIR, `${pep440}.ready`);
2066
+ }
2067
+
2068
+ /** True when an earlier prefetch left everything `version` needs in uv's cache. */
2069
+ function isBackendPrefetched(version) {
2070
+ return Boolean(version) && fs.existsSync(_prefetchMarker(toPep440(version)));
2071
+ }
2072
+
2073
+ /**
2074
+ * Before an app update restarts, put the backend it will need into uv's cache
2075
+ * by installing it into a throwaway environment. The first launch then
2076
+ * installs offline, so it neither waits on nor depends on PyPI.
2077
+ * Throws with uv's own words when the download fails.
2078
+ */
2079
+ async function prefetchBackend(appVersion, { isPackaged } = {}) {
2080
+ if (process.env.GAIA_LOCAL_WHEEL) {
2081
+ log("GAIA_LOCAL_WHEEL is set: the next launch installs that wheel, so there is nothing to prefetch");
2082
+ return;
2083
+ }
2084
+ const version = toPep440(appVersion);
2085
+ const pipPackage = `amd-gaia[ui]==${version}`;
2086
+ const workDir = path.join(PREFETCH_DIR, version);
2087
+ const venv = path.join(workDir, "venv");
2088
+ const python = IS_WINDOWS ? path.join(venv, "Scripts", "python.exe") : path.join(venv, "bin", "python");
2089
+ log(`Prefetching ${pipPackage} for the update`);
2090
+ await ensureUv({ isPackaged });
2091
+ _removeScratchVenvs();
2092
+ fs.mkdirSync(workDir, { recursive: true });
2093
+ try {
2094
+ const venvResult = await runCommand("uv", ["venv", venv, "--python", "3.12"], { stageLabel: "prefetch" });
2095
+ if (venvResult.code !== 0) {
2096
+ throw new Error(`uv venv exited ${venvResult.code}: ${_lastLines(venvResult)}`);
2097
+ }
2098
+ const args = ["pip", "install", pipPackage, "--refresh", "--python", python];
2099
+ let result = await runCommand("uv", args, { stageLabel: "prefetch" });
2100
+ if (result.code !== 0 && isTlsCertError(`${result.stdout || ""}\n${result.stderr || ""}`)) {
2101
+ result = await runCommand("uv", [...args, "--native-tls"], { stageLabel: "prefetch" });
2102
+ }
2103
+ if (result.code !== 0) {
2104
+ throw new Error(`Couldn't download ${pipPackage} (uv exit ${result.code}): ${_lastLines(result)}`);
2105
+ }
2106
+ } finally {
2107
+ fs.rmSync(workDir, { recursive: true, force: true });
2108
+ }
2109
+ fs.writeFileSync(_prefetchMarker(version), new Date().toISOString());
2110
+ log(`Prefetched ${pipPackage}: the next launch installs it offline`);
2111
+ }
2112
+
2113
+ /** Scratch venvs a quit cut short; the .ready markers beside them stay. */
2114
+ function _removeScratchVenvs() {
2115
+ let entries = [];
2116
+ try {
2117
+ entries = fs.readdirSync(PREFETCH_DIR, { withFileTypes: true });
2118
+ } catch (err) {
2119
+ if (err.code !== "ENOENT") throw err;
2120
+ }
2121
+ for (const entry of entries) {
2122
+ if (entry.isDirectory()) fs.rmSync(path.join(PREFETCH_DIR, entry.name), { recursive: true, force: true });
2123
+ }
2124
+ }
2125
+
2126
+ function _lastLines({ stdout, stderr }) {
2127
+ return `${stdout || ""}\n${stderr || ""}`
2128
+ .split("\n")
2129
+ .map((line) => line.trimEnd())
2130
+ .filter(Boolean)
2131
+ .slice(-5)
2132
+ .join(" | ");
2133
+ }
2134
+
1981
2135
  // ── Version-aware ensure ─────────────────────────────────────────────────────
1982
2136
 
2137
+ /**
2138
+ * Whether the backend is finished at the app's version. A recorded failure or
2139
+ * unfinished stage says no, so it reaches ensureBackend and the user.
2140
+ */
2141
+ function isBackendCurrent() {
2142
+ // A local wheel is reinstalled on every launch, as ensureBackend says.
2143
+ if (process.env.GAIA_LOCAL_WHEEL) return false;
2144
+ const bin = findGaiaBin();
2145
+ if (!bin) return false;
2146
+ const prev = getState();
2147
+ if (prev && prev.state !== STATES.READY) return false;
2148
+ return getInstalledVersion(bin) === toPep440(appVersion());
2149
+ }
2150
+
1983
2151
  /**
1984
2152
  * The installed backend version, or null when it is not installed.
1985
2153
  * For the app's own venv this reads the package record instead of running
@@ -2049,12 +2217,13 @@ function venvPackageVersion() {
2049
2217
  * opts:
2050
2218
  * - onProgress(stage, percent, message)
2051
2219
  * - version: override the pinned version
2052
- * - skipGaiaInit: bool
2053
- * - chooseLemonade: see installBackend
2054
2220
  * - allowPartialRestart: bool (default true) — restart from scratch if
2055
2221
  * the state file indicates a `partial` install.
2222
+ * - signal: AbortSignal — stops the running step (the app is quitting)
2056
2223
  *
2057
- * Throws InstallError on failure and updates the state file.
2224
+ * Throws InstallError on failure and updates the state file. Throws
2225
+ * InstallCancelled when `signal` stopped it; the state file then still says
2226
+ * installing, so the next launch runs the install again.
2058
2227
  */
2059
2228
  async function ensureBackend(opts = {}) {
2060
2229
  openLog({ truncate: true });
@@ -2097,6 +2266,7 @@ async function ensureBackend(opts = {}) {
2097
2266
  }.`,
2098
2267
  {
2099
2268
  stage: STAGES.PRE_CHECKS,
2269
+ reason: FAILURE_REASONS.DISK,
2100
2270
  suggestion: `Free at least ${requiredMb} MB at ${path.dirname(
2101
2271
  GAIA_HOME
2102
2272
  )} and try again.`,
@@ -2132,47 +2302,28 @@ async function ensureBackend(opts = {}) {
2132
2302
  // reinstall from the local wheel so CI gets a fresh install each run.
2133
2303
  const expectedVersion = resolveBackendVersion(opts);
2134
2304
  const existingBin = findGaiaBin();
2135
- // Installed means setup finished too, not just that the package is there.
2136
- // No state file at all is an install from before it existed.
2305
+ // Installed means the package finished installing. No state file at all
2306
+ // is an install from before it existed.
2137
2307
  const prev = preChecks.previousState;
2138
- const setupFinished =
2139
- !prev || (prev.state === STATES.READY && prev.init !== "failed");
2140
2308
  const packageFinished =
2141
- setupFinished ||
2142
- (prev &&
2143
- (prev.state === STATES.READY ||
2144
- prev.stage === STAGES.GAIA_INIT ||
2145
- prev.stage === STAGES.VERIFY));
2309
+ !prev ||
2310
+ prev.state === STATES.READY ||
2311
+ prev.stage === LEGACY_SETUP_STAGE ||
2312
+ prev.stage === STAGES.VERIFY ||
2313
+ prev.stage === STAGES.AGENT_REFRESH;
2146
2314
  if (existingBin) {
2147
2315
  const installedVersion = getInstalledVersion(existingBin);
2148
2316
  const versionMatches =
2149
2317
  expectedVersion !== null && installedVersion === toPep440(expectedVersion);
2150
- if (versionMatches && !setupFinished && packageFinished) {
2151
- log(
2152
- `GAIA ${installedVersion} is installed but its setup did not finish ` +
2153
- `(state=${prev.state}, stage=${prev.stage || "-"}) — running gaia init`
2154
- );
2155
- const report = makeProgressReporter(opts.onProgress);
2156
- const setup = await runGaiaInit(
2157
- existingBin,
2158
- report,
2159
- expectedVersion,
2160
- shouldSkipGaiaInit(opts),
2161
- opts.chooseLemonade
2162
- );
2163
- setState(STATES.READY, { version: expectedVersion, installedVersion, ...setup });
2164
- report(STAGES.VERIFY, 100, `GAIA ${installedVersion} ready`);
2318
+ if (versionMatches && packageFinished && prev && prev.stage === STAGES.AGENT_REFRESH) {
2319
+ await refreshAgent(existingBin, opts);
2165
2320
  return existingBin;
2166
2321
  }
2167
- if (versionMatches && setupFinished) {
2322
+ if (versionMatches && packageFinished) {
2168
2323
  log(
2169
2324
  `GAIA backend already installed at version ${installedVersion} — nothing to do`
2170
2325
  );
2171
- setState(STATES.READY, {
2172
- version: expectedVersion,
2173
- installedVersion,
2174
- init: prev ? prev.init : undefined,
2175
- });
2326
+ setState(STATES.READY, { version: expectedVersion, installedVersion });
2176
2327
  // Tell the UI we are instantly ready.
2177
2328
  const report = makeProgressReporter(opts.onProgress);
2178
2329
  report(STAGES.VERIFY, 100, `GAIA ${installedVersion} ready`);
@@ -2205,8 +2356,13 @@ async function ensureBackend(opts = {}) {
2205
2356
  throw err;
2206
2357
  }
2207
2358
 
2359
+ await refreshAgent(verified, opts);
2208
2360
  return verified;
2209
2361
  } catch (err) {
2362
+ if (err instanceof InstallCancelled) {
2363
+ log(err.message);
2364
+ throw err;
2365
+ }
2210
2366
  if (err instanceof InstallError) {
2211
2367
  setState(STATES.FAILED, {
2212
2368
  stage: err.stage || null,
@@ -2232,11 +2388,13 @@ module.exports = {
2232
2388
  ensureUv,
2233
2389
  installBackend,
2234
2390
  ensureBackend,
2391
+ isBackendCurrent,
2392
+ prefetchBackend,
2393
+ isBackendPrefetched,
2235
2394
  getInstalledVersion,
2236
2395
  findGaiaBin,
2237
2396
  toPep440,
2238
- lemonadeInitArgs,
2239
- initFailureReason,
2397
+ stopRunningDaemon,
2240
2398
 
2241
2399
  // Pre-checks
2242
2400
  runPreChecks,
@@ -2255,9 +2413,6 @@ module.exports = {
2255
2413
  setState,
2256
2414
  clearState,
2257
2415
 
2258
- // gaia init progress
2259
- parseInitEventLine,
2260
- createInitProgress,
2261
2416
  computeOverallPercent,
2262
2417
 
2263
2418
  // Logging
@@ -2267,12 +2422,14 @@ module.exports = {
2267
2422
  logError,
2268
2423
  getLogPath,
2269
2424
  getStatePath,
2425
+ readLogTail,
2270
2426
 
2271
2427
  // Constants
2272
2428
  STATES,
2273
2429
  STAGES,
2274
- GAIA_INIT_MESSAGE,
2275
- WAITING_FOR_LEMONADE_CHOICE_MESSAGE,
2430
+ FAILURE_REASONS,
2431
+ DAEMON_STOP_TIMEOUT_MS,
2432
+ DAEMON_STOP_LEMONADE_EXIT,
2276
2433
  GAIA_HOME,
2277
2434
  GAIA_VENV,
2278
2435
  GAIA_BIN,
@@ -2280,4 +2437,5 @@ module.exports = {
2280
2437
 
2281
2438
  // Error
2282
2439
  InstallError,
2440
+ InstallCancelled,
2283
2441
  };