robopark 2.8.36 → 3.0.0
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/README.md +88 -63
- package/bin/robopark.js +7 -17
- package/conversation/elevenlabs_agent.py +1985 -0
- package/conversation/requirements.txt +3 -0
- package/conversation/supervisor_store.py +189 -0
- package/dist/kernel/config-schema.js +37 -0
- package/dist/kernel/types.js +7 -0
- package/dist/robopark/access.js +99 -0
- package/dist/robopark/add-robot.js +188 -0
- package/dist/robopark/agent-ctl.js +305 -0
- package/dist/robopark/auto-start.js +289 -0
- package/dist/robopark/conversation.js +505 -0
- package/dist/robopark/deployment-commands.js +47 -0
- package/dist/robopark/discovery.js +180 -0
- package/dist/robopark/doctor.js +175 -0
- package/dist/robopark/enroll.js +68 -0
- package/dist/robopark/llm-set.js +87 -0
- package/dist/robopark/motor-control.js +195 -0
- package/dist/robopark/preview-agent-launcher.js +77 -0
- package/dist/robopark/probe.js +138 -0
- package/dist/robopark/profile.js +69 -0
- package/dist/robopark/python-env.js +162 -0
- package/dist/robopark/robot-runtime.js +489 -0
- package/dist/robopark/scan.js +97 -0
- package/dist/robopark/screen-control.js +55 -0
- package/dist/robopark/secrets.js +41 -0
- package/dist/robopark/serve.js +285 -0
- package/dist/robopark/server-add.js +114 -0
- package/dist/robopark/setup-livekit.js +300 -0
- package/dist/robopark/setup.js +286 -0
- package/dist/robopark/standalone.js +466 -0
- package/dist/robopark/stop-all.js +141 -0
- package/dist/robopark/verify.js +192 -0
- package/dist/robopark/vision-agent-launcher.js +98 -0
- package/dist/robopark/vision-control.js +81 -0
- package/dist/robopark-cli.js +799 -0
- package/package.json +21 -5
- package/pi-client/_install_steps.sh +29 -29
- package/pi-client/client.py +61 -2
- package/pi-client/install.sh +40 -40
- package/pi-client/join_convo.sh +54 -54
- package/pi-client/livekit_bridge.py +16 -7
- package/pi-client/motor_bridge.py +6 -3
- package/scheduler/fleet_config.json +75 -0
- package/scheduler/main.py +4505 -135
- package/scheduler/media_lock.py +57 -0
- package/scheduler/preview_agent.py +1465 -87
- package/scheduler/production_config.json +139 -0
- package/scheduler/robot_supervisor.py +1705 -0
- package/scheduler/scripts/install-robot-supervisor-linux.sh +33 -0
- package/scheduler/scripts/install-robot-supervisor-windows.ps1 +49 -0
- package/scheduler/scripts/robopark-supervisor.service +20 -0
- package/scheduler/scripts/start-scheduler-local.ps1 +50 -0
- package/scheduler/supervisor.example.json +26 -0
- package/scheduler/vision_motion_trigger.py +101 -0
- package/screen/screen_runtime.py +75 -0
- package/vision/app_pi_clean.py +253 -16
- package/vision/audio_server_pi.py +19 -0
- package/vision/install.sh +34 -34
- package/vision/motor_server.py +224 -61
- package/vision/requirements_camera.txt +6 -0
- package/vision/requirements_motor.txt +4 -0
- package/vision/requirements_pi_unified.txt +1 -0
- package/vision/requirements_vision_agent.txt +19 -0
- package/vision/run.sh +244 -244
- package/vision/services/services.sh +12 -12
- package/scheduler/__pycache__/main.cpython-312.pyc +0 -0
- package/scheduler/__pycache__/preview_agent.cpython-312.pyc +0 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
4
|
+
import { homedir, hostname, networkInterfaces } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { registerAutoStart } from './auto-start.js';
|
|
8
|
+
import { findPython, findSchedulerPath, prepareRobotPython } from './python-env.js';
|
|
9
|
+
import { roboparkServe, schedulerHealthy } from './serve.js';
|
|
10
|
+
import { activateFromPairing } from './access.js';
|
|
11
|
+
const STATE_DIR = process.env.ROBOPARK_HOME
|
|
12
|
+
? resolve(process.env.ROBOPARK_HOME)
|
|
13
|
+
: join(homedir(), '.robopark');
|
|
14
|
+
const CONNECTION_PATH = join(STATE_DIR, 'connection.json');
|
|
15
|
+
const DESIRED_PATH = join(STATE_DIR, 'desired-runtime.json');
|
|
16
|
+
const STATUS_PATH = join(STATE_DIR, 'runtime-status.json');
|
|
17
|
+
const GATEWAY_PATH = join(STATE_DIR, 'gateway.json');
|
|
18
|
+
const LOG_DIR = join(STATE_DIR, 'logs');
|
|
19
|
+
function ensureStateDir() {
|
|
20
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
21
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
function atomicJson(path, value, mode = 0o600) {
|
|
24
|
+
ensureStateDir();
|
|
25
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
26
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode });
|
|
27
|
+
renameSync(temp, path);
|
|
28
|
+
}
|
|
29
|
+
function normalizedUrl(value) {
|
|
30
|
+
const candidate = value.trim().replace(/\/+$/, '');
|
|
31
|
+
const url = new URL(candidate);
|
|
32
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
33
|
+
throw new Error('gateway URL must use http:// or https://');
|
|
34
|
+
}
|
|
35
|
+
return url.toString().replace(/\/+$/, '');
|
|
36
|
+
}
|
|
37
|
+
export function encodePairingToken(payload) {
|
|
38
|
+
return `rp1_${Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')}`;
|
|
39
|
+
}
|
|
40
|
+
export function decodePairingToken(value) {
|
|
41
|
+
const trimmed = value.trim();
|
|
42
|
+
let payload;
|
|
43
|
+
if (trimmed.startsWith('rp1_')) {
|
|
44
|
+
try {
|
|
45
|
+
payload = JSON.parse(Buffer.from(trimmed.slice(4), 'base64url').toString('utf8'));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new Error('invalid RoboPark connection token');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else if (/^https?:\/\//i.test(trimmed) && trimmed.includes('#')) {
|
|
52
|
+
const split = trimmed.lastIndexOf('#');
|
|
53
|
+
payload = {
|
|
54
|
+
v: 1,
|
|
55
|
+
schedulerUrl: trimmed.slice(0, split),
|
|
56
|
+
enrollmentToken: trimmed.slice(split + 1),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
throw new Error('invalid connection token; copy the complete token shown by `robopark gateway start`');
|
|
61
|
+
}
|
|
62
|
+
if (payload.v !== 1 || !payload.schedulerUrl || !payload.enrollmentToken) {
|
|
63
|
+
throw new Error('unsupported or incomplete RoboPark connection token');
|
|
64
|
+
}
|
|
65
|
+
payload.schedulerUrl = normalizedUrl(payload.schedulerUrl);
|
|
66
|
+
if (payload.expiresAt && Date.parse(payload.expiresAt) <= Date.now()) {
|
|
67
|
+
throw new Error('this connection token has expired; create a new token on the gateway');
|
|
68
|
+
}
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
function readConnection() {
|
|
72
|
+
if (existsSync(CONNECTION_PATH)) {
|
|
73
|
+
const parsed = JSON.parse(readFileSync(CONNECTION_PATH, 'utf8'));
|
|
74
|
+
if (!parsed.schedulerUrl || !parsed.deviceId || !parsed.deviceToken) {
|
|
75
|
+
throw new Error(`invalid saved connection at ${CONNECTION_PATH}; reconnect this device`);
|
|
76
|
+
}
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
// Adopt the exact deployed preview-agent identity in place. Older releases
|
|
80
|
+
// predate connection.json but already persist everything required here.
|
|
81
|
+
const legacyPath = join(STATE_DIR, 'preview_agent.json');
|
|
82
|
+
if (existsSync(legacyPath)) {
|
|
83
|
+
try {
|
|
84
|
+
const legacy = JSON.parse(readFileSync(legacyPath, 'utf8'));
|
|
85
|
+
const tokenPath = join(STATE_DIR, 'device_token');
|
|
86
|
+
const deviceToken = existsSync(tokenPath)
|
|
87
|
+
? readFileSync(tokenPath, 'utf8').trim()
|
|
88
|
+
: String(legacy.device_token ?? '').trim();
|
|
89
|
+
const schedulerUrl = String(legacy.scheduler_url ?? '').trim();
|
|
90
|
+
const deviceId = String(legacy.device_id ?? '').trim();
|
|
91
|
+
const name = String(legacy.robot_id ?? legacy.name ?? hostname().split('.')[0]).trim();
|
|
92
|
+
if (schedulerUrl && deviceId && deviceToken) {
|
|
93
|
+
const adopted = {
|
|
94
|
+
version: 1,
|
|
95
|
+
schedulerUrl: normalizedUrl(schedulerUrl),
|
|
96
|
+
deviceId,
|
|
97
|
+
deviceToken,
|
|
98
|
+
name,
|
|
99
|
+
connectedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
atomicJson(CONNECTION_PATH, adopted);
|
|
102
|
+
return adopted;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Fall through to the actionable connect error.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw new Error('this device is not connected; run `robopark connect <token>` first');
|
|
110
|
+
}
|
|
111
|
+
async function jsonRequest(url, init, timeoutMs = 10_000) {
|
|
112
|
+
const response = await fetch(url, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
|
113
|
+
const body = await response.text();
|
|
114
|
+
let parsed = {};
|
|
115
|
+
try {
|
|
116
|
+
parsed = body ? JSON.parse(body) : {};
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
parsed = { detail: body };
|
|
120
|
+
}
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
const detail = typeof parsed === 'object' && parsed && 'detail' in parsed
|
|
123
|
+
? String(parsed.detail)
|
|
124
|
+
: `HTTP ${response.status}`;
|
|
125
|
+
throw new Error(detail);
|
|
126
|
+
}
|
|
127
|
+
return parsed;
|
|
128
|
+
}
|
|
129
|
+
export async function connectDevice(token, opts = {}) {
|
|
130
|
+
const pairing = decodePairingToken(token);
|
|
131
|
+
const name = (opts.name ?? hostname().split('.')[0]).trim();
|
|
132
|
+
if (!name)
|
|
133
|
+
throw new Error('device name cannot be empty');
|
|
134
|
+
const enrolled = await jsonRequest(`${pairing.schedulerUrl}/api/devices/enroll`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: { 'content-type': 'application/json' },
|
|
137
|
+
body: JSON.stringify({
|
|
138
|
+
enrollment_token: pairing.enrollmentToken,
|
|
139
|
+
name,
|
|
140
|
+
device_role: 'voice_vision',
|
|
141
|
+
}),
|
|
142
|
+
});
|
|
143
|
+
const connection = {
|
|
144
|
+
version: 1,
|
|
145
|
+
// The gateway may bind on 0.0.0.0 and report its own loopback default.
|
|
146
|
+
// The pairing payload is the operator-selected device route.
|
|
147
|
+
schedulerUrl: pairing.schedulerUrl,
|
|
148
|
+
deviceId: enrolled.device_id,
|
|
149
|
+
deviceToken: enrolled.device_token,
|
|
150
|
+
name,
|
|
151
|
+
connectedAt: new Date().toISOString(),
|
|
152
|
+
};
|
|
153
|
+
atomicJson(CONNECTION_PATH, connection);
|
|
154
|
+
writeFileSync(join(STATE_DIR, 'device_token'), connection.deviceToken, { encoding: 'utf8', mode: 0o600 });
|
|
155
|
+
activateFromPairing();
|
|
156
|
+
console.log(chalk.green(`\n ✓ connected ${name}`));
|
|
157
|
+
console.log(` gateway: ${chalk.cyan(connection.schedulerUrl)}`);
|
|
158
|
+
console.log(` device: ${chalk.cyan(connection.deviceId)}`);
|
|
159
|
+
console.log(chalk.dim(' credentials were saved locally; the pairing token was not retained.\n'));
|
|
160
|
+
return connection;
|
|
161
|
+
}
|
|
162
|
+
function cliEntry() {
|
|
163
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
164
|
+
const candidates = [
|
|
165
|
+
join(here, '..', '..', 'bin', 'robopark.js'),
|
|
166
|
+
join(here, '..', '..', '..', 'bin', 'robopark.js'),
|
|
167
|
+
join(process.cwd(), 'bin', 'robopark.js'),
|
|
168
|
+
];
|
|
169
|
+
const found = candidates.find(existsSync);
|
|
170
|
+
if (!found)
|
|
171
|
+
throw new Error('could not resolve the packaged RoboPark CLI entry point');
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
async function updateDeviceConfiguration(desired) {
|
|
175
|
+
const c = desired.connection;
|
|
176
|
+
await jsonRequest(`${c.schedulerUrl}/api/devices/${encodeURIComponent(c.deviceId)}`, {
|
|
177
|
+
method: 'PATCH',
|
|
178
|
+
headers: {
|
|
179
|
+
'content-type': 'application/json',
|
|
180
|
+
authorization: `Bearer ${c.deviceToken}`,
|
|
181
|
+
},
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
name: c.name,
|
|
184
|
+
character_id: desired.character || null,
|
|
185
|
+
video_device: desired.video ? desired.videoDevice : 'none',
|
|
186
|
+
audio_device: desired.voice ? desired.audioDevice : 'none',
|
|
187
|
+
device_role: 'voice_vision',
|
|
188
|
+
production_mode: true,
|
|
189
|
+
}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
function positiveInteger(value, fallback, label) {
|
|
193
|
+
const parsed = Number.parseInt(value ?? String(fallback), 10);
|
|
194
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
195
|
+
throw new Error(`${label} must be a positive integer`);
|
|
196
|
+
return parsed;
|
|
197
|
+
}
|
|
198
|
+
export async function startDevice(opts) {
|
|
199
|
+
const connection = readConnection();
|
|
200
|
+
const desired = {
|
|
201
|
+
version: 1,
|
|
202
|
+
connection,
|
|
203
|
+
voice: Boolean(opts.voice),
|
|
204
|
+
video: Boolean(opts.video),
|
|
205
|
+
character: opts.character?.trim() || undefined,
|
|
206
|
+
videoDevice: opts.videoDevice ?? 'auto',
|
|
207
|
+
audioDevice: opts.audioDevice ?? 'default',
|
|
208
|
+
width: positiveInteger(opts.width, 640, 'width'),
|
|
209
|
+
height: positiveInteger(opts.height, 480, 'height'),
|
|
210
|
+
fps: positiveInteger(opts.fps, 15, 'fps'),
|
|
211
|
+
updatedAt: new Date().toISOString(),
|
|
212
|
+
};
|
|
213
|
+
if (!desired.voice && !desired.video) {
|
|
214
|
+
throw new Error('select at least one media path: `--voice`, `--video`, or both');
|
|
215
|
+
}
|
|
216
|
+
await updateDeviceConfiguration(desired);
|
|
217
|
+
atomicJson(DESIRED_PATH, desired);
|
|
218
|
+
if (opts.foreground) {
|
|
219
|
+
await superviseDevice(DESIRED_PATH);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const registration = await registerAutoStart({
|
|
223
|
+
role: 'robot-runtime',
|
|
224
|
+
name: connection.name,
|
|
225
|
+
command: process.execPath,
|
|
226
|
+
args: [cliEntry(), '_supervise', '--config', DESIRED_PATH],
|
|
227
|
+
workingDir: homedir(),
|
|
228
|
+
exclusiveMedia: true,
|
|
229
|
+
});
|
|
230
|
+
if (!registration.ok) {
|
|
231
|
+
throw new Error(`${registration.message}. Retry from an elevated terminal or use --foreground.`);
|
|
232
|
+
}
|
|
233
|
+
console.log(chalk.green(`\n ✓ RoboPark is supervising ${connection.name}`));
|
|
234
|
+
console.log(` voice: ${desired.voice ? chalk.green('on') : chalk.dim('off')}`);
|
|
235
|
+
console.log(` video: ${desired.video ? chalk.green('on') : chalk.dim('off')}`);
|
|
236
|
+
console.log(` character: ${chalk.cyan(desired.character || 'gateway default')}`);
|
|
237
|
+
console.log(` service: ${chalk.dim(registration.message)}`);
|
|
238
|
+
console.log(` status: ${chalk.dim(STATUS_PATH)}\n`);
|
|
239
|
+
}
|
|
240
|
+
function status(state) {
|
|
241
|
+
atomicJson(STATUS_PATH, { ...state, updatedAt: new Date().toISOString(), pid: process.pid });
|
|
242
|
+
}
|
|
243
|
+
async function reportSupervisor(desired, state, detail) {
|
|
244
|
+
const c = desired.connection;
|
|
245
|
+
try {
|
|
246
|
+
await jsonRequest(`${c.schedulerUrl}/api/devices/${encodeURIComponent(c.deviceId)}/supervisor-status`, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: {
|
|
249
|
+
'content-type': 'application/json',
|
|
250
|
+
authorization: `Bearer ${c.deviceToken}`,
|
|
251
|
+
},
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
services: [{
|
|
254
|
+
name: `robopark-device:${state}:${detail.slice(0, 120)}`,
|
|
255
|
+
enabled: true,
|
|
256
|
+
running: state === 'running' || state === 'starting',
|
|
257
|
+
pid: process.pid,
|
|
258
|
+
failure_count: 0,
|
|
259
|
+
}],
|
|
260
|
+
}),
|
|
261
|
+
}, 4_000);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// The local status file remains authoritative while the gateway is offline.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function heartbeatFresh(desired) {
|
|
268
|
+
const c = desired.connection;
|
|
269
|
+
try {
|
|
270
|
+
const device = await jsonRequest(`${c.schedulerUrl}/api/devices/${encodeURIComponent(c.deviceId)}`, { headers: { authorization: `Bearer ${c.deviceToken}` } }, 5_000);
|
|
271
|
+
const age = device.last_heartbeat ? Date.now() - Date.parse(device.last_heartbeat) : Number.POSITIVE_INFINITY;
|
|
272
|
+
return age < 45_000 && device.status !== 'offline';
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// A gateway outage must not cause a restart storm. The worker's own retry
|
|
276
|
+
// loop will reconnect, and the watchdog resumes once the gateway answers.
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function wait(ms) {
|
|
281
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
282
|
+
}
|
|
283
|
+
function terminate(child) {
|
|
284
|
+
if (!child || child.killed)
|
|
285
|
+
return;
|
|
286
|
+
try {
|
|
287
|
+
child.kill('SIGTERM');
|
|
288
|
+
}
|
|
289
|
+
catch { /* already gone */ }
|
|
290
|
+
setTimeout(() => {
|
|
291
|
+
if (child.exitCode === null) {
|
|
292
|
+
try {
|
|
293
|
+
child.kill('SIGKILL');
|
|
294
|
+
}
|
|
295
|
+
catch { /* already gone */ }
|
|
296
|
+
}
|
|
297
|
+
}, 10_000).unref();
|
|
298
|
+
}
|
|
299
|
+
export async function superviseDevice(configPath = DESIRED_PATH) {
|
|
300
|
+
let desired = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
301
|
+
const script = await findSchedulerPath();
|
|
302
|
+
if (!script)
|
|
303
|
+
throw new Error('packaged preview_agent.py was not found');
|
|
304
|
+
const python = prepareRobotPython(findPython(), script);
|
|
305
|
+
if (!python)
|
|
306
|
+
throw new Error('could not prepare the packaged RoboPark media runtime');
|
|
307
|
+
let child;
|
|
308
|
+
let stopping = false;
|
|
309
|
+
let failures = 0;
|
|
310
|
+
let startedAt = 0;
|
|
311
|
+
const shutdown = (signal) => {
|
|
312
|
+
stopping = true;
|
|
313
|
+
status({ state: 'stopping', signal });
|
|
314
|
+
terminate(child);
|
|
315
|
+
};
|
|
316
|
+
process.once('SIGINT', shutdown);
|
|
317
|
+
process.once('SIGTERM', shutdown);
|
|
318
|
+
while (!stopping) {
|
|
319
|
+
desired = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
320
|
+
const c = desired.connection;
|
|
321
|
+
const args = [
|
|
322
|
+
script,
|
|
323
|
+
'--scheduler-url', c.schedulerUrl,
|
|
324
|
+
'--robot-id', c.name,
|
|
325
|
+
'--device-token', c.deviceToken,
|
|
326
|
+
'--video-device', desired.video ? desired.videoDevice : 'none',
|
|
327
|
+
'--audio-device', desired.voice ? desired.audioDevice : 'none',
|
|
328
|
+
'--width', String(desired.width),
|
|
329
|
+
'--height', String(desired.height),
|
|
330
|
+
'--fps', String(desired.fps),
|
|
331
|
+
'--save-config',
|
|
332
|
+
];
|
|
333
|
+
startedAt = Date.now();
|
|
334
|
+
status({ state: 'starting', childFailures: failures, voice: desired.voice, video: desired.video });
|
|
335
|
+
await reportSupervisor(desired, 'starting', 'starting packaged camera/voice worker');
|
|
336
|
+
const outFd = openSync(join(LOG_DIR, 'device.log'), 'a');
|
|
337
|
+
const errFd = openSync(join(LOG_DIR, 'device.err.log'), 'a');
|
|
338
|
+
child = spawn(python, args, {
|
|
339
|
+
env: { ...process.env, ROBOPARK_DEVICE_ID: c.deviceId },
|
|
340
|
+
stdio: ['ignore', outFd, errFd],
|
|
341
|
+
windowsHide: true,
|
|
342
|
+
});
|
|
343
|
+
closeSync(outFd);
|
|
344
|
+
closeSync(errFd);
|
|
345
|
+
status({ state: 'running', childPid: child.pid, childFailures: failures });
|
|
346
|
+
await reportSupervisor(desired, 'running', `media worker pid ${child.pid ?? 'unknown'}`);
|
|
347
|
+
let watchdogFailures = 0;
|
|
348
|
+
const watchdog = setInterval(async () => {
|
|
349
|
+
if (!child || child.exitCode !== null || Date.now() - startedAt < 60_000)
|
|
350
|
+
return;
|
|
351
|
+
watchdogFailures = await heartbeatFresh(desired) ? 0 : watchdogFailures + 1;
|
|
352
|
+
if (watchdogFailures >= 3) {
|
|
353
|
+
status({ state: 'restarting', reason: 'heartbeat_stalled', childPid: child.pid });
|
|
354
|
+
await reportSupervisor(desired, 'degraded', 'heartbeat stalled; recycling media worker');
|
|
355
|
+
terminate(child);
|
|
356
|
+
}
|
|
357
|
+
}, 15_000);
|
|
358
|
+
watchdog.unref();
|
|
359
|
+
const exit = await new Promise((resolve, reject) => {
|
|
360
|
+
child?.once('error', reject);
|
|
361
|
+
child?.once('exit', (code, signal) => resolve({ code, signal }));
|
|
362
|
+
}).catch(error => ({ code: 1, signal: null, error }));
|
|
363
|
+
clearInterval(watchdog);
|
|
364
|
+
if (stopping)
|
|
365
|
+
break;
|
|
366
|
+
const ranFor = Date.now() - startedAt;
|
|
367
|
+
failures = ranFor > 5 * 60_000 ? 0 : failures + 1;
|
|
368
|
+
const delay = Math.min(30_000, 1_000 * 2 ** Math.min(failures, 5));
|
|
369
|
+
const detail = `worker stopped (${String('error' in exit ? exit.error : exit.signal ?? exit.code)}); restart in ${delay}ms`;
|
|
370
|
+
status({ state: 'backoff', detail, childFailures: failures, restartInMs: delay });
|
|
371
|
+
await reportSupervisor(desired, 'restarting', detail);
|
|
372
|
+
await wait(delay);
|
|
373
|
+
}
|
|
374
|
+
status({ state: 'stopped' });
|
|
375
|
+
await reportSupervisor(desired, 'stopped', 'device supervisor stopped');
|
|
376
|
+
}
|
|
377
|
+
function localPublicUrl(host, port, explicit) {
|
|
378
|
+
if (explicit)
|
|
379
|
+
return normalizedUrl(explicit);
|
|
380
|
+
let publicHost = host;
|
|
381
|
+
if (host === '0.0.0.0' || host === '::') {
|
|
382
|
+
const addresses = Object.entries(networkInterfaces())
|
|
383
|
+
.flatMap(([name, entries]) => (entries ?? [])
|
|
384
|
+
.filter(entry => entry.family === 'IPv4' && !entry.internal)
|
|
385
|
+
.map(entry => ({ name: name.toLowerCase(), address: entry.address })));
|
|
386
|
+
publicHost = addresses.find(entry => /tailscale|wireguard|wg/.test(entry.name))?.address
|
|
387
|
+
?? addresses.find(entry => entry.address.startsWith('100.'))?.address
|
|
388
|
+
?? addresses.find(entry => /ethernet|wi-?fi|wlan|eth/.test(entry.name))?.address
|
|
389
|
+
?? addresses[0]?.address
|
|
390
|
+
?? 'localhost';
|
|
391
|
+
}
|
|
392
|
+
return normalizedUrl(`http://${publicHost}:${port}`);
|
|
393
|
+
}
|
|
394
|
+
export async function gatewayStart(opts) {
|
|
395
|
+
const port = positiveInteger(opts.port, 8080, 'port');
|
|
396
|
+
const host = opts.host ?? '0.0.0.0';
|
|
397
|
+
if (opts.foreground) {
|
|
398
|
+
await runGatewayService({
|
|
399
|
+
port: String(port),
|
|
400
|
+
host,
|
|
401
|
+
dataDir: opts.dataDir,
|
|
402
|
+
open: opts.open,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
const args = [cliEntry(), '_gateway', '--port', String(port), '--host', host];
|
|
407
|
+
if (opts.dataDir)
|
|
408
|
+
args.push('--data-dir', opts.dataDir);
|
|
409
|
+
const registration = await registerAutoStart({
|
|
410
|
+
role: 'hub-scheduler',
|
|
411
|
+
name: 'gateway',
|
|
412
|
+
command: process.execPath,
|
|
413
|
+
args,
|
|
414
|
+
workingDir: homedir(),
|
|
415
|
+
});
|
|
416
|
+
if (!registration.ok) {
|
|
417
|
+
throw new Error(`${registration.message}. Retry from an elevated terminal or add --foreground.`);
|
|
418
|
+
}
|
|
419
|
+
console.log(chalk.green(` ✓ ${registration.message}`));
|
|
420
|
+
}
|
|
421
|
+
const schedulerUrl = localPublicUrl(host, port, opts.publicUrl);
|
|
422
|
+
const healthUrl = `http://127.0.0.1:${port}`;
|
|
423
|
+
const deadline = Date.now() + 30_000;
|
|
424
|
+
while (!(await schedulerHealthy(healthUrl, 1_000))) {
|
|
425
|
+
if (Date.now() >= deadline)
|
|
426
|
+
throw new Error(`gateway did not become healthy at ${schedulerUrl}`);
|
|
427
|
+
await wait(500);
|
|
428
|
+
}
|
|
429
|
+
const token = await createGatewayTokenAt(healthUrl, schedulerUrl);
|
|
430
|
+
console.log(chalk.bold('\n Connect a device'));
|
|
431
|
+
console.log(` ${chalk.cyan(`robopark connect ${token}`)}`);
|
|
432
|
+
console.log(chalk.dim(' This pairing token expires in 24 hours. Connected devices keep their own durable credentials.\n'));
|
|
433
|
+
}
|
|
434
|
+
async function createGatewayTokenAt(localUrl, publicUrl) {
|
|
435
|
+
await jsonRequest(`${localUrl}/api/settings`, {
|
|
436
|
+
method: 'PUT',
|
|
437
|
+
headers: { 'content-type': 'application/json' },
|
|
438
|
+
body: JSON.stringify({ production_mode: true }),
|
|
439
|
+
});
|
|
440
|
+
const result = await jsonRequest(`${localUrl}/api/settings/enrollment-token/rotate`, { method: 'POST' });
|
|
441
|
+
const pairing = encodePairingToken({
|
|
442
|
+
v: 1,
|
|
443
|
+
schedulerUrl: publicUrl,
|
|
444
|
+
enrollmentToken: result.enrollment_token,
|
|
445
|
+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
446
|
+
});
|
|
447
|
+
atomicJson(GATEWAY_PATH, { schedulerUrl: publicUrl, pairingToken: pairing, createdAt: new Date().toISOString() });
|
|
448
|
+
return pairing;
|
|
449
|
+
}
|
|
450
|
+
export async function runGatewayService(opts) {
|
|
451
|
+
await roboparkServe({
|
|
452
|
+
port: opts.port,
|
|
453
|
+
host: opts.host,
|
|
454
|
+
dataDir: opts.dataDir,
|
|
455
|
+
open: opts.open,
|
|
456
|
+
foreground: true,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
export function printRuntimeStatus() {
|
|
460
|
+
if (!existsSync(STATUS_PATH)) {
|
|
461
|
+
console.log(chalk.yellow(' no local runtime status; run `robopark start --voice --video`'));
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const current = JSON.parse(readFileSync(STATUS_PATH, 'utf8'));
|
|
465
|
+
console.log(JSON.stringify(current, null, 2));
|
|
466
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RoboPark / infinicode — stop everything on this machine, including whatever
|
|
3
|
+
* would respawn it.
|
|
4
|
+
*
|
|
5
|
+
* `--supervised` nodes (systemd/Task Scheduler/launchd, `Restart=always`) come
|
|
6
|
+
* back on their own after a plain kill — that's the class of bug that leaves a
|
|
7
|
+
* stale hub process answering on a port after someone thought they killed it.
|
|
8
|
+
* This kills every matching PID directly (never a blanket `killall node`) AND
|
|
9
|
+
* disables/removes the supervisor unit so it does not restart.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'node:child_process';
|
|
12
|
+
// Long-running daemons only — never a short-lived CLI invocation like
|
|
13
|
+
// `robopark setup` or `robopark stop` itself, which would otherwise match
|
|
14
|
+
// its own (or an ancestor shell's) command line and self-terminate mid-run.
|
|
15
|
+
const PATTERNS = [
|
|
16
|
+
'infinicode serve',
|
|
17
|
+
'infinicode.js serve',
|
|
18
|
+
'cli.js serve', // source/dev invocation: `node dist/cli.js serve ...`
|
|
19
|
+
'robopark serve',
|
|
20
|
+
'robopark-cli.js serve',
|
|
21
|
+
'robopark-cli.js robot-run',
|
|
22
|
+
'robopark-cli.js robot up',
|
|
23
|
+
'robopark.js robot up',
|
|
24
|
+
// `robopark serve` detaches this child, so the Node launcher is gone while
|
|
25
|
+
// the Python scheduler remains. Match both Windows and POSIX separators.
|
|
26
|
+
'scheduler.{0,3}main\\.py',
|
|
27
|
+
'preview_agent.py',
|
|
28
|
+
'app_pi_clean.py',
|
|
29
|
+
'audio_server_pi.py',
|
|
30
|
+
'motor_server.py',
|
|
31
|
+
];
|
|
32
|
+
export async function stopAll() {
|
|
33
|
+
const platform = process.platform;
|
|
34
|
+
if (platform === 'win32')
|
|
35
|
+
return stopAllWindows();
|
|
36
|
+
return stopAllUnix();
|
|
37
|
+
}
|
|
38
|
+
function stopAllWindows() {
|
|
39
|
+
const killedPids = [];
|
|
40
|
+
const removedUnits = [];
|
|
41
|
+
const errors = [];
|
|
42
|
+
// Find every process whose command line matches, by PID — not by image
|
|
43
|
+
// name, so we never touch an unrelated node.exe (e.g. an MCP server).
|
|
44
|
+
const ps = spawnSync('powershell.exe', [
|
|
45
|
+
'-NoProfile', '-NonInteractive', '-Command',
|
|
46
|
+
`Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and ($_.CommandLine -match '${PATTERNS.join('|').replace(/'/g, "''")}') } | Select-Object -ExpandProperty ProcessId`,
|
|
47
|
+
], { encoding: 'utf8' });
|
|
48
|
+
const pids = (ps.stdout ?? '').split(/\r?\n/).map(s => s.trim()).filter(Boolean).map(Number).filter(Number.isFinite);
|
|
49
|
+
const self = process.pid;
|
|
50
|
+
for (const pid of pids) {
|
|
51
|
+
if (pid === self)
|
|
52
|
+
continue; // never kill the process running this command
|
|
53
|
+
const kill = spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'], { encoding: 'utf8' });
|
|
54
|
+
if (kill.status === 0)
|
|
55
|
+
killedPids.push(pid);
|
|
56
|
+
else
|
|
57
|
+
errors.push(`taskkill ${pid}: ${(kill.stderr || kill.stdout || '').trim()}`);
|
|
58
|
+
}
|
|
59
|
+
// Remove any Scheduled Tasks registered by `robopark setup --auto-start`
|
|
60
|
+
// (auto-start.ts names them "RoboPark-<role>-<name>") so they don't relaunch
|
|
61
|
+
// on next login/boot.
|
|
62
|
+
const query = spawnSync('schtasks', ['/Query', '/FO', 'CSV', '/NH'], { encoding: 'utf8' });
|
|
63
|
+
const taskNames = (query.stdout ?? '')
|
|
64
|
+
.split(/\r?\n/)
|
|
65
|
+
.map(line => line.split(',')[0]?.replace(/^"|"$/g, ''))
|
|
66
|
+
.filter((n) => !!n && n.startsWith('\\RoboPark-'));
|
|
67
|
+
for (const name of taskNames) {
|
|
68
|
+
const del = spawnSync('schtasks', ['/Delete', '/TN', name, '/F'], { encoding: 'utf8' });
|
|
69
|
+
if (del.status === 0)
|
|
70
|
+
removedUnits.push(name);
|
|
71
|
+
else
|
|
72
|
+
errors.push(`schtasks /Delete ${name}: ${(del.stderr || del.stdout || '').trim()}`);
|
|
73
|
+
}
|
|
74
|
+
return { killedPids, removedUnits, errors };
|
|
75
|
+
}
|
|
76
|
+
function stopAllUnix() {
|
|
77
|
+
const killedPids = [];
|
|
78
|
+
const removedUnits = [];
|
|
79
|
+
const errors = [];
|
|
80
|
+
const self = process.pid;
|
|
81
|
+
if (process.platform === 'linux') {
|
|
82
|
+
// Disable restart policies before killing processes so systemd cannot
|
|
83
|
+
// repopulate the ports between process discovery and cleanup.
|
|
84
|
+
const list = spawnSync('systemctl', ['list-units', '--all', '--plain', '--no-legend'], { encoding: 'utf8' });
|
|
85
|
+
const units = (list.stdout ?? '')
|
|
86
|
+
.split('\n')
|
|
87
|
+
.map(l => l.trim().split(/\s+/)[0])
|
|
88
|
+
.filter((u) => !!u && u.endsWith('.service') && /infinicode|robopark/i.test(u));
|
|
89
|
+
for (const unit of units) {
|
|
90
|
+
const disable = spawnSync('systemctl', ['disable', '--now', unit], { encoding: 'utf8' });
|
|
91
|
+
if (disable.status === 0)
|
|
92
|
+
removedUnits.push(unit);
|
|
93
|
+
else
|
|
94
|
+
errors.push(`systemctl disable --now ${unit}: ${(disable.stderr || '').trim()}`);
|
|
95
|
+
}
|
|
96
|
+
if (units.length)
|
|
97
|
+
spawnSync('systemctl', ['daemon-reload'], { encoding: 'utf8' });
|
|
98
|
+
}
|
|
99
|
+
const ps = spawnSync('ps', ['ax', '-o', 'pid=,command='], { encoding: 'utf8' });
|
|
100
|
+
const lines = (ps.stdout ?? '').split('\n');
|
|
101
|
+
const re = new RegExp(PATTERNS.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'));
|
|
102
|
+
for (const line of lines) {
|
|
103
|
+
const m = line.match(/^\s*(\d+)\s+(.*)$/);
|
|
104
|
+
if (!m)
|
|
105
|
+
continue;
|
|
106
|
+
const pid = Number(m[1]);
|
|
107
|
+
const cmd = m[2];
|
|
108
|
+
if (pid === self || !re.test(cmd))
|
|
109
|
+
continue;
|
|
110
|
+
const kill = spawnSync('kill', ['-9', String(pid)], { encoding: 'utf8' });
|
|
111
|
+
if (kill.status === 0)
|
|
112
|
+
killedPids.push(pid);
|
|
113
|
+
else
|
|
114
|
+
errors.push(`kill -9 ${pid}: ${(kill.stderr || '').trim()}`);
|
|
115
|
+
}
|
|
116
|
+
// Also free the well-known RoboPark ports directly — belt and suspenders
|
|
117
|
+
// against a supervised process whose command line didn't match a pattern.
|
|
118
|
+
for (const port of [47913, 47921, 47922, 8080, 5000, 5057, 8000, 8001]) {
|
|
119
|
+
const lsof = spawnSync('lsof', ['-t', `-i:${port}`], { encoding: 'utf8' });
|
|
120
|
+
const fuser = spawnSync('fuser', ['-n', 'tcp', String(port)], { encoding: 'utf8' });
|
|
121
|
+
const pids = [...new Set(`${lsof.stdout ?? ''} ${fuser.stdout ?? ''}`
|
|
122
|
+
.split(/\s+/)
|
|
123
|
+
.filter(Boolean)
|
|
124
|
+
.map(s => Number(s.trim()))
|
|
125
|
+
.filter(pid => Number.isFinite(pid) && pid > 1))];
|
|
126
|
+
for (const pid of pids) {
|
|
127
|
+
if (pid === self || killedPids.includes(pid))
|
|
128
|
+
continue;
|
|
129
|
+
const kill = spawnSync('kill', ['-9', String(pid)], { encoding: 'utf8' });
|
|
130
|
+
if (kill.status === 0)
|
|
131
|
+
killedPids.push(pid);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (process.platform === 'linux') {
|
|
135
|
+
// Broad match, not just "robopark-*" — known unit names in the wild
|
|
136
|
+
// include a bare "infinicode.service" (see pi-client install scripts),
|
|
137
|
+
// which a narrower glob would silently leave running (and respawning
|
|
138
|
+
// whatever we just killed, if it has Restart=always).
|
|
139
|
+
}
|
|
140
|
+
return { killedPids, removedUnits, errors };
|
|
141
|
+
}
|