"use client"; import { useState, useEffect } from "react"; import type { ActionDefinition, ExperimentAction, } from "~/lib/experiment-designer/types"; import corePluginDef from "~/plugins/definitions/hristudio-core.json"; import wozPluginDef from "~/plugins/definitions/hristudio-woz.json"; /** * ActionRegistry * * Central singleton for loading and serving action definitions from: * - Core system action JSON manifests (hristudio-core, hristudio-woz) * - 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) */ export class ActionRegistry { private static instance: ActionRegistry; private actions = new Map(); private aliasIndex = new Map(); private coreActionsLoaded = false; private pluginActionsLoaded = false; private loadedStudyId: string | null = null; private listeners = new Set<() => void>(); private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"]; static getInstance(): ActionRegistry { if (!ActionRegistry.instance) { ActionRegistry.instance = new ActionRegistry(); } return ActionRegistry.instance; } /* ---------------- Reactivity ---------------- */ subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); } private notifyListeners(): void { this.listeners.forEach((listener) => listener()); } /* ---------------- Core / System Actions ---------------- */ async loadCoreActions(): Promise { if (this.coreActionsLoaded) return; // Load System Plugins (Core & WoZ) this.registerPluginDefinition(corePluginDef); this.registerPluginDefinition(wozPluginDef); console.log( `[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`, ); this.coreActionsLoaded = true; this.notifyListeners(); } /* ---------------- Plugin Actions ---------------- */ loadPluginActions(studyId: string, studyPlugins: any[]): void { if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return; if (this.loadedStudyId !== studyId) { this.resetPluginActions(); } let totalActionsLoaded = 0; (studyPlugins ?? []).forEach((plugin) => { this.registerPluginDefinition(plugin); totalActionsLoaded += plugin.actionDefinitions?.length || 0; }); console.log( `ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`, ); this.pluginActionsLoaded = true; this.loadedStudyId = studyId; this.notifyListeners(); } /* ---------------- Shared Registration Logic ---------------- */ private registerPluginDefinition(plugin: any) { const actionDefs = Array.isArray(plugin.actionDefinitions) ? plugin.actionDefinitions : undefined; if (!actionDefs) return; actionDefs.forEach((action: any) => { const rawCategory = typeof action.category === "string" ? action.category.toLowerCase().trim() : ""; const categoryMap: Record = { wizard: "wizard", robot: "robot", control: "control", observation: "observation", }; // Default category based on plugin type or explicit category let category = categoryMap[rawCategory]; if (!category) { if (plugin.id === "hristudio-woz") category = "wizard"; else if (plugin.id === "hristudio-core") category = "control"; else 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, }; // Extract semantic ID from metadata if available, otherwise fall back to database IDs // Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id const semanticRobotId = plugin.metadata?.robotId || plugin.metadata?.id || plugin.robotId || plugin.id; // For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data // For robot plugins, we namespace them (nao6-ros2.say_text) const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId); const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`; const actionType = actionId; // Type is usually same as ID const actionDef: ActionDefinition = { id: actionId, type: actionType, name: action.name, description: action.description ?? "", category, icon: action.icon ?? "Bot", color: action.color || "#10b981", parameters: this.convertParameterSchemaToParameters( action.parameterSchema, ), source: { kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed pluginId: semanticRobotId, robotId: plugin.robotId, pluginVersion: plugin.version ?? undefined, baseActionId: action.id, }, execution, parameterSchemaRaw: action.parameterSchema ?? undefined, nestable: action.nestable, }; // Prevent overwriting if it already exists (first-come-first-served, usually core first) if (!this.actions.has(actionId)) { this.actions.set(actionId, actionDef); } // Register aliases const aliases = Array.isArray(action.aliases) ? action.aliases : undefined; if (aliases) { for (const alias of aliases) { if (typeof alias === "string" && alias.trim()) { this.aliasIndex.set(alias, actionDef.id); } } } }); } 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" | "json" | "array" = "text"; if (paramDef.type === "number") { type = "number"; } else if (paramDef.type === "boolean") { type = "boolean"; } else if (paramDef.enum && Array.isArray(paramDef.enum)) { type = "select"; } else if (paramDef.type === "array") { type = "array"; } else if (paramDef.type === "object") { type = "json"; } 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; // Robust Reset: Remove valid plugin actions, BUT protect system plugins. const idsToDelete: string[] = []; this.actions.forEach((action, id) => { if ( action.source.kind === "plugin" && !this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "") ) { idsToDelete.push(id); } }); idsToDelete.forEach((id) => this.actions.delete(id)); this.notifyListeners(); } /* ---------------- 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 { const direct = this.actions.get(id); if (direct) return direct; const mapped = this.aliasIndex.get(id); return mapped ? this.actions.get(mapped) : undefined; } /* ---------------- Debug Helpers ---------------- */ getDebugInfo(): { coreActionsLoaded: boolean; pluginActionsLoaded: boolean; loadedStudyId: string | null; totalActions: number; actionsByCategory: Record; sampleActionIds: string[]; } { return { coreActionsLoaded: this.coreActionsLoaded, pluginActionsLoaded: this.pluginActionsLoaded, loadedStudyId: this.loadedStudyId, totalActions: this.actions.size, actionsByCategory: { wizard: this.getActionsByCategory("wizard").length, robot: this.getActionsByCategory("robot").length, control: this.getActionsByCategory("control").length, observation: this.getActionsByCategory("observation").length, }, sampleActionIds: Array.from(this.actions.keys()).slice(0, 10), }; } } export const actionRegistry = ActionRegistry.getInstance(); /* ---------------- React Hook ---------------- */ export function useActionRegistry(): ActionRegistry { const [, forceUpdate] = useState({}); useEffect(() => { const unsubscribe = actionRegistry.subscribe(() => { forceUpdate({}); }); return unsubscribe; }, []); return actionRegistry; }