chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)

This commit is contained in:
2025-08-08 00:37:35 -04:00
parent c071d33624
commit 1ac8296ab7
37 changed files with 5378 additions and 5758 deletions

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
@@ -419,6 +420,8 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],

View File

@@ -0,0 +1,253 @@
# Experiment Designer Step Integration (Modular Architecture + Drift Handling)
## Overview
The HRIStudio experiment designer has been redesigned with a step-based + provenance-aware architecture that provides intuitive experiment creation, transparent plugin usage, and reproducible execution through integrity hashing and a compiled execution graph.
## Architecture
### Core Design Philosophy
The designer follows a clear hierarchy that matches database structure, runtime execution, and reproducibility tracking:
- **Experiment** → **Steps****Actions** (with provenance + execution descriptors)
- Steps are primary containers in the flow (Step 1 → Step 2 → Step 3) with sortable ordering
- Actions are dragged from a categorized library into step containers (core vs plugin clearly labeled)
- Direct 1:1 mapping to database `steps` and `actions` tables, persisting provenance & transport metadata
### Key Components (Post-Modularization)
#### ActionRegistry (`ActionRegistry.ts`)
- Loads actions from core plugin repositories (`hristudio-core/plugins/`)
- Integrates study-scoped robot plugins (namespaced: `pluginId.actionId`)
- Provides fallback actions if plugin loading fails (ensures minimal operability)
- Maps plugin parameter schemas (primitive: text/number/select/boolean) to UI fields
- Retains provenance + execution descriptors (transport, timeout, retryable)
#### Step-Based Flow (`StepFlow.tsx`)
- Sortable step containers with drag-and-drop reordering (via `@dnd-kit`)
- Color-coded step types (sequential, parallel, conditional, loop) with left border accent
- Expandable/collapsible view for managing complex experiments
- Visual connectors between steps (light vertical separators)
- Isolated from parameter/editor logic for performance and clarity
#### Action Library (`ActionLibrary.tsx`)
- Categorized tabs: Wizard (blue), Robot (emerald), Control (amber), Observation (purple)
- Tooltips show description, parameters, provenance badge (C core / P plugin)
- Drag-and-drop from library directly into specific step droppable zones
- Footer statistics (total actions / category count)
- Empty + fallback guidance when plugin actions absent
- Guarantees availability: once the experiment's study context and its installed plugins are loaded, all corresponding plugin actions are registered and appear (guarded against duplicate loads / stale study mismatch)
- Plugin availability is study-scoped: only plugins installed for the experiment's parent study (via Plugin Store installation) are loaded and exposed; this ensures experiments cannot reference uninstalled or unauthorized plugin actions.
#### Properties Panel (`PropertiesPanel.tsx`)
- Context-aware: action selection → action parameters; step selection → step metadata; otherwise instructional state
- Boolean parameters now render as accessible Switch
- Number parameters with `min`/`max` render as Slider (shows live value + bounds)
- Number parameters without bounds fall back to numeric input
- Select/text unchanged; future: enum grouping + advanced editors
- Displays provenance + transport badges (plugin id@version, transport, retryable)
## User Experience
### Visual Design
- **Tightened Spacing**: Compact UI with efficient screen real estate usage
- **Dark Mode Support**: Proper selection states and theme-aware colors
- **Color Consistency**: Category colors used throughout for visual coherence
- **Micro-interactions**: Hover states, drag overlays, smooth transitions
### Interaction Patterns
- **Direct Action Editing**: Click any action to immediately edit properties (no step selection required)
- **Multi-level Sorting**: Reorder steps in flow, reorder actions within steps
- **Visual Feedback**: Drop zones highlight, selection states clear, drag handles intuitive
- **Touch-friendly**: Proper activation constraints for mobile/touch devices
### Properties Panel (Enhanced Parameter Controls)
- **Action-First Workflow**: Immediate property editing on action selection
- **Rich Metadata**: Icon, category color, provenance badges (Core/Plugin, transport)
- **Switch for Boolean**: Improves clarity vs checkbox in dense layouts
- **Slider for Ranged Number**: Applies when `min` or `max` present (live formatted value)
- **Graceful Fallbacks**: Plain number input if no bounds; text/select unchanged
- **Context-Aware**: Step editing (name/type/trigger) isolated from action editing
## Technical Implementation
### Drag and Drop System
Built with `@dnd-kit` for robust, accessible drag-and-drop:
```typescript
// Multi-context sorting support
const handleDragEnd = (event: DragEndEvent) => {
// Action from library to step
if (activeId.startsWith("action-") && overId.startsWith("step-")) {
// Add action to step
}
// Step reordering in flow
if (!activeId.startsWith("action-") && !overId.startsWith("step-")) {
// Reorder steps
}
// Action reordering within step
if (!activeId.startsWith("action-") && !overId.startsWith("step-")) {
// Reorder actions in step
}
};
```
### Plugin Integration (Registry-Centric)
Actions are loaded dynamically from multiple sources with provenance & version retention:
```typescript
class ActionRegistry {
async loadCoreActions() {
// Load from hristudio-core/plugins/
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
// Process and register actions
}
loadPluginActions(studyId: string, studyPlugins: Array<{plugin: any}>) {
// Load robot-specific actions from study plugins
// Map parameter schemas to form controls
}
}
```
### Database & Execution Conversion
Two-layer conversion:
1. Visual design → DB steps/actions with provenance & execution metadata
2. Visual design → Compiled execution graph (normalized actions + transport summary + integrity hash)
```typescript
function convertStepsToDatabase(steps: ExperimentStep[]): ConvertedStep[] {
return steps.map((step, index) => ({
name: step.name,
type: mapStepTypeToDatabase(step.type),
orderIndex: index,
conditions: step.trigger.conditions,
actions: step.actions.map((action, actionIndex) => ({
name: action.name,
type: action.type,
orderIndex: actionIndex,
parameters: action.parameters,
})),
}));
}
```
## Validation & Hash Drift Handling
A validation workflow now surfaces structural integrity + reproducibility signals:
### Validation Endpoint
- `experiments.validateDesign` returns:
- `valid` + `issues[]`
- `integrityHash` (deterministic structural hash from compiled execution graph)
- `pluginDependencies` (sorted, namespaced with versions)
- Execution summary (steps/actions/transport mix)
### Drift Detection (Client-Side)
- Local state caches: `lastValidatedHash` + serialized design snapshot
- Drift conditions:
1. Stored experiment `integrityHash` ≠ last validated hash
2. Design snapshot changed since last validation (structural or param changes)
- Badge States:
- `Validated` (green outline): design unchanged since last validation and matches stored hash
- `Drift` (destructive): mismatch or post-validation edits
- `Unvalidated`: no validation performed yet
### Rationale
- Encourages explicit revalidation after structural edits
- Prevents silent divergence from compiled execution artifact
- Future: differentiate structural vs param-only drift (hash currently parameter-key-based)
### Planned Enhancements
- Hash stability tuning (exclude mutable free-text values if needed)
- Inline warnings on mutated steps/actions
- Optional auto-validate on save (configurable)
## Plugin System Integration
### Core Actions
Loaded from `hristudio-core/plugins/` repositories:
- **wizard-actions.json**: Wizard speech, gestures, instructions
- **control-flow.json**: Wait, conditional logic, loops
- **observation.json**: Behavioral coding, data collection, measurements
### Robot Actions
Dynamically loaded based on study configuration:
- Robot-specific actions from plugin repositories
- Parameter schemas automatically converted to form controls
- Platform-specific validation and constraints
### Fallback System
Essential actions available even if plugin loading fails:
- Basic wizard speech and gesture actions
- Core control flow (wait, conditional)
- Simple observation and data collection
## Example Usage
### Creating an Experiment
1. **Add Steps**: Click "Add Step" to create containers in the experiment flow
2. **Configure Steps**: Set name, type (sequential/parallel/conditional/loop), triggers
3. **Drag Actions**: Drag from categorized library into step drop zones
4. **Edit Properties**: Click actions to immediately edit parameters
5. **Reorder**: Drag steps in flow, drag actions within steps
6. **Save**: Direct conversion to database step/action records (provenance & execution metadata persisted)
### Visual Workflow
```
Action Library Experiment Flow Properties
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ [Wizard] │ │ Step 1: Welcome │ │ Action: │
│ [Robot] │ -----> │ ├ Wizard Says │ ------> │ Wizard Says │
│ [Control] │ │ ├ Wait 2s │ │ Text: ... │
│ [Observe] │ │ └ Observe │ │ Tone: ... │
└─────────────┘ └──────────────────┘ └─────────────┘
```
## Benefits
### For Researchers
- **Intuitive Design**: Natural workflow matching experimental thinking
- **Visual Clarity**: Clear step-by-step experiment structure
- **Plugin Integration**: Access to full ecosystem of robot platforms
- **Direct Editing**: No complex nested selections required
### For System Architecture
- **Clean Separation**: Visual design vs execution logic clearly separated
- **Database Integrity**: Direct 1:1 mapping maintains relationships
- **Plugin Extensibility**: Easy integration of new robot platforms
- **Type Safety**: Complete TypeScript integration throughout
### For Development
- **Maintainable Code**: Clean component architecture with clear responsibilities
- **Performance**: Efficient rendering with proper React patterns
- **Error Handling**: Graceful degradation when plugins fail
- **Accessibility**: Built on accessible `@dnd-kit` foundation
## Modular Architecture Summary
| Module | Responsibility | Notes |
|--------|----------------|-------|
| `BlockDesigner.tsx` | Orchestration (state, save, validation, drift badges) | Thin controller after refactor |
| `ActionRegistry.ts` | Core + plugin action loading, provenance, fallback | Stateless across renders (singleton) |
| `ActionLibrary.tsx` | Categorized draggable palette | Performance-isolated |
| `StepFlow.tsx` | Sortable steps & actions, structural UI | No parameter logic |
| `PropertiesPanel.tsx` | Parameter + metadata editing (enhanced controls) | Switch + Slider integration |
## Future Enhancements
### Planned Features (Updated Roadmap)
- **Step Templates**: Reusable step patterns for common workflows
- **Visual Debugging**: Inline structural + provenance validation markers
- **Collaborative Editing**: Real-time multi-user design sessions
- **Advanced Conditionals**: Branching logic & guard editors (visual condition builder)
- **Structural Drift Granularity**: Distinguish param-value vs structural changes
- **Version Pin Diffing**: Detect plugin version upgrades vs design-pinned versions
### Integration Opportunities
- **Version Control**: Track experiment changes across iterations
- **A/B Testing**: Support for experimental variations within single design
- **Analytics Integration**: Step-level performance monitoring
- **Export Formats**: Convert to external workflow systems
This redesigned experiment designer now combines step-based structure, provenance tracking, transport-aware execution compilation, integrity hashing, and validation workflows to deliver reproducible, extensible, and transparent experimental protocols.

View File

@@ -21,9 +21,9 @@ bun run docker:up
bun db:push
bun db:seed
# Sync plugin repositories (admin only)
# This populates the plugin store with robot plugins
# from https://repo.hristudio.com
# Single command now syncs all repositories:
# - Core blocks from localhost:3000/hristudio-core
# - Robot plugins from https://repo.hristudio.com
# Start development
bun dev
@@ -89,7 +89,7 @@ Study → Experiment → Trial → Step → Action
| `bun typecheck` | TypeScript validation |
| `bun lint` | Code quality checks |
| `bun db:push` | Push schema changes |
| `bun db:seed` | Seed test data |
| `bun db:seed` | Seed data & sync repositories |
| `bun db:studio` | Open database GUI |
---
@@ -223,16 +223,16 @@ const hasRole = (role: string) => session?.user.role === role;
## 🤖 **Robot Integration**
### Core Blocks System
### Core Block System
```typescript
// Core blocks loaded from hristudio-core repository
await registry.loadCoreBlocks();
// Core blocks loaded from local repository during development
// Repository sync: localhost:3000/hristudio-core → database
// Block categories:
// Block categories (27 total blocks in 4 groups):
// - Events (4): when_trial_starts, when_participant_speaks, etc.
// - Wizard Actions (6): wizard_say, wizard_gesture, etc.
// - Control Flow (8): wait, repeat, if_condition, etc.
// - Observation (8): observe_behavior, record_audio, etc.
// - Observation (9): observe_behavior, record_audio, etc.
```
### Plugin Repository System
@@ -269,9 +269,9 @@ interface Plugin {
```
### Repository Integration
- **Live Repository**: `https://repo.hristudio.com`
- **Core Repository**: `https://core.hristudio.com`
- **Auto-sync**: Admin dashboard → Repositories → Sync
- **Robot Plugins**: `https://repo.hristudio.com` (live)
- **Core Blocks**: `localhost:3000/hristudio-core` (development)
- **Auto-sync**: Integrated into `bun db:seed` command
- **Plugin Store**: Browse → Install → Use in experiments
---
@@ -408,7 +408,7 @@ bun typecheck
3. Follow the established component patterns
4. Add proper error boundaries for new features
5. Test with multiple user roles
6. Sync plugin repositories after setup for full functionality
6. Use single `bun db:seed` for complete setup
### Code Standards
- Use TypeScript strict mode

View File

@@ -2,6 +2,94 @@
## Recent Changes Summary (February 2025)
### Experiment Designer Iteration (February 2025)
#### **Current Focus: Modularized Designer (ActionLibrary / StepFlow / PropertiesPanel / Registry) & Validation Drift Handling**
**Status**: In active iteration (not stable)
The experiment designer has been refactored into modular components (registry + library + flow + properties panel). Active iteration now targets validation drift visibility, richer parameter controls, and continued provenance/compilation integrity.
**Implemented (baseline):**
- Step-first container model (steps hold ordered actions) (stable)
- Drag-and-drop of actions into steps and reordering within steps (stable)
- Conversion utility to DB step/action structures (provenance & execution metadata flattening) (stable)
- Plugin action loading with namespaced IDs (pluginId.actionId) and provenance retention (stable)
- Execution compilation pipeline (deterministic graph + integrity hash) integrated in update mutation (stable)
**UI State (updated):**
- Compact spacing (h-6/7 inputs) and category coloring
- Dark mode variants applied (will audit accessibility)
- Category color system (may refine semantic tokens; now reused across extracted components)
- Step containers with colored left borders for type (moved into `StepFlow`)
- Drag overlays + hover states (keyboard reordering & provenance badge a11y still pending)
**Interaction Features (present / modularized):**
- Multi-level drag & drop (steps + actions) via `StepFlow`
- Direct action selection for inline parameter editing (now inside `PropertiesPanel`)
- Action tooltip (plugin/source details; provenance badges present; enrichment still planned)
- Drop zone highlighting
- Pointer sensor activation threshold (mobile/touch review pending)
**Plugin Integration (status):**
- Registry loads core + study plugin actions (version stored; pin drift resolution still pending; designer now guarantees installed study plugin actions appear once experiment + study resolved)
- Fallback actions present (unchanged)
- Param schema → UI field mapping (primitive only; now enhanced for boolean + ranged number)
- Provenance & execution metadata embedded in action instances (server persists) (stable)
- Structured error surfaces for plugin load/validation failures still TODO
**Technical Notes (updated):**
- Types expanded: provenance, execution descriptors, integrity hashing structures (stable)
- Acceptable performance; profiling after slider & drift logic integration still pending
- Accessibility review pending (keyboard reorder, focus indicators, slider a11y labels)
- Monolith split complete: `ActionRegistry.ts`, `ActionLibrary.tsx`, `StepFlow.tsx`, `PropertiesPanel.tsx`
- Need unit tests for registry + conversion + compiler + drift serializer; none present
**Key Files (current iteration - post modularization):**
- `~/components/experiments/designer/BlockDesigner.tsx` - Orchestrator (state, validation, drift)
- `~/components/experiments/designer/ActionRegistry.ts` - Registry (core + plugin + fallback)
- `~/components/experiments/designer/ActionLibrary.tsx` - Categorized draggables
- `~/components/experiments/designer/StepFlow.tsx` - Sortable steps & actions
- `~/components/experiments/designer/PropertiesPanel.tsx` - Parameter & metadata editor
**Current State Summary:**
Functional baseline with modular extraction complete. Parameter UI upgraded (boolean → Switch, ranged number → Slider). Hash drift indicator implemented (Validated / Drift / Unvalidated). Still pending: enum grouping polish, keyboard DnD accessibility, version pin drift resolution, structured plugin load error surfaces.
### Seed Script Simplification & Repository Integration (stable)
#### **Unified Repository-Based Plugin Loading**
Simplified and unified the seed scripts to load all plugins (core and robot) through the same repository sync mechanism.
**Seed Script Consolidation:**
- **Before**: 5 separate seed scripts (`seed.ts`, `seed-dev.ts`, `seed-simple.ts`, `seed-plugins.ts`, `seed-core-blocks.ts`)
- **After**: Single `seed-dev.ts` script with integrated repository sync
- **Benefits**: Consistent plugin loading, easier maintenance, real repository testing
**Core Repository Integration:**
- Core blocks now loaded from `http://localhost:3000/hristudio-core` during development
- Same repository sync logic as robot plugins from `https://repo.hristudio.com`
- Eliminates hardcoded core blocks - everything comes from repositories
- Local core repository served from `public/hristudio-core/` directory
**Simplified Setup Process:**
```bash
docker compose up -d
bun db:push
bun db:seed # Single command loads everything
```
**Repository Sync Integration:**
- Core system blocks loaded as single plugin with 4 block groups (27 total blocks)
- Robot plugins loaded individually (TurtleBot3 Burger/Waffle, NAO)
- All repositories use same sync validation and error handling
- Proper metadata storage with repository references
**Package.json Cleanup:**
- Removed `db:seed:simple`, `db:seed:plugins`, `db:seed:core-blocks`, `db:seed:full`
- Single `db:seed` command for all seeding needs
- Simplified development workflow with fewer script options
---
### Plugin Store Repository Integration
#### **Complete Repository Synchronization System**
@@ -32,9 +120,7 @@ Fixed and implemented full repository synchronization for dynamic plugin loading
- Repository names displayed for all plugins from proper metadata
- Study-scoped plugin installation working correctly
---
## Recent Changes Summary (December 2024)
## Recent Changes Summary (December 2024) (historical reference)
### Plugin System Implementation
@@ -171,7 +257,7 @@ The experiment designer was completely redesigned to integrate seamlessly with t
```
#### **Files Modified**
- `src/components/experiments/designer/EnhancedBlockDesigner.tsx` - Complete redesign
- `src/components/experiments/designer/BlockDesigner.tsx` - Current iterative version
- `src/components/ui/data-table.tsx` - Fixed control heights
- `src/components/experiments/experiments-data-table.tsx` - Fixed select styling
- `src/components/participants/participants-data-table.tsx` - Fixed select styling
@@ -279,24 +365,24 @@ The overall system theme was too monochromatic with insufficient color personali
---
### Current Status
### Current Status (Deprecated Section - To Be Rewritten)
#### **Completed**
- Complete experiment designer redesign with unified components
- (Remove) Designer not yet in a stable/complete state
- All data table control styling standardized
- System theme enhanced with better colors
- Breadcrumb navigation completely fixed
- Technical debt resolved
#### **Production Ready**
- All TypeScript errors resolved
- TypeScript surface improving; more types to add for provenance/execution
- Consistent styling throughout application
- Proper error handling and user feedback
- Excellent dark mode support
- Mobile/tablet friendly drag and drop
#### **Improvements Achieved**
- **Visual Consistency**: Complete - All components now use unified design system
- Visual consistency improved; still refactoring designer component size
- **User Experience**: Significant improvement in navigation and usability
- **Code Quality**: Clean, maintainable code with proper patterns
- **Performance**: Optimized drag and drop with better collision detection
@@ -304,7 +390,7 @@ The overall system theme was too monochromatic with insufficient color personali
---
### Core Block System Implementation (February 2024)
### Core Block System Implementation (February 2024) (archived)
**Complete documentation available in [`docs/core-blocks-system.md`](core-blocks-system.md)**
@@ -388,26 +474,37 @@ hristudio-core/
---
### Documentation Status
### Documentation Status (Updated With Provenance, Compilation, Pending Modularization)
All changes have been documented and the codebase is ready for production deployment. The system now features:
1. **Complete Plugin Architecture**: Both core blocks and robot actions loaded from repositories
2. **Working Repository Sync**: Live synchronization from `https://repo.hristudio.com`
3. **Proper Installation States**: Plugin store correctly shows installed vs available plugins
4. **TypeScript Compliance**: All unsafe `any` types replaced with proper typing
5. **Admin Access**: Full administrator role and permission system operational
1. Unified repository loading in place (integrity hash now captured; version drift alerts pending)
2. **Simplified Seed Scripts**: Single command setup with automatic repository synchronization
3. **Local Core Repository**: Core blocks served from `public/hristudio-core/` during development
4. **Working Repository Sync**: Live synchronization from `https://repo.hristudio.com` for robot plugins
5. **Proper Installation States**: Plugin store correctly shows installed vs available plugins
6. **TypeScript Compliance**: All unsafe `any` types replaced with proper typing
7. **Admin Access**: Full administrator role and permission system operational
**Core Documentation Files:**
**Core Documentation Files (Pending Updates):**
- [`docs/core-blocks-system.md`](core-blocks-system.md) - Complete core blocks implementation guide
- [`docs/plugin-system-implementation-guide.md`](plugin-system-implementation-guide.md) - Robot plugin system guide
- [`docs/work_in_progress.md`](work_in_progress.md) - Current development status
- [`docs/work_in_progress.md`](work_in_progress.md) - Current development status (now includes provenance & compiler updates)
**Production Readiness:**
- ✅ All TypeScript errors resolved (except documentation LaTeX)
-Repository synchronization fully functional
**Readiness Caveats:**
- Pending UI: provenance badges (partially done), re-validation triggers (implemented), drift indicator (TODO), modular split (TODO), parameter control upgrades (TODO), version drift resolution (TODO)
-Simplified seed scripts with unified repository loading
- ✅ Core repository integration via localhost during development
- ✅ Repository synchronization fully functional for both core and robot plugins
- ✅ Plugin store with proper installation state detection
- ✅ Admin dashboard with repository management
- ✅ Complete user authentication and authorization
- ✅ Study-scoped plugin installation working
- ✅ 98% feature completion maintained
- ✅ 98% feature completion maintained
**Development Workflow (Stable Pieces):**
- ✅ Single `bun db:seed` command for complete setup
- ✅ Core blocks loaded from local repository structure
- ✅ Robot plugins synchronized from live repository
- ✅ Consistent plugin architecture across all block types
- ✅ Real repository testing during development

View File

@@ -11,10 +11,6 @@
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "bun scripts/seed-dev.ts",
"db:seed:simple": "bun scripts/seed-simple.ts",
"db:seed:plugins": "bun scripts/seed-plugins.ts",
"db:seed:core-blocks": "bun scripts/seed-core-blocks.ts",
"db:seed:full": "bun scripts/seed.ts",
"dev": "next dev --turbo",
"docker:up": "colima start && docker-compose up -d",
"docker:down": "docker-compose down && colima stop",
@@ -46,6 +42,7 @@
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",

1
robot-plugins Submodule

Submodule robot-plugins added at 334dc68a22

View File

@@ -1,589 +0,0 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { eq } from "drizzle-orm";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:password@localhost:5140/hristudio";
const client = postgres(connectionString);
const db = drizzle(client, { schema });
async function seedCoreRepository() {
console.log("🏗️ Seeding core system repository...");
// Check if core repository already exists
const existingCoreRepo = await db
.select()
.from(schema.pluginRepositories)
.where(eq(schema.pluginRepositories.url, "https://core.hristudio.com"));
if (existingCoreRepo.length > 0) {
console.log("⚠️ Core repository already exists, skipping");
return;
}
// Get the first user to use as creator
const users = await db.select().from(schema.users);
const adminUser =
users.find((u) => u.email?.includes("sarah.chen")) ?? users[0];
if (!adminUser) {
console.log("⚠️ No users found. Please run basic seeding first.");
return;
}
const coreRepository = {
id: "00000000-0000-0000-0000-000000000001",
name: "HRIStudio Core System Blocks",
url: "https://core.hristudio.com",
description:
"Essential system blocks for experiment design including events, control flow, wizard actions, and logic operations",
trustLevel: "official" as const,
isEnabled: true,
isOfficial: true,
lastSyncAt: new Date(),
syncStatus: "completed" as const,
syncError: null,
metadata: {
apiVersion: "1.0",
pluginApiVersion: "1.0",
categories: ["core", "wizard", "control", "logic", "events"],
compatibility: {
hristudio: { min: "0.1.0", recommended: "0.1.0" },
},
isCore: true,
},
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date(),
createdBy: adminUser.id,
};
await db.insert(schema.pluginRepositories).values([coreRepository]);
console.log("✅ Created core system repository");
}
async function seedCorePlugin() {
console.log("🧱 Seeding core system plugin...");
// Check if core plugin already exists
const existingCorePlugin = await db
.select()
.from(schema.plugins)
.where(eq(schema.plugins.name, "HRIStudio Core System"));
if (existingCorePlugin.length > 0) {
console.log("⚠️ Core plugin already exists, skipping");
return;
}
const corePlugin = {
id: "00000000-0000-0000-0000-000000000001",
robotId: null, // Core plugin doesn't need a specific robot
name: "HRIStudio Core System",
version: "1.0.0",
description:
"Essential system blocks for experiment design including events, control flow, wizard actions, and logic operations",
author: "HRIStudio Team",
repositoryUrl: "https://core.hristudio.com",
trustLevel: "official" as const,
status: "active" as const,
configurationSchema: {
type: "object",
properties: {
enableAdvancedBlocks: {
type: "boolean",
default: true,
description: "Enable advanced control flow blocks",
},
wizardInterface: {
type: "string",
enum: ["basic", "advanced"],
default: "basic",
description: "Wizard interface complexity level",
},
},
},
actionDefinitions: [
// Event Blocks
{
id: "when_trial_starts",
name: "when trial starts",
description: "Triggered when the trial begins",
category: "logic",
icon: "Play",
timeout: 0,
retryable: false,
parameterSchema: {
type: "object",
properties: {},
required: [],
},
blockType: "hat",
color: "#22c55e",
nestable: false,
},
{
id: "when_participant_speaks",
name: "when participant speaks",
description: "Triggered when participant says something",
category: "logic",
icon: "Mic",
timeout: 0,
retryable: false,
parameterSchema: {
type: "object",
properties: {
keywords: {
type: "array",
items: { type: "string" },
default: [],
description: "Optional keywords to listen for",
},
},
required: [],
},
blockType: "hat",
color: "#22c55e",
nestable: false,
},
// Wizard Actions
{
id: "wizard_say",
name: "say",
description: "Wizard speaks to participant",
category: "interaction",
icon: "Users",
timeout: 30000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
message: {
type: "string",
default: "",
description: "What should the wizard say?",
},
},
required: ["message"],
},
blockType: "action",
color: "#a855f7",
nestable: false,
},
{
id: "wizard_gesture",
name: "gesture",
description: "Wizard performs a gesture",
category: "interaction",
icon: "Users",
timeout: 10000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
type: {
type: "string",
enum: ["wave", "point", "nod", "thumbs_up", "clap"],
default: "wave",
description: "Type of gesture to perform",
},
},
required: ["type"],
},
blockType: "action",
color: "#a855f7",
nestable: false,
},
{
id: "wizard_note",
name: "take note",
description: "Wizard records an observation",
category: "sensors",
icon: "FileText",
timeout: 15000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
category: {
type: "string",
enum: [
"behavior",
"performance",
"engagement",
"technical",
"other",
],
default: "behavior",
description: "Category of observation",
},
note: {
type: "string",
default: "",
description: "Observation details",
},
},
required: ["note"],
},
blockType: "action",
color: "#f59e0b",
nestable: false,
},
// Control Flow
{
id: "wait",
name: "wait",
description: "Pause execution for specified time",
category: "logic",
icon: "Clock",
timeout: 0,
retryable: false,
parameterSchema: {
type: "object",
properties: {
seconds: {
type: "number",
minimum: 0.1,
maximum: 300,
default: 1,
description: "Time to wait in seconds",
},
},
required: ["seconds"],
},
blockType: "action",
color: "#f97316",
nestable: false,
},
{
id: "repeat",
name: "repeat",
description: "Execute contained blocks multiple times",
category: "logic",
icon: "GitBranch",
timeout: 0,
retryable: false,
parameterSchema: {
type: "object",
properties: {
times: {
type: "number",
minimum: 1,
maximum: 50,
default: 3,
description: "Number of times to repeat",
},
},
required: ["times"],
},
blockType: "control",
color: "#f97316",
nestable: true,
},
{
id: "if_condition",
name: "if",
description: "Conditional execution based on conditions",
category: "logic",
icon: "GitBranch",
timeout: 0,
retryable: false,
parameterSchema: {
type: "object",
properties: {
condition: {
type: "string",
enum: [
"participant_speaks",
"time_elapsed",
"wizard_signal",
"custom_condition",
],
default: "participant_speaks",
description: "Condition to evaluate",
},
value: {
type: "string",
default: "",
description: "Value to compare against (if applicable)",
},
},
required: ["condition"],
},
blockType: "control",
color: "#f97316",
nestable: true,
},
{
id: "parallel",
name: "do together",
description: "Execute multiple blocks simultaneously",
category: "logic",
icon: "Layers",
timeout: 0,
retryable: false,
parameterSchema: {
type: "object",
properties: {},
required: [],
},
blockType: "control",
color: "#f97316",
nestable: true,
},
// Data Collection
{
id: "start_recording",
name: "start recording",
description: "Begin recording specified data streams",
category: "sensors",
icon: "Circle",
timeout: 5000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
streams: {
type: "array",
items: {
type: "string",
enum: [
"video",
"audio",
"screen",
"robot_data",
"wizard_actions",
],
},
default: ["video", "audio"],
description: "Data streams to record",
},
quality: {
type: "string",
enum: ["low", "medium", "high"],
default: "medium",
description: "Recording quality",
},
},
required: ["streams"],
},
blockType: "action",
color: "#dc2626",
nestable: false,
},
{
id: "stop_recording",
name: "stop recording",
description: "Stop recording and save data",
category: "sensors",
icon: "Square",
timeout: 10000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
save_location: {
type: "string",
default: "default",
description: "Where to save the recording",
},
},
required: [],
},
blockType: "action",
color: "#dc2626",
nestable: false,
},
{
id: "mark_event",
name: "mark event",
description: "Add a timestamped marker to the data",
category: "sensors",
icon: "MapPin",
timeout: 1000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
event_name: {
type: "string",
default: "",
description: "Name of the event to mark",
},
description: {
type: "string",
default: "",
description: "Optional event description",
},
},
required: ["event_name"],
},
blockType: "action",
color: "#f59e0b",
nestable: false,
},
// Study Flow Control
{
id: "show_instructions",
name: "show instructions",
description: "Display instructions to the participant",
category: "interaction",
icon: "FileText",
timeout: 60000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
title: {
type: "string",
default: "Instructions",
description: "Instruction title",
},
content: {
type: "string",
default: "",
description: "Instruction content (supports markdown)",
},
require_acknowledgment: {
type: "boolean",
default: true,
description: "Require participant to acknowledge reading",
},
},
required: ["content"],
},
blockType: "action",
color: "#3b82f6",
nestable: false,
},
{
id: "collect_response",
name: "collect response",
description: "Collect a response from the participant",
category: "sensors",
icon: "MessageCircle",
timeout: 120000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
question: {
type: "string",
default: "",
description: "Question to ask the participant",
},
response_type: {
type: "string",
enum: ["text", "scale", "choice", "voice"],
default: "text",
description: "Type of response to collect",
},
options: {
type: "array",
items: { type: "string" },
default: [],
description: "Options for choice responses",
},
},
required: ["question"],
},
blockType: "action",
color: "#8b5cf6",
nestable: false,
},
],
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date(),
};
await db.insert(schema.plugins).values([corePlugin]);
console.log("✅ Created core system plugin");
}
async function seedCoreStudyPlugins() {
console.log("🔗 Installing core plugin in all studies...");
// Get all studies
const studies = await db.select().from(schema.studies);
if (studies.length === 0) {
console.log("⚠️ No studies found. Please run basic seeding first.");
return;
}
// Check if core plugin installations already exist
const existingInstallation = await db
.select()
.from(schema.studyPlugins)
.where(
eq(schema.studyPlugins.pluginId, "00000000-0000-0000-0000-000000000001"),
);
if (existingInstallation.length > 0) {
console.log("⚠️ Core plugin already installed in studies, skipping");
return;
}
const coreInstallations = studies.map((study, index) => ({
id: `00000000-0000-0000-0000-00000000000${index + 2}`, // Start from 2 to avoid conflicts
studyId: study.id,
pluginId: "00000000-0000-0000-0000-000000000001",
configuration: {
enableAdvancedBlocks: true,
wizardInterface: "advanced",
recordingDefaults: {
video: true,
audio: true,
quality: "high",
},
},
installedAt: new Date("2024-01-01T00:00:00"),
installedBy: study.createdBy,
}));
await db.insert(schema.studyPlugins).values(coreInstallations);
console.log(`✅ Installed core plugin in ${studies.length} studies`);
}
async function main() {
try {
console.log("🏗️ HRIStudio Core System Seeding Started");
console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@"));
await seedCoreRepository();
await seedCorePlugin();
await seedCoreStudyPlugins();
console.log("✅ Core system seeding completed successfully!");
console.log("\n📋 Core System Summary:");
console.log(" 🏗️ Core Repository: 1 (HRIStudio Core System)");
console.log(" 🧱 Core Plugin: 1 (with 15 essential blocks)");
console.log(" 🔗 Study Installations: Installed in all studies");
console.log("\n🧱 Core Blocks Available:");
console.log(" 🎯 Events: when trial starts, when participant speaks");
console.log(" 🧙 Wizard: say, gesture, take note");
console.log(" ⏳ Control: wait, repeat, if condition, do together");
console.log(" 📊 Data: start/stop recording, mark event");
console.log(" 📋 Study: show instructions, collect response");
console.log("\n🎨 Block Designer Integration:");
console.log(" • All core blocks now come from the plugin system");
console.log(" • Consistent with robot plugin architecture");
console.log(" • Easy to extend and version core functionality");
console.log(" • Unified block management across all categories");
console.log("\n🚀 Ready to test unified block system!");
} catch (error) {
console.error("❌ Core system seeding failed:", error);
process.exit(1);
} finally {
await client.end();
}
}
if (require.main === module) {
void main();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,690 +0,0 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../src/server/db/schema";
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:password@localhost:5140/hristudio";
const client = postgres(connectionString);
const db = drizzle(client, { schema });
async function seedRobots() {
console.log("🤖 Seeding robots...");
// Check if robots already exist
const existingRobots = await db.select().from(schema.robots);
if (existingRobots.length > 0) {
console.log(
`⚠️ ${existingRobots.length} robots already exist, skipping robot seeding`,
);
return;
}
const robots = [
{
id: "31234567-89ab-cdef-0123-456789abcde1",
name: "TurtleBot3 Burger",
manufacturer: "ROBOTIS",
model: "TurtleBot3 Burger",
description:
"A compact, affordable, programmable, ROS2-based mobile robot for education and research",
capabilities: [
"differential_drive",
"lidar",
"imu",
"odometry",
"autonomous_navigation",
],
communicationProtocol: "ros2" as const,
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-01T00:00:00"),
},
{
id: "31234567-89ab-cdef-0123-456789abcde2",
name: "NAO Humanoid Robot",
manufacturer: "SoftBank Robotics",
model: "NAO v6",
description:
"Autonomous, programmable humanoid robot designed for education, research, and human-robot interaction studies",
capabilities: [
"bipedal_walking",
"speech_synthesis",
"speech_recognition",
"computer_vision",
"gestures",
"led_control",
"touch_sensors",
],
communicationProtocol: "custom" as const,
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-01T00:00:00"),
},
{
id: "31234567-89ab-cdef-0123-456789abcde3",
name: "TurtleBot3 Waffle Pi",
manufacturer: "ROBOTIS",
model: "TurtleBot3 Waffle Pi",
description:
"Extended TurtleBot3 platform with additional sensors and computing power for advanced research applications",
capabilities: [
"differential_drive",
"lidar",
"imu",
"odometry",
"camera",
"manipulation",
"autonomous_navigation",
],
communicationProtocol: "ros2" as const,
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-01T00:00:00"),
},
];
await db.insert(schema.robots).values(robots);
console.log(`✅ Created ${robots.length} robots`);
}
async function seedPluginRepositories() {
console.log("📦 Seeding plugin repositories...");
// Check if repositories already exist
const existingRepos = await db.select().from(schema.pluginRepositories);
if (existingRepos.length > 0) {
console.log(
`⚠️ ${existingRepos.length} plugin repositories already exist, skipping`,
);
return;
}
// Get the first user to use as creator
const users = await db.select().from(schema.users);
const adminUser =
users.find((u) => u.email?.includes("sean@soconnor.dev")) ?? users[0];
if (!adminUser) {
console.log("⚠️ No users found. Please run basic seeding first.");
return;
}
const repositories = [
{
id: "41234567-89ab-cdef-0123-456789abcde1",
name: "HRIStudio Official Robot Plugins",
url: "https://repo.hristudio.com",
description:
"Official collection of robot plugins maintained by the HRIStudio team",
trustLevel: "official" as const,
isEnabled: true,
isOfficial: true,
lastSyncAt: new Date("2024-01-10T12:00:00"),
syncStatus: "completed" as const,
syncError: null,
metadata: {
apiVersion: "1.0",
pluginApiVersion: "1.0",
categories: [
"mobile-robots",
"humanoid-robots",
"manipulators",
"drones",
],
compatibility: {
hristudio: { min: "0.1.0", recommended: "0.1.0" },
ros2: { distributions: ["humble", "iron"], recommended: "iron" },
},
},
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-10T12:00:00"),
createdBy: adminUser.id,
},
];
await db.insert(schema.pluginRepositories).values(repositories);
console.log(`✅ Created ${repositories.length} plugin repositories`);
}
async function seedPlugins() {
console.log("🔌 Seeding robot plugins...");
// Check if plugins already exist
const existingPlugins = await db.select().from(schema.plugins);
if (existingPlugins.length > 0) {
console.log(
`⚠️ ${existingPlugins.length} plugins already exist, skipping plugin seeding`,
);
return;
}
const plugins = [
{
id: "51234567-89ab-cdef-0123-456789abcde1",
robotId: "31234567-89ab-cdef-0123-456789abcde1",
name: "TurtleBot3 Burger",
version: "2.0.0",
description:
"A compact, affordable, programmable, ROS2-based mobile robot for education and research",
author: "ROBOTIS",
repositoryUrl: "https://repo.hristudio.com",
trustLevel: "official" as const,
status: "active" as const,
configurationSchema: {
type: "object",
properties: {
namespace: { type: "string", default: "turtlebot3" },
topics: {
type: "object",
properties: {
cmd_vel: { type: "string", default: "/cmd_vel" },
odom: { type: "string", default: "/odom" },
scan: { type: "string", default: "/scan" },
},
},
},
},
actionDefinitions: [
{
id: "move_velocity",
name: "Set Velocity",
description: "Control the robot's linear and angular velocity",
category: "movement",
icon: "navigation",
timeout: 30000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
linear: {
type: "number",
minimum: -0.22,
maximum: 0.22,
default: 0,
description: "Forward/backward velocity in m/s",
},
angular: {
type: "number",
minimum: -2.84,
maximum: 2.84,
default: 0,
description: "Rotational velocity in rad/s",
},
},
required: ["linear", "angular"],
},
ros2: {
messageType: "geometry_msgs/msg/Twist",
topic: "/cmd_vel",
payloadMapping: {
type: "transform",
transformFn: "transformToTwist",
},
},
},
{
id: "move_to_pose",
name: "Navigate to Position",
description:
"Navigate to a specific position on the map using autonomous navigation",
category: "movement",
icon: "target",
timeout: 120000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
x: {
type: "number",
default: 0,
description: "X coordinate in meters",
},
y: {
type: "number",
default: 0,
description: "Y coordinate in meters",
},
theta: {
type: "number",
default: 0,
description: "Final orientation in radians",
},
},
required: ["x", "y", "theta"],
},
ros2: {
messageType: "geometry_msgs/msg/PoseStamped",
action: "/navigate_to_pose",
payloadMapping: {
type: "transform",
transformFn: "transformToPoseStamped",
},
},
},
{
id: "stop_robot",
name: "Stop Robot",
description: "Immediately stop all robot movement",
category: "movement",
icon: "square",
timeout: 5000,
retryable: false,
parameterSchema: {
type: "object",
properties: {},
required: [],
},
ros2: {
messageType: "geometry_msgs/msg/Twist",
topic: "/cmd_vel",
payloadMapping: {
type: "static",
payload: {
linear: { x: 0.0, y: 0.0, z: 0.0 },
angular: { x: 0.0, y: 0.0, z: 0.0 },
},
},
},
},
],
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-10T12:00:00"),
},
{
id: "51234567-89ab-cdef-0123-456789abcde2",
robotId: "31234567-89ab-cdef-0123-456789abcde2",
name: "NAO Humanoid Robot",
version: "1.0.0",
description:
"Autonomous, programmable humanoid robot designed for education, research, and human-robot interaction studies",
author: "SoftBank Robotics",
repositoryUrl: "https://repo.hristudio.com",
trustLevel: "verified" as const,
status: "active" as const,
configurationSchema: {
type: "object",
properties: {
ip: { type: "string", default: "nao.local" },
port: { type: "number", default: 9559 },
modules: {
type: "array",
default: [
"ALMotion",
"ALTextToSpeech",
"ALAnimationPlayer",
"ALLeds",
],
},
},
},
actionDefinitions: [
{
id: "say_text",
name: "Say Text",
description: "Make the robot speak using text-to-speech",
category: "interaction",
icon: "volume-2",
timeout: 15000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
text: {
type: "string",
default: "Hello, I am NAO!",
description: "Text to speak",
},
volume: {
type: "number",
minimum: 0.1,
maximum: 1.0,
default: 0.7,
description: "Speech volume (0.1 to 1.0)",
},
},
required: ["text"],
},
naoqi: {
module: "ALTextToSpeech",
method: "say",
parameters: ["text"],
},
},
{
id: "play_animation",
name: "Play Animation",
description: "Play a predefined animation or gesture",
category: "interaction",
icon: "zap",
timeout: 20000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
animation: {
type: "string",
enum: ["Hello", "Goodbye", "Excited", "Thinking"],
default: "Hello",
description: "Animation to play",
},
},
required: ["animation"],
},
naoqi: {
module: "ALAnimationPlayer",
method: "run",
parameters: ["animations/Stand/Gestures/{animation}"],
},
},
{
id: "walk_to_position",
name: "Walk to Position",
description:
"Walk to a specific position relative to current location",
category: "movement",
icon: "footprints",
timeout: 30000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
x: {
type: "number",
minimum: -2.0,
maximum: 2.0,
default: 0.5,
description: "Forward distance in meters",
},
y: {
type: "number",
minimum: -1.0,
maximum: 1.0,
default: 0.0,
description: "Sideways distance in meters (left is positive)",
},
theta: {
type: "number",
minimum: -3.14159,
maximum: 3.14159,
default: 0.0,
description: "Turn angle in radians",
},
},
required: ["x", "y", "theta"],
},
naoqi: {
module: "ALMotion",
method: "walkTo",
parameters: ["x", "y", "theta"],
},
},
],
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-10T12:00:00"),
},
{
id: "51234567-89ab-cdef-0123-456789abcde3",
robotId: "31234567-89ab-cdef-0123-456789abcde3",
name: "TurtleBot3 Waffle Pi",
version: "2.0.0",
description:
"Extended TurtleBot3 platform with additional sensors and computing power for advanced research applications",
author: "ROBOTIS",
repositoryUrl: "https://repo.hristudio.com",
trustLevel: "official" as const,
status: "active" as const,
configurationSchema: {
type: "object",
properties: {
namespace: { type: "string", default: "turtlebot3" },
topics: {
type: "object",
properties: {
cmd_vel: { type: "string", default: "/cmd_vel" },
odom: { type: "string", default: "/odom" },
scan: { type: "string", default: "/scan" },
camera: { type: "string", default: "/camera/image_raw" },
},
},
},
},
actionDefinitions: [
{
id: "move_velocity",
name: "Set Velocity",
description: "Control the robot's linear and angular velocity",
category: "movement",
icon: "navigation",
timeout: 30000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
linear: {
type: "number",
minimum: -0.26,
maximum: 0.26,
default: 0,
description: "Forward/backward velocity in m/s",
},
angular: {
type: "number",
minimum: -1.82,
maximum: 1.82,
default: 0,
description: "Rotational velocity in rad/s",
},
},
required: ["linear", "angular"],
},
ros2: {
messageType: "geometry_msgs/msg/Twist",
topic: "/cmd_vel",
payloadMapping: {
type: "transform",
transformFn: "transformToTwist",
},
},
},
{
id: "capture_image",
name: "Capture Image",
description: "Capture an image from the robot's camera",
category: "sensors",
icon: "camera",
timeout: 10000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
filename: {
type: "string",
default: "image_{timestamp}.jpg",
description: "Filename for the captured image",
},
quality: {
type: "integer",
minimum: 1,
maximum: 100,
default: 85,
description: "JPEG quality (1-100)",
},
},
required: ["filename"],
},
ros2: {
messageType: "sensor_msgs/msg/Image",
topic: "/camera/image_raw",
payloadMapping: {
type: "transform",
transformFn: "captureAndSaveImage",
},
},
},
{
id: "scan_environment",
name: "Scan Environment",
description:
"Perform a 360-degree scan of the environment using LIDAR",
category: "sensors",
icon: "radar",
timeout: 15000,
retryable: true,
parameterSchema: {
type: "object",
properties: {
duration: {
type: "number",
minimum: 1.0,
maximum: 10.0,
default: 3.0,
description: "Scan duration in seconds",
},
save_data: {
type: "boolean",
default: true,
description: "Save scan data to file",
},
},
required: ["duration"],
},
ros2: {
messageType: "sensor_msgs/msg/LaserScan",
topic: "/scan",
payloadMapping: {
type: "transform",
transformFn: "collectLaserScan",
},
},
},
],
createdAt: new Date("2024-01-01T00:00:00"),
updatedAt: new Date("2024-01-10T12:00:00"),
},
];
await db.insert(schema.plugins).values(plugins);
console.log(`✅ Created ${plugins.length} robot plugins`);
}
async function seedStudyPlugins() {
console.log("🔌 Seeding study plugin installations...");
// Check if study plugins already exist
const existingStudyPlugins = await db.select().from(schema.studyPlugins);
if (existingStudyPlugins.length > 0) {
console.log(
`⚠️ ${existingStudyPlugins.length} study plugin installations already exist, skipping`,
);
return;
}
// Get study IDs from the existing studies
const studies = await db.select().from(schema.studies);
if (studies.length === 0) {
console.log("⚠️ No studies found. Please run basic seeding first.");
return;
}
const studyPlugins = [
{
id: "61234567-89ab-cdef-0123-456789abcde1",
studyId: studies[0]!.id, // First study (navigation)
pluginId: "51234567-89ab-cdef-0123-456789abcde1", // TurtleBot3 Burger
configuration: {
namespace: "navigation_study",
topics: {
cmd_vel: "/navigation_study/cmd_vel",
odom: "/navigation_study/odom",
scan: "/navigation_study/scan",
},
max_speed: 0.15,
safety_distance: 0.3,
},
installedAt: new Date("2024-01-05T10:00:00"),
installedBy: studies[0]!.createdBy,
},
{
id: "61234567-89ab-cdef-0123-456789abcde2",
studyId: studies[1]?.id ?? studies[0]!.id, // Second study (social robots) or fallback
pluginId: "51234567-89ab-cdef-0123-456789abcde2", // NAO Humanoid
configuration: {
ip: "192.168.1.100",
port: 9559,
modules: [
"ALMotion",
"ALTextToSpeech",
"ALAnimationPlayer",
"ALLeds",
"ALSpeechRecognition",
],
language: "English",
speech_speed: 100,
volume: 0.8,
},
installedAt: new Date("2024-01-05T11:00:00"),
installedBy: studies[1]?.createdBy ?? studies[0]!.createdBy,
},
{
id: "61234567-89ab-cdef-0123-456789abcde3",
studyId: studies[0]!.id, // First study also gets Waffle for advanced tasks
pluginId: "51234567-89ab-cdef-0123-456789abcde3", // TurtleBot3 Waffle
configuration: {
namespace: "advanced_navigation",
topics: {
cmd_vel: "/advanced_navigation/cmd_vel",
odom: "/advanced_navigation/odom",
scan: "/advanced_navigation/scan",
camera: "/advanced_navigation/camera/image_raw",
},
max_speed: 0.2,
camera_enabled: true,
lidar_enabled: true,
},
installedAt: new Date("2024-01-05T12:00:00"),
installedBy: studies[0]!.createdBy,
},
];
await db.insert(schema.studyPlugins).values(studyPlugins);
console.log(`✅ Created ${studyPlugins.length} study plugin installations`);
}
async function main() {
try {
console.log("🔌 HRIStudio Plugin System Seeding Started");
console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@"));
await seedRobots();
await seedPluginRepositories();
await seedPlugins();
await seedStudyPlugins();
console.log("✅ Plugin system seeding completed successfully!");
console.log("\n📋 Plugin System Summary:");
console.log(" 🤖 Robots: 3 (TurtleBot3 Burger, NAO, TurtleBot3 Waffle)");
console.log(" 📦 Plugin Repositories: 1 (official HRIStudio repo)");
console.log(" 🔌 Robot Plugins: 3 (with complete action definitions)");
console.log(" 📱 Study Plugin Installations: 3 (active configurations)");
console.log("\n🎯 Plugin Actions Available:");
console.log(
" 📍 TurtleBot3 Burger: 3 actions (movement, navigation, stop)",
);
console.log(" 🤖 NAO Humanoid: 3 actions (speech, animations, walking)");
console.log(" 📊 TurtleBot3 Waffle: 3 actions (movement, camera, LIDAR)");
console.log("\n🧪 Test Plugin Integration:");
console.log(" 1. Navigate to any experiment designer");
console.log(" 2. Check 'Robot' category in block library");
console.log(" 3. Plugin actions should appear alongside core blocks");
console.log(" 4. Actions are configured per study installation");
console.log("\n🚀 Ready to test robot plugin integration!");
} catch (error) {
console.error("❌ Plugin seeding failed:", error);
process.exit(1);
} finally {
await client.end();
}
}
if (require.main === module) {
void main();
}

View File

@@ -1,729 +0,0 @@
#!/usr/bin/env tsx
/**
* HRIStudio Database Seed Script (Simplified)
*
* This script seeds the database with comprehensive test data for the experiment designer,
* using raw SQL to avoid NextAuth import issues.
*/
import bcrypt from "bcryptjs";
import postgres from "postgres";
// Database connection
const connectionString =
process.env.DATABASE_URL ??
"postgresql://postgres:postgres@localhost:5140/hristudio";
const sql = postgres(connectionString);
console.log("🌱 Starting HRIStudio database seeding...");
async function clearDatabase() {
console.log("🧹 Clearing existing data...");
// Delete in reverse dependency order
await sql`DELETE FROM hs_trial_event`;
await sql`DELETE FROM hs_action`;
await sql`DELETE FROM hs_step`;
await sql`DELETE FROM hs_trial`;
await sql`DELETE FROM hs_participant`;
await sql`DELETE FROM hs_experiment`;
await sql`DELETE FROM hs_study_member`;
await sql`DELETE FROM hs_study`;
await sql`DELETE FROM hs_user_system_role`;
await sql`DELETE FROM hs_user`;
console.log("✅ Database cleared");
}
async function seedUsers() {
console.log("👥 Seeding users...");
// Hash password "password123" for all test users
const hashedPassword = await bcrypt.hash("password123", 12);
const users = [
{
id: "550e8400-e29b-41d4-a716-446655440001",
name: "Dr. Sarah Chen",
email: "sarah.chen@university.edu",
emailVerified: new Date(),
password: hashedPassword,
},
{
id: "550e8400-e29b-41d4-a716-446655440002",
name: "Dr. Michael Rodriguez",
email: "m.rodriguez@research.org",
emailVerified: new Date(),
password: hashedPassword,
},
{
id: "550e8400-e29b-41d4-a716-446655440003",
name: "Emma Thompson",
email: "emma.thompson@university.edu",
emailVerified: new Date(),
password: hashedPassword,
},
{
id: "550e8400-e29b-41d4-a716-446655440004",
name: "Dr. James Wilson",
email: "james.wilson@university.edu",
emailVerified: new Date(),
password: hashedPassword,
},
];
for (const user of users) {
await sql`
INSERT INTO hs_user (id, name, email, email_verified, password, created_at, updated_at)
VALUES (${user.id}, ${user.name}, ${user.email}, ${user.emailVerified}, ${user.password}, NOW(), NOW())
`;
}
// Add user roles
const userRoles = [
{
userId: "550e8400-e29b-41d4-a716-446655440001",
role: "administrator",
},
{
userId: "550e8400-e29b-41d4-a716-446655440002",
role: "researcher",
},
{
userId: "550e8400-e29b-41d4-a716-446655440003",
role: "wizard",
},
{
userId: "550e8400-e29b-41d4-a716-446655440004",
role: "observer",
},
];
for (const userRole of userRoles) {
await sql`
INSERT INTO hs_user_system_role (user_id, role, granted_at)
VALUES (${userRole.userId}, ${userRole.role}, NOW())
`;
}
console.log(`✅ Created ${users.length} users with roles`);
}
async function seedStudies() {
console.log("📚 Seeding studies...");
const studies = [
{
id: "650e8400-e29b-41d4-a716-446655440001",
name: "Robot Navigation Assistance Study",
description:
"Investigating how robots can effectively assist humans with indoor navigation tasks using multimodal interaction.",
institution: "MIT Computer Science",
irbProtocolNumber: "IRB-2024-001",
status: "active",
createdBy: "550e8400-e29b-41d4-a716-446655440002",
metadata: {
duration: "6 months",
targetParticipants: 50,
robotPlatform: "TurtleBot3",
environment: "Indoor office building",
},
},
{
id: "650e8400-e29b-41d4-a716-446655440002",
name: "Social Robot Interaction Patterns",
description:
"Exploring how different personality traits in robots affect human-robot collaboration in workplace settings.",
institution: "Stanford HCI Lab",
irbProtocolNumber: "IRB-2024-002",
status: "draft",
createdBy: "550e8400-e29b-41d4-a716-446655440002",
metadata: {
duration: "4 months",
targetParticipants: 30,
robotPlatform: "Pepper",
environment: "Office collaboration space",
},
},
{
id: "650e8400-e29b-41d4-a716-446655440003",
name: "Elderly Care Assistant Robot Study",
description:
"Evaluating the effectiveness of companion robots in assisted living facilities for elderly residents.",
institution: "MIT Computer Science",
irbProtocolNumber: "IRB-2024-003",
status: "completed",
createdBy: "550e8400-e29b-41d4-a716-446655440001",
metadata: {
duration: "8 months",
targetParticipants: 25,
robotPlatform: "NAO",
environment: "Assisted living facility",
},
},
];
for (const study of studies) {
await sql`
INSERT INTO hs_study (id, name, description, institution, irb_protocol, status, created_by, metadata, created_at, updated_at)
VALUES (${study.id}, ${study.name}, ${study.description}, ${study.institution}, ${study.irbProtocolNumber}, ${study.status}, ${study.createdBy}, ${JSON.stringify(study.metadata)}, NOW(), NOW())
`;
}
// Add study members
const studyMembers = [
// Navigation Study Team
{
studyId: "650e8400-e29b-41d4-a716-446655440001",
userId: "550e8400-e29b-41d4-a716-446655440002",
role: "owner",
},
{
studyId: "650e8400-e29b-41d4-a716-446655440001",
userId: "550e8400-e29b-41d4-a716-446655440003",
role: "wizard",
},
{
studyId: "650e8400-e29b-41d4-a716-446655440001",
userId: "550e8400-e29b-41d4-a716-446655440004",
role: "observer",
},
// Social Robots Study Team
{
studyId: "650e8400-e29b-41d4-a716-446655440002",
userId: "550e8400-e29b-41d4-a716-446655440002",
role: "owner",
},
{
studyId: "650e8400-e29b-41d4-a716-446655440002",
userId: "550e8400-e29b-41d4-a716-446655440001",
role: "researcher",
},
// Elderly Care Study Team
{
studyId: "650e8400-e29b-41d4-a716-446655440003",
userId: "550e8400-e29b-41d4-a716-446655440001",
role: "owner",
},
];
for (const member of studyMembers) {
await sql`
INSERT INTO hs_study_member (study_id, user_id, role, joined_at)
VALUES (${member.studyId}, ${member.userId}, ${member.role}, NOW())
`;
}
console.log(`✅ Created ${studies.length} studies with team members`);
}
async function seedExperiments() {
console.log("🧪 Seeding experiments...");
const experiments = [
{
id: "750e8400-e29b-41d4-a716-446655440001",
studyId: "650e8400-e29b-41d4-a716-446655440001",
name: "Baseline Navigation Task",
description:
"Participants navigate independently without robot assistance to establish baseline performance metrics.",
version: 1,
status: "ready",
estimatedDuration: 15,
createdBy: "550e8400-e29b-41d4-a716-446655440002",
metadata: {
condition: "control",
environment: "Building A, Floor 2",
equipment: ["motion capture", "eye tracker"],
instructions: "Find the conference room using only building signs",
},
},
{
id: "750e8400-e29b-41d4-a716-446655440002",
studyId: "650e8400-e29b-41d4-a716-446655440001",
name: "Robot-Assisted Navigation",
description:
"Participants navigate with robot providing verbal and gestural guidance to test effectiveness of robot assistance.",
version: 2,
status: "testing",
estimatedDuration: 20,
createdBy: "550e8400-e29b-41d4-a716-446655440002",
metadata: {
condition: "robot_assistance",
environment: "Building A, Floor 2",
equipment: ["motion capture", "eye tracker", "TurtleBot3"],
instructions: "Follow robot guidance to find the conference room",
},
},
{
id: "750e8400-e29b-41d4-a716-446655440003",
studyId: "650e8400-e29b-41d4-a716-446655440002",
name: "Robot Personality Variants",
description:
"Testing different robot personality types (friendly, professional, neutral) in collaborative tasks.",
version: 1,
status: "draft",
estimatedDuration: 30,
createdBy: "550e8400-e29b-41d4-a716-446655440002",
metadata: {
condition: "personality_comparison",
personalities: ["friendly", "professional", "neutral"],
tasks: ["document review", "scheduling", "problem solving"],
},
},
{
id: "750e8400-e29b-41d4-a716-446655440004",
studyId: "650e8400-e29b-41d4-a716-446655440003",
name: "Daily Companion Interaction",
description:
"Evaluating robot as daily companion for elderly residents including conversation and activity reminders.",
version: 3,
status: "ready",
estimatedDuration: 45,
createdBy: "550e8400-e29b-41d4-a716-446655440001",
metadata: {
condition: "companion_interaction",
activities: ["conversation", "medication reminder", "exercise prompts"],
duration_days: 14,
},
},
];
for (const experiment of experiments) {
await sql`
INSERT INTO hs_experiment (id, study_id, name, description, version, status, estimated_duration, created_by, metadata, created_at, updated_at)
VALUES (${experiment.id}, ${experiment.studyId}, ${experiment.name}, ${experiment.description}, ${experiment.version}, ${experiment.status}, ${experiment.estimatedDuration}, ${experiment.createdBy}, ${JSON.stringify(experiment.metadata)}, NOW(), NOW())
`;
}
console.log(`✅ Created ${experiments.length} experiments`);
}
async function seedStepsAndActions() {
console.log("📋 Seeding experiment steps and actions...");
// Baseline Navigation Experiment Steps
const steps = [
{
id: "850e8400-e29b-41d4-a716-446655440001",
experimentId: "750e8400-e29b-41d4-a716-446655440001",
name: "Welcome & Consent",
description:
"Greet participant, explain study, and obtain informed consent",
type: "wizard",
orderIndex: 0,
durationEstimate: 300,
required: true,
conditions: {
environment: "lab_room",
setup: "consent_forms_ready",
},
},
{
id: "850e8400-e29b-41d4-a716-446655440002",
experimentId: "750e8400-e29b-41d4-a716-446655440001",
name: "Equipment Setup",
description: "Attach motion capture markers and calibrate eye tracker",
type: "wizard",
orderIndex: 1,
durationEstimate: 180,
required: true,
conditions: {
equipment: ["motion_capture", "eye_tracker"],
calibration_required: true,
},
},
{
id: "850e8400-e29b-41d4-a716-446655440003",
experimentId: "750e8400-e29b-41d4-a716-446655440001",
name: "Task Instructions",
description: "Explain navigation task and destination to participant",
type: "wizard",
orderIndex: 2,
durationEstimate: 120,
required: true,
conditions: {
destination: "Conference Room B-201",
starting_point: "Building A Lobby",
},
},
{
id: "850e8400-e29b-41d4-a716-446655440004",
experimentId: "750e8400-e29b-41d4-a716-446655440001",
name: "Independent Navigation",
description:
"Participant navigates independently while data is collected",
type: "parallel",
orderIndex: 3,
durationEstimate: 600,
required: true,
conditions: {
data_collection: ["position", "gaze", "time"],
assistance: "none",
},
},
{
id: "850e8400-e29b-41d4-a716-446655440005",
experimentId: "750e8400-e29b-41d4-a716-446655440002",
name: "Robot Introduction",
description:
"Robot introduces itself and explains its role as navigation assistant",
type: "robot",
orderIndex: 0,
durationEstimate: 180,
required: true,
conditions: {
robot_behavior: "friendly_introduction",
voice_enabled: true,
},
},
{
id: "850e8400-e29b-41d4-a716-446655440006",
experimentId: "750e8400-e29b-41d4-a716-446655440002",
name: "Guided Navigation",
description:
"Robot provides turn-by-turn navigation guidance with gestures and speech",
type: "robot",
orderIndex: 1,
durationEstimate: 480,
required: true,
conditions: {
guidance_type: "multimodal",
gestures: true,
speech: true,
adaptation: "user_pace",
},
},
{
id: "850e8400-e29b-41d4-a716-446655440007",
experimentId: "750e8400-e29b-41d4-a716-446655440003",
name: "Personality Calibration",
description:
"Robot adjusts behavior based on assigned personality condition",
type: "conditional",
orderIndex: 0,
durationEstimate: 60,
required: true,
conditions: {
personality_variants: ["friendly", "professional", "neutral"],
behavior_parameters: {
friendly: { warmth: 0.8, formality: 0.3 },
professional: { warmth: 0.4, formality: 0.9 },
neutral: { warmth: 0.5, formality: 0.5 },
},
},
},
{
id: "850e8400-e29b-41d4-a716-446655440008",
experimentId: "750e8400-e29b-41d4-a716-446655440003",
name: "Collaborative Task",
description: "Human and robot work together on document review task",
type: "parallel",
orderIndex: 1,
durationEstimate: 1200,
required: true,
conditions: {
task_type: "document_review",
collaboration_level: "equal_partners",
performance_metrics: ["accuracy", "efficiency", "satisfaction"],
},
},
];
for (const step of steps) {
await sql`
INSERT INTO hs_step (id, experiment_id, name, description, type, order_index, duration_estimate, required, conditions, created_at, updated_at)
VALUES (${step.id}, ${step.experimentId}, ${step.name}, ${step.description}, ${step.type}, ${step.orderIndex}, ${step.durationEstimate}, ${step.required}, ${JSON.stringify(step.conditions)}, NOW(), NOW())
`;
}
console.log("✅ Created experiment steps");
// Create actions for each step
const actions = [
{
id: "950e8400-e29b-41d4-a716-446655440001",
stepId: "850e8400-e29b-41d4-a716-446655440001",
name: "Greet Participant",
description: "Welcome participant and introduce research team",
type: "wizard_speech",
orderIndex: 0,
parameters: {
script:
"Hello! Welcome to our navigation study. I'm [NAME] and I'll be guiding you through today's session.",
tone: "friendly_professional",
},
},
{
id: "950e8400-e29b-41d4-a716-446655440002",
stepId: "850e8400-e29b-41d4-a716-446655440001",
name: "Explain Study",
description: "Provide overview of study purpose and procedures",
type: "wizard_speech",
orderIndex: 1,
parameters: {
script:
"Today we're studying how people navigate indoor environments. You'll be asked to find a specific location in the building.",
documentation_required: true,
},
},
{
id: "950e8400-e29b-41d4-a716-446655440003",
stepId: "850e8400-e29b-41d4-a716-446655440005",
name: "Robot Self-Introduction",
description: "Robot introduces itself with friendly demeanor",
type: "robot_speech",
orderIndex: 0,
parameters: {
text: "Hello! I'm your navigation assistant. My name is Robi and I'm here to help you find your destination.",
gesture: "wave",
eye_contact: true,
voice_parameters: {
pitch: 0.7,
speed: 0.8,
emotion: "friendly",
},
},
},
{
id: "950e8400-e29b-41d4-a716-446655440004",
stepId: "850e8400-e29b-41d4-a716-446655440006",
name: "Start Navigation",
description: "Robot begins guiding participant toward destination",
type: "robot_movement",
orderIndex: 0,
parameters: {
movement_type: "lead",
speed: "slow_human_pace",
path_planning: "optimal_with_explanations",
safety_distance: 1.5,
},
},
{
id: "950e8400-e29b-41d4-a716-446655440005",
stepId: "850e8400-e29b-41d4-a716-446655440007",
name: "Load Personality Profile",
description: "Configure robot behavior based on personality condition",
type: "robot_config",
orderIndex: 0,
parameters: {
config_type: "personality_parameters",
profiles: {
friendly: {
greeting_style: "warm",
speech_patterns: "casual",
gesture_frequency: "high",
},
professional: {
greeting_style: "formal",
speech_patterns: "business",
gesture_frequency: "moderate",
},
neutral: {
greeting_style: "standard",
speech_patterns: "neutral",
gesture_frequency: "low",
},
},
},
},
];
for (const action of actions) {
await sql`
INSERT INTO hs_action (id, step_id, name, description, type, order_index, parameters, created_at, updated_at)
VALUES (${action.id}, ${action.stepId}, ${action.name}, ${action.description}, ${action.type}, ${action.orderIndex}, ${JSON.stringify(action.parameters)}, NOW(), NOW())
`;
}
console.log(`✅ Created ${actions.length} actions for steps`);
}
async function seedParticipants() {
console.log("👤 Seeding participants...");
const participants = [
{
id: "a50e8400-e29b-41d4-a716-446655440001",
studyId: "650e8400-e29b-41d4-a716-446655440001",
participantCode: "NAV001",
name: "Alex Johnson",
email: "alex.johnson@email.com",
demographics: {
age: 28,
gender: "non-binary",
education: "bachelor",
tech_experience: "high",
robot_experience: "medium",
mobility: "none",
},
consentGiven: true,
consentDate: new Date("2024-01-15"),
notes: "Interested in robotics, works in tech industry",
},
{
id: "a50e8400-e29b-41d4-a716-446655440002",
studyId: "650e8400-e29b-41d4-a716-446655440001",
participantCode: "NAV002",
name: "Maria Santos",
email: "maria.santos@email.com",
demographics: {
age: 34,
gender: "female",
education: "master",
tech_experience: "medium",
robot_experience: "low",
mobility: "none",
},
consentGiven: true,
consentDate: new Date("2024-01-16"),
notes: "Architecture background, good spatial reasoning",
},
{
id: "a50e8400-e29b-41d4-a716-446655440003",
studyId: "650e8400-e29b-41d4-a716-446655440002",
participantCode: "SOC001",
name: "Jennifer Liu",
email: "jennifer.liu@email.com",
demographics: {
age: 29,
gender: "female",
education: "bachelor",
tech_experience: "medium",
robot_experience: "low",
work_environment: "office",
},
consentGiven: true,
consentDate: new Date("2024-01-20"),
notes: "Project manager, interested in workplace automation",
},
];
for (const participant of participants) {
await sql`
INSERT INTO hs_participant (id, study_id, participant_code, name, email, demographics, consent_given, consent_date, notes, created_at, updated_at)
VALUES (${participant.id}, ${participant.studyId}, ${participant.participantCode}, ${participant.name}, ${participant.email}, ${JSON.stringify(participant.demographics)}, ${participant.consentGiven}, ${participant.consentDate}, ${participant.notes}, NOW(), NOW())
`;
}
console.log(`✅ Created ${participants.length} participants`);
}
async function seedTrials() {
console.log("🎯 Seeding trials...");
const trials = [
{
id: "b50e8400-e29b-41d4-a716-446655440001",
experimentId: "750e8400-e29b-41d4-a716-446655440001",
participantId: "a50e8400-e29b-41d4-a716-446655440001",
wizardId: "550e8400-e29b-41d4-a716-446655440003",
sessionNumber: 1,
status: "completed",
scheduledAt: new Date("2024-01-15T10:00:00"),
startedAt: new Date("2024-01-15T10:05:00"),
completedAt: new Date("2024-01-15T10:20:00"),
notes: "Participant completed successfully, good baseline performance",
metadata: {
condition: "control",
completion_time: 893,
errors: 1,
assistance_requests: 0,
},
},
{
id: "b50e8400-e29b-41d4-a716-446655440002",
experimentId: "750e8400-e29b-41d4-a716-446655440002",
participantId: "a50e8400-e29b-41d4-a716-446655440001",
wizardId: "550e8400-e29b-41d4-a716-446655440003",
sessionNumber: 2,
status: "completed",
scheduledAt: new Date("2024-01-15T10:30:00"),
startedAt: new Date("2024-01-15T10:35:00"),
completedAt: new Date("2024-01-15T10:58:00"),
notes: "Robot assistance worked well, participant very satisfied",
metadata: {
condition: "robot_assistance",
completion_time: 654,
errors: 0,
assistance_requests: 2,
robot_performance: "excellent",
},
},
{
id: "b50e8400-e29b-41d4-a716-446655440003",
experimentId: "750e8400-e29b-41d4-a716-446655440003",
participantId: "a50e8400-e29b-41d4-a716-446655440003",
wizardId: "550e8400-e29b-41d4-a716-446655440003",
sessionNumber: 1,
status: "scheduled",
scheduledAt: new Date("2024-01-25T11:00:00"),
startedAt: null,
completedAt: null,
notes: "Personality condition: friendly",
metadata: {
condition: "friendly_personality",
personality_type: "friendly",
},
},
];
for (const trial of trials) {
await sql`
INSERT INTO hs_trial (id, experiment_id, participant_id, wizard_id, session_number, status, scheduled_at, started_at, completed_at, notes, metadata, created_at, updated_at)
VALUES (${trial.id}, ${trial.experimentId}, ${trial.participantId}, ${trial.wizardId}, ${trial.sessionNumber}, ${trial.status}, ${trial.scheduledAt}, ${trial.startedAt}, ${trial.completedAt}, ${trial.notes}, ${JSON.stringify(trial.metadata)}, NOW(), NOW())
`;
}
console.log(`✅ Created ${trials.length} trials`);
}
async function main() {
try {
console.log("🚀 HRIStudio Database Seeding Started");
console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@"));
await clearDatabase();
await seedUsers();
await seedStudies();
await seedExperiments();
await seedStepsAndActions();
await seedParticipants();
await seedTrials();
console.log("✅ Database seeding completed successfully!");
console.log("\n📋 Summary:");
console.log(" 👥 Users: 4 (admin, researcher, wizard, observer)");
console.log(" 📚 Studies: 3 (navigation, social robots, elderly care)");
console.log(" 🧪 Experiments: 4 (with comprehensive test scenarios)");
console.log(" 📋 Steps: 8 (covering all experiment types)");
console.log(" ⚡ Actions: 5 (detailed robot and wizard actions)");
console.log(" 👤 Participants: 3 (diverse demographics)");
console.log(" 🎯 Trials: 3 (completed, scheduled)");
console.log("🔑 Test Login Credentials:");
console.log(" Admin: sarah.chen@university.edu / password123");
console.log(" Researcher: m.rodriguez@research.org / password123");
console.log(" Wizard: emma.thompson@university.edu / password123");
console.log(" Observer: james.wilson@university.edu / password123");
console.log("\n🧪 Test Experiment Designer with:");
console.log(
" 📍 /experiments/750e8400-e29b-41d4-a716-446655440001/designer",
);
console.log(
" 📍 /experiments/750e8400-e29b-41d4-a716-446655440002/designer",
);
console.log(
" 📍 /experiments/750e8400-e29b-41d4-a716-446655440003/designer",
);
console.log("\n🚀 Ready to test the experiment designer!");
} catch (error) {
console.error("❌ Seeding failed:", error);
process.exit(1);
} finally {
await sql.end();
}
}
// Run the seeding
main().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@ import {
AlertCircle,
CheckCircle2,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "~/components/ui/button";
import {
@@ -28,55 +29,20 @@ import { api } from "~/trpc/react";
// Dashboard Overview Cards
function OverviewCards() {
const utils = api.useUtils();
// Auto-refresh overview data when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void utils.studies.list.invalidate();
void utils.experiments.getUserExperiments.invalidate();
void utils.trials.getUserTrials.invalidate();
}, 60000); // Refresh every minute
return () => clearInterval(interval);
}, [utils]);
const { data: studiesData } = api.studies.list.useQuery(
{ page: 1, limit: 1 },
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
},
);
const { data: experimentsData } = api.experiments.getUserExperiments.useQuery(
{ page: 1, limit: 1 },
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
},
);
const { data: trialsData } = api.trials.getUserTrials.useQuery(
{ page: 1, limit: 1 },
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
},
);
// TODO: Fix participants API call - needs actual study ID
const participantsData = { pagination: { total: 0 } };
const { data: stats, isLoading } = api.dashboard.getStats.useQuery();
const cards = [
{
title: "Active Studies",
value: studiesData?.pagination?.total ?? 0,
description: "Research studies in progress",
value: stats?.totalStudies ?? 0,
description: "Research studies you have access to",
icon: Building,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "Experiments",
value: experimentsData?.pagination?.total ?? 0,
value: stats?.totalExperiments ?? 0,
description: "Experiment protocols designed",
icon: FlaskConical,
color: "text-green-600",
@@ -84,7 +50,7 @@ function OverviewCards() {
},
{
title: "Participants",
value: participantsData?.pagination?.total ?? 0,
value: stats?.totalParticipants ?? 0,
description: "Enrolled participants",
icon: Users,
color: "text-purple-600",
@@ -92,14 +58,33 @@ function OverviewCards() {
},
{
title: "Trials",
value: trialsData?.pagination?.total ?? 0,
description: "Completed trials",
value: stats?.totalTrials ?? 0,
description: "Total trials conducted",
icon: TestTube,
color: "text-orange-600",
bg: "bg-orange-50",
},
];
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
@@ -122,41 +107,10 @@ function OverviewCards() {
// Recent Activity Component
function RecentActivity() {
// Mock data - replace with actual API calls
const activities = [
{
id: "1",
type: "trial_completed",
title: "Trial #142 completed",
description: "Memory retention study - Participant P001",
time: "2 hours ago",
status: "success",
},
{
id: "2",
type: "experiment_created",
title: "New experiment protocol",
description: "Social interaction study v2.1",
time: "4 hours ago",
status: "info",
},
{
id: "3",
type: "participant_enrolled",
title: "New participant enrolled",
description: "P045 added to cognitive study",
time: "6 hours ago",
status: "success",
},
{
id: "4",
type: "trial_started",
title: "Trial #143 started",
description: "Attention span experiment",
time: "8 hours ago",
status: "pending",
},
];
const { data: activities = [], isLoading } =
api.dashboard.getRecentActivity.useQuery({
limit: 8,
});
const getStatusIcon = (status: string) => {
switch (status) {
@@ -180,24 +134,46 @@ function RecentActivity() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center space-x-4">
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
<div className="flex-1 space-y-2">
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
</div>
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
<div className="text-muted-foreground text-sm">
{activity.time}
))}
</div>
) : activities.length === 0 ? (
<div className="py-8 text-center">
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No recent activity
</p>
</div>
) : (
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
</div>
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(activity.time, { addSuffix: true })}
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
@@ -262,33 +238,10 @@ function QuickActions() {
// Study Progress Component
function StudyProgress() {
// Mock data - replace with actual API calls
const studies = [
{
id: "1",
name: "Cognitive Load Study",
progress: 75,
participants: 24,
totalParticipants: 30,
status: "active",
},
{
id: "2",
name: "Social Interaction Research",
progress: 45,
participants: 18,
totalParticipants: 40,
status: "active",
},
{
id: "3",
name: "Memory Retention Analysis",
progress: 90,
participants: 45,
totalParticipants: 50,
status: "completing",
},
];
const { data: studies = [], isLoading } =
api.dashboard.getStudyProgress.useQuery({
limit: 5,
});
return (
<Card className="col-span-3">
@@ -299,31 +252,62 @@ function StudyProgress() {
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{studies.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{study.name}
</p>
<p className="text-muted-foreground text-sm">
{study.participants}/{study.totalParticipants} participants
</p>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
</div>
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
</div>
<Badge
variant={study.status === "active" ? "default" : "secondary"}
>
{study.status}
</Badge>
<div className="bg-muted h-2 w-full animate-pulse rounded" />
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
<Progress value={study.progress} className="h-2" />
<p className="text-muted-foreground text-xs">
{study.progress}% complete
</p>
</div>
))}
</div>
))}
</div>
) : studies.length === 0 ? (
<div className="py-8 text-center">
<Building className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No active studies found
</p>
<p className="text-muted-foreground text-xs">
Create a study to get started
</p>
</div>
) : (
<div className="space-y-6">
{studies.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{study.name}
</p>
<p className="text-muted-foreground text-sm">
{study.participants}/{study.totalParticipants} completed
trials
</p>
</div>
<Badge
variant={
study.status === "active" ? "default" : "secondary"
}
>
{study.status}
</Badge>
</div>
<Progress value={study.progress} className="h-2" />
<p className="text-muted-foreground text-xs">
{study.progress}% complete
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);

View File

@@ -1,6 +1,6 @@
import { notFound } from "next/navigation";
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
import type { ExperimentBlock } from "~/components/experiments/designer/EnhancedBlockDesigner";
import { BlockDesigner } from "~/components/experiments/designer/BlockDesigner";
import type { ExperimentStep } from "~/lib/experiment-designer/types";
import { api } from "~/trpc/server";
interface ExperimentDesignerPageProps {
@@ -22,19 +22,19 @@ export default async function ExperimentDesignerPage({
// Parse existing visual design if available
const existingDesign = experiment.visualDesign as {
blocks?: unknown[];
steps?: unknown[];
version?: number;
lastSaved?: string;
} | null;
// Only pass initialDesign if there's existing visual design data
const initialDesign =
existingDesign?.blocks && existingDesign.blocks.length > 0
existingDesign?.steps && existingDesign.steps.length > 0
? {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
blocks: existingDesign.blocks as ExperimentBlock[],
steps: existingDesign.steps as ExperimentStep[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
@@ -44,7 +44,7 @@ export default async function ExperimentDesignerPage({
: undefined;
return (
<EnhancedBlockDesigner
<BlockDesigner
experimentId={experiment.id}
initialDesign={initialDesign}
/>
@@ -66,13 +66,13 @@ export async function generateMetadata({
const experiment = await api.experiments.get({ id: resolvedParams.id });
return {
title: `${experiment?.name} - Flow Designer | HRIStudio`,
description: `Design experiment protocol for ${experiment?.name} using visual flow editor`,
title: `${experiment?.name} - Designer | HRIStudio`,
description: `Design experiment protocol for ${experiment?.name} using step-based editor`,
};
} catch {
return {
title: "Experiment Flow Designer | HRIStudio",
description: "Immersive visual experiment protocol designer",
title: "Experiment Designer | HRIStudio",
description: "Step-based experiment protocol designer",
};
}
}

View File

@@ -12,7 +12,7 @@ interface DashboardContentProps {
completedToday: number;
canControl: boolean;
canManage: boolean;
recentTrials: any[];
_recentTrials: unknown[];
}
export function DashboardContent({
@@ -24,7 +24,7 @@ export function DashboardContent({
completedToday,
canControl,
canManage,
recentTrials,
_recentTrials,
}: DashboardContentProps) {
const getWelcomeMessage = () => {
switch (userRole) {
@@ -105,7 +105,7 @@ export function DashboardContent({
},
];
const alerts: any[] = [];
const alerts: never[] = [];
const recentActivity = null;

View File

@@ -19,6 +19,8 @@ import {
TestTube,
} from "lucide-react";
import { useSidebar } from "~/components/ui/sidebar";
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,6 +29,12 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import {
Sidebar,
SidebarContent,
@@ -44,6 +52,8 @@ import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
import { Logo } from "~/components/ui/logo";
import { useStudyManagement } from "~/hooks/useStudyManagement";
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
import { api } from "~/trpc/react";
// Navigation items
const navigationItems = [
@@ -103,9 +113,17 @@ export function AppSidebar({
const { data: session } = useSession();
const pathname = usePathname();
const isAdmin = userRole === "administrator";
const { state: sidebarState } = useSidebar();
const isCollapsed = sidebarState === "collapsed";
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } =
useStudyManagement();
// Debug API call
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
enabled: process.env.NODE_ENV === "development",
staleTime: 1000 * 30, // 30 seconds
});
type Study = {
id: string;
name: string;
@@ -130,6 +148,11 @@ export function AppSidebar({
await selectStudy(studyId);
} catch (error) {
console.error("Failed to select study:", error);
// Handle auth errors first
if (isAuthError(error)) {
await handleAuthError(error, "Session expired while selecting study");
return;
}
// If study selection fails (e.g., study not found), clear the selection
await selectStudy(null);
}
@@ -139,6 +162,18 @@ export function AppSidebar({
(study: Study) => study.id === selectedStudyId,
);
// Debug logging for study data
React.useEffect(() => {
console.log("Sidebar debug - User studies:", {
count: userStudies.length,
studies: userStudies.map((s) => ({ id: s.id, name: s.name })),
selectedStudyId,
selectedStudy: selectedStudy
? { id: selectedStudy.id, name: selectedStudy.name }
: null,
});
}, [userStudies, selectedStudyId, selectedStudy]);
// If we have a selectedStudyId but can't find the study, clear the selection
React.useEffect(() => {
if (selectedStudyId && userStudies.length > 0 && !selectedStudy) {
@@ -152,12 +187,24 @@ export function AppSidebar({
// Auto-refresh studies list when component mounts to catch external changes
useEffect(() => {
const interval = setInterval(() => {
void refreshStudyData();
void (async () => {
try {
await refreshStudyData();
} catch (error) {
console.error("Failed to refresh study data:", error);
if (isAuthError(error)) {
void handleAuthError(error, "Session expired during data refresh");
}
}
})();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refreshStudyData]);
// Show debug info in development
const showDebug = process.env.NODE_ENV === "development";
return (
<Sidebar collapsible="icon" variant="sidebar" {...props}>
<SidebarHeader>
@@ -165,7 +212,7 @@ export function AppSidebar({
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/dashboard">
<Logo iconSize="md" showText={true} />
<Logo iconSize="md" showText={!isCollapsed} />
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@@ -179,52 +226,110 @@ export function AppSidebar({
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
</span>
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
align="start"
>
<DropdownMenuLabel>Studies</DropdownMenuLabel>
{userStudies.map((study: Study) => (
<DropdownMenuItem
key={study.id}
onClick={() => handleStudySelect(study.id)}
className="cursor-pointer"
>
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate" title={study.name}>
{study.name}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/studies/new">
<Building className="mr-2 h-4 w-4" />
Create study
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{selectedStudy?.name ?? "Select Study"}
</span>
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
align="start"
>
<DropdownMenuLabel>Studies</DropdownMenuLabel>
{userStudies.map((study: Study) => (
<DropdownMenuItem
key={study.id}
onClick={() => handleStudySelect(study.id)}
className="cursor-pointer"
>
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate" title={study.name}>
{study.name}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton className="w-full">
<Building className="h-4 w-4 flex-shrink-0" />
<span className="truncate">
{selectedStudy?.name ?? "Select Study"}
</span>
<ChevronDown className="ml-auto h-4 w-4 flex-shrink-0" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
align="start"
>
<DropdownMenuLabel>Studies</DropdownMenuLabel>
{userStudies.map((study: Study) => (
<DropdownMenuItem
key={study.id}
onClick={() => handleStudySelect(study.id)}
className="cursor-pointer"
>
<Building className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate" title={study.name}>
{study.name}
</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/studies/new">
<Building className="mr-2 h-4 w-4" />
Create study
</Link>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{selectedStudyId && (
<DropdownMenuItem
onClick={async () => {
await selectStudy(null);
}}
>
<Building className="mr-2 h-4 w-4 opacity-50" />
Clear selection
</DropdownMenuItem>
)}
<DropdownMenuItem asChild>
<Link href="/studies/new">
<Building className="mr-2 h-4 w-4" />
Create study
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
@@ -240,14 +345,29 @@ export function AppSidebar({
pathname === item.url ||
(item.url !== "/dashboard" && pathname.startsWith(item.url));
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{item.title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
menuButton
)}
</SidebarMenuItem>
);
})}
@@ -256,7 +376,7 @@ export function AppSidebar({
</SidebarGroup>
{/* Study-specific items hint */}
{!selectedStudyId && (
{!selectedStudyId && !isCollapsed && (
<SidebarGroup>
<SidebarGroupContent>
<div className="text-muted-foreground px-3 py-2 text-xs">
@@ -276,14 +396,31 @@ export function AppSidebar({
{adminItems.map((item) => {
const isActive = pathname.startsWith(item.url);
const menuButton = (
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={item.url}>
<item.icon className="h-4 w-4" />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{menuButton}
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{item.title}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
menuButton
)}
</SidebarMenuItem>
);
})}
@@ -293,46 +430,135 @@ 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>
)}
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(session?.user?.name ?? session?.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
{isCollapsed ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(
session?.user?.name ??
session?.user?.email ??
"U"
)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(
session?.user?.name ??
session?.user?.email ??
"U"
)
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 h-4 w-4" />
Profile & Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipTrigger>
<TooltipContent side="right" className="text-sm">
{session?.user?.name ?? "User Menu"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
@@ -343,7 +569,7 @@ export function AppSidebar({
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
@@ -351,22 +577,53 @@ export function AppSidebar({
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 h-4 w-4" />
Profile & Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(session?.user?.name ?? session?.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/profile">
<Settings className="mr-2 h-4 w-4" />
Profile & Settings
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>

View File

@@ -14,12 +14,12 @@ 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 { api } from "~/trpc/react";
@@ -228,7 +228,9 @@ export const columns: ColumnDef<Experiment>[] = [
const date = row.getValue("createdAt");
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>
);
},
@@ -306,20 +308,37 @@ export function ExperimentsTable() {
const data: Experiment[] = React.useMemo(() => {
if (!experimentsData) return [];
return experimentsData.map((exp: any) => ({
interface RawExperiment {
id: string;
name: string;
description?: string | null;
status: Experiment["status"];
version: number;
estimatedDuration?: number | null;
createdAt: string | Date;
studyId: string;
createdBy?: { name?: string | null; email?: string | null } | null;
trialCount?: number | null;
stepCount?: number | null;
}
const adapt = (exp: RawExperiment): Experiment => ({
id: exp.id,
name: exp.name,
description: exp.description,
description: exp.description ?? "",
status: exp.status,
version: exp.version,
estimatedDuration: exp.estimatedDuration,
createdAt: exp.createdAt,
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,
}));
studyName: activeStudy?.title ?? "Unknown Study",
createdByName: exp.createdBy?.name ?? exp.createdBy?.email ?? "Unknown",
trialCount: exp.trialCount ?? 0,
stepCount: exp.stepCount ?? 0,
});
return experimentsData.map((e) => adapt(e as unknown as RawExperiment));
}, [experimentsData, activeStudy]);
if (!activeStudy) {

View File

@@ -0,0 +1,236 @@
"use client";
import React, { useState } from "react";
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 type { ActionDefinition } from "~/lib/experiment-designer/types";
import {
Plus,
User,
Bot,
GitBranch,
Eye,
GripVertical,
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Timer,
MousePointer,
Mic,
Activity,
Play,
} from "lucide-react";
import { useDraggable } from "@dnd-kit/core";
// Local icon map (duplicated minimal map for isolation to avoid circular imports)
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Zap,
Timer,
MousePointer,
Mic,
Activity,
Play,
};
interface DraggableActionProps {
action: ActionDefinition;
}
function DraggableAction({ action }: DraggableActionProps) {
const [showTooltip, setShowTooltip] = useState(false);
const { attributes, listeners, setNodeRef, transform, isDragging } =
useDraggable({
id: `action-${action.id}`,
data: { action },
});
const style = {
transform: transform
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
: undefined,
};
const IconComponent = iconMap[action.icon] ?? Zap;
const categoryColors: Record<ActionDefinition["category"], string> = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
};
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}
className={cn(
"group hover:bg-accent/50 relative flex cursor-grab items-center gap-2 rounded-md border p-2 text-xs transition-colors",
isDragging && "opacity-50",
)}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
draggable={false}
>
<div
className={cn(
"flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-white",
categoryColors[action.category],
)}
>
<IconComponent className="h-3 w-3" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 truncate font-medium">
{action.source.kind === "plugin" ? (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
P
</span>
) : (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
C
</span>
)}
{action.name}
</div>
<div className="text-muted-foreground truncate text-xs">
{action.description ?? ""}
</div>
</div>
<div className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
<GripVertical className="h-3 w-3" />
</div>
{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="mt-1 text-xs opacity-75">
Category: {action.category} ID: {action.id}
</div>
{action.parameters.length > 0 && (
<div className="mt-1 text-xs opacity-75">
Parameters: {action.parameters.map((p) => p.name).join(", ")}
</div>
)}
</div>
)}
</div>
);
}
export interface ActionLibraryProps {
className?: string;
}
export function ActionLibrary({ className }: ActionLibraryProps) {
const registry = actionRegistry;
const [activeCategory, setActiveCategory] =
useState<ActionDefinition["category"]>("wizard");
const categories: Array<{
key: ActionDefinition["category"];
label: string;
icon: React.ComponentType<{ className?: string }>;
color: string;
}> = [
{
key: "wizard",
label: "Wizard",
icon: User,
color: "bg-blue-500",
},
{
key: "robot",
label: "Robot",
icon: Bot,
color: "bg-emerald-500",
},
{
key: "control",
label: "Control",
icon: GitBranch,
color: "bg-amber-500",
},
{
key: "observation",
label: "Observe",
icon: Eye,
color: "bg-purple-500",
},
];
return (
<div className={cn("flex h-full flex-col", className)}>
{/* Category tabs */}
<div className="border-b p-2">
<div className="grid grid-cols-2 gap-1">
{categories.map((category) => {
const IconComponent = category.icon;
const isActive = activeCategory === category.key;
return (
<Button
key={category.key}
variant={isActive ? "default" : "ghost"}
size="sm"
className={cn(
"h-7 justify-start text-xs",
isActive && `${category.color} text-white hover:opacity-90`,
)}
onClick={() => setActiveCategory(category.key)}
>
<IconComponent className="mr-1 h-3 w-3" />
{category.label}
</Button>
);
})}
</div>
</div>
{/* Actions list */}
<ScrollArea className="flex-1">
<div className="space-y-1 p-2">
{registry.getActionsByCategory(activeCategory).length === 0 ? (
<div className="text-muted-foreground py-8 text-center">
<div className="bg-muted mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full">
<Plus className="h-4 w-4" />
</div>
<p className="text-sm">No actions available</p>
<p className="text-xs">Check plugin configuration</p>
</div>
) : (
registry
.getActionsByCategory(activeCategory)
.map((action) => <DraggableAction key={action.id} action={action} />)
)}
</div>
</ScrollArea>
<div className="border-t p-2">
<div className="flex items-center justify-between">
<Badge variant="secondary" className="text-[10px]">
{registry.getAllActions().length} total
</Badge>
<Badge variant="outline" className="text-[10px]">
{registry.getActionsByCategory(activeCategory).length} in view
</Badge>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,450 @@
"use client";
import type { ActionDefinition } from "~/lib/experiment-designer/types";
/**
* ActionRegistry
*
* Central singleton for loading and serving action definitions from:
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
*
* Responsibilities:
* - Lazy, idempotent loading of core and plugin actions
* - Provenance retention (core vs plugin, plugin id/version, robot id)
* - Parameter schema → UI parameter mapping (primitive only for now)
* - Fallback action population if core load fails (ensures minimal functionality)
*
* Notes:
* - The registry is client-side only (designer runtime); server performs its own
* validation & compilation using persisted action instances (never trusts client).
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
*/
export class ActionRegistry {
private static instance: ActionRegistry;
private actions = new Map<string, ActionDefinition>();
private coreActionsLoaded = false;
private pluginActionsLoaded = false;
private loadedStudyId: string | null = null;
static getInstance(): ActionRegistry {
if (!ActionRegistry.instance) {
ActionRegistry.instance = new ActionRegistry();
}
return ActionRegistry.instance;
}
/* ---------------- Core Actions ---------------- */
async loadCoreActions(): Promise<void> {
if (this.coreActionsLoaded) return;
interface CoreBlockParam {
id: string;
name: string;
type: string;
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number;
}
interface CoreBlock {
id: string;
name: string;
description?: string;
category: string;
icon?: string;
color?: string;
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
}
try {
const coreActionSets = ["wizard-actions", "control-flow", "observation"];
for (const actionSetId of coreActionSets) {
try {
const response = await fetch(
`/hristudio-core/plugins/${actionSetId}.json`,
);
// 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
actionSet.blocks.forEach((block) => {
if (!block.id || !block.name) return;
const actionDef: ActionDefinition = {
id: block.id,
type: block.id,
name: block.name,
description: block.description ?? "",
category: this.mapBlockCategoryToActionCategory(block.category),
icon: block.icon ?? "Zap",
color: block.color ?? "#6b7280",
parameters: (block.parameters ?? []).map((param) => ({
id: param.id,
name: param.name,
type:
(param.type as "text" | "number" | "select" | "boolean") ||
"text",
placeholder: param.placeholder,
options: param.options,
min: param.min,
max: param.max,
value: param.value,
required: param.required !== false,
description: param.description,
step: param.step,
})),
source: {
kind: "core",
baseActionId: block.id,
},
execution: {
transport: "internal",
timeoutMs: block.timeoutMs,
retryable: block.retryable,
},
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
};
this.actions.set(actionDef.id, actionDef);
});
} catch (error) {
// Non-fatal: we will fallback later
console.warn(`Failed to load core action set ${actionSetId}:`, error);
}
}
this.coreActionsLoaded = true;
} catch (error) {
console.error("Failed to load core actions:", error);
this.loadFallbackActions();
}
}
private mapBlockCategoryToActionCategory(
category: string,
): ActionDefinition["category"] {
switch (category) {
case "wizard":
case "event":
return "wizard";
case "robot":
return "robot";
case "control":
return "control";
case "sensor":
case "observation":
return "observation";
default:
return "wizard";
}
}
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
id: "wizard_speak",
type: "wizard_speak",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
color: "#3b82f6",
parameters: [
{
id: "text",
name: "Text to say",
type: "text",
placeholder: "Hello, participant!",
required: true,
},
],
source: { kind: "core", baseActionId: "wizard_speak" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {
type: "object",
properties: {
text: { type: "string" },
},
required: ["text"],
},
},
{
id: "wait",
type: "wait",
name: "Wait",
description: "Wait for specified time",
category: "control",
icon: "Clock",
color: "#f59e0b",
parameters: [
{
id: "duration",
name: "Duration (seconds)",
type: "number",
min: 0.1,
max: 300,
value: 2,
required: true,
},
],
source: { kind: "core", baseActionId: "wait" },
execution: { transport: "internal", timeoutMs: 60000 },
parameterSchemaRaw: {
type: "object",
properties: {
duration: {
type: "number",
minimum: 0.1,
maximum: 300,
default: 2,
},
},
required: ["duration"],
},
},
{
id: "observe",
type: "observe",
name: "Observe",
description: "Record participant behavior",
category: "observation",
icon: "Eye",
color: "#8b5cf6",
parameters: [
{
id: "behavior",
name: "Behavior to observe",
type: "select",
options: ["facial_expression", "body_language", "verbal_response"],
required: true,
},
],
source: { kind: "core", baseActionId: "observe" },
execution: { transport: "internal", timeoutMs: 120000 },
parameterSchemaRaw: {
type: "object",
properties: {
behavior: {
type: "string",
enum: ["facial_expression", "body_language", "verbal_response"],
},
},
required: ["behavior"],
},
},
];
fallbackActions.forEach((action) => this.actions.set(action.id, action));
}
/* ---------------- Plugin Actions ---------------- */
loadPluginActions(
studyId: string,
studyPlugins: Array<{
plugin: {
id: string;
robotId: string | null;
version: string | null;
actionDefinitions?: Array<{
id: string;
name: string;
description?: string;
category?: string;
icon?: string;
timeout?: number;
retryable?: boolean;
parameterSchema?: unknown;
ros2?: {
topic?: string;
messageType?: string;
service?: string;
action?: string;
payloadMapping?: unknown;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
};
rest?: {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
};
}>;
};
}>,
): void {
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
if (this.loadedStudyId !== studyId) {
this.resetPluginActions();
}
(studyPlugins ?? []).forEach((studyPlugin) => {
const { plugin } = studyPlugin;
const actionDefs = Array.isArray(plugin.actionDefinitions)
? plugin.actionDefinitions
: undefined;
if (!actionDefs) return;
actionDefs.forEach((action) => {
const category =
(action.category as ActionDefinition["category"]) || "robot";
const execution = action.ros2
? {
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
: {
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
const actionDef: ActionDefinition = {
id: `${plugin.id}.${action.id}`,
type: `${plugin.id}.${action.id}`,
name: action.name,
description: action.description ?? "",
category,
icon: action.icon ?? "Bot",
color: "#10b981",
parameters: this.convertParameterSchemaToParameters(
action.parameterSchema,
),
source: {
kind: "plugin",
pluginId: plugin.id,
robotId: plugin.robotId,
pluginVersion: plugin.version ?? undefined,
baseActionId: action.id,
},
execution,
parameterSchemaRaw: action.parameterSchema ?? undefined,
};
this.actions.set(actionDef.id, actionDef);
});
});
this.pluginActionsLoaded = true;
this.loadedStudyId = studyId;
}
private convertParameterSchemaToParameters(
parameterSchema: unknown,
): ActionDefinition["parameters"] {
interface JsonSchemaProperty {
type?: string;
title?: string;
description?: string;
enum?: string[];
default?: string | number | boolean;
minimum?: number;
maximum?: number;
}
interface JsonSchema {
properties?: Record<string, JsonSchemaProperty>;
required?: string[];
}
const schema = parameterSchema as JsonSchema | undefined;
if (!schema?.properties) return [];
return Object.entries(schema.properties).map(([key, paramDef]) => {
let type: "text" | "number" | "select" | "boolean" = "text";
if (paramDef.type === "number") {
type = "number";
} else if (paramDef.type === "boolean") {
type = "boolean";
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
type = "select";
}
return {
id: key,
name: paramDef.title ?? key.charAt(0).toUpperCase() + key.slice(1),
type,
value: paramDef.default,
placeholder: paramDef.description,
options: paramDef.enum,
min: paramDef.minimum,
max: paramDef.maximum,
required: true,
};
});
}
private resetPluginActions(): void {
this.pluginActionsLoaded = false;
this.loadedStudyId = null;
// Remove existing plugin actions (retain known core ids + fallback ids)
const pluginActionIds = Array.from(this.actions.keys()).filter(
(id) =>
!id.startsWith("wizard_") &&
!id.startsWith("wait") &&
!id.startsWith("observe"),
);
pluginActionIds.forEach((id) => this.actions.delete(id));
}
/* ---------------- Query Helpers ---------------- */
getActionsByCategory(
category: ActionDefinition["category"],
): ActionDefinition[] {
return Array.from(this.actions.values()).filter(
(action) => action.category === category,
);
}
getAllActions(): ActionDefinition[] {
return Array.from(this.actions.values());
}
getAction(id: string): ActionDefinition | undefined {
return this.actions.get(id);
}
}
export const actionRegistry = ActionRegistry.getInstance();

View File

@@ -0,0 +1,670 @@
"use client";
/**
* BlockDesigner (Modular Refactor)
*
* Responsibilities:
* - Own overall experiment design state (steps + actions)
* - Coordinate drag & drop between ActionLibrary (source) and StepFlow (targets)
* - Persist design via experiments.update mutation (optionally compiling execution graph)
* - Trigger server-side validation (experiments.validateDesign) to obtain integrity hash
* - Track & surface "hash drift" (design changed since last validation or mismatch with stored integrityHash)
*
* Extracted Modules:
* - ActionRegistry -> ./ActionRegistry.ts
* - ActionLibrary -> ./ActionLibrary.tsx
* - StepFlow -> ./StepFlow.tsx
* - PropertiesPanel -> ./PropertiesPanel.tsx
*
* Enhancements Added Here:
* - Hash drift indicator logic (Validated / Drift / Unvalidated)
* - Modular wiring replacing previous monolithic file
*/
import React, { useState, useCallback, useEffect, useMemo } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { toast } from "sonner";
import { Save, Download, Play, Plus } 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 { PageHeader, ActionButton } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import {
type ExperimentDesign,
type ExperimentStep,
type ExperimentAction,
type ActionDefinition,
} from "~/lib/experiment-designer/types";
import { api } from "~/trpc/react";
import { ActionLibrary } from "./ActionLibrary";
import { StepFlow } from "./StepFlow";
import { PropertiesPanel } from "./PropertiesPanel";
import { actionRegistry } from "./ActionRegistry";
/* -------------------------------------------------------------------------- */
/* Utilities */
/* -------------------------------------------------------------------------- */
/**
* Build a lightweight JSON string representing the current design for drift checks.
* We include full steps & actions; param value churn will intentionally flag drift
* (acceptable trade-off for now; can switch to structural signature if too noisy).
*/
function serializeDesignSteps(steps: ExperimentStep[]): string {
return JSON.stringify(
steps.map((s) => ({
id: s.id,
order: s.order,
type: s.type,
trigger: {
type: s.trigger.type,
conditionKeys: Object.keys(s.trigger.conditions).sort(),
},
actions: s.actions.map((a) => ({
id: a.id,
type: a.type,
sourceKind: a.source.kind,
pluginId: a.source.pluginId,
pluginVersion: a.source.pluginVersion,
transport: a.execution.transport,
parameterKeys: Object.keys(a.parameters).sort(),
})),
})),
);
}
/* -------------------------------------------------------------------------- */
/* Props */
/* -------------------------------------------------------------------------- */
interface BlockDesignerProps {
experimentId: string;
initialDesign?: ExperimentDesign;
onSave?: (design: ExperimentDesign) => void;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export function BlockDesigner({
experimentId,
initialDesign,
onSave,
}: BlockDesignerProps) {
/* ---------------------------- Experiment Query ---------------------------- */
const { data: experiment } = api.experiments.get.useQuery({
id: experimentId,
});
/* ------------------------------ Local Design ------------------------------ */
const [design, setDesign] = useState<ExperimentDesign>(() => {
const defaultDesign: ExperimentDesign = {
id: experimentId,
name: "New Experiment",
description: "",
steps: [],
version: 1,
lastSaved: new Date(),
};
return initialDesign ?? defaultDesign;
});
const [selectedStepId, setSelectedStepId] = useState<string | null>(null);
const [selectedActionId, setSelectedActionId] = useState<string | null>(null);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
/* ------------------------- Validation / Drift Tracking -------------------- */
const [isValidating, setIsValidating] = useState(false);
const [lastValidatedHash, setLastValidatedHash] = useState<string | null>(
null,
);
const [lastValidatedDesignJson, setLastValidatedDesignJson] = useState<
string | null
>(null);
// Recompute drift conditions
const currentDesignJson = useMemo(
() => serializeDesignSteps(design.steps),
[design.steps],
);
const hasIntegrityHash = !!experiment?.integrityHash;
const hashMismatch =
hasIntegrityHash &&
lastValidatedHash &&
experiment?.integrityHash !== lastValidatedHash;
const designChangedSinceValidation =
!!lastValidatedDesignJson && lastValidatedDesignJson !== currentDesignJson;
const drift =
hasIntegrityHash && (hashMismatch ? true : designChangedSinceValidation);
/* ---------------------------- Active Drag State --------------------------- */
// Removed unused activeId state (drag overlay removed in modular refactor)
/* ------------------------------- tRPC Mutations --------------------------- */
const updateExperiment = api.experiments.update.useMutation({
onSuccess: () => {
toast.success("Experiment saved");
setHasUnsavedChanges(false);
},
onError: (err) => {
toast.error(`Failed to save: ${err.message}`);
},
});
const trpcUtils = api.useUtils();
/* ------------------------------- Plugins Load ----------------------------- */
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId },
);
/* ---------------------------- Registry Loading ---------------------------- */
useEffect(() => {
actionRegistry.loadCoreActions().catch((err) => {
console.error("Core actions load failed:", err);
toast.error("Failed to load core action library");
});
}, []);
useEffect(() => {
if (experiment?.studyId && (studyPlugins?.length ?? 0) > 0) {
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]);
/* ------------------------------ Breadcrumbs ------------------------------- */
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.studyId}`,
},
{ label: "Experiments", href: `/studies/${experiment?.studyId}` },
{ label: design.name, href: `/experiments/${experimentId}` },
{ label: "Designer" },
]);
/* ------------------------------ DnD Sensors ------------------------------- */
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
const handleDragStart = useCallback((_event: DragStartEvent) => {
// activeId tracking removed (drag overlay no longer used)
}, []);
/* ------------------------------ Helpers ----------------------------------- */
const addActionToStep = useCallback(
(stepId: string, def: ActionDefinition) => {
const newAction: ExperimentAction = {
id: `action_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
type: def.type,
name: def.name,
parameters: {},
category: def.category,
source: def.source,
execution: def.execution ?? { transport: "internal" },
parameterSchemaRaw: def.parameterSchemaRaw,
};
// Default param values
def.parameters.forEach((p) => {
if (p.value !== undefined) {
newAction.parameters[p.id] = p.value;
}
});
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId ? { ...s, actions: [...s.actions, newAction] } : s,
),
}));
setHasUnsavedChanges(true);
toast.success(`Added ${def.name}`);
},
[],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
// activeId reset removed (no longer tracked)
if (!over) return;
const activeIdStr = active.id.toString();
const overIdStr = over.id.toString();
// From library to step droppable
if (activeIdStr.startsWith("action-") && overIdStr.startsWith("step-")) {
const actionId = activeIdStr.replace("action-", "");
const stepId = overIdStr.replace("step-", "");
const def = actionRegistry.getAction(actionId);
if (def) {
addActionToStep(stepId, def);
}
return;
}
// Step reorder (both plain ids of steps)
if (
!activeIdStr.startsWith("action-") &&
!overIdStr.startsWith("step-") &&
!overIdStr.startsWith("action-")
) {
const oldIndex = design.steps.findIndex((s) => s.id === activeIdStr);
const newIndex = design.steps.findIndex((s) => s.id === overIdStr);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
setDesign((prev) => ({
...prev,
steps: arrayMove(prev.steps, oldIndex, newIndex).map(
(s, index) => ({ ...s, order: index }),
),
}));
setHasUnsavedChanges(true);
}
return;
}
// Action reorder (within same step)
if (
!activeIdStr.startsWith("action-") &&
!overIdStr.startsWith("step-") &&
activeIdStr !== overIdStr
) {
// Identify which step these actions belong to
const containingStep = design.steps.find((s) =>
s.actions.some((a) => a.id === activeIdStr),
);
const targetStep = design.steps.find((s) =>
s.actions.some((a) => a.id === overIdStr),
);
if (
containingStep &&
targetStep &&
containingStep.id === targetStep.id
) {
const oldActionIndex = containingStep.actions.findIndex(
(a) => a.id === activeIdStr,
);
const newActionIndex = containingStep.actions.findIndex(
(a) => a.id === overIdStr,
);
if (
oldActionIndex !== -1 &&
newActionIndex !== -1 &&
oldActionIndex !== newActionIndex
) {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === containingStep.id
? {
...s,
actions: arrayMove(
s.actions,
oldActionIndex,
newActionIndex,
),
}
: s,
),
}));
setHasUnsavedChanges(true);
}
}
}
},
[design.steps, addActionToStep],
);
const addStep = useCallback(() => {
const newStep: ExperimentStep = {
id: `step_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name: `Step ${design.steps.length + 1}`,
description: "",
type: "sequential",
order: design.steps.length,
trigger: {
type: design.steps.length === 0 ? "trial_start" : "previous_step",
conditions: {},
},
actions: [],
expanded: true,
};
setDesign((prev) => ({
...prev,
steps: [...prev.steps, newStep],
}));
setHasUnsavedChanges(true);
}, [design.steps.length]);
const updateStep = useCallback(
(stepId: string, updates: Partial<ExperimentStep>) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId ? { ...s, ...updates } : s,
),
}));
setHasUnsavedChanges(true);
},
[],
);
const deleteStep = useCallback(
(stepId: string) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.filter((s) => s.id !== stepId),
}));
if (selectedStepId === stepId) setSelectedStepId(null);
setHasUnsavedChanges(true);
},
[selectedStepId],
);
const updateAction = useCallback(
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId
? {
...s,
actions: s.actions.map((a) =>
a.id === actionId ? { ...a, ...updates } : a,
),
}
: s,
),
}));
setHasUnsavedChanges(true);
},
[],
);
const deleteAction = useCallback(
(stepId: string, actionId: string) => {
setDesign((prev) => ({
...prev,
steps: prev.steps.map((s) =>
s.id === stepId
? {
...s,
actions: s.actions.filter((a) => a.id !== actionId),
}
: s,
),
}));
if (selectedActionId === actionId) setSelectedActionId(null);
setHasUnsavedChanges(true);
},
[selectedActionId],
);
/* ------------------------------- Validation ------------------------------- */
const runValidation = useCallback(async () => {
setIsValidating(true);
try {
const result = await trpcUtils.experiments.validateDesign.fetch({
experimentId,
visualDesign: { steps: design.steps },
});
if (!result.valid) {
toast.error(
`Validation failed: ${result.issues.slice(0, 3).join(", ")}${
result.issues.length > 3 ? "…" : ""
}`,
);
return;
}
if (result.integrityHash) {
setLastValidatedHash(result.integrityHash);
setLastValidatedDesignJson(currentDesignJson);
toast.success(
`Validated • Hash: ${result.integrityHash.slice(0, 10)}`,
);
} else {
toast.success("Validated (no hash produced)");
}
} catch (err) {
toast.error(
`Validation error: ${
err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
setIsValidating(false);
}
}, [experimentId, design.steps, trpcUtils, currentDesignJson]);
/* --------------------------------- Saving --------------------------------- */
const saveDesign = useCallback(() => {
const visualDesign = {
steps: design.steps,
version: design.version,
lastSaved: new Date().toISOString(),
};
updateExperiment.mutate({
id: experimentId,
visualDesign,
createSteps: true,
compileExecution: true,
});
const updatedDesign = { ...design, lastSaved: new Date() };
setDesign(updatedDesign);
onSave?.(updatedDesign);
}, [design, experimentId, onSave, updateExperiment]);
/* --------------------------- Selection Resolution ------------------------- */
const selectedStep = design.steps.find((s) => s.id === selectedStepId);
const selectedAction = selectedStep?.actions.find(
(a) => a.id === selectedActionId,
);
/* ------------------------------- Header Badges ---------------------------- */
const validationBadge = drift ? (
<Badge
variant="destructive"
className="text-xs"
title="Design has drifted since last validation or differs from stored hash"
>
Drift
</Badge>
) : lastValidatedHash ? (
<Badge
variant="outline"
className="border-green-400 text-xs text-green-700 dark:text-green-400"
title="Design matches last validated structure"
>
Validated
</Badge>
) : (
<Badge variant="outline" className="text-xs" title="Not yet validated">
Unvalidated
</Badge>
);
/* ---------------------------------- Render -------------------------------- */
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-4">
<PageHeader
title={design.name}
description="Design your experiment using steps and categorized actions"
icon={Play}
actions={
<div className="flex flex-wrap items-center gap-2">
{validationBadge}
{experiment?.integrityHash && (
<Badge variant="outline" className="text-xs">
Hash: {experiment.integrityHash.slice(0, 10)}
</Badge>
)}
{experiment?.executionGraphSummary && (
<Badge variant="outline" className="text-xs">
Exec: {experiment.executionGraphSummary.steps ?? 0}s /
{experiment.executionGraphSummary.actions ?? 0}a
</Badge>
)}
{Array.isArray(experiment?.pluginDependencies) &&
experiment.pluginDependencies.length > 0 && (
<Badge variant="secondary" className="text-xs">
{experiment.pluginDependencies.length} plugins
</Badge>
)}
<Badge variant="secondary" className="text-xs">
{design.steps.length} steps
</Badge>
{hasUnsavedChanges && (
<Badge
variant="outline"
className="border-orange-300 text-orange-600"
>
Unsaved
</Badge>
)}
<ActionButton
onClick={saveDesign}
disabled={!hasUnsavedChanges || updateExperiment.isPending}
>
<Save className="mr-2 h-4 w-4" />
{updateExperiment.isPending ? "Saving…" : "Save"}
</ActionButton>
<ActionButton
variant="outline"
onClick={() => {
setHasUnsavedChanges(false); // immediate feedback
void runValidation();
}}
disabled={isValidating}
>
<Play className="mr-2 h-4 w-4" />
{isValidating ? "Validating…" : "Revalidate"}
</ActionButton>
<ActionButton variant="outline">
<Download className="mr-2 h-4 w-4" />
Export
</ActionButton>
</div>
}
/>
<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">
<Plus className="h-4 w-4" />
Action Library
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ActionLibrary />
</CardContent>
</Card>
</div>
{/* Flow */}
<div className="col-span-6">
<StepFlow
steps={design.steps}
selectedStepId={selectedStepId}
selectedActionId={selectedActionId}
onStepSelect={(id) => {
setSelectedStepId(id);
setSelectedActionId(null);
}}
onStepDelete={deleteStep}
onStepUpdate={updateStep}
onActionSelect={(actionId) => setSelectedActionId(actionId)}
onActionDelete={deleteAction}
emptyState={
<div className="py-8 text-center">
<Play className="text-muted-foreground/50 mx-auto h-8 w-8" />
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
<p className="text-muted-foreground mt-1 text-xs">
Add your first step to begin designing
</p>
<Button className="mt-2" size="sm" onClick={addStep}>
<Plus className="mr-1 h-3 w-3" />
Add First Step
</Button>
</div>
}
headerRight={
<Button size="sm" onClick={addStep} className="h-6 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Step
</Button>
}
/>
</div>
{/* Properties */}
<div className="col-span-3">
<Card className="h-[calc(100vh-12rem)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-sm">
Properties
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<ScrollArea className="h-full pr-1">
<PropertiesPanel
design={design}
selectedStep={selectedStep}
selectedAction={selectedAction}
onActionUpdate={updateAction}
onStepUpdate={updateStep}
/>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
</DndContext>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,461 @@
"use client";
import React from "react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "~/components/ui/select";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { Badge } from "~/components/ui/badge";
import { cn } from "~/lib/utils";
import {
TRIGGER_OPTIONS,
type ExperimentAction,
type ExperimentStep,
type StepType,
type TriggerType,
type ExperimentDesign,
} from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry";
import {
Settings,
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Timer,
MousePointer,
Mic,
Activity,
Play,
} from "lucide-react";
/**
* PropertiesPanel
*
* Extracted modular panel for editing either:
* - Action properties (when an action is selected)
* - Step properties (when a step is selected and no action selected)
* - Empty instructional state otherwise
*
* Enhancements:
* - Boolean parameters render as Switch
* - Number parameters with min/max render as Slider (with live value)
* - Number parameters without bounds fall back to numeric input
* - Select and text remain standard controls
* - Provenance + category badges retained
*/
export interface PropertiesPanelProps {
design: ExperimentDesign;
selectedStep?: ExperimentStep;
selectedAction?: ExperimentAction;
onActionUpdate: (
stepId: string,
actionId: string,
updates: Partial<ExperimentAction>,
) => void;
onStepUpdate: (stepId: string, updates: Partial<ExperimentStep>) => void;
className?: string;
}
export function PropertiesPanel({
design,
selectedStep,
selectedAction,
onActionUpdate,
onStepUpdate,
className,
}: PropertiesPanelProps) {
const registry = actionRegistry;
// Find containing step for selected action (if any)
const containingStep =
selectedAction &&
design.steps.find((s) => s.actions.some((a) => a.id === selectedAction.id));
/* -------------------------- Action Properties View -------------------------- */
if (selectedAction && containingStep) {
const def = registry.getAction(selectedAction.type);
const categoryColors = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
} as const;
// Icon resolution uses statically imported lucide icons (no dynamic require)
// Icon resolution uses statically imported lucide icons (no dynamic require)
const iconComponents: Record<
string,
React.ComponentType<{ className?: string }>
> = {
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Timer,
MousePointer,
Mic,
Activity,
Play,
};
const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{
className?: string;
}>)
: Zap;
return (
<div className={cn("space-y-3", className)}>
{/* Header / Metadata */}
<div className="border-b pb-3">
<div className="mb-2 flex items-center gap-2">
{def && (
<div
className={cn(
"flex h-6 w-6 items-center justify-center rounded text-white",
categoryColors[def.category],
)}
>
<ResolvedIcon className="h-3 w-3" />
</div>
)}
<div className="min-w-0">
<h3 className="truncate text-sm font-medium">
{selectedAction.name}
</h3>
<p className="text-muted-foreground text-xs">
{def?.category} {selectedAction.type}
</p>
</div>
</div>
<div className="flex flex-wrap gap-1">
<Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.source.kind === "plugin" ? "Plugin" : "Core"}
</Badge>
{selectedAction.source.pluginId && (
<Badge variant="secondary" className="h-4 text-[10px]">
{selectedAction.source.pluginId}
{selectedAction.source.pluginVersion
? `@${selectedAction.source.pluginVersion}`
: ""}
</Badge>
)}
<Badge variant="outline" className="h-4 text-[10px]">
{selectedAction.execution.transport}
</Badge>
{selectedAction.execution.retryable && (
<Badge variant="outline" className="h-4 text-[10px]">
retryable
</Badge>
)}
</div>
{def?.description && (
<p className="text-muted-foreground mt-2 text-xs leading-relaxed">
{def.description}
</p>
)}
</div>
{/* General Action Fields */}
<div className="space-y-2">
<div>
<Label className="text-xs">Display Name</Label>
<Input
value={selectedAction.name}
onChange={(e) =>
onActionUpdate(containingStep.id, selectedAction.id, {
name: e.target.value,
})
}
className="mt-1 h-7 text-xs"
/>
</div>
</div>
{/* Parameters */}
{def?.parameters.length ? (
<div className="space-y-3">
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
Parameters
</div>
<div className="space-y-3">
{def.parameters.map((param) => {
const rawValue = selectedAction.parameters[param.id];
const commonLabel = (
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
);
/* ---- Handlers ---- */
const updateParamValue = (value: unknown) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: value,
},
});
};
/* ---- Control Rendering ---- */
let control: React.ReactNode = null;
if (param.type === "text") {
control = (
<Input
value={(rawValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)}
className="mt-1 h-7 text-xs"
/>
);
} else if (param.type === "select") {
control = (
<Select
value={(rawValue as string) ?? ""}
onValueChange={(val) => updateParamValue(val)}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(rawValue)}
onCheckedChange={(val) => updateParamValue(val)}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(rawValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const numericVal =
typeof rawValue === "number"
? rawValue
: typeof param.value === "number"
? param.value
: (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max =
param.max ??
Math.max(
min + 1,
Number.isFinite(numericVal) ? numericVal : min + 1,
);
// Step heuristic
const range = max - min;
const step =
param.step ??
(range <= 5
? 0.1
: range <= 50
? 0.5
: Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals: number[]) =>
updateParamValue(vals[0])
}
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0)
}
className="mt-1 h-7 text-xs"
/>
);
}
}
return (
<div key={param.id} className="space-y-1">
{commonLabel}
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
})}
</div>
</div>
) : (
<div className="text-muted-foreground text-xs">
No parameters for this action.
</div>
)}
</div>
);
}
/* --------------------------- Step Properties View --------------------------- */
if (selectedStep) {
return (
<div className={cn("space-y-3", className)}>
<div className="border-b pb-2">
<h3 className="flex items-center gap-2 text-sm font-medium">
<div
className={cn("h-3 w-3 rounded", {
"bg-blue-500": selectedStep.type === "sequential",
"bg-emerald-500": selectedStep.type === "parallel",
"bg-amber-500": selectedStep.type === "conditional",
"bg-purple-500": selectedStep.type === "loop",
})}
/>
Step Settings
</h3>
</div>
<div className="space-y-2">
<div>
<Label className="text-xs">Name</Label>
<Input
value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
className="mt-1 h-7 text-xs"
/>
</div>
<div>
<Label className="text-xs">Type</Label>
<Select
value={selectedStep.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, { type: val as StepType })
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sequential">Sequential</SelectItem>
<SelectItem value="parallel">Parallel</SelectItem>
<SelectItem value="conditional">Conditional</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Trigger</Label>
<Select
value={selectedStep.trigger.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
})
}
>
<SelectTrigger className="mt-1 h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}
/* ------------------------------- Empty State ------------------------------- */
return (
<div
className={cn(
"flex h-24 items-center justify-center text-center",
className,
)}
>
<div>
<Settings className="text-muted-foreground/50 mx-auto mb-2 h-6 w-6" />
<h3 className="mb-1 text-sm font-medium">Select Step or Action</h3>
<p className="text-muted-foreground text-xs">
Click in the flow to edit properties
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,443 @@
"use client";
import React from "react";
import { useDroppable } from "@dnd-kit/core";
import {
useSortable,
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";
import {
GripVertical,
ChevronDown,
ChevronRight,
Plus,
Trash2,
Zap,
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Timer,
MousePointer,
Mic,
Activity,
Play,
GitBranch,
} from "lucide-react";
import { cn } from "~/lib/utils";
import type {
ExperimentStep,
ExperimentAction,
} from "~/lib/experiment-designer/types";
import { actionRegistry } from "./ActionRegistry";
/* -------------------------------------------------------------------------- */
/* Icon Map (localized to avoid cross-file re-render dependencies) */
/* -------------------------------------------------------------------------- */
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
MessageSquare,
Hand,
Navigation,
Volume2,
Clock,
Eye,
Bot,
User,
Zap,
Timer,
MousePointer,
Mic,
Activity,
Play,
GitBranch,
};
/* -------------------------------------------------------------------------- */
/* DroppableStep */
/* -------------------------------------------------------------------------- */
interface DroppableStepProps {
stepId: string;
children: React.ReactNode;
isEmpty?: boolean;
}
function DroppableStep({ stepId, children, isEmpty }: DroppableStepProps) {
const { isOver, setNodeRef } = useDroppable({
id: `step-${stepId}`,
});
return (
<div
ref={setNodeRef}
className={cn(
"min-h-[60px] rounded border-2 border-dashed transition-colors",
isOver
? "border-blue-500 bg-blue-50 dark:bg-blue-950/20"
: "border-transparent",
isEmpty && "bg-muted/20",
)}
>
{isEmpty ? (
<div className="flex items-center justify-center p-4 text-center">
<div className="text-muted-foreground">
<Plus className="mx-auto mb-1 h-5 w-5" />
<p className="text-xs">Drop actions here</p>
</div>
</div>
) : (
children
)}
</div>
);
}
/* -------------------------------------------------------------------------- */
/* SortableAction */
/* -------------------------------------------------------------------------- */
interface SortableActionProps {
action: ExperimentAction;
index: number;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}
function SortableAction({
action,
index,
isSelected,
onSelect,
onDelete,
}: SortableActionProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: action.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const def = actionRegistry.getAction(action.type);
const IconComponent = iconMap[def?.icon ?? "Zap"] ?? Zap;
const categoryColors = {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
} as const;
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
className={cn(
"group flex cursor-pointer items-center justify-between rounded border p-2 text-xs transition-colors",
isSelected
? "border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-950/30"
: "hover:bg-accent/50",
isDragging && "opacity-50",
)}
onClick={onSelect}
>
<div className="flex items-center gap-2">
<div
{...listeners}
className="text-muted-foreground/80 hover:text-foreground cursor-grab rounded p-0.5"
>
<GripVertical className="h-3 w-3" />
</div>
<Badge variant="outline" className="h-4 text-[10px]">
{index + 1}
</Badge>
{def && (
<div
className={cn(
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded text-white",
categoryColors[def.category],
)}
>
<IconComponent className="h-2.5 w-2.5" />
</div>
)}
<span className="flex items-center gap-1 truncate font-medium">
{action.source.kind === "plugin" ? (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-emerald-600 text-[8px] font-bold text-white">
P
</span>
) : (
<span className="inline-flex h-3 w-3 items-center justify-center rounded-full bg-slate-500 text-[8px] font-bold text-white">
C
</span>
)}
{action.name}
</span>
<Badge variant="secondary" className="h-4 text-[10px] capitalize">
{(action.type ?? "").replace(/_/g, " ")}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* SortableStep */
/* -------------------------------------------------------------------------- */
interface SortableStepProps {
step: ExperimentStep;
index: number;
isSelected: boolean;
selectedActionId: string | null;
onSelect: () => void;
onDelete: () => void;
onUpdate: (updates: Partial<ExperimentStep>) => void;
onActionSelect: (actionId: string) => void;
onActionDelete: (actionId: string) => void;
}
function SortableStep({
step,
index,
isSelected,
selectedActionId,
onSelect,
onDelete,
onUpdate,
onActionSelect,
onActionDelete,
}: SortableStepProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: step.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const stepTypeColors: Record<ExperimentStep["type"], string> = {
sequential: "border-l-blue-500",
parallel: "border-l-emerald-500",
conditional: "border-l-amber-500",
loop: "border-l-purple-500",
};
return (
<div ref={setNodeRef} style={style} {...attributes}>
<Card
className={cn(
"border-l-4 transition-all",
stepTypeColors[step.type],
isSelected
? "bg-blue-50/50 ring-2 ring-blue-500 dark:bg-blue-950/20 dark:ring-blue-400"
: "",
isDragging && "rotate-2 opacity-50 shadow-lg",
)}
>
<CardHeader className="cursor-pointer pb-2" onClick={() => onSelect()}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onUpdate({ expanded: !step.expanded });
}}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
<Badge variant="outline" className="h-5 text-xs">
{index + 1}
</Badge>
<div>
<div className="text-sm font-medium">{step.name}</div>
<div className="text-muted-foreground text-xs">
{step.actions.length} actions {step.type}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3 w-3" />
</Button>
<div {...listeners} className="cursor-grab p-1">
<GripVertical className="text-muted-foreground h-4 w-4" />
</div>
</div>
</div>
</CardHeader>
{step.expanded && (
<CardContent className="pt-0">
<DroppableStep stepId={step.id} isEmpty={step.actions.length === 0}>
{step.actions.length > 0 && (
<SortableContext
items={step.actions.map((a) => a.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{step.actions.map((action, actionIndex) => (
<SortableAction
key={action.id}
action={action}
index={actionIndex}
isSelected={selectedActionId === action.id}
onSelect={() => onActionSelect(action.id)}
onDelete={() => onActionDelete(action.id)}
/>
))}
</div>
</SortableContext>
)}
</DroppableStep>
</CardContent>
)}
</Card>
</div>
);
}
/* -------------------------------------------------------------------------- */
/* StepFlow (Scrollable Container of Steps) */
/* -------------------------------------------------------------------------- */
export interface StepFlowProps {
steps: ExperimentStep[];
selectedStepId: string | null;
selectedActionId: string | null;
onStepSelect: (id: string) => void;
onStepDelete: (id: string) => void;
onStepUpdate: (id: string, updates: Partial<ExperimentStep>) => void;
onActionSelect: (actionId: string) => void;
onActionDelete: (stepId: string, actionId: string) => void;
onActionUpdate?: (
stepId: string,
actionId: string,
updates: Partial<ExperimentAction>,
) => void;
emptyState?: React.ReactNode;
headerRight?: React.ReactNode;
}
export function StepFlow({
steps,
selectedStepId,
selectedActionId,
onStepSelect,
onStepDelete,
onStepUpdate,
onActionSelect,
onActionDelete,
emptyState,
headerRight,
}: StepFlowProps) {
return (
<Card className="h-[calc(100vh-12rem)]">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4" />
Experiment Flow
</div>
{headerRight}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ScrollArea className="h-full">
<div className="p-2">
{steps.length === 0 ? (
(emptyState ?? (
<div className="py-8 text-center">
<GitBranch className="text-muted-foreground/50 mx-auto h-8 w-8" />
<h3 className="mt-2 text-sm font-medium">No steps yet</h3>
<p className="text-muted-foreground mt-1 text-xs">
Add your first step to begin designing
</p>
</div>
))
) : (
<SortableContext
items={steps.map((s) => s.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{steps.map((step, index) => (
<div key={step.id}>
<SortableStep
step={step}
index={index}
isSelected={selectedStepId === step.id}
selectedActionId={selectedActionId}
onSelect={() => onStepSelect(step.id)}
onDelete={() => onStepDelete(step.id)}
onUpdate={(updates) => onStepUpdate(step.id, updates)}
onActionSelect={onActionSelect}
onActionDelete={(actionId) =>
onActionDelete(step.id, actionId)
}
/>
{index < steps.length - 1 && (
<div className="flex justify-center py-1">
<div className="bg-border h-2 w-px" />
</div>
)}
</div>
))}
</div>
</SortableContext>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -15,12 +15,12 @@ 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 { api } from "~/trpc/react";
@@ -164,7 +164,9 @@ export const columns: ColumnDef<Participant>[] = [
const date = row.getValue("createdAt");
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>
);
},
@@ -238,25 +240,27 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
// Refetch when active study changes
useEffect(() => {
if (activeStudy?.id || studyId) {
refetch();
void refetch();
}
}, [activeStudy?.id, studyId, refetch]);
const data: Participant[] = React.useMemo(() => {
if (!participantsData?.participants) return [];
return participantsData.participants.map((p) => ({
id: p.id,
participantCode: p.participantCode,
email: p.email,
name: p.name,
consentGiven: p.hasConsent,
consentDate: p.latestConsent?.signedAt
? new Date(p.latestConsent.signedAt as unknown as string)
: null,
createdAt: p.createdAt,
trialCount: p.trialCount,
}));
return participantsData.participants.map(
(p): Participant => ({
id: p.id,
participantCode: p.participantCode,
email: p.email,
name: p.name,
consentGiven: p.hasConsent,
consentDate: p.latestConsent?.signedAt
? new Date(p.latestConsent.signedAt as unknown as string)
: null,
createdAt: p.createdAt,
trialCount: p.trialCount,
}),
);
}, [participantsData]);
if (!studyId && !activeStudy) {

View File

@@ -212,7 +212,6 @@ export function PluginStoreBrowse() {
data: availablePlugins,
isLoading,
error,
refetch,
} = api.robots.plugins.list.useQuery(
{
status:
@@ -227,10 +226,14 @@ export function PluginStoreBrowse() {
},
);
const utils = api.useUtils();
const installPluginMutation = api.robots.plugins.install.useMutation({
onSuccess: () => {
toast.success("Plugin installed successfully!");
void refetch();
// Invalidate both plugin queries to refresh the UI
void utils.robots.plugins.list.invalidate();
void utils.robots.plugins.getStudyPlugins.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to install plugin");
@@ -426,7 +429,10 @@ export function PluginStoreBrowse() {
{error.message ||
"An error occurred while loading the plugin store."}
</p>
<Button onClick={() => refetch()} variant="outline">
<Button
onClick={() => void utils.robots.plugins.list.refetch()}
variant="outline"
>
Try Again
</Button>
</div>

View File

@@ -25,6 +25,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
export type Plugin = {
plugin: {
@@ -85,18 +87,45 @@ const statusConfig = {
};
function PluginActionsCell({ plugin }: { plugin: Plugin }) {
const { selectedStudyId } = useStudyContext();
const utils = api.useUtils();
const uninstallMutation = api.robots.plugins.uninstall.useMutation({
onSuccess: () => {
toast.success("Plugin uninstalled successfully");
// Invalidate plugin queries to refresh the UI
void utils.robots.plugins.getStudyPlugins.invalidate();
void utils.robots.plugins.list.invalidate();
},
onError: (error) => {
toast.error(error.message || "Failed to uninstall plugin");
},
});
const isCorePlugin = plugin.plugin.name === "HRIStudio Core System";
const handleUninstall = async () => {
if (isCorePlugin) {
toast.error(
"Cannot uninstall the core system plugin - it's required for experiment design",
);
return;
}
if (
window.confirm(
`Are you sure you want to uninstall "${plugin.plugin.name}"?`,
`Are you sure you want to uninstall "${plugin.plugin.name}"? This will remove all plugin blocks from experiments in this study.`,
)
) {
try {
// TODO: Implement uninstall mutation
toast.success("Plugin uninstalled successfully");
} catch {
toast.error("Failed to uninstall plugin");
if (!selectedStudyId) {
toast.error("No study selected");
return;
}
uninstallMutation.mutate({
studyId: selectedStudyId,
pluginId: plugin.plugin.id,
});
}
};
@@ -145,10 +174,17 @@ function PluginActionsCell({ plugin }: { plugin: Plugin }) {
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleUninstall}
className="text-red-600 focus:text-red-600"
disabled={uninstallMutation.isPending || isCorePlugin}
className={
isCorePlugin ? "text-gray-400" : "text-red-600 focus:text-red-600"
}
>
<Trash2 className="mr-2 h-4 w-4" />
Uninstall
{isCorePlugin
? "Core Plugin"
: uninstallMutation.isPending
? "Uninstalling..."
: "Uninstall"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -0,0 +1,63 @@
"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "~/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
import { handleAuthError, isAuthError } from "~/lib/auth-error-handler";
/**
* Custom hook for centralized study management across the platform.
@@ -52,6 +53,15 @@ export function useStudyManagement() {
return newStudy;
} catch (error) {
console.error("Failed to create study:", error);
// Handle auth errors
if (isAuthError(error)) {
await handleAuthError(
error,
"Authentication failed while creating study",
);
return;
}
const message =
error instanceof Error ? error.message : "Failed to create study";
toast.error(message);
@@ -90,6 +100,15 @@ export function useStudyManagement() {
return updatedStudy;
} catch (error) {
console.error("Failed to update study:", error);
// Handle auth errors
if (isAuthError(error)) {
await handleAuthError(
error,
"Authentication failed while updating study",
);
return;
}
const message =
error instanceof Error ? error.message : "Failed to update study";
toast.error(message);
@@ -121,6 +140,15 @@ export function useStudyManagement() {
// Navigate to studies list
router.push("/studies");
} catch (error) {
console.error("Failed to delete study:", error);
// Handle auth errors
if (isAuthError(error)) {
await handleAuthError(
error,
"Authentication failed while deleting study",
);
return;
}
const message =
error instanceof Error ? error.message : "Failed to delete study";
toast.error(message);
@@ -253,6 +281,12 @@ export function useStudyManagement() {
enabled: !!selectedStudyId,
staleTime: 1000 * 60 * 2, // 2 minutes
retry: (failureCount, error) => {
console.log("Selected study query error:", error);
// Handle auth errors first
if (isAuthError(error)) {
void handleAuthError(error, "Session expired while loading study");
return false;
}
// Don't retry if study not found (404-like errors)
if (
error.message?.includes("not found") ||
@@ -290,6 +324,15 @@ export function useStudyManagement() {
{
staleTime: 1000 * 60 * 2, // 2 minutes
refetchOnWindowFocus: true,
retry: (failureCount, error) => {
console.log("Studies query error:", error);
// Handle auth errors
if (isAuthError(error)) {
void handleAuthError(error, "Session expired while loading studies");
return false;
}
return failureCount < 2;
},
},
);

View File

@@ -0,0 +1,181 @@
"use client";
import { signOut } from "next-auth/react";
import { toast } from "sonner";
import { TRPCClientError } from "@trpc/client";
/**
* Auth error codes that should trigger automatic logout
*/
const AUTH_ERROR_CODES = [
"UNAUTHORIZED",
"FORBIDDEN",
"UNAUTHENTICATED",
] as const;
/**
* Auth error messages that should trigger automatic logout
*/
const AUTH_ERROR_MESSAGES = [
"unauthorized",
"unauthenticated",
"forbidden",
"invalid token",
"token expired",
"session expired",
"authentication failed",
"access denied",
] as const;
/**
* Checks if an error is an authentication/authorization error that should trigger logout
*/
export function isAuthError(error: unknown): boolean {
if (!error) return false;
// Check TRPC errors
if (error instanceof TRPCClientError) {
// Check error code
const trpcErrorData = error.data as
| { code?: string; httpStatus?: number }
| undefined;
const errorCode = trpcErrorData?.code;
if (
errorCode &&
AUTH_ERROR_CODES.includes(errorCode as (typeof AUTH_ERROR_CODES)[number])
) {
return true;
}
// Check HTTP status codes
const httpStatus = trpcErrorData?.httpStatus;
if (httpStatus === 401 || httpStatus === 403) {
return true;
}
// Check error message
const message = error.message?.toLowerCase() ?? "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check generic errors
if (error instanceof Error) {
const message = error.message?.toLowerCase() || "";
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check error objects with message property
if (typeof error === "object" && error !== null) {
if ("message" in error) {
const errorObj = error as { message: unknown };
const message = String(errorObj.message).toLowerCase();
return AUTH_ERROR_MESSAGES.some((authMsg) => message.includes(authMsg));
}
// Check for status codes in error objects
if ("status" in error) {
const statusObj = error as { status: unknown };
const status = statusObj.status as number;
return status === 401 || status === 403;
}
}
return false;
}
/**
* Handles authentication errors by logging out the user
*/
export async function handleAuthError(
error: unknown,
customMessage?: string,
): Promise<void> {
if (!isAuthError(error)) {
return;
}
console.warn("Authentication error detected, logging out user:", error);
// Show user-friendly message
const message = customMessage ?? "Session expired. Please log in again.";
toast.error(message);
// Small delay to let the toast show
setTimeout(() => {
void (async () => {
try {
await signOut({
callbackUrl: "/",
redirect: true,
});
} catch (signOutError) {
console.error("Error during sign out:", signOutError);
// Force redirect if signOut fails
window.location.href = "/";
}
})();
}, 1000);
}
/**
* React Query error handler that automatically handles auth errors
*/
export function createAuthErrorHandler(customMessage?: string) {
return (error: unknown) => {
void handleAuthError(error, customMessage);
};
}
/**
* tRPC error handler that automatically handles auth errors
*/
export function handleTRPCError(error: unknown, customMessage?: string): void {
void handleAuthError(error, customMessage);
}
/**
* Generic error handler for any error type
*/
export function handleGenericError(
error: unknown,
customMessage?: string,
): void {
void handleAuthError(error, customMessage);
}
/**
* Hook-style error handler for use in React components
*/
export function useAuthErrorHandler() {
return {
handleAuthError: (error: unknown, customMessage?: string) => {
void handleAuthError(error, customMessage);
},
isAuthError,
createErrorHandler: createAuthErrorHandler,
};
}
/**
* Higher-order function to wrap API calls with automatic auth error handling
*/
export function withAuthErrorHandling<
T extends (...args: unknown[]) => Promise<unknown>,
>(fn: T, customMessage?: string): T {
return (async (...args: Parameters<T>): Promise<ReturnType<T>> => {
try {
return (await fn(...args)) as ReturnType<T>;
} catch (error) {
await handleAuthError(error, customMessage);
throw error; // Re-throw so calling code can handle it too
}
}) as T;
}
/**
* Utility to check if current error should show a generic error message
* (i.e., it's not an auth error that will auto-logout)
*/
export function shouldShowGenericError(error: unknown): boolean {
return !isAuthError(error);
}

View File

@@ -0,0 +1,160 @@
import type {
ExperimentStep,
ExperimentAction,
ExecutionDescriptor,
} from "./types";
// Convert step-based design to database records
export function convertStepsToDatabase(
steps: ExperimentStep[],
): ConvertedStep[] {
return steps.map((step, index) => ({
name: step.name,
description: step.description,
type: mapStepTypeToDatabase(step.type),
orderIndex: index,
durationEstimate: calculateStepDuration(step.actions),
required: true,
conditions: step.trigger.conditions,
actions: step.actions.map((action, actionIndex) =>
convertActionToDatabase(action, actionIndex),
),
}));
}
// Map designer step types to database step types
function mapStepTypeToDatabase(
stepType: ExperimentStep["type"],
): "wizard" | "robot" | "parallel" | "conditional" {
switch (stepType) {
case "sequential":
return "wizard"; // Default to wizard for sequential
case "parallel":
return "parallel";
case "conditional":
case "loop":
return "conditional";
default:
return "wizard";
}
}
// Calculate step duration from actions
function calculateStepDuration(actions: ExperimentAction[]): number {
let total = 0;
for (const action of actions) {
switch (action.type) {
case "wizard_speak":
case "robot_speak":
// Estimate based on text length if available
const text = action.parameters.text as string;
if (text) {
total += Math.max(2, text.length / 10); // ~10 chars per second
} else {
total += 3;
}
break;
case "wait":
total += (action.parameters.duration as number) || 2;
break;
case "robot_move":
total += 5; // Movement takes longer
break;
case "wizard_gesture":
total += 2;
break;
case "observe":
total += (action.parameters.duration as number) || 5;
break;
default:
total += 2; // Default duration
}
}
return Math.max(1, Math.round(total));
}
// Estimate action timeout
function estimateActionTimeout(action: ExperimentAction): number {
switch (action.type) {
case "wizard_speak":
case "robot_speak":
return 30;
case "robot_move":
return 60;
case "wait": {
const duration = action.parameters.duration as number | undefined;
return (duration ?? 2) + 10; // Add buffer
}
case "observe":
return 120; // Observation can take longer
default:
return 30;
}
}
// Database conversion types (same as before)
export interface ConvertedStep {
name: string;
description?: string;
type: "wizard" | "robot" | "parallel" | "conditional";
orderIndex: number;
durationEstimate?: number;
required: boolean;
conditions: Record<string, unknown>;
actions: ConvertedAction[];
}
export interface ConvertedAction {
name: string;
description?: string;
type: string;
orderIndex: number;
parameters: Record<string, unknown>;
timeout?: number;
// Provenance & execution metadata (flattened for now)
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
transport?: ExecutionDescriptor["transport"];
ros2?: ExecutionDescriptor["ros2"];
rest?: ExecutionDescriptor["rest"];
retryable?: boolean;
parameterSchemaRaw?: unknown;
sourceKind?: "core" | "plugin";
category?: string;
}
// Deprecated legacy compatibility function removed to eliminate unsafe any usage.
// If needed, implement a proper migration path elsewhere.
/**
* Convert a single experiment action into a ConvertedAction (DB shape),
* preserving provenance and execution metadata for reproducibility.
*/
export function convertActionToDatabase(
action: ExperimentAction,
orderIndex: number,
): ConvertedAction {
return {
name: action.name,
description: `${action.type} action`,
type: action.type,
orderIndex,
parameters: action.parameters,
timeout: estimateActionTimeout(action),
pluginId: action.source.pluginId,
pluginVersion: action.source.pluginVersion,
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
transport: action.execution.transport,
ros2: action.execution.ros2,
rest: action.execution.rest,
retryable: action.execution.retryable,
parameterSchemaRaw: action.parameterSchemaRaw,
sourceKind: action.source.kind,
category: action.category,
};
}

View File

@@ -0,0 +1,314 @@
/**
* Execution Compiler Utilities
*
* Purpose:
* - Produce a deterministic execution graph snapshot from the visual design
* - Generate an integrity hash capturing provenance & structural identity
* - Extract normalized plugin dependency list (pluginId@version)
*
* These utilities are used on the server prior to saving an experiment so that
* trial execution can rely on an immutable compiled artifact. This helps ensure
* reproducibility by decoupling future plugin updates from already designed
* experiment protocols.
*
* NOTE:
* - This module intentionally performs only pure / synchronous operations.
* - Any plugin resolution or database queries should happen in a higher layer
* before invoking these functions.
*/
import type {
ExperimentDesign,
ExperimentStep,
ExperimentAction,
ExecutionDescriptor,
} from "./types";
/* ---------- Public Types ---------- */
export interface CompiledExecutionGraph {
version: number;
generatedAt: string; // ISO timestamp
steps: CompiledExecutionStep[];
pluginDependencies: string[];
hash: string;
}
export interface CompiledExecutionStep {
id: string;
name: string;
order: number;
type: ExperimentStep["type"];
trigger: {
type: string;
conditions: Record<string, unknown>;
};
actions: CompiledExecutionAction[];
estimatedDuration?: number;
}
export interface CompiledExecutionAction {
id: string;
name: string;
type: string;
category: string;
provenance: {
sourceKind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
};
execution: ExecutionDescriptor;
parameters: Record<string, unknown>;
parameterSchemaRaw?: unknown;
timeout?: number;
retryable?: boolean;
}
/* ---------- Compile Entry Point ---------- */
/**
* Compile an ExperimentDesign into a reproducible execution graph + hash.
*/
export function compileExecutionDesign(
design: ExperimentDesign,
opts: { hashAlgorithm?: "sha256" | "sha1" } = {},
): CompiledExecutionGraph {
const pluginDependencies = collectPluginDependencies(design);
const compiledSteps: CompiledExecutionStep[] = design.steps
.slice()
.sort((a, b) => a.order - b.order)
.map((step) => compileStep(step));
const structuralSignature = buildStructuralSignature(
design,
compiledSteps,
pluginDependencies,
);
const hash = stableHash(structuralSignature, opts.hashAlgorithm ?? "sha256");
return {
version: 1,
generatedAt: new Date().toISOString(),
steps: compiledSteps,
pluginDependencies,
hash,
};
}
/* ---------- Step / Action Compilation ---------- */
function compileStep(step: ExperimentStep): CompiledExecutionStep {
const compiledActions: CompiledExecutionAction[] = step.actions.map(
(action, index) => compileAction(action, index),
);
return {
id: step.id,
name: step.name,
order: step.order,
type: step.type,
trigger: {
type: step.trigger.type,
conditions: step.trigger.conditions ?? {},
},
actions: compiledActions,
estimatedDuration: step.estimatedDuration,
};
}
function compileAction(
action: ExperimentAction,
_index: number, // index currently unused (reserved for future ordering diagnostics)
): CompiledExecutionAction {
return {
id: action.id,
name: action.name,
type: action.type,
category: action.category,
provenance: {
sourceKind: action.source.kind,
pluginId: action.source.pluginId,
pluginVersion: action.source.pluginVersion,
robotId: action.source.robotId,
baseActionId: action.source.baseActionId,
},
execution: action.execution,
parameters: action.parameters,
parameterSchemaRaw: action.parameterSchemaRaw,
timeout: action.execution.timeoutMs,
retryable: action.execution.retryable,
};
}
/* ---------- Plugin Dependency Extraction ---------- */
export function collectPluginDependencies(design: ExperimentDesign): string[] {
const set = new Set<string>();
for (const step of design.steps) {
for (const action of step.actions) {
if (action.source.kind === "plugin" && action.source.pluginId) {
const versionPart = action.source.pluginVersion
? `@${action.source.pluginVersion}`
: "";
set.add(`${action.source.pluginId}${versionPart}`);
}
}
}
return Array.from(set).sort();
}
/* ---------- Integrity Hash Generation ---------- */
/**
* Build a minimal, deterministic JSON-serializable representation capturing:
* - Step ordering, ids, types, triggers
* - Action ordering, ids, types, provenance, execution transport, parameters (keys only for hash)
* - Plugin dependency list
*
* Parameter values are not fully included (only key presence) to avoid hash churn
* on mutable text fields while preserving structural identity. If full parameter
* value hashing is desired, adjust `summarizeParametersForHash`.
*/
function buildStructuralSignature(
design: ExperimentDesign,
steps: CompiledExecutionStep[],
pluginDependencies: string[],
): unknown {
return {
experimentId: design.id,
version: design.version,
steps: steps.map((s) => ({
id: s.id,
order: s.order,
type: s.type,
trigger: {
type: s.trigger.type,
// Include condition keys only for stability
conditionKeys: Object.keys(s.trigger.conditions).sort(),
},
actions: s.actions.map((a) => ({
id: a.id,
type: a.type,
category: a.category,
provenance: a.provenance,
transport: a.execution.transport,
timeout: a.timeout,
retryable: a.retryable ?? false,
parameterKeys: summarizeParametersForHash(a.parameters),
})),
})),
pluginDependencies,
};
}
function summarizeParametersForHash(params: Record<string, unknown>): string[] {
return Object.keys(params).sort();
}
/* ---------- Stable Hash Implementation ---------- */
/**
* Simple stable hash using built-in Web Crypto if available; falls back
* to a lightweight JS implementation (FNV-1a) for environments without
* crypto.subtle (e.g. some test runners).
*
* This is synchronous; if crypto.subtle is present it still uses
* a synchronous wrapper by blocking on the Promise with deasync style
* simulation (not implemented) so we default to FNV-1a here for portability.
*/
function stableHash(value: unknown, algorithm: "sha256" | "sha1"): string {
// Use a deterministic JSON stringify
const json = JSON.stringify(value);
// FNV-1a 64-bit (represented as hex)
let hashHigh = 0xcbf29ce4;
let hashLow = 0x84222325; // Split 64-bit for simple JS accumulation
for (let i = 0; i < json.length; i++) {
const c = json.charCodeAt(i);
// XOR low part
hashLow ^= c;
// 64-bit FNV prime: 1099511628211 -> split multiply
// (hash * prime) mod 2^64
// Multiply low
let low =
(hashLow & 0xffff) * 0x1b3 +
(((hashLow >>> 16) * 0x1b3) & 0xffff) * 0x10000;
// Include high
low +=
((hashHigh & 0xffff) * 0x1b3 +
(((hashHigh >>> 16) * 0x1b3) & 0xffff) * 0x10000) &
0xffffffff;
// Rotate values (approximate 64-bit handling)
hashHigh ^= low >>> 13;
hashHigh &= 0xffffffff;
hashLow = low & 0xffffffff;
}
// Combine into hex; algorithm param reserved for future (differing strategies)
const highHex = (hashHigh >>> 0).toString(16).padStart(8, "0");
const lowHex = (hashLow >>> 0).toString(16).padStart(8, "0");
return `${algorithm}-${highHex}${lowHex}`;
}
/* ---------- Validation Helpers (Optional Use) ---------- */
/**
* Lightweight structural sanity checks prior to compilation.
* Returns array of issues; empty array means pass.
*/
export function validateDesignStructure(design: ExperimentDesign): string[] {
const issues: string[] = [];
if (!design.steps.length) {
issues.push("No steps defined");
}
const seenStepIds = new Set<string>();
for (const step of design.steps) {
if (seenStepIds.has(step.id)) {
issues.push(`Duplicate step id: ${step.id}`);
} else {
seenStepIds.add(step.id);
}
if (!step.actions.length) {
issues.push(`Step "${step.name}" has no actions`);
}
const seenActionIds = new Set<string>();
for (const action of step.actions) {
if (seenActionIds.has(action.id)) {
issues.push(`Duplicate action id in step "${step.name}": ${action.id}`);
} else {
seenActionIds.add(action.id);
}
if (!action.type) {
issues.push(`Action "${action.id}" missing type`);
}
if (!action.source?.kind) {
issues.push(`Action "${action.id}" missing provenance`);
}
if (!action.execution?.transport) {
issues.push(`Action "${action.id}" missing execution transport`);
}
}
}
return issues;
}
/**
* High-level convenience wrapper: validate + compile; throws on issues.
*/
export function validateAndCompile(
design: ExperimentDesign,
): CompiledExecutionGraph {
const issues = validateDesignStructure(design);
if (issues.length) {
const error = new Error(
`Design validation failed:\n- ${issues.join("\n- ")}`,
);
(error as { issues?: string[] }).issues = issues;
throw error;
}
return compileExecutionDesign(design);
}

View File

@@ -0,0 +1,166 @@
// Core experiment designer types
export type StepType = "sequential" | "parallel" | "conditional" | "loop";
export type ActionCategory = "wizard" | "robot" | "observation" | "control";
export type ActionType =
| "wizard_speak"
| "wizard_gesture"
| "robot_move"
| "robot_speak"
| "wait"
| "observe"
| "collect_data"
// Namespaced plugin action types will use pattern: pluginId.actionId
| (string & {});
export type TriggerType =
| "trial_start"
| "participant_action"
| "timer"
| "previous_step";
export interface ActionParameter {
id: string;
name: string;
type: "text" | "number" | "select" | "boolean";
placeholder?: string;
options?: string[];
min?: number;
max?: number;
value?: string | number | boolean;
required?: boolean;
description?: string;
step?: number; // numeric increment if relevant
}
export interface ActionDefinition {
id: string;
type: ActionType;
name: string;
description: string;
category: ActionCategory;
icon: string;
color: string;
parameters: ActionParameter[];
source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string; // original internal action id inside plugin repo
};
execution?: ExecutionDescriptor;
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
}
export interface ExperimentAction {
id: string;
type: ActionType;
name: string;
parameters: Record<string, unknown>;
duration?: number;
category: ActionCategory;
source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
};
execution: ExecutionDescriptor;
parameterSchemaRaw?: unknown;
}
export interface StepTrigger {
type: TriggerType;
conditions: Record<string, unknown>;
}
export interface ExperimentStep {
id: string;
name: string;
description?: string;
type: StepType;
order: number;
trigger: StepTrigger;
actions: ExperimentAction[];
estimatedDuration?: number;
expanded: boolean;
}
export interface ExperimentDesign {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
compiledAt?: Date; // when an execution plan was compiled
integrityHash?: string; // hash of action definitions for reproducibility
}
// Trigger options for UI
export const TRIGGER_OPTIONS = [
{ value: "trial_start" as const, label: "Trial starts" },
{ value: "participant_action" as const, label: "Participant acts" },
{ value: "timer" as const, label: "After timer" },
{ value: "previous_step" as const, label: "Previous step completes" },
];
// Step type options for UI
export const STEP_TYPE_OPTIONS = [
{
value: "sequential" as const,
label: "Sequential",
description: "Actions run one after another",
},
{
value: "parallel" as const,
label: "Parallel",
description: "Actions run at the same time",
},
{
value: "conditional" as const,
label: "Conditional",
description: "Actions run if condition is met",
},
{
value: "loop" as const,
label: "Loop",
description: "Actions repeat multiple times",
},
];
// Execution descriptors (appended)
export interface ExecutionDescriptor {
transport: "ros2" | "rest" | "internal";
timeoutMs?: number;
retryable?: boolean;
ros2?: Ros2Execution;
rest?: RestExecution;
}
export interface Ros2Execution {
topic?: string;
messageType?: string;
service?: string;
action?: string;
qos?: {
reliability?: string;
durability?: string;
history?: string;
depth?: number;
};
payloadMapping?: unknown; // mapping definition retained for transform at runtime
}
export interface RestExecution {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
path: string;
headers?: Record<string, string>;
query?: Record<string, string | number | boolean>;
bodyTemplate?: unknown;
}

View File

@@ -0,0 +1,326 @@
import { z } from "zod";
import type {
ExperimentStep,
ExperimentAction,
StepType,
TriggerType,
ActionCategory,
ExecutionDescriptor,
} from "./types";
/**
* Visual Design Guard
*
* Provides a robust Zod-based parsing/normalization pipeline that:
* - Accepts a loosely-typed visualDesign.steps payload coming from the client
* - Normalizes and validates it into strongly typed arrays for internal processing
* - Strips unknown fields (preserves only what we rely on)
* - Ensures provenance + execution descriptors are structurally sound
*
* This replaces ad-hoc runtime filtering in the experiments update mutation.
*
* Usage:
* const { steps, issues } = parseVisualDesignSteps(rawSteps);
* if (issues.length) -> reject request
* else -> steps (ExperimentStep[]) is now safe for conversion & compilation
*/
// Enumerations (reuse domain model semantics without hard binding to future expansions)
const stepTypeEnum = z.enum(["sequential", "parallel", "conditional", "loop"]);
const triggerTypeEnum = z.enum([
"trial_start",
"participant_action",
"timer",
"previous_step",
]);
const actionCategoryEnum = z.enum([
"wizard",
"robot",
"observation",
"control",
]);
// Provenance
const actionSourceSchema = z
.object({
kind: z.enum(["core", "plugin"]),
pluginId: z.string().min(1).optional(),
pluginVersion: z.string().min(1).optional(),
robotId: z.string().min(1).nullable().optional(),
baseActionId: z.string().min(1).optional(),
})
.strict();
// Execution descriptor
const executionDescriptorSchema = z
.object({
transport: z.enum(["ros2", "rest", "internal"]),
timeoutMs: z.number().int().positive().optional(),
retryable: z.boolean().optional(),
ros2: z
.object({
topic: z.string().min(1).optional(),
messageType: z.string().min(1).optional(),
service: z.string().min(1).optional(),
action: z.string().min(1).optional(),
qos: z
.object({
reliability: z.string().optional(),
durability: z.string().optional(),
history: z.string().optional(),
depth: z.number().int().optional(),
})
.strict()
.optional(),
payloadMapping: z.unknown().optional(),
})
.strict()
.optional(),
rest: z
.object({
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
path: z.string().min(1),
headers: z.record(z.string(), z.string()).optional(),
})
.strict()
.optional(),
})
.strict();
// Action parameter snapshot is a free-form structure retained for audit
const parameterSchemaRawSchema = z.unknown().optional();
// Action schema (loose input → normalized internal)
const visualActionInputSchema = z
.object({
id: z.string().min(1),
type: z.string().min(1),
name: z.string().min(1),
category: actionCategoryEnum.optional(),
parameters: z.record(z.string(), z.unknown()).default({}),
source: actionSourceSchema.optional(),
execution: executionDescriptorSchema.optional(),
parameterSchemaRaw: parameterSchemaRawSchema,
})
.strict();
// Trigger schema
const triggerSchema = z
.object({
type: triggerTypeEnum,
conditions: z.record(z.string(), z.unknown()).default({}),
})
.strict();
// Step schema
const visualStepInputSchema = z
.object({
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
type: stepTypeEnum,
order: z.number().int().nonnegative().optional(),
trigger: triggerSchema.optional(),
actions: z.array(visualActionInputSchema),
expanded: z.boolean().optional(),
})
.strict();
// Array schema root
const visualDesignStepsSchema = z.array(visualStepInputSchema);
/**
* Parse & normalize raw steps payload.
*/
export function parseVisualDesignSteps(raw: unknown): {
steps: ExperimentStep[];
issues: string[];
} {
const issues: string[] = [];
const parsed = visualDesignStepsSchema.safeParse(raw);
if (!parsed.success) {
const zodErr = parsed.error;
issues.push(
...zodErr.issues.map(
(issue) =>
`steps${
issue.path.length ? "." + issue.path.join(".") : ""
}: ${issue.message} (code=${issue.code})`,
),
);
return { steps: [], issues };
}
// Normalize to internal ExperimentStep[] shape
const inputSteps = parsed.data;
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
const actions: ExperimentAction[] = s.actions.map((a) => {
// Default provenance
const source: {
kind: "core" | "plugin";
pluginId?: string;
pluginVersion?: string;
robotId?: string | null;
baseActionId?: string;
} = a.source
? {
kind: a.source.kind,
pluginId: a.source.pluginId,
pluginVersion: a.source.pluginVersion,
robotId: a.source.robotId ?? null,
baseActionId: a.source.baseActionId,
}
: { kind: "core" };
// Default execution
const execution: ExecutionDescriptor = a.execution
? {
transport: a.execution.transport,
timeoutMs: a.execution.timeoutMs,
retryable: a.execution.retryable,
ros2: a.execution.ros2,
rest: a.execution.rest
? {
method: a.execution.rest.method,
path: a.execution.rest.path,
headers: a.execution.rest.headers
? Object.fromEntries(
Object.entries(a.execution.rest.headers).filter(
(kv): kv is [string, string] =>
typeof kv[1] === "string",
),
)
: undefined,
}
: undefined,
}
: { transport: "internal" };
return {
id: a.id,
type: a.type, // dynamic (pluginId.actionId)
name: a.name,
parameters: a.parameters ?? {},
duration: undefined,
category: (a.category ?? "wizard") as ActionCategory,
source: {
kind: source.kind,
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
pluginVersion:
source.kind === "plugin" ? source.pluginVersion : undefined,
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
baseActionId:
source.kind === "plugin" ? source.baseActionId : undefined,
},
execution,
parameterSchemaRaw: a.parameterSchemaRaw,
};
});
// Construct step
return {
id: s.id,
name: s.name,
description: s.description,
type: s.type as StepType,
order: typeof s.order === "number" ? s.order : idx,
trigger: {
type: (s.trigger?.type ?? "previous_step") as TriggerType,
conditions: s.trigger?.conditions ?? {},
},
actions,
estimatedDuration: undefined,
expanded: s.expanded ?? true,
};
});
// Basic structural checks
const seenStepIds = new Set<string>();
for (const st of normalized) {
if (seenStepIds.has(st.id)) {
issues.push(`Duplicate step id: ${st.id}`);
}
seenStepIds.add(st.id);
if (!st.actions.length) {
issues.push(`Step "${st.name}" has no actions`);
}
const seenActionIds = new Set<string>();
for (const act of st.actions) {
if (seenActionIds.has(act.id)) {
issues.push(`Duplicate action id in step "${st.name}": ${act.id}`);
}
seenActionIds.add(act.id);
if (!act.source.kind) {
issues.push(`Action "${act.id}" missing source.kind`);
}
if (!act.execution.transport) {
issues.push(`Action "${act.id}" missing execution transport`);
}
}
}
return { steps: normalized, issues };
}
/**
* Estimate aggregate duration (in seconds) from normalized steps.
* Uses simple additive heuristic: sum each step's summed action durations
* if present; falls back to rough defaults for certain action patterns.
*/
export function estimateDesignDurationSeconds(steps: ExperimentStep[]): number {
let total = 0;
for (const step of steps) {
let stepSum = 0;
for (const action of step.actions) {
const t = classifyDuration(action);
stepSum += t;
}
total += stepSum;
}
return Math.max(1, Math.round(total));
}
function classifyDuration(action: ExperimentAction): number {
// Heuristic mapping (could be evolved to plugin-provided estimates)
switch (true) {
case action.type.startsWith("wizard_speak"):
case action.type.startsWith("robot_speak"): {
const text = action.parameters.text as string | undefined;
if (text && text.length > 0) {
return Math.max(2, Math.round(text.length / 10));
}
return 3;
}
case action.type.startsWith("wait"): {
const d = action.parameters.duration as number | undefined;
return d && d > 0 ? d : 2;
}
case action.type.startsWith("robot_move"):
return 5;
case action.type.startsWith("wizard_gesture"):
return 2;
case action.type.startsWith("observe"): {
const d = action.parameters.duration as number | undefined;
return d && d > 0 ? d : 5;
}
default:
return 2;
}
}
/**
* Convenience wrapper: validates, returns steps or throws with issues attached.
*/
export function assertVisualDesignSteps(raw: unknown): ExperimentStep[] {
const { steps, issues } = parseVisualDesignSteps(raw);
if (issues.length) {
const err = new Error(
`Visual design validation failed:\n- ${issues.join("\n- ")}`,
);
(err as { issues?: string[] }).issues = issues;
throw err;
}
return steps;
}

View File

@@ -2,6 +2,7 @@ import { adminRouter } from "~/server/api/routers/admin";
import { analyticsRouter } from "~/server/api/routers/analytics";
import { authRouter } from "~/server/api/routers/auth";
import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments";
import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
@@ -28,6 +29,7 @@ export const appRouter = createTRPCRouter({
analytics: analyticsRouter,
collaboration: collaborationRouter,
admin: adminRouter,
dashboard: dashboardRouter,
});
// export type definition of API

View File

@@ -0,0 +1,312 @@
import { and, count, desc, eq, gte, inArray, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
activityLogs,
experiments,
participants,
studies,
studyMembers,
trials,
users,
userSystemRoles,
} from "~/server/db/schema";
export const dashboardRouter = createTRPCRouter({
getRecentActivity: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(20).default(10),
studyId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to
const accessibleStudies = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, userId));
const studyIds = accessibleStudies.map((s) => s.studyId);
// If no accessible studies, return empty
if (studyIds.length === 0) {
return [];
}
// Build where conditions
const whereConditions = input.studyId
? eq(activityLogs.studyId, input.studyId)
: inArray(activityLogs.studyId, studyIds);
// Get recent activity logs
const activities = await ctx.db
.select({
id: activityLogs.id,
action: activityLogs.action,
description: activityLogs.description,
createdAt: activityLogs.createdAt,
user: {
name: users.name,
email: users.email,
},
study: {
name: studies.name,
},
})
.from(activityLogs)
.innerJoin(users, eq(activityLogs.userId, users.id))
.innerJoin(studies, eq(activityLogs.studyId, studies.id))
.where(whereConditions)
.orderBy(desc(activityLogs.createdAt))
.limit(input.limit);
return activities.map((activity) => ({
id: activity.id,
type: activity.action,
title: activity.description,
description: `${activity.study.name} - ${activity.user.name}`,
time: activity.createdAt,
status: "info" as const,
}));
}),
getStudyProgress: protectedProcedure
.input(
z.object({
limit: z.number().min(1).max(10).default(5),
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to with participant counts
const studyProgress = await ctx.db
.select({
id: studies.id,
name: studies.name,
status: studies.status,
createdAt: studies.createdAt,
totalParticipants: count(participants.id),
})
.from(studies)
.innerJoin(studyMembers, eq(studies.id, studyMembers.studyId))
.leftJoin(participants, eq(studies.id, participants.studyId))
.where(
and(eq(studyMembers.userId, userId), eq(studies.status, "active")),
)
.groupBy(studies.id, studies.name, studies.status, studies.createdAt)
.orderBy(desc(studies.createdAt))
.limit(input.limit);
// Get trial completion counts for each study
const studyIds = studyProgress.map((s) => s.id);
const trialCounts =
studyIds.length > 0
? await ctx.db
.select({
studyId: experiments.studyId,
completedTrials: count(trials.id),
})
.from(experiments)
.innerJoin(trials, eq(experiments.id, trials.experimentId))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "completed"),
),
)
.groupBy(experiments.studyId)
: [];
const trialCountMap = new Map(
trialCounts.map((tc) => [tc.studyId, tc.completedTrials]),
);
return studyProgress.map((study) => {
const completedTrials = trialCountMap.get(study.id) ?? 0;
const totalParticipants = study.totalParticipants;
// Calculate progress based on completed trials vs participants
// If no participants, progress is 0; if trials >= participants, progress is 100%
const progress =
totalParticipants > 0
? Math.min(
100,
Math.round((completedTrials / totalParticipants) * 100),
)
: 0;
return {
id: study.id,
name: study.name,
progress,
participants: completedTrials, // Using completed trials as active participants
totalParticipants,
status: study.status,
};
});
}),
getStats: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get studies the user has access to
const accessibleStudies = await ctx.db
.select({ studyId: studyMembers.studyId })
.from(studyMembers)
.where(eq(studyMembers.userId, userId));
const studyIds = accessibleStudies.map((s) => s.studyId);
if (studyIds.length === 0) {
return {
totalStudies: 0,
totalExperiments: 0,
totalParticipants: 0,
totalTrials: 0,
activeTrials: 0,
scheduledTrials: 0,
completedToday: 0,
};
}
// Get total counts
const [studyCount] = await ctx.db
.select({ count: count() })
.from(studies)
.where(inArray(studies.id, studyIds));
const [experimentCount] = await ctx.db
.select({ count: count() })
.from(experiments)
.where(inArray(experiments.studyId, studyIds));
const [participantCount] = await ctx.db
.select({ count: count() })
.from(participants)
.where(inArray(participants.studyId, studyIds));
const [trialCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(inArray(experiments.studyId, studyIds));
// Get active trials count
const [activeTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "in_progress"),
),
);
// Get scheduled trials count
const [scheduledTrialsCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "scheduled"),
),
);
// Get today's completed trials
const today = new Date();
today.setHours(0, 0, 0, 0);
const [completedTodayCount] = await ctx.db
.select({ count: count() })
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(
and(
inArray(experiments.studyId, studyIds),
eq(trials.status, "completed"),
gte(trials.completedAt, today),
),
);
return {
totalStudies: studyCount?.count ?? 0,
totalExperiments: experimentCount?.count ?? 0,
totalParticipants: participantCount?.count ?? 0,
totalTrials: trialCount?.count ?? 0,
activeTrials: activeTrialsCount?.count ?? 0,
scheduledTrials: scheduledTrialsCount?.count ?? 0,
completedToday: completedTodayCount?.count ?? 0,
};
}),
debug: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get user info
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
});
// Get user system roles
const systemRoles = await ctx.db.query.userSystemRoles.findMany({
where: eq(userSystemRoles.userId, userId),
});
// Get user study memberships
const studyMemberships = await ctx.db.query.studyMembers.findMany({
where: eq(studyMembers.userId, userId),
with: {
study: {
columns: {
id: true,
name: true,
status: true,
},
},
},
});
// Get all studies (admin view)
const allStudies = await ctx.db.query.studies.findMany({
columns: {
id: true,
name: true,
status: true,
createdBy: true,
},
where: sql`deleted_at IS NULL`,
limit: 10,
});
return {
user: user
? {
id: user.id,
email: user.email,
name: user.name,
}
: null,
systemRoles: systemRoles.map((r) => r.role),
studyMemberships: studyMemberships.map((m) => ({
studyId: m.studyId,
role: m.role,
study: m.study,
})),
allStudies,
session: {
userId: ctx.session.user.id,
userEmail: ctx.session.user.email,
userRole: ctx.session.user.roles?.[0]?.role ?? null,
},
};
}),
});

View File

@@ -468,6 +468,7 @@ export const robotsRouter = createTRPCRouter({
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
actionDefinitions: plugins.actionDefinitions,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
},

View File

@@ -4,8 +4,15 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
activityLogs, studies, studyMemberRoleEnum, studyMembers,
studyStatusEnum, users, userSystemRoles
activityLogs,
plugins,
studies,
studyMemberRoleEnum,
studyMembers,
studyPlugins,
studyStatusEnum,
users,
userSystemRoles,
} from "~/server/db/schema";
export const studiesRouter = createTRPCRouter({
@@ -274,6 +281,20 @@ export const studiesRouter = createTRPCRouter({
role: "owner",
});
// Auto-install core plugin in new study
const corePlugin = await ctx.db.query.plugins.findFirst({
where: eq(plugins.name, "HRIStudio Core System"),
});
if (corePlugin) {
await ctx.db.insert(studyPlugins).values({
studyId: newStudy.id,
pluginId: corePlugin.id,
configuration: {},
installedBy: userId,
});
}
// Log activity
await ctx.db.insert(activityLogs).values({
studyId: newStudy.id,
@@ -534,7 +555,7 @@ export const studiesRouter = createTRPCRouter({
studyId,
userId,
action: "member_removed",
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? 'Unknown user'}`,
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? "Unknown user"}`,
});
return { success: true };

View File

@@ -393,6 +393,7 @@ export const experiments = createTable(
visualDesign: jsonb("visual_design"),
executionGraph: jsonb("execution_graph"),
pluginDependencies: text("plugin_dependencies").array(),
integrityHash: varchar("integrity_hash", { length: 128 }),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
},
(table) => ({
@@ -496,12 +497,24 @@ export const actions = createTable(
.references(() => steps.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
description: text("description"),
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data'
type: varchar("type", { length: 100 }).notNull(), // e.g., 'speak', 'move', 'wait', 'collect_data' or pluginId.actionId
orderIndex: integer("order_index").notNull(),
parameters: jsonb("parameters").default({}),
validationSchema: jsonb("validation_schema"),
timeout: integer("timeout"), // in seconds
retryCount: integer("retry_count").default(0).notNull(),
// Provenance & execution metadata
sourceKind: varchar("source_kind", { length: 20 }), // 'core' | 'plugin'
pluginId: varchar("plugin_id", { length: 255 }),
pluginVersion: varchar("plugin_version", { length: 50 }),
robotId: varchar("robot_id", { length: 255 }),
baseActionId: varchar("base_action_id", { length: 255 }),
category: varchar("category", { length: 50 }),
transport: varchar("transport", { length: 20 }), // 'ros2' | 'rest' | 'internal'
ros2: jsonb("ros2_config"),
rest: jsonb("rest_config"),
retryable: boolean("retryable"),
parameterSchemaRaw: jsonb("parameter_schema_raw"),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),