infinicode 2.8.37 → 2.8.38

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.
@@ -5,6 +5,7 @@ Manages robot fleet, LiveKit servers, and session orchestration
5
5
 
6
6
  import os
7
7
  import asyncio
8
+ import contextvars
8
9
  import json
9
10
  import logging
10
11
  import re
@@ -33,9 +34,15 @@ DB_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "scheduler.
33
34
  # Path to a ROBOVOICE-style settings file the scheduler reads for characters.
34
35
  # In production this should be a bind-mount to ROBOVOICE-main/settings.json (or .default.json).
35
36
  ROBOVOICE_SETTINGS_PATH = os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), "robovoice_settings.json")
36
- # Optional shared credential used only by the ROBOVOICE worker to refresh an
37
- # active RoboPark session. Device endpoints continue to require device tokens.
38
- ROBOPARK_AGENT_TOKEN = os.getenv("ROBOPARK_AGENT_TOKEN", "").strip()
37
+ # Shared credential used only by the ROBOVOICE worker to refresh an active
38
+ # RoboPark session. Device endpoints continue to require device tokens. If the
39
+ # environment does not provide it, lifespan() creates/loads this local secret
40
+ # file so a bare `robopark serve` is safe to run without manual secret wiring.
41
+ ROBOPARK_AGENT_TOKEN = os.getenv("ROBOPARK_AGENT_TOKEN", "").strip()
42
+ ROBOPARK_AGENT_TOKEN_FILE = os.getenv(
43
+ "ROBOPARK_AGENT_TOKEN_FILE",
44
+ os.path.join(os.getenv("SCHEDULER_DATA_DIR", "/app/data"), ".robopark-agent-token"),
45
+ )
39
46
 
40
47
  # ElevenLabs credentials used by the /api/voices/elevenlabs endpoint. We accept
41
48
  # both the long-form ELEVENLABS_API_KEY and a shorter ELEVENLABS_KEY alias so
@@ -387,6 +394,31 @@ async def init_db():
387
394
  PRIMARY KEY (device_id, service_name)
388
395
  );
389
396
 
397
+ -- ── Operator shell (C1 gap-fill) ──
398
+ -- When the operator clicks "Tail logs" or runs a diagnostic
399
+ -- command in the dashboard, we insert a row here. The robot
400
+ -- supervisor's next status-report poll receives the row in
401
+ -- its `commands` response, runs the request on a background
402
+ -- thread, and POSTs the result back to /api/devices/{id}/
403
+ -- supervisor-output which updates the `result` column. The
404
+ -- dashboard polls GET /api/robots/{id}/shell/result/{req_id}
405
+ -- for the response, with an 8s budget.
406
+ --
407
+ -- `kind` discriminates: tail_logs (read log file) vs
408
+ -- shell_run (execute an allowlisted diagnostic command).
409
+ CREATE TABLE IF NOT EXISTS shell_requests (
410
+ id TEXT PRIMARY KEY,
411
+ device_id TEXT NOT NULL,
412
+ service_name TEXT,
413
+ kind TEXT NOT NULL,
414
+ params TEXT NOT NULL DEFAULT '{}',
415
+ requested_at TEXT NOT NULL,
416
+ completed_at TEXT,
417
+ result_ok INTEGER,
418
+ result_payload TEXT
419
+ );
420
+ CREATE INDEX IF NOT EXISTS idx_shell_requests_device ON shell_requests(device_id, requested_at);
421
+
390
422
  CREATE TABLE IF NOT EXISTS history_log (
391
423
  id INTEGER PRIMARY KEY AUTOINCREMENT,
392
424
  timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
@@ -561,18 +593,30 @@ async def init_db():
561
593
  # =============================================================================
562
594
 
563
595
  @asynccontextmanager
564
- async def lifespan(app: FastAPI):
565
- await init_db()
566
- if not ROBOPARK_AGENT_TOKEN:
567
- logger.warning(
568
- "ROBOPARK_AGENT_TOKEN is not set -- the voice agent's "
569
- "POST /api/sessions/{id}/keepalive calls will all 401. Without "
570
- "keepalive succeeding, every motion-triggered session's "
571
- "last_activity_at never updates and gets cut off by its own "
572
- "silence timeout within ~15s, before a greeting or any real "
573
- "conversation can complete. Set it to match ROBOPARK_AGENT_TOKEN "
574
- "in the voice agent's own .env before relying on production mode."
575
- )
596
+ async def lifespan(app: FastAPI):
597
+ await init_db()
598
+ global ROBOPARK_AGENT_TOKEN
599
+ if not ROBOPARK_AGENT_TOKEN:
600
+ try:
601
+ if os.path.isfile(ROBOPARK_AGENT_TOKEN_FILE):
602
+ with open(ROBOPARK_AGENT_TOKEN_FILE, "r", encoding="utf-8") as f:
603
+ ROBOPARK_AGENT_TOKEN = f.read().strip()
604
+ if not ROBOPARK_AGENT_TOKEN:
605
+ ROBOPARK_AGENT_TOKEN = secrets.token_urlsafe(32)
606
+ os.makedirs(os.path.dirname(ROBOPARK_AGENT_TOKEN_FILE) or ".", exist_ok=True)
607
+ with open(ROBOPARK_AGENT_TOKEN_FILE, "w", encoding="utf-8") as f:
608
+ f.write(ROBOPARK_AGENT_TOKEN + "\n")
609
+ try:
610
+ os.chmod(ROBOPARK_AGENT_TOKEN_FILE, 0o600)
611
+ except OSError:
612
+ pass
613
+ logger.info("Generated RoboPark agent secret in the scheduler data directory")
614
+ except OSError as exc:
615
+ logger.error("Could not load/create RoboPark agent secret: %s", exc)
616
+ if not ROBOPARK_AGENT_TOKEN:
617
+ logger.warning(
618
+ "RoboPark agent secret is unavailable; voice-agent keepalive calls will 401."
619
+ )
576
620
  asyncio.create_task(metrics_collector())
577
621
  asyncio.create_task(health_checker())
578
622
  yield
@@ -597,8 +641,60 @@ app.add_middleware(
597
641
  allow_credentials=_allow_credentials,
598
642
  allow_methods=["*"],
599
643
  allow_headers=["*"],
644
+ # X-Operator carries the per-action attribution. CORS preflight
645
+ # must allow it or the dashboard's rpAction() fetches will fail.
646
+ expose_headers=["X-Operator"],
600
647
  )
601
648
 
649
+
650
+ # ── Operator-actor middleware (C3) ──
651
+ # Reads the X-Operator header on every request and stores a sanitized
652
+ # version in a contextvar. The header is informational only — the
653
+ # scheduler is a single-tenant surface; the actor is recorded in the
654
+ # audit log so "who clicked End-session?" is answerable. The header
655
+ # value is sanitized at this boundary (trimmed, control chars stripped,
656
+ # capped at 64 chars) so the value reaching the database is always a
657
+ # safe printable string. An absent or invalid header leaves the
658
+ # contextvar unset; _log_history then uses the call-site default
659
+ # (usually "operator").
660
+ class _OperatorActorMiddleware:
661
+ def __init__(self, app):
662
+ self.app = app
663
+ async def __call__(self, scope, receive, send):
664
+ if scope.get("type") == "http":
665
+ raw = None
666
+ # ASGI scope headers is a list of (name, value) tuples where
667
+ # value is bytes — except when a header has been repeated, in
668
+ # which case some servers emit (name, [b"a", b"b"]). Build a
669
+ # case-insensitive map that flattens both shapes.
670
+ for k, v in (scope.get("headers") or []):
671
+ if not isinstance(k, (bytes, bytearray)):
672
+ continue
673
+ if k.lower() != b"x-operator":
674
+ continue
675
+ if isinstance(v, (list, tuple)):
676
+ v = b", ".join(bytes(x) for x in v if isinstance(x, (bytes, bytearray)))
677
+ if not isinstance(v, (bytes, bytearray)):
678
+ continue
679
+ raw = bytes(v)
680
+ break
681
+ actor = None
682
+ if raw is not None:
683
+ try:
684
+ actor = _sanitize_actor_name(raw.decode("utf-8", errors="replace"))
685
+ except Exception:
686
+ actor = None
687
+ token = _current_actor_override.set(actor)
688
+ try:
689
+ await self.app(scope, receive, send)
690
+ finally:
691
+ _current_actor_override.reset(token)
692
+ else:
693
+ await self.app(scope, receive, send)
694
+
695
+
696
+ app.add_middleware(_OperatorActorMiddleware)
697
+
602
698
  # WebSocket clients for real-time updates
603
699
  ws_clients: List[WebSocket] = []
604
700
 
@@ -693,15 +789,17 @@ async def robot_heartbeat(robot_id: str, ip_address: Optional[str] = None):
693
789
  return {"status": "ok"}
694
790
 
695
791
  @app.post("/api/robots/{robot_id}/request-session")
696
- async def request_session(robot_id: str,
697
- authorization: Optional[str] = Header(default=None)):
792
+ async def request_session(robot_id: str,
793
+ authorization: Optional[str] = Header(default=None)):
698
794
  """Robot requests a session after scene detection.
699
795
 
700
796
  Requires a valid enrolled-device Bearer token. The response never contains
701
797
  the LiveKit api_key/api_secret; instead the scheduler mints a short-lived
702
798
  JWT the robot uses to join the room."""
703
- if not await _authorize_fleet(authorization):
704
- raise HTTPException(401, "Invalid or missing device token")
799
+ if not await _authorize_fleet(authorization):
800
+ raise HTTPException(401, "Invalid or missing device token")
801
+ if not await get_production_mode():
802
+ raise HTTPException(403, "production_mode_off")
705
803
  async with aiosqlite.connect(DB_PATH) as db:
706
804
  db.row_factory = aiosqlite.Row
707
805
 
@@ -860,13 +958,24 @@ async def simulate_trigger(robot_id: str):
860
958
  return {"queued": False, "reason": "production_mode_off"}
861
959
  async with aiosqlite.connect(DB_PATH) as db:
862
960
  db.row_factory = aiosqlite.Row
863
- async with db.execute(
864
- "SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
865
- ) as c:
866
- robot = await c.fetchone()
867
- if not robot:
868
- raise HTTPException(404, "Robot not found")
869
- if robot["current_session_id"]:
961
+ async with db.execute(
962
+ "SELECT id, current_session_id FROM robots WHERE id = ?", (robot_id,)
963
+ ) as c:
964
+ robot = await c.fetchone()
965
+ if not robot:
966
+ async with db.execute("SELECT id, name, character_id FROM devices WHERE id = ?", (robot_id,)) as c:
967
+ device = await c.fetchone()
968
+ if not device:
969
+ raise HTTPException(404, "Robot not found")
970
+ await db.execute(
971
+ "INSERT OR IGNORE INTO robots (id, name, character_id, status) VALUES (?, ?, ?, 'idle')",
972
+ (device["id"], device["name"], device["character_id"]),
973
+ )
974
+ await db.commit()
975
+ robot = {"id": device["id"], "current_session_id": None}
976
+ if not await _effective_device_production_mode(db, robot_id):
977
+ return {"queued": False, "reason": "device_production_mode_off"}
978
+ if robot["current_session_id"]:
870
979
  return {"queued": False, "reason": "session_active"}
871
980
  await db.execute(
872
981
  "INSERT INTO trigger_commands (robot_id, source, requested_at) VALUES (?, ?, ?) "
@@ -1542,14 +1651,16 @@ async def health_checker():
1542
1651
  # =============================================================================
1543
1652
 
1544
1653
  @app.get("/api/settings")
1545
- async def get_settings():
1546
- """Public settings (production mode + whether default enrollment token exists)."""
1547
- pm = await get_production_mode()
1548
- has_default_token = bool(await get_setting("default_enrollment_token"))
1549
- return {
1550
- "production_mode": pm,
1551
- "default_enrollment_token_set": has_default_token,
1552
- }
1654
+ async def get_settings():
1655
+ """Public settings with secret values reduced to status only."""
1656
+ pm = await get_production_mode()
1657
+ has_default_token = bool(await get_setting("default_enrollment_token"))
1658
+ return {
1659
+ "production_mode": pm,
1660
+ "default_enrollment_token_set": has_default_token,
1661
+ "agent_token_configured": bool(ROBOPARK_AGENT_TOKEN),
1662
+ "agent_token_source": "environment" if os.getenv("ROBOPARK_AGENT_TOKEN", "").strip() else "scheduler_secret_file",
1663
+ }
1553
1664
 
1554
1665
  @app.put("/api/settings")
1555
1666
  async def update_settings(payload: SettingsPayload):
@@ -1573,14 +1684,40 @@ async def update_settings(payload: SettingsPayload):
1573
1684
  await _log_history("settings", "settings", "production_mode", "updated", "operator", f"value={payload.production_mode}")
1574
1685
  return {"status": "ok", "production_mode": payload.production_mode}
1575
1686
 
1576
- @app.post("/api/settings/enrollment-token/rotate")
1577
- async def rotate_enrollment_token():
1687
+ @app.post("/api/settings/enrollment-token/rotate")
1688
+ async def rotate_enrollment_token():
1578
1689
  """Generate a fresh default enrollment token (rotates the secret)."""
1579
1690
  token = secrets.token_urlsafe(24)
1580
1691
  await set_setting("default_enrollment_token", _hash(token))
1581
1692
  logger.info("Default enrollment token rotated")
1582
1693
  await _log_history("settings", "settings", "default_enrollment_token", "rotated", "operator")
1583
- return {"enrollment_token": token}
1694
+ return {"enrollment_token": token}
1695
+
1696
+ @app.post("/api/settings/agent-token/provision")
1697
+ async def provision_agent_token(payload: Optional[dict] = None):
1698
+ """Return the worker secret once so the UI/CLI can provision ROBOVOICE.
1699
+
1700
+ The value is never included in normal settings responses or logs. Rotation
1701
+ is explicit because existing workers would otherwise lose connectivity.
1702
+ """
1703
+ global ROBOPARK_AGENT_TOKEN
1704
+ rotate = bool((payload or {}).get("rotate"))
1705
+ if rotate or not ROBOPARK_AGENT_TOKEN:
1706
+ ROBOPARK_AGENT_TOKEN = secrets.token_urlsafe(32)
1707
+ os.makedirs(os.path.dirname(ROBOPARK_AGENT_TOKEN_FILE) or ".", exist_ok=True)
1708
+ temp_path = ROBOPARK_AGENT_TOKEN_FILE + ".tmp"
1709
+ with open(temp_path, "w", encoding="utf-8") as f:
1710
+ f.write(ROBOPARK_AGENT_TOKEN + "\n")
1711
+ os.replace(temp_path, ROBOPARK_AGENT_TOKEN_FILE)
1712
+ try:
1713
+ os.chmod(ROBOPARK_AGENT_TOKEN_FILE, 0o600)
1714
+ except OSError:
1715
+ pass
1716
+ await _log_history("settings", "settings", "agent_token", "rotated", "operator")
1717
+ return {
1718
+ "agent_token": ROBOPARK_AGENT_TOKEN,
1719
+ "warning": "Shown once by this response. Store it only in the ROBOVOICE worker environment.",
1720
+ }
1584
1721
 
1585
1722
  @app.get("/api/history")
1586
1723
  async def get_history(category: Optional[str] = None, limit: int = 200):
@@ -2181,18 +2318,63 @@ async def _authorize_device(device_id: str, authorization: Optional[str]) -> boo
2181
2318
 
2182
2319
  async def _log_history(category: str, entity_type: Optional[str] = None, entity_id: Optional[str] = None,
2183
2320
  action: Optional[str] = None, actor: Optional[str] = None, details: Optional[str] = None):
2184
- """Append an immutable audit/history record."""
2321
+ """Append an immutable audit/history record.
2322
+
2323
+ The actor argument is overridden by `_current_actor_override` (a
2324
+ contextvar set by the FastAPI middleware from the X-Operator
2325
+ header) when present. The middleware is the only writer to the
2326
+ contextvar, so an untrusted caller cannot impersonate another
2327
+ operator by setting it directly. Falls back to the call-site
2328
+ `actor` argument when no override is set (e.g. system events)."""
2329
+ final_actor = _current_actor_override.get() or actor
2185
2330
  try:
2186
2331
  async with aiosqlite.connect(DB_PATH) as db:
2187
2332
  await db.execute(
2188
2333
  """INSERT INTO history_log (timestamp, category, entity_type, entity_id, action, actor, details)
2189
2334
  VALUES (?, ?, ?, ?, ?, ?, ?)""",
2190
- (datetime.utcnow().isoformat(), category, entity_type, entity_id, action, actor, details),
2335
+ (datetime.utcnow().isoformat(), category, entity_type, entity_id, action, final_actor, details),
2191
2336
  )
2192
2337
  await db.commit()
2193
2338
  except Exception as e:
2194
2339
  logger.warning(f"history log failed: {e}")
2195
2340
 
2341
+
2342
+ # Per-request operator name. Set by the operator_actor_middleware (which
2343
+ # reads the X-Operator header) and read by _log_history. Sanitized at
2344
+ # the middleware boundary so the value reaching the database is always
2345
+ # a safe printable string.
2346
+ _current_actor_override: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("current_actor", default=None)
2347
+
2348
+
2349
+ def _sanitize_actor_name(name: Optional[str]) -> Optional[str]:
2350
+ """Trim, strip control chars, cap at 64 chars. Returns None if the
2351
+ result is empty so the caller can fall back to the default actor."""
2352
+ if not name:
2353
+ return None
2354
+ s = str(name).strip()
2355
+ if not s:
2356
+ return None
2357
+ # Allow printable ASCII + tab; strip everything else. Newlines and
2358
+ # CR would split a single history row into two display lines, so
2359
+ # we collapse them to a single space.
2360
+ out = []
2361
+ for c in s:
2362
+ if c == "\t" or c == " ":
2363
+ out.append(c)
2364
+ elif 0x20 <= ord(c) < 0x7f:
2365
+ out.append(c)
2366
+ else:
2367
+ # collapse any other whitespace-ish char to a space
2368
+ if c in ("\n", "\r", "\v", "\f"):
2369
+ out.append(" ")
2370
+ # drop anything else silently
2371
+ cleaned = "".join(out).strip()
2372
+ if not cleaned:
2373
+ return None
2374
+ if len(cleaned) > 64:
2375
+ cleaned = cleaned[:64]
2376
+ return cleaned
2377
+
2196
2378
  async def _authorize_fleet(authorization: Optional[str]) -> bool:
2197
2379
  """Verify a Bearer token matches ANY enrolled device token (fleet auth).
2198
2380
 
@@ -2414,12 +2596,55 @@ async def device_supervisor_status(device_id: str, payload: SupervisorStatusRepo
2414
2596
  # "restart" on a service) onto this same request/response cycle --
2415
2597
  # the robot already calls this every 10s, no separate poll needed.
2416
2598
  # Consumed immediately: read then delete, so a command fires once.
2417
- async with db.execute(
2418
- "SELECT service_name, action FROM supervisor_commands WHERE device_id = ?", (device_id,)
2419
- ) as c:
2420
- pending = [dict(r) for r in await c.fetchall()]
2599
+ # Backward-compat: rows may or may not have a `kind` column on
2600
+ # older DBs. Default to the original "supervisor_action" kind
2601
+ # (treated as restart / start / stop on the service) when missing.
2602
+ pending = []
2603
+ try:
2604
+ async with db.execute(
2605
+ "SELECT service_name, action, kind, params, id FROM shell_requests "
2606
+ "WHERE device_id = ? AND completed_at IS NULL ORDER BY requested_at",
2607
+ (device_id,),
2608
+ ) as c:
2609
+ shell_rows = [dict(r) for r in await c.fetchall()]
2610
+ for r in shell_rows:
2611
+ pending.append({
2612
+ "service_name": r.get("service_name") or "",
2613
+ "action": "run",
2614
+ "kind": r.get("kind") or "shell_run",
2615
+ "params": json.loads(r["params"]) if r.get("params") else {},
2616
+ "id": r["id"],
2617
+ })
2618
+ except Exception:
2619
+ shell_rows = []
2620
+ try:
2621
+ async with db.execute(
2622
+ "SELECT service_name, action FROM supervisor_commands WHERE device_id = ?", (device_id,)
2623
+ ) as c:
2624
+ for r in await c.fetchall():
2625
+ pending.append({"service_name": r["service_name"], "action": r["action"], "kind": "supervisor_action"})
2626
+ except Exception:
2627
+ pass
2421
2628
  if pending:
2422
- await db.execute("DELETE FROM supervisor_commands WHERE device_id = ?", (device_id,))
2629
+ # Mark shell requests as "in flight" so the dashboard can
2630
+ # distinguish "queued" from "still queued" (the row is read
2631
+ # but not yet completed). supervisor_commands rows are
2632
+ # one-shot and are deleted to mirror the previous behavior.
2633
+ try:
2634
+ ids = [p["id"] for p in pending if p.get("id")]
2635
+ if ids:
2636
+ placeholders = ",".join("?" for _ in ids)
2637
+ await db.execute(
2638
+ f"UPDATE shell_requests SET requested_at = requested_at "
2639
+ f"WHERE id IN ({placeholders})",
2640
+ ids,
2641
+ )
2642
+ except Exception:
2643
+ pass
2644
+ try:
2645
+ await db.execute("DELETE FROM supervisor_commands WHERE device_id = ?", (device_id,))
2646
+ except Exception:
2647
+ pass
2423
2648
  await db.commit()
2424
2649
  await broadcast("supervisor_status", {"device_id": device_id, "services": blob})
2425
2650
  return {"status": "ok", "commands": pending}
@@ -2446,6 +2671,244 @@ async def restart_device_service(device_id: str, service_name: str):
2446
2671
  await _log_history("device", "device", device_id, "service_restart_queued", "operator", f"service={service_name}")
2447
2672
  return {"status": "queued", "service": service_name}
2448
2673
 
2674
+
2675
+ # ── Operator shell (C1) ──
2676
+ # Endpoints the dashboard calls to tail logs and run a small allowlisted
2677
+ # set of diagnostic commands on a specific robot. Commands are queued in
2678
+ # the `shell_requests` table; the robot's supervisor picks them up on its
2679
+ # next status-report poll, runs them, and POSTs results back to
2680
+ # /api/devices/{id}/supervisor-output. The dashboard polls
2681
+ # /api/robots/{id}/shell/result/{request_id} with an 8s budget.
2682
+
2683
+ class ShellRequest(BaseModel):
2684
+ kind: str # "tail_logs" | "shell_run"
2685
+ service: Optional[str] = None
2686
+ params: Optional[dict] = None
2687
+
2688
+
2689
+ async def _queue_shell_request_async(device_id: str, kind: str, service: Optional[str], params: dict) -> str:
2690
+ """Insert a shell request row and return its id. Picked up by the
2691
+ robot's supervisor on its next status-report poll."""
2692
+ request_id = secrets.token_urlsafe(12)
2693
+ now = datetime.utcnow().isoformat()
2694
+ async with aiosqlite.connect(DB_PATH) as db:
2695
+ await db.execute(
2696
+ """INSERT INTO shell_requests
2697
+ (id, device_id, service_name, kind, params, requested_at)
2698
+ VALUES (?, ?, ?, ?, ?, ?)""",
2699
+ (request_id, device_id, service, kind, json.dumps(params or {}), now),
2700
+ )
2701
+ await db.commit()
2702
+ return request_id
2703
+
2704
+
2705
+ @app.post("/api/robots/{robot_id}/shell/tail")
2706
+ async def robot_shell_tail(robot_id: str, payload: ShellRequest):
2707
+ """Queue a tail-logs request for a robot. Returns a request_id the
2708
+ dashboard polls to get the result.
2709
+
2710
+ robot_id == device_id in this codebase (the dashboard's "robot" id is
2711
+ the same value as /api/devices rows)."""
2712
+ device_id = robot_id
2713
+ params = dict(payload.params or {})
2714
+ if "lines" not in params:
2715
+ params["lines"] = 200
2716
+ request_id = await _queue_shell_request_async(device_id, "tail_logs", payload.service, params)
2717
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
2718
+
2719
+
2720
+ @app.post("/api/robots/{robot_id}/shell/exec")
2721
+ async def robot_shell_exec(robot_id: str, payload: ShellRequest):
2722
+ """Queue a shell-run request for a robot. Returns a request_id the
2723
+ dashboard polls to get the result."""
2724
+ device_id = robot_id
2725
+ params = dict(payload.params or {})
2726
+ if not params.get("name"):
2727
+ raise HTTPException(400, "params.name is required (e.g. 'uptime', 'free', 'ps', 'tail')")
2728
+ request_id = await _queue_shell_request_async(device_id, "shell_run", payload.service, params)
2729
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
2730
+
2731
+
2732
+ @app.get("/api/robots/{robot_id}/shell/result/{request_id}")
2733
+ async def robot_shell_result(robot_id: str, request_id: str):
2734
+ """Return the result of a previously-queued shell request. The
2735
+ dashboard polls this with an 8s budget; we reply as soon as the
2736
+ robot has reported the result back."""
2737
+ async with aiosqlite.connect(DB_PATH) as db:
2738
+ db.row_factory = aiosqlite.Row
2739
+ async with db.execute("SELECT * FROM shell_requests WHERE id = ?", (request_id,)) as c:
2740
+ r = await c.fetchone()
2741
+ if not r or r["device_id"] != robot_id:
2742
+ raise HTTPException(404, "No such shell request for this robot")
2743
+ out = {
2744
+ "request_id": r["id"],
2745
+ "device_id": r["device_id"],
2746
+ "service": r["service_name"],
2747
+ "kind": r["kind"],
2748
+ "params": json.loads(r["params"]) if r["params"] else {},
2749
+ "requested_at": r["requested_at"],
2750
+ "completed": r["completed_at"] is not None,
2751
+ "completed_at": r["completed_at"],
2752
+ "result": None,
2753
+ }
2754
+ if r["completed_at"] is not None:
2755
+ try:
2756
+ out["result"] = json.loads(r["result_payload"]) if r["result_payload"] else {"ok": bool(r["result_ok"])}
2757
+ except Exception:
2758
+ out["result"] = {"ok": bool(r["result_ok"]), "error": "(result payload not parseable)"}
2759
+ return out
2760
+
2761
+
2762
+ # Long-poll variant: holds the connection open until the result arrives
2763
+ # (or the 8s budget elapses), so the dashboard can wait without polling
2764
+ # in a tight loop.
2765
+ @app.get("/api/robots/{robot_id}/shell/wait/{request_id}")
2766
+ async def robot_shell_wait(robot_id: str, request_id: str, timeout: float = 8.0):
2767
+ deadline = asyncio.get_event_loop().time() + max(0.5, min(15.0, timeout))
2768
+ while True:
2769
+ async with aiosqlite.connect(DB_PATH) as db:
2770
+ db.row_factory = aiosqlite.Row
2771
+ async with db.execute("SELECT * FROM shell_requests WHERE id = ?", (request_id,)) as c:
2772
+ r = await c.fetchone()
2773
+ if not r or r["device_id"] != robot_id:
2774
+ raise HTTPException(404, "No such shell request for this robot")
2775
+ if r["completed_at"] is not None:
2776
+ try:
2777
+ payload = json.loads(r["result_payload"]) if r["result_payload"] else {"ok": bool(r["result_ok"])}
2778
+ except Exception:
2779
+ payload = {"ok": bool(r["result_ok"]), "error": "(result payload not parseable)"}
2780
+ return {"request_id": request_id, "completed": True, "result": payload}
2781
+ if asyncio.get_event_loop().time() >= deadline:
2782
+ return {"request_id": request_id, "completed": False, "timed_out": True}
2783
+ await asyncio.sleep(0.4)
2784
+
2785
+
2786
+ @app.post("/api/devices/{device_id}/supervisor-output")
2787
+ async def device_supervisor_output(device_id: str, payload: dict, authorization: Optional[str] = Header(default=None)):
2788
+ """Robot supervisor posts back the result of a tail_logs or shell_run
2789
+ request. We just record it on the shell_requests row; the dashboard
2790
+ picks it up via /api/robots/{id}/shell/result/{request_id}."""
2791
+ if not await _authorize_device(device_id, authorization):
2792
+ raise HTTPException(401, "Invalid device token")
2793
+ request_id = payload.get("request_id")
2794
+ if not request_id:
2795
+ raise HTTPException(400, "request_id is required")
2796
+ now = datetime.utcnow().isoformat()
2797
+ result_payload = payload.get("payload") or {}
2798
+ ok = 1 if result_payload.get("ok") else 0
2799
+ async with aiosqlite.connect(DB_PATH) as db:
2800
+ db.row_factory = aiosqlite.Row
2801
+ async with db.execute("SELECT device_id FROM shell_requests WHERE id = ?", (request_id,)) as c:
2802
+ r = await c.fetchone()
2803
+ if not r:
2804
+ raise HTTPException(404, "No such shell request")
2805
+ if r["device_id"] != device_id:
2806
+ raise HTTPException(403, "request_id does not belong to this device")
2807
+ await db.execute(
2808
+ """UPDATE shell_requests SET completed_at = ?, result_ok = ?, result_payload = ?
2809
+ WHERE id = ?""",
2810
+ (now, ok, json.dumps(result_payload), request_id),
2811
+ )
2812
+ await db.commit()
2813
+ kind = payload.get("kind") or "shell"
2814
+ service = payload.get("service") or ""
2815
+ await _log_history("device", "device", device_id, f"shell_{kind}_completed", "operator",
2816
+ f"service={service}, request_id={request_id}, ok={bool(ok)}")
2817
+ return {"status": "ok"}
2818
+
2819
+
2820
+ # Endpoint the dashboard uses to list the available shell commands
2821
+ # (allowlist) so the UI can populate a picker instead of typing names.
2822
+ # The list mirrors the SHELL_ALLOWLIST in robot_supervisor.py — that
2823
+ # file is the authoritative source for what's actually allowed at
2824
+ # run time. Keep these two lists in sync when adding/removing commands.
2825
+ SHELL_DASHBOARD_ALLOWLIST = [
2826
+ {"name": "uptime", "argv": ["uptime"]},
2827
+ {"name": "free", "argv": ["free", "-m"]},
2828
+ {"name": "df", "argv": ["df", "-h"]},
2829
+ {"name": "ps", "argv": ["ps", "aux"]},
2830
+ {"name": "uname", "argv": ["uname", "-a"]},
2831
+ {"name": "date", "argv": ["date"]},
2832
+ {"name": "whoami", "argv": ["whoami"]},
2833
+ {"name": "hostname", "argv": ["hostname"]},
2834
+ {"name": "ip", "argv": ["ip", "addr"]},
2835
+ {"name": "ss", "argv": ["ss", "-tlnp"]},
2836
+ {"name": "os-release", "argv": ["cat", "/etc/os-release"]},
2837
+ {"name": "ls-logs", "argv": ["ls", "-la", "<dir>"]},
2838
+ {"name": "systemctl-status", "argv": ["systemctl", "status", "<name>", "--no-pager", "-n", "30"]},
2839
+ {"name": "journalctl", "argv": ["journalctl", "-u", "<name>", "-n", "200", "--no-pager"]},
2840
+ {"name": "tail", "argv": ["tail", "-n", "<n>", "<path>"]},
2841
+ ]
2842
+ SHELL_DASHBOARD_OUTPUT_MAX = 64 * 1024
2843
+ SHELL_DASHBOARD_TIMEOUT = 8.0
2844
+
2845
+ @app.get("/api/shell/allowlist")
2846
+ async def shell_allowlist():
2847
+ """Return the list of shell command names the robot's supervisor
2848
+ supports. The dashboard uses this to populate the picker; the
2849
+ robot side does the actual allowlist enforcement at run time."""
2850
+ return {
2851
+ "commands": [
2852
+ {"name": s["name"], "argv": s["argv"]}
2853
+ for s in SHELL_DASHBOARD_ALLOWLIST
2854
+ ],
2855
+ "max_output_bytes": SHELL_DASHBOARD_OUTPUT_MAX,
2856
+ "timeout_seconds": SHELL_DASHBOARD_TIMEOUT,
2857
+ }
2858
+
2859
+
2860
+ # ── Speaker roundtrip test (C2) ──
2861
+ # The dashboard's "Test speaker" button hits this. We queue a
2862
+ # speaker_test request on the robot's shell_requests table; the
2863
+ # robot's next status-report poll runs the round-trip (play tone,
2864
+ # record mic) and POSTs the result back via /supervisor-output.
2865
+ # The dashboard polls the same /shell/wait endpoint used for the
2866
+ # other shell commands (the supervisor treats speaker_test as
2867
+ # just another request kind).
2868
+
2869
+ class SpeakerTestRequest(BaseModel):
2870
+ frequency: Optional[float] = None
2871
+ duration: Optional[float] = None
2872
+ amplitude: Optional[float] = None
2873
+ threshold_db: Optional[float] = None
2874
+ output: Optional[str] = None
2875
+ input: Optional[str] = None
2876
+ sample_rate: Optional[int] = None
2877
+
2878
+
2879
+ @app.post("/api/robots/{robot_id}/shell/speaker-test")
2880
+ async def robot_speaker_test(robot_id: str, payload: SpeakerTestRequest):
2881
+ """Queue a speaker + mic round-trip test on the robot. Returns a
2882
+ request_id; poll /api/robots/{id}/shell/wait/{rid} for the result."""
2883
+ device_id = robot_id
2884
+ params = payload.model_dump(exclude_none=True)
2885
+ request_id = await _queue_shell_request_async(device_id, "speaker_test", None, params)
2886
+ return {"status": "queued", "request_id": request_id, "device_id": device_id}
2887
+
2888
+
2889
+ # ── Incident acknowledgement (C4) ──
2890
+ # The dashboard's Incidents sub-tab lets an operator acknowledge an
2891
+ # active incident (a robot stuck in stuck_session, server_unavailable,
2892
+ # etc). The acknowledgement is stored client-side (localStorage) so
2893
+ # each operator on their own browser gets their own list; this
2894
+ # endpoint is the "central log" mirror so the ack also shows up in
2895
+ # the audit history with the operator's name (via X-Operator).
2896
+ class IncidentAck(BaseModel):
2897
+ robot_id: str
2898
+ code: str
2899
+ message: Optional[str] = None
2900
+
2901
+ @app.post("/api/incidents/ack")
2902
+ async def incidents_ack(payload: IncidentAck, x_operator: Optional[str] = Header(default=None, alias="X-Operator")):
2903
+ """Log an operator ack of an active incident. The dashboard
2904
+ stores its own list; this endpoint mirrors the ack in the audit
2905
+ log so the action is visible centrally with the operator's name."""
2906
+ actor = _sanitize_actor_name(x_operator) or 'operator'
2907
+ await _log_history("incident", "robot", payload.robot_id, "acked", actor,
2908
+ f"code={payload.code}, message={payload.message or ''}")
2909
+ return {"status": "ok", "robot_id": payload.robot_id, "code": payload.code, "by": actor, "at": datetime.utcnow().isoformat()}
2910
+
2911
+
2449
2912
  @app.get("/api/devices/{device_id}/config")
2450
2913
  async def device_config(device_id: str, authorization: Optional[str] = Header(default=None)):
2451
2914
  """Pi polls this to learn current production_mode + assigned character, etc."""
@@ -2506,10 +2969,14 @@ async def device_request_session(device_id: str,
2506
2969
  db.row_factory = aiosqlite.Row
2507
2970
 
2508
2971
  # Resolve the enrolled device.
2509
- async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
2510
- device = await c.fetchone()
2511
- if not device:
2512
- raise HTTPException(404, "Device not found")
2972
+ async with db.execute("SELECT * FROM devices WHERE id = ?", (device_id,)) as c:
2973
+ device = await c.fetchone()
2974
+ if not device:
2975
+ raise HTTPException(404, "Device not found")
2976
+ if not await get_production_mode():
2977
+ raise HTTPException(403, "production_mode_off")
2978
+ if not bool(device["production_mode"]):
2979
+ raise HTTPException(403, "device_production_mode_off")
2513
2980
 
2514
2981
  # Mirror device -> robot identity (id == device_id). Additive + idempotent:
2515
2982
  # if the robot row already exists it is left untouched.
@@ -3693,12 +4160,16 @@ async def dashboard():
3693
4160
  const connected = ref(false);
3694
4161
  const serverMetrics = ref({});
3695
4162
  const devices = ref([]);
3696
- const settings = ref({ production_mode: false, default_enrollment_token_set: false });
4163
+ const settings = ref({ production_mode: false, default_enrollment_token_set: false, agent_token_configured: false, agent_token_source: null });
3697
4164
  const livekit = ref({ url: null, api_key: null, has_secret: false });
3698
- const showAddDevice = ref(false);
3699
- const showDevices = ref(true);
4165
+ const showAddDevice = ref(false);
4166
+ const showDevices = ref(true);
4167
+ const showCommands = ref(false);
4168
+ const commandNetwork = ref('lan');
4169
+ const commandRobotId = ref('');
3700
4170
  const newDevice = ref({ name: '', tailscale_ip: '', lan_ip: '', motor_server_url: '', character_id: '', livekit_url: '', video_device: 'auto', audio_device: 'default', notes: '' });
3701
- const rotatedToken = ref(null);
4171
+ const rotatedToken = ref(null);
4172
+ const enrollmentNetwork = ref('lan');
3702
4173
  const preview = ref({ robot: null, active: false, url: null, token: null, room: null, keepalive: null });
3703
4174
  const simulation = ref({ robotId: null, busy: false });
3704
4175
  const deviceInventory = ref({});
@@ -3756,18 +4227,25 @@ async def dashboard():
3756
4227
 
3757
4228
  const buildDeviceInventory = () => {
3758
4229
  const map = {};
3759
- for (const robot of robots.value) {
3760
- const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
3761
- const savedVideo = device?.video_device || 'auto';
3762
- const savedAudio = device?.audio_device || 'default';
3763
- map[robot.id] = {
3764
- deviceId: device?.id || null,
3765
- robotName: robot.name,
3766
- selectedVideo: savedVideo,
3767
- selectedAudio: savedAudio,
3768
- videoOptions: ['auto', 'none', 'picamera', '/dev/video0', '/dev/video1', '/dev/video2'],
3769
- audioOptions: ['default', 'none'],
3770
- };
4230
+ for (const robot of robots.value) {
4231
+ const device = devices.value.find(d => d.name === robot.name || d.id === robot.id);
4232
+ const savedVideo = device?.video_device || 'auto';
4233
+ const savedAudio = device?.audio_device || 'default';
4234
+ const hardware = device?.device_inventory || {};
4235
+ const videoOptions = (hardware.video || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4236
+ const audioOptions = (hardware.audio_input || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4237
+ const audioOutputOptions = (hardware.audio_output || []).map(x => typeof x === 'string' ? x : x.id).filter(Boolean);
4238
+ map[robot.id] = {
4239
+ deviceId: device?.id || null,
4240
+ robotName: robot.name,
4241
+ selectedVideo: savedVideo,
4242
+ selectedAudio: savedAudio,
4243
+ selectedAudioOutput: device?.audio_output_device || 'default',
4244
+ greetingText: (device?.greeting_phrases || []).join('\\n'),
4245
+ videoOptions: [...new Set(['auto', 'none', savedVideo, ...videoOptions])],
4246
+ audioOptions: [...new Set(['default', 'none', savedAudio, ...audioOptions])],
4247
+ audioOutputOptions: [...new Set(['default', 'none', device?.audio_output_device || 'default', ...audioOutputOptions])],
4248
+ };
3771
4249
  }
3772
4250
  deviceInventory.value = map;
3773
4251
  };
@@ -3837,7 +4315,7 @@ async def dashboard():
3837
4315
  }
3838
4316
  };
3839
4317
 
3840
- const rotateEnrollmentToken = async () => {
4318
+ const rotateEnrollmentToken = async () => {
3841
4319
  if (!confirm('Rotate the global default enrollment token? Any Pi waiting to enroll with the previous token will be rejected.')) return;
3842
4320
  try {
3843
4321
  const r = await fetch('/api/settings/enrollment-token/rotate', { method: 'POST' }).then(r => r.json());
@@ -3846,7 +4324,23 @@ async def dashboard():
3846
4324
  } catch (e) {
3847
4325
  alert('Failed to rotate token: ' + e.message);
3848
4326
  }
3849
- };
4327
+ };
4328
+
4329
+ const provisionAgentToken = async (rotate = false) => {
4330
+ if (rotate && !confirm('Rotate the ROBOVOICE authentication secret? Existing workers must be restarted after synchronization.')) return;
4331
+ try {
4332
+ const r = await fetch('/api/settings/agent-token/provision', {
4333
+ method: 'POST', headers: {'Content-Type': 'application/json'},
4334
+ body: JSON.stringify({ rotate }),
4335
+ }).then(r => r.json());
4336
+ if (!r.agent_token) throw new Error(r.detail || 'No secret returned');
4337
+ alert('ROBOVOICE secret (shown once):\\n\\n' + r.agent_token + '\\n\\nCopy it into the worker environment or run: robopark secrets --scheduler-url ' + window.location.origin + ' --worker-env <ROBOVOICE>/.env');
4338
+ settings.value.agent_token_configured = true;
4339
+ await fetchData();
4340
+ } catch (e) {
4341
+ alert('Failed to provision agent secret: ' + e.message);
4342
+ }
4343
+ };
3850
4344
 
3851
4345
  const submitNewDevice = async () => {
3852
4346
  try {
@@ -3866,7 +4360,7 @@ async def dashboard():
3866
4360
  }
3867
4361
  };
3868
4362
 
3869
- const updateDeviceMedia = async (robotId) => {
4363
+ const updateDeviceMedia = async (robotId) => {
3870
4364
  const inv = deviceInventory.value[robotId];
3871
4365
  if (!inv || !inv.deviceId) return;
3872
4366
  try {
@@ -3874,18 +4368,20 @@ async def dashboard():
3874
4368
  method: 'PATCH',
3875
4369
  headers: {'Content-Type': 'application/json'},
3876
4370
  body: JSON.stringify({
3877
- video_device: inv.selectedVideo,
3878
- audio_device: inv.selectedAudio,
3879
- }),
3880
- });
3881
- fetchData();
4371
+ video_device: inv.selectedVideo,
4372
+ audio_device: inv.selectedAudio,
4373
+ audio_output_device: inv.selectedAudioOutput,
4374
+ }),
4375
+ });
4376
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
4377
+ fetchData();
3882
4378
  } catch (e) {
3883
4379
  alert('Failed to update device media: ' + e.message);
3884
4380
  }
3885
4381
  };
3886
4382
 
3887
- const startPreview = async (robot) => {
3888
- stopPreview();
4383
+ const startPreview = async (robot) => {
4384
+ await stopPreview();
3889
4385
  try {
3890
4386
  const r = await fetch(`/api/robots/${robot.id}/preview/start`, { method: 'POST' }).then(r => r.json());
3891
4387
  if (!r.active) {
@@ -3905,13 +4401,19 @@ async def dashboard():
3905
4401
  }
3906
4402
  };
3907
4403
 
3908
- const stopPreview = () => {
3909
- if (preview.value.keepalive) clearInterval(preview.value.keepalive);
3910
- if (preview.value.room) {
3911
- try { window.__lkPreviewRoom?.disconnect(); } catch (e) {}
3912
- }
3913
- preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
3914
- };
4404
+ const stopPreview = async () => {
4405
+ const robotId = preview.value.robot?.id;
4406
+ if (preview.value.keepalive) clearInterval(preview.value.keepalive);
4407
+ if (preview.value.room) {
4408
+ try { window.__lkPreviewRoom?.disconnect(); } catch (e) {}
4409
+ }
4410
+ preview.value = { robot: null, active: false, url: null, token: null, room: null, keepalive: null };
4411
+ if (robotId) {
4412
+ try {
4413
+ await fetch(`/api/robots/${robotId}/preview/stop`, { method: 'POST' });
4414
+ } catch (e) { console.warn('preview stop failed', e); }
4415
+ }
4416
+ };
3915
4417
 
3916
4418
  const simulateTrigger = async (robot) => {
3917
4419
  if (!settings.value.production_mode || simulation.value.busy) return;
@@ -3931,7 +4433,7 @@ async def dashboard():
3931
4433
  }
3932
4434
  };
3933
4435
 
3934
- const stopConversation = async (robot) => {
4436
+ const stopConversation = async (robot) => {
3935
4437
  try {
3936
4438
  const response = await fetch(`/api/robots/${robot.id}/simulate-stop`, { method: 'POST' });
3937
4439
  const result = await response.json();
@@ -3940,7 +4442,52 @@ async def dashboard():
3940
4442
  } catch (e) {
3941
4443
  alert('Failed to stop conversation: ' + e.message);
3942
4444
  }
3943
- };
4445
+ };
4446
+
4447
+ const recoverRobot = async (robot) => {
4448
+ if (!confirm(`Clear stale session state for ${robot.name}?`)) return;
4449
+ try {
4450
+ const response = await fetch(`/api/robots/${robot.id}/recover`, { method: 'POST' });
4451
+ const result = await response.json();
4452
+ if (!response.ok) throw new Error(result.detail || `HTTP ${response.status}`);
4453
+ await fetchData();
4454
+ } catch (e) {
4455
+ alert('Failed to recover robot: ' + e.message);
4456
+ }
4457
+ };
4458
+
4459
+ const toggleDeviceProduction = async (robotId) => {
4460
+ const inv = deviceInventory.value[robotId];
4461
+ if (!inv || !inv.deviceId) return;
4462
+ await setDeviceProduction(inv.deviceId);
4463
+ };
4464
+
4465
+ const setDeviceProduction = async (deviceId) => {
4466
+ const device = devices.value.find(d => d.id === deviceId);
4467
+ if (!device) return;
4468
+ try {
4469
+ const response = await fetch(`/api/devices/${deviceId}`, {
4470
+ method: 'PATCH', headers: {'Content-Type': 'application/json'},
4471
+ body: JSON.stringify({ production_mode: !device?.production_mode }),
4472
+ });
4473
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
4474
+ await fetchData();
4475
+ } catch (e) { alert('Failed to change robot production mode: ' + e.message); }
4476
+ };
4477
+
4478
+ const saveDeviceGreetings = async (robotId) => {
4479
+ const inv = deviceInventory.value[robotId];
4480
+ if (!inv || !inv.deviceId) return;
4481
+ try {
4482
+ const greeting_phrases = inv.greetingText.split(/\\r?\\n/).map(s => s.trim()).filter(Boolean).slice(0, 20);
4483
+ const response = await fetch(`/api/devices/${inv.deviceId}`, {
4484
+ method: 'PATCH', headers: {'Content-Type': 'application/json'},
4485
+ body: JSON.stringify({ greeting_phrases }),
4486
+ });
4487
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
4488
+ await fetchData();
4489
+ } catch (e) { alert('Failed to save greetings: ' + e.message); }
4490
+ };
3944
4491
 
3945
4492
  const connectLiveKitPreview = async (url, token) => {
3946
4493
  const { Room } = LiveKitClient;
@@ -3977,7 +4524,15 @@ async def dashboard():
3977
4524
  }
3978
4525
  };
3979
4526
 
3980
- const dismissRotatedToken = () => { rotatedToken.value = null; };
4527
+ const dismissRotatedToken = () => { rotatedToken.value = null; };
4528
+ const enrollmentCommand = (token) => {
4529
+ if (!token) return '';
4530
+ const schedulerPort = window.location.port || '8080';
4531
+ const networkFlag = enrollmentNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
4532
+ const hubProtocol = window.location.protocol === 'https:' ? 'https://' : 'http://';
4533
+ const hubUrl = `${hubProtocol}${window.location.hostname}:47913`;
4534
+ return `robopark setup --robot --name "${token.name || token.device_id}" --hub-url ${hubUrl} --scheduler-port ${schedulerPort} ${networkFlag} --enrollment-token ${token.token} --start --auto-start`;
4535
+ };
3981
4536
 
3982
4537
  const joinConversation = async (device) => {
3983
4538
  try {
@@ -4080,9 +4635,10 @@ async def dashboard():
4080
4635
  setInterval(fetchData, 30000);
4081
4636
  });
4082
4637
 
4083
- onUnmounted(() => {
4084
- if (ws) ws.close();
4085
- });
4638
+ onUnmounted(() => {
4639
+ if (ws) ws.close();
4640
+ stopPreview();
4641
+ });
4086
4642
 
4087
4643
  const openVoiceStackForm = (stack = null) => {
4088
4644
  if (stack) {
@@ -4176,17 +4732,44 @@ async def dashboard():
4176
4732
  const stackName = (id) => voiceStacks.value.find(s => s.id === id)?.name || '—';
4177
4733
  const characterName = (id) => characterPresets.value.find(c => c.id === id)?.name || '—';
4178
4734
 
4179
- return {
4180
- robots, servers, sessions, stats, connected, serverMetrics,
4181
- devices, settings, livekit, showAddDevice, showDevices, newDevice, rotatedToken, simulation,
4735
+ const schedulerBase = () => window.location.origin;
4736
+ const selectedCommandRobot = computed(() => devices.value.find(d => d.id === commandRobotId.value) || devices.value[0] || null);
4737
+ const commandRows = computed(() => {
4738
+ const base = schedulerBase();
4739
+ const robot = selectedCommandRobot.value;
4740
+ const robotId = robot?.id || '<DEVICE_ID>';
4741
+ const robotName = robot?.name || '<ROBOT_NAME>';
4742
+ const networkFlag = commandNetwork.value === 'tailscale' ? '--tailscale' : '--lan';
4743
+ const tokenPlaceholder = '<ENROLLMENT_TOKEN_FROM_ADD_DEVICE>';
4744
+ return [
4745
+ { group: 'Scheduler', name: 'Health check', description: 'Confirm the control center API is reachable.', command: `curl -s ${base}/api/settings` },
4746
+ { group: 'Scheduler', name: 'Start scheduler', description: 'Start the RoboPark scheduler and dashboard on port 8080.', command: 'robopark serve --port 8080' },
4747
+ { group: 'Robot', name: 'Enroll robot', description: `Generated for ${robotName} over ${commandNetwork.value}. Paste the one-time token from the Add Device dialog.`, command: `robopark setup --robot --name "${robotName}" --hub-url ${base.replace(/:\\d+$/, '')}:47913 --scheduler-port ${window.location.port || '8080'} ${networkFlag} --enrollment-token ${tokenPlaceholder} --start --auto-start` },
4748
+ { group: 'Robot', name: 'Simulate motion', description: 'Queue a full production trigger for the selected robot.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-trigger` },
4749
+ { group: 'Robot', name: 'Stop conversation', description: 'Stop the active conversation and clear queued trigger state.', command: `curl -s -X POST ${base}/api/robots/${robotId}/simulate-stop` },
4750
+ { group: 'Robot', name: 'Recover stale state', description: 'Release a robot stuck in connecting/running after a failed teardown.', command: `curl -s -X POST ${base}/api/robots/${robotId}/recover` },
4751
+ { group: 'Worker', name: 'Sync agent secret', description: 'Provision the scheduler-managed secret into ROBOVOICE-main.', command: `robopark secrets --scheduler-url ${base} --worker-env C:\\path\\to\\ROBOVOICE-main\\.env` },
4752
+ { group: 'Diagnostics', name: 'Tail agent logs', description: 'Inspect the local ROBOVOICE worker.', command: 'docker logs --tail 100 caal-agent' },
4753
+ { group: 'Diagnostics', name: 'Check Docker', description: 'List containers, health, ports, and networks.', command: 'docker ps -a --format "table {{.Names}}\\t{{.Status}}\\t{{.Ports}}\\t{{.Networks}}"' },
4754
+ { group: 'Diagnostics', name: 'Check LiveKit port', description: 'Confirm the local LiveKit listener is present.', command: 'Get-NetTCPConnection -LocalPort 7883 -ErrorAction SilentlyContinue' },
4755
+ ];
4756
+ });
4757
+ const copyCommand = async (command) => {
4758
+ try { await navigator.clipboard.writeText(command); }
4759
+ catch (e) { window.prompt('Copy command:', command); }
4760
+ };
4761
+
4762
+ return {
4763
+ robots, servers, sessions, stats, connected, serverMetrics,
4764
+ devices, settings, livekit, showAddDevice, showDevices, showCommands, commandNetwork, commandRobotId, selectedCommandRobot, commandRows, copyCommand, newDevice, rotatedToken, enrollmentNetwork, enrollmentCommand, simulation,
4182
4765
  showLiveKitConfig, livekitForm, preview, deviceInventory,
4183
4766
  voiceStacks, characterPresets, showVoiceStackForm, showCharacterForm,
4184
4767
  editingVoiceStack, editingCharacter, voiceStackForm, characterForm,
4185
4768
  loadModel, unloadModel, statusColor, formatDuration, formatTime,
4186
- toggleProductionMode, rotateEnrollmentToken,
4769
+ toggleProductionMode, rotateEnrollmentToken, provisionAgentToken,
4187
4770
  submitNewDevice, deleteDevice, rotateDeviceToken, dismissRotatedToken,
4188
4771
  joinConversation, saveLiveKitConfig, openLiveKitConfig,
4189
- startPreview, stopPreview, simulateTrigger, stopConversation, updateDeviceMedia,
4772
+ startPreview, stopPreview, simulateTrigger, stopConversation, recoverRobot, toggleDeviceProduction, setDeviceProduction, saveDeviceGreetings, updateDeviceMedia,
4190
4773
  openVoiceStackForm, saveVoiceStack, deleteVoiceStack,
4191
4774
  openCharacterForm, saveCharacter, deleteCharacter,
4192
4775
  assignRobotCharacter, stackName, characterName,
@@ -4200,20 +4783,30 @@ async def dashboard():
4200
4783
  <h1 class="text-3xl font-bold">🤖 RoboPark Control Center</h1>
4201
4784
  <p class="text-gray-400">Session Scheduler Dashboard — Shenzhen Bay Park (test site)</p>
4202
4785
  </div>
4203
- <div class="flex items-center gap-4">
4204
- <button @click="openLiveKitConfig"
4786
+ <div class="flex items-center gap-4">
4787
+ <button @click="showCommands = !showCommands"
4788
+ :class="showCommands ? 'bg-blue-700 hover:bg-blue-600' : 'bg-gray-700 hover:bg-gray-600'"
4789
+ class="px-3 py-2 rounded-lg text-sm">Commands</button>
4790
+ <button @click="openLiveKitConfig"
4205
4791
  :class="livekit.url && livekit.has_secret ? 'bg-cyan-700 hover:bg-cyan-600' : 'bg-gray-700 hover:bg-gray-600'"
4206
4792
  class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
4207
4793
  :title="livekit.url || 'Not configured'">
4208
4794
  <span class="w-2 h-2 rounded-full" :class="livekit.url && livekit.has_secret ? 'bg-cyan-200' : 'bg-yellow-400'"></span>
4209
4795
  LiveKit: <strong>{{ livekit.url && livekit.has_secret ? (livekit.url.replace(/^wss?:[/][/]/, '')) : 'NOT SET' }}</strong>
4210
4796
  </button>
4211
- <button @click="toggleProductionMode"
4797
+ <button @click="toggleProductionMode"
4212
4798
  :class="settings.production_mode ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-700 hover:bg-gray-600'"
4213
4799
  class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm">
4214
4800
  <span class="w-2 h-2 rounded-full" :class="settings.production_mode ? 'bg-green-200 animate-pulse' : 'bg-gray-400'"></span>
4215
4801
  Production Mode: <strong>{{ settings.production_mode ? 'ON' : 'OFF' }}</strong>
4216
- </button>
4802
+ </button>
4803
+ <button @click="provisionAgentToken(false)"
4804
+ :class="settings.agent_token_configured ? 'bg-emerald-800 hover:bg-emerald-700' : 'bg-red-800 hover:bg-red-700'"
4805
+ class="px-3 py-2 rounded-lg flex items-center gap-2 text-sm"
4806
+ title="Provision the scheduler-managed ROBOVOICE authentication secret">
4807
+ <span class="w-2 h-2 rounded-full" :class="settings.agent_token_configured ? 'bg-emerald-200' : 'bg-red-300'"></span>
4808
+ Agent Secret: <strong>{{ settings.agent_token_configured ? 'SET' : 'MISSING' }}</strong>
4809
+ </button>
4217
4810
  <span :class="connected ? 'text-green-400' : 'text-red-400'" class="flex items-center gap-2">
4218
4811
  <span class="w-2 h-2 rounded-full" :class="connected ? 'bg-green-400' : 'bg-red-400'"></span>
4219
4812
  {{ connected ? 'Live' : 'Disconnected' }}
@@ -4221,7 +4814,38 @@ async def dashboard():
4221
4814
  </div>
4222
4815
  </header>
4223
4816
 
4224
- <!-- Stats Row -->
4817
+ <!-- Command Center -->
4818
+ <section v-if="showCommands" class="bg-gray-800 rounded-lg p-5 mb-6">
4819
+ <div class="flex flex-wrap items-center justify-between gap-3 mb-4">
4820
+ <div>
4821
+ <h2 class="text-xl font-semibold">Command Center</h2>
4822
+ <p class="text-xs text-gray-400">Copy-ready operational commands. Secrets are never included; enrollment uses a one-time token from the Add Device dialog.</p>
4823
+ </div>
4824
+ <div class="flex gap-2 items-center">
4825
+ <select v-model="commandRobotId" class="bg-gray-700 rounded px-3 py-2 text-sm">
4826
+ <option value="">Select robot</option>
4827
+ <option v-for="d in devices" :key="d.id" :value="d.id">{{ d.name }} ({{ d.id }})</option>
4828
+ </select>
4829
+ <button @click="commandNetwork = 'lan'" :class="commandNetwork === 'lan' ? 'bg-blue-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">LAN</button>
4830
+ <button @click="commandNetwork = 'tailscale'" :class="commandNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-2 rounded text-sm">Tailscale</button>
4831
+ </div>
4832
+ </div>
4833
+ <div class="grid md:grid-cols-2 gap-3">
4834
+ <div v-for="cmd in commandRows" :key="cmd.group + cmd.name" class="bg-gray-900 rounded p-3">
4835
+ <div class="flex items-start justify-between gap-2">
4836
+ <div>
4837
+ <div class="text-xs text-cyan-400">{{ cmd.group }}</div>
4838
+ <div class="font-medium text-sm">{{ cmd.name }}</div>
4839
+ <div class="text-xs text-gray-500 mt-1">{{ cmd.description }}</div>
4840
+ </div>
4841
+ <button @click="copyCommand(cmd.command)" class="shrink-0 px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-xs">Copy</button>
4842
+ </div>
4843
+ <code class="block mt-2 text-xs text-green-300 break-all select-all">{{ cmd.command }}</code>
4844
+ </div>
4845
+ </div>
4846
+ </section>
4847
+
4848
+ <!-- Stats Row -->
4225
4849
  <div class="grid grid-cols-5 gap-4 mb-6">
4226
4850
  <div class="bg-gray-800 rounded-lg p-4">
4227
4851
  <div class="text-2xl font-bold text-green-400">{{ stats.active_sessions || 0 }}</div>
@@ -4298,9 +4922,12 @@ async def dashboard():
4298
4922
  <td class="py-2 px-2 font-mono text-xs">{{ d.tailscale_ip || '—' }}</td>
4299
4923
  <td class="py-2 px-2 font-mono text-xs">{{ d.lan_ip || '—' }}</td>
4300
4924
  <td class="py-2 px-2 font-mono text-xs text-gray-400">{{ d.motor_server_url || '—' }}</td>
4301
- <td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
4302
- <td class="py-2 px-2 text-xs space-x-2">
4303
- <button @click="joinConversation(d)"
4925
+ <td class="py-2 px-2 text-xs text-gray-400">{{ formatTime(d.last_heartbeat) }}</td>
4926
+ <td class="py-2 px-2 text-xs space-x-2">
4927
+ <button @click="setDeviceProduction(d.id)"
4928
+ :class="d.production_mode ? 'bg-green-700 hover:bg-green-600' : 'bg-gray-700 hover:bg-gray-600'"
4929
+ class="px-2 py-1 rounded">Prod {{ d.production_mode ? 'ON' : 'OFF' }}</button>
4930
+ <button @click="joinConversation(d)"
4304
4931
  :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret"
4305
4932
  :class="(settings.production_mode && livekit.url && livekit.has_secret) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
4306
4933
  class="px-2 py-1 rounded"
@@ -4381,9 +5008,18 @@ async def dashboard():
4381
5008
  <div v-if="rotatedToken" class="fixed inset-0 bg-black/70 flex items-center justify-center z-50" @click.self="dismissRotatedToken">
4382
5009
  <div class="bg-gray-800 rounded-lg p-6 w-full max-w-lg">
4383
5010
  <h3 class="text-lg font-semibold mb-2">⚠ Save this token now</h3>
4384
- <p class="text-xs text-gray-400 mb-4">This token is shown <strong>once</strong>. Copy it to the Pi (e.g. into <code>/etc/robopark/device.json</code>) before dismissing.</p>
4385
- <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
4386
- <div class="flex justify-end mt-4">
5011
+ <p class="text-xs text-gray-400 mb-4">This token is shown <strong>once</strong>. Copy it to the Pi (e.g. into <code>/etc/robopark/device.json</code>) before dismissing.</p>
5012
+ <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ rotatedToken.token }}</div>
5013
+ <div class="mt-4">
5014
+ <div class="text-xs text-gray-400 mb-2">Robot network for generated enrollment command</div>
5015
+ <div class="flex gap-2 mb-2">
5016
+ <button @click="enrollmentNetwork = 'lan'" :class="enrollmentNetwork === 'lan' ? 'bg-blue-600' : 'bg-gray-700'" class="px-3 py-1 rounded text-xs">LAN</button>
5017
+ <button @click="enrollmentNetwork = 'tailscale'" :class="enrollmentNetwork === 'tailscale' ? 'bg-cyan-600' : 'bg-gray-700'" class="px-3 py-1 rounded text-xs">Tailscale</button>
5018
+ </div>
5019
+ <div class="bg-gray-900 p-3 rounded font-mono text-xs break-all select-all">{{ enrollmentCommand(rotatedToken) }}</div>
5020
+ <p class="text-xs text-gray-500 mt-1">LAN requires the robot and scheduler on the same subnet. Tailscale requires Tailscale on both devices and a Tailscale scheduler URL/host.</p>
5021
+ </div>
5022
+ <div class="flex justify-end mt-4">
4387
5023
  <button @click="dismissRotatedToken" class="px-3 py-2 bg-blue-600 hover:bg-blue-700 rounded">I have saved it</button>
4388
5024
  </div>
4389
5025
  </div>
@@ -4444,7 +5080,11 @@ async def dashboard():
4444
5080
  Character: <strong>{{ characterName(robot.character_id) }}</strong> ·
4445
5081
  Voice Stack: <strong>{{ stackName(robot.voice_stack_id) }}</strong>
4446
5082
  </div>
4447
- <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
5083
+ <div v-if="deviceInventory[robot.id]" class="flex items-center justify-between mb-2 text-xs">
5084
+ <span>Robot production: <strong :class="devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'text-green-400' : 'text-gray-400'">{{ devices.find(d => d.id === deviceInventory[robot.id].deviceId)?.production_mode ? 'ON' : 'OFF' }}</strong></span>
5085
+ <button @click="toggleDeviceProduction(robot.id)" class="px-2 py-1 rounded bg-gray-600 hover:bg-gray-500">Toggle</button>
5086
+ </div>
5087
+ <div class="grid grid-cols-2 gap-2 mb-2 text-sm">
4448
5088
  <div>
4449
5089
  <label class="text-xs text-gray-500">Character</label>
4450
5090
  <select :value="robot.character_id"
@@ -4460,7 +5100,7 @@ async def dashboard():
4460
5100
  </div>
4461
5101
  </div>
4462
5102
  <div v-if="deviceInventory[robot.id]" class="space-y-2 text-sm">
4463
- <div class="grid grid-cols-2 gap-2">
5103
+ <div class="grid grid-cols-3 gap-2">
4464
5104
  <div>
4465
5105
  <label class="text-xs text-gray-500">Camera</label>
4466
5106
  <select v-model="deviceInventory[robot.id].selectedVideo"
@@ -4469,15 +5109,28 @@ async def dashboard():
4469
5109
  <option v-for="opt in deviceInventory[robot.id].videoOptions" :key="opt" :value="opt">{{ opt }}</option>
4470
5110
  </select>
4471
5111
  </div>
4472
- <div>
4473
- <label class="text-xs text-gray-500">Microphone</label>
5112
+ <div>
5113
+ <label class="text-xs text-gray-500">Microphone</label>
4474
5114
  <select v-model="deviceInventory[robot.id].selectedAudio"
4475
5115
  @change="updateDeviceMedia(robot.id)"
4476
5116
  class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
4477
5117
  <option v-for="opt in deviceInventory[robot.id].audioOptions" :key="opt" :value="opt">{{ opt }}</option>
4478
- </select>
4479
- </div>
4480
- </div>
5118
+ </select>
5119
+ </div>
5120
+ <div>
5121
+ <label class="text-xs text-gray-500">Speaker</label>
5122
+ <select v-model="deviceInventory[robot.id].selectedAudioOutput"
5123
+ @change="updateDeviceMedia(robot.id)"
5124
+ class="w-full bg-gray-800 rounded px-2 py-1 text-xs">
5125
+ <option v-for="opt in deviceInventory[robot.id].audioOutputOptions" :key="opt" :value="opt">{{ opt }}</option>
5126
+ </select>
5127
+ </div>
5128
+ </div>
5129
+ <div>
5130
+ <label class="text-xs text-gray-500">Cached greeting phrases (one per line)</label>
5131
+ <textarea v-model="deviceInventory[robot.id].greetingText" rows="2" class="w-full bg-gray-800 rounded px-2 py-1 text-xs" placeholder="Hello! Want to go for a ride?"></textarea>
5132
+ <button @click="saveDeviceGreetings(robot.id)" class="mt-1 px-2 py-1 rounded bg-gray-600 hover:bg-gray-500 text-xs">Save Greetings</button>
5133
+ </div>
4481
5134
  <button @click="startPreview(robot)"
4482
5135
  :disabled="!settings.production_mode || !livekit.url || !livekit.has_secret || preview.active"
4483
5136
  :class="(settings.production_mode && livekit.url && livekit.has_secret && !preview.active) ? 'bg-cyan-600 hover:bg-cyan-700' : 'bg-gray-700 cursor-not-allowed'"
@@ -4491,13 +5144,16 @@ async def dashboard():
4491
5144
  class="px-2 py-1 rounded text-xs">
4492
5145
  {{ simulation.robotId === robot.id ? 'Triggering…' : 'Simulate Motion' }}
4493
5146
  </button>
4494
- <button @click="stopConversation(robot)"
5147
+ <button @click="stopConversation(robot)"
4495
5148
  :disabled="robot.status !== 'connecting' && robot.status !== 'running'"
4496
5149
  :class="robot.status === 'connecting' || robot.status === 'running' ? 'bg-red-600 hover:bg-red-500' : 'bg-gray-700 cursor-not-allowed'"
4497
5150
  class="px-2 py-1 rounded text-xs">
4498
- Stop Conversation
4499
- </button>
4500
- </div>
5151
+ Stop Conversation
5152
+ </button>
5153
+ </div>
5154
+ <button @click="recoverRobot(robot)" class="w-full px-2 py-1 rounded text-xs bg-gray-600 hover:bg-gray-500">
5155
+ Recover Stale State
5156
+ </button>
4501
5157
  <button v-if="preview.active && preview.robot?.id === robot.id"
4502
5158
  @click="stopPreview"
4503
5159
  class="w-full px-2 py-1 bg-red-600 hover:bg-red-700 rounded text-xs">