lvwerra HF Staff Claude Fable 5 commited on
Commit
5bfc1c8
·
1 Parent(s): bddccfa

Settings: Update Agent Manager pulls the latest template

Browse files

Duplicated Spaces had no way to get app updates. A new Settings row
compares the Space repo's sha with the template's (honoring
duplicated_from) and shows up-to-date or the available update; on
confirm the server clones the template and force-pushes its main onto
the Space repo, which HF rebuilds automatically. Requires the same
write-scoped HF_TOKEN as relaunch; the token never leaves the server
and is scrubbed from error output. Bucket data is untouched; manual
edits to the Space repo are overwritten (said clearly in the help).

Also documents the new preinstalled tooling in the environment skill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

server/src/index.js CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os';
3
  import path from 'node:path';
4
  import fs from 'node:fs';
5
  import { URL, fileURLToPath } from 'node:url';
 
6
  import express from 'express';
7
  import { WebSocketServer } from 'ws';
8
  import {
@@ -315,6 +316,69 @@ app.post('/api/relaunch', async (_req, res) => {
315
  } catch (e) { return res.json({ ok: false, reason: String(e.message || e) }); }
316
  });
317
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
318
  // ---------- secrets: describe each injected secret/variable, feed a skill ----------
319
  const SECRET_NOTES_FILE = path.join(DATA_DIR, 'secret-notes.json');
320
  function loadSecretNotes() {
@@ -449,6 +513,9 @@ Hermes — alongside plain shells and a file browser.
449
 
450
  ## Tooling
451
  - A full Linux shell with \`git\`, \`ripgrep\` (\`rg\`), \`node\`, and \`python3\`, plus build tools. Reach for \`rg\` for fast search.
 
 
 
452
  - Network access is available; API keys are provided via the environment (below) or your home config.
453
 
454
  ## Working well here
 
3
  import path from 'node:path';
4
  import fs from 'node:fs';
5
  import { URL, fileURLToPath } from 'node:url';
6
+ import { execFile } from 'node:child_process';
7
  import express from 'express';
8
  import { WebSocketServer } from 'ws';
9
  import {
 
316
  } catch (e) { return res.json({ ok: false, reason: String(e.message || e) }); }
317
  });
318
 
319
+ // ---------- self-update: pull the latest app from the template ----------
320
+ // Duplicated Spaces never get app updates. This compares the Space repo's sha
321
+ // to the template's and, on request, force-pushes the template's main onto the
322
+ // Space repo (HF rebuilds automatically). Agents, logins, and files live on
323
+ // the bucket, so replacing the app code is safe; any manual edits to the
324
+ // Space's own repo are overwritten.
325
+ const TEMPLATE_SPACE = 'lvwerra/agent-manager-template';
326
+ async function updateSource() {
327
+ const own = process.env.SPACE_ID;
328
+ const token = hfToken();
329
+ const headers = token ? { authorization: `Bearer ${token}` } : {};
330
+ const info = await (await fetch(`https://huggingface.co/api/spaces/${own}`, { headers })).json();
331
+ // Prefer the recorded origin for real duplicates; fall back to the canonical
332
+ // template (also correct for the original development Space).
333
+ const template = (typeof info.duplicated_from === 'string' && info.duplicated_from) || TEMPLATE_SPACE;
334
+ const tInfo = await (await fetch(`https://huggingface.co/api/spaces/${template}`)).json();
335
+ return { own, ownSha: info.sha || null, template, templateSha: tInfo.sha || null };
336
+ }
337
+
338
+ app.get('/api/update/check', async (_req, res) => {
339
+ if (!process.env.SPACE_ID) return res.json({ ok: false, reason: 'no-space' });
340
+ try {
341
+ const src = await updateSource();
342
+ res.json({
343
+ ok: true,
344
+ template: src.template,
345
+ current: src.ownSha,
346
+ latest: src.templateSha,
347
+ behind: !!(src.ownSha && src.templateSha && src.ownSha !== src.templateSha),
348
+ canUpdate: !!hfToken(),
349
+ });
350
+ } catch (e) { res.json({ ok: false, reason: String(e.message || e) }); }
351
+ });
352
+
353
+ let updateBusy = false;
354
+ app.post('/api/update', async (_req, res) => {
355
+ const token = hfToken();
356
+ if (!process.env.SPACE_ID) return res.json({ ok: false, reason: 'no-space' });
357
+ if (!token) return res.json({ ok: false, reason: 'no-token' });
358
+ if (updateBusy) return res.json({ ok: false, reason: 'busy' });
359
+ updateBusy = true;
360
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'am-update-'));
361
+ const git = (args, cwd) => new Promise((resolve, reject) => {
362
+ execFile('git', args, { cwd, timeout: 180_000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } },
363
+ (err, stdout, stderr) => (err ? reject(new Error(String(stderr || err.message).replace(token, '***'))) : resolve(stdout)));
364
+ });
365
+ try {
366
+ const src = await updateSource();
367
+ if (src.ownSha && src.templateSha && src.ownSha === src.templateSha) {
368
+ return res.json({ ok: true, upToDate: true });
369
+ }
370
+ await git(['clone', '--quiet', `https://huggingface.co/spaces/${src.template}`, dir]);
371
+ await git(['remote', 'add', 'own', `https://user:${token}@huggingface.co/spaces/${src.own}`], dir);
372
+ await git(['push', '--force', 'own', 'main'], dir);
373
+ res.json({ ok: true, from: src.ownSha, to: src.templateSha });
374
+ } catch (e) {
375
+ res.json({ ok: false, reason: String(e.message || e).slice(0, 300) });
376
+ } finally {
377
+ updateBusy = false;
378
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
379
+ }
380
+ });
381
+
382
  // ---------- secrets: describe each injected secret/variable, feed a skill ----------
383
  const SECRET_NOTES_FILE = path.join(DATA_DIR, 'secret-notes.json');
384
  function loadSecretNotes() {
 
513
 
514
  ## Tooling
515
  - A full Linux shell with \`git\`, \`ripgrep\` (\`rg\`), \`node\`, and \`python3\`, plus build tools. Reach for \`rg\` for fast search.
516
+ - Preinstalled utilities: \`jq\`, \`htop\`, \`lsof\`, \`tree\`, \`ncdu\`, \`sqlite3\`, \`vim\`/\`nano\`, \`zip\`/\`unzip\`, \`ffmpeg\`, \`imagemagick\`, \`gh\` (GitHub CLI; auths from \`$GH_TOKEN\`), \`git-lfs\`, \`hf\` (Hugging Face CLI).
517
+ - **Headless Chromium for Playwright is baked in** (\`PLAYWRIGHT_BROWSERS_PATH\`): node and python playwright work immediately, no browser download. Use it to screenshot or test web work.
518
+ - The default \`python3\` ships \`numpy\`, \`pandas\`, \`matplotlib\` (\`MPLBACKEND=Agg\`), \`seaborn\`, \`requests\`, \`pillow\`, \`huggingface_hub\`, \`ipython\` — fine for one-off scripts; build real project envs with uv on \`$AM_LOCAL\` (below).
519
  - Network access is available; API keys are provided via the environment (below) or your home config.
520
 
521
  ## Working well here
web/src/api.ts CHANGED
@@ -57,6 +57,15 @@ export const setDemo = (active: boolean): Promise<{ ok: boolean; active: boolean
57
  export const relaunchSpace = (): Promise<{ ok: boolean; reason?: string }> =>
58
  fetch('/api/relaunch', { method: 'POST' }).then(json);
59
 
 
 
 
 
 
 
 
 
 
60
  export interface AmConfig {
61
  artifacts: { enabled: boolean; space: string; visibility: 'public' | 'private' };
62
  jobs: { askAboveUsd: number };
 
57
  export const relaunchSpace = (): Promise<{ ok: boolean; reason?: string }> =>
58
  fetch('/api/relaunch', { method: 'POST' }).then(json);
59
 
60
+ // ---- app self-update from the template ----
61
+ export interface UpdateCheck {
62
+ ok: boolean; reason?: string; template?: string;
63
+ current?: string | null; latest?: string | null; behind?: boolean; canUpdate?: boolean;
64
+ }
65
+ export const checkUpdate = (): Promise<UpdateCheck> => fetch('/api/update/check').then(json);
66
+ export const runUpdate = (): Promise<{ ok: boolean; reason?: string; upToDate?: boolean }> =>
67
+ fetch('/api/update', { method: 'POST' }).then(json);
68
+
69
  export interface AmConfig {
70
  artifacts: { enabled: boolean; space: string; visibility: 'public' | 'private' };
71
  jobs: { askAboveUsd: number };
web/src/components/SettingsView.tsx CHANGED
@@ -177,6 +177,21 @@ export default function SettingsView({
177
  return () => clearTimeout(t);
178
  }, [cfg, savedCfg]);
179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  const doRelaunch = async () => {
181
  setRelaunch({ busy: true });
182
  try {
@@ -390,6 +405,32 @@ export default function SettingsView({
390
  </div>
391
  {relaunch.msg && <div className="s-help" style={{ marginTop: 6 }}>{relaunch.msg}</div>}
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  <h3>Space</h3>
394
  {info?.bucketUnverified && (
395
  <div className="s-warn">
 
177
  return () => clearTimeout(t);
178
  }, [cfg, savedCfg]);
179
 
180
+ // App self-update: version check on open, update on confirm.
181
+ const [upd, setUpd] = useState<api.UpdateCheck | null>(null);
182
+ const [updState, setUpdState] = useState<{ busy?: boolean; msg?: string; confirm?: boolean }>({});
183
+ useEffect(() => { api.checkUpdate().then(setUpd).catch(() => {}); }, []);
184
+ const doUpdate = async () => {
185
+ setUpdState({ busy: true });
186
+ try {
187
+ const r = await api.runUpdate();
188
+ if (r.ok && r.upToDate) setUpdState({ msg: 'Already up to date.' });
189
+ else if (r.ok) setUpdState({ msg: 'Update pushed. The Space rebuilds now (1 to 2 min); reload once it is back.' });
190
+ else if (r.reason === 'no-token') setUpdState({ msg: 'Add a write-scoped HF_TOKEN secret to the Space to enable updates.' });
191
+ else setUpdState({ msg: `Update failed (${r.reason}).` });
192
+ } catch { setUpdState({ msg: 'Request failed.' }); }
193
+ };
194
+
195
  const doRelaunch = async () => {
196
  setRelaunch({ busy: true });
197
  try {
 
405
  </div>
406
  {relaunch.msg && <div className="s-help" style={{ marginTop: 6 }}>{relaunch.msg}</div>}
407
 
408
+ <div className="setting-row">
409
+ <div>
410
+ <div className="s-label">Update Agent Manager</div>
411
+ <div className="s-help">
412
+ Pulls the latest app version from the template ({upd?.template || 'the template'}) into this
413
+ Space and rebuilds. Your agents, logins, and files live on the bucket and are untouched.
414
+ {upd?.ok && !upd.behind && <> Currently up to date <span className="mono">({(upd.current || '').slice(0, 7)})</span>.</>}
415
+ {upd?.ok && upd.behind && <> Update available: <span className="mono">{(upd.current || '').slice(0, 7)} → {(upd.latest || '').slice(0, 7)}</span>.</>}
416
+ </div>
417
+ </div>
418
+ {!upd?.ok || !upd.canUpdate ? (
419
+ <button className="btn-ghost" disabled title="Needs a write-scoped HF_TOKEN Space secret"><RefreshGlyph /> Update app</button>
420
+ ) : !upd.behind ? (
421
+ <button className="btn-ghost" disabled><RefreshGlyph /> Up to date</button>
422
+ ) : updState.confirm ? (
423
+ <span className="confirm-del">
424
+ <span className="s-muted">Replace app code and rebuild?</span>
425
+ <button className="btn-primary" disabled={updState.busy} onClick={doUpdate}>{updState.busy ? '…' : 'Update'}</button>
426
+ <button className="btn-ghost" onClick={() => setUpdState({})}>Cancel</button>
427
+ </span>
428
+ ) : (
429
+ <button className="btn-primary" onClick={() => setUpdState({ confirm: true })}><RefreshGlyph /> Update app</button>
430
+ )}
431
+ </div>
432
+ {updState.msg && <div className="s-help" style={{ marginTop: 6 }}>{updState.msg}</div>}
433
+
434
  <h3>Space</h3>
435
  {info?.bucketUnverified && (
436
  <div className="s-warn">