Spaces:
Running
Running
Settings: Update Agent Manager pulls the latest template
Browse filesDuplicated 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 +67 -0
- web/src/api.ts +9 -0
- web/src/components/SettingsView.tsx +41 -0
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">
|