@amd-gaia/agent-ui 0.21.2 → 0.23.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.
@@ -16,20 +16,40 @@
16
16
  * - macOS: <Bundle>.app/Contents/Resources/agents/<id>/
17
17
  * - Linux: /opt/<AppName>/resources/agents/<id>/
18
18
  * - Target: path.join(os.homedir(), ".gaia", "agents", "<id>")
19
- * - Sentinel: <target>/.seeded (exists → already seeded → skip)
19
+ * - Sentinel: <target>/.seeded (this copy completed)
20
+ * - Marker: ~/.gaia/seeder/<id>.seeded ("this machine seeded <id> once";
21
+ * EXISTENCE is the signal — content is informational, never parsed for
22
+ * decisions. Lives outside ~/.gaia/agents/ so deleting an agent, or the
23
+ * whole agents dir, never resurrects it.)
20
24
  *
21
25
  * Write protocol (atomic-ish, crash-safe):
22
26
  * 1. Remove any stale `<id>.partial/` sibling from a prior failed run.
23
27
  * 2. Copy source → `<id>.partial/`.
24
28
  * 3. `fs.renameSync(<id>.partial, <id>)` — atomic on the same filesystem.
25
- * 4. Write `<id>/.seeded` last, so a partial seed never looks complete.
29
+ * 4. Write `<id>/.seeded`, so a partial seed never looks complete.
30
+ * 5. Write the marker LAST — a crash between 4 and 5 self-heals next
31
+ * launch via the marker back-fill (rule 2 below).
32
+ *
33
+ * Per-agent decision order (markers read fresh on every call, no caching):
34
+ * 1. Marker exists → skip, full stop. A deleted agent is never re-seeded;
35
+ * a dir the user re-created under the same id is never touched.
36
+ * 2. No marker, target has `.seeded` → back-fill the marker, skip.
37
+ * 3. No marker, target exists WITHOUT `.seeded` → user-owned data,
38
+ * log a warning, skip, write no marker.
39
+ * 4. No marker, no target → seed via the write protocol above.
40
+ *
41
+ * Recovery: to get a deleted bundled agent back, remove
42
+ * `~/.gaia/seeder/<id>.seeded` and relaunch.
43
+ *
44
+ * Legacy cleanup: the retired `zoo-agent` demo is removed from
45
+ * ~/.gaia/agents/ when it is provably an unmodified seeded copy — sentinel
46
+ * present, no file newer than its seededAt (+5s slack), never through a
47
+ * symlink. Removed ids are reported via the additive `cleaned` array.
26
48
  *
27
49
  * Behaviour:
28
- * - Target `<id>/` exists WITH `.seeded` → already seeded, skip.
29
- * - Target `<id>/` exists WITHOUT `.seeded` → treat as user-owned data,
30
- * log a warning, and skip (never clobber a hand-authored agent).
31
- * - `process.resourcesPath` unset (dev / Jest) or source dir missing →
32
- * empty result, no error.
50
+ * - `process.resourcesPath` unset (dev / Jest) → empty result, no error,
51
+ * no cleanup (never touch the real HOME from dev/test contexts).
52
+ * - Source dir missing → legacy cleanup still runs, then empty result.
33
53
  * - Per-agent failures are isolated: they go into `errors[]` but do not
34
54
  * stop the next agent from being seeded.
35
55
  *
@@ -53,6 +73,10 @@ function agentsTargetRoot() {
53
73
  return path.join(gaiaHome(), "agents");
54
74
  }
55
75
 
76
+ function seederMarkersDir() {
77
+ return path.join(gaiaHome(), "seeder");
78
+ }
79
+
56
80
  function logsDir() {
57
81
  return path.join(gaiaHome(), "logs");
58
82
  }
@@ -124,6 +148,48 @@ function isDirectory(p) {
124
148
  }
125
149
  }
126
150
 
151
+ // ── Marker helpers ───────────────────────────────────────────────────────
152
+
153
+ /**
154
+ * Defense-in-depth: an agent id must be a single plain path segment before
155
+ * it is used in any path.join or marker filename.
156
+ */
157
+ function isSinglePathSegment(id) {
158
+ return (
159
+ typeof id === "string" &&
160
+ id.length > 0 &&
161
+ id !== "." &&
162
+ id !== ".." &&
163
+ !id.includes("/") &&
164
+ !id.includes("\\") &&
165
+ !id.includes(path.sep)
166
+ );
167
+ }
168
+
169
+ function markerPath(id) {
170
+ if (!isSinglePathSegment(id)) {
171
+ throw new Error(`Invalid agent id for marker path: ${JSON.stringify(id)}`);
172
+ }
173
+ return path.join(seederMarkersDir(), `${id}.seeded`);
174
+ }
175
+
176
+ function hasMarker(id) {
177
+ return fs.existsSync(markerPath(id));
178
+ }
179
+
180
+ function writeMarker(id, source) {
181
+ fs.mkdirSync(seederMarkersDir(), { recursive: true });
182
+ fs.writeFileSync(
183
+ markerPath(id),
184
+ JSON.stringify(
185
+ { seededAt: new Date().toISOString(), source },
186
+ null,
187
+ 2
188
+ ),
189
+ { encoding: "utf8" }
190
+ );
191
+ }
192
+
127
193
  // ── Seeding core ─────────────────────────────────────────────────────────
128
194
 
129
195
  /**
@@ -135,18 +201,42 @@ function isDirectory(p) {
135
201
  * Throws only on programmer error. All IO errors are caught and logged.
136
202
  */
137
203
  function seedOneAgent(sourceDir, targetRoot, id) {
204
+ if (!isSinglePathSegment(id)) {
205
+ log("WARN", `Skipping invalid agent id ${JSON.stringify(id)}`);
206
+ return { status: "skipped" };
207
+ }
208
+
138
209
  const src = path.join(sourceDir, id);
139
210
  const target = path.join(targetRoot, id);
140
211
  const partial = path.join(targetRoot, `${id}.partial`);
141
212
  const sentinel = path.join(target, ".seeded");
142
213
 
143
- // Already seeded?
214
+ // Rule 1: marker wins, full stop — honors deletion and leaves any
215
+ // user-recreated dir under the same id untouched.
216
+ if (hasMarker(id)) {
217
+ log("INFO", `Skipping "${id}" — already seeded once on this machine (marker present)`);
218
+ return { status: "skipped" };
219
+ }
220
+
221
+ // Rule 2: seeded before markers existed → back-fill the marker.
222
+ // Runs every launch, so it also heals a crash between sentinel and marker.
144
223
  if (fs.existsSync(sentinel)) {
145
- log("INFO", `Skipping "${id}" — already seeded (sentinel present)`);
224
+ try {
225
+ writeMarker(id, src);
226
+ log("INFO", `Skipping "${id}" — already seeded (sentinel present); marker back-filled`);
227
+ } catch (err) {
228
+ log(
229
+ "ERROR",
230
+ `Failed to back-fill marker for "${id}": ${
231
+ err && err.message ? err.message : err
232
+ } — will retry next launch`
233
+ );
234
+ }
146
235
  return { status: "skipped" };
147
236
  }
148
237
 
149
- // Target exists but no sentinel → user-owned data. Do not touch.
238
+ // Rule 3: target exists but no sentinel → user-owned data. Do not touch,
239
+ // write no marker.
150
240
  if (fs.existsSync(target)) {
151
241
  log(
152
242
  "WARN",
@@ -176,7 +266,7 @@ function seedOneAgent(sourceDir, targetRoot, id) {
176
266
  copyDirRecursive(src, partial);
177
267
  fs.renameSync(partial, target);
178
268
 
179
- // Write sentinel LAST — its presence means "copy completed".
269
+ // Write sentinel — its presence means "copy completed".
180
270
  fs.writeFileSync(
181
271
  sentinel,
182
272
  JSON.stringify(
@@ -190,6 +280,9 @@ function seedOneAgent(sourceDir, targetRoot, id) {
190
280
  { encoding: "utf8" }
191
281
  );
192
282
 
283
+ // Marker LAST — a crash here self-heals via the rule-2 back-fill.
284
+ writeMarker(id, src);
285
+
193
286
  log("INFO", `Seeded "${id}" from ${src} to ${target}`);
194
287
  return { status: "seeded" };
195
288
  } catch (err) {
@@ -214,15 +307,156 @@ function seedOneAgent(sourceDir, targetRoot, id) {
214
307
  }
215
308
  }
216
309
 
310
+ // ── Legacy cleanup ───────────────────────────────────────────────────────
311
+
312
+ // Retired bundled ids removed from user machines when provably unmodified.
313
+ const LEGACY_CLEANUP_IDS = ["zoo-agent"];
314
+
315
+ // Filesystem mtime granularity + copy latency headroom for the
316
+ // modified-since-seed check.
317
+ const MODIFIED_SLACK_MS = 5000;
318
+
319
+ function listFilesRecursive(dir) {
320
+ const out = [];
321
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
322
+ // __pycache__/*.pyc is regenerated by the registry on every load — it
323
+ // postdates seededAt but is not a user modification.
324
+ if (entry.isDirectory() && entry.name === "__pycache__") continue;
325
+ if (!entry.isDirectory() && entry.name.endsWith(".pyc")) continue;
326
+ const p = path.join(dir, entry.name);
327
+ if (entry.isDirectory()) {
328
+ out.push(...listFilesRecursive(p));
329
+ } else {
330
+ out.push(p);
331
+ }
332
+ }
333
+ return out;
334
+ }
335
+
336
+ /**
337
+ * Remove retired seeded demo agents (currently `zoo-agent`) from
338
+ * ~/.gaia/agents/. Deletes ONLY when the dir is provably an unmodified
339
+ * seeded copy; anything ambiguous is left in place. Failures are logged
340
+ * and retried on the next launch (the sentinel keys the retry — no marker
341
+ * is involved, since a retired id can never be re-seeded anyway).
342
+ *
343
+ * @returns {string[]} ids actually removed (verified gone from disk).
344
+ */
345
+ function cleanupLegacyAgents() {
346
+ const cleaned = [];
347
+
348
+ for (const id of LEGACY_CLEANUP_IDS) {
349
+ const target = path.join(agentsTargetRoot(), id);
350
+
351
+ let st;
352
+ try {
353
+ st = fs.lstatSync(target);
354
+ } catch {
355
+ continue; // not present — nothing to clean
356
+ }
357
+
358
+ // Never rm through a symlink — it would delete the link target.
359
+ if (st.isSymbolicLink()) {
360
+ log(
361
+ "WARN",
362
+ `Legacy "${id}" at ${target} is a symlink — leaving it untouched`
363
+ );
364
+ continue;
365
+ }
366
+
367
+ if (!st.isDirectory()) {
368
+ log("WARN", `Legacy "${id}" at ${target} is not a directory — leaving it untouched`);
369
+ continue;
370
+ }
371
+
372
+ // No sentinel → user-authored, not ours to remove.
373
+ const sentinel = path.join(target, ".seeded");
374
+ if (!fs.existsSync(sentinel)) {
375
+ continue;
376
+ }
377
+
378
+ let seededAtMs;
379
+ try {
380
+ seededAtMs = Date.parse(
381
+ JSON.parse(fs.readFileSync(sentinel, "utf8")).seededAt
382
+ );
383
+ if (!Number.isFinite(seededAtMs)) {
384
+ throw new Error("seededAt missing or not a valid timestamp");
385
+ }
386
+ } catch (err) {
387
+ log(
388
+ "WARN",
389
+ `Cannot determine seededAt for legacy "${id}" (${
390
+ err && err.message ? err.message : err
391
+ }) — treating as user-modified, leaving it untouched`
392
+ );
393
+ continue;
394
+ }
395
+
396
+ // Any file newer than the seed time means the user customized it —
397
+ // it is their work now, never delete it.
398
+ let modified = false;
399
+ try {
400
+ for (const file of listFilesRecursive(target)) {
401
+ if (fs.lstatSync(file).mtimeMs > seededAtMs + MODIFIED_SLACK_MS) {
402
+ log(
403
+ "WARN",
404
+ `Legacy "${id}" was modified after seeding (${file}) — leaving it untouched`
405
+ );
406
+ modified = true;
407
+ break;
408
+ }
409
+ }
410
+ } catch (err) {
411
+ log(
412
+ "WARN",
413
+ `Cannot inspect legacy "${id}" for modifications (${
414
+ err && err.message ? err.message : err
415
+ }) — leaving it untouched`
416
+ );
417
+ continue;
418
+ }
419
+ if (modified) {
420
+ continue;
421
+ }
422
+
423
+ try {
424
+ fs.rmSync(target, { recursive: true, force: true });
425
+ } catch (err) {
426
+ log(
427
+ "ERROR",
428
+ `Failed to remove legacy "${id}" at ${target}: ${
429
+ err && err.message ? err.message : err
430
+ } — will retry next launch`
431
+ );
432
+ continue;
433
+ }
434
+
435
+ // Report success only after verifying it is actually gone.
436
+ if (fs.existsSync(target)) {
437
+ log(
438
+ "ERROR",
439
+ `Legacy "${id}" still present after removal attempt: ${target} — will retry next launch`
440
+ );
441
+ continue;
442
+ }
443
+
444
+ log("INFO", `Removed legacy seeded agent "${id}" from ${target}`);
445
+ cleaned.push(id);
446
+ }
447
+
448
+ return cleaned;
449
+ }
450
+
217
451
  /**
218
452
  * Seed all bundled agents found under `<resourcesPath>/agents/`.
219
453
  *
220
454
  * Idempotent — safe to call on every app launch.
221
455
  *
222
- * @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[]}>}
456
+ * @returns {Promise<{seeded: string[], skipped: string[], errors: {id: string, error: Error}[], cleaned: string[]}>}
223
457
  */
224
458
  async function seedBundledAgents() {
225
- const result = { seeded: [], skipped: [], errors: [] };
459
+ const result = { seeded: [], skipped: [], errors: [], cleaned: [] };
226
460
 
227
461
  // Guard against dev / test environments where resourcesPath is unset.
228
462
  if (!process.resourcesPath) {
@@ -233,6 +467,11 @@ async function seedBundledAgents() {
233
467
  return result;
234
468
  }
235
469
 
470
+ // Legacy cleanup runs before the missing-source guard: an upgraded
471
+ // install must shed the retired zoo-agent even if this build ships no
472
+ // bundled agents.
473
+ result.cleaned = cleanupLegacyAgents();
474
+
236
475
  const sourceDir = path.join(process.resourcesPath, "agents");
237
476
 
238
477
  if (!fs.existsSync(sourceDir) || !isDirectory(sourceDir)) {
@@ -284,7 +523,8 @@ async function seedBundledAgents() {
284
523
  log(
285
524
  "INFO",
286
525
  `Seeding complete — seeded=${result.seeded.length} ` +
287
- `skipped=${result.skipped.length} errors=${result.errors.length}`
526
+ `skipped=${result.skipped.length} errors=${result.errors.length} ` +
527
+ `cleaned=${result.cleaned.length}`
288
528
  );
289
529
 
290
530
  return result;
@@ -295,7 +535,9 @@ module.exports = {
295
535
  // Exposed for tests — do not rely on these from production code.
296
536
  _internals: {
297
537
  seedOneAgent,
538
+ cleanupLegacyAgents,
298
539
  agentsTargetRoot,
540
+ seederMarkersDir,
299
541
  logFilePath,
300
542
  },
301
543
  };
@@ -4,9 +4,19 @@
4
4
  /**
5
5
  * auto-updater.cjs — GAIA Agent UI auto-update service.
6
6
  *
7
- * Wraps `electron-updater` for the GitHub Releases auto-update flow.
7
+ * Wraps `electron-updater` against a CONFIGURABLE generic feed (R2-primary —
8
+ * issue #1724). The forward-update channel URL is resolved at runtime from
9
+ * `GAIA_UPDATE_FEED_URL` or `feedUrl` in ~/.gaia/update-config.json, and the
10
+ * app fetches the mutable `latest*.yml` channel pointer from it. When no feed
11
+ * is configured the updater enters a loud `no-channel` state (no silent
12
+ * no-op). The actual R2 publish feed + mutable channel pointer is milestone-52
13
+ * work (#1719); until it lands, point the env var at any generic feed (a local
14
+ * mock works) to exercise the path.
15
+ *
8
16
  * Implements §4 Layer 3 + §7 Phase F of docs/plans/desktop-installer.mdx.
9
- * Issue #1336: adds in-app rollback to a specific previous release.
17
+ * Issue #1336: in-app rollback to a specific previous release still lists and
18
+ * installs from GitHub Releases (that path is independent of the forward feed
19
+ * and migrates to R2 with #1719).
10
20
  *
11
21
  * Behavior:
12
22
  * - First check 10 seconds after `init()` is called (typically from
@@ -51,6 +61,12 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // Subsequent checks every 4h
51
61
  const LOG_PATH = path.join(os.homedir(), ".gaia", "electron-updater.log");
52
62
  const UPDATE_CONFIG_PATH = path.join(os.homedir(), ".gaia", "update-config.json");
53
63
 
64
+ // Mutable channel pointer name. electron-updater's generic provider fetches
65
+ // `${feedUrl}/${DEFAULT_CHANNEL}.yml` (+ per-platform variants). The Hub Worker
66
+ // re-points this file each release while versioned artifacts stay immutable —
67
+ // that split is the A6/A7 resolution (#1724).
68
+ const DEFAULT_CHANNEL = "latest";
69
+
54
70
  // per_page=100 is the GitHub API max for a single page; we fetch broadly so
55
71
  // draft/prerelease/platform filtering still yields a full page of installable
56
72
  // releases before the display cap below. >100 releases would need pagination
@@ -72,6 +88,8 @@ const STATES = Object.freeze({
72
88
  DOWNLOADED: "downloaded",
73
89
  ERROR: "error",
74
90
  DISABLED: "disabled",
91
+ // No forward-update feed configured — loud, actionable, never a silent no-op.
92
+ NO_CHANNEL: "no-channel",
75
93
  });
76
94
 
77
95
  // ── Module state ─────────────────────────────────────────────────────────────
@@ -93,6 +111,8 @@ let scheduledTimeout = null;
93
111
  let initialCheckTimeout = null;
94
112
  let ipcHandlersRegistered = false;
95
113
  let initialized = false;
114
+ // The resolved forward-update feed URL (generic/R2), or null when unconfigured.
115
+ let forwardFeedUrl = null;
96
116
 
97
117
  // Lazy-loaded Electron references (populated inside init()).
98
118
  let electronApi = null; // { dialog, ipcMain, app }
@@ -147,13 +167,86 @@ function _loadPin() {
147
167
  */
148
168
  function _savePin(pinnedVersion) {
149
169
  fs.mkdirSync(path.dirname(UPDATE_CONFIG_PATH), { recursive: true });
170
+ // Merge into any existing config so we don't clobber a persisted feedUrl.
171
+ let existing = {};
172
+ try {
173
+ if (fs.existsSync(UPDATE_CONFIG_PATH)) {
174
+ existing = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8")) || {};
175
+ }
176
+ } catch {
177
+ existing = {};
178
+ }
150
179
  fs.writeFileSync(
151
180
  UPDATE_CONFIG_PATH,
152
- JSON.stringify({ pinnedVersion }, null, 2),
181
+ JSON.stringify({ ...existing, pinnedVersion }, null, 2),
153
182
  "utf8"
154
183
  );
155
184
  }
156
185
 
186
+ // ── Forward-update feed resolution (issue #1724) ────────────────────────────────
187
+
188
+ /**
189
+ * Resolve the generic forward-update feed URL, or null if none is configured.
190
+ * Precedence: GAIA_UPDATE_FEED_URL env > feedUrl in update-config.json.
191
+ */
192
+ function _resolveFeedUrl() {
193
+ const envUrl = process.env.GAIA_UPDATE_FEED_URL;
194
+ if (typeof envUrl === "string" && envUrl.trim()) return envUrl.trim();
195
+ try {
196
+ if (fs.existsSync(UPDATE_CONFIG_PATH)) {
197
+ const cfg = JSON.parse(fs.readFileSync(UPDATE_CONFIG_PATH, "utf8"));
198
+ if (cfg && typeof cfg.feedUrl === "string" && cfg.feedUrl.trim()) {
199
+ return cfg.feedUrl.trim();
200
+ }
201
+ }
202
+ } catch (err) {
203
+ log("warn", "Failed to read feedUrl from update-config.json:", err && err.message);
204
+ }
205
+ return null;
206
+ }
207
+
208
+ /**
209
+ * Point electron-updater at the configured generic (R2) feed + mutable channel.
210
+ * When nothing is configured, enter the loud NO_CHANNEL state and set no feed —
211
+ * an unconfigured updater must never silently pretend it's up to date.
212
+ *
213
+ * @returns {boolean} true when a feed was applied, false when NO_CHANNEL.
214
+ */
215
+ function _applyForwardFeed() {
216
+ if (!autoUpdaterRef) return false;
217
+ const url = _resolveFeedUrl();
218
+ if (!url) {
219
+ forwardFeedUrl = null;
220
+ setState({
221
+ status: STATES.NO_CHANNEL,
222
+ error:
223
+ "No update channel configured — set GAIA_UPDATE_FEED_URL (or feedUrl " +
224
+ "in ~/.gaia/update-config.json) to your R2 channel base URL. " +
225
+ "Auto-update is paused until a feed is configured.",
226
+ });
227
+ log("warn", "No update feed configured — auto-update paused (NO_CHANNEL)");
228
+ return false;
229
+ }
230
+ try {
231
+ autoUpdaterRef.setFeedURL({
232
+ provider: "generic",
233
+ url,
234
+ channel: DEFAULT_CHANNEL,
235
+ });
236
+ forwardFeedUrl = url;
237
+ log("info", `Update feed set (generic): ${url} [channel=${DEFAULT_CHANNEL}]`);
238
+ return true;
239
+ } catch (err) {
240
+ forwardFeedUrl = null;
241
+ setState({
242
+ status: STATES.ERROR,
243
+ error: `Failed to set update feed URL: ${(err && err.message) || String(err)}`,
244
+ });
245
+ log("error", "Failed to set feed URL:", err && err.message);
246
+ return false;
247
+ }
248
+ }
249
+
157
250
  // ── State management ─────────────────────────────────────────────────────────
158
251
 
159
252
  function broadcastState() {
@@ -359,8 +452,10 @@ async function installVersion(tag) {
359
452
  /**
360
453
  * Clear the version pin and resume normal auto-updates.
361
454
  *
362
- * Restores the GitHub provider feed, sets autoDownload=true, and clears
363
- * allowDowngrade so the next check is the normal forward-only flow.
455
+ * Restores the configured forward (R2 generic) feed, sets autoDownload=true,
456
+ * and clears allowDowngrade so the next check is the normal forward-only flow.
457
+ * If no feed is configured, resume lands in the loud NO_CHANNEL state rather
458
+ * than silently reverting to a stale rollback feed.
364
459
  */
365
460
  function clearPin() {
366
461
  // A failed clear is non-fatal — worst case the stale pin re-pauses updates
@@ -375,17 +470,9 @@ function clearPin() {
375
470
  if (autoUpdaterRef) {
376
471
  autoUpdaterRef.autoDownload = true;
377
472
  autoUpdaterRef.allowDowngrade = false;
378
- // Restore the GitHub provider (overrides any generic/tagged feed set by
379
- // installVersion). Owner/repo must match electron-builder.yml publish block.
380
- try {
381
- autoUpdaterRef.setFeedURL({
382
- provider: "github",
383
- owner: "amd",
384
- repo: "gaia",
385
- });
386
- } catch (err) {
387
- log("warn", "Failed to restore GitHub feed on resume:", err && err.message);
388
- }
473
+ // Restore the forward channel (overrides any generic/tagged rollback feed
474
+ // set by installVersion). Loud NO_CHANNEL if unconfigured.
475
+ _applyForwardFeed();
389
476
  }
390
477
 
391
478
  log("info", "Version pin cleared — auto-updates resumed");
@@ -403,6 +490,16 @@ async function checkForUpdates() {
403
490
  log("warn", "checkForUpdates called before init — ignoring");
404
491
  return;
405
492
  }
493
+ // No forward feed and not pinned → surface the loud NO_CHANNEL state instead
494
+ // of asking electron-updater to check a feed that doesn't exist. Re-resolve
495
+ // first so a feed written to config after init() is picked up here.
496
+ if (!forwardFeedUrl && !state.pinnedVersion) {
497
+ if (!_applyForwardFeed()) return;
498
+ // Feed just became available after a no-feed (NO_CHANNEL) start — init()
499
+ // skipped arming the periodic scheduler, so arm it now or background checks
500
+ // never resume once the feed goes live.
501
+ if (!scheduledTimeout) scheduleNextCheck();
502
+ }
406
503
  if (checkInProgress) {
407
504
  log("info", "Skipping check — another check is already in progress");
408
505
  return;
@@ -691,6 +788,25 @@ function init(mainWindow) {
691
788
  log("warn", "Failed to register IPC handlers:", err && err.message);
692
789
  }
693
790
 
791
+ // Point the updater at the configured R2 generic feed (#1724). When a pin is
792
+ // active, installVersion() owns the feed (rollback path), so skip. With no
793
+ // pin and no feed, _applyForwardFeed enters the loud NO_CHANNEL state and we
794
+ // don't schedule checks against a nonexistent feed.
795
+ let feedReady = true;
796
+ if (!savedPin) {
797
+ feedReady = _applyForwardFeed();
798
+ }
799
+
800
+ initialized = true;
801
+
802
+ if (!feedReady && !savedPin) {
803
+ log(
804
+ "info",
805
+ "Auto-updater initialized without a channel — checks paused until a feed is configured"
806
+ );
807
+ return;
808
+ }
809
+
694
810
  // First check after CHECK_DELAY_MS, then every CHECK_INTERVAL_MS.
695
811
  initialCheckTimeout = setTimeout(async () => {
696
812
  try {
@@ -701,7 +817,6 @@ function init(mainWindow) {
701
817
  scheduleNextCheck();
702
818
  }, CHECK_DELAY_MS);
703
819
 
704
- initialized = true;
705
820
  log(
706
821
  "info",
707
822
  `Auto-updater initialized; first check in ${CHECK_DELAY_MS}ms`
@@ -399,7 +399,7 @@ async function showFailureDialog(parentWindow, errorInfo = {}) {
399
399
  return "retry";
400
400
  case 2: {
401
401
  try {
402
- await shell.openExternal("https://amd-gaia.ai/quickstart#cli-install");
402
+ await shell.openExternal("https://amd-gaia.ai/docs/quickstart#cli-install");
403
403
  } catch {
404
404
  // ignore
405
405
  }