From 1ac8296ab738ac59a95eeb746bfcfbacf7adc064 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Fri, 8 Aug 2025 00:37:35 -0400 Subject: [PATCH] chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup) --- bun.lock | 3 + docs/experiment-designer-step-integration.md | 253 +++ docs/quick-reference.md | 26 +- docs/work_in_progress.md | 139 +- package.json | 5 +- robot-plugins | 1 + scripts/seed-core-blocks.ts | 589 ------ scripts/seed-dev.ts | 1231 ++++++------- scripts/seed-plugins.ts | 690 ------- scripts/seed-simple.ts | 729 -------- scripts/seed.ts | 1077 ----------- src/app/(dashboard)/dashboard/page.tsx | 270 ++- .../experiments/[id]/designer/page.tsx | 20 +- src/components/dashboard/DashboardContent.tsx | 6 +- src/components/dashboard/app-sidebar.tsx | 481 +++-- .../experiments/ExperimentsTable.tsx | 51 +- .../experiments/designer/ActionLibrary.tsx | 236 +++ .../experiments/designer/ActionRegistry.ts | 450 +++++ .../experiments/designer/BlockDesigner.tsx | 670 +++++++ .../designer/EnhancedBlockDesigner.tsx | 1587 ----------------- .../experiments/designer/PropertiesPanel.tsx | 461 +++++ .../experiments/designer/StepFlow.tsx | 443 +++++ .../participants/ParticipantsTable.tsx | 44 +- .../plugins/plugin-store-browse.tsx | 12 +- src/components/plugins/plugins-columns.tsx | 52 +- src/components/ui/slider.tsx | 63 + src/hooks/useStudyManagement.ts | 43 + src/lib/auth-error-handler.ts | 181 ++ .../experiment-designer/block-converter.ts | 160 ++ .../experiment-designer/execution-compiler.ts | 314 ++++ src/lib/experiment-designer/types.ts | 166 ++ .../visual-design-guard.ts | 326 ++++ src/server/api/root.ts | 2 + src/server/api/routers/dashboard.ts | 312 ++++ src/server/api/routers/robots.ts | 1 + src/server/api/routers/studies.ts | 27 +- src/server/db/schema.ts | 15 +- 37 files changed, 5378 insertions(+), 5758 deletions(-) create mode 100644 docs/experiment-designer-step-integration.md create mode 160000 robot-plugins delete mode 100644 scripts/seed-core-blocks.ts delete mode 100644 scripts/seed-plugins.ts delete mode 100644 scripts/seed-simple.ts delete mode 100644 scripts/seed.ts create mode 100644 src/components/experiments/designer/ActionLibrary.tsx create mode 100644 src/components/experiments/designer/ActionRegistry.ts create mode 100644 src/components/experiments/designer/BlockDesigner.tsx delete mode 100644 src/components/experiments/designer/EnhancedBlockDesigner.tsx create mode 100644 src/components/experiments/designer/PropertiesPanel.tsx create mode 100644 src/components/experiments/designer/StepFlow.tsx create mode 100644 src/components/ui/slider.tsx create mode 100644 src/lib/auth-error-handler.ts create mode 100644 src/lib/experiment-designer/block-converter.ts create mode 100644 src/lib/experiment-designer/execution-compiler.ts create mode 100644 src/lib/experiment-designer/types.ts create mode 100644 src/lib/experiment-designer/visual-design-guard.ts create mode 100644 src/server/api/routers/dashboard.ts diff --git a/bun.lock b/bun.lock index bd6ae55..04003fa 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/docs/experiment-designer-step-integration.md b/docs/experiment-designer-step-integration.md new file mode 100644 index 0000000..166218c --- /dev/null +++ b/docs/experiment-designer-step-integration.md @@ -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. \ No newline at end of file diff --git a/docs/quick-reference.md b/docs/quick-reference.md index e0ef81d..80a5ca6 100644 --- a/docs/quick-reference.md +++ b/docs/quick-reference.md @@ -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 diff --git a/docs/work_in_progress.md b/docs/work_in_progress.md index bdb43f6..2aedbb5 100644 --- a/docs/work_in_progress.md +++ b/docs/work_in_progress.md @@ -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 \ No newline at end of file +- ✅ 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 \ No newline at end of file diff --git a/package.json b/package.json index 185a4f1..0a16c36 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/robot-plugins b/robot-plugins new file mode 160000 index 0000000..334dc68 --- /dev/null +++ b/robot-plugins @@ -0,0 +1 @@ +Subproject commit 334dc68a22b1265b44e5991b8939d3589718ecca diff --git a/scripts/seed-core-blocks.ts b/scripts/seed-core-blocks.ts deleted file mode 100644 index 5609b34..0000000 --- a/scripts/seed-core-blocks.ts +++ /dev/null @@ -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(); -} diff --git a/scripts/seed-dev.ts b/scripts/seed-dev.ts index 51038a5..fa357c6 100644 --- a/scripts/seed-dev.ts +++ b/scripts/seed-dev.ts @@ -1,91 +1,207 @@ import bcrypt from "bcryptjs"; import { drizzle } from "drizzle-orm/postgres-js"; +import { eq, sql } from "drizzle-orm"; import postgres from "postgres"; import * as schema from "../src/server/db/schema"; // Database connection const connectionString = process.env.DATABASE_URL!; -const sql = postgres(connectionString); -const db = drizzle(sql, { schema }); +const connection = postgres(connectionString); +const db = drizzle(connection, { schema }); + +// Repository sync helper +async function syncRepository( + repoId: string, + repoUrl: string, +): Promise { + try { + console.log(`🔄 Syncing repository: ${repoUrl}`); + + // Use localhost for development + const devUrl = repoUrl.includes("core.hristudio.com") + ? "http://localhost:3000/hristudio-core" + : repoUrl; + + // Fetch repository metadata + const repoResponse = await fetch(`${devUrl}/repository.json`); + if (!repoResponse.ok) { + throw new Error( + `Failed to fetch repository metadata: ${repoResponse.status}`, + ); + } + const repoMetadata = (await repoResponse.json()) as { + description?: string; + author?: { name?: string }; + urls?: { git?: string }; + trust?: string; + }; + + // For core repository, create a single plugin with all block groups + if (repoUrl.includes("core.hristudio.com")) { + const indexResponse = await fetch(`${devUrl}/plugins/index.json`); + if (!indexResponse.ok) { + throw new Error( + `Failed to fetch plugin index: ${indexResponse.status}`, + ); + } + const indexData = (await indexResponse.json()) as { + plugins?: Array<{ blockCount?: number }>; + }; + + // Create core system plugin + await db.insert(schema.plugins).values({ + robotId: null, + name: "HRIStudio Core System", + version: "1.0.0", + description: repoMetadata.description ?? "", + author: repoMetadata.author?.name ?? "Unknown", + repositoryUrl: repoMetadata.urls?.git ?? "", + trustLevel: + (repoMetadata.trust as "official" | "verified" | "community") ?? + "community", + status: "active", + actionDefinitions: [], + metadata: { + platform: "Core", + category: "system", + repositoryId: repoId, + blockGroups: indexData.plugins ?? [], + totalBlocks: + indexData.plugins?.reduce( + (sum: number, p: { blockCount?: number }) => + sum + (p.blockCount ?? 0), + 0, + ) ?? 0, + }, + }); + + console.log( + `✅ Synced core system with ${indexData.plugins?.length ?? 0} block groups`, + ); + return 1; + } + + // For robot repositories, sync individual plugins + const pluginIndexResponse = await fetch(`${devUrl}/plugins/index.json`); + if (!pluginIndexResponse.ok) { + throw new Error( + `Failed to fetch plugin index: ${pluginIndexResponse.status}`, + ); + } + const pluginFiles = (await pluginIndexResponse.json()) as string[]; + + let syncedCount = 0; + for (const pluginFile of pluginFiles) { + try { + const pluginResponse = await fetch(`${devUrl}/plugins/${pluginFile}`); + if (!pluginResponse.ok) { + console.warn( + `Failed to fetch ${pluginFile}: ${pluginResponse.status}`, + ); + continue; + } + const pluginData = (await pluginResponse.json()) as { + name?: string; + version?: string; + description?: string; + manufacturer?: { name?: string }; + documentation?: { mainUrl?: string }; + trustLevel?: string; + actions?: unknown[]; + platform?: string; + category?: string; + specs?: unknown; + ros2Config?: unknown; + }; + + await db.insert(schema.plugins).values({ + robotId: null, // Will be matched later if needed + name: pluginData.name ?? pluginFile.replace(".json", ""), + version: pluginData.version ?? "1.0.0", + description: pluginData.description ?? "", + author: + pluginData.manufacturer?.name ?? + repoMetadata.author?.name ?? + "Unknown", + repositoryUrl: + pluginData.documentation?.mainUrl ?? repoMetadata.urls?.git ?? "", + trustLevel: + (pluginData.trustLevel as "official" | "verified" | "community") ?? + (repoMetadata.trust as "official" | "verified" | "community") ?? + "community", + status: "active", + actionDefinitions: pluginData.actions ?? [], + metadata: { + platform: pluginData.platform, + category: pluginData.category, + repositoryId: repoId, + specs: pluginData.specs, + ros2Config: pluginData.ros2Config, + }, + }); + + console.log(`✅ Synced plugin: ${pluginData.name}`); + syncedCount++; + } catch (error) { + console.warn(`Failed to process ${pluginFile}:`, error); + } + } + + return syncedCount; + } catch (error) { + console.error(`Failed to sync repository ${repoUrl}:`, error); + return 0; + } +} async function main() { - console.log("🌱 Starting seed script..."); + console.log("🌱 Starting simplified seed script..."); try { // Clean existing data (in reverse order of dependencies) console.log("🧹 Cleaning existing data..."); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.trialEvents); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.trials); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.steps); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.experiments); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.participants); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.studyMembers); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.userSystemRoles); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.studies); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.users); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.pluginRepositories); - // eslint-disable-next-line drizzle/enforce-delete-with-where - await db.delete(schema.robots); + await db.delete(schema.studyPlugins).where(sql`1=1`); + await db.delete(schema.plugins).where(sql`1=1`); + await db.delete(schema.pluginRepositories).where(sql`1=1`); + await db.delete(schema.trialEvents).where(sql`1=1`); + await db.delete(schema.trials).where(sql`1=1`); + await db.delete(schema.steps).where(sql`1=1`); + await db.delete(schema.experiments).where(sql`1=1`); + await db.delete(schema.participants).where(sql`1=1`); + await db.delete(schema.studyMembers).where(sql`1=1`); + await db.delete(schema.userSystemRoles).where(sql`1=1`); + await db.delete(schema.studies).where(sql`1=1`); + await db.delete(schema.users).where(sql`1=1`); + await db.delete(schema.robots).where(sql`1=1`); - // Create robots first + // Create robots console.log("🤖 Creating robots..."); const robots = [ { - name: "NAO Robot", + 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"], + communicationProtocol: "ros2" as const, + }, + { + name: "NAO Humanoid Robot", manufacturer: "SoftBank Robotics", model: "NAO V6", - version: "2.8", - capabilities: { - speech: true, - movement: true, - vision: true, - touch: true, - leds: true, - }, - connectionType: "wifi", - status: "available", - }, - { - name: "Pepper Robot", - manufacturer: "SoftBank Robotics", - model: "Pepper", - version: "2.9", - capabilities: { - speech: true, - movement: true, - vision: true, - touch: true, - tablet: true, - }, - connectionType: "wifi", - status: "available", - }, - { - name: "TurtleBot3", - manufacturer: "ROBOTIS", - model: "Burger", - version: "1.0", - capabilities: { - movement: true, - vision: true, - lidar: true, - }, - connectionType: "ros2", - status: "maintenance", + description: + "Humanoid robot designed for education, research, and social interaction", + capabilities: ["speech", "vision", "walking", "gestures"], + communicationProtocol: "rest" as const, }, ]; - await db.insert(schema.robots).values(robots); + const insertedRobots = await db + .insert(schema.robots) + .values(robots) + .returning(); + console.log(`✅ Created ${insertedRobots.length} robots`); // Create users console.log("👥 Creating users..."); @@ -129,749 +245,406 @@ async function main() { }, ]; - await db.insert(schema.users).values(users); + const insertedUsers = await db + .insert(schema.users) + .values(users) + .returning(); + console.log(`✅ Created ${insertedUsers.length} users`); // Assign system roles console.log("🎭 Assigning system roles..."); - // Get user IDs after insertion - const insertedUsers = await db.select().from(schema.users); - const seanUser = insertedUsers.find( - (u) => u.email === "sean@soconnor.dev", - )!; - const aliceUser = insertedUsers.find( - (u) => u.email === "alice.rodriguez@university.edu", - )!; - const bobUser = insertedUsers.find( - (u) => u.email === "bob.chen@research.org", - )!; - const emilyUser = insertedUsers.find( - (u) => u.email === "emily.watson@lab.edu", - )!; - const mariaUser = insertedUsers.find( - (u) => u.email === "maria.santos@tech.edu", - )!; + const seanUser = insertedUsers.find((u) => u.email === "sean@soconnor.dev"); - const systemRoles = [ + if (!seanUser) { + throw new Error("Sean user not found after creation"); + } + + await db.insert(schema.userSystemRoles).values({ + userId: seanUser.id, + role: "administrator", + }); + + console.log(`✅ Assigned administrator role to Sean`); + + // Create plugin repositories + console.log("📦 Creating plugin repositories..."); + const repositories = [ { - userId: seanUser.id, // Sean O'Connor - role: "administrator" as const, - grantedBy: seanUser.id, + 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, + syncStatus: "pending" as const, + createdBy: seanUser.id, }, { - userId: aliceUser.id, // Alice Rodriguez - role: "researcher" as const, - grantedBy: seanUser.id, - }, - { - userId: bobUser.id, // Bob Chen - role: "researcher" as const, - grantedBy: seanUser.id, - }, - { - userId: emilyUser.id, // Emily Watson - role: "wizard" as const, - grantedBy: seanUser.id, - }, - { - userId: mariaUser.id, // Maria Santos - role: "researcher" as const, - grantedBy: seanUser.id, + 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, + syncStatus: "pending" as const, + createdBy: seanUser.id, }, ]; - await db.insert(schema.userSystemRoles).values(systemRoles); + const insertedRepos = await db + .insert(schema.pluginRepositories) + .values(repositories) + .returning(); + console.log(`✅ Created ${insertedRepos.length} plugin repositories`); + + // Sync repositories to populate plugins + console.log("🔄 Syncing plugin repositories..."); + let totalPlugins = 0; + + for (const repo of insertedRepos) { + const syncedCount = await syncRepository(repo.id, repo.url); + totalPlugins += syncedCount; + + // Update sync status + await db + .update(schema.pluginRepositories) + .set({ + syncStatus: syncedCount > 0 ? "completed" : "failed", + lastSyncAt: new Date(), + }) + .where(eq(schema.pluginRepositories.id, repo.id)); + } // Create studies console.log("📚 Creating studies..."); const studies = [ { - name: "Robot-Assisted Learning in Elementary Education", + name: "Human-Robot Collaboration Study", description: - "Investigating the effectiveness of social robots in supporting mathematics learning for elementary school students. This study examines how children interact with robotic tutors and measures learning outcomes.", - institution: "University of Technology", + "Investigating collaborative tasks between humans and robots in shared workspace environments", + institution: "MIT Computer Science", irbProtocol: "IRB-2024-001", status: "active" as const, - createdBy: aliceUser.id, // Alice Rodriguez + createdBy: seanUser.id, }, { - name: "Elderly Care Robot Acceptance Study", + name: "Robot Navigation Study", description: - "Exploring the acceptance and usability of companion robots among elderly populations in assisted living facilities. Focus on emotional responses and daily interaction patterns.", - institution: "Research Institute for Aging", + "A comprehensive study on robot navigation and obstacle avoidance in dynamic environments", + institution: "Stanford HCI Lab", irbProtocol: "IRB-2024-002", - status: "active" as const, - createdBy: bobUser.id, // Bob Chen + status: "draft" as const, + createdBy: seanUser.id, }, { - name: "Navigation Robot Trust Study", + name: "Social Robot Interaction Study", description: - "Examining human trust in autonomous navigation robots in public spaces. Measuring behavioral indicators of trust and comfort levels during robot-guided navigation tasks.", - institution: "Tech University", + "Examining social dynamics between humans and humanoid robots in educational settings", + institution: "Carnegie Mellon", irbProtocol: "IRB-2024-003", - status: "draft" as const, - createdBy: mariaUser.id, // Maria Santos + status: "active" as const, + createdBy: seanUser.id, }, ]; - await db.insert(schema.studies).values(studies); - - // Get study IDs after insertion - const insertedStudies = await db.select().from(schema.studies); - const study1 = insertedStudies.find( - (s) => s.name === "Robot-Assisted Learning in Elementary Education", - )!; - const study2 = insertedStudies.find( - (s) => s.name === "Elderly Care Robot Acceptance Study", - )!; - const study3 = insertedStudies.find( - (s) => s.name === "Navigation Robot Trust Study", - )!; + const insertedStudies = await db + .insert(schema.studies) + .values(studies) + .returning(); + console.log(`✅ Created ${insertedStudies.length} studies`); // Create study memberships console.log("👥 Creating study memberships..."); - const studyMemberships = [ - // Study 1 members - { - studyId: study1.id, - userId: aliceUser.id, // Alice (owner) - role: "owner" as const, - joinedAt: new Date(), - }, - { - studyId: study1.id, - userId: emilyUser.id, // Emily (wizard) - role: "wizard" as const, - joinedAt: new Date(), - }, - { - studyId: study1.id, - userId: seanUser.id, // Sean (researcher) - role: "researcher" as const, - joinedAt: new Date(), - }, + const studyMemberships = []; - // Study 2 members - { - studyId: study2.id, - userId: bobUser.id, // Bob (owner) + // Sean as owner of all studies + for (const study of insertedStudies) { + studyMemberships.push({ + studyId: study.id, + userId: seanUser.id, role: "owner" as const, - joinedAt: new Date(), - }, - { - studyId: study2.id, - userId: aliceUser.id, // Alice (researcher) - role: "researcher" as const, - joinedAt: new Date(), - }, - { - studyId: study2.id, - userId: emilyUser.id, // Emily (wizard) - role: "wizard" as const, - joinedAt: new Date(), - }, + }); + } - // Study 3 members - { - studyId: study3.id, - userId: mariaUser.id, // Maria (owner) - role: "owner" as const, - joinedAt: new Date(), - }, - { - studyId: study3.id, - userId: seanUser.id, // Sean (researcher) + // Add other users as researchers/wizards + const otherUsers = insertedUsers.filter((u) => u.id !== seanUser.id); + if (otherUsers.length > 0 && insertedStudies[0]) { + studyMemberships.push({ + studyId: insertedStudies[0].id, + userId: otherUsers[0]!.id, role: "researcher" as const, - joinedAt: new Date(), - }, - ]; + }); + + if (otherUsers.length > 1 && insertedStudies[1]) { + studyMemberships.push({ + studyId: insertedStudies[1].id, + userId: otherUsers[1]!.id, + role: "wizard" as const, + }); + } + } await db.insert(schema.studyMembers).values(studyMemberships); + console.log(`✅ Created ${studyMemberships.length} study memberships`); - // Create participants + // Install core plugin in all studies + console.log("🔌 Installing core plugin in all studies..."); + const corePlugin = await db + .select() + .from(schema.plugins) + .where(eq(schema.plugins.name, "HRIStudio Core System")) + .limit(1); + + if (corePlugin.length > 0) { + const coreInstallations = insertedStudies.map((study) => ({ + studyId: study.id, + pluginId: corePlugin[0]!.id, + configuration: {}, + installedBy: seanUser.id, + })); + + await db.insert(schema.studyPlugins).values(coreInstallations); + console.log( + `✅ Installed core plugin in ${insertedStudies.length} studies`, + ); + } + + // Create some participants console.log("👤 Creating participants..."); - const participants = [ - // Study 1 participants (children) - { - studyId: study1.id, - participantCode: "CHILD_001", - name: "Alex Johnson", - email: "parent1@email.com", - demographics: { age: 8, gender: "male", grade: 3 }, - consentGiven: true, - consentDate: new Date("2024-01-15"), - }, - { - studyId: study1.id, - participantCode: "CHILD_002", - name: "Emma Davis", - email: "parent2@email.com", - demographics: { age: 9, gender: "female", grade: 4 }, - consentGiven: true, - consentDate: new Date("2024-01-16"), - }, - { - studyId: study1.id, - participantCode: "CHILD_003", - name: "Oliver Smith", - email: "parent3@email.com", - demographics: { age: 7, gender: "male", grade: 2 }, - consentGiven: true, - consentDate: new Date("2024-01-17"), - }, + const participants = []; - // Study 2 participants (elderly) - { - studyId: study2.id, - participantCode: "ELDERLY_001", - name: "Margaret Thompson", - email: "mthompson@email.com", - demographics: { - age: 78, - gender: "female", - living_situation: "assisted_living", - }, - consentGiven: true, - consentDate: new Date("2024-01-20"), - }, - { - studyId: study2.id, - participantCode: "ELDERLY_002", - name: "Robert Wilson", - email: "rwilson@email.com", - demographics: { - age: 82, - gender: "male", - living_situation: "independent", - }, - consentGiven: true, - consentDate: new Date("2024-01-21"), - }, - { - studyId: study2.id, - participantCode: "ELDERLY_003", - name: "Dorothy Garcia", - email: "dgarcia@email.com", - demographics: { - age: 75, - gender: "female", - living_situation: "assisted_living", - }, - consentGiven: true, - consentDate: new Date("2024-01-22"), - }, + for (let i = 0; i < insertedStudies.length; i++) { + const study = insertedStudies[i]; + if (study) { + participants.push( + { + studyId: study.id, + participantCode: `P${String(i * 2 + 1).padStart(3, "0")}`, + name: `Participant ${i * 2 + 1}`, + email: `participant${i * 2 + 1}@example.com`, + demographics: { age: 25 + i, gender: "prefer not to say" }, + consentGiven: true, + consentGivenAt: new Date(), + }, + { + studyId: study.id, + participantCode: `P${String(i * 2 + 2).padStart(3, "0")}`, + name: `Participant ${i * 2 + 2}`, + email: `participant${i * 2 + 2}@example.com`, + demographics: { age: 30 + i, gender: "prefer not to say" }, + consentGiven: true, + consentGivenAt: new Date(), + }, + ); + } + } - // Study 3 participants (adults) - { - studyId: study3.id, - participantCode: "ADULT_001", - name: "James Miller", - email: "jmiller@email.com", - demographics: { age: 28, gender: "male", occupation: "engineer" }, - consentGiven: true, - consentDate: new Date("2024-01-25"), - }, - { - studyId: study3.id, - participantCode: "ADULT_002", - name: "Sarah Brown", - email: "sbrown@email.com", - demographics: { age: 34, gender: "female", occupation: "teacher" }, - consentGiven: true, - consentDate: new Date("2024-01-26"), - }, - ]; + const insertedParticipants = await db + .insert(schema.participants) + .values(participants) + .returning(); + console.log(`✅ Created ${insertedParticipants.length} participants`); - await db.insert(schema.participants).values(participants); - - // Get inserted robot and participant IDs - const insertedRobots = await db.select().from(schema.robots); - const naoRobot = insertedRobots.find((r) => r.name === "NAO Robot")!; - const pepperRobot = insertedRobots.find((r) => r.name === "Pepper Robot")!; - - const insertedParticipants = await db.select().from(schema.participants); - - // Create experiments + // Create basic experiments console.log("🧪 Creating experiments..."); - const experiments = [ - { - studyId: study1.id, - name: "Math Tutoring Session", - description: - "Robot provides personalized math instruction and encouragement", - version: 1, - robotId: naoRobot.id, // NAO Robot - status: "ready" as const, - estimatedDuration: 30, - createdBy: aliceUser.id, - }, - { - studyId: study1.id, - name: "Reading Comprehension Support", - description: - "Robot assists with reading exercises and comprehension questions", - version: 1, - robotId: naoRobot.id, // NAO Robot - status: "testing" as const, - estimatedDuration: 25, - createdBy: aliceUser.id, - }, - { - studyId: study2.id, - name: "Daily Companion Interaction", - description: - "Robot engages in conversation and provides daily reminders", - version: 1, - robotId: pepperRobot.id, // Pepper Robot - status: "ready" as const, - estimatedDuration: 45, - createdBy: bobUser.id, - }, - { - studyId: study2.id, - name: "Medication Reminder Protocol", - description: "Robot provides medication reminders and health check-ins", - version: 1, - robotId: pepperRobot.id, // Pepper Robot - status: "draft" as const, - estimatedDuration: 15, - createdBy: bobUser.id, - }, - { - studyId: study3.id, - name: "Campus Navigation Assistance", - description: - "Robot guides participants through campus navigation tasks", - version: 1, - robotId: insertedRobots.find((r) => r.name === "TurtleBot3")!.id, // TurtleBot3 - status: "ready" as const, - estimatedDuration: 20, - createdBy: mariaUser.id, - }, - ]; + const experiments = insertedStudies.map((study, i) => ({ + studyId: study.id, + name: `Basic Interaction Protocol ${i + 1}`, + description: `A simple human-robot interaction experiment for ${study.name}`, + version: 1, + status: "ready" as const, + estimatedDuration: 30 + i * 10, + createdBy: seanUser.id, + })); - await db.insert(schema.experiments).values(experiments); + const insertedExperiments = await db + .insert(schema.experiments) + .values(experiments) + .returning(); + console.log(`✅ Created ${insertedExperiments.length} experiments`); - // Get inserted experiment IDs - const insertedExperiments = await db.select().from(schema.experiments); - const experiment1 = insertedExperiments.find( - (e) => e.name === "Math Tutoring Session", - )!; - const experiment3 = insertedExperiments.find( - (e) => e.name === "Daily Companion Interaction", - )!; - const experiment5 = insertedExperiments.find( - (e) => e.name === "Campus Navigation Assistance", - )!; + // Create some trials for dashboard demo + console.log("🧪 Creating sample trials..."); + const trials = []; - // Create experiment steps - console.log("📋 Creating experiment steps..."); - const steps = [ - // Math Tutoring Session steps - { - experimentId: experiment1.id, - name: "Welcome and Introduction", - description: "Robot introduces itself and explains the session", - type: "wizard" as const, - orderIndex: 1, - durationEstimate: 300, // 5 minutes - required: true, - }, - { - experimentId: experiment1.id, - name: "Math Problem Presentation", - description: "Robot presents age-appropriate math problems", - type: "robot" as const, - orderIndex: 2, - durationEstimate: 1200, // 20 minutes - required: true, - }, - { - experimentId: experiment1.id, - name: "Encouragement and Feedback", - description: "Robot provides positive feedback and encouragement", - type: "wizard" as const, - orderIndex: 3, - durationEstimate: 300, // 5 minutes - required: true, - }, + for (const experiment of insertedExperiments) { + if (!experiment) continue; - // Daily Companion Interaction steps - { - experimentId: experiment3.id, - name: "Morning Greeting", - description: "Robot greets participant and asks about their day", - type: "wizard" as const, - orderIndex: 1, - durationEstimate: 600, // 10 minutes - required: true, - }, - { - experimentId: experiment3.id, - name: "Health Check-in", - description: "Robot asks about health and well-being", - type: "wizard" as const, - orderIndex: 2, - durationEstimate: 900, // 15 minutes - required: true, - }, - { - experimentId: experiment3.id, - name: "Activity Planning", - description: "Robot helps plan daily activities", - type: "robot" as const, - orderIndex: 3, - durationEstimate: 1200, // 20 minutes - required: true, - }, + const studyParticipants = insertedParticipants.filter( + (p) => p.studyId === experiment.studyId, + ); - // Campus Navigation steps - { - experimentId: experiment5.id, - name: "Navigation Instructions", - description: "Robot explains navigation task and safety protocols", - type: "wizard" as const, - orderIndex: 1, - durationEstimate: 300, // 5 minutes - required: true, - }, - { - experimentId: experiment5.id, - name: "Guided Navigation", - description: "Robot guides participant to designated location", - type: "robot" as const, - orderIndex: 2, - durationEstimate: 900, // 15 minutes - required: true, - }, - ]; + if (studyParticipants.length > 0) { + // Create 2-3 trials per experiment + const trialCount = Math.min(studyParticipants.length, 3); + for (let j = 0; j < trialCount; j++) { + const participant = studyParticipants[j]; + if (participant) { + const scheduledAt = new Date( + Date.now() - Math.random() * 2 * 24 * 60 * 60 * 1000, + ); + const startedAt = new Date(scheduledAt.getTime() + 30 * 60 * 1000); // 30 minutes after scheduled + const completedAt = new Date(startedAt.getTime() + 45 * 60 * 1000); // 45 minutes after started - await db.insert(schema.steps).values(steps); + // Vary the status: some completed, some in progress, some scheduled + let status: "scheduled" | "in_progress" | "completed" | "aborted"; + let actualStartedAt = null; + let actualCompletedAt = null; - // Get inserted step IDs - const insertedSteps = await db.select().from(schema.steps); + if (j === 0) { + status = "completed"; + actualStartedAt = startedAt; + actualCompletedAt = completedAt; + } else if (j === 1 && trialCount > 2) { + status = "in_progress"; + actualStartedAt = startedAt; + } else { + status = "scheduled"; + } - // Create trials - console.log("🏃 Creating trials..."); - const now = new Date(); - const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000); - const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); + trials.push({ + participantId: participant.id, + experimentId: experiment.id, + sessionNumber: j + 1, + status, + scheduledAt, + startedAt: actualStartedAt, + completedAt: actualCompletedAt, + notes: `Trial session ${j + 1} for ${experiment.name}`, + createdBy: seanUser.id, + }); + } + } + } + } - const trials = [ - // Completed trials - { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_001", - )!.id, // Alex Johnson - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 1, - status: "completed" as const, - scheduledAt: new Date("2024-02-01T10:00:00Z"), - startedAt: new Date("2024-02-01T10:05:00Z"), - completedAt: new Date("2024-02-01T10:32:00Z"), - duration: 27 * 60, // 27 minutes - notes: "Participant was very engaged and showed good comprehension", - }, - { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_002", - )!.id, // Emma Davis - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 1, - status: "completed" as const, - scheduledAt: new Date("2024-02-01T11:00:00Z"), - startedAt: new Date("2024-02-01T11:02:00Z"), - completedAt: new Date("2024-02-01T11:28:00Z"), - duration: 26 * 60, // 26 minutes - notes: - "Excellent performance, participant seemed to enjoy the interaction", - }, - { - experimentId: experiment3.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "ELDERLY_001", - )!.id, // Margaret Thompson - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 1, - status: "completed" as const, - scheduledAt: new Date("2024-02-02T14:00:00Z"), - startedAt: new Date("2024-02-02T14:03:00Z"), - completedAt: new Date("2024-02-02T14:48:00Z"), - duration: 45 * 60, // 45 minutes - notes: "Participant was initially hesitant but warmed up to the robot", - }, + const insertedTrials = await db + .insert(schema.trials) + .values(trials) + .returning(); + console.log(`✅ Created ${insertedTrials.length} trials`); - // In progress trial - { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_003", - )!.id, // Sophia Martinez - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 1, - status: "in_progress" as const, - scheduledAt: now, - startedAt: new Date(now.getTime() - 10 * 60 * 1000), // Started 10 minutes ago - completedAt: null, - duration: null, - notes: "Session in progress", - }, + // Create some activity logs for dashboard demo + console.log("📝 Creating activity logs..."); + const activityEntries = []; - // Scheduled trials - { - experimentId: experiment3.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "ELDERLY_002", - )!.id, // Robert Wilson - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 1, - status: "scheduled" as const, - scheduledAt: tomorrow, - startedAt: null, - completedAt: null, - duration: null, - notes: null, - }, - { - experimentId: experiment5.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "ADULT_001", - )!.id, // James Miller - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 1, - status: "scheduled" as const, - scheduledAt: nextWeek, - startedAt: null, - completedAt: null, - duration: null, - notes: null, - }, - { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_001", - )!.id, // Alex Johnson - wizardId: emilyUser.id, // Emily Watson - sessionNumber: 2, - status: "scheduled" as const, - scheduledAt: new Date(nextWeek.getTime() + 2 * 24 * 60 * 60 * 1000), - startedAt: null, - completedAt: null, - duration: null, - notes: null, - }, - ]; + // Study creation activities + for (const study of insertedStudies) { + activityEntries.push({ + studyId: study.id, + userId: seanUser.id, + action: "study_created", + description: `Created study "${study.name}"`, + createdAt: new Date( + Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000, + ), // Random time in last week + }); + } - await db.insert(schema.trials).values(trials); + // Experiment creation activities + for (const experiment of insertedExperiments) { + activityEntries.push({ + studyId: experiment.studyId, + userId: seanUser.id, + action: "experiment_created", + description: `Created experiment protocol "${experiment.name}"`, + createdAt: new Date( + Date.now() - Math.random() * 5 * 24 * 60 * 60 * 1000, + ), // Random time in last 5 days + }); + } - // Get inserted trial IDs - const insertedTrials = await db.select().from(schema.trials); + // Participant enrollment activities + for (const participant of insertedParticipants) { + activityEntries.push({ + studyId: participant.studyId, + userId: seanUser.id, + action: "participant_enrolled", + description: `Enrolled participant ${participant.participantCode}`, + createdAt: new Date( + Date.now() - Math.random() * 3 * 24 * 60 * 60 * 1000, + ), // Random time in last 3 days + }); + } - // Create trial events for completed trials - console.log("📝 Creating trial events..."); - const trialEvents = [ - // Events for Alex Johnson's completed trial - { - trialId: insertedTrials[0]!.id, - eventType: "trial_started" as const, - timestamp: new Date("2024-02-01T10:05:00Z"), - data: { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_001", - )!.id, + // Plugin installation activities + for (const study of insertedStudies) { + activityEntries.push({ + studyId: study.id, + userId: seanUser.id, + action: "plugin_installed", + description: "Installed HRIStudio Core System plugin", + createdAt: new Date( + Date.now() - Math.random() * 2 * 24 * 60 * 60 * 1000, + ), // Random time in last 2 days + }); + } + + // Add some recent activities + const firstStudy = insertedStudies[0]; + if (firstStudy) { + activityEntries.push( + { + studyId: firstStudy.id, + userId: seanUser.id, + action: "trial_scheduled", + description: "Scheduled new trial session", + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago }, - }, - { - trialId: insertedTrials[0]!.id, - eventType: "step_started" as const, - timestamp: new Date("2024-02-01T10:05:30Z"), - data: { - stepId: insertedSteps[0]!.id, - stepName: "Welcome and Introduction", + { + studyId: firstStudy.id, + userId: seanUser.id, + action: "experiment_updated", + description: "Updated experiment parameters", + createdAt: new Date(Date.now() - 4 * 60 * 60 * 1000), // 4 hours ago }, - }, - { - trialId: insertedTrials[0]!.id, - eventType: "robot_action" as const, - timestamp: new Date("2024-02-01T10:06:00Z"), - data: { - action: "speak", - content: "Hello Alex! I'm excited to work on math with you today.", - }, - }, - { - trialId: insertedTrials[0]!.id, - eventType: "step_completed" as const, - timestamp: new Date("2024-02-01T10:10:30Z"), - data: { stepId: insertedSteps[0]!.id, duration: 300 }, - }, - { - trialId: insertedTrials[0]!.id, - eventType: "step_started" as const, - timestamp: new Date("2024-02-01T10:10:45Z"), - data: { - stepId: insertedSteps[1]!.id, - stepName: "Math Problem Presentation", - }, - }, - { - trialId: insertedTrials[0]!.id, - eventType: "trial_completed" as const, - timestamp: new Date("2024-02-01T10:32:00Z"), - data: { totalDuration: 27 * 60, outcome: "successful" }, - }, + ); + } - // Events for Emma Davis's completed trial - { - trialId: insertedTrials[1]!.id, - eventType: "trial_started" as const, - timestamp: new Date("2024-02-01T11:02:00Z"), - data: { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_002", - )!.id, - }, - }, - { - trialId: insertedTrials[1]!.id, - eventType: "step_started" as const, - timestamp: new Date("2024-02-01T11:02:30Z"), - data: { - stepId: insertedSteps[0]!.id, - stepName: "Welcome and Introduction", - }, - }, - { - trialId: insertedTrials[1]!.id, - eventType: "robot_action" as const, - timestamp: new Date("2024-02-01T11:03:00Z"), - data: { - action: "speak", - content: "Hi Emma! Are you ready for some fun math problems?", - }, - }, - { - trialId: insertedTrials[1]!.id, - eventType: "trial_completed" as const, - timestamp: new Date("2024-02-01T11:28:00Z"), - data: { totalDuration: 26 * 60, outcome: "successful" }, - }, + const insertedActivity = await db + .insert(schema.activityLogs) + .values(activityEntries) + .returning(); + console.log(`✅ Created ${insertedActivity.length} activity log entries`); - // Events for Margaret Thompson's completed trial - { - trialId: insertedTrials[2]!.id, - eventType: "trial_started" as const, - timestamp: new Date("2024-02-02T14:03:00Z"), - data: { - experimentId: experiment3.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "ELDERLY_001", - )!.id, - }, - }, - { - trialId: insertedTrials[2]!.id, - eventType: "step_started" as const, - timestamp: new Date("2024-02-02T14:03:30Z"), - data: { stepId: insertedSteps[3]!.id, stepName: "Morning Greeting" }, - }, - { - trialId: insertedTrials[2]!.id, - eventType: "robot_action" as const, - timestamp: new Date("2024-02-02T14:04:00Z"), - data: { - action: "speak", - content: "Good afternoon, Margaret. How are you feeling today?", - }, - }, - { - trialId: insertedTrials[2]!.id, - eventType: "trial_completed" as const, - timestamp: new Date("2024-02-02T14:48:00Z"), - data: { totalDuration: 45 * 60, outcome: "successful" }, - }, - - // Events for in-progress trial - { - trialId: insertedTrials[3]!.id, - eventType: "trial_started" as const, - timestamp: new Date(now.getTime() - 10 * 60 * 1000), - data: { - experimentId: experiment1.id, - participantId: insertedParticipants.find( - (p) => p.participantCode === "CHILD_003", - )!.id, - }, - }, - { - trialId: insertedTrials[3]!.id, - eventType: "step_started" as const, - timestamp: new Date(now.getTime() - 9 * 60 * 1000), - data: { - stepId: insertedSteps[0]!.id, - stepName: "Welcome and Introduction", - }, - }, - { - trialId: insertedTrials[3]!.id, - eventType: "step_completed" as const, - timestamp: new Date(now.getTime() - 5 * 60 * 1000), - data: { stepId: insertedSteps[0]!.id, duration: 240 }, - }, - { - trialId: insertedTrials[3]!.id, - eventType: "step_started" as const, - timestamp: new Date(now.getTime() - 5 * 60 * 1000), - data: { - stepId: insertedSteps[1]!.id, - stepName: "Math Problem Presentation", - }, - }, - ]; - - await db.insert(schema.trialEvents).values(trialEvents); - - console.log("✅ Seed script completed successfully!"); + console.log("\n✅ Seed script completed successfully!"); console.log("\n📊 Created:"); console.log(` • ${insertedRobots.length} robots`); console.log(` • ${insertedUsers.length} users`); - console.log(` • ${systemRoles.length} system roles`); + console.log(` • ${insertedRepos.length} plugin repositories`); + console.log(` • ${totalPlugins} plugins (via repository sync)`); console.log(` • ${insertedStudies.length} studies`); console.log(` • ${studyMemberships.length} study memberships`); console.log(` • ${insertedParticipants.length} participants`); console.log(` • ${insertedExperiments.length} experiments`); - console.log(` • ${insertedSteps.length} experiment steps`); console.log(` • ${insertedTrials.length} trials`); - console.log(` • ${trialEvents.length} trial events`); console.log("\n👤 Login credentials:"); console.log(" Email: sean@soconnor.dev"); console.log(" Password: password123"); console.log(" Role: Administrator"); - console.log("\n🎭 Other test users:"); - console.log(" • alice.rodriguez@university.edu (Researcher)"); - console.log(" • bob.chen@research.org (Researcher)"); - console.log(" • emily.watson@lab.edu (Wizard)"); - console.log(" • maria.santos@tech.edu (Researcher)"); - console.log(" All users have the same password: password123"); + console.log("\n🔄 Plugin repositories synced:"); + for (const repo of insertedRepos) { + console.log(` • ${repo.name}: ${repo.url}`); + } + + console.log("\n🎯 Next steps:"); + console.log(" 1. Start the development server: bun dev"); + console.log(" 2. Access admin dashboard to manage repositories"); + console.log(" 3. Browse plugin store to see synced plugins"); } catch (error) { console.error("❌ Error running seed script:", error); throw error; } finally { - await sql.end(); + await connection.end(); } } -main() - .then(() => { - console.log("🎉 Seed script finished successfully"); - process.exit(0); - }) - .catch((error) => { - console.error("💥 Seed script failed:", error); - process.exit(1); - }); +if (import.meta.url === `file://${process.argv[1]}`) { + void main(); +} + +export default main; diff --git a/scripts/seed-plugins.ts b/scripts/seed-plugins.ts deleted file mode 100644 index e90190a..0000000 --- a/scripts/seed-plugins.ts +++ /dev/null @@ -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(); -} diff --git a/scripts/seed-simple.ts b/scripts/seed-simple.ts deleted file mode 100644 index ff18df4..0000000 --- a/scripts/seed-simple.ts +++ /dev/null @@ -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); diff --git a/scripts/seed.ts b/scripts/seed.ts deleted file mode 100644 index 875a810..0000000 --- a/scripts/seed.ts +++ /dev/null @@ -1,1077 +0,0 @@ -#!/usr/bin/env tsx - -/** - * HRIStudio Database Seed Script - * - * This script seeds the database with comprehensive test data for the experiment designer, - * including users, studies, experiments, steps, actions, and participants. - */ - -import { drizzle } from "drizzle-orm/postgres-js"; -import { sql } from "drizzle-orm"; -import postgres from "postgres"; -import * as schema from "../src/server/db/schema"; - -// Database connection -const connectionString = - process.env.DATABASE_URL ?? - "postgresql://postgres:postgres@localhost:5140/hristudio"; -const client = postgres(connectionString); -const db = drizzle(client, { schema }); - -console.log("🌱 Starting HRIStudio database seeding..."); - -async function clearDatabase() { - console.log("🧹 Clearing existing data..."); - - // Delete in reverse dependency order using TRUNCATE for safety - await db.execute(sql`TRUNCATE TABLE hs_trial_event CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_action CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_step CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_trial CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_participant CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_experiment CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_study_member CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_study CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_user_system_role CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_user CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_robot CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_plugin_repository CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_plugin CASCADE`); - await db.execute(sql`TRUNCATE TABLE hs_study_plugin CASCADE`); - - console.log("✅ Database cleared"); -} - -async function seedUsers() { - console.log("👥 Seeding users..."); - - const users = [ - { - id: "01234567-89ab-cdef-0123-456789abcde0", - name: "Sean O'Connor", - email: "sean@soconnor.dev", - emailVerified: new Date(), - institution: "HRIStudio", - activeStudyId: null, - }, - { - id: "01234567-89ab-cdef-0123-456789abcde1", - name: "Dr. Sarah Chen", - email: "sarah.chen@university.edu", - emailVerified: new Date(), - institution: "MIT Computer Science", - activeStudyId: null, - }, - { - id: "01234567-89ab-cdef-0123-456789abcde2", - name: "Dr. Michael Rodriguez", - email: "m.rodriguez@research.org", - emailVerified: new Date(), - institution: "Stanford HCI Lab", - activeStudyId: null, - }, - { - id: "01234567-89ab-cdef-0123-456789abcde3", - name: "Emma Thompson", - email: "emma.thompson@university.edu", - emailVerified: new Date(), - institution: "MIT Computer Science", - activeStudyId: null, - }, - { - id: "01234567-89ab-cdef-0123-456789abcde4", - name: "Dr. James Wilson", - email: "james.wilson@university.edu", - emailVerified: new Date(), - institution: "MIT Computer Science", - activeStudyId: null, - }, - ]; - - await db.insert(schema.users).values(users); - - // Add user roles - const userRoles = [ - { - userId: "01234567-89ab-cdef-0123-456789abcde0", - role: "administrator" as const, - assignedAt: new Date(), - assignedBy: "01234567-89ab-cdef-0123-456789abcde0", // Sean as admin - }, - { - userId: "01234567-89ab-cdef-0123-456789abcde1", - role: "researcher" as const, - assignedAt: new Date(), - assignedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - { - userId: "01234567-89ab-cdef-0123-456789abcde2", - role: "researcher" as const, - assignedAt: new Date(), - assignedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - { - userId: "01234567-89ab-cdef-0123-456789abcde3", - role: "wizard" as const, - assignedAt: new Date(), - assignedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - { - userId: "01234567-89ab-cdef-0123-456789abcde4", - role: "observer" as const, - assignedAt: new Date(), - assignedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - ]; - - await db.insert(schema.userSystemRoles).values(userRoles); - - console.log(`✅ Created ${users.length} users with roles`); -} - -async function seedStudies() { - console.log("📚 Seeding studies..."); - - const studies = [ - { - id: "11234567-89ab-cdef-0123-456789abcde1", - 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" as const, - createdBy: "01234567-89ab-cdef-0123-456789abcde0", - metadata: { - duration: "6 months", - targetParticipants: 50, - robotPlatform: "TurtleBot3", - environment: "Indoor office building", - }, - }, - { - id: "11234567-89ab-cdef-0123-456789abcde2", - 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" as const, - createdBy: "01234567-89ab-cdef-0123-456789abcde0", - metadata: { - duration: "4 months", - targetParticipants: 30, - robotPlatform: "Pepper", - environment: "Office collaboration space", - }, - }, - { - id: "11234567-89ab-cdef-0123-456789abcde3", - name: "Assistive Robotics for Elderly Care", - description: - "Evaluating the effectiveness of companion robots in assisted living facilities for improving quality of life.", - institution: "University of Washington", - irbProtocolNumber: "IRB-2024-003", - status: "completed" as const, - createdBy: "01234567-89ab-cdef-0123-456789abcde0", - metadata: { - duration: "12 months", - targetParticipants: 40, - robotPlatform: "Companion Robot", - environment: "Assisted living facility", - }, - }, - ]; - - await db.insert(schema.studies).values(studies); - - // Add study members - const studyMembers = [ - // Sean as admin/owner of all studies - { - studyId: "11234567-89ab-cdef-0123-456789abcde1", - userId: "01234567-89ab-cdef-0123-456789abcde0", - role: "owner" as const, - joinedAt: new Date(), - invitedBy: null, - }, - // Navigation Study Team - { - studyId: "11234567-89ab-cdef-0123-456789abcde1", - userId: "01234567-89ab-cdef-0123-456789abcde2", - role: "researcher" as const, - joinedAt: new Date(), - invitedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - { - studyId: "11234567-89ab-cdef-0123-456789abcde1", - userId: "01234567-89ab-cdef-0123-456789abcde3", - role: "wizard" as const, - joinedAt: new Date(), - invitedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - { - studyId: "11234567-89ab-cdef-0123-456789abcde1", - userId: "01234567-89ab-cdef-0123-456789abcde4", - role: "observer" as const, - joinedAt: new Date(), - invitedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - - // Sean as admin/owner of Social Robots Study - { - studyId: "11234567-89ab-cdef-0123-456789abcde2", - userId: "01234567-89ab-cdef-0123-456789abcde0", - role: "owner" as const, - joinedAt: new Date(), - invitedBy: null, - }, - // Social Robots Study Team - { - studyId: "11234567-89ab-cdef-0123-456789abcde2", - userId: "01234567-89ab-cdef-0123-456789abcde2", - role: "researcher" as const, - joinedAt: new Date(), - invitedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - { - studyId: "11234567-89ab-cdef-0123-456789abcde2", - userId: "01234567-89ab-cdef-0123-456789abcde1", - role: "researcher" as const, - joinedAt: new Date(), - invitedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - - // Sean as admin/owner of Elderly Care Study - { - studyId: "11234567-89ab-cdef-0123-456789abcde3", - userId: "01234567-89ab-cdef-0123-456789abcde0", - role: "owner" as const, - joinedAt: new Date(), - invitedBy: null, - }, - // Elderly Care Study Team - { - studyId: "11234567-89ab-cdef-0123-456789abcde3", - userId: "01234567-89ab-cdef-0123-456789abcde1", - role: "researcher" as const, - joinedAt: new Date(), - invitedBy: "01234567-89ab-cdef-0123-456789abcde0", - }, - ]; - - await db.insert(schema.studyMembers).values(studyMembers); - - console.log(`✅ Created ${studies.length} studies with team members`); -} - -async function seedExperiments() { - console.log("🧪 Seeding experiments..."); - - const experiments = [ - { - id: "exp-navigation-baseline", - studyId: "study-hri-navigation", - name: "Baseline Navigation Task", - description: - "Participants navigate independently without robot assistance to establish baseline performance metrics.", - version: 1, - robotId: null, - status: "ready" as const, - estimatedDuration: 15, // minutes - createdBy: "user-researcher-1", - metadata: { - condition: "control", - environment: "Building A, Floor 2", - equipment: ["motion capture", "eye tracker"], - instructions: "Find the conference room using only building signs", - }, - }, - { - id: "exp-navigation-robot", - studyId: "study-hri-navigation", - name: "Robot-Assisted Navigation", - description: - "Participants navigate with robot providing verbal and gestural guidance to test effectiveness of robot assistance.", - version: 2, - robotId: null, - status: "testing" as const, - estimatedDuration: 20, - createdBy: "user-researcher-1", - 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: "exp-social-personality", - studyId: "study-social-robots", - name: "Robot Personality Variants", - description: - "Testing different robot personality types (friendly, professional, neutral) in collaborative tasks.", - version: 1, - robotId: null, - status: "draft" as const, - estimatedDuration: 30, - createdBy: "user-researcher-1", - metadata: { - condition: "personality_comparison", - personalities: ["friendly", "professional", "neutral"], - tasks: ["document review", "scheduling", "problem solving"], - }, - }, - { - id: "exp-elderly-companion", - studyId: "study-elderly-assistance", - name: "Daily Companion Interaction", - description: - "Evaluating robot as daily companion for elderly residents including conversation and activity reminders.", - version: 3, - robotId: null, - status: "ready" as const, - estimatedDuration: 45, - createdBy: "user-admin-1", - metadata: { - condition: "companion_interaction", - activities: ["conversation", "medication reminder", "exercise prompts"], - duration_days: 14, - }, - }, - ]; - - await db.insert(schema.experiments).values(experiments); - console.log(`✅ Created ${experiments.length} experiments`); -} - -async function seedStepsAndActions() { - console.log("📋 Seeding experiment steps and actions..."); - - // Baseline Navigation Experiment Steps - const baselineSteps = [ - { - id: "step-baseline-1", - experimentId: "exp-navigation-baseline", - name: "Welcome & Consent", - description: - "Greet participant, explain study, and obtain informed consent", - type: "wizard" as const, - orderIndex: 0, - durationEstimate: 300, // 5 minutes in seconds - required: true, - conditions: { - environment: "lab_room", - setup: "consent_forms_ready", - }, - }, - { - id: "step-baseline-2", - experimentId: "exp-navigation-baseline", - name: "Equipment Setup", - description: "Attach motion capture markers and calibrate eye tracker", - type: "wizard" as const, - orderIndex: 1, - durationEstimate: 180, - required: true, - conditions: { - equipment: ["motion_capture", "eye_tracker"], - calibration_required: true, - }, - }, - { - id: "step-baseline-3", - experimentId: "exp-navigation-baseline", - name: "Task Instructions", - description: "Explain navigation task and destination to participant", - type: "wizard" as const, - orderIndex: 2, - durationEstimate: 120, - required: true, - conditions: { - destination: "Conference Room B-201", - starting_point: "Building A Lobby", - }, - }, - { - id: "step-baseline-4", - experimentId: "exp-navigation-baseline", - name: "Independent Navigation", - description: - "Participant navigates independently while data is collected", - type: "parallel" as const, - orderIndex: 3, - durationEstimate: 600, - required: true, - conditions: { - data_collection: ["position", "gaze", "time"], - assistance: "none", - }, - }, - { - id: "step-baseline-5", - experimentId: "exp-navigation-baseline", - name: "Post-Task Survey", - description: - "Participant completes questionnaire about navigation experience", - type: "wizard" as const, - orderIndex: 4, - durationEstimate: 240, - required: true, - conditions: { - survey_type: "navigation_experience", - questions: ["difficulty", "confidence", "stress_level"], - }, - }, - ]; - - await db.insert(schema.steps).values(baselineSteps); - - // Robot-Assisted Navigation Experiment Steps - const robotSteps = [ - { - id: "step-robot-1", - experimentId: "exp-navigation-robot", - name: "Robot Introduction", - description: - "Robot introduces itself and explains its role as navigation assistant", - type: "robot" as const, - orderIndex: 0, - durationEstimate: 180, - required: true, - conditions: { - robot_behavior: "friendly_introduction", - voice_enabled: true, - }, - }, - { - id: "step-robot-2", - experimentId: "exp-navigation-robot", - name: "Guided Navigation", - description: - "Robot provides turn-by-turn navigation guidance with gestures and speech", - type: "robot" as const, - orderIndex: 1, - durationEstimate: 480, - required: true, - conditions: { - guidance_type: "multimodal", - gestures: true, - speech: true, - adaptation: "user_pace", - }, - }, - { - id: "step-robot-3", - experimentId: "exp-navigation-robot", - name: "Arrival Confirmation", - description: - "Robot confirms successful arrival and asks about experience", - type: "robot" as const, - orderIndex: 2, - durationEstimate: 120, - required: true, - conditions: { - confirmation_required: true, - feedback_collection: "immediate", - }, - }, - ]; - - await db.insert(schema.steps).values(robotSteps); - - // Social Robot Personality Steps - const socialSteps = [ - { - id: "step-social-1", - experimentId: "exp-social-personality", - name: "Personality Calibration", - description: - "Robot adjusts behavior based on assigned personality condition", - type: "conditional" as const, - 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: "step-social-2", - experimentId: "exp-social-personality", - name: "Collaborative Task", - description: "Human and robot work together on document review task", - type: "parallel" as const, - orderIndex: 1, - durationEstimate: 1200, - required: true, - conditions: { - task_type: "document_review", - collaboration_level: "equal_partners", - performance_metrics: ["accuracy", "efficiency", "satisfaction"], - }, - }, - ]; - - await db.insert(schema.steps).values(socialSteps); - - console.log("✅ Created experiment steps"); - - // Create actions for each step - const actions = [ - // Baseline Navigation Actions - { - id: "action-baseline-1-1", - stepId: "step-baseline-1", - 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: "action-baseline-1-2", - stepId: "step-baseline-1", - 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: "action-baseline-1-3", - stepId: "step-baseline-1", - name: "Obtain Consent", - description: "Review consent form and obtain participant signature", - type: "wizard_form", - orderIndex: 2, - parameters: { - form_type: "informed_consent", - signature_required: true, - questions_allowed: true, - }, - }, - - // Robot Navigation Actions - { - id: "action-robot-1-1", - stepId: "step-robot-1", - 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: "action-robot-1-2", - stepId: "step-robot-1", - name: "Explain Robot Role", - description: "Robot explains how it will assist with navigation", - type: "robot_speech", - orderIndex: 1, - parameters: { - text: "I'll guide you to the conference room using gestures and directions. Please follow me and let me know if you need clarification.", - gesture: "pointing", - led_indicators: true, - }, - }, - { - id: "action-robot-2-1", - stepId: "step-robot-2", - 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: "action-robot-2-2", - stepId: "step-robot-2", - name: "Provide Turn Instructions", - description: - "Robot gives clear directional instructions at decision points", - type: "robot_speech", - orderIndex: 1, - parameters: { - instruction_type: "turn_by_turn", - gesture_coordination: true, - confirmation_requests: ["ready_to_continue", "understand_direction"], - adaptive_repetition: true, - }, - }, - - // Social Robot Actions - { - id: "action-social-1-1", - stepId: "step-social-1", - 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", - }, - }, - }, - }, - { - id: "action-social-2-1", - stepId: "step-social-2", - name: "Initiate Collaboration", - description: "Robot starts collaborative document review task", - type: "robot_interaction", - orderIndex: 0, - parameters: { - task_initiation: "collaborative", - document_type: "research_proposal", - review_criteria: ["clarity", "feasibility", "innovation"], - interaction_style: "personality_dependent", - }, - }, - ]; - - await db.insert(schema.actions).values(actions); - console.log(`✅ Created ${actions.length} actions for steps`); -} - -async function seedParticipants() { - console.log("👤 Seeding participants..."); - - const participants = [ - { - id: "participant-1", - studyId: "study-hri-navigation", - 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: "participant-2", - studyId: "study-hri-navigation", - 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: "participant-3", - studyId: "study-hri-navigation", - participantCode: "NAV003", - name: "David Kim", - email: "david.kim@email.com", - demographics: { - age: 45, - gender: "male", - education: "phd", - tech_experience: "high", - robot_experience: "high", - mobility: "none", - }, - consentGiven: true, - consentDate: new Date("2024-01-17"), - notes: "Computer science professor, very familiar with robots", - }, - { - id: "participant-4", - studyId: "study-social-robots", - 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", - }, - { - id: "participant-5", - studyId: "study-elderly-assistance", - participantCode: "ELD001", - name: "Robert Thompson", - email: "robert.thompson@email.com", - demographics: { - age: 72, - gender: "male", - education: "high_school", - tech_experience: "low", - robot_experience: "none", - living_situation: "assisted_living", - health_conditions: ["arthritis", "mild_hearing_loss"], - }, - consentGiven: true, - consentDate: new Date("2024-01-10"), - notes: "Retired teacher, very social and cooperative", - }, - ]; - - await db.insert(schema.participants).values(participants); - console.log(`✅ Created ${participants.length} participants`); -} - -async function seedTrials() { - console.log("🎯 Seeding trials..."); - - const trials = [ - // Navigation Study Trials - { - id: "trial-nav-001", - experimentId: "exp-navigation-baseline", - participantId: "participant-1", - wizardId: "user-wizard-1", - sessionNumber: 1, - status: "completed" as const, - 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, // seconds - errors: 1, - assistance_requests: 0, - }, - }, - { - id: "trial-nav-002", - experimentId: "exp-navigation-robot", - participantId: "participant-1", - wizardId: "user-wizard-1", - sessionNumber: 2, - status: "completed" as const, - 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: "trial-nav-003", - experimentId: "exp-navigation-baseline", - participantId: "participant-2", - wizardId: "user-wizard-1", - sessionNumber: 1, - status: "completed" as const, - scheduledAt: new Date("2024-01-16T14:00:00"), - startedAt: new Date("2024-01-16T14:03:00"), - completedAt: new Date("2024-01-16T14:18:00"), - notes: "Good spatial reasoning, minimal difficulty", - metadata: { - condition: "control", - completion_time: 720, - errors: 0, - assistance_requests: 0, - }, - }, - { - id: "trial-nav-004", - experimentId: "exp-navigation-robot", - participantId: "participant-2", - wizardId: "user-wizard-1", - sessionNumber: 2, - status: "in_progress" as const, - scheduledAt: new Date("2024-01-16T14:30:00"), - startedAt: new Date("2024-01-16T14:35:00"), - completedAt: null, - notes: "Currently in progress", - metadata: { - condition: "robot_assistance", - }, - }, - { - id: "trial-soc-001", - experimentId: "exp-social-personality", - participantId: "participant-4", - wizardId: "user-wizard-1", - sessionNumber: 1, - status: "scheduled" as const, - scheduledAt: new Date("2024-01-25T11:00:00"), - startedAt: null, - completedAt: null, - notes: "Personality condition: friendly", - metadata: { - condition: "friendly_personality", - personality_type: "friendly", - }, - }, - ]; - - await db.insert(schema.trials).values(trials); - console.log(`✅ Created ${trials.length} trials`); -} - -async function seedTrialEvents() { - console.log("📊 Seeding trial events..."); - - const trialEvents = [ - // Events for completed navigation trial - { - id: "event-1", - trialId: "trial-nav-001", - stepId: "step-baseline-1", - actionId: "action-baseline-1-1", - eventType: "step_start" as const, - timestamp: new Date("2024-01-15T10:05:00"), - data: { - step_name: "Welcome & Consent", - wizard_id: "user-wizard-1", - }, - }, - { - id: "event-2", - trialId: "trial-nav-001", - stepId: "step-baseline-1", - actionId: "action-baseline-1-1", - eventType: "custom" as const, - timestamp: new Date("2024-01-15T10:06:30"), - data: { - action_name: "Greet Participant", - duration: 90, - success: true, - }, - }, - { - id: "event-3", - trialId: "trial-nav-001", - stepId: "step-baseline-4", - actionId: null, - eventType: "step_start" as const, - timestamp: new Date("2024-01-15T10:10:00"), - data: { - step_name: "Independent Navigation", - starting_location: "Building A Lobby", - }, - }, - { - id: "event-4", - trialId: "trial-nav-001", - stepId: "step-baseline-4", - actionId: null, - eventType: "custom" as const, - timestamp: new Date("2024-01-15T10:12:30"), - data: { - event_type: "wrong_turn", - location: "Hallway B", - correction_time: 45, - }, - }, - { - id: "event-5", - trialId: "trial-nav-001", - stepId: "step-baseline-4", - actionId: null, - eventType: "step_end" as const, - timestamp: new Date("2024-01-15T10:18:53"), - data: { - step_name: "Independent Navigation", - destination_reached: true, - total_time: 533, - path_efficiency: 0.78, - }, - }, - - // Events for robot-assisted trial - { - id: "event-6", - trialId: "trial-nav-002", - stepId: "step-robot-1", - actionId: "action-robot-1-1", - eventType: "custom" as const, - timestamp: new Date("2024-01-15T10:36:30"), - data: { - action_name: "Robot Self-Introduction", - robot_speech: "Hello! I'm your navigation assistant...", - participant_response: "positive", - engagement_level: "high", - }, - }, - { - id: "event-7", - trialId: "trial-nav-002", - stepId: "step-robot-2", - actionId: "action-robot-2-1", - eventType: "custom" as const, - timestamp: new Date("2024-01-15T10:45:15"), - data: { - event_type: "robot_guidance", - instruction: "Turn right at the end of this hallway", - gesture_performed: "pointing_right", - participant_compliance: true, - response_time: 2.3, - }, - }, - ]; - - await db.insert(schema.trialEvents).values(trialEvents); - console.log(`✅ Created ${trialEvents.length} trial events`); -} - -async function seedRobots() { - console.log("🤖 Seeding robots..."); - - 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"), - }, - ]; - - await db.insert(schema.robots).values(robots); - console.log(`✅ Created ${robots.length} robots`); -} - -async function main() { - try { - console.log("🚀 HRIStudio Database Seeding Started"); - console.log("📍 Database:", connectionString.replace(/:[^:]*@/, ":***@")); - - await clearDatabase(); - await seedUsers(); - await seedStudies(); - await seedRobots(); - await seedExperiments(); - await seedStepsAndActions(); - await seedParticipants(); - await seedTrials(); - await seedTrialEvents(); - - 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(" 🤖 Robots: 2 (TurtleBot3, NAO)"); - console.log(" 🧪 Experiments: 4 (with comprehensive test scenarios)"); - console.log(" 📋 Steps: 10 (covering all experiment types)"); - console.log(" ⚡ Actions: 12 (detailed robot and wizard actions)"); - console.log(" 👤 Participants: 5 (diverse demographics)"); - console.log(" 🎯 Trials: 5 (completed, in-progress, scheduled)"); - console.log(" 📊 Events: 7 (detailed trial execution data)"); - console.log("\n🔑 Test Login Credentials:"); - console.log(" Admin: sarah.chen@university.edu"); - console.log(" Researcher: m.rodriguez@research.org"); - console.log(" Wizard: emma.thompson@university.edu"); - console.log(" Observer: james.wilson@university.edu"); - console.log("\n🧪 Test Experiment Designer with:"); - console.log(" 📍 /experiments/exp-navigation-baseline/designer"); - console.log(" 📍 /experiments/exp-navigation-robot/designer"); - console.log(" 📍 /experiments/exp-social-personality/designer"); - console.log("\n🚀 Ready to test the experiment designer!"); - } catch (error) { - console.error("❌ Seeding failed:", error); - process.exit(1); - } finally { - await client.end(); - } -} - -// Run the seeding -main().catch(console.error); diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx index aee75bb..e7c5997 100644 --- a/src/app/(dashboard)/dashboard/page.tsx +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + +
+
+ + +
+
+ + + ))} +
+ ); + } + return (
{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() { -
- {activities.map((activity) => ( -
- {getStatusIcon(activity.status)} -
-

- {activity.title} -

-

- {activity.description} -

+ {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
-
- {activity.time} + ))} +
+ ) : activities.length === 0 ? ( +
+ +

+ No recent activity +

+
+ ) : ( +
+ {activities.map((activity) => ( +
+ {getStatusIcon(activity.status)} +
+

+ {activity.title} +

+

+ {activity.description} +

+
+
+ {formatDistanceToNow(activity.time, { addSuffix: true })} +
-
- ))} -
+ ))} +
+ )} ); @@ -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 ( @@ -299,31 +252,62 @@ function StudyProgress() { -
- {studies.map((study) => ( -
-
-
-

- {study.name} -

-

- {study.participants}/{study.totalParticipants} participants -

+ {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
- - {study.status} - +
+
- -

- {study.progress}% complete -

-
- ))} -
+ ))} +
+ ) : studies.length === 0 ? ( +
+ +

+ No active studies found +

+

+ Create a study to get started +

+
+ ) : ( +
+ {studies.map((study) => ( +
+
+
+

+ {study.name} +

+

+ {study.participants}/{study.totalParticipants} completed + trials +

+
+ + {study.status} + +
+ +

+ {study.progress}% complete +

+
+ ))} +
+ )} ); diff --git a/src/app/(dashboard)/experiments/[id]/designer/page.tsx b/src/app/(dashboard)/experiments/[id]/designer/page.tsx index a9a19eb..3b8ed90 100644 --- a/src/app/(dashboard)/experiments/[id]/designer/page.tsx +++ b/src/app/(dashboard)/experiments/[id]/designer/page.tsx @@ -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 ( - @@ -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", }; } } diff --git a/src/components/dashboard/DashboardContent.tsx b/src/components/dashboard/DashboardContent.tsx index c694c2f..fba1bd9 100644 --- a/src/components/dashboard/DashboardContent.tsx +++ b/src/components/dashboard/DashboardContent.tsx @@ -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; diff --git a/src/components/dashboard/app-sidebar.tsx b/src/components/dashboard/app-sidebar.tsx index 0095f39..278c1ad 100644 --- a/src/components/dashboard/app-sidebar.tsx +++ b/src/components/dashboard/app-sidebar.tsx @@ -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 ( @@ -165,7 +212,7 @@ export function AppSidebar({ - + @@ -179,52 +226,110 @@ export function AppSidebar({ - - - - - + {isCollapsed ? ( + + + + + + + + + {selectedStudy?.name ?? "Select Study"} + + + + + + Studies + {userStudies.map((study: Study) => ( + handleStudySelect(study.id)} + className="cursor-pointer" + > + + + {study.name} + + + ))} + + {selectedStudyId && ( + { + await selectStudy(null); + }} + > + + Clear selection + + )} + + + + Create study + + + + + + {selectedStudy?.name ?? "Select Study"} - - - - - - Studies - {userStudies.map((study: Study) => ( - handleStudySelect(study.id)} - className="cursor-pointer" - > - - - {study.name} + + + + ) : ( + + + + + + {selectedStudy?.name ?? "Select Study"} + + + + + Studies + {userStudies.map((study: Study) => ( + handleStudySelect(study.id)} + className="cursor-pointer" + > + + + {study.name} + + + ))} + + {selectedStudyId && ( + { + await selectStudy(null); + }} + > + + Clear selection + + )} + + + + Create study + - ))} - - {selectedStudyId && ( - { - await selectStudy(null); - }} - > - - Clear selection - - )} - - - - Create study - - - - + + + )} @@ -240,14 +345,29 @@ export function AppSidebar({ pathname === item.url || (item.url !== "/dashboard" && pathname.startsWith(item.url)); + const menuButton = ( + + + + {item.title} + + + ); + return ( - - - - {item.title} - - + {isCollapsed ? ( + + + {menuButton} + + {item.title} + + + + ) : ( + menuButton + )} ); })} @@ -256,7 +376,7 @@ export function AppSidebar({ {/* Study-specific items hint */} - {!selectedStudyId && ( + {!selectedStudyId && !isCollapsed && (
@@ -276,14 +396,31 @@ export function AppSidebar({ {adminItems.map((item) => { const isActive = pathname.startsWith(item.url); + const menuButton = ( + + + + {item.title} + + + ); + return ( - - - - {item.title} - - + {isCollapsed ? ( + + + + {menuButton} + + + {item.title} + + + + ) : ( + menuButton + )} ); })} @@ -293,46 +430,135 @@ export function AppSidebar({ )} + {/* Debug Info */} + {showDebug && ( + + Debug Info + +
+
Session: {session?.user?.email ?? "No session"}
+
Role: {userRole ?? "No role"}
+
Studies: {userStudies.length}
+
Selected: {selectedStudy?.name ?? "None"}
+
Auth: {session ? "✓" : "✗"}
+ {debugData && ( + <> +
DB User: {debugData.user?.email ?? "None"}
+
+ System Roles: {debugData.systemRoles.join(", ") || "None"} +
+
Memberships: {debugData.studyMemberships.length}
+
All Studies: {debugData.allStudies.length}
+
+ Session ID: {debugData.session.userId.slice(0, 8)}... +
+ + )} +
+
+
+ )} + - - - - - - - {(session?.user?.name ?? session?.user?.email ?? "U") - .charAt(0) - .toUpperCase()} - - -
- - {session?.user?.name ?? "User"} - - - {session?.user?.email ?? ""} - -
- -
-
- - -
- + {isCollapsed ? ( + + + + + + + + + + {( + session?.user?.name ?? + session?.user?.email ?? + "U" + ) + .charAt(0) + .toUpperCase()} + + +
+ + {session?.user?.name ?? "User"} + + + {session?.user?.email ?? ""} + +
+ +
+
+ + +
+ + + + {( + session?.user?.name ?? + session?.user?.email ?? + "U" + ) + .charAt(0) + .toUpperCase()} + + +
+ + {session?.user?.name ?? "User"} + + + {session?.user?.email ?? ""} + +
+
+
+ + + + + Profile & Settings + + + + + + Sign out + +
+
+
+ + {session?.user?.name ?? "User Menu"} + +
+
+ ) : ( + + + + -
+
{session?.user?.name ?? "User"} @@ -351,22 +577,53 @@ export function AppSidebar({ {session?.user?.email ?? ""}
-
- - - - - - Profile & Settings - - - - - - Sign out - - -
+ + + + + +
+ + + + {(session?.user?.name ?? session?.user?.email ?? "U") + .charAt(0) + .toUpperCase()} + + +
+ + {session?.user?.name ?? "User"} + + + {session?.user?.email ?? ""} + +
+
+
+ + + + + Profile & Settings + + + + + + Sign out + +
+ + )} diff --git a/src/components/experiments/ExperimentsTable.tsx b/src/components/experiments/ExperimentsTable.tsx index 16bf3eb..df680e5 100644 --- a/src/components/experiments/ExperimentsTable.tsx +++ b/src/components/experiments/ExperimentsTable.tsx @@ -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[] = [ const date = row.getValue("createdAt"); return (
- {formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })} + {formatDistanceToNow(new Date(date as string | number | Date), { + addSuffix: true, + })}
); }, @@ -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) { diff --git a/src/components/experiments/designer/ActionLibrary.tsx b/src/components/experiments/designer/ActionLibrary.tsx new file mode 100644 index 0000000..7c2f078 --- /dev/null +++ b/src/components/experiments/designer/ActionLibrary.tsx @@ -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> = { + 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 = { + wizard: "bg-blue-500", + robot: "bg-emerald-500", + control: "bg-amber-500", + observation: "bg-purple-500", + }; + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + draggable={false} + > +
+ +
+
+
+ {action.source.kind === "plugin" ? ( + + P + + ) : ( + + C + + )} + {action.name} +
+
+ {action.description ?? ""} +
+
+
+ +
+ + {showTooltip && ( +
+
{action.name}
+
{action.description}
+
+ Category: {action.category} • ID: {action.id} +
+ {action.parameters.length > 0 && ( +
+ Parameters: {action.parameters.map((p) => p.name).join(", ")} +
+ )} +
+ )} +
+ ); +} + +export interface ActionLibraryProps { + className?: string; +} + +export function ActionLibrary({ className }: ActionLibraryProps) { + const registry = actionRegistry; + const [activeCategory, setActiveCategory] = + useState("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 ( +
+ {/* Category tabs */} +
+
+ {categories.map((category) => { + const IconComponent = category.icon; + const isActive = activeCategory === category.key; + return ( + + ); + })} +
+
+ + {/* Actions list */} + +
+ {registry.getActionsByCategory(activeCategory).length === 0 ? ( +
+
+ +
+

No actions available

+

Check plugin configuration

+
+ ) : ( + registry + .getActionsByCategory(activeCategory) + .map((action) => ) + )} +
+
+ +
+
+ + {registry.getAllActions().length} total + + + {registry.getActionsByCategory(activeCategory).length} in view + +
+
+
+ ); +} diff --git a/src/components/experiments/designer/ActionRegistry.ts b/src/components/experiments/designer/ActionRegistry.ts new file mode 100644 index 0000000..09d0d07 --- /dev/null +++ b/src/components/experiments/designer/ActionRegistry.ts @@ -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(); + 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 { + 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; + }; + }>; + }; + }>, + ): 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; + 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(); diff --git a/src/components/experiments/designer/BlockDesigner.tsx b/src/components/experiments/designer/BlockDesigner.tsx new file mode 100644 index 0000000..820c535 --- /dev/null +++ b/src/components/experiments/designer/BlockDesigner.tsx @@ -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(() => { + const defaultDesign: ExperimentDesign = { + id: experimentId, + name: "New Experiment", + description: "", + steps: [], + version: 1, + lastSaved: new Date(), + }; + return initialDesign ?? defaultDesign; + }); + + const [selectedStepId, setSelectedStepId] = useState(null); + const [selectedActionId, setSelectedActionId] = useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + /* ------------------------- Validation / Drift Tracking -------------------- */ + const [isValidating, setIsValidating] = useState(false); + const [lastValidatedHash, setLastValidatedHash] = useState( + 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) => { + 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) => { + 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 ? ( + + Drift + + ) : lastValidatedHash ? ( + + Validated + + ) : ( + + Unvalidated + + ); + + /* ---------------------------------- Render -------------------------------- */ + return ( + +
+ + {validationBadge} + {experiment?.integrityHash && ( + + Hash: {experiment.integrityHash.slice(0, 10)}… + + )} + {experiment?.executionGraphSummary && ( + + Exec: {experiment.executionGraphSummary.steps ?? 0}s / + {experiment.executionGraphSummary.actions ?? 0}a + + )} + {Array.isArray(experiment?.pluginDependencies) && + experiment.pluginDependencies.length > 0 && ( + + {experiment.pluginDependencies.length} plugins + + )} + + {design.steps.length} steps + + {hasUnsavedChanges && ( + + Unsaved + + )} + + + {updateExperiment.isPending ? "Saving…" : "Save"} + + { + setHasUnsavedChanges(false); // immediate feedback + void runValidation(); + }} + disabled={isValidating} + > + + {isValidating ? "Validating…" : "Revalidate"} + + + + Export + +
+ } + /> + +
+ {/* Action Library */} +
+ + + + + Action Library + + + + + + +
+ + {/* Flow */} +
+ { + setSelectedStepId(id); + setSelectedActionId(null); + }} + onStepDelete={deleteStep} + onStepUpdate={updateStep} + onActionSelect={(actionId) => setSelectedActionId(actionId)} + onActionDelete={deleteAction} + emptyState={ +
+ +

No steps yet

+

+ Add your first step to begin designing +

+ +
+ } + headerRight={ + + } + /> +
+ + {/* Properties */} +
+ + + + Properties + + + + + + + + +
+
+
+ + ); +} diff --git a/src/components/experiments/designer/EnhancedBlockDesigner.tsx b/src/components/experiments/designer/EnhancedBlockDesigner.tsx deleted file mode 100644 index b54fe1d..0000000 --- a/src/components/experiments/designer/EnhancedBlockDesigner.tsx +++ /dev/null @@ -1,1587 +0,0 @@ -"use client"; - -import React, { useState, useCallback, useMemo } from "react"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - type DragEndEvent, - type DragStartEvent, - DragOverlay, - useDraggable, - useDroppable, - type CollisionDetection, - type DroppableContainer, -} from "@dnd-kit/core"; -import { - SortableContext, - sortableKeyboardCoordinates, - verticalListSortingStrategy, - useSortable, - arrayMove, -} from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; -// Removed unused resizable imports -import { ScrollArea } from "~/components/ui/scroll-area"; -import { Button } from "~/components/ui/button"; -import { Badge } from "~/components/ui/badge"; -import { Input } from "~/components/ui/input"; -import { Label } from "~/components/ui/label"; -import { Separator } from "~/components/ui/separator"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "~/components/ui/select"; -import { toast } from "sonner"; -import { cn } from "~/lib/utils"; -import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider"; -import { api } from "~/trpc/react"; -import { useEffect } from "react"; -import { PageHeader, ActionButton } from "~/components/ui/page-header"; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { - Play, - Users, - Bot, - GitBranch, - Activity, - Zap, - Plus, - Save, - Download, - Settings, - GripVertical, - Trash2, - Clock, - Palette, -} from "lucide-react"; -// import { useParams } from "next/navigation"; // Unused - -// Types -type BlockShape = "action" | "control" | "hat" | "cap" | "boolean" | "value"; -type BlockCategory = - | "event" - | "wizard" - | "robot" - | "control" - | "sensor" - | "logic"; - -interface BlockParameter { - id: string; - name: string; - type: "text" | "number" | "select" | "boolean"; - value?: string | number | boolean; - placeholder?: string; - options?: string[]; - min?: number; - max?: number; - step?: number; -} - -export interface ExperimentBlock { - id: string; - type: string; - category: BlockCategory; - shape: BlockShape; - displayName: string; - description: string; - icon: string; - color: string; - parameters: BlockParameter[]; - children?: ExperimentBlock[]; - nestable?: boolean; - order: number; -} - -export interface BlockDesign { - id: string; - name: string; - description: string; - blocks: ExperimentBlock[]; - version: number; - lastSaved: Date; -} - -// Block Registry -class BlockRegistry { - private static instance: BlockRegistry; - private blocks = new Map(); - private coreBlocksLoaded = false; - private pluginActionsLoaded = false; - - static getInstance(): BlockRegistry { - if (!BlockRegistry.instance) { - BlockRegistry.instance = new BlockRegistry(); - } - return BlockRegistry.instance; - } - - async loadCoreBlocks() { - if (this.coreBlocksLoaded) return; - - try { - console.log("Loading core blocks from hristudio-core repository..."); - - // Load core blocks from the hristudio-core repository - const coreBlockSets = [ - "events", - "wizard-actions", - "control-flow", - "observation", - ]; - - let blocksLoaded = 0; - - for (const blockSetId of coreBlockSets) { - try { - const response = await fetch( - `/hristudio-core/plugins/${blockSetId}.json`, - ); - - if (!response.ok) { - console.warn( - `Failed to load ${blockSetId}: ${response.status} ${response.statusText}`, - ); - continue; - } - - const blockSet = (await response.json()) as { - blocks?: Array<{ - id: string; - name: string; - description?: string; - category: string; - shape?: string; - icon?: string; - color?: string; - parameters?: BlockParameter[]; - nestable?: boolean; - }>; - }; - - if (!blockSet.blocks || !Array.isArray(blockSet.blocks)) { - console.warn(`Invalid block set structure for ${blockSetId}`); - continue; - } - - blockSet.blocks.forEach((block) => { - if (!block.id || !block.name || !block.category) { - console.warn(`Skipping invalid block in ${blockSetId}:`, block); - return; - } - - const blockDef: PluginBlockDefinition = { - type: block.id, - shape: (block.shape ?? "action") as BlockShape, - category: block.category as BlockCategory, - displayName: block.name, - description: block.description ?? "", - icon: block.icon ?? "Square", - color: block.color ?? "#6b7280", - parameters: block.parameters ?? [], - nestable: block.nestable ?? false, - }; - - this.blocks.set(blockDef.type, blockDef); - blocksLoaded++; - }); - - console.log( - `Loaded ${blockSet.blocks.length} blocks from ${blockSetId}`, - ); - } catch (blockSetError) { - console.error( - `Error loading block set ${blockSetId}:`, - blockSetError, - ); - } - } - - if (blocksLoaded > 0) { - console.log(`Successfully loaded ${blocksLoaded} core blocks`); - this.coreBlocksLoaded = true; - } else { - throw new Error("No core blocks could be loaded from repository"); - } - } catch (error) { - console.error("Failed to load core blocks:", error); - // Fallback to minimal core blocks if loading fails - this.loadFallbackCoreBlocks(); - } - } - - private loadFallbackCoreBlocks() { - console.warn( - "Loading minimal fallback blocks due to core repository loading failure", - ); - - const fallbackBlocks: PluginBlockDefinition[] = [ - { - type: "when_trial_starts", - shape: "hat", - category: "event", - displayName: "when trial starts", - description: "Triggered when the trial begins", - icon: "Play", - color: "#22c55e", - parameters: [], - nestable: false, - }, - { - type: "wait", - shape: "action", - category: "control", - displayName: "wait", - description: "Pause execution for specified time", - icon: "Clock", - color: "#f97316", - parameters: [ - { - id: "seconds", - name: "Seconds", - type: "number", - value: 1, - min: 0.1, - max: 60, - step: 0.1, - }, - ], - nestable: false, - }, - ]; - - fallbackBlocks.forEach((block) => this.blocks.set(block.type, block)); - this.coreBlocksLoaded = true; - } - - registerBlock(blockDef: PluginBlockDefinition) { - this.blocks.set(blockDef.type, blockDef); - } - - getBlock(type: string): PluginBlockDefinition | undefined { - return this.blocks.get(type); - } - - getBlocksByCategory(category: BlockCategory): PluginBlockDefinition[] { - return Array.from(this.blocks.values()).filter( - (b) => b.category === category, - ); - } - - getAllBlocks(): PluginBlockDefinition[] { - return Array.from(this.blocks.values()); - } - - loadPluginActions( - studyId: string, - studyPlugins: Array<{ - plugin: { - robotId: string | null; - actionDefinitions?: Array<{ - id: string; - name: string; - description?: string; - category: string; - icon?: string; - parameterSchema?: Record; - }>; - }; - }>, - ) { - if (this.pluginActionsLoaded) return; - - studyPlugins.forEach((studyPlugin) => { - const { plugin } = studyPlugin; - if ( - plugin.robotId && - plugin.actionDefinitions && - Array.isArray(plugin.actionDefinitions) - ) { - plugin.actionDefinitions.forEach((action) => { - const blockDef: PluginBlockDefinition = { - type: `plugin_${plugin.robotId}_${action.id}`, - shape: "action", - category: this.mapActionCategoryToBlockCategory(action.category), - displayName: action.name, - description: action.description ?? "", - icon: action.icon ?? "Bot", - color: "#3b82f6", // Robot blue - parameters: this.convertActionParametersToBlockParameters( - action.parameterSchema ?? {}, - ), - nestable: false, - }; - this.registerBlock(blockDef); - }); - } - }); - - this.pluginActionsLoaded = true; - } - - private mapActionCategoryToBlockCategory( - actionCategory: string, - ): BlockCategory { - switch (actionCategory) { - case "movement": - return "robot"; - case "interaction": - return "robot"; - case "sensors": - return "sensor"; - case "logic": - return "logic"; - default: - return "robot"; - } - } - - private convertActionParametersToBlockParameters(parameterSchema: { - properties?: Record< - string, - { - type?: string; - enum?: string[]; - title?: string; - default?: string | number | boolean; - description?: string; - minimum?: number; - maximum?: number; - } - >; - }): BlockParameter[] { - if (!parameterSchema?.properties) return []; - - return Object.entries(parameterSchema.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, - step: paramDef.type === "number" ? 0.1 : undefined, - }; - }); - } - - resetPluginActions() { - this.pluginActionsLoaded = false; - // Remove plugin blocks - const pluginBlockTypes = Array.from(this.blocks.keys()).filter((type) => - type.startsWith("plugin_"), - ); - pluginBlockTypes.forEach((type) => this.blocks.delete(type)); - } - createBlock(type: string, order: number): ExperimentBlock { - const blockDef = this.blocks.get(type); - if (!blockDef) { - throw new Error(`Block type ${type} not found`); - } - - return { - id: `block_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, - type: blockDef.type, - category: blockDef.category, - shape: blockDef.shape, - displayName: blockDef.displayName, - description: blockDef.description, - icon: blockDef.icon, - color: blockDef.color, - parameters: blockDef.parameters.map((param) => ({ ...param })), - children: blockDef.nestable ? [] : undefined, - nestable: blockDef.nestable, - order, - }; - } -} - -interface PluginBlockDefinition { - type: string; - shape: BlockShape; - category: BlockCategory; - displayName: string; - description: string; - icon: string; - color: string; - parameters: BlockParameter[]; - nestable?: boolean; -} - -// Icon mapping -const IconComponents: Record< - string, - React.ComponentType<{ className?: string }> -> = { - Play, - Users, - Bot, - GitBranch, - Activity, - Zap, - Clock, -}; - -// Draggable Palette Block -interface DraggablePaletteBlockProps { - blockDef: PluginBlockDefinition; - showParameterPreview?: boolean; -} - -function DraggablePaletteBlock({ - blockDef, - showParameterPreview, -}: DraggablePaletteBlockProps) { - const { attributes, listeners, setNodeRef, transform, isDragging } = - useDraggable({ - id: `palette-${blockDef.type}`, - data: { blockType: blockDef.type, isFromPalette: true }, - }); - - const style = { - transform: transform - ? `translate3d(${transform.x}px, ${transform.y}px, 0)` - : undefined, - zIndex: isDragging ? 1000 : undefined, - }; - - const IconComponent = IconComponents[blockDef.icon] ?? Bot; - - return ( -
-
-
- -
-
-
- {blockDef.displayName} -
-
- {blockDef.description} -
- {showParameterPreview && blockDef.parameters.length > 0 && ( -
- {blockDef.parameters.slice(0, 2).map((param, idx) => ( - - {param.name} - - ))} - {blockDef.parameters.length > 2 && ( - - +{blockDef.parameters.length - 2} - - )} -
- )} -
- -
-
- ); -} - -// Block Palette -interface BlockPaletteProps { - showParameterPreview?: boolean; -} - -function BlockPalette({ showParameterPreview = false }: BlockPaletteProps) { - const registry = BlockRegistry.getInstance(); - const categories: BlockCategory[] = [ - "event", - "wizard", - "robot", - "control", - "sensor", - ]; - const [activeCategory, setActiveCategory] = useState("wizard"); - - const categoryConfig = { - event: { label: "Events", icon: Play, color: "bg-green-500" }, - wizard: { label: "Wizard", icon: Users, color: "bg-purple-500" }, - robot: { label: "Robot", icon: Bot, color: "bg-blue-500" }, - control: { label: "Control", icon: GitBranch, color: "bg-orange-500" }, - sensor: { label: "Sensors", icon: Activity, color: "bg-green-600" }, - logic: { label: "Logic", icon: Zap, color: "bg-pink-500" }, - }; - - return ( -
-
-
- {categories.map((category) => { - const config = categoryConfig[category]; - const IconComponent = config.icon; - const isActive = activeCategory === category; - return ( - - ); - })} -
-
- - -
- {registry.getBlocksByCategory(activeCategory).map((blockDef) => ( - - ))} -
-
-
- ); -} - -// Droppable Container for control blocks -interface DroppableContainerProps { - id: string; - isEmpty: boolean; - children?: React.ReactNode; - isMainCanvas?: boolean; -} - -function DroppableContainer({ - id, - isEmpty, - children, - isMainCanvas = false, -}: DroppableContainerProps) { - const { isOver, setNodeRef } = useDroppable({ id }); - - if (isMainCanvas && !isEmpty) { - // Main canvas with content - no special styling - return ( -
- {children} -
- ); - } - - return ( -
- {isEmpty ? ( -
- {isMainCanvas ? ( -
- -

- Drag blocks from the palette to build your experiment -

-
- ) : ( -
- Drop blocks here -
- )} -
- ) : ( - children - )} -
- ); -} - -// Sortable Block Component -interface SortableBlockProps { - block: ExperimentBlock; - isSelected: boolean; - selectedBlockId: string | null; - onSelect: () => void; - onDelete: () => void; - onAddToControl?: (parentId: string, childId: string) => void; - onRemoveFromControl?: (parentId: string, childId: string) => void; - level?: number; -} - -function SortableBlock({ - block, - isSelected, - selectedBlockId, - onSelect, - onDelete, - onAddToControl, - onRemoveFromControl, - level = 0, -}: SortableBlockProps) { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: block.id, - }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const IconComponent = IconComponents[block.icon] ?? Bot; - - const renderParameterPreview = () => { - if (block.parameters.length === 0) return null; - - return block.parameters.slice(0, 2).map((param) => ( - - {param.type === "text" && param.value && `"${param.value}"`} - {param.type === "number" && param.value} - {param.type === "select" && param.value} - {param.type === "boolean" && (param.value ? "✓" : "✗")} - - )); - }; - - const baseClasses = cn( - "group relative flex items-center gap-3 rounded-lg border px-4 py-3 text-sm font-medium transition-all", - "hover:shadow-md cursor-pointer select-none", - isSelected && "ring-2 ring-primary ring-offset-2 ring-offset-background", - isDragging && "opacity-30 shadow-2xl scale-105 rotate-1", - level > 0 && "ml-4", - ); - - const renderBlock = () => { - switch (block.shape) { - case "hat": - return ( -
-
-
-
- -
-
- - - {block.displayName} - -
-
-
- ); - - case "control": - return ( -
-
-
- -
-
- - - {block.displayName} - -
- {renderParameterPreview()} -
-
- -
- {block.nestable && ( -
- - {block.children && block.children.length > 0 && ( - c.id)} - strategy={verticalListSortingStrategy} - > -
- {block.children.map((child) => ( - onSelect()} - onDelete={() => - onRemoveFromControl?.(block.id, child.id) - } - onAddToControl={onAddToControl} - onRemoveFromControl={onRemoveFromControl} - level={level + 1} - /> - ))} -
-
- )} -
-
- )} -
- ); - - default: - return ( -
-
- -
-
- - {block.displayName} -
- {renderParameterPreview()} -
-
- -
- ); - } - }; - - return ( -
- {renderBlock()} -
- ); -} - -// Main Designer Component -interface EnhancedBlockDesignerProps { - experimentId: string; - initialDesign?: BlockDesign; - onSave?: (design: BlockDesign) => void; -} - -export function EnhancedBlockDesigner({ - experimentId, - initialDesign, - onSave, -}: EnhancedBlockDesignerProps) { - const registry = BlockRegistry.getInstance(); - - // Add error logging for debugging - useEffect(() => { - console.log("Designer mounted with:", { experimentId, initialDesign }); - }, [experimentId, initialDesign]); - - const [design, setDesign] = useState(() => { - const defaultDesign = { - id: experimentId, - name: "New Experiment", - description: "", - blocks: [] as ExperimentBlock[], - version: 1, - lastSaved: new Date(), - }; - - if (initialDesign) { - console.log("Using existing design:", initialDesign); - return initialDesign; - } - - // Create default "when trial starts" block if no initial design - try { - defaultDesign.blocks = [registry.createBlock("when_trial_starts", 0)]; - console.log("Created default design with when_trial_starts block"); - } catch (error) { - console.error("Failed to create default block:", error); - defaultDesign.blocks = []; - } - return defaultDesign; - }); - - const [selectedBlockId, setSelectedBlockId] = useState(null); - const [activeId, setActiveId] = useState(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - // API mutation for saving - const updateExperiment = api.experiments.update.useMutation({ - onSuccess: () => { - setHasUnsavedChanges(false); - toast.success("Design saved successfully"); - }, - onError: (error) => { - toast.error("Failed to save design: " + error.message); - }, - }); - - // Load experiment data to get study ID - const { data: experiment } = api.experiments.get.useQuery({ - id: experimentId, - }); - - // Load study plugins for this experiment's study - const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery( - { studyId: experiment?.studyId ?? "" }, - { enabled: !!experiment?.studyId }, - ); - - // Load core blocks on component mount - useEffect(() => { - registry.loadCoreBlocks().catch((error) => { - console.error("Failed to initialize core blocks:", error); - toast.error("Failed to load core blocks. Using fallback blocks."); - }); - }, [registry]); - - // Load plugin actions into registry when study plugins are available - useEffect(() => { - if (experiment?.studyId && studyPlugins) { - registry.loadPluginActions(experiment.studyId, studyPlugins); - } - }, [experiment?.studyId, studyPlugins, registry]); - - // Set breadcrumbs - useBreadcrumbsEffect([ - { label: "Dashboard", href: "/dashboard" }, - { label: "Experiments", href: "/experiments" }, - { label: design.name, href: `/experiments/${design.id}` }, - { label: "Designer" }, - ]); - - // DnD sensors with improved collision detection - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 5 }, - }), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - // Helper functions for nested block operations - const findBlockById = useCallback( - (id: string, blocks: ExperimentBlock[]): ExperimentBlock | null => { - for (const block of blocks) { - if (block.id === id) return block; - if (block.children) { - const found = findBlockById(id, block.children); - if (found) return found; - } - } - return null; - }, - [], - ); - - const removeBlockFromStructure = useCallback( - (id: string, blocks: ExperimentBlock[]): ExperimentBlock[] => { - return blocks - .filter((block) => block.id !== id) - .map((block) => ({ - ...block, - children: block.children - ? removeBlockFromStructure(id, block.children) - : block.children, - })); - }, - [], - ); - - // Custom collision detection for nested blocks - const customCollisionDetection = useCallback((args) => { - if (!args.pointerCoordinates) return closestCenter(args); - - // First check for droppable containers (control blocks and main canvas) - const droppableCollisions = - args.droppableContainers - ?.filter( - (container) => - container.id?.toString().startsWith("control-") || - container.id === "main-canvas", - ) - ?.map((container) => { - // Handle rect being a ref or direct object - const rect = - "current" in container.rect - ? container.rect.current - : container.rect; - if (!rect) return null; - - return { - id: container.id, - data: container.data, - rect, - }; - }) - ?.filter(Boolean) ?? []; - - if (droppableCollisions.length > 0) { - // Return the closest droppable container - let closest: - | ((typeof droppableCollisions)[0] & { distance: number }) - | null = null; - - for (const current of droppableCollisions) { - if (!current?.rect) continue; - - const distance = Math.sqrt( - Math.pow( - args.pointerCoordinates.x - - current.rect.left - - current.rect.width / 2, - 2, - ) + - Math.pow( - args.pointerCoordinates.y - - current.rect.top - - current.rect.height / 2, - 2, - ), - ); - - if (distance < (closest?.distance ?? Infinity)) { - closest = { ...current, distance }; - } - } - - if (closest) return [closest]; - } - - // Fall back to default collision detection - return closestCenter(args); - }, []); - - // Handle drag start - const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveId(event.active.id as string); - }, []); - - // Handle drag end - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - setActiveId(null); - - if (!over) return; - - const activeId = active.id.toString(); - const overId = over.id.toString(); - - // Handle drop from palette - if (typeof activeId === "string" && activeId.startsWith("palette-")) { - const blockType = activeId.replace("palette-", ""); - - // Dropping into control block - if (overId.startsWith("control-")) { - const controlId = overId.replace("control-", ""); - const newBlock = registry.createBlock(blockType, 0); - - setDesign((prev) => ({ - ...prev, - blocks: prev.blocks.map((block) => - block.id === controlId - ? { - ...block, - children: [...(block.children ?? []), newBlock], - } - : block, - ), - })); - - setHasUnsavedChanges(true); - toast.success(`Added ${newBlock.displayName} to control block`); - return; - } - - // Dropping in main area or main canvas - if (overId === "main-canvas" || !overId.includes("-")) { - const newBlock = registry.createBlock( - blockType, - design.blocks.length, - ); - setDesign((prev) => ({ - ...prev, - blocks: [...prev.blocks, newBlock], - })); - - setHasUnsavedChanges(true); - toast.success(`Added ${newBlock.displayName} block`); - return; - } - } - - // Handle dragging blocks out of control structures to main canvas - if ( - overId === "main-canvas" && - !activeId.toString().startsWith("palette-") - ) { - const draggedBlock = findBlockById(activeId, design.blocks); - if (!draggedBlock) return; - - setDesign((prev) => { - // Remove from any control structure - const newBlocks = removeBlockFromStructure(activeId, prev.blocks); - - // Add to main blocks if not already there - if (!newBlocks.some((b) => b.id === activeId)) { - newBlocks.push(draggedBlock); - } - - return { ...prev, blocks: newBlocks }; - }); - - setHasUnsavedChanges(true); - toast.success("Block moved to main flow"); - return; - } - - // Handle reordering existing blocks or moving into control structures - if (typeof overId === "string" && overId.startsWith("control-")) { - const controlId = overId.replace("control-", ""); - const draggedBlock = findBlockById(activeId, design.blocks); - - if (!draggedBlock) return; - - setDesign((prev) => { - // Remove from current location - const newBlocks = removeBlockFromStructure(activeId, prev.blocks); - - // Add to control block - const updatedBlocks = newBlocks.map((block) => - block.id === controlId - ? { - ...block, - children: [...(block.children ?? []), draggedBlock], - } - : block, - ); - - return { ...prev, blocks: updatedBlocks }; - }); - - setHasUnsavedChanges(true); - toast.success("Block moved to control structure"); - return; - } - - // Normal reordering within main blocks - if (activeId !== overId && !overId.includes("-")) { - setDesign((prev) => { - const activeIndex = prev.blocks.findIndex( - (block) => block.id === activeId, - ); - const overIndex = prev.blocks.findIndex( - (block) => block.id === overId, - ); - - if (activeIndex !== -1 && overIndex !== -1) { - const newBlocks = arrayMove(prev.blocks, activeIndex, overIndex); - newBlocks.forEach((block, index) => { - block.order = index; - }); - - return { ...prev, blocks: newBlocks }; - } - return prev; - }); - setHasUnsavedChanges(true); - } - }, - [design.blocks, registry, findBlockById, removeBlockFromStructure], - ); - - // Handle block selection - const handleBlockSelect = useCallback((blockId: string) => { - setSelectedBlockId((prev) => (prev === blockId ? null : blockId)); - }, []); - - // Handle block deletion - const handleBlockDelete = useCallback( - (blockId: string) => { - setDesign((prev) => ({ - ...prev, - blocks: prev.blocks.filter((block) => block.id !== blockId), - })); - - if (selectedBlockId === blockId) { - setSelectedBlockId(null); - } - - setHasUnsavedChanges(true); - toast.success("Block deleted"); - }, - [selectedBlockId], - ); - - // Handle removal from control structure - const handleRemoveFromControl = useCallback( - (parentId: string, childId: string) => { - setDesign((prev) => ({ - ...prev, - blocks: prev.blocks.map((block) => { - if (block.id === parentId && block.children) { - return { - ...block, - children: block.children.filter((child) => child.id !== childId), - }; - } - return block; - }), - })); - - setHasUnsavedChanges(true); - toast.success("Block removed from control structure"); - }, - [], - ); - - // Handle parameter changes - const handleParameterChange = useCallback( - ( - blockId: string, - parameterId: string, - value: string | number | boolean, - ) => { - setDesign((prev) => ({ - ...prev, - blocks: prev.blocks.map((block) => { - if (block.id === blockId) { - return { - ...block, - parameters: block.parameters.map((param) => - param.id === parameterId ? { ...param, value } : param, - ), - }; - } - // Also check children in control blocks - if (block.children) { - return { - ...block, - children: block.children.map((child) => - child.id === blockId - ? { - ...child, - parameters: child.parameters.map((param) => - param.id === parameterId ? { ...param, value } : param, - ), - } - : child, - ), - }; - } - return block; - }), - })); - setHasUnsavedChanges(true); - }, - [], - ); - - // Save design - const handleSave = useCallback(() => { - console.log("Saving design:", design); - const visualDesign = { - blocks: design.blocks, - version: design.version, - lastSaved: new Date().toISOString(), - }; - - updateExperiment.mutate({ - id: experimentId, - visualDesign, - }); - - if (onSave) { - const updatedDesign = { ...design, lastSaved: new Date() }; - setDesign(updatedDesign); - onSave(updatedDesign); - } - }, [design, experimentId, onSave, updateExperiment]); - - // Find selected block (including in children) - const selectedBlock = useMemo(() => { - if (!selectedBlockId) return null; - - for (const block of design.blocks) { - if (block.id === selectedBlockId) return block; - if (block.children) { - const childBlock = block.children.find((c) => c.id === selectedBlockId); - if (childBlock) return childBlock; - } - } - return null; - }, [selectedBlockId, design.blocks]); - - return ( - -
- {/* Page Header */} - - {hasUnsavedChanges && ( - - Unsaved Changes - - )} - - {design.blocks.length} blocks - - - - {updateExperiment.isPending ? "Saving..." : "Save"} - - - - Export - -
- } - /> - - {/* Main Designer */} -
- {/* Block Palette */} -
- - - - - Block Library - - - - - - -
- - {/* Block Canvas */} -
- - - - - Experiment Flow - -

- Drag blocks from the palette • Click to select • Drag to - reorder -

-
- - -
- - {design.blocks.length > 0 && ( - b.id)} - strategy={verticalListSortingStrategy} - > -
- {design.blocks.map((block) => ( - handleBlockSelect(block.id)} - onDelete={() => handleBlockDelete(block.id)} - onRemoveFromControl={handleRemoveFromControl} - /> - ))} -
-
- )} -
-
-
-
-
-
- - {/* Properties Panel */} -
- - - - - Properties - - - - {selectedBlock ? ( -
-
-
-
- {IconComponents[selectedBlock.icon] && - React.createElement( - IconComponents[selectedBlock.icon] ?? Bot, - { - className: "h-4 w-4 text-white", - }, - )} -
-
-
- {selectedBlock.displayName} -
-
- {selectedBlock.category} • {selectedBlock.shape} -
-
-
-

- {selectedBlock.description} -

-
- - {selectedBlock.parameters.length > 0 && ( - <> - -
- - {selectedBlock.parameters.map((param) => ( -
- - - {param.type === "text" && ( - - handleParameterChange( - selectedBlock.id, - param.id, - e.target.value, - ) - } - className="h-8" - /> - )} - - {param.type === "number" && ( - - handleParameterChange( - selectedBlock.id, - param.id, - parseFloat(e.target.value) || 0, - ) - } - className="h-8" - /> - )} - - {param.type === "select" && ( - - )} -
- ))} -
- - )} -
- ) : ( -
-
- -

- Select a Block -

-

- Click on a block to edit its properties -

-
-
- )} -
-
-
-
- - {/* Drag Overlay */} - - {activeId ? ( -
- {typeof activeId === "string" && - activeId.startsWith("palette-") ? ( -
- { - registry.getBlock(activeId.replace("palette-", "")) - ?.displayName - } -
- ) : ( - (() => { - const draggedBlock = findBlockById(activeId, design.blocks); - return draggedBlock ? ( -
- {draggedBlock.displayName} -
- ) : null; - })() - )} -
- ) : null} -
-
- - ); -} diff --git a/src/components/experiments/designer/PropertiesPanel.tsx b/src/components/experiments/designer/PropertiesPanel.tsx new file mode 100644 index 0000000..c1720da --- /dev/null +++ b/src/components/experiments/designer/PropertiesPanel.tsx @@ -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, + ) => void; + onStepUpdate: (stepId: string, updates: Partial) => 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 ( +
+ {/* Header / Metadata */} +
+
+ {def && ( +
+ +
+ )} +
+

+ {selectedAction.name} +

+

+ {def?.category} • {selectedAction.type} +

+
+
+
+ + {selectedAction.source.kind === "plugin" ? "Plugin" : "Core"} + + {selectedAction.source.pluginId && ( + + {selectedAction.source.pluginId} + {selectedAction.source.pluginVersion + ? `@${selectedAction.source.pluginVersion}` + : ""} + + )} + + {selectedAction.execution.transport} + + {selectedAction.execution.retryable && ( + + retryable + + )} +
+ {def?.description && ( +

+ {def.description} +

+ )} +
+ + {/* General Action Fields */} +
+
+ + + onActionUpdate(containingStep.id, selectedAction.id, { + name: e.target.value, + }) + } + className="mt-1 h-7 text-xs" + /> +
+
+ + {/* Parameters */} + {def?.parameters.length ? ( +
+
+ Parameters +
+
+ {def.parameters.map((param) => { + const rawValue = selectedAction.parameters[param.id]; + const commonLabel = ( + + ); + + /* ---- 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 = ( + updateParamValue(e.target.value)} + className="mt-1 h-7 text-xs" + /> + ); + } else if (param.type === "select") { + control = ( + + ); + } else if (param.type === "boolean") { + control = ( +
+ updateParamValue(val)} + aria-label={param.name} + /> + + {Boolean(rawValue) ? "Enabled" : "Disabled"} + +
+ ); + } 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 = ( +
+
+ + updateParamValue(vals[0]) + } + /> + + {step < 1 + ? Number(numericVal).toFixed(2) + : Number(numericVal).toString()} + +
+
+ {min} + {max} +
+
+ ); + } else { + control = ( + + updateParamValue(parseFloat(e.target.value) || 0) + } + className="mt-1 h-7 text-xs" + /> + ); + } + } + + return ( +
+ {commonLabel} + {param.description && ( +
+ {param.description} +
+ )} + {control} +
+ ); + })} +
+
+ ) : ( +
+ No parameters for this action. +
+ )} +
+ ); + } + + /* --------------------------- Step Properties View --------------------------- */ + if (selectedStep) { + return ( +
+
+

+
+ Step Settings +

+
+
+
+ + + onStepUpdate(selectedStep.id, { name: e.target.value }) + } + className="mt-1 h-7 text-xs" + /> +
+
+ + + onStepUpdate(selectedStep.id, { + description: e.target.value, + }) + } + className="mt-1 h-7 text-xs" + /> +
+
+ + +
+
+ + +
+
+
+ ); + } + + /* ------------------------------- Empty State ------------------------------- */ + return ( +
+
+ +

Select Step or Action

+

+ Click in the flow to edit properties +

+
+
+ ); +} diff --git a/src/components/experiments/designer/StepFlow.tsx b/src/components/experiments/designer/StepFlow.tsx new file mode 100644 index 0000000..ee0ab65 --- /dev/null +++ b/src/components/experiments/designer/StepFlow.tsx @@ -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> = { + 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 ( +
+ {isEmpty ? ( +
+
+ +

Drop actions here

+
+
+ ) : ( + children + )} +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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 ( +
+
+
+ +
+ + {index + 1} + + {def && ( +
+ +
+ )} + + {action.source.kind === "plugin" ? ( + + P + + ) : ( + + C + + )} + {action.name} + + + {(action.type ?? "").replace(/_/g, " ")} + +
+ +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* SortableStep */ +/* -------------------------------------------------------------------------- */ + +interface SortableStepProps { + step: ExperimentStep; + index: number; + isSelected: boolean; + selectedActionId: string | null; + onSelect: () => void; + onDelete: () => void; + onUpdate: (updates: Partial) => 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 = { + sequential: "border-l-blue-500", + parallel: "border-l-emerald-500", + conditional: "border-l-amber-500", + loop: "border-l-purple-500", + }; + + return ( +
+ + onSelect()}> +
+
+ + + {index + 1} + +
+
{step.name}
+
+ {step.actions.length} actions • {step.type} +
+
+
+
+ +
+ +
+
+
+
+ {step.expanded && ( + + + {step.actions.length > 0 && ( + a.id)} + strategy={verticalListSortingStrategy} + > +
+ {step.actions.map((action, actionIndex) => ( + onActionSelect(action.id)} + onDelete={() => onActionDelete(action.id)} + /> + ))} +
+
+ )} +
+
+ )} +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* 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) => void; + onActionSelect: (actionId: string) => void; + onActionDelete: (stepId: string, actionId: string) => void; + onActionUpdate?: ( + stepId: string, + actionId: string, + updates: Partial, + ) => void; + emptyState?: React.ReactNode; + headerRight?: React.ReactNode; +} + +export function StepFlow({ + steps, + selectedStepId, + selectedActionId, + onStepSelect, + onStepDelete, + onStepUpdate, + onActionSelect, + onActionDelete, + emptyState, + headerRight, +}: StepFlowProps) { + return ( + + + +
+ + Experiment Flow +
+ {headerRight} +
+
+ + +
+ {steps.length === 0 ? ( + (emptyState ?? ( +
+ +

No steps yet

+

+ Add your first step to begin designing +

+
+ )) + ) : ( + s.id)} + strategy={verticalListSortingStrategy} + > +
+ {steps.map((step, index) => ( +
+ 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 && ( +
+
+
+ )} +
+ ))} +
+ + )} +
+ + + + ); +} diff --git a/src/components/participants/ParticipantsTable.tsx b/src/components/participants/ParticipantsTable.tsx index 9c64453..42a43c0 100644 --- a/src/components/participants/ParticipantsTable.tsx +++ b/src/components/participants/ParticipantsTable.tsx @@ -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[] = [ const date = row.getValue("createdAt"); return (
- {formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })} + {formatDistanceToNow(new Date(date as string | number | Date), { + addSuffix: true, + })}
); }, @@ -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) { diff --git a/src/components/plugins/plugin-store-browse.tsx b/src/components/plugins/plugin-store-browse.tsx index ef3fb30..ddc590e 100644 --- a/src/components/plugins/plugin-store-browse.tsx +++ b/src/components/plugins/plugin-store-browse.tsx @@ -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."}

-
diff --git a/src/components/plugins/plugins-columns.tsx b/src/components/plugins/plugins-columns.tsx index 694463c..8120188 100644 --- a/src/components/plugins/plugins-columns.tsx +++ b/src/components/plugins/plugins-columns.tsx @@ -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 }) { - Uninstall + {isCorePlugin + ? "Core Plugin" + : uninstallMutation.isPending + ? "Uninstalling..." + : "Uninstall"}
diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx new file mode 100644 index 0000000..9eb510c --- /dev/null +++ b/src/components/ui/slider.tsx @@ -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) { + const _values = React.useMemo( + () => + Array.isArray(value) + ? value + : Array.isArray(defaultValue) + ? defaultValue + : [min, max], + [value, defaultValue, min, max], + ); + + return ( + + + + + {Array.from({ length: _values.length }, (_, index) => ( + + ))} + + ); +} + +export { Slider }; diff --git a/src/hooks/useStudyManagement.ts b/src/hooks/useStudyManagement.ts index 47f2f11..5749f01 100644 --- a/src/hooks/useStudyManagement.ts +++ b/src/hooks/useStudyManagement.ts @@ -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; + }, }, ); diff --git a/src/lib/auth-error-handler.ts b/src/lib/auth-error-handler.ts new file mode 100644 index 0000000..169475a --- /dev/null +++ b/src/lib/auth-error-handler.ts @@ -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 { + 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, +>(fn: T, customMessage?: string): T { + return (async (...args: Parameters): Promise> => { + try { + return (await fn(...args)) as ReturnType; + } 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); +} diff --git a/src/lib/experiment-designer/block-converter.ts b/src/lib/experiment-designer/block-converter.ts new file mode 100644 index 0000000..ba8c233 --- /dev/null +++ b/src/lib/experiment-designer/block-converter.ts @@ -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; + actions: ConvertedAction[]; +} + +export interface ConvertedAction { + name: string; + description?: string; + type: string; + orderIndex: number; + parameters: Record; + 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, + }; +} diff --git a/src/lib/experiment-designer/execution-compiler.ts b/src/lib/experiment-designer/execution-compiler.ts new file mode 100644 index 0000000..7e08978 --- /dev/null +++ b/src/lib/experiment-designer/execution-compiler.ts @@ -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; + }; + 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; + 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(); + 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[] { + 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(); + 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(); + 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); +} diff --git a/src/lib/experiment-designer/types.ts b/src/lib/experiment-designer/types.ts new file mode 100644 index 0000000..2635b7c --- /dev/null +++ b/src/lib/experiment-designer/types.ts @@ -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; + 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; +} + +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; + query?: Record; + bodyTemplate?: unknown; +} diff --git a/src/lib/experiment-designer/visual-design-guard.ts b/src/lib/experiment-designer/visual-design-guard.ts new file mode 100644 index 0000000..baabe8c --- /dev/null +++ b/src/lib/experiment-designer/visual-design-guard.ts @@ -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(); + 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(); + 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; +} diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 32392a6..5699e11 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -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 diff --git a/src/server/api/routers/dashboard.ts b/src/server/api/routers/dashboard.ts new file mode 100644 index 0000000..dca3b39 --- /dev/null +++ b/src/server/api/routers/dashboard.ts @@ -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, + }, + }; + }), +}); diff --git a/src/server/api/routers/robots.ts b/src/server/api/routers/robots.ts index e1206b6..3047b5e 100644 --- a/src/server/api/routers/robots.ts +++ b/src/server/api/routers/robots.ts @@ -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, }, diff --git a/src/server/api/routers/studies.ts b/src/server/api/routers/studies.ts index abd968c..a0d54d3 100644 --- a/src/server/api/routers/studies.ts +++ b/src/server/api/routers/studies.ts @@ -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 }; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index fe01278..47c9fef 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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(),