infinicode 2.8.64 → 2.8.65

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.
@@ -318,7 +318,19 @@ button.act:disabled{opacity:0.5;cursor:default;}
318
318
  .pk-kv .v.warn{color:var(--warn);} .pk-kv .v.bad{color:var(--bad);} .pk-kv .v.ok{color:var(--ok);}
319
319
  .pk-kv .l{font-size:0.56rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--muted);}
320
320
  .pk-ctl{display:flex;gap:0.45rem;flex-wrap:wrap;}
321
- .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
321
+ .pk-note{font-family:var(--mono);font-size:0.58rem;color:var(--gold);opacity:0.85;margin-top:0.4rem;}
322
+ .pk-pipeline{display:grid;gap:0.32rem;margin-top:0.6rem;}
323
+ .pk-pipe-step{display:grid;grid-template-columns:0.55rem 8.2rem 1fr;gap:0.45rem;align-items:start;padding:0.32rem 0.4rem;border-radius:7px;background:rgba(255,255,255,0.025);font-family:var(--mono);font-size:0.56rem;color:var(--muted);}
324
+ .pk-pipe-step i{width:0.48rem;height:0.48rem;border-radius:50%;margin-top:0.08rem;background:var(--muted);box-shadow:0 0 0 2px rgba(255,255,255,0.04);}
325
+ .pk-pipe-step.running i{background:var(--warn);box-shadow:0 0 8px var(--warn);animation:pulse 1s infinite;}
326
+ .pk-pipe-step.ok i{background:var(--ok);box-shadow:0 0 7px rgba(70,220,150,.45);}
327
+ .pk-pipe-step.failed i,.pk-pipe-step.blocked i{background:var(--bad);box-shadow:0 0 7px rgba(255,90,90,.45);}
328
+ .pk-pipe-step .stage{color:var(--text);text-transform:uppercase;letter-spacing:.04em;}
329
+ .pk-pipe-step .detail{text-align:right;overflow-wrap:anywhere;}
330
+ .pk-pipeline-summary{padding:0.5rem 0.6rem;border:1px solid var(--glass-border);border-radius:8px;font-family:var(--mono);font-size:0.6rem;color:var(--muted);}
331
+ .pk-pipeline-summary.ready{color:var(--ok);border-color:rgba(70,220,150,.35);}
332
+ .pk-pipeline-summary.running{color:var(--warn);border-color:rgba(255,196,70,.35);}
333
+ .pk-pipeline-summary.blocked{color:var(--bad);border-color:rgba(255,90,90,.4);}
322
334
  .pk-prodtoggle{display:flex;align-items:center;gap:0.5rem;margin-top:0.6rem;padding:0.45rem 0.6rem;border-radius:8px;border:1px solid var(--border-strong);background:var(--panel-2);cursor:pointer;}
323
335
  .pk-prodtoggle input{width:15px;height:15px;accent-color:var(--gold-bright,var(--gold));cursor:pointer;}
324
336
  .pk-prodtoggle span{font-family:var(--mono);font-size:0.66rem;color:var(--ink);}
@@ -3276,7 +3288,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
3276
3288
  // so the "ended" ring can fade out for ~4s after a session ends,
3277
3289
  // and the "live/talking" status can persist briefly across polls.
3278
3290
  var _statusMem = {}; // name -> { lastActiveAt, lastEndedAt, prevReadiness }
3279
- var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, deviceSelectorsReady=false;
3291
+ var drawerId=null, camRAF=null, meterRAF=null, camAbort=null, drawerTimer=null, pipelineTimer=null, deviceSelectorsReady=false;
3280
3292
  var previewOn=false, previewTimer=null; // on-demand operator camera preview
3281
3293
  var visionOn=false; // RoboVisionAI_PI overlay feed (direct MJPEG, not LiveKit)
3282
3294
  var lkRoom=null, micAnalyser=null, audioCtx=null, lkAudioEls=[];
@@ -5366,7 +5378,8 @@ button.act:disabled{opacity:0.5;cursor:default;}
5366
5378
  if(camRAF)cancelAnimationFrame(camRAF); camRAF=null;
5367
5379
  if(meterRAF)cancelAnimationFrame(meterRAF); meterRAF=null;
5368
5380
  if(camAbort){ try{camAbort.abort();}catch(e){} camAbort=null; }
5369
- if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
5381
+ if(drawerTimer){ clearInterval(drawerTimer); drawerTimer=null; }
5382
+ if(pipelineTimer){ clearInterval(pipelineTimer); pipelineTimer=null; }
5370
5383
  if(lkRoom){ try{ lkRoom.disconnect(); }catch(e){} lkRoom=null; }
5371
5384
  micAnalyser=null;
5372
5385
  }
@@ -5909,9 +5922,13 @@ button.act:disabled{opacity:0.5;cursor:default;}
5909
5922
  +'<img id="pk-vision-img" style="width:100%;height:100%;object-fit:cover;position:absolute;inset:0;display:none;border-radius:inherit" alt="vision feed"/>'
5910
5923
  +'<div class="hud"><div class="rec"><i></i>REC · '+esc(n.name)+'</div><div class="tr" id="pk-camres">— · —</div><div class="br" id="pk-camts">--:--:--</div></div></div>'
5911
5924
  +'<div class="pk-note" id="pk-camnote">seeking live feed…</div>'
5912
- +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
5913
- +'<label class="pk-prodtoggle"><input type="checkbox" id="pk-prodmode"'+(devForPm.production_mode?' checked':'')+'><span>Production mode — continuous motion-triggered loop, camera always live</span></label>')
5914
- + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
5925
+ +'<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn" id="pk-golive" data-robot="'+esc(n.name)+'">▶ Go live</button><span class="pk-note" id="pk-golivenote" style="margin:0"></span></div>'
5926
+ +'<label class="pk-prodtoggle"><input type="checkbox" id="pk-prodmode"'+(devForPm.production_mode?' checked':'')+'><span>Production mode — continuous motion-triggered loop, camera always live</span></label>')
5927
+ + sect('Remote production test','<div id="pk-pipeline-summary" class="pk-pipeline-summary">Checking robot, camera, scheduler and LiveKit...</div>'
5928
+ + '<div class="pk-ctl" style="margin-top:0.55rem"><button class="rp-btn primary" id="pk-pipeline-start">Start full test</button><button class="rp-btn danger" id="pk-pipeline-stop">Stop conversation</button></div>'
5929
+ + '<div class="pk-note">Starts the camera, sends the same motion trigger used in production, and follows every reported stage remotely.</div>'
5930
+ + '<div class="pk-pipeline" id="pk-pipeline"></div>')
5931
+ + sect('🎙 microphone','<div class="pk-meter"><span class="lab">input</span><div class="pk-bars" id="pk-micbars"></div><span class="pk-val" id="pk-micval">—</span></div><div class="pk-note" id="pk-micstate">awaiting robot audio plugin</div>')
5915
5932
  + sect('🔊 speaker','<canvas class="pk-wave" id="pk-spkwave" width="360" height="32"></canvas><div class="pk-meter"><span class="lab">output</span><div class="pk-bars" id="pk-spkbars"></div><span class="pk-val" id="pk-spkval">—</span></div><div class="pk-note" id="pk-spkstate">TTS plays on the robot speaker — use “Test speaker” below to round-trip a tone and confirm the audio path</div>'
5916
5933
  + '<div class="rp-form" id="pk-spk-test" style="grid-template-columns:1fr 1fr;gap:0.4rem;margin-top:0.55rem;padding:0.55rem;border-color:var(--border-strong);">'
5917
5934
  + '<div class="rp-field"><label>Frequency</label><select id="pk-spk-freq"><option value="500">500 Hz (low)</option><option value="1000" selected>1000 Hz (1 kHz reference)</option><option value="2000">2000 Hz (presence)</option><option value="3000">3000 Hz (clarity)</option></select></div>'
@@ -5937,7 +5954,7 @@ button.act:disabled{opacity:0.5;cursor:default;}
5937
5954
  if(deviceSection && speakerSection) speakerSection.after(deviceSection);
5938
5955
  refreshDeviceSelectors(n);
5939
5956
  b.innerHTML += '<div class="pk-ctl" style="margin-top:0.5rem"><button class="rp-btn danger" id="pk-recover" data-robot="'+esc(n.name)+'">Recover robot</button><span class="pk-note" style="margin:0">clears stale sessions and queued triggers for this robot</span></div>';
5940
- var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
5957
+ var deviceSearch=$('pk-device-search'); if(deviceSearch) deviceSearch.addEventListener('input', function(){ refreshDeviceSelectors(n); });
5941
5958
  var deviceSave=$('pk-device-save'); if(deviceSave) deviceSave.addEventListener('click', function(){ saveDeviceSelectors(n); });
5942
5959
  startCam(n); startMeters(n); loadEffectiveConfig(schedId(n)); refreshSupervisorPanel(n);
5943
5960
  _bindShellSection(n);
@@ -5958,7 +5975,12 @@ button.act:disabled{opacity:0.5;cursor:default;}
5958
5975
  restartService(n, btn.dataset.svc, btn);
5959
5976
  });
5960
5977
  var eb=$('pk-end'); if(eb) eb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } endSession(schedId(n)); });
5961
- var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });
5978
+ var rb=$('pk-recover'); if(rb) rb.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } recoverRobot(schedId(n)); });
5979
+ var pipelineStart=$('pk-pipeline-start'); if(pipelineStart) pipelineStart.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } startPipelineTest(n); });
5980
+ var pipelineStop=$('pk-pipeline-stop'); if(pipelineStop) pipelineStop.addEventListener('click', function(){ if(rpReadOnly){ showOut('read-only node'); return; } stopPipelineTest(n); });
5981
+ pollPipeline(n);
5982
+ if(pipelineTimer) clearInterval(pipelineTimer);
5983
+ pipelineTimer=setInterval(function(){ if(drawerId===n.id) pollPipeline(n); }, 1500);
5962
5984
  var fb=$('pk-follow'); if(fb) fb.addEventListener('click', function(){ selectTab('fleet'); $('cmdInput').value='/follow'; selectNode(n.id,n.name); showOut('type a runId after /follow, or run /status on '+n.name); });
5963
5985
  } else {
5964
5986
  b.innerHTML =
@@ -5966,10 +5988,69 @@ button.act:disabled{opacity:0.5;cursor:default;}
5966
5988
  + sect('🔌 capabilities', (n.caps.length?('<div class="caps">'+n.caps.map(function(c){return '<span class="cap">'+esc(c)+'</span>';}).join('')+'</div>'):'<div class="pk-note">none reported</div>'))
5967
5989
  + sect('⚙ controls','<div class="pk-ctl"><button class="rp-btn" id="pk-status">Run /status</button></div>');
5968
5990
  var sb=$('pk-status'); if(sb) sb.addEventListener('click', function(){ selectTab('fleet'); selectNode(n.id,n.name); $('cmdInput').value='/status'; runCommand('/status'); });
5969
- }
5970
- refreshDrawer();
5971
- }
5972
- function refreshDrawer(){
5991
+ }
5992
+ refreshDrawer();
5993
+ }
5994
+
5995
+ var PIPELINE_LABELS={
5996
+ robot_online:'Robot online',camera_ready:'Camera detected',microphone_ready:'Microphone detected',speaker_ready:'Speaker detected',
5997
+ motion_detected:'Motion received',scheduler_session:'Scheduler session',livekit_join:'LiveKit joined',camera_published:'Camera published',
5998
+ microphone_published:'Microphone published',voice_worker:'Voice worker',stt_listening:'STT listening',llm_response:'LLM response',
5999
+ tts_subscribed:'TTS subscribed',playback_started:'Robot playback',session_ended:'Session ended'
6000
+ };
6001
+ function renderPipeline(data){
6002
+ var host=$('pk-pipeline'), summary=$('pk-pipeline-summary'); if(!host||!summary) return;
6003
+ var latest={}, prereqs=data.prerequisites||{}, session=data.latest_session||{}, sid=session.id||null;
6004
+ (data.events||[]).forEach(function(ev){
6005
+ if(ev.session_id && sid && ev.session_id!==sid) return;
6006
+ latest[ev.stage]=ev;
6007
+ });
6008
+ ['robot_online','camera_ready','microphone_ready','speaker_ready'].forEach(function(stage){
6009
+ if(!latest[stage]) latest[stage]={stage:stage,status:prereqs[stage]?'ok':'blocked',message:prereqs[stage]?'Ready':'Not reported'};
6010
+ });
6011
+ var running=data.overall==='running';
6012
+ host.innerHTML=(data.stages||Object.keys(PIPELINE_LABELS)).map(function(stage){
6013
+ var ev=latest[stage], status=ev&&ev.status||((running&&stage==='motion_detected')?'running':'pending');
6014
+ var detail=ev&&(ev.message||ev.timestamp)||'waiting';
6015
+ if(ev&&ev.timestamp) detail=detail+' · '+new Date(ev.timestamp+'Z').toLocaleTimeString();
6016
+ return '<div class="pk-pipe-step '+esc(status)+'"><i></i><span class="stage">'+esc(PIPELINE_LABELS[stage]||stage)+'</span><span class="detail">'+esc(detail)+'</span></div>';
6017
+ }).join('');
6018
+ var blockers=data.blockers||[];
6019
+ summary.className='pk-pipeline-summary '+esc(data.overall||'blocked');
6020
+ summary.textContent=blockers.length?('BLOCKED: '+blockers[0]):(running?'TEST RUNNING: watch the live camera and stage timeline':'READY: camera-to-speaker remote test can start');
6021
+ }
6022
+ function pollPipeline(n){
6023
+ fetch('/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-status'+QS,{cache:'no-store'})
6024
+ .then(function(r){ if(!r.ok) throw new Error('scheduler '+r.status); return r.json(); })
6025
+ .then(renderPipeline)
6026
+ .catch(function(e){ var s=$('pk-pipeline-summary'); if(s){ s.className='pk-pipeline-summary blocked'; s.textContent='SCHEDULER UNREACHABLE: '+e.message; } });
6027
+ }
6028
+ function queuePipelineTest(n){
6029
+ var start=$('pk-pipeline-start'); if(start){ start.disabled=true; start.textContent='Starting...'; }
6030
+ if(!previewOn) startPreview(n);
6031
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/start',{}).then(function(result){
6032
+ if(!result.queued) throw new Error(result.reason||'trigger was not queued');
6033
+ showOut('full production test queued for '+(n.label||n.name));
6034
+ pollPipeline(n);
6035
+ }).catch(function(e){ showOut('production test: '+e.message); pollPipeline(n); }).finally(function(){ if(start){ start.disabled=false; start.textContent='Start full test'; } });
6036
+ }
6037
+ function startPipelineTest(n){
6038
+ var device=schedulerDevice(n)||{};
6039
+ if(!device.id){ showOut('Robot has no scheduler device identity'); return; }
6040
+ if(!device.production_mode){
6041
+ rpAction('PATCH','/robopark/api/devices/'+encodeURIComponent(device.id),{production_mode:true}).then(function(){
6042
+ device.production_mode=true; var pm=$('pk-prodmode'); if(pm) pm.checked=true; queuePipelineTest(n);
6043
+ }).catch(function(e){ showOut('Could not enable production mode: '+e.message); });
6044
+ return;
6045
+ }
6046
+ queuePipelineTest(n);
6047
+ }
6048
+ function stopPipelineTest(n){
6049
+ rpAction('POST','/robopark/api/robots/'+encodeURIComponent(schedId(n))+'/pipeline-test/stop',{}).then(function(){
6050
+ showOut('conversation stopped for '+(n.label||n.name)); pollPipeline(n); startCam(n);
6051
+ }).catch(function(e){ showOut('stop test: '+e.message); });
6052
+ }
6053
+ function refreshDrawer(){
5973
6054
  var n=drawerId?byId[drawerId]:null; if(!n) return;
5974
6055
  if(n.kind==='robot' && !deviceSelectorsReady && $('pk-video-device')){
5975
6056
  var devCheck=schedulerDevice(n);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinicode",
3
- "version": "2.8.64",
3
+ "version": "2.8.65",
4
4
  "description": "OpenKernel — provider-agnostic AI execution kernel. Native coding agent + mission-driven execution runtime.",
5
5
  "type": "module",
6
6
  "main": "./dist/kernel/index.js",
@@ -216,12 +216,20 @@ class DeviceEnrollResponse(BaseModel):
216
216
  device_token: str
217
217
  scheduler_url: str
218
218
 
219
- class DeviceHeartbeat(BaseModel):
219
+ class DeviceHeartbeat(BaseModel):
220
220
  status: Optional[str] = None
221
221
  ip: Optional[str] = None
222
222
  uptime_seconds: Optional[int] = None
223
223
  production_mode: Optional[bool] = None
224
- device_inventory: Optional[dict] = None
224
+ device_inventory: Optional[dict] = None
225
+
226
+ class PipelineEventPayload(BaseModel):
227
+ stage: str
228
+ status: str = "ok"
229
+ message: Optional[str] = None
230
+ session_id: Optional[str] = None
231
+ source: str = "robot"
232
+ details: Optional[dict] = None
225
233
 
226
234
  class SupervisorStatusReport(BaseModel):
227
235
  services: List[ServiceStatus] = []
@@ -439,7 +447,7 @@ async def init_db():
439
447
  );
440
448
  CREATE INDEX IF NOT EXISTS idx_shell_requests_device ON shell_requests(device_id, requested_at);
441
449
 
442
- CREATE TABLE IF NOT EXISTS history_log (
450
+ CREATE TABLE IF NOT EXISTS history_log (
443
451
  id INTEGER PRIMARY KEY AUTOINCREMENT,
444
452
  timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
445
453
  category TEXT NOT NULL,
@@ -447,8 +455,22 @@ async def init_db():
447
455
  entity_id TEXT,
448
456
  action TEXT,
449
457
  actor TEXT,
450
- details TEXT
451
- );
458
+ details TEXT
459
+ );
460
+
461
+ CREATE TABLE IF NOT EXISTS pipeline_events (
462
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
463
+ robot_id TEXT NOT NULL,
464
+ session_id TEXT,
465
+ stage TEXT NOT NULL,
466
+ status TEXT NOT NULL,
467
+ message TEXT,
468
+ source TEXT NOT NULL DEFAULT 'robot',
469
+ details TEXT,
470
+ timestamp TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
471
+ );
472
+ CREATE INDEX IF NOT EXISTS idx_pipeline_robot_time
473
+ ON pipeline_events(robot_id, timestamp DESC);
452
474
 
453
475
  -- Scheduler-managed voice stacks (STT/LLM/TTS configuration)
454
476
  CREATE TABLE IF NOT EXISTS voice_stacks (
@@ -1342,8 +1364,8 @@ async def session_joined(session_id: str,
1342
1364
  await _log_history("session", "session", session_id, "joined", "robot", f"latency_ms={latency_ms}")
1343
1365
  return {"status": "ok", "joined_at": now, "latency_ms": latency_ms}
1344
1366
 
1345
- @app.post("/api/sessions/{session_id}/keepalive")
1346
- async def session_keepalive(session_id: str,
1367
+ @app.post("/api/sessions/{session_id}/keepalive")
1368
+ async def session_keepalive(session_id: str,
1347
1369
  authorization: Optional[str] = Header(default=None),
1348
1370
  agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token")):
1349
1371
  """Silence-based session extension signal.
@@ -1381,7 +1403,29 @@ async def session_keepalive(session_id: str,
1381
1403
  await db.commit()
1382
1404
  if cur.rowcount == 0:
1383
1405
  raise HTTPException(404, "Session not found or already ended")
1384
- return {"status": "ok", "last_activity_at": now}
1406
+ return {"status": "ok", "last_activity_at": now}
1407
+
1408
+ @app.post("/api/sessions/{session_id}/pipeline-events")
1409
+ async def voice_pipeline_event(session_id: str, payload: PipelineEventPayload,
1410
+ authorization: Optional[str] = Header(default=None),
1411
+ agent_token: Optional[str] = Header(default=None, alias="X-RoboPark-Agent-Token")):
1412
+ """ROBOVOICE callback for worker, STT, LLM and TTS stage timing."""
1413
+ device_authorized = await _authorize_fleet(authorization)
1414
+ agent_authorized = bool(
1415
+ ROBOPARK_AGENT_TOKEN and agent_token
1416
+ and hmac.compare_digest(agent_token.strip(), ROBOPARK_AGENT_TOKEN)
1417
+ )
1418
+ if not device_authorized and not agent_authorized:
1419
+ raise HTTPException(401, "Invalid or missing agent token")
1420
+ async with aiosqlite.connect(DB_PATH) as db:
1421
+ db.row_factory = aiosqlite.Row
1422
+ async with db.execute("SELECT robot_id FROM sessions WHERE id = ?", (session_id,)) as c:
1423
+ session = await c.fetchone()
1424
+ if not session:
1425
+ raise HTTPException(404, "Session not found")
1426
+ payload.session_id = session_id
1427
+ payload.source = "voice_agent"
1428
+ return {"status": "ok", **(await _insert_pipeline_event(session["robot_id"], payload))}
1385
1429
 
1386
1430
  @app.get("/api/sessions/{session_id}/status")
1387
1431
  async def get_session_status(session_id: str):
@@ -2654,8 +2698,8 @@ async def device_trigger_command(device_id: str,
2654
2698
  await db.commit()
2655
2699
  return {"trigger": True, "source": command["source"], "production_mode": True}
2656
2700
 
2657
- @app.post("/api/devices/{device_id}/heartbeat")
2658
- async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
2701
+ @app.post("/api/devices/{device_id}/heartbeat")
2702
+ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
2659
2703
  authorization: Optional[str] = Header(default=None)):
2660
2704
  if not await _authorize_device(device_id, authorization):
2661
2705
  raise HTTPException(401, "Invalid device token")
@@ -2689,8 +2733,133 @@ async def device_heartbeat(device_id: str, payload: DeviceHeartbeat,
2689
2733
  return {
2690
2734
  "status": "ok",
2691
2735
  "production_mode": effective_pm,
2692
- "server_time": datetime.utcnow().isoformat(),
2693
- }
2736
+ "server_time": datetime.utcnow().isoformat(),
2737
+ }
2738
+
2739
+ PIPELINE_STAGES = (
2740
+ "robot_online", "camera_ready", "microphone_ready", "speaker_ready",
2741
+ "motion_detected", "scheduler_session", "livekit_join", "camera_published",
2742
+ "microphone_published", "voice_worker", "stt_listening", "llm_response",
2743
+ "tts_subscribed", "playback_started", "session_ended",
2744
+ )
2745
+ PIPELINE_STATUSES = {"pending", "running", "ok", "failed", "blocked", "skipped"}
2746
+
2747
+ async def _insert_pipeline_event(device_id: str, payload: PipelineEventPayload) -> dict:
2748
+ if payload.stage not in PIPELINE_STAGES:
2749
+ raise HTTPException(422, f"Unknown pipeline stage: {payload.stage}")
2750
+ if payload.status not in PIPELINE_STATUSES:
2751
+ raise HTTPException(422, f"Unknown pipeline status: {payload.status}")
2752
+ message = (payload.message or "")[:500]
2753
+ details = json.dumps(payload.details or {}, separators=(",", ":"))[:4000]
2754
+ now = datetime.utcnow().isoformat()
2755
+ async with aiosqlite.connect(DB_PATH) as db:
2756
+ cur = await db.execute(
2757
+ """INSERT INTO pipeline_events
2758
+ (robot_id, session_id, stage, status, message, source, details, timestamp)
2759
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
2760
+ (device_id, payload.session_id, payload.stage, payload.status, message,
2761
+ (payload.source or "robot")[:40], details, now),
2762
+ )
2763
+ await db.commit()
2764
+ await broadcast("pipeline_event", {"robot_id": device_id, "stage": payload.stage, "status": payload.status})
2765
+ return {"id": cur.lastrowid, "timestamp": now}
2766
+
2767
+ @app.post("/api/devices/{device_id}/pipeline-events")
2768
+ async def device_pipeline_event(device_id: str, payload: PipelineEventPayload,
2769
+ authorization: Optional[str] = Header(default=None)):
2770
+ """Receive real pipeline stage transitions from the enrolled robot."""
2771
+ if not await _authorize_device(device_id, authorization):
2772
+ raise HTTPException(401, "Invalid device token")
2773
+ return {"status": "ok", **(await _insert_pipeline_event(device_id, payload))}
2774
+
2775
+ @app.get("/api/robots/{robot_id}/pipeline-status")
2776
+ async def robot_pipeline_status(robot_id: str, limit: int = 80):
2777
+ """One operator-facing snapshot for remote camera-to-playback testing."""
2778
+ limit = max(10, min(limit, 200))
2779
+ async with aiosqlite.connect(DB_PATH) as db:
2780
+ db.row_factory = aiosqlite.Row
2781
+ async with db.execute(
2782
+ "SELECT * FROM devices WHERE id = ? OR lower(name) = lower(?) ORDER BY last_heartbeat DESC LIMIT 1",
2783
+ (robot_id, robot_id),
2784
+ ) as c:
2785
+ device = await c.fetchone()
2786
+ if not device:
2787
+ raise HTTPException(404, "Robot device not found")
2788
+ device_id = device["id"]
2789
+ telemetry = await _robot_telemetry(db, device_id)
2790
+ async with db.execute(
2791
+ "SELECT * FROM sessions WHERE robot_id = ? ORDER BY started_at DESC LIMIT 1", (device_id,)
2792
+ ) as c:
2793
+ latest_session = await c.fetchone()
2794
+ async with db.execute(
2795
+ "SELECT * FROM pipeline_events WHERE robot_id = ? ORDER BY timestamp DESC LIMIT ?",
2796
+ (device_id, limit),
2797
+ ) as c:
2798
+ rows = await c.fetchall()
2799
+
2800
+ try:
2801
+ inventory = json.loads(device["device_inventory"] or "{}")
2802
+ except (TypeError, ValueError):
2803
+ inventory = {}
2804
+ cameras = inventory.get("video") or inventory.get("cameras") or []
2805
+ inputs = inventory.get("audio_input") or inventory.get("audio_inputs") or inventory.get("inputs") or []
2806
+ outputs = inventory.get("audio_output") or inventory.get("audio_outputs") or inventory.get("outputs") or []
2807
+ cameras = [d for d in cameras if str(d.get("id", "")).lower() not in ("auto", "none")]
2808
+ inputs = [d for d in inputs if str(d.get("id", "")).lower() not in ("default", "none")]
2809
+ outputs = [d for d in outputs if str(d.get("id", "")).lower() not in ("default", "none")]
2810
+ heartbeat_age = telemetry.get("heartbeat_age_seconds") if telemetry else None
2811
+ prereqs = {
2812
+ "robot_online": heartbeat_age is not None and heartbeat_age <= 30,
2813
+ "camera_ready": bool(cameras),
2814
+ "microphone_ready": bool(inputs),
2815
+ "speaker_ready": bool(outputs),
2816
+ }
2817
+ events = []
2818
+ for row in reversed(rows):
2819
+ item = dict(row)
2820
+ try:
2821
+ item["details"] = json.loads(item.get("details") or "{}")
2822
+ except (TypeError, ValueError):
2823
+ item["details"] = {}
2824
+ events.append(item)
2825
+ blockers = []
2826
+ if not prereqs["robot_online"]:
2827
+ blockers.append("Robot heartbeat is stale or missing")
2828
+ if not bool(device["production_mode"]):
2829
+ blockers.append("Robot production mode is off")
2830
+ if telemetry and telemetry.get("readiness_code") not in ("ready", "in_session"):
2831
+ blockers.append(telemetry.get("readiness_message") or telemetry["readiness_code"])
2832
+ if not prereqs["camera_ready"]:
2833
+ blockers.append("Camera inventory has not been reported")
2834
+ if not prereqs["microphone_ready"]:
2835
+ blockers.append("Microphone inventory has not been reported")
2836
+ if not prereqs["speaker_ready"]:
2837
+ blockers.append("Speaker inventory has not been reported")
2838
+ return {
2839
+ "robot_id": device_id,
2840
+ "robot_name": device["name"],
2841
+ "overall": "blocked" if blockers else ("running" if latest_session and not latest_session["ended_at"] else "ready"),
2842
+ "blockers": list(dict.fromkeys(blockers)),
2843
+ "prerequisites": prereqs,
2844
+ "telemetry": telemetry,
2845
+ "latest_session": dict(latest_session) if latest_session else None,
2846
+ "events": events,
2847
+ "stages": list(PIPELINE_STAGES),
2848
+ }
2849
+
2850
+ @app.post("/api/robots/{robot_id}/pipeline-test/start")
2851
+ async def start_pipeline_test(robot_id: str):
2852
+ snapshot = await robot_pipeline_status(robot_id)
2853
+ hard_blockers = [b for b in snapshot["blockers"] if "inventory" not in b.lower()]
2854
+ if hard_blockers:
2855
+ return {"queued": False, "reason": hard_blockers[0], "blockers": snapshot["blockers"]}
2856
+ result = await simulate_trigger(snapshot["robot_id"])
2857
+ return {**result, "robot_id": snapshot["robot_id"], "blockers": snapshot["blockers"]}
2858
+
2859
+ @app.post("/api/robots/{robot_id}/pipeline-test/stop")
2860
+ async def stop_pipeline_test(robot_id: str):
2861
+ snapshot = await robot_pipeline_status(robot_id)
2862
+ return await simulate_stop(snapshot["robot_id"])
2694
2863
 
2695
2864
  @app.post("/api/devices/{device_id}/supervisor-status")
2696
2865
  async def device_supervisor_status(device_id: str, payload: SupervisorStatusReport,
@@ -4266,11 +4435,18 @@ async def dashboard():
4266
4435
  <script src="https://cdn.tailwindcss.com"></script>
4267
4436
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
4268
4437
  <script src="https://cdn.jsdelivr.net/npm/livekit-client@1/dist/livekit-client.umd.min.js"></script>
4269
- </head>
4270
- <body class="bg-gray-900 text-white">
4271
- <div id="app"></div>
4272
- <script>
4273
- const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
4438
+ </head>
4439
+ <body class="bg-gray-900 text-white">
4440
+ <div class="bg-amber-950 border-b border-amber-600 px-4 py-3 text-sm text-amber-100">
4441
+ <strong>Legacy scheduler view.</strong> The canonical operator UI is the full RoboPark Control Center Park view on the mesh hub at port 47913.
4442
+ <a id="fullControlCenterLink" class="underline ml-2" href="#">Open full Park Control Center</a>
4443
+ </div>
4444
+ <div id="app"></div>
4445
+ <script>
4446
+ const fullControlCenterLink = document.getElementById('fullControlCenterLink');
4447
+ const meshHost = window.location.hostname || 'localhost';
4448
+ fullControlCenterLink.href = `${window.location.protocol}//${meshHost}:47913/${window.location.search}#park`;
4449
+ const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
4274
4450
 
4275
4451
  createApp({
4276
4452
  setup() {
@@ -433,6 +433,31 @@ class PreviewAgent:
433
433
  self._motion_capture_lock = asyncio.Lock()
434
434
  self._mesh_bootstrap_ready = False
435
435
  self._next_mesh_bootstrap = 0.0
436
+ self._reported_pipeline: set[tuple[str, str]] = set()
437
+
438
+ async def _report_pipeline(self, stage: str, status: str = "ok", message: str = "",
439
+ details: Optional[dict] = None, once: bool = True) -> None:
440
+ """Report a real robot-side transition for the Park test timeline."""
441
+ if not self._session or not self.device_id or not self.device_token:
442
+ return
443
+ session_id = self._vision_session_id or self._current_state.session_id
444
+ key = (session_id or "idle", stage)
445
+ if once and key in self._reported_pipeline:
446
+ return
447
+ try:
448
+ response = await self._session.post(
449
+ f"{self.scheduler_url.rstrip('/')}/api/devices/{self.device_id}/pipeline-events",
450
+ headers={"Authorization": f"Bearer {self.device_token}"},
451
+ json={"stage": stage, "status": status, "message": message,
452
+ "session_id": session_id, "source": "preview_agent",
453
+ "details": details or {}},
454
+ timeout=5.0,
455
+ )
456
+ response.raise_for_status()
457
+ if once:
458
+ self._reported_pipeline.add(key)
459
+ except Exception as exc:
460
+ logger.debug("pipeline event %s was not accepted: %s", stage, exc)
436
461
 
437
462
  async def _ensure_mesh_identity(self) -> bool:
438
463
  if not _mesh_proxy_headers():
@@ -744,7 +769,7 @@ class PreviewAgent:
744
769
  dt = dt.replace(tzinfo=timezone.utc)
745
770
  return dt.timestamp()
746
771
 
747
- async def _end_vision_session(self) -> None:
772
+ async def _end_vision_session(self) -> None:
748
773
  # Tell the scheduler too, not just the local publisher/LiveKit
749
774
  # connection — otherwise the sessions row never gets ended_at set,
750
775
  # and the server's active_sessions count climbs forever until it
@@ -763,7 +788,8 @@ class PreviewAgent:
763
788
  )
764
789
  except Exception as e:
765
790
  logger.warning(f"failed to notify scheduler of session end: {e}")
766
- self._vision_session_id = None
791
+ await self._report_pipeline("session_ended", "ok", "Robot conversation loop stopped")
792
+ self._vision_session_id = None
767
793
  self._vision_trigger_in_flight = False
768
794
  self._vision_hard_deadline = 0.0
769
795
  self._vision_last_activity = 0.0
@@ -784,8 +810,16 @@ class PreviewAgent:
784
810
  self._mesh_bootstrap_ready = False
785
811
  self._next_mesh_bootstrap = 0.0
786
812
  if production_mode is not None:
787
- self.production_mode = production_mode
788
- if not production_mode and self._vision_session_id:
813
+ self.production_mode = production_mode
814
+ await self._report_pipeline("robot_online", message="Robot heartbeat accepted")
815
+ inventory = _get_device_inventory()
816
+ real_video = [d for d in inventory.get("video", []) if str(d.get("id", "")).lower() not in ("auto", "none")]
817
+ real_inputs = [d for d in inventory.get("audio_input", []) if str(d.get("id", "")).lower() not in ("default", "none")]
818
+ real_outputs = [d for d in inventory.get("audio_output", []) if str(d.get("id", "")).lower() not in ("default", "none")]
819
+ await self._report_pipeline("camera_ready", "ok" if real_video else "blocked", f"{len(real_video)} camera device(s) detected")
820
+ await self._report_pipeline("microphone_ready", "ok" if real_inputs else "blocked", f"{len(real_inputs)} microphone device(s) detected")
821
+ await self._report_pipeline("speaker_ready", "ok" if real_outputs else "blocked", f"{len(real_outputs)} speaker device(s) detected")
822
+ if not production_mode and self._vision_session_id:
789
823
  await self._end_vision_session()
790
824
  try:
791
825
  await asyncio.wait_for(self._shutdown.wait(), timeout=self.heartbeat_interval)
@@ -923,8 +957,9 @@ class PreviewAgent:
923
957
  if not self.device_id or not self.device_token or not self._session:
924
958
  logger.warning("vision motion event received but not enrolled yet — ignoring")
925
959
  return
926
- self._vision_trigger_in_flight = True
927
- logger.info("motion detected by RoboVisionAI_PI requesting a session")
960
+ self._vision_trigger_in_flight = True
961
+ await self._report_pipeline("motion_detected", "ok", f"Motion received from {payload.get('source', 'camera')}", once=False)
962
+ logger.info("motion detected by RoboVisionAI_PI — requesting a session")
928
963
  # The cue is feedback only; never block session allocation on speaker
929
964
  await self._stop_motion_capture()
930
965
  # DirectShow can return a stale handle for a short interval after
@@ -943,9 +978,10 @@ class PreviewAgent:
943
978
  )
944
979
  r.raise_for_status()
945
980
  data = r.json()
946
- except Exception as e:
947
- logger.error(f"request-session failed: {e}")
948
- self._vision_trigger_in_flight = False
981
+ except Exception as e:
982
+ logger.error(f"request-session failed: {e}")
983
+ await self._report_pipeline("scheduler_session", "failed", f"Session request failed: {type(e).__name__}", once=False)
984
+ self._vision_trigger_in_flight = False
949
985
  return
950
986
  state = PreviewState(
951
987
  active=True,
@@ -960,8 +996,9 @@ class PreviewAgent:
960
996
  self._vision_trigger_in_flight = False
961
997
  self._vision_hard_deadline = now + self.vision_session_seconds
962
998
  self._vision_last_activity = now
963
- self._remote_session_ended = False
964
- self._current_state = state
999
+ self._remote_session_ended = False
1000
+ self._current_state = state
1001
+ await self._report_pipeline("scheduler_session", "ok", "Scheduler allocated a conversation session")
965
1002
  # If the scheduler returned a voice config, let the agent know by
966
1003
  # posting it to our local webhook endpoint. The preview agent itself
967
1004
  # does not consume it, but this makes the config observable locally
@@ -1006,9 +1043,10 @@ class LiveKitPublisher:
1006
1043
  # the camera is being handed between preview and voice sessions.
1007
1044
  self._last_motion_sample = 0.0
1008
1045
 
1009
- async def start(self) -> None:
1010
- self.room = self.rtc.Room()
1011
- await self.room.connect(self.url, self.token)
1046
+ async def start(self) -> None:
1047
+ self.room = self.rtc.Room()
1048
+ await self.room.connect(self.url, self.token)
1049
+ await self.agent._report_pipeline("livekit_join", "ok", "Robot joined the LiveKit room")
1012
1050
 
1013
1051
  # Register after signaling. Registering during Room.connect can invoke
1014
1052
  # callbacks while the native LiveKit participant state is incomplete;
@@ -1019,9 +1057,10 @@ class LiveKitPublisher:
1019
1057
  logger.info(f"track_subscribed: kind={track.kind} from={participant.identity} sid={publication.sid}")
1020
1058
  if track.kind != self.rtc.TrackKind.KIND_AUDIO:
1021
1059
  return
1022
- if participant.identity == self.room.local_participant.identity:
1023
- return
1024
- self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
1060
+ if participant.identity == self.room.local_participant.identity:
1061
+ return
1062
+ self._tasks.append(asyncio.create_task(self.agent._report_pipeline("tts_subscribed", "ok", "Subscribed to remote voice audio")))
1063
+ self._tasks.append(asyncio.create_task(self._play_remote_audio(track, publication.sid)))
1025
1064
 
1026
1065
  self.room.on("track_subscribed", _on_track_subscribed)
1027
1066
  logger.info("audio out: track_subscribed listener registered")
@@ -1073,15 +1112,17 @@ class LiveKitPublisher:
1073
1112
  self.video_track = self.rtc.LocalVideoTrack.create_video_track("camera", self.video_source)
1074
1113
  vopts = self.rtc.TrackPublishOptions()
1075
1114
  vopts.source = self.rtc.TrackSource.SOURCE_CAMERA
1076
- await self.room.local_participant.publish_track(self.video_track, vopts)
1077
- self.video_source.capture_frame(first_frame)
1115
+ await self.room.local_participant.publish_track(self.video_track, vopts)
1116
+ self.video_source.capture_frame(first_frame)
1117
+ await self.agent._report_pipeline("camera_published", "ok", "Camera track published")
1078
1118
 
1079
1119
  if audio_enabled:
1080
1120
  self.audio_source = self.rtc.AudioSource(48000, 1)
1081
1121
  self.audio_track = self.rtc.LocalAudioTrack.create_audio_track("microphone", self.audio_source)
1082
1122
  aopts = self.rtc.TrackPublishOptions()
1083
1123
  aopts.source = self.rtc.TrackSource.SOURCE_MICROPHONE
1084
- await self.room.local_participant.publish_track(self.audio_track, aopts)
1124
+ await self.room.local_participant.publish_track(self.audio_track, aopts)
1125
+ await self.agent._report_pipeline("microphone_published", "ok", "Microphone track published")
1085
1126
 
1086
1127
  # Register speaker playback (subscribe to the voice agent's TTS audio
1087
1128
  # track) BEFORE opening the camera. Camera open is a slow/occasionally
@@ -1217,11 +1258,12 @@ class LiveKitPublisher:
1217
1258
  async for frame in stream:
1218
1259
  af = frame.frame if hasattr(frame, "frame") else frame
1219
1260
  frame_count += 1
1220
- if frame_count == 1:
1221
- logger.info(
1222
- f"audio out: first frame received for {sid} "
1223
- f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
1224
- )
1261
+ if frame_count == 1:
1262
+ logger.info(
1263
+ f"audio out: first frame received for {sid} "
1264
+ f"(rate={af.sample_rate}, channels={getattr(af, 'num_channels', 1)})"
1265
+ )
1266
+ await self.agent._report_pipeline("playback_started", "ok", "First TTS audio frame reached the robot")
1225
1267
  in_rate = af.sample_rate
1226
1268
  in_channels = int(getattr(af, "num_channels", 1) or 1)
1227
1269
  raw = bytes(af.data)