fix: upgrade to Next.js 16.2.1 and resolve bundling issues

- Fixed client bundle contamination by moving child_process-dependent code
- Created standalone /api/robots/command route for SSH robot commands
- Created plugins router to replace robots.plugins for plugin management
- Added getStudyPlugins procedure to studies router
- Fixed trial.studyId references to trial.experiment.studyId
- Updated WizardInterface to use REST API for robot commands
This commit is contained in:
Sean O'Connor
2026-03-22 01:08:13 -04:00
parent 79bb298756
commit add3380307
16 changed files with 612 additions and 69 deletions

View File

@@ -8,6 +8,9 @@ import type {
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
import { DesignerPageClient } from "./DesignerPageClient";
import { db } from "~/server/db";
import { studyPlugins, plugins } from "~/server/db/schema";
import { desc, eq } from "drizzle-orm";
interface ExperimentDesignerPageProps {
params: Promise<{
@@ -74,10 +77,20 @@ export default async function ExperimentDesignerPage({
actionDefinitions: Array<{ id: string }> | null;
};
};
const rawInstalledPluginsUnknown: unknown =
await api.robots.plugins.getStudyPlugins({
studyId: experiment.study.id,
});
const installedPluginsResult = await db
.select({
plugin: {
id: plugins.id,
name: plugins.name,
version: plugins.version,
actionDefinitions: plugins.actionDefinitions,
},
})
.from(studyPlugins)
.innerJoin(plugins, eq(studyPlugins.pluginId, plugins.id))
.where(eq(studyPlugins.studyId, experiment.study.id))
.orderBy(desc(studyPlugins.installedAt));
const rawInstalledPluginsUnknown = installedPluginsResult;
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object"

View File

@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "~/lib/auth";
import { db } from "~/server/db";
import { studyMembers } from "~/server/db/schema";
import { and, eq } from "drizzle-orm";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
export async function POST(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
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": {
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')\\""`;
const wakeUpCmd = `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; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.wakeUp()\\""`;
await execAsync(disableAlCmd).catch((e) =>
console.warn("AL disable failed (non-critical/already disabled):", e),
);
await execAsync(wakeUpCmd);
return NextResponse.json({ success: true });
}
case "executeSystemAction": {
const { id, parameters: actionParams } = parameters ?? {};
console.log(`[Robots API] Executing system action ${id}`);
let command = "";
switch (id) {
case "say_with_emotion":
case "say_text_with_emotion": {
const text = String(actionParams?.text || "Hello");
const emotion = String(actionParams?.emotion || "happy");
const tag =
emotion === "happy"
? "^joyful"
: emotion === "sad"
? "^sad"
: emotion === "thinking"
? "^thoughtful"
: "^joyful";
command = `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; s = naoqi.ALProxy('ALAnimatedSpeech', '127.0.0.1', 9559); s.say('${tag} ${text.replace(/'/g, "\\'")}')\\""`;
break;
}
case "wake_up":
command = `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; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.wakeUp()\\""`;
break;
case "rest":
command = `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; m = naoqi.ALProxy('ALMotion', '127.0.0.1', 9559); m.rest()\\""`;
break;
default:
return NextResponse.json(
{ error: `System action ${id} not implemented` },
{ status: 400 },
);
}
await execAsync(command);
return NextResponse.json({ success: true });
}
default:
return NextResponse.json(
{ error: `Unknown action: ${action}` },
{ status: 400 },
);
}
} catch (error) {
console.error("[Robots API] Error:", error);
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Internal server error",
},
{ status: 500 },
);
}
}

View File

@@ -274,7 +274,7 @@ export function DesignerRoot({
},
});
const { data: studyPluginsRaw } = api.robots.plugins.getStudyPlugins.useQuery(
const { data: studyPluginsRaw } = api.studies.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId },
);

View File

@@ -197,22 +197,21 @@ export function PluginStoreBrowse() {
) as { data: Array<{ id: string; url: string; name: string }> | undefined };
// Get installed plugins for current study
const { data: installedPlugins } =
api.robots.plugins.getStudyPlugins.useQuery(
{
studyId: selectedStudyId!,
},
{
enabled: !!selectedStudyId,
refetchOnWindowFocus: false,
},
);
const { data: installedPlugins } = api.studies.getStudyPlugins.useQuery(
{
studyId: selectedStudyId!,
},
{
enabled: !!selectedStudyId,
refetchOnWindowFocus: false,
},
);
const {
data: availablePlugins,
isLoading,
error,
} = api.robots.plugins.list.useQuery(
} = api.plugins.list.useQuery(
{
status:
statusFilter === "all"
@@ -228,12 +227,12 @@ export function PluginStoreBrowse() {
const utils = api.useUtils();
const installPluginMutation = api.robots.plugins.install.useMutation({
const installPluginMutation = api.plugins.install.useMutation({
onSuccess: () => {
toast.success("Plugin installed successfully!");
// Invalidate both plugin queries to refresh the UI
void utils.robots.plugins.list.invalidate();
void utils.robots.plugins.getStudyPlugins.invalidate();
void utils.plugins.list.invalidate();
void utils.studies.getStudyPlugins.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to install plugin");
@@ -430,7 +429,7 @@ export function PluginStoreBrowse() {
"An error occurred while loading the plugin store."}
</p>
<Button
onClick={() => void utils.robots.plugins.list.refetch()}
onClick={() => void utils.plugins.list.refetch()}
variant="outline"
>
Try Again

View File

@@ -90,12 +90,12 @@ function PluginActionsCell({ plugin }: { plugin: Plugin }) {
const { selectedStudyId } = useStudyContext();
const utils = api.useUtils();
const uninstallMutation = api.robots.plugins.uninstall.useMutation({
const uninstallMutation = api.plugins.uninstall.useMutation({
onSuccess: () => {
toast.success("Plugin uninstalled successfully");
// Invalidate plugin queries to refresh the UI
void utils.robots.plugins.getStudyPlugins.invalidate();
void utils.robots.plugins.list.invalidate();
void utils.studies.getStudyPlugins.invalidate();
void utils.plugins.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to uninstall plugin");

View File

@@ -28,7 +28,7 @@ export function PluginsDataTable() {
isLoading,
error,
refetch,
} = api.robots.plugins.getStudyPlugins.useQuery(
} = api.studies.getStudyPlugins.useQuery(
{
studyId: selectedStudyId!,
},

View File

@@ -182,7 +182,7 @@ export function RobotActionsPanel({
// Get installed plugins for the study
const { data: plugins = [], isLoading } =
api.robots.plugins.getStudyPlugins.useQuery({
api.studies.getStudyPlugins.useQuery({
studyId,
});

View File

@@ -167,19 +167,36 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
// Robot initialization mutation (for startup routine)
const initializeRobotMutation = api.robots.plugins.initialize.useMutation({
onSuccess: () => {
const [isInitializing, setIsInitializing] = useState(false);
const initializeRobot = async () => {
setIsInitializing(true);
try {
const response = await fetch("/api/robots/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "initialize",
studyId: trial?.experiment.studyId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to initialize robot");
}
toast.success("Robot initialized", {
description: "Autonomous Life disabled and robot awake.",
});
},
onError: (error: any) => {
} catch (error: any) {
toast.error("Robot initialization failed", {
description: error.message,
});
},
});
} finally {
setIsInitializing(false);
}
};
// Log robot action mutation (for client-side execution)
const logRobotActionMutation = api.trials.logRobotAction.useMutation({
@@ -188,8 +205,34 @@ export const WizardInterface = React.memo(function WizardInterface({
},
});
const executeSystemActionMutation =
api.robots.plugins.executeSystemAction.useMutation();
const executeSystemAction = async (
actionId: string,
params?: Record<string, unknown>,
) => {
setIsExecutingAction(true);
try {
const response = await fetch("/api/robots/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "executeSystemAction",
studyId: trial?.experiment.studyId,
parameters: { id: actionId, parameters: params },
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to execute action");
}
return { success: true };
} catch (error) {
throw error;
} finally {
setIsExecutingAction(false);
}
};
const [isCompleting, setIsCompleting] = useState(false);
// Map database step types to component step types
@@ -236,10 +279,7 @@ export const WizardInterface = React.memo(function WizardInterface({
autoConnect: true,
onSystemAction: async (actionId, parameters) => {
console.log(`[Wizard] Executing system action: ${actionId}`, parameters);
await executeSystemActionMutation.mutateAsync({
id: actionId,
parameters,
});
await executeSystemAction(actionId, parameters);
},
});
@@ -549,7 +589,7 @@ export const WizardInterface = React.memo(function WizardInterface({
"[WizardInterface] Triggering robot initialization:",
trial.experiment.robotId,
);
initializeRobotMutation.mutate({ id: trial.experiment.robotId });
await initializeRobot();
}
toast.success("Trial started successfully");

View File

@@ -7,7 +7,7 @@ import { experimentsRouter } from "~/server/api/routers/experiments";
import { filesRouter } from "~/server/api/routers/files";
import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
import { robotsRouter } from "~/server/api/routers/robots";
import { pluginsRouter } from "~/server/api/routers/plugins";
import { studiesRouter } from "~/server/api/routers/studies";
import { trialsRouter } from "~/server/api/routers/trials";
import { usersRouter } from "~/server/api/routers/users";
@@ -26,9 +26,9 @@ export const appRouter = createTRPCRouter({
experiments: experimentsRouter,
participants: participantsRouter,
trials: trialsRouter,
robots: robotsRouter,
files: filesRouter,
media: mediaRouter,
plugins: pluginsRouter,
analytics: analyticsRouter,
collaboration: collaborationRouter,
admin: adminRouter,

View File

@@ -0,0 +1,235 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq, type SQL } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
pluginStatusEnum,
plugins,
studyMembers,
studyPlugins,
} from "~/server/db/schema";
export const pluginsRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
robotId: z.string().optional(),
status: z.enum(pluginStatusEnum.enumValues).optional(),
limit: z.number().min(1).max(100).default(50),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const conditions: SQL[] = [];
if (input.robotId) {
conditions.push(eq(plugins.robotId, input.robotId));
}
if (input.status) {
conditions.push(eq(plugins.status, input.status));
}
const query = ctx.db
.select({
id: plugins.id,
robotId: plugins.robotId,
name: plugins.name,
version: plugins.version,
description: plugins.description,
author: plugins.author,
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
metadata: plugins.metadata,
})
.from(plugins);
const results = await (
conditions.length > 0 ? query.where(and(...conditions)) : query
)
.orderBy(desc(plugins.updatedAt))
.limit(input.limit)
.offset(input.offset);
return results;
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const pluginResults = await ctx.db
.select()
.from(plugins)
.where(eq(plugins.id, input.id))
.limit(1);
const plugin = pluginResults[0];
if (!plugin) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
return plugin;
}),
install: protectedProcedure
.input(
z.object({
studyId: z.string(),
pluginId: z.string(),
configuration: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Check if user has appropriate access
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership || !["owner", "researcher"].includes(membership.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Insufficient permissions to install plugins",
});
}
// Check if plugin exists
const plugin = await ctx.db
.select()
.from(plugins)
.where(eq(plugins.id, input.pluginId))
.limit(1);
if (!plugin[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
// Check if plugin is already installed
const existing = await ctx.db
.select()
.from(studyPlugins)
.where(
and(
eq(studyPlugins.studyId, input.studyId),
eq(studyPlugins.pluginId, input.pluginId),
),
)
.limit(1);
if (existing[0]) {
throw new TRPCError({
code: "CONFLICT",
message: "Plugin already installed for this study",
});
}
const installations = await ctx.db
.insert(studyPlugins)
.values({
studyId: input.studyId,
pluginId: input.pluginId,
configuration: input.configuration ?? {},
installedBy: userId,
})
.returning();
const installation = installations[0];
if (!installation) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to install plugin",
});
}
return installation;
}),
uninstall: protectedProcedure
.input(
z.object({
studyId: z.string(),
pluginId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Check if user has appropriate access
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership || !["owner", "researcher"].includes(membership.role)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Insufficient permissions to uninstall plugins",
});
}
const result = await ctx.db
.delete(studyPlugins)
.where(
and(
eq(studyPlugins.studyId, input.studyId),
eq(studyPlugins.pluginId, input.pluginId),
),
)
.returning();
if (!result[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin installation not found",
});
}
return { success: true };
}),
getActions: protectedProcedure
.input(
z.object({
pluginId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const plugin = await ctx.db
.select({
id: plugins.id,
actionDefinitions: plugins.actionDefinitions,
})
.from(plugins)
.where(eq(plugins.id, input.pluginId))
.limit(1);
if (!plugin[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Plugin not found",
});
}
return plugin[0].actionDefinitions ?? [];
}),
});

View File

@@ -841,6 +841,63 @@ export const studiesRouter = createTRPCRouter({
};
}),
getStudyPlugins: protectedProcedure
.input(
z.object({
studyId: z.string().uuid(),
}),
)
.query(async ({ ctx, input }) => {
const { studyId } = input;
const userId = ctx.session.user.id;
// Check if user has access to this study (any role)
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, userId),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this study",
});
}
const installedPlugins = await ctx.db
.select({
plugin: {
id: plugins.id,
robotId: plugins.robotId,
name: plugins.name,
version: plugins.version,
description: plugins.description,
author: plugins.author,
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
actionDefinitions: plugins.actionDefinitions,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
metadata: plugins.metadata,
},
installation: {
id: studyPlugins.id,
configuration: studyPlugins.configuration,
installedAt: studyPlugins.installedAt,
installedBy: studyPlugins.installedBy,
},
})
.from(studyPlugins)
.innerJoin(plugins, eq(studyPlugins.pluginId, plugins.id))
.where(eq(studyPlugins.studyId, studyId))
.orderBy(desc(studyPlugins.installedAt));
return installedPlugins;
}),
// Plugin configuration management
getPluginConfiguration: protectedProcedure
.input(

View File

@@ -7,7 +7,7 @@ import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react";
import SuperJSON from "superjson";
import { type AppRouter } from "~/server/api/root";
import type { AppRouter } from "~/server/api/root";
import { createQueryClient } from "./query-client";
let clientQueryClientSingleton: QueryClient | undefined = undefined;