blitzwing 0.1.8 → 0.1.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blitzwing",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Interactive setup wizard to join a Blitzwing Petals mother swarm as a compute contributor",
5
5
  "bin": {
6
6
  "blitzwing": "bin/blitzwing.js"
@@ -0,0 +1,64 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { heartbeatHost } from "./api.js";
5
+ import { HOME_DIR } from "./config.js";
6
+
7
+ /** Must be well below mother HEARTBEAT_TTL_SECONDS (default 60). */
8
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 20_000;
9
+
10
+ /**
11
+ * Start a detached background process that heartbeats the mother orchestrator.
12
+ * @param {{ motherUrl: string, hostId: string, intervalMs?: number }} opts
13
+ */
14
+ export function startContributorHeartbeatDaemon({
15
+ motherUrl,
16
+ hostId,
17
+ intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
18
+ }) {
19
+ const hbPath = path.join(HOME_DIR, "heartbeat.mjs");
20
+ fs.mkdirSync(HOME_DIR, { recursive: true });
21
+ fs.writeFileSync(
22
+ hbPath,
23
+ `const mother = ${JSON.stringify(motherUrl)};
24
+ const hostId = ${JSON.stringify(hostId)};
25
+ const intervalMs = ${intervalMs};
26
+ async function beat() {
27
+ try {
28
+ await fetch(mother.replace(/\\/$/, "") + "/v1/hosts/heartbeat", {
29
+ method: "POST",
30
+ headers: {
31
+ "Content-Type": "application/json",
32
+ "ngrok-skip-browser-warning": "true",
33
+ },
34
+ body: JSON.stringify({ host_id: hostId }),
35
+ });
36
+ } catch {}
37
+ }
38
+ setInterval(beat, intervalMs);
39
+ beat();
40
+ `
41
+ );
42
+ const child = spawn(process.execPath, [hbPath], {
43
+ detached: true,
44
+ stdio: "ignore",
45
+ });
46
+ child.unref();
47
+ }
48
+
49
+ /** In-process heartbeat loop (for tests or long-running CLI). */
50
+ export async function startContributorHeartbeatLoop({
51
+ motherUrl,
52
+ hostId,
53
+ intervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
54
+ }) {
55
+ async function beat() {
56
+ try {
57
+ await heartbeatHost(motherUrl, { host_id: hostId });
58
+ } catch {
59
+ /* mother may be briefly unavailable */
60
+ }
61
+ }
62
+ setInterval(beat, intervalMs);
63
+ await beat();
64
+ }
package/src/install.js CHANGED
@@ -220,6 +220,7 @@ from pydantic import BaseModel, Field
220
220
 
221
221
  class ReloadRequest(BaseModel):
222
222
  block_indices: str = Field(..., pattern=r"^\\d+:\\d+$")
223
+ initial_peers: Optional[List[str]] = None
223
224
 
224
225
  class StatusResponse(BaseModel):
225
226
  running: bool
@@ -259,6 +260,13 @@ class Mgr:
259
260
  c += ["--public_ip", self.public_ip]
260
261
  if self.initial_peers:
261
262
  c += ["--initial_peers", *self.initial_peers]
263
+ use_auto_relay = os.environ.get("PETALS_USE_AUTO_RELAY", "1") not in ("0", "false", "False")
264
+ if not self.announce_maddrs and use_auto_relay:
265
+ pass
266
+ elif not use_auto_relay:
267
+ c += ["--no_auto_relay"]
268
+ if os.environ.get("PETALS_SKIP_REACHABILITY_CHECK", "1") in ("1", "true", "True"):
269
+ c += ["--skip_reachability_check"]
262
270
  return c
263
271
 
264
272
  def start(self, bi=None):
@@ -296,7 +304,9 @@ class Mgr:
296
304
  self.last_exit_code = p.returncode
297
305
  self._proc = None
298
306
 
299
- def reload(self, bi):
307
+ def reload(self, bi, initial_peers=None):
308
+ if initial_peers is not None:
309
+ self.initial_peers = initial_peers
300
310
  self.stop(); time.sleep(1); self.start(bi)
301
311
 
302
312
  def status(self):
@@ -333,7 +343,7 @@ def status():
333
343
  def reload(body: ReloadRequest):
334
344
  a,b = map(int, body.block_indices.split(":"))
335
345
  if b <= a: raise HTTPException(400, "bad range")
336
- mgr.reload(body.block_indices)
346
+ mgr.reload(body.block_indices, initial_peers=body.initial_peers)
337
347
  time.sleep(0.5)
338
348
  return mgr.status()
339
349
 
package/src/net.js CHANGED
@@ -24,6 +24,59 @@ export async function detectPublicIp() {
24
24
  return "";
25
25
  }
26
26
 
27
+ /** Local LAN/WSL IP for shard_manager_url metadata (not Petals announce). */
28
+ export async function detectLocalIp() {
29
+ if (process.env.BLITZWING_LOCAL_IP) {
30
+ return process.env.BLITZWING_LOCAL_IP.trim();
31
+ }
32
+ try {
33
+ const { networkInterfaces } = await import("node:os");
34
+ const nets = networkInterfaces();
35
+ for (const name of Object.keys(nets)) {
36
+ for (const net of nets[name] || []) {
37
+ if (net.family === "IPv4" && !net.internal) {
38
+ return net.address;
39
+ }
40
+ }
41
+ }
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ return "127.0.0.1";
46
+ }
47
+
48
+ /** Optional ngrok TCP tunnel for dev; production should use VM public IP or auto-relay. */
49
+ export async function discoverNgrokTcpAnnounce(petalsPort) {
50
+ const override = process.env.BLITZWING_ANNOUNCE_MADDRS?.trim();
51
+ if (override) return override;
52
+
53
+ try {
54
+ const res = await fetch("http://127.0.0.1:4040/api/tunnels", {
55
+ signal: AbortSignal.timeout(2000),
56
+ });
57
+ if (!res.ok) return null;
58
+ const data = await res.json();
59
+ const tunnel = (data.tunnels || []).find((t) => t.proto === "tcp");
60
+ if (!tunnel?.public_url) return null;
61
+ const url = new URL(tunnel.public_url);
62
+ return `/dns4/${url.hostname}/tcp/${url.port || petalsPort}`;
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export function extractPeerMultiaddrFromLog(logPath, fs) {
69
+ if (!fs.existsSync(logPath)) return null;
70
+ const text = fs.readFileSync(logPath, "utf8");
71
+ const line = text
72
+ .split("\n")
73
+ .reverse()
74
+ .find((l) => l.includes("Running a server on"));
75
+ if (!line) return null;
76
+ const m = line.match(/Running a server on \['([^']+)'\]/);
77
+ return m ? m[1] : null;
78
+ }
79
+
27
80
  export function which(cmd) {
28
81
  try {
29
82
  const out = execFileSync(process.platform === "win32" ? "where" : "which", [cmd], {
package/src/wizard.js CHANGED
@@ -5,7 +5,12 @@ import path from "node:path";
5
5
  import { spawn } from "node:child_process";
6
6
  import { DEFAULT_DISCOVERY_URL, HOME_DIR, SHARD_PORT, PETALS_PORT } from "./config.js";
7
7
  import { listMothers, motherHosts, joinHost, readyHost, leaveHost } from "./api.js";
8
- import { detectPublicIp } from "./net.js";
8
+ import {
9
+ detectPublicIp,
10
+ detectLocalIp,
11
+ discoverNgrokTcpAnnounce,
12
+ extractPeerMultiaddrFromLog,
13
+ } from "./net.js";
9
14
  import {
10
15
  ensureVenvAndPetals,
11
16
  startShardManagerProcess,
@@ -13,6 +18,7 @@ import {
13
18
  venvPython,
14
19
  } from "./install.js";
15
20
  import { saveState, loadState, clearState, ensureHome } from "./state.js";
21
+ import { startContributorHeartbeatDaemon } from "./heartbeat.js";
16
22
 
17
23
  function parseArgs(argv) {
18
24
  const out = { cmd: null, discoveryUrl: process.env.BLITZWING_DISCOVERY_URL || DEFAULT_DISCOVERY_URL };
@@ -55,6 +61,9 @@ ${color.bold("blitzwing")} — join a Blitzwing mother swarm as a compute node
55
61
 
56
62
  Env:
57
63
  BLITZWING_DISCOVERY_URL Override discovery service (default ${DEFAULT_DISCOVERY_URL})
64
+ BLITZWING_ANNOUNCE_MADDRS Explicit Petals announce multiaddr (VM with public IP)
65
+ BLITZWING_LOCAL_IP Local IP for shard manager metadata
66
+ PETALS_USE_AUTO_RELAY=0 Disable libp2p auto-relay (use with public IP / port-forward)
58
67
  `);
59
68
  }
60
69
 
@@ -130,18 +139,70 @@ async function wizard(args) {
130
139
  const layersN = Number(layers);
131
140
 
132
141
  const detected = await detectPublicIp();
133
- const publicIp = await p.text({
134
- message: "Public IP for this machine (other peers must reach you)",
135
- initialValue: detected || "",
136
- validate(v) {
137
- if (!v || !v.trim()) return "Public IP is required";
138
- },
142
+ const networkMode = await p.select({
143
+ message: "How will other peers reach your Petals node?",
144
+ options: [
145
+ {
146
+ value: "relay",
147
+ label: "Auto (recommended for home / NAT)",
148
+ hint: "Uses Petals libp2p relay — no port forwarding",
149
+ },
150
+ {
151
+ value: "public",
152
+ label: "Public IP / cloud VM",
153
+ hint: "TCP 31337 (and 8001) open on the internet",
154
+ },
155
+ {
156
+ value: "ngrok",
157
+ label: "Dev tunnel (ngrok TCP on :4040)",
158
+ hint: "Only if ngrok is already running",
159
+ },
160
+ ],
161
+ initialValue: "relay",
139
162
  });
140
- if (p.isCancel(publicIp)) {
163
+ if (p.isCancel(networkMode)) {
141
164
  p.cancel("Setup cancelled");
142
165
  process.exit(0);
143
166
  }
144
167
 
168
+ let publicIp = "";
169
+ let announceMaddrs = "";
170
+ let useAutoRelay = "1";
171
+
172
+ if (networkMode === "public") {
173
+ publicIp = await p.text({
174
+ message: "Public IPv4 (must be reachable on TCP " + PETALS_PORT + ")",
175
+ initialValue: detected || "",
176
+ validate(v) {
177
+ if (!v || !/^\d+\.\d+\.\d+\.\d+$/.test(String(v).trim())) {
178
+ return "Enter a valid public IPv4 address";
179
+ }
180
+ },
181
+ });
182
+ if (p.isCancel(publicIp)) {
183
+ p.cancel("Setup cancelled");
184
+ process.exit(0);
185
+ }
186
+ publicIp = String(publicIp).trim();
187
+ announceMaddrs = `/ip4/${publicIp}/tcp/${PETALS_PORT}`;
188
+ useAutoRelay = "0";
189
+ } else if (networkMode === "ngrok") {
190
+ const ngrok = await discoverNgrokTcpAnnounce(PETALS_PORT);
191
+ if (!ngrok) {
192
+ p.cancel("No ngrok TCP tunnel on http://127.0.0.1:4040. Start: ngrok tcp " + PETALS_PORT);
193
+ process.exit(1);
194
+ }
195
+ announceMaddrs = ngrok;
196
+ publicIp = ngrok.match(/dns4\/([^/]+)/)?.[1] || detected || "relay";
197
+ useAutoRelay = "0";
198
+ p.log.info(`Using ngrok announce ${announceMaddrs}`);
199
+ } else {
200
+ publicIp = detected || "relay";
201
+ p.log.info("Using Petals libp2p auto-relay (no inbound port forward required).");
202
+ }
203
+
204
+ const localIp = await detectLocalIp();
205
+
145
206
  const hederaAccount = await p.text({
146
207
  message: "Hedera account ID for layer payouts (e.g. 0.0.123456)",
147
208
  initialValue: process.env.BLITZWING_HEDERA_ACCOUNT_ID || "",
@@ -157,7 +218,7 @@ async function wizard(args) {
157
218
  const hederaAccountId = String(hederaAccount).trim();
158
219
 
159
220
  const confirm = await p.confirm({
160
- message: `Join ${selected.model} hosting ${layersN} layers from ${String(publicIp).trim()} paying to ${hederaAccountId}?`,
221
+ message: `Join ${selected.model} hosting ${layersN} layers (${networkMode}) paying to ${hederaAccountId}?`,
161
222
  initialValue: true,
162
223
  });
163
224
  if (p.isCancel(confirm) || !confirm) {
@@ -180,7 +241,8 @@ async function wizard(args) {
180
241
  const python = venvPython();
181
242
  spin.stop("Environment ready");
182
243
 
183
- const shardManagerUrl = `http://${String(publicIp).trim()}:${SHARD_PORT}`;
244
+ const shardManagerUrl = `http://${localIp}:${SHARD_PORT}`;
245
+ const petalsLogPath = path.join(HOME_DIR, "petals.log");
184
246
 
185
247
  spin.start("Requesting layer assignment from mother…");
186
248
  let assignment;
@@ -202,22 +264,28 @@ async function wizard(args) {
202
264
 
203
265
  const logPath = path.join(HOME_DIR, "shard_manager.log");
204
266
  spin.start("Starting local Petals server…");
205
- const { pid } = startShardManagerProcess({
206
- python,
207
- logPath,
208
- env: {
267
+ const shardEnv = {
209
268
  MODEL_NAME: selected.model,
210
- PUBLIC_IP: String(publicIp).trim(),
269
+ PUBLIC_IP: announceMaddrs ? "" : String(publicIp).trim(),
211
270
  BLOCK_INDICES: assignment.block_indices,
212
271
  INITIAL_PEERS: (assignment.initial_peers || []).join(","),
213
272
  PETALS_PORT: String(PETALS_PORT),
214
273
  IDENTITY_PATH: path.join(HOME_DIR, "petals-identity"),
215
274
  SHARD_AUTO_START: "1",
216
- },
275
+ PETALS_USE_AUTO_RELAY: useAutoRelay,
276
+ PETALS_SKIP_REACHABILITY_CHECK: "1",
277
+ };
278
+ if (announceMaddrs) {
279
+ shardEnv.ANNOUNCE_MADDRS = announceMaddrs;
280
+ }
281
+ const { pid } = startShardManagerProcess({
282
+ python,
283
+ logPath,
284
+ env: shardEnv,
217
285
  });
218
286
 
219
287
  try {
220
- await waitForShardRunning({ timeoutMs: 600000, statusHost: String(publicIp).trim() });
288
+ await waitForShardRunning({ timeoutMs: 600000, statusHost: "127.0.0.1" });
221
289
  } catch (err) {
222
290
  spin.stop("Petals did not become ready");
223
291
  p.cancel(`${err.message}. See ${logPath}`);
@@ -226,8 +294,12 @@ async function wizard(args) {
226
294
  spin.stop("Petals is serving your layers");
227
295
 
228
296
  spin.start("Finalizing handoff with mother…");
297
+ const peerMultiaddr = extractPeerMultiaddrFromLog(petalsLogPath, fs);
229
298
  try {
230
- await readyHost(selected.mother_url, { host_id: assignment.host_id });
299
+ await readyHost(selected.mother_url, {
300
+ host_id: assignment.host_id,
301
+ peer_multiaddr: peerMultiaddr || undefined,
302
+ });
231
303
  } catch (err) {
232
304
  spin.stop("Handoff failed");
233
305
  p.cancel(err.message);
@@ -242,6 +314,8 @@ async function wizard(args) {
242
314
  block_indices: assignment.block_indices,
243
315
  layers_hosted: assignment.layers_hosted,
244
316
  public_ip: String(publicIp).trim(),
317
+ network_mode: networkMode,
318
+ announce_maddrs: announceMaddrs || null,
245
319
  shard_manager_url: shardManagerUrl,
246
320
  shard_pid: pid,
247
321
  discovery_url: args.discoveryUrl,
@@ -249,7 +323,10 @@ async function wizard(args) {
249
323
  joined_at: new Date().toISOString(),
250
324
  };
251
325
  saveState(state);
252
- startHeartbeatDaemon(state);
326
+ startContributorHeartbeatDaemon({
327
+ motherUrl: state.mother_url,
328
+ hostId: state.host_id,
329
+ });
253
330
 
254
331
  p.outro(
255
332
  `${color.green("You are online.")} Hosting ${color.cyan(String(state.layers_hosted))} layers of ${color.cyan(state.model)} at ${state.block_indices}\n` +
@@ -257,32 +334,6 @@ async function wizard(args) {
257
334
  );
258
335
  }
259
336
 
260
- function startHeartbeatDaemon(state) {
261
- const hbPath = path.join(HOME_DIR, "heartbeat.mjs");
262
- fs.writeFileSync(
263
- hbPath,
264
- `const mother = ${JSON.stringify(state.mother_url)};
265
- const hostId = ${JSON.stringify(state.host_id)};
266
- async function beat() {
267
- try {
268
- await fetch(mother.replace(/\\/$/, "") + "/v1/hosts/heartbeat", {
269
- method: "POST",
270
- headers: { "Content-Type": "application/json" },
271
- body: JSON.stringify({ host_id: hostId }),
272
- });
273
- } catch {}
274
- }
275
- setInterval(beat, 60000);
276
- beat();
277
- `
278
- );
279
- const child = spawn(process.execPath, [hbPath], {
280
- detached: true,
281
- stdio: "ignore",
282
- });
283
- child.unref();
284
- }
285
-
286
337
  async function showStatus() {
287
338
  const state = loadState();
288
339
  if (!state) {
@@ -297,7 +348,7 @@ async function showStatus() {
297
348
  console.log(` mother: ${state.mother_url}`);
298
349
  console.log(` public_ip: ${state.public_ip}`);
299
350
  try {
300
- const statusHost = state.public_ip || "127.0.0.1";
351
+ const statusHost = "127.0.0.1";
301
352
  const res = await fetch(`http://${statusHost}:${SHARD_PORT}/status`);
302
353
  if (res.ok) {
303
354
  const st = await res.json();
@@ -325,7 +376,7 @@ async function doLeave() {
325
376
  p.log.warn(`Mother leave call failed: ${err.message}`);
326
377
  }
327
378
  try {
328
- const statusHost = state.public_ip || "127.0.0.1";
379
+ const statusHost = "127.0.0.1";
329
380
  await fetch(`http://${statusHost}:${SHARD_PORT}/stop`, { method: "POST" });
330
381
  } catch {
331
382
  /* ignore */