mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
chore: clean diagnostics and prepare for designer structural refactor (stub legacy useActiveStudy)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { BlockDesigner } from "~/components/experiments/designer/BlockDesigner";
|
||||
import { DesignerShell } from "~/components/experiments/designer/DesignerShell";
|
||||
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
@@ -44,7 +44,7 @@ export default async function ExperimentDesignerPage({
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<BlockDesigner
|
||||
<DesignerShell
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
/>
|
||||
|
||||
@@ -35,8 +35,12 @@ export default async function DashboardLayout({
|
||||
const cookieStore = await cookies();
|
||||
const defaultOpen = cookieStore.get("sidebar_state")?.value === "true";
|
||||
|
||||
// Pre-seed selected study from cookie (SSR) to avoid client flash
|
||||
const selectedStudyCookie =
|
||||
cookieStore.get("hristudio_selected_study")?.value ?? null;
|
||||
|
||||
return (
|
||||
<StudyProvider>
|
||||
<StudyProvider initialStudyId={selectedStudyCookie}>
|
||||
<BreadcrumbProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar userRole={userRole} />
|
||||
|
||||
@@ -4,19 +4,21 @@ import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { ParticipantsTable } from "~/components/participants/ParticipantsTable";
|
||||
import { ManagementPageLayout } from "~/components/ui/page-layout";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
|
||||
export default function StudyParticipantsPage() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const { setActiveStudy, activeStudy } = useActiveStudy();
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// Set the active study if it doesn't match the current route
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && activeStudy?.id !== studyId) {
|
||||
setActiveStudy(studyId);
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, activeStudy?.id, setActiveStudy]);
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
return (
|
||||
<ManagementPageLayout
|
||||
@@ -25,7 +27,7 @@ export default function StudyParticipantsPage() {
|
||||
breadcrumb={[
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: activeStudy?.title ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Participants" },
|
||||
]}
|
||||
createButton={{
|
||||
|
||||
@@ -4,19 +4,21 @@ import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { TrialsTable } from "~/components/trials/TrialsTable";
|
||||
import { ManagementPageLayout } from "~/components/ui/page-layout";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
|
||||
export default function StudyTrialsPage() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const { setActiveStudy, activeStudy } = useActiveStudy();
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// Set the active study if it doesn't match the current route
|
||||
useEffect(() => {
|
||||
if (studyId && activeStudy?.id !== studyId) {
|
||||
setActiveStudy(studyId);
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, activeStudy?.id, setActiveStudy]);
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
return (
|
||||
<ManagementPageLayout
|
||||
@@ -25,7 +27,7 @@ export default function StudyTrialsPage() {
|
||||
breadcrumb={[
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: activeStudy?.title ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials" },
|
||||
]}
|
||||
createButton={{
|
||||
|
||||
@@ -430,37 +430,99 @@ export function AppSidebar({
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
{/* Debug Info */}
|
||||
{showDebug && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Debug Info</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<div className="text-muted-foreground space-y-1 px-3 py-2 text-xs">
|
||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
||||
<div>Role: {userRole ?? "No role"}</div>
|
||||
<div>Studies: {userStudies.length}</div>
|
||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
||||
{debugData && (
|
||||
<>
|
||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
||||
<div>
|
||||
System Roles: {debugData.systemRoles.join(", ") || "None"}
|
||||
</div>
|
||||
<div>Memberships: {debugData.studyMemberships.length}</div>
|
||||
<div>All Studies: {debugData.allStudies.length}</div>
|
||||
<div>
|
||||
Session ID: {debugData.session.userId.slice(0, 8)}...
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
{/* Debug info moved to footer tooltip button */}
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
{showDebug && (
|
||||
<SidebarMenuItem>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-xs"
|
||||
aria-label="Debug info"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
className="space-y-1 p-2 text-[10px]"
|
||||
>
|
||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
||||
<div>Role: {userRole ?? "No role"}</div>
|
||||
<div>Studies: {userStudies.length}</div>
|
||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
||||
{debugData && (
|
||||
<>
|
||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
||||
<div>
|
||||
System Roles:{" "}
|
||||
{debugData.systemRoles.join(", ") || "None"}
|
||||
</div>
|
||||
<div>
|
||||
Memberships: {debugData.studyMemberships.length}
|
||||
</div>
|
||||
<div>All Studies: {debugData.allStudies.length}</div>
|
||||
<div>
|
||||
Session ID: {debugData.session.userId.slice(0, 8)}
|
||||
...
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full justify-start">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span className="truncate">Debug</span>
|
||||
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-popper-anchor-width] max-w-72"
|
||||
align="start"
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs font-medium">
|
||||
Debug Info
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="space-y-1 px-2 py-1 text-[11px] leading-tight">
|
||||
<div>Session: {session?.user?.email ?? "No session"}</div>
|
||||
<div>Role: {userRole ?? "No role"}</div>
|
||||
<div>Studies: {userStudies.length}</div>
|
||||
<div>Selected: {selectedStudy?.name ?? "None"}</div>
|
||||
<div>Auth: {session ? "✓" : "✗"}</div>
|
||||
{debugData && (
|
||||
<>
|
||||
<div>DB User: {debugData.user?.email ?? "None"}</div>
|
||||
<div>
|
||||
System Roles:{" "}
|
||||
{debugData.systemRoles.join(", ") || "None"}
|
||||
</div>
|
||||
<div>
|
||||
Memberships: {debugData.studyMemberships.length}
|
||||
</div>
|
||||
<div>All Studies: {debugData.allStudies.length}</div>
|
||||
<div>
|
||||
Session ID: {debugData.session.userId.slice(0, 8)}
|
||||
...
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
<SidebarMenuItem>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function StudyGuard({ children, fallback }: StudyGuardProps) {
|
||||
}
|
||||
|
||||
if (!selectedStudyId) {
|
||||
return fallback || <DefaultStudyRequiredMessage />;
|
||||
return fallback ?? <DefaultStudyRequiredMessage />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Experiment = {
|
||||
@@ -37,28 +37,26 @@ export type Experiment = {
|
||||
createdByName: string;
|
||||
trialCount: number;
|
||||
stepCount: number;
|
||||
actionCount: number;
|
||||
latestActivityAt: Date | null;
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: "📝",
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
className: "bg-yellow-100 text-yellow-800",
|
||||
icon: "🧪",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: "✅",
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
className: "bg-red-100 text-red-800",
|
||||
icon: "🚫",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,24 +118,7 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "studyName",
|
||||
header: "Study",
|
||||
cell: ({ row }) => {
|
||||
const studyName = row.getValue("studyName");
|
||||
const studyId = row.original.studyId;
|
||||
return (
|
||||
<div className="max-w-[120px] truncate">
|
||||
<Link
|
||||
href={`/studies/${studyId}`}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{String(studyName)}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Study column removed (active study context already selected)
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
@@ -153,12 +134,7 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={statusInfo.className}>
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -181,6 +157,18 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actionCount",
|
||||
header: "Actions",
|
||||
cell: ({ row }) => {
|
||||
const actionCount = row.getValue("actionCount");
|
||||
return (
|
||||
<Badge className="bg-indigo-100 text-indigo-800">
|
||||
{Number(actionCount)} action{Number(actionCount) !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "trialCount",
|
||||
header: "Trials",
|
||||
@@ -200,6 +188,23 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "latestActivityAt",
|
||||
header: "Last Activity",
|
||||
cell: ({ row }) => {
|
||||
const ts = row.getValue("latestActivityAt");
|
||||
if (!ts) {
|
||||
return <span className="text-muted-foreground text-sm">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="text-sm">
|
||||
{formatDistanceToNow(new Date(ts as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "estimatedDuration",
|
||||
header: "Duration",
|
||||
@@ -288,7 +293,7 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
];
|
||||
|
||||
export function ExperimentsTable() {
|
||||
const { activeStudy } = useActiveStudy();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: experimentsData,
|
||||
@@ -297,11 +302,11 @@ export function ExperimentsTable() {
|
||||
refetch,
|
||||
} = api.experiments.list.useQuery(
|
||||
{
|
||||
studyId: activeStudy?.id ?? "",
|
||||
studyId: selectedStudyId ?? "",
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!activeStudy?.id,
|
||||
enabled: !!selectedStudyId,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -320,28 +325,40 @@ export function ExperimentsTable() {
|
||||
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||
trialCount?: number | null;
|
||||
stepCount?: number | null;
|
||||
actionCount?: number | null;
|
||||
latestActivityAt?: string | Date | null;
|
||||
}
|
||||
|
||||
const adapt = (exp: RawExperiment): Experiment => ({
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
description: exp.description ?? "",
|
||||
status: exp.status,
|
||||
version: exp.version,
|
||||
estimatedDuration: exp.estimatedDuration ?? 0,
|
||||
createdAt:
|
||||
exp.createdAt instanceof Date ? exp.createdAt : new Date(exp.createdAt),
|
||||
studyId: exp.studyId,
|
||||
studyName: activeStudy?.title ?? "Unknown Study",
|
||||
createdByName: exp.createdBy?.name ?? exp.createdBy?.email ?? "Unknown",
|
||||
trialCount: exp.trialCount ?? 0,
|
||||
stepCount: exp.stepCount ?? 0,
|
||||
});
|
||||
const adapt = (exp: RawExperiment): Experiment => {
|
||||
const createdAt =
|
||||
exp.createdAt instanceof Date ? exp.createdAt : new Date(exp.createdAt);
|
||||
const latestActivityAt = exp.latestActivityAt
|
||||
? exp.latestActivityAt instanceof Date
|
||||
? exp.latestActivityAt
|
||||
: new Date(exp.latestActivityAt)
|
||||
: null;
|
||||
return {
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
description: exp.description ?? "",
|
||||
status: exp.status,
|
||||
version: exp.version,
|
||||
estimatedDuration: exp.estimatedDuration ?? 0,
|
||||
createdAt,
|
||||
studyId: exp.studyId,
|
||||
studyName: "Active Study",
|
||||
createdByName: exp.createdBy?.name ?? exp.createdBy?.email ?? "Unknown",
|
||||
trialCount: exp.trialCount ?? 0,
|
||||
stepCount: exp.stepCount ?? 0,
|
||||
actionCount: exp.actionCount ?? 0,
|
||||
latestActivityAt,
|
||||
};
|
||||
};
|
||||
|
||||
return experimentsData.map((e) => adapt(e as unknown as RawExperiment));
|
||||
}, [experimentsData, activeStudy]);
|
||||
}, [experimentsData]);
|
||||
|
||||
if (!activeStudy) {
|
||||
if (!selectedStudyId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { useActionRegistry } from "./ActionRegistry";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
Plus,
|
||||
@@ -119,7 +119,7 @@ function DraggableAction({ action }: DraggableActionProps) {
|
||||
{showTooltip && (
|
||||
<div className="bg-popover absolute top-0 left-full z-50 ml-2 max-w-xs rounded-md border p-2 text-xs shadow-md">
|
||||
<div className="font-medium">{action.name}</div>
|
||||
<div className="text-muted-foreground">{action.description}</div>
|
||||
<div className="text-muted-foreground">{action.description}</div>
|
||||
<div className="mt-1 text-xs opacity-75">
|
||||
Category: {action.category} • ID: {action.id}
|
||||
</div>
|
||||
@@ -139,7 +139,7 @@ export interface ActionLibraryProps {
|
||||
}
|
||||
|
||||
export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
const registry = actionRegistry;
|
||||
const registry = useActionRegistry();
|
||||
const [activeCategory, setActiveCategory] =
|
||||
useState<ActionDefinition["category"]>("wizard");
|
||||
|
||||
@@ -216,7 +216,9 @@ export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
) : (
|
||||
registry
|
||||
.getActionsByCategory(activeCategory)
|
||||
.map((action) => <DraggableAction key={action.id} action={action} />)
|
||||
.map((action) => (
|
||||
<DraggableAction key={action.id} action={action} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -230,6 +232,18 @@ export function ActionLibrary({ className }: ActionLibraryProps) {
|
||||
{registry.getActionsByCategory(activeCategory).length} in view
|
||||
</Badge>
|
||||
</div>
|
||||
{/* Debug info */}
|
||||
<div className="text-muted-foreground mt-1 text-[9px]">
|
||||
W:{registry.getActionsByCategory("wizard").length} R:
|
||||
{registry.getActionsByCategory("robot").length} C:
|
||||
{registry.getActionsByCategory("control").length} O:
|
||||
{registry.getActionsByCategory("observation").length}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[9px]">
|
||||
Core loaded: {registry.getDebugInfo().coreActionsLoaded ? "✓" : "✗"}
|
||||
Plugins loaded:{" "}
|
||||
{registry.getDebugInfo().pluginActionsLoaded ? "✓" : "✗"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
|
||||
/**
|
||||
@@ -27,6 +28,7 @@ export class ActionRegistry {
|
||||
private coreActionsLoaded = false;
|
||||
private pluginActionsLoaded = false;
|
||||
private loadedStudyId: string | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
static getInstance(): ActionRegistry {
|
||||
if (!ActionRegistry.instance) {
|
||||
@@ -35,6 +37,17 @@ export class ActionRegistry {
|
||||
return ActionRegistry.instance;
|
||||
}
|
||||
|
||||
/* ---------------- Reactivity ---------------- */
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private notifyListeners(): void {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
/* ---------------- Core Actions ---------------- */
|
||||
|
||||
async loadCoreActions(): Promise<void> {
|
||||
@@ -67,21 +80,26 @@ export class ActionRegistry {
|
||||
}
|
||||
|
||||
try {
|
||||
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
|
||||
const coreActionSets = [
|
||||
"wizard-actions",
|
||||
"control-flow",
|
||||
"observation",
|
||||
"events",
|
||||
];
|
||||
|
||||
for (const actionSetId of coreActionSets) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/hristudio-core/plugins/${actionSetId}.json`,
|
||||
);
|
||||
// Non-blocking skip if not found
|
||||
// Non-blocking skip if not found
|
||||
if (!response.ok) continue;
|
||||
|
||||
const rawActionSet = (await response.json()) as unknown;
|
||||
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
|
||||
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
|
||||
|
||||
// Register each block as an ActionDefinition
|
||||
// Register each block as an ActionDefinition
|
||||
actionSet.blocks.forEach((block) => {
|
||||
if (!block.id || !block.name) return;
|
||||
|
||||
@@ -131,6 +149,7 @@ export class ActionRegistry {
|
||||
}
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
this.notifyListeners();
|
||||
} catch (error) {
|
||||
console.error("Failed to load core actions:", error);
|
||||
this.loadFallbackActions();
|
||||
@@ -142,8 +161,9 @@ export class ActionRegistry {
|
||||
): ActionDefinition["category"] {
|
||||
switch (category) {
|
||||
case "wizard":
|
||||
case "event":
|
||||
return "wizard";
|
||||
case "event":
|
||||
return "wizard"; // Events are wizard-initiated triggers
|
||||
case "robot":
|
||||
return "robot";
|
||||
case "control":
|
||||
@@ -252,6 +272,7 @@ export class ActionRegistry {
|
||||
];
|
||||
|
||||
fallbackActions.forEach((action) => this.actions.set(action.id, action));
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
@@ -294,22 +315,52 @@ export class ActionRegistry {
|
||||
};
|
||||
}>,
|
||||
): void {
|
||||
console.log("ActionRegistry.loadPluginActions called with:", {
|
||||
studyId,
|
||||
pluginCount: studyPlugins?.length ?? 0,
|
||||
plugins: studyPlugins?.map((sp) => ({
|
||||
id: sp.plugin.id,
|
||||
actionCount: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions.length
|
||||
: 0,
|
||||
hasActionDefs: !!sp.plugin.actionDefinitions,
|
||||
})),
|
||||
});
|
||||
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
this.resetPluginActions();
|
||||
}
|
||||
|
||||
let totalActionsLoaded = 0;
|
||||
|
||||
(studyPlugins ?? []).forEach((studyPlugin) => {
|
||||
const { plugin } = studyPlugin;
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
|
||||
console.log(`Plugin ${plugin.id}:`, {
|
||||
actionDefinitions: plugin.actionDefinitions,
|
||||
isArray: Array.isArray(plugin.actionDefinitions),
|
||||
actionCount: actionDefs?.length ?? 0,
|
||||
});
|
||||
|
||||
if (!actionDefs) return;
|
||||
|
||||
actionDefs.forEach((action) => {
|
||||
const category =
|
||||
(action.category as ActionDefinition["category"]) || "robot";
|
||||
const rawCategory =
|
||||
typeof action.category === "string"
|
||||
? action.category.toLowerCase().trim()
|
||||
: "";
|
||||
const categoryMap: Record<string, ActionDefinition["category"]> = {
|
||||
wizard: "wizard",
|
||||
robot: "robot",
|
||||
control: "control",
|
||||
observation: "observation",
|
||||
};
|
||||
const category = categoryMap[rawCategory] ?? "robot";
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
@@ -364,11 +415,26 @@ export class ActionRegistry {
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
};
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
totalActionsLoaded++;
|
||||
});
|
||||
});
|
||||
|
||||
console.log(
|
||||
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
|
||||
);
|
||||
console.log("Current action registry state:", {
|
||||
totalActions: this.actions.size,
|
||||
actionsByCategory: {
|
||||
wizard: this.getActionsByCategory("wizard").length,
|
||||
robot: this.getActionsByCategory("robot").length,
|
||||
control: this.getActionsByCategory("control").length,
|
||||
observation: this.getActionsByCategory("observation").length,
|
||||
},
|
||||
});
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
private convertParameterSchemaToParameters(
|
||||
@@ -422,8 +488,23 @@ export class ActionRegistry {
|
||||
const pluginActionIds = Array.from(this.actions.keys()).filter(
|
||||
(id) =>
|
||||
!id.startsWith("wizard_") &&
|
||||
!id.startsWith("when_") &&
|
||||
!id.startsWith("wait") &&
|
||||
!id.startsWith("observe"),
|
||||
!id.startsWith("observe") &&
|
||||
!id.startsWith("repeat") &&
|
||||
!id.startsWith("if_") &&
|
||||
!id.startsWith("parallel") &&
|
||||
!id.startsWith("sequence") &&
|
||||
!id.startsWith("random_") &&
|
||||
!id.startsWith("try_") &&
|
||||
!id.startsWith("break") &&
|
||||
!id.startsWith("measure_") &&
|
||||
!id.startsWith("count_") &&
|
||||
!id.startsWith("record_") &&
|
||||
!id.startsWith("capture_") &&
|
||||
!id.startsWith("log_") &&
|
||||
!id.startsWith("survey_") &&
|
||||
!id.startsWith("physiological_"),
|
||||
);
|
||||
pluginActionIds.forEach((id) => this.actions.delete(id));
|
||||
}
|
||||
@@ -445,6 +526,46 @@ export class ActionRegistry {
|
||||
getAction(id: string): ActionDefinition | undefined {
|
||||
return this.actions.get(id);
|
||||
}
|
||||
|
||||
/* ---------------- Debug Helpers ---------------- */
|
||||
|
||||
getDebugInfo(): {
|
||||
coreActionsLoaded: boolean;
|
||||
pluginActionsLoaded: boolean;
|
||||
loadedStudyId: string | null;
|
||||
totalActions: number;
|
||||
actionsByCategory: Record<ActionDefinition["category"], number>;
|
||||
sampleActionIds: string[];
|
||||
} {
|
||||
return {
|
||||
coreActionsLoaded: this.coreActionsLoaded,
|
||||
pluginActionsLoaded: this.pluginActionsLoaded,
|
||||
loadedStudyId: this.loadedStudyId,
|
||||
totalActions: this.actions.size,
|
||||
actionsByCategory: {
|
||||
wizard: this.getActionsByCategory("wizard").length,
|
||||
robot: this.getActionsByCategory("robot").length,
|
||||
control: this.getActionsByCategory("control").length,
|
||||
observation: this.getActionsByCategory("observation").length,
|
||||
},
|
||||
sampleActionIds: Array.from(this.actions.keys()).slice(0, 10),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const actionRegistry = ActionRegistry.getInstance();
|
||||
|
||||
/* ---------------- React Hook ---------------- */
|
||||
|
||||
export function useActionRegistry(): ActionRegistry {
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = actionRegistry.subscribe(() => {
|
||||
forceUpdate({});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
return actionRegistry;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* BlockDesigner is being phased out in favor of DesignerShell (see DesignerShell.tsx).
|
||||
* TODO: Remove this file after full migration of add/update/delete handlers, hashing,
|
||||
* validation, drift detection, and export logic to the new architecture.
|
||||
*/
|
||||
|
||||
/**
|
||||
* BlockDesigner (Modular Refactor)
|
||||
*
|
||||
|
||||
554
src/components/experiments/designer/DependencyInspector.tsx
Normal file
554
src/components/experiments/designer/DependencyInspector.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
Package,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface PluginDependency {
|
||||
pluginId: string;
|
||||
version: string;
|
||||
robotId?: string;
|
||||
name?: string;
|
||||
status: "available" | "missing" | "outdated" | "error";
|
||||
installedVersion?: string;
|
||||
actionCount: number;
|
||||
driftedActionCount: number;
|
||||
}
|
||||
|
||||
export interface ActionSignatureDrift {
|
||||
actionId: string;
|
||||
actionName: string;
|
||||
stepId: string;
|
||||
stepName: string;
|
||||
type: string;
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
driftType: "missing_definition" | "schema_changed" | "version_mismatch";
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface DependencyInspectorProps {
|
||||
steps: ExperimentStep[];
|
||||
/**
|
||||
* Map of action instance ID to signature drift information
|
||||
*/
|
||||
actionSignatureDrift: Set<string>;
|
||||
/**
|
||||
* Available action definitions from registry
|
||||
*/
|
||||
actionDefinitions: ActionDefinition[];
|
||||
/**
|
||||
* Called when user wants to reconcile a drifted action
|
||||
*/
|
||||
onReconcileAction?: (actionId: string) => void;
|
||||
/**
|
||||
* Called when user wants to refresh plugin dependencies
|
||||
*/
|
||||
onRefreshDependencies?: () => void;
|
||||
/**
|
||||
* Called when user wants to install a missing plugin
|
||||
*/
|
||||
onInstallPlugin?: (pluginId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Functions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function extractPluginDependencies(
|
||||
steps: ExperimentStep[],
|
||||
actionDefinitions: ActionDefinition[],
|
||||
driftedActions: Set<string>,
|
||||
): PluginDependency[] {
|
||||
const dependencyMap = new Map<string, PluginDependency>();
|
||||
|
||||
// Collect all plugin actions used in the experiment
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||
const key = `${action.source.pluginId}@${action.source.pluginVersion}`;
|
||||
|
||||
if (!dependencyMap.has(key)) {
|
||||
dependencyMap.set(key, {
|
||||
pluginId: action.source.pluginId,
|
||||
version: action.source.pluginVersion ?? "unknown",
|
||||
status: "available", // Will be updated below
|
||||
actionCount: 0,
|
||||
driftedActionCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const dep = dependencyMap.get(key)!;
|
||||
dep.actionCount++;
|
||||
|
||||
if (driftedActions.has(action.id)) {
|
||||
dep.driftedActionCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update status based on available definitions
|
||||
dependencyMap.forEach((dep) => {
|
||||
const availableActions = actionDefinitions.filter(
|
||||
(def) =>
|
||||
def.source.kind === "plugin" && def.source.pluginId === dep.pluginId,
|
||||
);
|
||||
|
||||
if (availableActions.length === 0) {
|
||||
dep.status = "missing";
|
||||
} else {
|
||||
// Check if we have the exact version
|
||||
const exactVersion = availableActions.find(
|
||||
(def) => def.source.pluginVersion === dep.version,
|
||||
);
|
||||
|
||||
if (!exactVersion) {
|
||||
dep.status = "outdated";
|
||||
// Get the installed version
|
||||
const anyVersion = availableActions[0];
|
||||
dep.installedVersion = anyVersion?.source.pluginVersion;
|
||||
} else {
|
||||
dep.status = "available";
|
||||
dep.installedVersion = dep.version;
|
||||
}
|
||||
|
||||
// Set plugin name from first available definition
|
||||
if (availableActions[0]) {
|
||||
dep.name = availableActions[0].source.pluginId; // Could be enhanced with actual plugin name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(dependencyMap.values()).sort((a, b) =>
|
||||
a.pluginId.localeCompare(b.pluginId),
|
||||
);
|
||||
}
|
||||
|
||||
function extractActionDrifts(
|
||||
steps: ExperimentStep[],
|
||||
actionDefinitions: ActionDefinition[],
|
||||
driftedActions: Set<string>,
|
||||
): ActionSignatureDrift[] {
|
||||
const drifts: ActionSignatureDrift[] = [];
|
||||
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
if (driftedActions.has(action.id)) {
|
||||
const definition = actionDefinitions.find(
|
||||
(def) => def.type === action.type,
|
||||
);
|
||||
|
||||
let driftType: ActionSignatureDrift["driftType"] = "missing_definition";
|
||||
let details = "";
|
||||
|
||||
if (!definition) {
|
||||
driftType = "missing_definition";
|
||||
details = `Action definition for type '${action.type}' not found`;
|
||||
} else if (
|
||||
action.source.pluginId &&
|
||||
action.source.pluginVersion !== definition.source.pluginVersion
|
||||
) {
|
||||
driftType = "version_mismatch";
|
||||
details = `Expected v${action.source.pluginVersion}, found v${definition.source.pluginVersion}`;
|
||||
} else {
|
||||
driftType = "schema_changed";
|
||||
details = "Action schema or execution parameters have changed";
|
||||
}
|
||||
|
||||
drifts.push({
|
||||
actionId: action.id,
|
||||
actionName: action.name,
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
type: action.type,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
driftType,
|
||||
details,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return drifts;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Plugin Dependency Item */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface PluginDependencyItemProps {
|
||||
dependency: PluginDependency;
|
||||
onInstall?: (pluginId: string) => void;
|
||||
}
|
||||
|
||||
function PluginDependencyItem({
|
||||
dependency,
|
||||
onInstall,
|
||||
}: PluginDependencyItemProps) {
|
||||
const statusConfig = {
|
||||
available: {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
badgeVariant: "outline" as const,
|
||||
badgeColor: "border-green-300 text-green-700 dark:text-green-300",
|
||||
},
|
||||
missing: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
badgeVariant: "destructive" as const,
|
||||
badgeColor: "",
|
||||
},
|
||||
outdated: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
badgeVariant: "secondary" as const,
|
||||
badgeColor: "",
|
||||
},
|
||||
error: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
badgeVariant: "destructive" as const,
|
||||
badgeColor: "",
|
||||
},
|
||||
};
|
||||
|
||||
const config = statusConfig[dependency.status];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className={cn("h-4 w-4", config.color)} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{dependency.pluginId}</span>
|
||||
<Badge
|
||||
variant={config.badgeVariant}
|
||||
className={cn("h-4 text-[10px]", config.badgeColor)}
|
||||
>
|
||||
{dependency.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
v{dependency.version}
|
||||
{dependency.installedVersion &&
|
||||
dependency.installedVersion !== dependency.version && (
|
||||
<span> (installed: v{dependency.installedVersion})</span>
|
||||
)}
|
||||
• {dependency.actionCount} actions
|
||||
{dependency.driftedActionCount > 0 && (
|
||||
<span className="text-amber-600 dark:text-amber-400">
|
||||
• {dependency.driftedActionCount} drifted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dependency.status === "missing" && onInstall && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => onInstall(dependency.pluginId)}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action Drift Item */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ActionDriftItemProps {
|
||||
drift: ActionSignatureDrift;
|
||||
onReconcile?: (actionId: string) => void;
|
||||
}
|
||||
|
||||
function ActionDriftItem({ drift, onReconcile }: ActionDriftItemProps) {
|
||||
const driftConfig = {
|
||||
missing_definition: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Missing",
|
||||
},
|
||||
schema_changed: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
badgeVariant: "secondary" as const,
|
||||
label: "Schema Changed",
|
||||
},
|
||||
version_mismatch: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Version Mismatch",
|
||||
},
|
||||
};
|
||||
|
||||
const config = driftConfig[drift.driftType];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between rounded-md border p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className={cn("h-4 w-4", config.color)} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{drift.actionName}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
in {drift.stepName} • {drift.type}
|
||||
</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={config.badgeVariant}
|
||||
className="h-4 flex-shrink-0 text-[10px]"
|
||||
>
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{drift.details && (
|
||||
<p className="text-muted-foreground mt-1 text-xs leading-relaxed">
|
||||
{drift.details}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{drift.pluginId && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{drift.pluginId}
|
||||
{drift.pluginVersion && `@${drift.pluginVersion}`}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onReconcile && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={() => onReconcile(drift.actionId)}
|
||||
>
|
||||
Fix
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DependencyInspector Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function DependencyInspector({
|
||||
steps,
|
||||
actionSignatureDrift,
|
||||
actionDefinitions,
|
||||
onReconcileAction,
|
||||
onRefreshDependencies,
|
||||
onInstallPlugin,
|
||||
className,
|
||||
}: DependencyInspectorProps) {
|
||||
const dependencies = useMemo(
|
||||
() =>
|
||||
extractPluginDependencies(steps, actionDefinitions, actionSignatureDrift),
|
||||
[steps, actionDefinitions, actionSignatureDrift],
|
||||
);
|
||||
|
||||
const drifts = useMemo(
|
||||
() => extractActionDrifts(steps, actionDefinitions, actionSignatureDrift),
|
||||
[steps, actionDefinitions, actionSignatureDrift],
|
||||
);
|
||||
|
||||
// Count core vs plugin actions
|
||||
const actionCounts = useMemo(() => {
|
||||
let core = 0;
|
||||
let plugin = 0;
|
||||
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
if (action.source.kind === "plugin") {
|
||||
plugin++;
|
||||
} else {
|
||||
core++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { core, plugin, total: core + plugin };
|
||||
}, [steps]);
|
||||
|
||||
const hasIssues =
|
||||
dependencies.some((d) => d.status !== "available") || drifts.length > 0;
|
||||
|
||||
return (
|
||||
<Card className={cn("h-[calc(100vh-12rem)]", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Dependencies
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{hasIssues ? (
|
||||
<Badge variant="destructive" className="h-4 text-[10px]">
|
||||
Issues
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 border-green-300 text-[10px] text-green-700 dark:text-green-300"
|
||||
>
|
||||
Healthy
|
||||
</Badge>
|
||||
)}
|
||||
{onRefreshDependencies && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={onRefreshDependencies}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-3">
|
||||
{/* Action Summary */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Action Summary
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
<Zap className="mr-1 h-2 w-2" />
|
||||
{actionCounts.core} core
|
||||
</Badge>
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
<Package className="mr-1 h-2 w-2" />
|
||||
{actionCounts.plugin} plugin
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{actionCounts.total} total
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plugin Dependencies */}
|
||||
{dependencies.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Plugin Dependencies ({dependencies.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{dependencies.map((dep) => (
|
||||
<PluginDependencyItem
|
||||
key={`${dep.pluginId}@${dep.version}`}
|
||||
dependency={dep}
|
||||
onInstall={onInstallPlugin}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Action Signature Drifts */}
|
||||
{drifts.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
Action Drift ({drifts.length})
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{drifts.map((drift) => (
|
||||
<ActionDriftItem
|
||||
key={drift.actionId}
|
||||
drift={drift}
|
||||
onReconcile={onReconcileAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{dependencies.length === 0 && drifts.length === 0 && (
|
||||
<div className="py-8 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<Package className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">No plugin dependencies</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
This experiment uses only core actions
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Healthy State */}
|
||||
{dependencies.length > 0 && !hasIssues && (
|
||||
<div className="py-4 text-center">
|
||||
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/20">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
All dependencies healthy
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No drift or missing plugins detected
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
734
src/components/experiments/designer/DesignerShell.tsx
Normal file
734
src/components/experiments/designer/DesignerShell.tsx
Normal file
@@ -0,0 +1,734 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* DesignerShell
|
||||
*
|
||||
* High-level orchestration component for the Experiment Designer redesign.
|
||||
* Replaces prior monolithic `BlockDesigner` responsibilities and delegates:
|
||||
* - Data loading (experiment + study plugins)
|
||||
* - Store initialization (steps, persisted/validated hashes)
|
||||
* - Hash & drift status display
|
||||
* - Save / validate / export actions (callback props)
|
||||
* - Layout composition (Action Library | Step Flow | Properties Panel)
|
||||
*
|
||||
* This file intentionally does NOT contain:
|
||||
* - Raw drag & drop logic (belongs to StepFlow & related internal modules)
|
||||
* - Parameter field rendering logic (PropertiesPanel / ParameterFieldFactory)
|
||||
* - Action registry loading internals (ActionRegistry singleton)
|
||||
*
|
||||
* Future Extensions:
|
||||
* - Conflict modal
|
||||
* - Bulk drift reconciliation
|
||||
* - Command palette (action insertion)
|
||||
* - Auto-save throttle controls
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Play, Save, Download, RefreshCw } from "lucide-react";
|
||||
import { DndContext, closestCenter } from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragOverEvent } from "@dnd-kit/core";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { PageHeader, ActionButton } from "~/components/ui/page-header";
|
||||
|
||||
import { api } from "~/trpc/react";
|
||||
import type {
|
||||
ExperimentDesign,
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ActionDefinition,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { useDesignerStore } from "./state/store";
|
||||
import { computeDesignHash } from "./state/hashing";
|
||||
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { ActionLibrary } from "./ActionLibrary";
|
||||
import { StepFlow } from "./StepFlow";
|
||||
import { PropertiesPanel } from "./PropertiesPanel";
|
||||
import { ValidationPanel } from "./ValidationPanel";
|
||||
import { DependencyInspector } from "./DependencyInspector";
|
||||
import { validateExperimentDesign } from "./state/validators";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface DesignerShellProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
/**
|
||||
* Called after a successful persisted save (server acknowledged).
|
||||
*/
|
||||
onPersist?: (design: ExperimentDesign) => void;
|
||||
/**
|
||||
* Whether to auto-run compilation on save.
|
||||
*/
|
||||
autoCompile?: boolean;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function buildEmptyDesign(
|
||||
experimentId: string,
|
||||
name?: string,
|
||||
description?: string | null,
|
||||
): ExperimentDesign {
|
||||
return {
|
||||
id: experimentId,
|
||||
name: name?.trim().length ? name : "Untitled Experiment",
|
||||
description: description ?? "",
|
||||
version: 1,
|
||||
steps: [],
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
function adaptExistingDesign(experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
visualDesign: unknown;
|
||||
}): ExperimentDesign | undefined {
|
||||
if (
|
||||
!experiment?.visualDesign ||
|
||||
typeof experiment.visualDesign !== "object" ||
|
||||
!("steps" in (experiment.visualDesign as Record<string, unknown>))
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const vd = experiment.visualDesign as {
|
||||
steps?: ExperimentStep[];
|
||||
version?: number;
|
||||
lastSaved?: string;
|
||||
};
|
||||
if (!vd.steps || !Array.isArray(vd.steps)) return undefined;
|
||||
return {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
steps: vd.steps,
|
||||
version: vd.version ?? 1,
|
||||
lastSaved:
|
||||
vd.lastSaved && typeof vd.lastSaved === "string"
|
||||
? new Date(vd.lastSaved)
|
||||
: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* DesignerShell */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function DesignerShell({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onPersist,
|
||||
autoCompile = true,
|
||||
}: DesignerShellProps) {
|
||||
/* ---------------------------- Remote Experiment --------------------------- */
|
||||
const {
|
||||
data: experiment,
|
||||
isLoading: loadingExperiment,
|
||||
refetch: refetchExperiment,
|
||||
} = api.experiments.get.useQuery({ id: experimentId });
|
||||
|
||||
/* ------------------------------ Store Access ------------------------------ */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const setSteps = useDesignerStore((s) => s.setSteps);
|
||||
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
const setPersistedHash = useDesignerStore((s) => s.setPersistedHash);
|
||||
const setValidatedHash = useDesignerStore((s) => s.setValidatedHash);
|
||||
const selectedActionId = useDesignerStore((s) => s.selectedActionId);
|
||||
const selectedStepId = useDesignerStore((s) => s.selectedStepId);
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
const validationIssues = useDesignerStore((s) => s.validationIssues);
|
||||
const actionSignatureDrift = useDesignerStore((s) => s.actionSignatureDrift);
|
||||
const upsertStep = useDesignerStore((s) => s.upsertStep);
|
||||
const removeStep = useDesignerStore((s) => s.removeStep);
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const removeAction = useDesignerStore((s) => s.removeAction);
|
||||
|
||||
/* ------------------------------ Step Creation ------------------------------ */
|
||||
const createNewStep = useCallback(() => {
|
||||
const newStep: ExperimentStep = {
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `Step ${steps.length + 1}`,
|
||||
description: "",
|
||||
type: "sequential",
|
||||
order: steps.length,
|
||||
trigger: {
|
||||
type: "trial_start",
|
||||
conditions: {},
|
||||
},
|
||||
actions: [],
|
||||
expanded: true,
|
||||
};
|
||||
upsertStep(newStep);
|
||||
selectStep(newStep.id);
|
||||
toast.success(`Created ${newStep.name}`);
|
||||
}, [steps.length, upsertStep, selectStep]);
|
||||
|
||||
/* ------------------------------ DnD Handlers ------------------------------ */
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over) return;
|
||||
|
||||
// Handle action drag to step
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
over.id.toString().startsWith("step-")
|
||||
) {
|
||||
const actionData = active.data.current?.action as ActionDefinition;
|
||||
const stepId = over.id.toString().replace("step-", "");
|
||||
|
||||
if (!actionData) return;
|
||||
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
// Create new action instance
|
||||
const newAction: ExperimentAction = {
|
||||
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: actionData.type,
|
||||
name: actionData.name,
|
||||
category: actionData.category,
|
||||
parameters: {},
|
||||
source: actionData.source,
|
||||
execution: actionData.execution ?? {
|
||||
transport: "internal",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
|
||||
upsertAction(stepId, newAction);
|
||||
selectStep(stepId);
|
||||
selectAction(stepId, newAction.id);
|
||||
toast.success(`Added ${actionData.name} to ${step.name}`);
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectStep, selectAction],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((_event: DragOverEvent) => {
|
||||
// This could be used for visual feedback during drag
|
||||
}, []);
|
||||
|
||||
/* ------------------------------- Local State ------------------------------ */
|
||||
const [designMeta, setDesignMeta] = useState<{
|
||||
name: string;
|
||||
description: string;
|
||||
version: number;
|
||||
}>(() => {
|
||||
const init =
|
||||
initialDesign ??
|
||||
(experiment ? adaptExistingDesign(experiment) : undefined) ??
|
||||
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
|
||||
return {
|
||||
name: init.name,
|
||||
description: init.description,
|
||||
version: init.version,
|
||||
};
|
||||
});
|
||||
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
/* ----------------------------- Experiment Update -------------------------- */
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Experiment saved");
|
||||
await refetchExperiment();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Save failed: ${err.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
/* ------------------------------ Plugin Loading ---------------------------- */
|
||||
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
|
||||
{ studyId: experiment?.studyId ?? "" },
|
||||
{ enabled: !!experiment?.studyId },
|
||||
);
|
||||
|
||||
// Load core actions once
|
||||
useEffect(() => {
|
||||
actionRegistry
|
||||
.loadCoreActions()
|
||||
.catch((err) => console.error("Core action load failed:", err));
|
||||
}, []);
|
||||
|
||||
// Load study plugin actions when available
|
||||
useEffect(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPlugins || studyPlugins.length === 0) return;
|
||||
actionRegistry.loadPluginActions(
|
||||
experiment.studyId,
|
||||
studyPlugins.map((sp) => ({
|
||||
plugin: {
|
||||
id: sp.plugin.id,
|
||||
robotId: sp.plugin.robotId,
|
||||
version: sp.plugin.version,
|
||||
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
|
||||
? sp.plugin.actionDefinitions
|
||||
: undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------- Initialize Store Steps ------------------------- */
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
if (loadingExperiment) return;
|
||||
const resolvedInitial =
|
||||
initialDesign ??
|
||||
(experiment ? adaptExistingDesign(experiment) : undefined) ??
|
||||
buildEmptyDesign(experimentId, experiment?.name, experiment?.description);
|
||||
setDesignMeta({
|
||||
name: resolvedInitial.name,
|
||||
description: resolvedInitial.description,
|
||||
version: resolvedInitial.version,
|
||||
});
|
||||
setSteps(resolvedInitial.steps);
|
||||
// Set persisted hash if experiment already has integrityHash
|
||||
if (experiment?.integrityHash) {
|
||||
setPersistedHash(experiment.integrityHash);
|
||||
setValidatedHash(experiment.integrityHash);
|
||||
}
|
||||
setInitialized(true);
|
||||
// Kick off first hash compute
|
||||
void recomputeHash();
|
||||
}, [
|
||||
initialized,
|
||||
loadingExperiment,
|
||||
experiment,
|
||||
initialDesign,
|
||||
experimentId,
|
||||
setSteps,
|
||||
setPersistedHash,
|
||||
setValidatedHash,
|
||||
recomputeHash,
|
||||
]);
|
||||
|
||||
/* ----------------------------- Drift Computation -------------------------- */
|
||||
const driftState = useMemo(() => {
|
||||
if (!lastValidatedHash || !currentDesignHash) {
|
||||
return {
|
||||
status: "unvalidated" as const,
|
||||
drift: false,
|
||||
};
|
||||
}
|
||||
if (currentDesignHash !== lastValidatedHash) {
|
||||
return { status: "drift" as const, drift: true };
|
||||
}
|
||||
return { status: "validated" as const, drift: false };
|
||||
}, [lastValidatedHash, currentDesignHash]);
|
||||
|
||||
/* ------------------------------ Derived Flags ----------------------------- */
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
|
||||
const totalActions = steps.reduce((sum, s) => sum + s.actions.length, 0);
|
||||
|
||||
/* ------------------------------- Validation ------------------------------- */
|
||||
const validateDesign = useCallback(async () => {
|
||||
if (!experimentId) return;
|
||||
setIsValidating(true);
|
||||
try {
|
||||
// Run local validation
|
||||
const validationResult = validateExperimentDesign(steps, {
|
||||
steps,
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
});
|
||||
|
||||
// Compute hash for integrity
|
||||
const hash = await computeDesignHash(steps);
|
||||
setValidatedHash(hash);
|
||||
|
||||
if (validationResult.valid) {
|
||||
toast.success(`Validated • ${hash.slice(0, 10)}… • No issues found`);
|
||||
} else {
|
||||
toast.warning(
|
||||
`Validated with ${validationResult.errorCount} errors, ${validationResult.warningCount} warnings`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [experimentId, steps, setValidatedHash]);
|
||||
|
||||
/* ---------------------------------- Save ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!experimentId) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const visualDesign = {
|
||||
steps,
|
||||
version: designMeta.version,
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
updateExperiment.mutate({
|
||||
id: experimentId,
|
||||
visualDesign,
|
||||
createSteps: true,
|
||||
compileExecution: autoCompile,
|
||||
});
|
||||
// Optimistic hash recompute to reflect state
|
||||
await recomputeHash();
|
||||
onPersist?.({
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
description: designMeta.description,
|
||||
version: designMeta.version,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [
|
||||
experimentId,
|
||||
steps,
|
||||
designMeta,
|
||||
recomputeHash,
|
||||
updateExperiment,
|
||||
onPersist,
|
||||
autoCompile,
|
||||
]);
|
||||
|
||||
/* -------------------------------- Export ---------------------------------- */
|
||||
const handleExport = useCallback(async () => {
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const designHash = currentDesignHash ?? (await computeDesignHash(steps));
|
||||
const bundle = {
|
||||
format: "hristudio.design.v1",
|
||||
exportedAt: new Date().toISOString(),
|
||||
experiment: {
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
version: designMeta.version,
|
||||
integrityHash: designHash,
|
||||
steps,
|
||||
pluginDependencies:
|
||||
experiment?.pluginDependencies?.slice().sort() ?? [],
|
||||
},
|
||||
compiled: null, // Will be implemented when execution graph is available
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${designMeta.name
|
||||
.replace(/[^a-z0-9-_]+/gi, "_")
|
||||
.toLowerCase()}_design.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Exported design bundle");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Export failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [
|
||||
currentDesignHash,
|
||||
steps,
|
||||
experimentId,
|
||||
designMeta,
|
||||
experiment?.pluginDependencies,
|
||||
]);
|
||||
|
||||
/* ---------------------------- Incremental Hashing ------------------------- */
|
||||
// Optionally re-hash after step mutations (basic heuristic)
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
void recomputeHash();
|
||||
}, [steps.length, initialized, recomputeHash]);
|
||||
|
||||
/* ------------------------------- Header Badges ---------------------------- */
|
||||
const hashBadge =
|
||||
driftState.status === "drift" ? (
|
||||
<Badge variant="destructive" title="Design drift detected">
|
||||
Drift
|
||||
</Badge>
|
||||
) : driftState.status === "validated" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-green-700 dark:text-green-400"
|
||||
title="Design validated"
|
||||
>
|
||||
Validated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" title="Not validated">
|
||||
Unvalidated
|
||||
</Badge>
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ----------------------------------- */
|
||||
if (loadingExperiment && !initialized) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Loading experiment design…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description="Design your experiment by composing ordered steps with provenance-aware actions."
|
||||
icon={Play}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{hashBadge}
|
||||
{experiment?.integrityHash && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hash: {experiment.integrityHash.slice(0, 10)}…
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{steps.length} steps
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{totalActions} actions
|
||||
</Badge>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600"
|
||||
>
|
||||
Unsaved
|
||||
</Badge>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={persist}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "Saving…" : "Save"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={validateDesign}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{isValidating ? "Validating…" : "Validate"}
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
variant="outline"
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{isExporting ? "Exporting…" : "Export"}
|
||||
</ActionButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{/* Action Library */}
|
||||
<div className="col-span-3">
|
||||
<Card className="h-[calc(100vh-12rem)]">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm">
|
||||
Action Library
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ActionLibrary />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Step Flow */}
|
||||
<div className="col-span-6">
|
||||
<StepFlow
|
||||
steps={steps}
|
||||
selectedStepId={selectedStepId ?? null}
|
||||
selectedActionId={selectedActionId ?? null}
|
||||
onStepSelect={(id: string) => selectStep(id)}
|
||||
onActionSelect={(id: string) =>
|
||||
selectedStepId && id
|
||||
? selectAction(selectedStepId, id)
|
||||
: undefined
|
||||
}
|
||||
onStepDelete={(stepId: string) => {
|
||||
removeStep(stepId);
|
||||
toast.success("Step deleted");
|
||||
}}
|
||||
onStepUpdate={(
|
||||
stepId: string,
|
||||
updates: Partial<ExperimentStep>,
|
||||
) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
upsertStep({ ...step, ...updates });
|
||||
}}
|
||||
onActionDelete={(stepId: string, actionId: string) => {
|
||||
removeAction(stepId, actionId);
|
||||
toast.success("Action deleted");
|
||||
}}
|
||||
emptyState={
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
Add your first step to begin designing.
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={createNewStep}
|
||||
>
|
||||
+ Step
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="col-span-3">
|
||||
<Tabs defaultValue="properties" className="h-[calc(100vh-12rem)]">
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="properties" className="text-xs">
|
||||
Properties
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="validation" className="text-xs">
|
||||
Issues
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="dependencies" className="text-xs">
|
||||
Dependencies
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<TabsContent value="properties" className="m-0 h-full">
|
||||
<ScrollArea className="h-full p-3">
|
||||
<PropertiesPanel
|
||||
design={{
|
||||
id: experimentId,
|
||||
name: designMeta.name,
|
||||
description: designMeta.description,
|
||||
version: designMeta.version,
|
||||
steps,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
selectedStep={steps.find(
|
||||
(s) => s.id === selectedStepId,
|
||||
)}
|
||||
selectedAction={
|
||||
steps
|
||||
.find(
|
||||
(s: ExperimentStep) => s.id === selectedStepId,
|
||||
)
|
||||
?.actions.find(
|
||||
(a: ExperimentAction) =>
|
||||
a.id === selectedActionId,
|
||||
) ?? undefined
|
||||
}
|
||||
onActionUpdate={(stepId, actionId, updates) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
const action = step.actions.find(
|
||||
(a) => a.id === actionId,
|
||||
);
|
||||
if (!action) return;
|
||||
upsertAction(stepId, { ...action, ...updates });
|
||||
}}
|
||||
onStepUpdate={(stepId, updates) => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
upsertStep({ ...step, ...updates });
|
||||
}}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="validation" className="m-0 h-full">
|
||||
<ValidationPanel
|
||||
issues={validationIssues}
|
||||
onIssueClick={(issue) => {
|
||||
if (issue.stepId) {
|
||||
selectStep(issue.stepId);
|
||||
if (issue.actionId) {
|
||||
selectAction(issue.stepId, issue.actionId);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="dependencies" className="m-0 h-full">
|
||||
<DependencyInspector
|
||||
steps={steps}
|
||||
actionSignatureDrift={actionSignatureDrift}
|
||||
actionDefinitions={actionRegistry.getAllActions()}
|
||||
onReconcileAction={(actionId) => {
|
||||
// TODO: Implement drift reconciliation
|
||||
toast.info(
|
||||
`Reconciliation for action ${actionId} - TODO`,
|
||||
);
|
||||
}}
|
||||
onRefreshDependencies={() => {
|
||||
// TODO: Implement dependency refresh
|
||||
toast.info("Dependency refresh - TODO");
|
||||
}}
|
||||
onInstallPlugin={(pluginId) => {
|
||||
// TODO: Implement plugin installation
|
||||
toast.info(`Install plugin ${pluginId} - TODO`);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DesignerShell;
|
||||
470
src/components/experiments/designer/SaveBar.tsx
Normal file
470
src/components/experiments/designer/SaveBar.tsx
Normal file
@@ -0,0 +1,470 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Save,
|
||||
Download,
|
||||
Upload,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
GitBranch,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type VersionStrategy = "manual" | "auto_minor" | "auto_patch";
|
||||
export type SaveState = "clean" | "dirty" | "saving" | "conflict" | "error";
|
||||
|
||||
export interface SaveBarProps {
|
||||
/**
|
||||
* Current save state
|
||||
*/
|
||||
saveState: SaveState;
|
||||
/**
|
||||
* Whether auto-save is enabled
|
||||
*/
|
||||
autoSaveEnabled: boolean;
|
||||
/**
|
||||
* Current version strategy
|
||||
*/
|
||||
versionStrategy: VersionStrategy;
|
||||
/**
|
||||
* Number of unsaved changes
|
||||
*/
|
||||
dirtyCount: number;
|
||||
/**
|
||||
* Current design hash for integrity
|
||||
*/
|
||||
currentHash?: string;
|
||||
/**
|
||||
* Last persisted hash
|
||||
*/
|
||||
persistedHash?: string;
|
||||
/**
|
||||
* Last save timestamp
|
||||
*/
|
||||
lastSaved?: Date;
|
||||
/**
|
||||
* Whether there's a conflict with server state
|
||||
*/
|
||||
hasConflict?: boolean;
|
||||
/**
|
||||
* Current experiment version
|
||||
*/
|
||||
currentVersion: number;
|
||||
/**
|
||||
* Called when user manually saves
|
||||
*/
|
||||
onSave: () => void;
|
||||
/**
|
||||
* Called when user exports the design
|
||||
*/
|
||||
onExport: () => void;
|
||||
/**
|
||||
* Called when user imports a design
|
||||
*/
|
||||
onImport?: (file: File) => void;
|
||||
/**
|
||||
* Called when auto-save setting changes
|
||||
*/
|
||||
onAutoSaveChange: (enabled: boolean) => void;
|
||||
/**
|
||||
* Called when version strategy changes
|
||||
*/
|
||||
onVersionStrategyChange: (strategy: VersionStrategy) => void;
|
||||
/**
|
||||
* Called when user resolves a conflict
|
||||
*/
|
||||
onResolveConflict?: () => void;
|
||||
/**
|
||||
* Called when user wants to validate the design
|
||||
*/
|
||||
onValidate?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Save State Configuration */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const saveStateConfig = {
|
||||
clean: {
|
||||
icon: CheckCircle,
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
label: "Saved",
|
||||
description: "All changes saved",
|
||||
},
|
||||
dirty: {
|
||||
icon: AlertCircle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
label: "Unsaved",
|
||||
description: "You have unsaved changes",
|
||||
},
|
||||
saving: {
|
||||
icon: RefreshCw,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
label: "Saving",
|
||||
description: "Saving changes...",
|
||||
},
|
||||
conflict: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
label: "Conflict",
|
||||
description: "Server conflict detected",
|
||||
},
|
||||
error: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
label: "Error",
|
||||
description: "Save failed",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Version Strategy Options */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const versionStrategyOptions = [
|
||||
{
|
||||
value: "manual" as const,
|
||||
label: "Manual",
|
||||
description: "Only increment version when explicitly requested",
|
||||
},
|
||||
{
|
||||
value: "auto_minor" as const,
|
||||
label: "Auto Minor",
|
||||
description: "Auto-increment minor version on structural changes",
|
||||
},
|
||||
{
|
||||
value: "auto_patch" as const,
|
||||
label: "Auto Patch",
|
||||
description: "Auto-increment patch version on any save",
|
||||
},
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Functions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function formatLastSaved(date?: Date): string {
|
||||
if (!date) return "Never";
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||
|
||||
if (diffMins < 1) return "Just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
return `${diffDays}d ago`;
|
||||
}
|
||||
|
||||
function getNextVersion(
|
||||
current: number,
|
||||
strategy: VersionStrategy,
|
||||
hasStructuralChanges = false,
|
||||
): number {
|
||||
switch (strategy) {
|
||||
case "manual":
|
||||
return current;
|
||||
case "auto_minor":
|
||||
return hasStructuralChanges ? current + 1 : current;
|
||||
case "auto_patch":
|
||||
return current + 1;
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Import Handler */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function ImportButton({ onImport }: { onImport?: (file: File) => void }) {
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file && onImport) {
|
||||
onImport(file);
|
||||
}
|
||||
// Reset input to allow re-selecting the same file
|
||||
event.target.value = "";
|
||||
};
|
||||
|
||||
if (!onImport) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="import-design"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => document.getElementById("import-design")?.click()}
|
||||
>
|
||||
<Upload className="mr-2 h-3 w-3" />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* SaveBar Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function SaveBar({
|
||||
saveState,
|
||||
autoSaveEnabled,
|
||||
versionStrategy,
|
||||
dirtyCount,
|
||||
currentHash,
|
||||
persistedHash,
|
||||
lastSaved,
|
||||
hasConflict,
|
||||
currentVersion,
|
||||
onSave,
|
||||
onExport,
|
||||
onImport,
|
||||
onAutoSaveChange,
|
||||
onVersionStrategyChange,
|
||||
onResolveConflict,
|
||||
onValidate,
|
||||
className,
|
||||
}: SaveBarProps) {
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const config = saveStateConfig[saveState];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const hasUnsavedChanges = saveState === "dirty" || dirtyCount > 0;
|
||||
const canSave = hasUnsavedChanges && saveState !== "saving";
|
||||
const hashesMatch =
|
||||
currentHash && persistedHash && currentHash === persistedHash;
|
||||
|
||||
return (
|
||||
<Card className={cn("rounded-t-none border-t-0", className)}>
|
||||
<div className="flex items-center justify-between p-3">
|
||||
{/* Left: Save Status & Info */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Save State Indicator */}
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
config.color,
|
||||
saveState === "saving" && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{config.label}</span>
|
||||
{dirtyCount > 0 && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({dirtyCount} changes)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
{/* Version Info */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<GitBranch className="text-muted-foreground h-3 w-3" />
|
||||
<span className="text-muted-foreground">Version</span>
|
||||
<Badge variant="outline" className="h-5 text-xs">
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last Saved */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>{formatLastSaved(lastSaved)}</span>
|
||||
</div>
|
||||
|
||||
{/* Hash Status */}
|
||||
{currentHash && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant={hashesMatch ? "outline" : "secondary"}
|
||||
className="h-5 font-mono text-[10px]"
|
||||
>
|
||||
{currentHash.slice(0, 8)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Conflict Resolution */}
|
||||
{hasConflict && onResolveConflict && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onResolveConflict}
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-3 w-3" />
|
||||
Resolve Conflict
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Validate */}
|
||||
{onValidate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onValidate}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-3 w-3" />
|
||||
Validate
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Import */}
|
||||
<ImportButton onImport={onImport} />
|
||||
|
||||
{/* Export */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onExport}
|
||||
>
|
||||
<Download className="mr-2 h-3 w-3" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
{/* Save */}
|
||||
<Button
|
||||
variant={canSave ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={onSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
<Save className="mr-2 h-3 w-3" />
|
||||
{saveState === "saving" ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
|
||||
{/* Settings Toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel */}
|
||||
{showSettings && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="bg-muted/30 space-y-3 p-3">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Auto-Save Toggle */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Auto-Save</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="auto-save"
|
||||
checked={autoSaveEnabled}
|
||||
onCheckedChange={onAutoSaveChange}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="auto-save"
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
Save automatically when idle
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version Strategy */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Version Strategy</Label>
|
||||
<Select
|
||||
value={versionStrategy}
|
||||
onValueChange={onVersionStrategyChange}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{versionStrategyOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div>
|
||||
<div className="font-medium">{option.label}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{option.description}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Next Version */}
|
||||
{versionStrategy !== "manual" && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
Next save will create version{" "}
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
v
|
||||
{getNextVersion(
|
||||
currentVersion,
|
||||
versionStrategy,
|
||||
hasUnsavedChanges,
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Details */}
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{config.description}
|
||||
{hasUnsavedChanges && autoSaveEnabled && (
|
||||
<span> • Auto-save enabled</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
389
src/components/experiments/designer/ValidationPanel.tsx
Normal file
389
src/components/experiments/designer/ValidationPanel.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { AlertCircle, AlertTriangle, Info, Filter, X } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
category?: "structural" | "parameter" | "semantic" | "execution";
|
||||
field?: string;
|
||||
actionId?: string;
|
||||
stepId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationPanelProps {
|
||||
/**
|
||||
* Map of entity ID to validation issues for that entity.
|
||||
*/
|
||||
issues: Record<string, ValidationIssue[]>;
|
||||
/**
|
||||
* Called when user clicks on an issue to navigate to the problematic entity.
|
||||
*/
|
||||
onIssueClick?: (issue: ValidationIssue) => void;
|
||||
/**
|
||||
* Called to clear a specific issue (if clearable).
|
||||
*/
|
||||
onIssueClear?: (entityId: string, issueIndex: number) => void;
|
||||
/**
|
||||
* Called to clear all issues for an entity.
|
||||
*/
|
||||
onEntityClear?: (entityId: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Severity Configuration */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const severityConfig = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-50 dark:bg-red-950/20",
|
||||
borderColor: "border-red-200 dark:border-red-800",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Error",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-50 dark:bg-amber-950/20",
|
||||
borderColor: "border-amber-200 dark:border-amber-800",
|
||||
badgeVariant: "secondary" as const,
|
||||
label: "Warning",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/20",
|
||||
borderColor: "border-blue-200 dark:border-blue-800",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Info",
|
||||
},
|
||||
} as const;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Functions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
|
||||
const flattened: Array<
|
||||
ValidationIssue & { entityId: string; index: number }
|
||||
> = [];
|
||||
|
||||
Object.entries(issuesMap).forEach(([entityId, issues]) => {
|
||||
issues.forEach((issue, index) => {
|
||||
flattened.push({ ...issue, entityId, index });
|
||||
});
|
||||
});
|
||||
|
||||
return flattened;
|
||||
}
|
||||
|
||||
function getEntityDisplayName(entityId: string): string {
|
||||
if (entityId.startsWith("step-")) {
|
||||
return `Step ${entityId.replace("step-", "")}`;
|
||||
}
|
||||
if (entityId.startsWith("action-")) {
|
||||
return `Action ${entityId.replace("action-", "")}`;
|
||||
}
|
||||
return entityId;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Item Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface IssueItemProps {
|
||||
issue: ValidationIssue & { entityId: string; index: number };
|
||||
onIssueClick?: (issue: ValidationIssue) => void;
|
||||
onIssueClear?: (entityId: string, issueIndex: number) => void;
|
||||
}
|
||||
|
||||
function IssueItem({ issue, onIssueClick, onIssueClear }: IssueItemProps) {
|
||||
const config = severityConfig[issue.severity];
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-start gap-3 rounded-md border p-3 transition-colors",
|
||||
config.borderColor,
|
||||
config.bgColor,
|
||||
onIssueClick && "cursor-pointer hover:shadow-sm",
|
||||
)}
|
||||
onClick={() => onIssueClick?.(issue)}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<IconComponent className={cn("h-4 w-4", config.color)} />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm leading-relaxed">{issue.message}</p>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<Badge variant={config.badgeVariant} className="h-4 text-[10px]">
|
||||
{config.label}
|
||||
</Badge>
|
||||
|
||||
{issue.category && (
|
||||
<Badge variant="outline" className="h-4 text-[10px] capitalize">
|
||||
{issue.category}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{getEntityDisplayName(issue.entityId)}
|
||||
</Badge>
|
||||
|
||||
{issue.field && (
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{issue.field}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onIssueClear && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onIssueClear(issue.entityId, issue.index);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* ValidationPanel Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function ValidationPanel({
|
||||
issues,
|
||||
onIssueClick,
|
||||
onIssueClear,
|
||||
onEntityClear: _onEntityClear,
|
||||
className,
|
||||
}: ValidationPanelProps) {
|
||||
const [severityFilter, setSeverityFilter] = useState<
|
||||
"all" | "error" | "warning" | "info"
|
||||
>("all");
|
||||
const [categoryFilter, setCategoryFilter] = useState<
|
||||
"all" | "structural" | "parameter" | "semantic" | "execution"
|
||||
>("all");
|
||||
|
||||
// Flatten and filter issues
|
||||
const flatIssues = useMemo(() => {
|
||||
const flat = flattenIssues(issues);
|
||||
|
||||
return flat.filter((issue) => {
|
||||
if (severityFilter !== "all" && issue.severity !== severityFilter) {
|
||||
return false;
|
||||
}
|
||||
if (categoryFilter !== "all" && issue.category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [issues, severityFilter, categoryFilter]);
|
||||
|
||||
// Count by severity
|
||||
const counts = useMemo(() => {
|
||||
const flat = flattenIssues(issues);
|
||||
return {
|
||||
total: flat.length,
|
||||
error: flat.filter((i) => i.severity === "error").length,
|
||||
warning: flat.filter((i) => i.severity === "warning").length,
|
||||
info: flat.filter((i) => i.severity === "info").length,
|
||||
};
|
||||
}, [issues]);
|
||||
|
||||
// Available categories
|
||||
const availableCategories = useMemo(() => {
|
||||
const flat = flattenIssues(issues);
|
||||
const categories = new Set(flat.map((i) => i.category).filter(Boolean));
|
||||
return Array.from(categories) as Array<
|
||||
"structural" | "parameter" | "semantic" | "execution"
|
||||
>;
|
||||
}, [issues]);
|
||||
|
||||
return (
|
||||
<Card className={cn("h-[calc(100vh-12rem)]", className)}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Validation Issues
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{counts.error > 0 && (
|
||||
<Badge variant="destructive" className="h-4 text-[10px]">
|
||||
{counts.error}
|
||||
</Badge>
|
||||
)}
|
||||
{counts.warning > 0 && (
|
||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
||||
{counts.warning}
|
||||
</Badge>
|
||||
)}
|
||||
{counts.info > 0 && (
|
||||
<Badge variant="outline" className="h-4 text-[10px]">
|
||||
{counts.info}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0">
|
||||
{/* Filters */}
|
||||
{counts.total > 0 && (
|
||||
<>
|
||||
<div className="border-b p-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Severity Filter */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="text-muted-foreground h-3 w-3" />
|
||||
<Button
|
||||
variant={severityFilter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSeverityFilter("all")}
|
||||
>
|
||||
All ({counts.total})
|
||||
</Button>
|
||||
{counts.error > 0 && (
|
||||
<Button
|
||||
variant={
|
||||
severityFilter === "error" ? "destructive" : "ghost"
|
||||
}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSeverityFilter("error")}
|
||||
>
|
||||
Errors ({counts.error})
|
||||
</Button>
|
||||
)}
|
||||
{counts.warning > 0 && (
|
||||
<Button
|
||||
variant={
|
||||
severityFilter === "warning" ? "secondary" : "ghost"
|
||||
}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSeverityFilter("warning")}
|
||||
>
|
||||
Warnings ({counts.warning})
|
||||
</Button>
|
||||
)}
|
||||
{counts.info > 0 && (
|
||||
<Button
|
||||
variant={severityFilter === "info" ? "outline" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSeverityFilter("info")}
|
||||
>
|
||||
Info ({counts.info})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
{availableCategories.length > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={categoryFilter === "all" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setCategoryFilter("all")}
|
||||
>
|
||||
All Categories
|
||||
</Button>
|
||||
{availableCategories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={
|
||||
categoryFilter === category ? "outline" : "ghost"
|
||||
}
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs capitalize"
|
||||
onClick={() => setCategoryFilter(category)}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Issues List */}
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
{counts.total === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-green-100 dark:bg-green-950/20">
|
||||
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-green-700 dark:text-green-300">
|
||||
No validation issues
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Your experiment design looks good!
|
||||
</p>
|
||||
</div>
|
||||
) : flatIssues.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
|
||||
<Filter className="h-4 w-4" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">No issues match filters</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Try adjusting your filter criteria
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{flatIssues.map((issue) => (
|
||||
<IssueItem
|
||||
key={`${issue.entityId}-${issue.index}`}
|
||||
issue={issue}
|
||||
onIssueClick={onIssueClick}
|
||||
onIssueClear={onIssueClear}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
391
src/components/experiments/designer/state/hashing.ts
Normal file
391
src/components/experiments/designer/state/hashing.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
/**
|
||||
* Hashing utilities for the Experiment Designer.
|
||||
*
|
||||
* Implements deterministic, canonical, incremental hashing per the redesign spec:
|
||||
* - Stable structural hashing for steps and actions
|
||||
* - Optional inclusion of parameter VALUES vs only parameter KEYS
|
||||
* - Incremental hash computation to avoid recomputing entire design on small changes
|
||||
* - Action signature hashing (schema/provenance sensitive) for drift detection
|
||||
*
|
||||
* Default behavior excludes parameter values from the design hash to reduce false-positive drift
|
||||
* caused by content edits (reproducibility concerns focus on structure + provenance).
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentAction,
|
||||
ExperimentStep,
|
||||
ExecutionDescriptor,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Canonicalization */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
type CanonicalPrimitive = string | number | boolean | null;
|
||||
type CanonicalValue =
|
||||
| CanonicalPrimitive
|
||||
| CanonicalValue[]
|
||||
| { [key: string]: CanonicalValue };
|
||||
|
||||
/**
|
||||
* Recursively canonicalize an unknown value:
|
||||
* - Removes undefined properties
|
||||
* - Sorts object keys
|
||||
* - Leaves arrays in existing (semantic) order
|
||||
*/
|
||||
function canonicalize(value: unknown): CanonicalValue {
|
||||
if (
|
||||
value === null ||
|
||||
typeof value === "string" ||
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => canonicalize(v));
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const out: Record<string, CanonicalValue> = {};
|
||||
Object.keys(obj)
|
||||
.filter((k) => obj[k] !== undefined)
|
||||
.sort()
|
||||
.forEach((k) => {
|
||||
out[k] = canonicalize(obj[k]);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
// Unsupported types (symbol, function, bigint) replaced with null
|
||||
return null;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Hashing Primitives */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convert an ArrayBuffer to a lowercase hex string.
|
||||
*/
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let hex = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
const b = bytes[i]?.toString(16).padStart(2, "0");
|
||||
hex += b;
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a UTF-8 string using Web Crypto if available, else Node's crypto.
|
||||
*/
|
||||
async function hashString(input: string): Promise<string> {
|
||||
// Prefer Web Crypto subtle (Edge/Browser compatible)
|
||||
if (typeof globalThis.crypto?.subtle?.digest === "function") {
|
||||
const enc = new TextEncoder().encode(input);
|
||||
const digest = await globalThis.crypto.subtle.digest("SHA-256", enc);
|
||||
return bufferToHex(digest);
|
||||
}
|
||||
|
||||
// Fallback to Node (should not execute in Edge runtime)
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const nodeCrypto: typeof import("crypto") = require("crypto");
|
||||
return nodeCrypto.createHash("sha256").update(input).digest("hex");
|
||||
} catch {
|
||||
throw new Error("No suitable crypto implementation available for hashing.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash an object using canonical JSON serialization (no whitespace, sorted keys).
|
||||
*/
|
||||
export async function hashObject(obj: unknown): Promise<string> {
|
||||
const canonical = canonicalize(obj);
|
||||
return hashString(JSON.stringify(canonical));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Structural Projections */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface DesignHashOptions {
|
||||
/**
|
||||
* Include parameter VALUES in hash rather than only parameter KEY sets.
|
||||
* Defaults to false (only parameter keys) to focus on structural reproducibility.
|
||||
*/
|
||||
includeParameterValues?: boolean;
|
||||
/**
|
||||
* Include action descriptive user-facing metadata (e.g. action.name) in hash.
|
||||
* Defaults to true - set false if wanting purely behavioral signature.
|
||||
*/
|
||||
includeActionNames?: boolean;
|
||||
/**
|
||||
* Include step descriptive fields (step.name, step.description).
|
||||
* Defaults to true.
|
||||
*/
|
||||
includeStepNames?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
|
||||
includeParameterValues: false,
|
||||
includeActionNames: true,
|
||||
includeStepNames: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Projection of an action for design hash purposes.
|
||||
*/
|
||||
function projectActionForDesign(
|
||||
action: ExperimentAction,
|
||||
options: Required<DesignHashOptions>,
|
||||
): Record<string, unknown> {
|
||||
const parameterProjection = options.includeParameterValues
|
||||
? canonicalize(action.parameters)
|
||||
: Object.keys(action.parameters).sort();
|
||||
|
||||
const base: Record<string, unknown> = {
|
||||
id: action.id,
|
||||
type: action.type,
|
||||
source: {
|
||||
kind: action.source.kind,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: projectExecutionDescriptor(action.execution),
|
||||
parameterKeysOrValues: parameterProjection,
|
||||
};
|
||||
|
||||
if (options.includeActionNames) {
|
||||
base.name = action.name;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
function projectExecutionDescriptor(
|
||||
exec: ExecutionDescriptor,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
transport: exec.transport,
|
||||
retryable: exec.retryable ?? false,
|
||||
timeoutMs: exec.timeoutMs ?? null,
|
||||
ros2: exec.ros2
|
||||
? {
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
: null,
|
||||
rest: exec.rest
|
||||
? {
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Projection of a step for design hash purposes.
|
||||
*/
|
||||
function projectStepForDesign(
|
||||
step: ExperimentStep,
|
||||
options: Required<DesignHashOptions>,
|
||||
): Record<string, unknown> {
|
||||
const base: Record<string, unknown> = {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
// Only the sorted keys of conditions (structural presence)
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
},
|
||||
actions: step.actions.map((a) => projectActionForDesign(a, options)),
|
||||
};
|
||||
|
||||
if (options.includeStepNames) {
|
||||
base.name = step.name;
|
||||
}
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Action Signature Hash (Schema / Provenance Drift) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ActionSignatureInput {
|
||||
type: string;
|
||||
category: string;
|
||||
parameterSchemaRaw?: unknown;
|
||||
execution?: ExecutionDescriptor;
|
||||
baseActionId?: string;
|
||||
pluginVersion?: string;
|
||||
pluginId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash that uniquely identifies the structural/schema definition of an action definition.
|
||||
* Used for plugin drift detection: if signature changes, existing action instances require inspection.
|
||||
*/
|
||||
export async function computeActionSignature(
|
||||
def: ActionSignatureInput,
|
||||
): Promise<string> {
|
||||
const projection = {
|
||||
type: def.type,
|
||||
category: def.category,
|
||||
pluginId: def.pluginId ?? null,
|
||||
pluginVersion: def.pluginVersion ?? null,
|
||||
baseActionId: def.baseActionId ?? null,
|
||||
execution: def.execution
|
||||
? {
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
||||
};
|
||||
return hashObject(projection);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Design Hash */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute a deterministic hash for the entire design (steps + actions) under given options.
|
||||
*/
|
||||
export async function computeDesignHash(
|
||||
steps: ExperimentStep[],
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<string> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const projected = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => projectStepForDesign(s, options));
|
||||
return hashObject({ steps: projected });
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Incremental Hashing */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface IncrementalHashMaps {
|
||||
actionHashes: Map<string, string>;
|
||||
stepHashes: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface IncrementalHashResult extends IncrementalHashMaps {
|
||||
designHash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute or reuse action/step hashes to avoid re-hashing unchanged branches.
|
||||
*/
|
||||
export async function computeIncrementalDesignHash(
|
||||
steps: ExperimentStep[],
|
||||
previous?: IncrementalHashMaps,
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<IncrementalHashResult> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const actionHashes = new Map<string, string>();
|
||||
const stepHashes = new Map<string, string>();
|
||||
|
||||
// First compute per-action hashes
|
||||
for (const step of steps) {
|
||||
for (const action of step.actions) {
|
||||
const existing = previous?.actionHashes.get(action.id);
|
||||
if (existing) {
|
||||
// Simple heuristic: if shallow structural keys unchanged, reuse
|
||||
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
|
||||
actionHashes.set(action.id, existing);
|
||||
continue;
|
||||
}
|
||||
const projectedAction = projectActionForDesign(action, options);
|
||||
const h = await hashObject(projectedAction);
|
||||
actionHashes.set(action.id, h);
|
||||
}
|
||||
}
|
||||
|
||||
// Then compute step hashes (including ordered list of action hashes)
|
||||
for (const step of steps) {
|
||||
const existing = previous?.stepHashes.get(step.id);
|
||||
if (existing) {
|
||||
stepHashes.set(step.id, existing);
|
||||
continue;
|
||||
}
|
||||
const projectedStep = {
|
||||
id: step.id,
|
||||
type: step.type,
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
},
|
||||
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
|
||||
...(options.includeStepNames ? { name: step.name } : {}),
|
||||
};
|
||||
const h = await hashObject(projectedStep);
|
||||
stepHashes.set(step.id, h);
|
||||
}
|
||||
|
||||
// Aggregate design hash from ordered step hashes + minimal meta
|
||||
const orderedStepHashes = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => stepHashes.get(s.id));
|
||||
|
||||
const designHash = await hashObject({
|
||||
steps: orderedStepHashes,
|
||||
count: steps.length,
|
||||
});
|
||||
|
||||
return { designHash, actionHashes, stepHashes };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Convenience helper to check if design hash matches a known validated hash.
|
||||
*/
|
||||
export function isDesignHashValidated(
|
||||
currentHash: string | undefined | null,
|
||||
validatedHash: string | undefined | null,
|
||||
): boolean {
|
||||
return Boolean(currentHash && validatedHash && currentHash === validatedHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine structural drift given last validated snapshot hash and current.
|
||||
*/
|
||||
export function hasStructuralDrift(
|
||||
currentHash: string | undefined | null,
|
||||
validatedHash: string | undefined | null,
|
||||
): boolean {
|
||||
if (!validatedHash) return false;
|
||||
if (!currentHash) return false;
|
||||
return currentHash !== validatedHash;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exports */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const Hashing = {
|
||||
canonicalize,
|
||||
hashObject,
|
||||
computeDesignHash,
|
||||
computeIncrementalDesignHash,
|
||||
computeActionSignature,
|
||||
isDesignHashValidated,
|
||||
hasStructuralDrift,
|
||||
};
|
||||
export default Hashing;
|
||||
519
src/components/experiments/designer/state/store.ts
Normal file
519
src/components/experiments/designer/state/store.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
"use client";
|
||||
/**
|
||||
* Experiment Designer Zustand Store
|
||||
*
|
||||
* Centralized state management for the redesigned experiment designer.
|
||||
* Responsibilities:
|
||||
* - Steps & actions structural state
|
||||
* - Selection state (step / action)
|
||||
* - Dirty tracking
|
||||
* - Hashing & drift (incremental design hash computation)
|
||||
* - Validation issue storage
|
||||
* - Plugin action signature drift detection
|
||||
* - Save / conflict / versioning control flags
|
||||
*
|
||||
* This store intentionally avoids direct network calls; consumers orchestrate
|
||||
* server mutations & pass results back into the store (pure state container).
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import {
|
||||
computeIncrementalDesignHash,
|
||||
type IncrementalHashMaps,
|
||||
type IncrementalHashResult,
|
||||
computeActionSignature,
|
||||
} from "./hashing";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ValidationIssue {
|
||||
entityId: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export type VersionStrategy = "auto" | "forceIncrement" | "none";
|
||||
|
||||
export interface ConflictState {
|
||||
serverHash: string;
|
||||
localHash: string;
|
||||
at: Date;
|
||||
}
|
||||
|
||||
export interface DesignerState {
|
||||
// Core structural
|
||||
steps: ExperimentStep[];
|
||||
|
||||
// Selection
|
||||
selectedStepId?: string;
|
||||
selectedActionId?: string;
|
||||
|
||||
// Dirty tracking (entity IDs)
|
||||
dirtyEntities: Set<string>;
|
||||
|
||||
// Hashing
|
||||
lastPersistedHash?: string;
|
||||
currentDesignHash?: string;
|
||||
lastValidatedHash?: string;
|
||||
incremental?: IncrementalHashMaps;
|
||||
|
||||
// Validation & drift
|
||||
validationIssues: Record<string, ValidationIssue[]>;
|
||||
actionSignatureIndex: Map<string, string>; // actionType or instance -> signature hash
|
||||
actionSignatureDrift: Set<string>; // action instance IDs with drift
|
||||
|
||||
// Saving & conflicts
|
||||
pendingSave: boolean;
|
||||
conflict?: ConflictState;
|
||||
versionStrategy: VersionStrategy;
|
||||
autoSaveEnabled: boolean;
|
||||
|
||||
// Flags
|
||||
busyHashing: boolean;
|
||||
busyValidating: boolean;
|
||||
|
||||
/* ------------------------------ Mutators --------------------------------- */
|
||||
|
||||
// Selection
|
||||
selectStep: (id?: string) => void;
|
||||
selectAction: (stepId: string, actionId?: string) => void;
|
||||
|
||||
// Steps
|
||||
setSteps: (steps: ExperimentStep[]) => void;
|
||||
upsertStep: (step: ExperimentStep) => void;
|
||||
removeStep: (stepId: string) => void;
|
||||
reorderStep: (from: number, to: number) => void;
|
||||
|
||||
// Actions
|
||||
upsertAction: (stepId: string, action: ExperimentAction) => void;
|
||||
removeAction: (stepId: string, actionId: string) => void;
|
||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||
|
||||
// Dirty
|
||||
markDirty: (id: string) => void;
|
||||
clearDirty: (id: string) => void;
|
||||
clearAllDirty: () => void;
|
||||
|
||||
// Hashing
|
||||
recomputeHash: (options?: {
|
||||
forceFull?: boolean;
|
||||
}) => Promise<IncrementalHashResult | null>;
|
||||
setPersistedHash: (hash: string) => void;
|
||||
setValidatedHash: (hash: string) => void;
|
||||
|
||||
// Validation
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) => void;
|
||||
clearValidationIssues: (entityId: string) => void;
|
||||
clearAllValidationIssues: () => void;
|
||||
|
||||
// Drift detection (action definition signature)
|
||||
setActionSignature: (actionId: string, signature: string) => void;
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) => void;
|
||||
clearActionSignatureDrift: (actionId: string) => void;
|
||||
|
||||
// Save workflow
|
||||
setPendingSave: (pending: boolean) => void;
|
||||
recordConflict: (serverHash: string, localHash: string) => void;
|
||||
clearConflict: () => void;
|
||||
setVersionStrategy: (strategy: VersionStrategy) => void;
|
||||
setAutoSaveEnabled: (enabled: boolean) => void;
|
||||
|
||||
// Bulk apply from server (authoritative sync after save/fetch)
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps.map((s) => ({
|
||||
...s,
|
||||
actions: s.actions.map((a) => ({ ...a })),
|
||||
}));
|
||||
}
|
||||
|
||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, idx) => ({ ...s, order: idx }));
|
||||
}
|
||||
|
||||
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||
// ExperimentAction type does not define orderIndex; preserve array order only
|
||||
return actions.map((a) => ({ ...a }));
|
||||
}
|
||||
|
||||
function updateActionList(
|
||||
existing: ExperimentAction[],
|
||||
action: ExperimentAction,
|
||||
): ExperimentAction[] {
|
||||
const idx = existing.findIndex((a) => a.id === action.id);
|
||||
if (idx >= 0) {
|
||||
const copy = [...existing];
|
||||
copy[idx] = { ...action };
|
||||
return copy;
|
||||
}
|
||||
return [...existing, { ...action }];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Store Implementation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
steps: [],
|
||||
dirtyEntities: new Set<string>(),
|
||||
validationIssues: {},
|
||||
actionSignatureIndex: new Map(),
|
||||
actionSignatureDrift: new Set(),
|
||||
pendingSave: false,
|
||||
versionStrategy: "auto_minor" as VersionStrategy,
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
set({
|
||||
selectedStepId: id,
|
||||
selectedActionId: id ? get().selectedActionId : undefined,
|
||||
}),
|
||||
selectAction: (stepId, actionId) =>
|
||||
set({
|
||||
selectedStepId: stepId,
|
||||
selectedActionId: actionId,
|
||||
}),
|
||||
|
||||
/* -------------------------------- Steps ---------------------------------- */
|
||||
setSteps: (steps) =>
|
||||
set(() => ({
|
||||
steps: reindexSteps(cloneSteps(steps)),
|
||||
dirtyEntities: new Set<string>(), // assume authoritative load
|
||||
})),
|
||||
|
||||
upsertStep: (step) =>
|
||||
set((state) => {
|
||||
const idx = state.steps.findIndex((s) => s.id === step.id);
|
||||
let steps: ExperimentStep[];
|
||||
if (idx >= 0) {
|
||||
steps = [...state.steps];
|
||||
steps[idx] = { ...step };
|
||||
} else {
|
||||
steps = [...state.steps, { ...step, order: state.steps.length }];
|
||||
}
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
|
||||
};
|
||||
}),
|
||||
|
||||
removeStep: (stepId) =>
|
||||
set((state) => {
|
||||
const steps = state.steps.filter((s) => s.id !== stepId);
|
||||
const dirty = new Set(state.dirtyEntities);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: dirty,
|
||||
selectedStepId:
|
||||
state.selectedStepId === stepId ? undefined : state.selectedStepId,
|
||||
selectedActionId: undefined,
|
||||
};
|
||||
}),
|
||||
|
||||
reorderStep: (from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= state.steps.length ||
|
||||
to >= state.steps.length ||
|
||||
from === to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const stepsDraft = [...state.steps];
|
||||
const [moved] = stepsDraft.splice(from, 1);
|
||||
if (!moved) return state;
|
||||
stepsDraft.splice(to, 0, moved);
|
||||
const reindexed = reindexSteps(stepsDraft);
|
||||
return {
|
||||
steps: reindexed,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
...reindexed.map((s) => s.id),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (stepId: string, action: ExperimentAction) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: reindexActions(updateActionList(s.actions, action)),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
action.id,
|
||||
stepId,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
removeAction: (stepId: string, actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: reindexActions(
|
||||
s.actions.filter((a) => a.id !== actionId),
|
||||
),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
dirty.add(actionId);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: dirty,
|
||||
selectedActionId:
|
||||
state.selectedActionId === actionId
|
||||
? undefined
|
||||
: state.selectedActionId,
|
||||
};
|
||||
}),
|
||||
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= s.actions.length ||
|
||||
to >= s.actions.length ||
|
||||
from === to
|
||||
) {
|
||||
return s;
|
||||
}
|
||||
const actionsDraft = [...s.actions];
|
||||
const [moved] = actionsDraft.splice(from, 1);
|
||||
if (!moved) return s;
|
||||
actionsDraft.splice(to, 0, moved);
|
||||
return { ...s, actions: reindexActions(actionsDraft) };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]),
|
||||
};
|
||||
}),
|
||||
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
dirtyEntities: state.dirtyEntities.has(id)
|
||||
? state.dirtyEntities
|
||||
: new Set<string>([...state.dirtyEntities, id]),
|
||||
})),
|
||||
clearDirty: (id: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.dirtyEntities.has(id)) return state;
|
||||
const next = new Set(state.dirtyEntities);
|
||||
next.delete(id);
|
||||
return { dirtyEntities: next };
|
||||
}),
|
||||
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
|
||||
|
||||
/* ------------------------------- Hashing --------------------------------- */
|
||||
recomputeHash: async (options?: { forceFull?: boolean }) => {
|
||||
const { steps, incremental } = get();
|
||||
if (steps.length === 0) {
|
||||
set({ currentDesignHash: undefined });
|
||||
return null;
|
||||
}
|
||||
set({ busyHashing: true });
|
||||
try {
|
||||
const result = await computeIncrementalDesignHash(
|
||||
steps,
|
||||
options?.forceFull ? undefined : incremental,
|
||||
);
|
||||
set({
|
||||
currentDesignHash: result.designHash,
|
||||
incremental: {
|
||||
actionHashes: result.actionHashes,
|
||||
stepHashes: result.stepHashes,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
set({ busyHashing: false });
|
||||
}
|
||||
},
|
||||
|
||||
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
|
||||
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
|
||||
|
||||
/* ----------------------------- Validation -------------------------------- */
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
|
||||
set((state: DesignerState) => ({
|
||||
validationIssues: {
|
||||
...state.validationIssues,
|
||||
[entityId]: issues,
|
||||
},
|
||||
})),
|
||||
clearValidationIssues: (entityId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.validationIssues[entityId]) return state;
|
||||
const next = { ...state.validationIssues };
|
||||
delete next[entityId];
|
||||
return { validationIssues: next };
|
||||
}),
|
||||
clearAllValidationIssues: () => set({ validationIssues: {} }),
|
||||
|
||||
/* ------------------------- Action Signature Drift ------------------------ */
|
||||
setActionSignature: (actionId: string, signature: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const index = new Map(state.actionSignatureIndex);
|
||||
index.set(actionId, signature);
|
||||
return { actionSignatureIndex: index };
|
||||
}),
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const current = state.actionSignatureIndex.get(action.id);
|
||||
if (!current) {
|
||||
const idx = new Map(state.actionSignatureIndex);
|
||||
idx.set(action.id, latestSignature);
|
||||
return { actionSignatureIndex: idx };
|
||||
}
|
||||
if (current === latestSignature) return {};
|
||||
const drift = new Set(state.actionSignatureDrift);
|
||||
drift.add(action.id);
|
||||
return { actionSignatureDrift: drift };
|
||||
}),
|
||||
clearActionSignatureDrift: (actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.actionSignatureDrift.has(actionId)) return state;
|
||||
const next = new Set(state.actionSignatureDrift);
|
||||
next.delete(actionId);
|
||||
return { actionSignatureDrift: next };
|
||||
}),
|
||||
|
||||
/* ------------------------------- Save Flow -------------------------------- */
|
||||
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
|
||||
recordConflict: (serverHash: string, localHash: string) =>
|
||||
set({
|
||||
conflict: { serverHash, localHash, at: new Date() },
|
||||
pendingSave: false,
|
||||
}),
|
||||
clearConflict: () => set({ conflict: undefined }),
|
||||
setVersionStrategy: (strategy: VersionStrategy) =>
|
||||
set({ versionStrategy: strategy }),
|
||||
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
|
||||
|
||||
/* ------------------------------ Server Sync ------------------------------ */
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) =>
|
||||
set((state: DesignerState) => {
|
||||
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
|
||||
const dirty = new Set<string>();
|
||||
return {
|
||||
steps: syncedSteps,
|
||||
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
|
||||
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
|
||||
dirtyEntities: dirty,
|
||||
conflict: undefined,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Convenience Selectors */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const useDesignerSteps = (): ExperimentStep[] =>
|
||||
useDesignerStore((s) => s.steps);
|
||||
|
||||
export const useDesignerSelection = (): {
|
||||
selectedStepId: string | undefined;
|
||||
selectedActionId: string | undefined;
|
||||
} =>
|
||||
useDesignerStore((s) => ({
|
||||
selectedStepId: s.selectedStepId,
|
||||
selectedActionId: s.selectedActionId,
|
||||
}));
|
||||
|
||||
export const useDesignerHashes = (): {
|
||||
currentDesignHash: string | undefined;
|
||||
lastPersistedHash: string | undefined;
|
||||
lastValidatedHash: string | undefined;
|
||||
} =>
|
||||
useDesignerStore((s) => ({
|
||||
currentDesignHash: s.currentDesignHash,
|
||||
lastPersistedHash: s.lastPersistedHash,
|
||||
lastValidatedHash: s.lastValidatedHash,
|
||||
}));
|
||||
|
||||
export const useDesignerDrift = (): {
|
||||
hasDrift: boolean;
|
||||
actionSignatureDrift: Set<string>;
|
||||
} =>
|
||||
useDesignerStore((s) => ({
|
||||
hasDrift:
|
||||
!!s.lastValidatedHash &&
|
||||
!!s.currentDesignHash &&
|
||||
s.currentDesignHash !== s.lastValidatedHash,
|
||||
actionSignatureDrift: s.actionSignatureDrift,
|
||||
}));
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Signature Helper (on-demand) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Compute a signature for an action definition or instance (schema + provenance).
|
||||
* Store modules can call this to register baseline signatures.
|
||||
*/
|
||||
export async function computeBaselineActionSignature(
|
||||
action: ExperimentAction,
|
||||
): Promise<string> {
|
||||
return computeActionSignature({
|
||||
type: action.type,
|
||||
category: action.category,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
execution: action.execution,
|
||||
baseActionId: action.source.baseActionId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
pluginId: action.source.pluginId,
|
||||
});
|
||||
}
|
||||
762
src/components/experiments/designer/state/validators.ts
Normal file
762
src/components/experiments/designer/state/validators.ts
Normal file
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Validation utilities for the Experiment Designer.
|
||||
*
|
||||
* Implements comprehensive validation rules per the redesign spec:
|
||||
* - Structural validation (step names, types, trigger configurations)
|
||||
* - Parameter validation (required fields, type checking, bounds)
|
||||
* - Semantic validation (uniqueness, dependencies, best practices)
|
||||
* - Cross-step validation (workflow integrity, execution feasibility)
|
||||
*
|
||||
* Each validator returns an array of ValidationIssue objects with severity levels.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ActionDefinition,
|
||||
TriggerType,
|
||||
StepType,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: "error" | "warning" | "info";
|
||||
message: string;
|
||||
category: "structural" | "parameter" | "semantic" | "execution";
|
||||
field?: string;
|
||||
suggestion?: string;
|
||||
actionId?: string;
|
||||
stepId?: string;
|
||||
}
|
||||
|
||||
export interface ValidationContext {
|
||||
steps: ExperimentStep[];
|
||||
actionDefinitions: ActionDefinition[];
|
||||
allowPartialValidation?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue[];
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
infoCount: number;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Validation Rule Sets */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const VALID_STEP_TYPES: StepType[] = [
|
||||
"sequential",
|
||||
"parallel",
|
||||
"conditional",
|
||||
"loop",
|
||||
];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
"timer",
|
||||
"previous_step",
|
||||
];
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Structural Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateStructural(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Global structural checks
|
||||
if (steps.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Experiment must contain at least one step",
|
||||
category: "structural",
|
||||
suggestion: "Add a step to begin designing your experiment",
|
||||
});
|
||||
return issues; // Early return for empty experiment
|
||||
}
|
||||
|
||||
// Step-level validation
|
||||
steps.forEach((step, stepIndex) => {
|
||||
const stepId = step.id;
|
||||
|
||||
// Step name validation
|
||||
if (!step.name?.trim()) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Step name cannot be empty",
|
||||
category: "structural",
|
||||
field: "name",
|
||||
stepId,
|
||||
suggestion: "Provide a descriptive name for this step",
|
||||
});
|
||||
} else if (step.name.length > 100) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: "Step name is very long and may be truncated in displays",
|
||||
category: "structural",
|
||||
field: "name",
|
||||
stepId,
|
||||
suggestion: "Consider shortening the step name",
|
||||
});
|
||||
}
|
||||
|
||||
// Step type validation
|
||||
if (!VALID_STEP_TYPES.includes(step.type)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Invalid step type: ${step.type}`,
|
||||
category: "structural",
|
||||
field: "type",
|
||||
stepId,
|
||||
suggestion: `Valid types are: ${VALID_STEP_TYPES.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Step order validation
|
||||
if (step.order !== stepIndex) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Step order mismatch: expected ${stepIndex}, got ${step.order}`,
|
||||
category: "structural",
|
||||
field: "order",
|
||||
stepId,
|
||||
suggestion: "Step order must be sequential starting from 0",
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger validation
|
||||
if (!VALID_TRIGGER_TYPES.includes(step.trigger.type)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Invalid trigger type: ${step.trigger.type}`,
|
||||
category: "structural",
|
||||
field: "trigger.type",
|
||||
stepId,
|
||||
suggestion: `Valid trigger types are: ${VALID_TRIGGER_TYPES.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Conditional step must have conditions
|
||||
if (step.type === "conditional") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Conditional step must define at least one condition",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to define when this step should execute",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Loop step should have termination conditions
|
||||
if (step.type === "loop") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Loop step should define termination conditions to prevent infinite loops",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to control when the loop should exit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel step should have multiple actions
|
||||
if (step.type === "parallel" && step.actions.length < 2) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Parallel step has fewer than 2 actions - consider using sequential type",
|
||||
category: "structural",
|
||||
stepId,
|
||||
suggestion: "Add more actions or change to sequential execution",
|
||||
});
|
||||
}
|
||||
|
||||
// Action-level structural validation
|
||||
step.actions.forEach((action, actionIndex) => {
|
||||
const actionId = action.id;
|
||||
|
||||
// Action name validation
|
||||
if (!action.name?.trim()) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action name cannot be empty",
|
||||
category: "structural",
|
||||
field: "name",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Provide a descriptive name for this action",
|
||||
});
|
||||
}
|
||||
|
||||
// Action type validation
|
||||
if (!action.type?.trim()) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action type cannot be empty",
|
||||
category: "structural",
|
||||
field: "type",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Select a valid action type from the library",
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Action order validation removed as orderIndex is not in the type definition
|
||||
// Actions are ordered by their position in the array
|
||||
|
||||
// Source validation
|
||||
if (!action.source?.kind) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action source kind is required",
|
||||
category: "structural",
|
||||
field: "source.kind",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Action must specify if it's from core or plugin source",
|
||||
});
|
||||
}
|
||||
|
||||
// Plugin actions need plugin metadata
|
||||
if (action.source?.kind === "plugin") {
|
||||
if (!action.source.pluginId) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Plugin action must specify pluginId",
|
||||
category: "structural",
|
||||
field: "source.pluginId",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Plugin actions require valid plugin identification",
|
||||
});
|
||||
}
|
||||
if (!action.source.pluginVersion) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: "Plugin action should specify version for reproducibility",
|
||||
category: "structural",
|
||||
field: "source.pluginVersion",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Pin plugin version to ensure consistent behavior",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Execution descriptor validation
|
||||
if (!action.execution?.transport) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Action must specify execution transport",
|
||||
category: "structural",
|
||||
field: "execution.transport",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion:
|
||||
"Define how this action should be executed (rest, ros2, etc.)",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Parameter Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateParameters(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
const { actionDefinitions } = context;
|
||||
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
const stepId = step.id;
|
||||
const actionId = action.id;
|
||||
|
||||
// Find action definition
|
||||
const definition = actionDefinitions.find(
|
||||
(def) => def.type === action.type,
|
||||
);
|
||||
|
||||
if (!definition) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Action definition not found for type: ${action.type}`,
|
||||
category: "parameter",
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Check if the required plugin is installed and loaded",
|
||||
});
|
||||
return; // Skip parameter validation for missing definitions
|
||||
}
|
||||
|
||||
// Validate each parameter
|
||||
definition.parameters.forEach((paramDef) => {
|
||||
const paramId = paramDef.id;
|
||||
const value = action.parameters[paramId];
|
||||
const field = `parameters.${paramId}`;
|
||||
|
||||
// Required parameter check
|
||||
if (paramDef.required) {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === "string" && value.trim() === "");
|
||||
|
||||
if (isEmpty) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Required parameter '${paramDef.name}' is missing`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Provide a value for this required parameter",
|
||||
});
|
||||
return; // Skip type validation for missing required params
|
||||
}
|
||||
}
|
||||
|
||||
// Skip validation for optional empty parameters
|
||||
if (value === undefined || value === null) return;
|
||||
|
||||
// Type validation
|
||||
switch (paramDef.type) {
|
||||
case "text":
|
||||
if (typeof value !== "string") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be text`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a text value",
|
||||
});
|
||||
// Note: maxLength validation removed as it's not in the ActionParameter type
|
||||
}
|
||||
break;
|
||||
|
||||
case "number":
|
||||
if (typeof value !== "number" || isNaN(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a valid number`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a numeric value",
|
||||
});
|
||||
} else {
|
||||
// Range validation
|
||||
if (paramDef.min !== undefined && value < paramDef.min) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be at least ${paramDef.min}`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: `Enter a value >= ${paramDef.min}`,
|
||||
});
|
||||
}
|
||||
if (paramDef.max !== undefined && value > paramDef.max) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be at most ${paramDef.max}`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: `Enter a value <= ${paramDef.max}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "boolean":
|
||||
if (typeof value !== "boolean") {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be true or false`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Use the toggle switch to set this value",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "select":
|
||||
if (
|
||||
paramDef.options &&
|
||||
!paramDef.options.includes(value as string)
|
||||
) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' has invalid value`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: `Choose from: ${paramDef.options.join(", ")}`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unknown parameter type '${paramDef.type}' for '${paramDef.name}'`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Check action definition for correct parameter types",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for unexpected parameters
|
||||
Object.keys(action.parameters).forEach((paramId) => {
|
||||
const isDefinedParam = definition.parameters.some(
|
||||
(def) => def.id === paramId,
|
||||
);
|
||||
if (!isDefinedParam) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: `Unexpected parameter '${paramId}' - not defined in action schema`,
|
||||
category: "parameter",
|
||||
field: `parameters.${paramId}`,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion:
|
||||
"Remove this parameter or check if action definition is outdated",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Semantic Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateSemantic(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for duplicate step IDs
|
||||
const stepIds = new Set<string>();
|
||||
const duplicateStepIds = new Set<string>();
|
||||
|
||||
steps.forEach((step) => {
|
||||
if (stepIds.has(step.id)) {
|
||||
duplicateStepIds.add(step.id);
|
||||
}
|
||||
stepIds.add(step.id);
|
||||
});
|
||||
|
||||
duplicateStepIds.forEach((stepId) => {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Duplicate step ID: ${stepId}`,
|
||||
category: "semantic",
|
||||
stepId,
|
||||
suggestion: "Step IDs must be unique throughout the experiment",
|
||||
});
|
||||
});
|
||||
|
||||
// Check for duplicate action IDs globally
|
||||
const actionIds = new Set<string>();
|
||||
const duplicateActionIds = new Set<string>();
|
||||
|
||||
steps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
if (actionIds.has(action.id)) {
|
||||
duplicateActionIds.add(action.id);
|
||||
}
|
||||
actionIds.add(action.id);
|
||||
});
|
||||
});
|
||||
|
||||
duplicateActionIds.forEach((actionId) => {
|
||||
const containingSteps = steps.filter((s) =>
|
||||
s.actions.some((a) => a.id === actionId),
|
||||
);
|
||||
|
||||
containingSteps.forEach((step) => {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Duplicate action ID: ${actionId}`,
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
actionId,
|
||||
suggestion: "Action IDs must be unique throughout the experiment",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Check for empty steps
|
||||
steps.forEach((step) => {
|
||||
if (step.actions.length === 0) {
|
||||
const severity = step.type === "parallel" ? "error" : "warning";
|
||||
issues.push({
|
||||
severity,
|
||||
message: `${step.type} step has no actions`,
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
suggestion: "Add actions to this step or remove it",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Documentation suggestions
|
||||
steps.forEach((step) => {
|
||||
// Missing step descriptions
|
||||
if (!step.description?.trim()) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "Consider adding a description to document step purpose",
|
||||
category: "semantic",
|
||||
field: "description",
|
||||
stepId: step.id,
|
||||
suggestion:
|
||||
"Descriptions improve experiment documentation and reproducibility",
|
||||
});
|
||||
}
|
||||
|
||||
// Actions without meaningful names
|
||||
step.actions.forEach((action) => {
|
||||
if (
|
||||
action.name === action.type ||
|
||||
action.name.toLowerCase().includes("untitled")
|
||||
) {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "Consider providing a more descriptive action name",
|
||||
category: "semantic",
|
||||
field: "name",
|
||||
stepId: step.id,
|
||||
actionId: action.id,
|
||||
suggestion:
|
||||
"Descriptive names help with experiment understanding and debugging",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Workflow logic suggestions
|
||||
steps.forEach((step, index) => {
|
||||
// First step should typically use trial_start trigger
|
||||
if (index === 0 && step.trigger.type !== "trial_start") {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "First step typically uses trial_start trigger",
|
||||
category: "semantic",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using trial_start trigger for the initial step",
|
||||
});
|
||||
}
|
||||
|
||||
// Timer triggers without reasonable durations
|
||||
if (step.trigger.type === "timer") {
|
||||
const duration = step.trigger.conditions?.duration;
|
||||
if (typeof duration === "number") {
|
||||
if (duration < 100) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message: "Very short timer duration may cause timing issues",
|
||||
category: "semantic",
|
||||
field: "trigger.conditions.duration",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using at least 100ms for reliable timing",
|
||||
});
|
||||
}
|
||||
if (duration > 300000) {
|
||||
// 5 minutes
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message: "Long timer duration - ensure this is intentional",
|
||||
category: "semantic",
|
||||
field: "trigger.conditions.duration",
|
||||
stepId: step.id,
|
||||
suggestion:
|
||||
"Verify the timer duration is correct for your use case",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Cross-Step Execution Validation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateExecution(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for unreachable steps (basic heuristic)
|
||||
if (steps.length > 1) {
|
||||
const trialStartSteps = steps.filter(
|
||||
(s) => s.trigger.type === "trial_start",
|
||||
);
|
||||
if (trialStartSteps.length > 1) {
|
||||
trialStartSteps.slice(1).forEach((step) => {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Multiple steps with trial_start trigger may cause execution conflicts",
|
||||
category: "execution",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Consider using sequential triggers for subsequent steps",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing robot dependencies
|
||||
const robotActions = steps.flatMap((step) =>
|
||||
step.actions.filter(
|
||||
(action) =>
|
||||
action.execution.transport === "ros2" ||
|
||||
action.execution.transport === "rest",
|
||||
),
|
||||
);
|
||||
|
||||
if (robotActions.length > 0) {
|
||||
// This would need robot registry integration in full implementation
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"Experiment contains robot actions - ensure robot connections are configured",
|
||||
category: "execution",
|
||||
suggestion:
|
||||
"Verify robot plugins are installed and robots are accessible",
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Main Validation Function */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function validateExperimentDesign(
|
||||
steps: ExperimentStep[],
|
||||
context: ValidationContext,
|
||||
): ValidationResult {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Run all validation rule sets
|
||||
issues.push(...validateStructural(steps, context));
|
||||
issues.push(...validateParameters(steps, context));
|
||||
issues.push(...validateSemantic(steps, context));
|
||||
issues.push(...validateExecution(steps, context));
|
||||
|
||||
// Count issues by severity
|
||||
const errorCount = issues.filter((i) => i.severity === "error").length;
|
||||
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
||||
const infoCount = issues.filter((i) => i.severity === "info").length;
|
||||
|
||||
// Experiment is valid if no errors (warnings and info are allowed)
|
||||
const valid = errorCount === 0;
|
||||
|
||||
return {
|
||||
valid,
|
||||
issues,
|
||||
errorCount,
|
||||
warningCount,
|
||||
infoCount,
|
||||
};
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Grouping Utilities */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function groupIssuesByEntity(
|
||||
issues: ValidationIssue[],
|
||||
): Record<string, ValidationIssue[]> {
|
||||
const grouped: Record<string, ValidationIssue[]> = {};
|
||||
|
||||
issues.forEach((issue) => {
|
||||
const entityId = issue.actionId || issue.stepId || "experiment";
|
||||
if (!grouped[entityId]) {
|
||||
grouped[entityId] = [];
|
||||
}
|
||||
grouped[entityId].push(issue);
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function getIssuesByStep(
|
||||
issues: ValidationIssue[],
|
||||
stepId: string,
|
||||
): ValidationIssue[] {
|
||||
return issues.filter((issue) => issue.stepId === stepId);
|
||||
}
|
||||
|
||||
export function getIssuesByAction(
|
||||
issues: ValidationIssue[],
|
||||
actionId: string,
|
||||
): ValidationIssue[] {
|
||||
return issues.filter((issue) => issue.actionId === actionId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Exports */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const Validators = {
|
||||
validateStructural,
|
||||
validateParameters,
|
||||
validateSemantic,
|
||||
validateExecution,
|
||||
validateExperimentDesign,
|
||||
groupIssuesByEntity,
|
||||
getIssuesByStep,
|
||||
getIssuesByAction,
|
||||
};
|
||||
|
||||
export default Validators;
|
||||
@@ -14,77 +14,103 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import { experimentsColumns, type Experiment } from "./experiments-columns";
|
||||
|
||||
export function ExperimentsDataTable() {
|
||||
const { activeStudy } = useActiveStudy();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
|
||||
const columns = React.useMemo(() => {
|
||||
return experimentsColumns.filter(
|
||||
(col) => !("accessorKey" in col) || col.accessorKey !== "study",
|
||||
);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: experimentsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.experiments.getUserExperiments.useQuery(
|
||||
{ page: 1, limit: 50 },
|
||||
} = api.experiments.list.useQuery(
|
||||
{ studyId: selectedStudyId ?? "" },
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!selectedStudyId,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto-refresh experiments when component mounts to catch external changes
|
||||
React.useEffect(() => {
|
||||
if (!selectedStudyId) return;
|
||||
const interval = setInterval(() => {
|
||||
void refetch();
|
||||
}, 30000); // Refresh every 30 seconds
|
||||
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [refetch]);
|
||||
}, [refetch, selectedStudyId]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(activeStudy
|
||||
...(selectedStudyId
|
||||
? [
|
||||
{
|
||||
label: (activeStudy as { title: string; id: string }).title,
|
||||
href: `/studies/${(activeStudy as { id: string }).id}`,
|
||||
label: "Experiments",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments" },
|
||||
]
|
||||
: [{ label: "Experiments" }]),
|
||||
]);
|
||||
|
||||
// Transform experiments data to match the Experiment type expected by columns
|
||||
// Transform experiments data (already filtered by studyId) to match columns
|
||||
const experiments: Experiment[] = React.useMemo(() => {
|
||||
if (!experimentsData?.experiments) return [];
|
||||
if (!experimentsData) return [];
|
||||
if (!selectedStudyId) return [];
|
||||
|
||||
return experimentsData.experiments.map((experiment) => ({
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description,
|
||||
status: experiment.status,
|
||||
createdAt: experiment.createdAt,
|
||||
updatedAt: experiment.updatedAt,
|
||||
studyId: experiment.studyId,
|
||||
study: experiment.study,
|
||||
createdBy: experiment.createdBy ?? "",
|
||||
interface ListExperiment {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: Experiment["status"];
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
studyId: string;
|
||||
createdBy?: { name?: string | null; email?: string | null } | null;
|
||||
steps?: unknown[];
|
||||
trials?: unknown[];
|
||||
}
|
||||
|
||||
return (experimentsData as ListExperiment[]).map((exp) => ({
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
description: exp.description,
|
||||
status: exp.status,
|
||||
createdAt:
|
||||
exp.createdAt instanceof Date ? exp.createdAt : new Date(exp.createdAt),
|
||||
updatedAt:
|
||||
exp.updatedAt instanceof Date ? exp.updatedAt : new Date(exp.updatedAt),
|
||||
studyId: exp.studyId,
|
||||
study: {
|
||||
id: exp.studyId,
|
||||
name: "Active Study",
|
||||
},
|
||||
createdBy: exp.createdBy?.name ?? exp.createdBy?.email ?? "",
|
||||
owner: {
|
||||
name: experiment.createdBy?.name ?? null,
|
||||
email: experiment.createdBy?.email ?? "",
|
||||
name: exp.createdBy?.name ?? null,
|
||||
email: exp.createdBy?.email ?? "",
|
||||
},
|
||||
_count: {
|
||||
steps: experiment._count?.steps ?? 0,
|
||||
trials: experiment._count?.trials ?? 0,
|
||||
steps: Array.isArray(exp.steps) ? exp.steps.length : 0,
|
||||
trials: Array.isArray(exp.trials) ? exp.trials.length : 0,
|
||||
},
|
||||
userRole: undefined,
|
||||
canEdit: true,
|
||||
canDelete: true,
|
||||
}));
|
||||
}, [experimentsData]);
|
||||
}, [experimentsData, selectedStudyId]);
|
||||
|
||||
// Status filter options
|
||||
const statusOptions = [
|
||||
@@ -169,7 +195,7 @@ export function ExperimentsDataTable() {
|
||||
|
||||
<div className="space-y-4">
|
||||
<DataTable
|
||||
columns={experimentsColumns}
|
||||
columns={columns}
|
||||
data={filteredExperiments}
|
||||
searchKey="name"
|
||||
searchPlaceholder="Search experiments..."
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Participant = {
|
||||
@@ -220,7 +220,7 @@ interface ParticipantsTableProps {
|
||||
}
|
||||
|
||||
export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
||||
const { activeStudy } = useActiveStudy();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
const {
|
||||
data: participantsData,
|
||||
@@ -229,20 +229,20 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
||||
refetch,
|
||||
} = api.participants.list.useQuery(
|
||||
{
|
||||
studyId: studyId ?? activeStudy?.id ?? "",
|
||||
studyId: studyId ?? selectedStudyId ?? "",
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!(studyId ?? activeStudy?.id),
|
||||
enabled: !!(studyId ?? selectedStudyId),
|
||||
},
|
||||
);
|
||||
|
||||
// Refetch when active study changes
|
||||
useEffect(() => {
|
||||
if (activeStudy?.id || studyId) {
|
||||
if (selectedStudyId || studyId) {
|
||||
void refetch();
|
||||
}
|
||||
}, [activeStudy?.id, studyId, refetch]);
|
||||
}, [selectedStudyId, studyId, refetch]);
|
||||
|
||||
const data: Participant[] = React.useMemo(() => {
|
||||
if (!participantsData?.participants) return [];
|
||||
@@ -263,7 +263,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
|
||||
);
|
||||
}, [participantsData]);
|
||||
|
||||
if (!studyId && !activeStudy) {
|
||||
if (!studyId && !selectedStudyId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
|
||||
@@ -15,14 +15,14 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useActiveStudy } from "~/hooks/useActiveStudy";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Trial = {
|
||||
@@ -44,6 +44,7 @@ export type Trial = {
|
||||
wizardId: string | null;
|
||||
eventCount: number;
|
||||
mediaCount: number;
|
||||
latestEventAt: Date | null;
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
@@ -178,11 +179,11 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
href={`/participants/${participantId}`}
|
||||
className="font-mono text-sm hover:underline"
|
||||
>
|
||||
{String(participantCode) || "Unknown"}
|
||||
{(participantCode ?? "Unknown") as string}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-sm">
|
||||
{String(participantCode) || "Unknown"}
|
||||
{(participantCode ?? "Unknown") as string}
|
||||
</span>
|
||||
)}
|
||||
{participantName && (
|
||||
@@ -210,7 +211,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
|
||||
return (
|
||||
<div className="max-w-[150px] truncate text-sm">
|
||||
{String(wizardName)}
|
||||
{wizardName as string}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -279,7 +280,10 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
}
|
||||
|
||||
if (scheduledAt) {
|
||||
const scheduleDate = scheduledAt ? new Date(scheduledAt as string | number | Date) : null;
|
||||
const scheduleDate =
|
||||
scheduledAt != null
|
||||
? new Date(scheduledAt as string | number | Date)
|
||||
: null;
|
||||
const isUpcoming = scheduleDate && scheduleDate > new Date();
|
||||
return (
|
||||
<div className="text-sm">
|
||||
@@ -302,21 +306,31 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
accessorKey: "eventCount",
|
||||
header: "Data",
|
||||
cell: ({ row }) => {
|
||||
const eventCount = row.getValue("eventCount") || 0;
|
||||
const mediaCount = row.original?.mediaCount || 0;
|
||||
const eventCount = row.getValue("eventCount") ?? 0;
|
||||
const mediaCount = row.original?.mediaCount ?? 0;
|
||||
const latestEventAt = row.original?.latestEventAt
|
||||
? new Date(row.original.latestEventAt)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<Badge className="mr-1 bg-purple-100 text-purple-800">
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge className="bg-purple-100 text-purple-800">
|
||||
{Number(eventCount)} events
|
||||
</Badge>
|
||||
</div>
|
||||
{mediaCount > 0 && (
|
||||
<div className="mt-1">
|
||||
{mediaCount > 0 && (
|
||||
<Badge className="bg-orange-100 text-orange-800">
|
||||
{mediaCount} media
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{latestEventAt && (
|
||||
<div className="text-muted-foreground mt-1 text-[11px]">
|
||||
Last evt:{" "}
|
||||
{latestEventAt.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -343,7 +357,9 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -415,8 +431,8 @@ interface TrialsTableProps {
|
||||
}
|
||||
|
||||
export function TrialsTable({ studyId }: TrialsTableProps = {}) {
|
||||
const { activeStudy } = useActiveStudy();
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>("all");
|
||||
|
||||
const {
|
||||
data: trialsData,
|
||||
@@ -425,75 +441,82 @@ export function TrialsTable({ studyId }: TrialsTableProps = {}) {
|
||||
refetch,
|
||||
} = api.trials.list.useQuery(
|
||||
{
|
||||
studyId: studyId ?? activeStudy?.id,
|
||||
studyId: studyId ?? selectedStudyId ?? "",
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
enabled: !!(studyId ?? activeStudy?.id),
|
||||
enabled: !!(studyId ?? selectedStudyId),
|
||||
},
|
||||
);
|
||||
|
||||
// Refetch when active study changes
|
||||
useEffect(() => {
|
||||
if (activeStudy?.id || studyId) {
|
||||
refetch();
|
||||
if (selectedStudyId || studyId) {
|
||||
void refetch();
|
||||
}
|
||||
}, [activeStudy?.id, studyId, refetch]);
|
||||
}, [selectedStudyId, studyId, refetch]);
|
||||
|
||||
// Adapt trials.list payload (no wizard, counts, sessionNumber, scheduledAt in list response)
|
||||
const data: Trial[] = React.useMemo(() => {
|
||||
if (!trialsData || !Array.isArray(trialsData)) return [];
|
||||
if (!Array.isArray(trialsData)) return [];
|
||||
|
||||
return trialsData
|
||||
.map((trial: any) => {
|
||||
if (!trial || typeof trial !== "object") {
|
||||
return {
|
||||
id: "",
|
||||
sessionNumber: 0,
|
||||
status: "scheduled" as const,
|
||||
scheduledAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date(),
|
||||
experimentName: "Invalid Trial",
|
||||
experimentId: "",
|
||||
studyName: "Unknown Study",
|
||||
studyId: "",
|
||||
participantCode: null,
|
||||
participantName: null,
|
||||
participantId: null,
|
||||
wizardName: null,
|
||||
wizardId: null,
|
||||
eventCount: 0,
|
||||
mediaCount: 0,
|
||||
};
|
||||
}
|
||||
interface ListTrial {
|
||||
id: string;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
status: Trial["status"];
|
||||
sessionNumber: number | null;
|
||||
scheduledAt: Date | null;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
experiment: { id: string; name: string; studyId: string };
|
||||
participant?: { id: string; participantCode: string } | null;
|
||||
wizard?: {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
} | null;
|
||||
eventCount?: number;
|
||||
mediaCount?: number;
|
||||
latestEventAt?: Date | null;
|
||||
userRole: string;
|
||||
canAccess: boolean;
|
||||
}
|
||||
|
||||
return {
|
||||
id: trial.id || "",
|
||||
sessionNumber: trial.sessionNumber || 0,
|
||||
status: trial.status || "scheduled",
|
||||
scheduledAt: trial.scheduledAt || null,
|
||||
startedAt: trial.startedAt || null,
|
||||
completedAt: trial.completedAt || null,
|
||||
createdAt: trial.createdAt || new Date(),
|
||||
experimentName: trial.experiment?.name || "Unknown Experiment",
|
||||
experimentId: trial.experiment?.id || "",
|
||||
studyName: trial.experiment?.study?.name || "Unknown Study",
|
||||
studyId: trial.experiment?.study?.id || "",
|
||||
participantCode: trial.participant?.participantCode || null,
|
||||
participantName: trial.participant?.name || null,
|
||||
participantId: trial.participant?.id || null,
|
||||
wizardName: trial.wizard?.name || null,
|
||||
wizardId: trial.wizard?.id || null,
|
||||
eventCount: trial._count?.events || 0,
|
||||
mediaCount: trial._count?.mediaCaptures || 0,
|
||||
};
|
||||
})
|
||||
.filter((trial) => trial.id); // Filter out any trials without valid IDs
|
||||
}, [trialsData]);
|
||||
const mapped = (trialsData as ListTrial[]).map((t) => ({
|
||||
id: t.id,
|
||||
sessionNumber: t.sessionNumber ?? 0,
|
||||
status: t.status,
|
||||
scheduledAt: t.scheduledAt ?? null,
|
||||
startedAt: t.startedAt ?? null,
|
||||
completedAt: t.completedAt ?? null,
|
||||
createdAt: t.createdAt,
|
||||
experimentName: t.experiment.name,
|
||||
experimentId: t.experiment.id,
|
||||
studyName: "Active Study",
|
||||
studyId: t.experiment.studyId,
|
||||
participantCode: t.participant?.participantCode ?? null,
|
||||
participantName: null,
|
||||
participantId: t.participant?.id ?? null,
|
||||
wizardName: t.wizard?.name ?? null,
|
||||
wizardId: t.wizard?.id ?? null,
|
||||
eventCount: t.eventCount ?? 0,
|
||||
mediaCount: t.mediaCount ?? 0,
|
||||
latestEventAt: t.latestEventAt ?? null,
|
||||
}));
|
||||
// Apply status filter (if not "all")
|
||||
if (statusFilter !== "all") {
|
||||
return mapped.filter((t) => t.status === statusFilter);
|
||||
}
|
||||
return mapped;
|
||||
}, [trialsData, statusFilter]);
|
||||
|
||||
if (!studyId && !activeStudy) {
|
||||
if (!selectedStudyId && !studyId) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
@@ -551,8 +574,8 @@ export function TrialsTable({ studyId }: TrialsTableProps = {}) {
|
||||
<DropdownMenuItem onClick={() => setStatusFilter("completed")}>
|
||||
Completed
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatusFilter("aborted")}>
|
||||
Aborted
|
||||
<DropdownMenuItem onClick={() => setStatusFilter("aborted")}>
|
||||
Aborted
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatusFilter("failed")}>
|
||||
Failed
|
||||
|
||||
@@ -1,146 +1,44 @@
|
||||
"use client";
|
||||
/**
|
||||
* @file useActiveStudy.ts
|
||||
*
|
||||
* Legacy placeholder for the deprecated `useActiveStudy` hook.
|
||||
*
|
||||
* This file exists solely to satisfy lingering TypeScript project
|
||||
* service references (e.g. editor cached import paths) after the
|
||||
* migration to the unified `useSelectedStudyDetails` hook.
|
||||
*
|
||||
* Previous responsibilities:
|
||||
* - Exposed the currently "active" study id via localStorage.
|
||||
* - Partially overlapped with a separate study context implementation.
|
||||
*
|
||||
* Migration:
|
||||
* - All consumers should now import `useSelectedStudyDetails` from:
|
||||
* `~/hooks/useSelectedStudyDetails`
|
||||
* - That hook centralizes selection, metadata, counts, and role info.
|
||||
*
|
||||
* Safe Removal:
|
||||
* - Once you are certain no editors / build artifacts reference this
|
||||
* path, you may delete this file. It is intentionally tiny and has
|
||||
* zero runtime footprint unless mistakenly invoked.
|
||||
*/
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const ACTIVE_STUDY_KEY = "hristudio-active-study";
|
||||
|
||||
// Helper function to validate UUID format
|
||||
const isValidUUID = (id: string): boolean => {
|
||||
const uuidRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
return uuidRegex.test(id);
|
||||
};
|
||||
|
||||
export function useActiveStudy() {
|
||||
const { data: session } = useSession();
|
||||
const [activeStudyId, setActiveStudyId] = useState<string | null>(null);
|
||||
const [isSettingActiveStudy, setIsSettingActiveStudy] = useState(false);
|
||||
|
||||
// Load active study from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(ACTIVE_STUDY_KEY);
|
||||
if (stored && isValidUUID(stored)) {
|
||||
setActiveStudyId(stored);
|
||||
} else if (stored) {
|
||||
// Clear invalid UUID from localStorage
|
||||
localStorage.removeItem(ACTIVE_STUDY_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get active study details
|
||||
const { data: activeStudy, isLoading: isLoadingActiveStudy } =
|
||||
api.studies.get.useQuery(
|
||||
{ id: activeStudyId! },
|
||||
{
|
||||
enabled: !!activeStudyId && isValidUUID(activeStudyId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: false, // Don't retry if study doesn't exist
|
||||
},
|
||||
);
|
||||
|
||||
// Clear localStorage if study doesn't exist
|
||||
useEffect(() => {
|
||||
if (activeStudyId && !activeStudy && !isLoadingActiveStudy) {
|
||||
localStorage.removeItem(ACTIVE_STUDY_KEY);
|
||||
setActiveStudyId(null);
|
||||
toast.error(
|
||||
"Selected study no longer exists. Please select a new study.",
|
||||
);
|
||||
}
|
||||
}, [activeStudy, activeStudyId, isLoadingActiveStudy]);
|
||||
|
||||
// Get user's studies for switching (always use memberOnly: true for security)
|
||||
const { data: studiesData, isLoading: isLoadingStudies } =
|
||||
api.studies.list.useQuery(
|
||||
{ limit: 20, memberOnly: true },
|
||||
{
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
enabled: !!session?.user?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const userStudies = studiesData?.studies ?? [];
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const setActiveStudy = (studyId: string) => {
|
||||
if (!isValidUUID(studyId)) {
|
||||
toast.error("Invalid study ID format");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSettingActiveStudy(true);
|
||||
setActiveStudyId(studyId);
|
||||
localStorage.setItem(ACTIVE_STUDY_KEY, studyId);
|
||||
|
||||
// Invalidate all related queries when study changes
|
||||
void utils.participants.invalidate();
|
||||
void utils.trials.invalidate();
|
||||
void utils.experiments.invalidate();
|
||||
|
||||
toast.success("Active study updated");
|
||||
|
||||
// Reset loading state after a brief delay to allow queries to refetch
|
||||
setTimeout(() => {
|
||||
setIsSettingActiveStudy(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const clearActiveStudy = () => {
|
||||
setIsSettingActiveStudy(true);
|
||||
setActiveStudyId(null);
|
||||
localStorage.removeItem(ACTIVE_STUDY_KEY);
|
||||
|
||||
// Invalidate all related queries when clearing study
|
||||
void utils.participants.invalidate();
|
||||
void utils.trials.invalidate();
|
||||
void utils.experiments.invalidate();
|
||||
|
||||
toast.success("Active study cleared");
|
||||
|
||||
// Reset loading state after a brief delay
|
||||
setTimeout(() => {
|
||||
setIsSettingActiveStudy(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Note: Auto-selection removed to require manual study selection
|
||||
|
||||
return {
|
||||
// State
|
||||
activeStudyId,
|
||||
activeStudy:
|
||||
activeStudy && typeof activeStudy === "object"
|
||||
? {
|
||||
id: activeStudy.id,
|
||||
title: (activeStudy as { name?: string }).name ?? "",
|
||||
description:
|
||||
(activeStudy as { description?: string }).description ?? "",
|
||||
}
|
||||
: null,
|
||||
userStudies: userStudies.map(
|
||||
(study: { id: string; name: string; description?: string | null }) => ({
|
||||
id: study.id,
|
||||
title: study.name,
|
||||
description: study.description ?? "",
|
||||
}),
|
||||
),
|
||||
|
||||
// Loading states
|
||||
isLoadingActiveStudy,
|
||||
isLoadingStudies,
|
||||
isSettingActiveStudy,
|
||||
isClearingActiveStudy: false,
|
||||
|
||||
// Actions
|
||||
setActiveStudy,
|
||||
clearActiveStudy,
|
||||
|
||||
// Utilities
|
||||
hasActiveStudy: !!activeStudyId,
|
||||
hasStudies: userStudies.length > 0,
|
||||
};
|
||||
/**
|
||||
* @deprecated Use `useSelectedStudyDetails()` instead.
|
||||
* Legacy no-op placeholder retained only to satisfy stale references.
|
||||
* Returns a neutral object so accidental invocations are harmless.
|
||||
*/
|
||||
export function useActiveStudy(): DeprecatedActiveStudyHookReturn {
|
||||
return { studyId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias maintained for backward compatibility with (now removed)
|
||||
* code that might have referenced the old hook's return type.
|
||||
* Kept minimal on purpose.
|
||||
*/
|
||||
export interface DeprecatedActiveStudyHookReturn {
|
||||
/** Previously the active study id (now: studyId in useSelectedStudyDetails) */
|
||||
studyId: string | null;
|
||||
}
|
||||
|
||||
export default useActiveStudy;
|
||||
|
||||
123
src/hooks/useSelectedStudyDetails.ts
Normal file
123
src/hooks/useSelectedStudyDetails.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
|
||||
/**
|
||||
* useSelectedStudyDetails
|
||||
*
|
||||
* Strongly typed unified source of truth for the currently selected study.
|
||||
*
|
||||
* Provides a single hook to retrieve:
|
||||
* - selected study id
|
||||
* - lightweight summary counts
|
||||
* - role + createdAt
|
||||
* - loading / fetching flags
|
||||
* - mutation helpers
|
||||
*/
|
||||
|
||||
interface StudyRelatedEntity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface StudyMember {
|
||||
id: string;
|
||||
userId?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface StudyDetails {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
experiments?: StudyRelatedEntity[];
|
||||
participants?: StudyRelatedEntity[];
|
||||
members?: StudyMember[];
|
||||
userRole?: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface StudySummary {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
experimentCount: number;
|
||||
participantCount: number;
|
||||
memberCount: number;
|
||||
userRole?: string;
|
||||
createdAt?: Date;
|
||||
}
|
||||
|
||||
export interface UseSelectedStudyDetailsReturn {
|
||||
studyId: string | null;
|
||||
study: StudySummary | null;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
refetch: () => Promise<unknown>;
|
||||
setStudyId: (id: string | null) => void;
|
||||
clearStudy: () => void;
|
||||
hasStudy: boolean;
|
||||
}
|
||||
|
||||
export function useSelectedStudyDetails(): UseSelectedStudyDetailsReturn {
|
||||
const { selectedStudyId, setSelectedStudyId } = useStudyContext();
|
||||
|
||||
const { data, isLoading, isFetching, refetch } = api.studies.get.useQuery(
|
||||
{ id: selectedStudyId ?? "" },
|
||||
{
|
||||
enabled: !!selectedStudyId,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const study: StudySummary | null = useMemo(() => {
|
||||
if (!data || !selectedStudyId) return null;
|
||||
|
||||
// data is inferred from tRPC; we defensively narrow array fields
|
||||
const typed = data as StudyDetails;
|
||||
|
||||
const experiments = Array.isArray(typed.experiments)
|
||||
? typed.experiments
|
||||
: [];
|
||||
const participants = Array.isArray(typed.participants)
|
||||
? typed.participants
|
||||
: [];
|
||||
const members = Array.isArray(typed.members) ? typed.members : [];
|
||||
|
||||
return {
|
||||
id: typed.id,
|
||||
name: typed.name ?? "Unnamed Study",
|
||||
description: typed.description ?? "",
|
||||
status: typed.status ?? "active",
|
||||
experimentCount: experiments.length,
|
||||
participantCount: participants.length,
|
||||
memberCount: members.length,
|
||||
userRole: typed.userRole,
|
||||
createdAt: typed.createdAt,
|
||||
};
|
||||
}, [data, selectedStudyId]);
|
||||
|
||||
const setStudyId = useCallback(
|
||||
(id: string | null) => {
|
||||
void setSelectedStudyId(id);
|
||||
},
|
||||
[setSelectedStudyId],
|
||||
);
|
||||
|
||||
const clearStudy = useCallback(() => {
|
||||
void setSelectedStudyId(null);
|
||||
}, [setSelectedStudyId]);
|
||||
|
||||
return {
|
||||
studyId: selectedStudyId,
|
||||
study,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
setStudyId,
|
||||
clearStudy,
|
||||
hasStudy: !!study,
|
||||
};
|
||||
}
|
||||
@@ -18,14 +18,24 @@ const StudyContext = createContext<StudyContextType | undefined>(undefined);
|
||||
|
||||
const STUDY_STORAGE_KEY = "hristudio-selected-study";
|
||||
|
||||
export function StudyProvider({ children }: { children: ReactNode }) {
|
||||
export function StudyProvider({
|
||||
children,
|
||||
initialStudyId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
initialStudyId?: string | null;
|
||||
}) {
|
||||
const [selectedStudyId, setSelectedStudyIdState] = useState<string | null>(
|
||||
null,
|
||||
initialStudyId ?? null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Load from localStorage on mount
|
||||
// Load from localStorage on mount (only if no server-provided initial ID)
|
||||
useEffect(() => {
|
||||
if (initialStudyId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stored = localStorage.getItem(STUDY_STORAGE_KEY);
|
||||
if (stored && stored !== "null") {
|
||||
@@ -36,19 +46,23 @@ export function StudyProvider({ children }: { children: ReactNode }) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [initialStudyId]);
|
||||
|
||||
// Persist to localStorage when changed
|
||||
const setSelectedStudyId = (studyId: string | null) => {
|
||||
// Persist to localStorage & cookie when changed
|
||||
const setSelectedStudyId = (studyId: string | null): void => {
|
||||
setSelectedStudyIdState(studyId);
|
||||
try {
|
||||
if (studyId) {
|
||||
localStorage.setItem(STUDY_STORAGE_KEY, studyId);
|
||||
// 30 days
|
||||
document.cookie = `hristudio_selected_study=${studyId}; Path=/; Max-Age=2592000; SameSite=Lax`;
|
||||
} else {
|
||||
localStorage.removeItem(STUDY_STORAGE_KEY);
|
||||
document.cookie =
|
||||
"hristudio_selected_study=; Path=/; Max-Age=0; SameSite=Lax";
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to save study selection to localStorage:", error);
|
||||
console.warn("Failed to persist study selection:", error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { and, asc, count, desc, eq, inArray } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
experimentStatusEnum,
|
||||
robots,
|
||||
steps,
|
||||
trials,
|
||||
stepTypeEnum,
|
||||
studyMembers,
|
||||
userSystemRoles,
|
||||
@@ -124,11 +125,64 @@ export const experimentsRouter = createTRPCRouter({
|
||||
orderBy: [desc(experiments.updatedAt)],
|
||||
});
|
||||
|
||||
return experimentsList.map((exp) => ({
|
||||
...exp,
|
||||
stepCount: exp.steps.length,
|
||||
trialCount: exp.trials.length,
|
||||
}));
|
||||
// Aggregate action counts & latest trial activity (single pass merges)
|
||||
const experimentIds = experimentsList.map((e) => e.id);
|
||||
|
||||
const actionCountMap = new Map<string, number>();
|
||||
const latestTrialActivityMap = new Map<string, Date>();
|
||||
|
||||
if (experimentIds.length > 0) {
|
||||
// Action counts (join actions -> steps -> experiments)
|
||||
const actionCounts = await ctx.db
|
||||
.select({
|
||||
experimentId: steps.experimentId,
|
||||
count: count(),
|
||||
})
|
||||
.from(actions)
|
||||
.innerJoin(steps, eq(actions.stepId, steps.id))
|
||||
.where(inArray(steps.experimentId, experimentIds))
|
||||
.groupBy(steps.experimentId);
|
||||
|
||||
actionCounts.forEach((row) =>
|
||||
actionCountMap.set(row.experimentId, Number(row.count) || 0),
|
||||
);
|
||||
|
||||
// Latest trial activity (max of trial started/completed/created timestamps)
|
||||
const trialActivity = await ctx.db
|
||||
.select({
|
||||
experimentId: trials.experimentId,
|
||||
latest: sql`max(GREATEST(
|
||||
COALESCE(${trials.completedAt}, 'epoch'::timestamptz),
|
||||
COALESCE(${trials.startedAt}, 'epoch'::timestamptz),
|
||||
COALESCE(${trials.createdAt}, 'epoch'::timestamptz)
|
||||
))`.as("latest"),
|
||||
})
|
||||
.from(trials)
|
||||
.where(inArray(trials.experimentId, experimentIds))
|
||||
.groupBy(trials.experimentId);
|
||||
|
||||
trialActivity.forEach((row) => {
|
||||
if (row.latest) {
|
||||
latestTrialActivityMap.set(row.experimentId, row.latest as Date);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return experimentsList.map((exp) => {
|
||||
const trialLatest = latestTrialActivityMap.get(exp.id);
|
||||
const latestActivityAt =
|
||||
trialLatest && trialLatest > exp.updatedAt
|
||||
? trialLatest
|
||||
: exp.updatedAt;
|
||||
|
||||
return {
|
||||
...exp,
|
||||
stepCount: exp.steps.length,
|
||||
trialCount: exp.trials.length,
|
||||
actionCount: actionCountMap.get(exp.id) ?? 0,
|
||||
latestActivityAt,
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
getUserExperiments: protectedProcedure
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
trials,
|
||||
trialStatusEnum,
|
||||
wizardInterventions,
|
||||
mediaCaptures,
|
||||
users,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
@@ -113,6 +115,8 @@ export const trialsRouter = createTRPCRouter({
|
||||
participantId: trials.participantId,
|
||||
experimentId: trials.experimentId,
|
||||
status: trials.status,
|
||||
sessionNumber: trials.sessionNumber,
|
||||
scheduledAt: trials.scheduledAt,
|
||||
startedAt: trials.startedAt,
|
||||
completedAt: trials.completedAt,
|
||||
duration: trials.duration,
|
||||
@@ -128,11 +132,17 @@ export const trialsRouter = createTRPCRouter({
|
||||
id: participants.id,
|
||||
participantCode: participants.participantCode,
|
||||
},
|
||||
wizard: {
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
},
|
||||
userRole: studyMembers.role,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||
.leftJoin(users, eq(users.id, trials.wizardId))
|
||||
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
|
||||
.where(and(eq(studyMembers.userId, userId), ...conditions))
|
||||
.orderBy(desc(trials.createdAt))
|
||||
@@ -141,9 +151,52 @@ export const trialsRouter = createTRPCRouter({
|
||||
|
||||
const results = await query;
|
||||
|
||||
// Add permission flags for each trial
|
||||
// Aggregate event & media counts (batched)
|
||||
const trialIds = results.map((r) => r.id);
|
||||
const eventCountMap = new Map<string, number>();
|
||||
const mediaCountMap = new Map<string, number>();
|
||||
const latestEventAtMap = new Map<string, Date>();
|
||||
// Hoisted map for latest event timestamps so it is in scope after aggregation block
|
||||
// (removed redeclaration of latestEventAtMap; now hoisted above)
|
||||
|
||||
if (trialIds.length > 0) {
|
||||
const eventCounts = await db
|
||||
.select({
|
||||
trialId: trialEvents.trialId,
|
||||
count: count(),
|
||||
latest: sql`max(${trialEvents.timestamp})`.as("latest"),
|
||||
})
|
||||
.from(trialEvents)
|
||||
.where(inArray(trialEvents.trialId, trialIds))
|
||||
.groupBy(trialEvents.trialId);
|
||||
|
||||
eventCounts.forEach((ec) => {
|
||||
eventCountMap.set(ec.trialId, Number(ec.count) || 0);
|
||||
if (ec.latest) {
|
||||
latestEventAtMap.set(ec.trialId, ec.latest as Date);
|
||||
}
|
||||
});
|
||||
|
||||
const mediaCounts = await db
|
||||
.select({
|
||||
trialId: mediaCaptures.trialId,
|
||||
count: count(),
|
||||
})
|
||||
.from(mediaCaptures)
|
||||
.where(inArray(mediaCaptures.trialId, trialIds))
|
||||
.groupBy(mediaCaptures.trialId);
|
||||
|
||||
mediaCounts.forEach((mc) => {
|
||||
mediaCountMap.set(mc.trialId, Number(mc.count) || 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Add permission flags & counts
|
||||
return results.map((trial) => ({
|
||||
...trial,
|
||||
eventCount: eventCountMap.get(trial.id) ?? 0,
|
||||
mediaCount: mediaCountMap.get(trial.id) ?? 0,
|
||||
latestEventAt: latestEventAtMap.get(trial.id) ?? null,
|
||||
canAccess: ["owner", "researcher", "wizard"].includes(trial.userRole),
|
||||
}));
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user