Mike0021 commited on
Commit
cd1e525
·
verified ·
1 Parent(s): df3a27a

Supervisor: Pi Web starts before projects are unpacked; session index for the launcher

Browse files
Files changed (2) hide show
  1. README.md +1 -1
  2. 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
  }