Spaces:
Paused
Paused
Supervisor: Pi Web starts before projects are unpacked; session index for the launcher
Browse files- README.md +1 -1
- sandbox/hfpi.mjs +104 -4
README.md
CHANGED
|
@@ -22,7 +22,7 @@ What is inside:
|
|
| 22 |
- Node 22, Python 3, uv, git, git-lfs, build tools, ripgrep, fd, jq, zstd
|
| 23 |
- the `hf` CLI (`huggingface_hub` 2.1.1) and the Hugging Face agent skills in `~/.agents/skills`
|
| 24 |
- `sandbox/hfpi.mjs`: the supervisor that restores the user's projects and sessions from their bucket, runs Pi Web,
|
| 25 |
-
saves state while it changes, and stops the machine when idle
|
| 26 |
- `sandbox/git-credential-huggingface`: git authenticates to huggingface.co with the token the user lent at sign-in
|
| 27 |
- `sandbox/AGENTS.md`: what the agent is told about this environment
|
| 28 |
|
|
|
|
| 22 |
- Node 22, Python 3, uv, git, git-lfs, build tools, ripgrep, fd, jq, zstd
|
| 23 |
- the `hf` CLI (`huggingface_hub` 2.1.1) and the Hugging Face agent skills in `~/.agents/skills`
|
| 24 |
- `sandbox/hfpi.mjs`: the supervisor that restores the user's projects and sessions from their bucket, runs Pi Web,
|
| 25 |
+
saves state while it changes (with a session index the Space's launcher reads), and stops the machine when idle
|
| 26 |
- `sandbox/git-credential-huggingface`: git authenticates to huggingface.co with the token the user lent at sign-in
|
| 27 |
- `sandbox/AGENTS.md`: what the agent is told about this environment
|
| 28 |
|
sandbox/hfpi.mjs
CHANGED
|
@@ -14,6 +14,7 @@
|
|
| 14 |
// hfpi changes --name N files changed since the import, for a Hub PR
|
| 15 |
// hfpi patch --name N the same changes as a git patch
|
| 16 |
// hfpi status JSON for the gateway
|
|
|
|
| 17 |
// hfpi heartbeat user activity seen by the gateway
|
| 18 |
// hfpi stop final snapshot, then stop Pi Web and the supervisor
|
| 19 |
|
|
@@ -30,6 +31,7 @@ const RUN_DIR = process.env.HFPI_RUN_DIR || "/tmp/hfpi";
|
|
| 30 |
const PORT = Number(process.env.HFPI_PIWEB_PORT || 30141);
|
| 31 |
const PROJECTS_DIR = path.join(PERSIST, "projects");
|
| 32 |
const AGENT_ARCHIVE = path.join(PERSIST, "agent", "agent.tar.zst");
|
|
|
|
| 33 |
const MACHINE_DIR = path.join(PERSIST, "machine");
|
| 34 |
const IDLE_MINUTES = Number(process.env.HFPI_IDLE_MINUTES ?? 30);
|
| 35 |
const MAX_LIFETIME_MINUTES = Number(process.env.HFPI_MAX_LIFETIME_MINUTES ?? 23 * 60 + 40);
|
|
@@ -256,10 +258,104 @@ function snapshotAgent({ force = false } = {}) {
|
|
| 256 |
touch(pending);
|
| 257 |
const bytes = writeArchive(AGENT_ARCHIVE, ["-C", AGENT_DIR, ...AGENT_EXCLUDES.map((e) => `--exclude=${e}`), "."]);
|
| 258 |
fs.renameSync(pending, marker);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
return { bytes, at: new Date().toISOString() };
|
| 260 |
});
|
| 261 |
}
|
| 262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
function snapshotAll({ force = false } = {}) {
|
| 264 |
const results = { agent: null, projects: [] };
|
| 265 |
try {
|
|
@@ -300,7 +396,7 @@ function restoreProjects() {
|
|
| 300 |
if (!NAME_RE.test(name)) continue;
|
| 301 |
const archive = projectArchivePath(name);
|
| 302 |
const dir = path.join(WORKSPACE, name);
|
| 303 |
-
if (!exists(archive) || exists(dir)) continue;
|
| 304 |
const started = Date.now();
|
| 305 |
run("tar", ["--zstd", "-xf", archive, "-C", WORKSPACE]);
|
| 306 |
touch(path.join(RUN_DIR, "markers", `project-${name}`));
|
|
@@ -414,10 +510,10 @@ async function cmdRun() {
|
|
| 414 |
writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "restoring" });
|
| 415 |
touch(path.join(RUN_DIR, "heartbeat"));
|
| 416 |
|
|
|
|
|
|
|
| 417 |
restoreAgent();
|
| 418 |
-
const restored = restoreProjects();
|
| 419 |
configurePi();
|
| 420 |
-
log(`restore done in ${((Date.now() - bootStarted) / 1000).toFixed(1)}s (${restored.length} projects)`);
|
| 421 |
|
| 422 |
const jobId = process.env.JOB_ID || "";
|
| 423 |
const allowedHosts = [process.env.PI_WEB_ALLOWED_HOSTS, jobId && `${jobId}--${PORT}.hf.jobs`].filter(Boolean).join(",");
|
|
@@ -451,6 +547,8 @@ async function cmdRun() {
|
|
| 451 |
});
|
| 452 |
};
|
| 453 |
startPiWeb();
|
|
|
|
|
|
|
| 454 |
writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "running", restored });
|
| 455 |
|
| 456 |
const shutdown = async (reason) => {
|
|
@@ -689,6 +787,8 @@ async function main() {
|
|
| 689 |
return cmdPatch(args);
|
| 690 |
case "status":
|
| 691 |
return print(await cmdStatus());
|
|
|
|
|
|
|
| 692 |
case "heartbeat":
|
| 693 |
touch(path.join(RUN_DIR, "heartbeat"));
|
| 694 |
return print({ ok: true });
|
|
@@ -696,7 +796,7 @@ async function main() {
|
|
| 696 |
touch(path.join(RUN_DIR, "stop-requested"));
|
| 697 |
return print({ ok: true });
|
| 698 |
default:
|
| 699 |
-
console.error("usage: hfpi run|snapshot|import|delete|changes|patch|status|heartbeat|stop");
|
| 700 |
process.exit(2);
|
| 701 |
}
|
| 702 |
}
|
|
|
|
| 14 |
// hfpi changes --name N files changed since the import, for a Hub PR
|
| 15 |
// hfpi patch --name N the same changes as a git patch
|
| 16 |
// hfpi status JSON for the gateway
|
| 17 |
+
// hfpi sessions the session index (also saved to the bucket with the agent)
|
| 18 |
// hfpi heartbeat user activity seen by the gateway
|
| 19 |
// hfpi stop final snapshot, then stop Pi Web and the supervisor
|
| 20 |
|
|
|
|
| 31 |
const PORT = Number(process.env.HFPI_PIWEB_PORT || 30141);
|
| 32 |
const PROJECTS_DIR = path.join(PERSIST, "projects");
|
| 33 |
const AGENT_ARCHIVE = path.join(PERSIST, "agent", "agent.tar.zst");
|
| 34 |
+
const SESSION_INDEX = path.join(PERSIST, "agent", "index.json"); // read by the Space while the machine is off
|
| 35 |
const MACHINE_DIR = path.join(PERSIST, "machine");
|
| 36 |
const IDLE_MINUTES = Number(process.env.HFPI_IDLE_MINUTES ?? 30);
|
| 37 |
const MAX_LIFETIME_MINUTES = Number(process.env.HFPI_MAX_LIFETIME_MINUTES ?? 23 * 60 + 40);
|
|
|
|
| 258 |
touch(pending);
|
| 259 |
const bytes = writeArchive(AGENT_ARCHIVE, ["-C", AGENT_DIR, ...AGENT_EXCLUDES.map((e) => `--exclude=${e}`), "."]);
|
| 260 |
fs.renameSync(pending, marker);
|
| 261 |
+
try {
|
| 262 |
+
writeSessionIndex();
|
| 263 |
+
} catch (e) {
|
| 264 |
+
log("session index failed:", e.message);
|
| 265 |
+
}
|
| 266 |
return { bytes, at: new Date().toISOString() };
|
| 267 |
});
|
| 268 |
}
|
| 269 |
|
| 270 |
+
// ---------------------------------------------------------------- session index
|
| 271 |
+
|
| 272 |
+
// One line per session for the Space's launcher: it lists sessions instantly, before the
|
| 273 |
+
// machine (and Pi Web) is up. Files are only re-read when they changed.
|
| 274 |
+
const indexCache = new Map();
|
| 275 |
+
let lastIndex = "";
|
| 276 |
+
|
| 277 |
+
function messageText(message) {
|
| 278 |
+
const content = message?.content;
|
| 279 |
+
if (typeof content === "string") return content;
|
| 280 |
+
if (Array.isArray(content)) return content.filter((c) => c?.type === "text").map((c) => c.text).join(" ");
|
| 281 |
+
return "";
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function sessionEntry(file) {
|
| 285 |
+
const st = fs.statSync(file);
|
| 286 |
+
const cached = indexCache.get(file);
|
| 287 |
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) return cached.entry;
|
| 288 |
+
let header = null;
|
| 289 |
+
let name = null;
|
| 290 |
+
let firstUser = null;
|
| 291 |
+
let messages = 0;
|
| 292 |
+
for (const line of fs.readFileSync(file, "utf8").split("\n")) {
|
| 293 |
+
if (!line) continue;
|
| 294 |
+
if (!header) {
|
| 295 |
+
try {
|
| 296 |
+
header = JSON.parse(line);
|
| 297 |
+
} catch {
|
| 298 |
+
break;
|
| 299 |
+
}
|
| 300 |
+
continue;
|
| 301 |
+
}
|
| 302 |
+
if (line.includes('"type":"session_info"')) {
|
| 303 |
+
try {
|
| 304 |
+
name = JSON.parse(line).name || name;
|
| 305 |
+
} catch {}
|
| 306 |
+
} else if (line.startsWith('{"type":"message"')) {
|
| 307 |
+
messages++;
|
| 308 |
+
if (!firstUser && line.includes('"role":"user"')) {
|
| 309 |
+
try {
|
| 310 |
+
const entry = JSON.parse(line);
|
| 311 |
+
if (entry.message?.role === "user") firstUser = messageText(entry.message);
|
| 312 |
+
} catch {}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
if (!header || header.type !== "session") return null;
|
| 317 |
+
const cwd = header.cwd || "";
|
| 318 |
+
const rel = path.relative(WORKSPACE, cwd);
|
| 319 |
+
const project = cwd.includes(`${path.sep}pi-cwd${path.sep}`) ? "pi-cwd" : !rel || rel.startsWith("..") ? path.basename(cwd) : rel.split(path.sep)[0];
|
| 320 |
+
const entry = {
|
| 321 |
+
id: header.id,
|
| 322 |
+
cwd,
|
| 323 |
+
project,
|
| 324 |
+
title: (name || firstUser || "").replace(/\s+/g, " ").trim().slice(0, 140) || "New session",
|
| 325 |
+
created: header.timestamp,
|
| 326 |
+
modified: st.mtimeMs,
|
| 327 |
+
messages,
|
| 328 |
+
};
|
| 329 |
+
indexCache.set(file, { mtimeMs: st.mtimeMs, size: st.size, entry });
|
| 330 |
+
return entry;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
function sessionIndex(limit = 200) {
|
| 334 |
+
const root = path.join(AGENT_DIR, "sessions");
|
| 335 |
+
if (!exists(root)) return [];
|
| 336 |
+
const sessions = [];
|
| 337 |
+
for (const dir of fs.readdirSync(root, { withFileTypes: true })) {
|
| 338 |
+
if (!dir.isDirectory()) continue;
|
| 339 |
+
for (const file of fs.readdirSync(path.join(root, dir.name))) {
|
| 340 |
+
if (!file.endsWith(".jsonl")) continue;
|
| 341 |
+
try {
|
| 342 |
+
const entry = sessionEntry(path.join(root, dir.name, file));
|
| 343 |
+
if (entry) sessions.push(entry);
|
| 344 |
+
} catch {}
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
sessions.sort((a, b) => b.modified - a.modified);
|
| 348 |
+
return sessions.slice(0, limit);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function writeSessionIndex() {
|
| 352 |
+
const sessions = sessionIndex();
|
| 353 |
+
const text = JSON.stringify(sessions);
|
| 354 |
+
if (text === lastIndex && exists(SESSION_INDEX)) return;
|
| 355 |
+
writeJson(SESSION_INDEX, { updated_at: new Date().toISOString(), sessions });
|
| 356 |
+
lastIndex = text;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
function snapshotAll({ force = false } = {}) {
|
| 360 |
const results = { agent: null, projects: [] };
|
| 361 |
try {
|
|
|
|
| 396 |
if (!NAME_RE.test(name)) continue;
|
| 397 |
const archive = projectArchivePath(name);
|
| 398 |
const dir = path.join(WORKSPACE, name);
|
| 399 |
+
if (!exists(archive) || (exists(dir) && fs.readdirSync(dir).length > 0)) continue;
|
| 400 |
const started = Date.now();
|
| 401 |
run("tar", ["--zstd", "-xf", archive, "-C", WORKSPACE]);
|
| 402 |
touch(path.join(RUN_DIR, "markers", `project-${name}`));
|
|
|
|
| 510 |
writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "restoring" });
|
| 511 |
touch(path.join(RUN_DIR, "heartbeat"));
|
| 512 |
|
| 513 |
+
// Sessions and settings first, then Pi Web, which boots in its own process while the
|
| 514 |
+
// projects are unpacked here: Pi Web is ready seconds earlier.
|
| 515 |
restoreAgent();
|
|
|
|
| 516 |
configurePi();
|
|
|
|
| 517 |
|
| 518 |
const jobId = process.env.JOB_ID || "";
|
| 519 |
const allowedHosts = [process.env.PI_WEB_ALLOWED_HOSTS, jobId && `${jobId}--${PORT}.hf.jobs`].filter(Boolean).join(",");
|
|
|
|
| 547 |
});
|
| 548 |
};
|
| 549 |
startPiWeb();
|
| 550 |
+
const restored = restoreProjects();
|
| 551 |
+
log(`restore done in ${((Date.now() - bootStarted) / 1000).toFixed(1)}s (${restored.length} projects)`);
|
| 552 |
writeJson(path.join(RUN_DIR, "supervisor.json"), { pid: process.pid, started_at: new Date(bootStarted).toISOString(), phase: "running", restored });
|
| 553 |
|
| 554 |
const shutdown = async (reason) => {
|
|
|
|
| 787 |
return cmdPatch(args);
|
| 788 |
case "status":
|
| 789 |
return print(await cmdStatus());
|
| 790 |
+
case "sessions":
|
| 791 |
+
return print({ sessions: sessionIndex() });
|
| 792 |
case "heartbeat":
|
| 793 |
touch(path.join(RUN_DIR, "heartbeat"));
|
| 794 |
return print({ ok: true });
|
|
|
|
| 796 |
touch(path.join(RUN_DIR, "stop-requested"));
|
| 797 |
return print({ ok: true });
|
| 798 |
default:
|
| 799 |
+
console.error("usage: hfpi run|snapshot|import|delete|changes|patch|status|sessions|heartbeat|stop");
|
| 800 |
process.exit(2);
|
| 801 |
}
|
| 802 |
}
|