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.
@@ -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) deviceByName[norm(device.name)] = device;
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) || norm(row.name)===norm(device.name);
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 row=roboparkByName[String(n.name||'').toLowerCase()] || roboparkByName[n.name], exact=row, keys=(n.telemKeys||[]).concat([String(n.name||'').toLowerCase()]);
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
- var row=schedRow(n); if(row&&(row.id||row.robot_id)) return row.id||row.robot_id;
5340
- if(n.schedulerRobotId) return n.schedulerRobotId;
5341
- var byName=schedulerDevicesByName[String(n.name||'').toLowerCase()]; if(byName&&byName.id) return byName.id;
5342
- return n.name;
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
- var row=schedRow(n), id=row&&(row.robot_id||row.id);
5479
- return (id&&schedulerDevicesById[id]) || schedulerDevicesById[n.schedulerDeviceId] || schedulerDevicesById[n.schedulerRobotId] || schedulerDevicesByName[String(n.name||'').toLowerCase()] || schedulerDevicesById[n.name] || null;
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
- await this.autoUpdate.onAnnounce(env.data, peer?.role ?? 'peer');
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
- return { ok: false, message: `unregister not implemented for ${platform}` };
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
+ }