From 86c1f35537baf2519047cf582774241203c6a7d0 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 7 Apr 2026 12:31:44 -0400 Subject: [PATCH] fix: SSH actions in experiment runner, branch ID serialization, and branch UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - robot-communication.ts: add sshCommand to payloadMapping type - trial-execution.ts: fix executeRobotActionWithComm to use ros2 key as implementation fallback and skip ROS connection for SSH actions - route.ts: move studyId membership check inside initialize/executeSystemAction cases so executeSSH works without studyId; fix command param location - experiments.ts: build tempId→dbUUID map on step insert and replace branch nextStepId references after all steps are saved - WizardInterface.tsx: stop filtering branch actions from step action list Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/robots/command/route.ts | 49 +++++++++++++------ .../trials/wizard/WizardInterface.tsx | 3 +- src/server/api/routers/experiments.ts | 28 ++++++++++- src/server/services/robot-communication.ts | 2 + src/server/services/trial-execution.ts | 20 ++++++-- 5 files changed, 78 insertions(+), 24 deletions(-) diff --git a/src/app/api/robots/command/route.ts b/src/app/api/robots/command/route.ts index 388de31..684ee61 100644 --- a/src/app/api/robots/command/route.ts +++ b/src/app/api/robots/command/route.ts @@ -21,27 +21,27 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { action, studyId, robotId, parameters } = body; - // Verify user has access to the study - const membership = await db.query.studyMembers.findFirst({ - where: and( - eq(studyMembers.studyId, studyId), - eq(studyMembers.userId, session.user.id), - ), - }); - - if (!membership || !["owner", "researcher"].includes(membership.role)) { - return NextResponse.json( - { error: "Insufficient permissions" }, - { status: 403 }, - ); - } - const robotIp = process.env.NAO_ROBOT_IP || process.env.NAO_IP || "134.82.159.168"; const password = process.env.NAO_PASSWORD || "robolab"; switch (action) { case "initialize": { + // Requires study membership + const membership = await db.query.studyMembers.findFirst({ + where: and( + eq(studyMembers.studyId, studyId), + eq(studyMembers.userId, session.user.id), + ), + }); + + if (!membership || !["owner", "researcher"].includes(membership.role)) { + return NextResponse.json( + { error: "Insufficient permissions" }, + { status: 403 }, + ); + } + console.log(`[Robots API] Initializing robot at ${robotIp}`); const disableAlCmd = `sshpass -p "${password}" ssh -o StrictHostKeyChecking=no "nao@${robotIp}" "python2 -c \\"import sys; sys.path.append('/opt/aldebaran/lib/python2.7/site-packages'); import naoqi; al = naoqi.ALProxy('ALAutonomousLife', '127.0.0.1', 9559); al.setState('disabled')\\""`; @@ -58,6 +58,21 @@ export async function POST(request: NextRequest) { } case "executeSystemAction": { + // Requires study membership + const membership = await db.query.studyMembers.findFirst({ + where: and( + eq(studyMembers.studyId, studyId), + eq(studyMembers.userId, session.user.id), + ), + }); + + if (!membership || !["owner", "researcher"].includes(membership.role)) { + return NextResponse.json( + { error: "Insufficient permissions" }, + { status: 403 }, + ); + } + const { id, parameters: actionParams } = parameters ?? {}; console.log(`[Robots API] Executing system action ${id}`); @@ -145,7 +160,9 @@ export async function POST(request: NextRequest) { } case "executeSSH": { - const { command } = parameters ?? {}; + // Session auth is sufficient — no studyId needed + // command may be top-level in body or nested under parameters + const { command } = parameters ?? body; if (!command) { return NextResponse.json( { error: "Missing command parameter" }, diff --git a/src/components/trials/wizard/WizardInterface.tsx b/src/components/trials/wizard/WizardInterface.tsx index 8b4ed81..6d197bd 100755 --- a/src/components/trials/wizard/WizardInterface.tsx +++ b/src/components/trials/wizard/WizardInterface.tsx @@ -430,8 +430,7 @@ export const WizardInterface = React.memo(function WizardInterface({ order: step.order ?? index, actions: step.actions - ?.filter((a) => a.type !== "branch") - .map((action) => ({ + ?.map((action) => ({ id: action.id, name: action.name, description: action.description, diff --git a/src/server/api/routers/experiments.ts b/src/server/api/routers/experiments.ts index 0ad6f5a..3993ebf 100755 --- a/src/server/api/routers/experiments.ts +++ b/src/server/api/routers/experiments.ts @@ -675,8 +675,11 @@ export const experimentsRouter = createTRPCRouter({ // Delete existing steps and actions for this experiment await ctx.db.delete(steps).where(eq(steps.experimentId, id)); + // Map from designer temp step ID → new DB UUID (for branch nextStepId fix-up) + const stepIdMap = new Map(); + // Create new steps and actions - for (const convertedStep of convertedSteps) { + for (const [i, convertedStep] of convertedSteps.entries()) { const [newStep] = await ctx.db .insert(steps) .values({ @@ -698,6 +701,10 @@ export const experimentsRouter = createTRPCRouter({ }); } + // Record temp ID → real UUID so branch nextStepId refs can be fixed up + const tempId = normalizedSteps[i]?.id; + if (tempId) stepIdMap.set(tempId, newStep.id); + // Create actions for this step for (const convertedAction of convertedStep.actions) { await ctx.db.insert(actions).values({ @@ -724,6 +731,25 @@ export const experimentsRouter = createTRPCRouter({ }); } } + + // Fix-up branch nextStepId: replace temp designer IDs with real DB UUIDs + // in both action parameters and step conditions + for (const [tempId, dbId] of stepIdMap) { + await ctx.db.execute( + sql`UPDATE ${actions} + SET parameters = replace(parameters::text, ${tempId}, ${dbId})::jsonb + WHERE step_id IN ( + SELECT id FROM ${steps} WHERE experiment_id = ${id} + ) + AND parameters::text LIKE ${"%" + tempId + "%"}`, + ); + await ctx.db.execute( + sql`UPDATE ${steps} + SET conditions = replace(conditions::text, ${tempId}, ${dbId})::jsonb + WHERE experiment_id = ${id} + AND conditions::text LIKE ${"%" + tempId + "%"}`, + ); + } } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", diff --git a/src/server/services/robot-communication.ts b/src/server/services/robot-communication.ts index 96c9b3e..d2e5b53 100755 --- a/src/server/services/robot-communication.ts +++ b/src/server/services/robot-communication.ts @@ -30,6 +30,7 @@ export interface RobotAction { type?: string; transformFn?: string; payload?: Record; + sshCommand?: string; }; ros2?: { topic?: string; @@ -40,6 +41,7 @@ export interface RobotAction { type?: string; transformFn?: string; payload?: Record; + sshCommand?: string; }; }; }; diff --git a/src/server/services/trial-execution.ts b/src/server/services/trial-execution.ts index 202ae71..ae5b607 100755 --- a/src/server/services/trial-execution.ts +++ b/src/server/services/trial-execution.ts @@ -799,8 +799,18 @@ export class TrialExecutionEngine { parameters: Record, trialId: string, ): Promise { - // Ensure robot communication service is available - if (!this.robotComm.getConnectionStatus()) { + // Plugin JSON uses a top-level "ros2" key; fall back to it if "implementation" is absent + const impl = actionDefinition.implementation ?? actionDefinition.ros2; + + // Determine if this action uses SSH (animations or explicit sshCommand) + const sshCommand = + impl?.payloadMapping?.sshCommand || + impl?.ros2?.payloadMapping?.sshCommand; + const isSSHAction = + actionDefinition.id?.startsWith("play_animation_") || !!sshCommand; + + // SSH actions bypass ROS bridge — only connect for ROS-dependent actions + if (!isSSHAction && !this.robotComm.getConnectionStatus()) { try { await this.robotComm.connect(); } catch (error) { @@ -810,12 +820,12 @@ export class TrialExecutionEngine { } } - // Prepare robot action - use action.type which contains the namespaced format (plugin.actionId) + // Prepare robot action const robotAction: RobotAction = { pluginName: plugin.name, - actionId: action.type, // e.g., "nao6-ros2.play_animation_bow" + actionId: actionDefinition.id, // e.g., "play_animation_yes" parameters, - implementation: actionDefinition.implementation, + implementation: impl, }; // Execute action through robot communication service