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 +1 -1
- package/src/heartbeat.js +64 -0
- package/src/install.js +12 -2
- package/src/net.js +53 -0
- package/src/wizard.js +98 -47
package/package.json
CHANGED
package/src/heartbeat.js
ADDED
|
@@ -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 {
|
|
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
|
|
134
|
-
message: "
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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(
|
|
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
|
|
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://${
|
|
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
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
379
|
+
const statusHost = "127.0.0.1";
|
|
329
380
|
await fetch(`http://${statusHost}:${SHARD_PORT}/stop`, { method: "POST" });
|
|
330
381
|
} catch {
|
|
331
382
|
/* ignore */
|