chore: clean diagnostics and prepare for designer structural refactor (stub legacy useActiveStudy)

This commit is contained in:
2025-08-11 16:38:29 -04:00
parent 524eff89fd
commit 779c639465
33 changed files with 5147 additions and 882 deletions

View File

@@ -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}
/>

View File

@@ -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} />

View File

@@ -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={{

View File

@@ -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={{

View File

@@ -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>

View File

@@ -25,7 +25,7 @@ export function StudyGuard({ children, fallback }: StudyGuardProps) {
}
if (!selectedStudyId) {
return fallback || <DefaultStudyRequiredMessage />;
return fallback ?? <DefaultStudyRequiredMessage />;
}
return <>{children}</>;

View File

@@ -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">

View File

@@ -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>
);

View File

@@ -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;
}

View File

@@ -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)
*

View 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>
);
}

View 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;

View 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>
);
}

View 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>
);
}

View 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;

View 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,
});
}

View 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;

View File

@@ -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..."

View File

@@ -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">

View File

@@ -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

View File

@@ -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;

View 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,
};
}

View File

@@ -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);
}
};

View File

@@ -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

View File

@@ -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),
}));
}),