infinicode 2.8.54 → 2.8.57
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/dist/cli.js +1 -0
- package/dist/commands/serve.d.ts +1 -0
- package/dist/commands/serve.js +1 -0
- package/dist/kernel/config-schema.d.ts +2 -0
- package/dist/kernel/federation/dashboard-html.d.ts +1 -1
- package/dist/kernel/federation/dashboard-html.js +40 -16
- package/dist/kernel/federation/federation.d.ts +5 -0
- package/dist/kernel/federation/federation.js +41 -2
- package/dist/kernel/federation/peer-mesh.d.ts +3 -1
- package/dist/kernel/federation/peer-mesh.js +16 -0
- package/dist/robopark/auto-start.d.ts +1 -1
- package/dist/robopark/auto-start.js +29 -3
- package/dist/robopark/robot-runtime.d.ts +14 -0
- package/dist/robopark/robot-runtime.js +219 -0
- package/dist/robopark/setup.js +25 -112
- package/dist/robopark/stop-all.js +3 -0
- package/dist/robopark-cli.js +37 -0
- package/package.json +1 -1
- package/packages/robopark/scheduler/main.py +68 -29
- package/packages/robopark/scheduler/preview_agent.py +21 -13
|
@@ -1324,9 +1324,21 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1324
1324
|
var rows = data[0], devices = data[1];
|
|
1325
1325
|
var deviceById = {}, deviceByName = {};
|
|
1326
1326
|
function norm(v){ return String(v||'').trim().toLowerCase(); }
|
|
1327
|
+
// A stale re-enrollment can leave more than one row with the same
|
|
1328
|
+
// display name. Keep the live/fresh device, never whichever SQLite
|
|
1329
|
+
// happened to return last, because all operator routes are id-keyed.
|
|
1330
|
+
function deviceScore(device){
|
|
1331
|
+
var status=norm(device&&device.status), at=Date.parse(device&&device.last_heartbeat||''), score=0;
|
|
1332
|
+
if(status==='online'||status==='running'||status==='detecting'||status==='connecting') score+=10000;
|
|
1333
|
+
if(Number.isFinite(at)) score+=Math.max(0, Math.floor(at/1000));
|
|
1334
|
+
return score;
|
|
1335
|
+
}
|
|
1327
1336
|
(Array.isArray(devices)?devices:[]).forEach(function(device){
|
|
1328
1337
|
deviceById[device.id] = device;
|
|
1329
|
-
if(device.name)
|
|
1338
|
+
if(device.name){
|
|
1339
|
+
var key=norm(device.name), current=deviceByName[key];
|
|
1340
|
+
if(!current || deviceScore(device)>deviceScore(current)) deviceByName[key] = device;
|
|
1341
|
+
}
|
|
1330
1342
|
});
|
|
1331
1343
|
schedulerDevicesById = deviceById;
|
|
1332
1344
|
schedulerDevicesByName = deviceByName;
|
|
@@ -1336,7 +1348,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
1336
1348
|
// legacy robots table has not been populated for that device yet.
|
|
1337
1349
|
(Array.isArray(devices)?devices:[]).forEach(function(device){
|
|
1338
1350
|
var already = candidates.some(function(row){
|
|
1339
|
-
return norm(row.id||row.robot_id)===norm(device.id)
|
|
1351
|
+
return norm(row.id||row.robot_id)===norm(device.id);
|
|
1340
1352
|
});
|
|
1341
1353
|
if(!already) candidates.push({id:device.id, name:device.name});
|
|
1342
1354
|
});
|
|
@@ -5313,8 +5325,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5313
5325
|
// name, which is not necessarily the scheduler's robots.id (e.g. an
|
|
5314
5326
|
// enrolled device's id is a generated dev_xxxx, distinct from its name).
|
|
5315
5327
|
// Falls back to n.name if telemetry hasn't matched a row yet.
|
|
5316
|
-
function schedRow(n){
|
|
5317
|
-
var
|
|
5328
|
+
function schedRow(n){
|
|
5329
|
+
var preferred=schedulerDevice(n), preferredId=preferred&&preferred.id;
|
|
5330
|
+
var row=(preferredId&&roboparkByName[String(preferredId).toLowerCase()]) || roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase(),String(preferredId||'').toLowerCase()]);
|
|
5318
5331
|
var seen={}, candidates=[];
|
|
5319
5332
|
for(var rk in roboparkByName){
|
|
5320
5333
|
var candidate=roboparkByName[rk], cid=candidate&&(candidate.robot_id||candidate.id);
|
|
@@ -5324,8 +5337,9 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5324
5337
|
for(var ki=0;ki<keys.length;ki++){ var key=String(keys[ki]||'').toLowerCase(); if(key&&(label===key||idlabel===key||label.indexOf(key)>-1||key.indexOf(label)>-1)){ matches=true; break; } }
|
|
5325
5338
|
if(matches) candidates.push(candidate);
|
|
5326
5339
|
}
|
|
5327
|
-
function readyScore(candidate){
|
|
5328
|
-
var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
|
|
5340
|
+
function readyScore(candidate){
|
|
5341
|
+
var device=schedulerDevicesById[candidate.robot_id||candidate.id]||{}, status=String(candidate.device_status||device.status||'').toLowerCase(), score=0;
|
|
5342
|
+
if(preferredId && String(candidate.robot_id||candidate.id||'')===String(preferredId)) score+=100000;
|
|
5329
5343
|
if(candidate.production_mode===true||device.production_mode===true) score+=1000;
|
|
5330
5344
|
if(status==='online'||status==='running'||status==='detecting') score+=500;
|
|
5331
5345
|
if(candidate.readiness_code==='ready') score+=250;
|
|
@@ -5335,12 +5349,14 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5335
5349
|
candidates.sort(function(a,b){ return readyScore(b)-readyScore(a); });
|
|
5336
5350
|
return candidates[0]||row;
|
|
5337
5351
|
}
|
|
5338
|
-
function schedId(n){
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
var
|
|
5342
|
-
|
|
5343
|
-
|
|
5352
|
+
function schedId(n){
|
|
5353
|
+
// Device ids are authoritative. A legacy robots row may share the
|
|
5354
|
+
// name but have no preview, heartbeat, or hardware inventory.
|
|
5355
|
+
var device=schedulerDevice(n); if(device&&device.id) return device.id;
|
|
5356
|
+
var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
|
|
5357
|
+
if(n.schedulerRobotId) return n.schedulerRobotId;
|
|
5358
|
+
return n.name;
|
|
5359
|
+
}
|
|
5344
5360
|
function closeDrawer(){
|
|
5345
5361
|
// tell the scheduler to end any live preview so the robot stops its cam
|
|
5346
5362
|
if(previewOn && drawerId){ var pn=byId[drawerId]; if(pn){ try{ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(pn))+'/preview/stop'+QS,{method:'POST',keepalive:true}); }catch(e){} } }
|
|
@@ -5474,10 +5490,18 @@ button.act:disabled{opacity:0.5;cursor:default;}
|
|
|
5474
5490
|
refreshDrawer();
|
|
5475
5491
|
}).catch(function(){});
|
|
5476
5492
|
}
|
|
5477
|
-
function schedulerDevice(n){
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5493
|
+
function schedulerDevice(n){
|
|
5494
|
+
// Deliberately does not call schedRow: telemetry can contain a legacy
|
|
5495
|
+
// robot name, while this lookup must choose the current enrolled device.
|
|
5496
|
+
var ids=[n.schedulerDeviceId,n.schedulerRobotId,n.id,n.nodeId];
|
|
5497
|
+
for(var i=0;i<ids.length;i++) if(ids[i]&&schedulerDevicesById[ids[i]]) return schedulerDevicesById[ids[i]];
|
|
5498
|
+
var names=[n.name,n.displayName,n.label];
|
|
5499
|
+
for(var j=0;j<names.length;j++){
|
|
5500
|
+
var key=String(names[j]||'').toLowerCase();
|
|
5501
|
+
if(key&&schedulerDevicesByName[key]) return schedulerDevicesByName[key];
|
|
5502
|
+
}
|
|
5503
|
+
return null;
|
|
5504
|
+
}
|
|
5481
5505
|
function deviceList(inv, key, fallback){
|
|
5482
5506
|
var list=inv&&Array.isArray(inv[key])?inv[key]:[];
|
|
5483
5507
|
return list.length?list:fallback.map(function(id){ return {id:id,name:id}; });
|
|
@@ -54,6 +54,8 @@ export interface FederationOptions {
|
|
|
54
54
|
/** Base URLs of peers to connect to on start (static seeds). */
|
|
55
55
|
seeds?: string[];
|
|
56
56
|
autoUpdate?: boolean;
|
|
57
|
+
/** Hub/relay npm-release polling interval. Defaults to one minute. */
|
|
58
|
+
updatePollMs?: number;
|
|
57
59
|
/** Satellite: how to apply an incoming shared config (register providers etc.). */
|
|
58
60
|
applyConfig?: (cfg: SharedConfig) => void | Promise<void>;
|
|
59
61
|
/** Satellite: auto-pull shared config from any hub/relay we connect to. */
|
|
@@ -133,6 +135,7 @@ export declare class Federation {
|
|
|
133
135
|
/** nodeIds we're currently dialing back after an inbound heartbeat/hello. */
|
|
134
136
|
private inboundConnecting;
|
|
135
137
|
private configPullTimer?;
|
|
138
|
+
private updatePollTimer?;
|
|
136
139
|
readonly configSync: ConfigSync;
|
|
137
140
|
readonly autoUpdate: AutoUpdate;
|
|
138
141
|
readonly compute: ComputeRouter;
|
|
@@ -259,6 +262,8 @@ export declare class Federation {
|
|
|
259
262
|
private maybeAutoPull;
|
|
260
263
|
/** Satellite: re-pull from the first connected hub/relay (periodic refresh). */
|
|
261
264
|
private autoPullFromHub;
|
|
265
|
+
/** Fetch npm's latest tag, announce it to satellites, then update this hub. */
|
|
266
|
+
private pollNpmRelease;
|
|
262
267
|
/** Auto-discover peers over Tailscale and connect to each (skips self/known). */
|
|
263
268
|
discoverAndConnect(tag?: string): Promise<number>;
|
|
264
269
|
/**
|
|
@@ -3,7 +3,7 @@ import { DASHBOARD_HTML } from './dashboard-html.js';
|
|
|
3
3
|
import { PeerMesh } from './peer-mesh.js';
|
|
4
4
|
import { EventBridge } from './event-bridge.js';
|
|
5
5
|
import { ConfigSync } from './config-sync.js';
|
|
6
|
-
import { AutoUpdate } from './auto-update.js';
|
|
6
|
+
import { AutoUpdate, compareVersions } from './auto-update.js';
|
|
7
7
|
import { performSelfUpdate } from './self-update.js';
|
|
8
8
|
import { ComputeRouter } from './compute-router.js';
|
|
9
9
|
import { TelemetryCollector, loadFromSnapshot, selfNodeStatus, nodeStatusFromPeer } from './telemetry.js';
|
|
@@ -32,6 +32,7 @@ export class Federation {
|
|
|
32
32
|
/** nodeIds we're currently dialing back after an inbound heartbeat/hello. */
|
|
33
33
|
inboundConnecting = new Set();
|
|
34
34
|
configPullTimer;
|
|
35
|
+
updatePollTimer;
|
|
35
36
|
configSync;
|
|
36
37
|
autoUpdate;
|
|
37
38
|
compute = new ComputeRouter();
|
|
@@ -134,6 +135,15 @@ export class Federation {
|
|
|
134
135
|
if (options.autoPullConfig && options.configPullMs !== 0) {
|
|
135
136
|
this.configPullTimer = setInterval(() => void this.autoPullFromHub(), options.configPullMs ?? 30_000);
|
|
136
137
|
}
|
|
138
|
+
// The hub owns release discovery. Satellites never poll npm: they only
|
|
139
|
+
// trust signed mesh announcements from a hub/relay. This makes npm publish
|
|
140
|
+
// -> fleet update a real loop, while retaining the --auto-update opt-in.
|
|
141
|
+
if (options.autoUpdate && (identity.role === 'hub' || identity.role === 'relay')) {
|
|
142
|
+
const pollMs = Math.max(30_000, options.updatePollMs ?? 60_000);
|
|
143
|
+
void this.pollNpmRelease();
|
|
144
|
+
this.updatePollTimer = setInterval(() => void this.pollNpmRelease(), pollMs);
|
|
145
|
+
logger.info(`[federation] npm release watcher enabled (every ${Math.round(pollMs / 1000)}s)`);
|
|
146
|
+
}
|
|
137
147
|
logger.info(`[federation] node ${identity.displayName} (${identity.role}) online on :${port}`);
|
|
138
148
|
}
|
|
139
149
|
// ── Inbound RPC ────────────────────────────────────────────────────────────
|
|
@@ -172,7 +182,10 @@ export class Federation {
|
|
|
172
182
|
return reply(env, 'ack', self);
|
|
173
183
|
case 'update.announce': {
|
|
174
184
|
const peer = this.mesh?.get(env.from);
|
|
175
|
-
|
|
185
|
+
// Acknowledge immediately. npm can take minutes on a Pi, while the
|
|
186
|
+
// sender's RPC timeout is intentionally short; the update continues
|
|
187
|
+
// in the background and exits the supervised mesh process on success.
|
|
188
|
+
void this.autoUpdate.onAnnounce(env.data, peer?.role ?? 'peer');
|
|
176
189
|
return reply(env, 'ack', self);
|
|
177
190
|
}
|
|
178
191
|
case 'role.get':
|
|
@@ -625,6 +638,30 @@ export class Federation {
|
|
|
625
638
|
if (hub)
|
|
626
639
|
await this.maybeAutoPull(hub);
|
|
627
640
|
}
|
|
641
|
+
/** Fetch npm's latest tag, announce it to satellites, then update this hub. */
|
|
642
|
+
async pollNpmRelease() {
|
|
643
|
+
try {
|
|
644
|
+
const response = await fetch('https://registry.npmjs.org/infinicode/latest', {
|
|
645
|
+
signal: AbortSignal.timeout(10_000),
|
|
646
|
+
});
|
|
647
|
+
if (!response.ok)
|
|
648
|
+
throw new Error(`npm registry ${response.status}`);
|
|
649
|
+
const payload = await response.json();
|
|
650
|
+
const version = typeof payload.version === 'string' ? payload.version.trim() : '';
|
|
651
|
+
if (!/^\d+(?:\.\d+){1,3}(?:[-+][A-Za-z0-9.-]+)?$/.test(version)) {
|
|
652
|
+
throw new Error('npm registry returned an invalid version');
|
|
653
|
+
}
|
|
654
|
+
await this.mesh?.announceUpdate({ version, channel: 'npm' });
|
|
655
|
+
const current = this.autoUpdate.currentVersion();
|
|
656
|
+
if (compareVersions(version, current) > 0) {
|
|
657
|
+
this.deps.logger.info(`[federation] npm release ${version} announced to fleet; updating ${current} -> ${version}`);
|
|
658
|
+
await this.autoUpdate.onAnnounce({ version, channel: 'npm' }, this.deps.identity.role);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch (err) {
|
|
662
|
+
this.deps.logger.warn(`[federation] npm release check failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
628
665
|
/** Auto-discover peers over Tailscale and connect to each (skips self/known). */
|
|
629
666
|
async discoverAndConnect(tag) {
|
|
630
667
|
const port = this.deps.options.port ?? DEFAULT_MESH_PORT;
|
|
@@ -752,6 +789,8 @@ export class Federation {
|
|
|
752
789
|
this.lan?.stop();
|
|
753
790
|
if (this.configPullTimer)
|
|
754
791
|
clearInterval(this.configPullTimer);
|
|
792
|
+
if (this.updatePollTimer)
|
|
793
|
+
clearInterval(this.updatePollTimer);
|
|
755
794
|
if (this.telemetryTimer)
|
|
756
795
|
clearInterval(this.telemetryTimer);
|
|
757
796
|
this.mesh?.stop();
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* manage (satellites are accept-only). A satellite never dials us.
|
|
11
11
|
*/
|
|
12
12
|
import type { Logger } from '../types.js';
|
|
13
|
-
import type { FederationEnvelope, NodeIdentity, PeerInfo, StreamFrame } from './types.js';
|
|
13
|
+
import type { FederationEnvelope, NodeIdentity, PeerInfo, StreamFrame, UpdateAnnounce } from './types.js';
|
|
14
14
|
import { MeshClient } from './transport-http.js';
|
|
15
15
|
export interface PeerMeshOptions {
|
|
16
16
|
self: NodeIdentity;
|
|
@@ -38,6 +38,8 @@ export declare class PeerMesh {
|
|
|
38
38
|
connected(): PeerInfo[];
|
|
39
39
|
/** Discover + attach a peer at a base URL (manifest probe + open its stream). */
|
|
40
40
|
connect(url: string): Promise<PeerInfo | null>;
|
|
41
|
+
/** Send a hub/relay version announcement to every currently connected peer. */
|
|
42
|
+
announceUpdate(announce: UpdateAnnounce): Promise<void>;
|
|
41
43
|
private openStream;
|
|
42
44
|
start(): void;
|
|
43
45
|
private tick;
|
|
@@ -22,6 +22,11 @@ export class PeerMesh {
|
|
|
22
22
|
async connect(url) {
|
|
23
23
|
try {
|
|
24
24
|
const manifest = await this.opts.client.fetchManifest(url);
|
|
25
|
+
// A hub may discover or seed its own advertised address. Never add self
|
|
26
|
+
// as a peer: it duplicates the hub in fleet status and can create a
|
|
27
|
+
// pointless self-stream/update loop.
|
|
28
|
+
if (manifest.nodeId === this.opts.self.nodeId)
|
|
29
|
+
return null;
|
|
25
30
|
const peer = {
|
|
26
31
|
nodeId: manifest.nodeId,
|
|
27
32
|
displayName: manifest.displayName,
|
|
@@ -48,6 +53,17 @@ export class PeerMesh {
|
|
|
48
53
|
return null;
|
|
49
54
|
}
|
|
50
55
|
}
|
|
56
|
+
/** Send a hub/relay version announcement to every currently connected peer. */
|
|
57
|
+
async announceUpdate(announce) {
|
|
58
|
+
await Promise.all(this.connected().map(async (peer) => {
|
|
59
|
+
try {
|
|
60
|
+
await this.opts.client.rpc(peer.url, envelope('update.announce', this.opts.self.nodeId, announce, { to: peer.nodeId }));
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
this.opts.logger.warn(`[federation] update announce to ${peer.displayName} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
64
|
+
}
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
51
67
|
openStream(peer) {
|
|
52
68
|
this.streams.get(peer.nodeId)?.(); // close any prior
|
|
53
69
|
const stop = this.opts.client.openStream(peer.url, f => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export interface ServiceArgs {
|
|
2
|
-
role: 'hub' | 'robot' | 'control' | 'hub-scheduler' | 'robot-preview-agent' | 'robot-vision-agent';
|
|
2
|
+
role: 'hub' | 'robot' | 'control' | 'hub-scheduler' | 'robot-runtime' | 'robot-preview-agent' | 'robot-vision-agent';
|
|
3
3
|
name: string;
|
|
4
4
|
command: string;
|
|
5
5
|
args: string[];
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* This is used by `robopark setup --auto-start`.
|
|
10
10
|
*/
|
|
11
|
-
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
11
|
+
import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
|
12
12
|
import { homedir } from 'node:os';
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
|
-
import { spawn } from 'node:child_process';
|
|
14
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
15
15
|
function shellQuote(s) {
|
|
16
16
|
if (!/[^a-zA-Z0-9_./:=,-]/.test(s))
|
|
17
17
|
return s;
|
|
@@ -138,5 +138,31 @@ export function unregisterAutoStart(role, name) {
|
|
|
138
138
|
return { ok: false, message: `failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
|
|
141
|
+
if (platform === 'linux') {
|
|
142
|
+
const unitName = `robopark-${role}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}.service`;
|
|
143
|
+
const unitPath = `/etc/systemd/system/${unitName}`;
|
|
144
|
+
const stopped = spawnSync('systemctl', ['disable', '--now', unitName], { encoding: 'utf8' });
|
|
145
|
+
try {
|
|
146
|
+
if (existsSync(unitPath))
|
|
147
|
+
rmSync(unitPath);
|
|
148
|
+
}
|
|
149
|
+
catch { /* systemd output is more useful below */ }
|
|
150
|
+
spawnSync('systemctl', ['daemon-reload'], { stdio: 'ignore' });
|
|
151
|
+
return {
|
|
152
|
+
ok: stopped.status === 0 || !existsSync(unitPath),
|
|
153
|
+
message: `removed systemd unit ${unitName}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (platform === 'darwin') {
|
|
157
|
+
const label = `ai.robopark.${role}.${name}`;
|
|
158
|
+
const plistPath = join(homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
|
|
159
|
+
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
160
|
+
try {
|
|
161
|
+
if (existsSync(plistPath))
|
|
162
|
+
rmSync(plistPath);
|
|
163
|
+
}
|
|
164
|
+
catch { /* ignore */ }
|
|
165
|
+
return { ok: true, message: `removed launchd plist ${plistPath}` };
|
|
166
|
+
}
|
|
167
|
+
return { ok: false, message: `unregister not supported on ${platform}` };
|
|
142
168
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RobotRuntimeOptions {
|
|
2
|
+
name: string;
|
|
3
|
+
hubUrl: string;
|
|
4
|
+
schedulerUrl: string;
|
|
5
|
+
token: string;
|
|
6
|
+
port?: string;
|
|
7
|
+
network?: 'lan' | 'tailscale';
|
|
8
|
+
enrollmentToken?: string;
|
|
9
|
+
videoDevice?: string;
|
|
10
|
+
audioDevice?: string;
|
|
11
|
+
vision?: boolean;
|
|
12
|
+
foreground?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function roboparkRobotRuntime(opts: RobotRuntimeOptions): Promise<void>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark robot runtime.
|
|
3
|
+
*
|
|
4
|
+
* One long-lived parent for a robot's mesh satellite, RoboVision camera/audio
|
|
5
|
+
* service, and preview agent. Starting these as unrelated detached commands
|
|
6
|
+
* allowed a healthy-looking mesh node with no motion service or no device
|
|
7
|
+
* inventory. The runtime starts dependencies in order and restarts a child if
|
|
8
|
+
* it exits unexpectedly.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { resolveInfinicodeBin } from './serve.js';
|
|
17
|
+
const VISION_URL = 'http://127.0.0.1:5000/api/media/inventory';
|
|
18
|
+
const RESTART_DELAY_MS = 5_000;
|
|
19
|
+
function resetStaleEnrollment() {
|
|
20
|
+
const configDir = join(homedir(), '.robopark');
|
|
21
|
+
const tokenPath = join(configDir, 'device_token');
|
|
22
|
+
const configPath = join(configDir, 'preview_agent.json');
|
|
23
|
+
try {
|
|
24
|
+
if (existsSync(tokenPath))
|
|
25
|
+
rmSync(tokenPath);
|
|
26
|
+
}
|
|
27
|
+
catch { /* enrollment will report a real error */ }
|
|
28
|
+
if (!existsSync(configPath))
|
|
29
|
+
return;
|
|
30
|
+
try {
|
|
31
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
32
|
+
delete config.device_id;
|
|
33
|
+
delete config.device_token;
|
|
34
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A malformed optional config must not block first boot.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function roboparkCliPath() {
|
|
41
|
+
// dist/robopark/robot-runtime.js -> package root -> dist/robopark-cli.js
|
|
42
|
+
const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
|
|
43
|
+
const compiledCli = join(packageRoot, 'dist', 'robopark-cli.js');
|
|
44
|
+
return existsSync(compiledCli) ? compiledCli : process.argv[1];
|
|
45
|
+
}
|
|
46
|
+
function runtimeLog(label, foreground) {
|
|
47
|
+
if (foreground)
|
|
48
|
+
return 'inherit';
|
|
49
|
+
const logDir = join(homedir(), '.robopark', 'logs');
|
|
50
|
+
mkdirSync(logDir, { recursive: true });
|
|
51
|
+
return [
|
|
52
|
+
'ignore',
|
|
53
|
+
// spawn() accepts file descriptors, but not WriteStreams before their
|
|
54
|
+
// asynchronous open event. openSync makes detached startup reliable.
|
|
55
|
+
openSync(join(logDir, `${label}.log`), 'a'),
|
|
56
|
+
openSync(join(logDir, `${label}.err.log`), 'a'),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
async function waitForVision() {
|
|
60
|
+
const deadline = Date.now() + 120_000;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
try {
|
|
63
|
+
const response = await fetch(VISION_URL, { signal: AbortSignal.timeout(1_500) });
|
|
64
|
+
if (response.ok)
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Dependency installation on a fresh Pi can take a little while.
|
|
69
|
+
}
|
|
70
|
+
await new Promise(resolve => setTimeout(resolve, 1_000));
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
export async function roboparkRobotRuntime(opts) {
|
|
75
|
+
const port = opts.port ?? '47913';
|
|
76
|
+
const network = opts.network ?? 'lan';
|
|
77
|
+
const foreground = opts.foreground === true;
|
|
78
|
+
const cli = roboparkCliPath();
|
|
79
|
+
const children = [];
|
|
80
|
+
let stopping = false;
|
|
81
|
+
let recycling = false;
|
|
82
|
+
const infinicode = await resolveInfinicodeBin();
|
|
83
|
+
if (!infinicode)
|
|
84
|
+
throw new Error('could not resolve infinicode CLI');
|
|
85
|
+
children.push({
|
|
86
|
+
label: 'mesh',
|
|
87
|
+
command: infinicode.node,
|
|
88
|
+
args: [infinicode.script, 'serve', '--role', 'satellite', '--name', opts.name,
|
|
89
|
+
'--port', port, '--token', opts.token, '--seed', opts.hubUrl,
|
|
90
|
+
network === 'tailscale' ? '--tailscale' : '--lan', '--auto-update', '--supervised'],
|
|
91
|
+
});
|
|
92
|
+
if (opts.vision !== false) {
|
|
93
|
+
children.push({
|
|
94
|
+
label: 'vision',
|
|
95
|
+
command: process.execPath,
|
|
96
|
+
args: [cli, 'vision-agent', '--foreground', '--port', '5000',
|
|
97
|
+
'--motion-webhook-url', 'http://127.0.0.1:5057/'],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const previewArgs = [cli, 'preview-agent', '--foreground',
|
|
101
|
+
'--scheduler-url', opts.schedulerUrl, '--robot-id', opts.name,
|
|
102
|
+
'--video-device', opts.videoDevice ?? 'auto', '--audio-device', opts.audioDevice ?? 'default',
|
|
103
|
+
'--save-config'];
|
|
104
|
+
if (opts.vision !== false)
|
|
105
|
+
previewArgs.push('--robovision-url', 'http://127.0.0.1:5000');
|
|
106
|
+
if (opts.enrollmentToken)
|
|
107
|
+
previewArgs.push('--enrollment-token', opts.enrollmentToken);
|
|
108
|
+
const preview = { label: 'preview', command: process.execPath, args: previewArgs };
|
|
109
|
+
const start = (entry) => {
|
|
110
|
+
const child = spawn(entry.command, entry.args, {
|
|
111
|
+
env: { ...process.env, ...entry.env, ROBOPARK_ROBOT_RUNTIME: '1' },
|
|
112
|
+
stdio: runtimeLog(`robot-${opts.name}-${entry.label}`, foreground),
|
|
113
|
+
});
|
|
114
|
+
entry.child = child;
|
|
115
|
+
child.on('error', error => console.error(chalk.red(` ${entry.label} failed to start: ${error.message}`)));
|
|
116
|
+
child.on('exit', (code, signal) => {
|
|
117
|
+
entry.child = undefined;
|
|
118
|
+
if (stopping)
|
|
119
|
+
return;
|
|
120
|
+
// A clean mesh exit is how /update signals that npm replaced this
|
|
121
|
+
// package. Reload every child so the robot runs one coherent release.
|
|
122
|
+
if (entry.label === 'mesh' && code === 0) {
|
|
123
|
+
void recycleAfterMeshUpdate();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (recycling)
|
|
127
|
+
return;
|
|
128
|
+
console.error(chalk.yellow(` ${entry.label} exited (${signal ?? code ?? 'unknown'}); restarting in 5s`));
|
|
129
|
+
setTimeout(() => { if (!stopping)
|
|
130
|
+
start(entry); }, RESTART_DELAY_MS);
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
const stopChild = async (entry) => {
|
|
134
|
+
const child = entry.child;
|
|
135
|
+
if (!child)
|
|
136
|
+
return;
|
|
137
|
+
await new Promise(resolve => {
|
|
138
|
+
const timer = setTimeout(resolve, 5_000);
|
|
139
|
+
child.once('exit', () => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
resolve();
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
child.kill('SIGTERM');
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
clearTimeout(timer);
|
|
148
|
+
resolve();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
const recycleAfterMeshUpdate = async () => {
|
|
153
|
+
if (stopping || recycling)
|
|
154
|
+
return;
|
|
155
|
+
recycling = true;
|
|
156
|
+
console.log(chalk.dim(' mesh updated; restarting RoboVision and preview on the new package...'));
|
|
157
|
+
await Promise.all([...children.filter(entry => entry.label !== 'mesh'), preview].map(stopChild));
|
|
158
|
+
if (stopping)
|
|
159
|
+
return;
|
|
160
|
+
start(children[0]);
|
|
161
|
+
if (opts.vision !== false) {
|
|
162
|
+
start(children[1]);
|
|
163
|
+
if (!await waitForVision()) {
|
|
164
|
+
console.error(chalk.red(' RoboVision did not recover after the mesh update; retrying in 5s'));
|
|
165
|
+
// Stop the partially restarted stack before retrying. Otherwise a
|
|
166
|
+
// second mesh child would collide with the existing listener.
|
|
167
|
+
await Promise.all([...children, preview].map(stopChild));
|
|
168
|
+
recycling = false;
|
|
169
|
+
setTimeout(() => { if (!stopping)
|
|
170
|
+
void recycleAfterMeshUpdate(); }, RESTART_DELAY_MS);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
start(preview);
|
|
175
|
+
recycling = false;
|
|
176
|
+
console.log(chalk.green(' robot runtime updated and healthy'));
|
|
177
|
+
};
|
|
178
|
+
console.log(chalk.bold('\n robopark robot up'));
|
|
179
|
+
console.log(chalk.dim(' ' + '─'.repeat(52)));
|
|
180
|
+
console.log(` robot: ${chalk.cyan(opts.name)}`);
|
|
181
|
+
console.log(` scheduler: ${chalk.cyan(opts.schedulerUrl)}`);
|
|
182
|
+
console.log(` network: ${chalk.cyan(network)}`);
|
|
183
|
+
console.log(` services: ${opts.vision === false ? 'mesh + preview' : 'mesh + RoboVision + preview'}`);
|
|
184
|
+
console.log();
|
|
185
|
+
if (opts.enrollmentToken) {
|
|
186
|
+
console.log(chalk.dim(' clearing stale device identity for fresh enrollment…'));
|
|
187
|
+
resetStaleEnrollment();
|
|
188
|
+
}
|
|
189
|
+
// The mesh can reconnect independently; camera/audio must be healthy before
|
|
190
|
+
// preview sends its first inventory heartbeat or opens the motion webhook.
|
|
191
|
+
start(children[0]);
|
|
192
|
+
if (opts.vision !== false) {
|
|
193
|
+
start(children[1]);
|
|
194
|
+
console.log(chalk.dim(' waiting for RoboVision camera/audio inventory…'));
|
|
195
|
+
if (!await waitForVision()) {
|
|
196
|
+
stopping = true;
|
|
197
|
+
for (const entry of children)
|
|
198
|
+
entry.child?.kill('SIGTERM');
|
|
199
|
+
throw new Error('RoboVision did not become healthy at http://127.0.0.1:5000 within 120s');
|
|
200
|
+
}
|
|
201
|
+
console.log(chalk.green(' ✓ RoboVision ready; starting preview agent'));
|
|
202
|
+
}
|
|
203
|
+
start(preview);
|
|
204
|
+
const shutdown = () => {
|
|
205
|
+
stopping = true;
|
|
206
|
+
for (const entry of [...children, preview])
|
|
207
|
+
entry.child?.kill('SIGTERM');
|
|
208
|
+
};
|
|
209
|
+
process.once('SIGINT', shutdown);
|
|
210
|
+
process.once('SIGTERM', shutdown);
|
|
211
|
+
await new Promise(resolve => {
|
|
212
|
+
const check = setInterval(() => {
|
|
213
|
+
if (stopping && ![...children, preview].some(entry => entry.child)) {
|
|
214
|
+
clearInterval(check);
|
|
215
|
+
resolve();
|
|
216
|
+
}
|
|
217
|
+
}, 250);
|
|
218
|
+
});
|
|
219
|
+
}
|