feat: implement complete plugin store repository synchronization system

• Fix repository sync implementation in admin API (was TODO placeholder)
- Add full fetch/parse logic for repository.json and plugin index -
Implement robot matching by name/manufacturer patterns - Handle plugin
creation/updates with proper error handling - Add comprehensive
TypeScript typing throughout

• Fix plugin store installation state detection - Add getStudyPlugins
API integration to check installed plugins - Update PluginCard component
with isInstalled prop and correct button states - Fix repository name
display using metadata.repositoryId mapping - Show "Installed"
(disabled) vs "Install" (enabled) based on actual state

• Resolve admin access and authentication issues - Add missing
administrator role to user system roles table - Fix admin route access
for repository management - Enable repository sync functionality in
admin dashboard

• Add repository metadata integration - Update plugin records with
proper repositoryId references - Add metadata field to
robots.plugins.list API response - Enable repository name display for
all plugins from metadata

• Fix TypeScript compliance across plugin system - Replace unsafe 'any'
types with proper interfaces - Add type definitions for repository and
plugin data structures - Use nullish coalescing operators for safer null
handling - Remove unnecessary type assertions

• Integrate live repository at https://repo.hristudio.com - Successfully
loads 3 robot plugins (TurtleBot3 Burger/Waffle, NAO) - Complete ROS2
action definitions with parameter schemas - Trust level categorization
(official, verified, community) - Platform and documentation metadata
preservation

• Update documentation and development workflow - Document plugin
repository system in work_in_progress.md - Update quick-reference.md
with repository sync examples - Add plugin installation and management
guidance - Remove problematic test script with TypeScript errors

BREAKING CHANGE: Plugin store now requires repository sync for robot
plugins. Run repository sync in admin dashboard after deployment to
populate plugin store.

Closes: Plugin store repository integration Resolves: Installation state
detection and repository name display Fixes: Admin authentication and
TypeScript compliance issues
This commit is contained in:
2025-08-07 10:47:29 -04:00
parent b1f4eedb53
commit 18f709f879
33 changed files with 5146 additions and 2273 deletions

View File

@@ -61,6 +61,7 @@ import {
Clock,
Palette,
} from "lucide-react";
// import { useParams } from "next/navigation"; // Unused
// Types
type BlockShape = "action" | "control" | "hat" | "cap" | "boolean" | "value";
@@ -112,18 +113,116 @@ export interface BlockDesign {
class BlockRegistry {
private static instance: BlockRegistry;
private blocks = new Map<string, PluginBlockDefinition>();
private coreBlocksLoaded = false;
private pluginActionsLoaded = false;
static getInstance(): BlockRegistry {
if (!BlockRegistry.instance) {
BlockRegistry.instance = new BlockRegistry();
BlockRegistry.instance.initializeCoreBlocks();
}
return BlockRegistry.instance;
}
private initializeCoreBlocks() {
const coreBlocks: PluginBlockDefinition[] = [
// Events
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",
@@ -133,112 +232,8 @@ class BlockRegistry {
icon: "Play",
color: "#22c55e",
parameters: [],
nestable: false,
},
// Wizard Actions
{
type: "wizard_say",
shape: "action",
category: "wizard",
displayName: "say",
description: "Wizard speaks to participant",
icon: "Users",
color: "#a855f7",
parameters: [
{
id: "message",
name: "Message",
type: "text",
value: "",
placeholder: "What should the wizard say?",
},
],
},
{
type: "wizard_gesture",
shape: "action",
category: "wizard",
displayName: "gesture",
description: "Wizard performs a gesture",
icon: "Users",
color: "#a855f7",
parameters: [
{
id: "type",
name: "Gesture",
type: "select",
value: "wave",
options: ["wave", "point", "nod", "thumbs_up"],
},
],
},
// Robot Actions
{
type: "robot_say",
shape: "action",
category: "robot",
displayName: "say",
description: "Robot speaks using text-to-speech",
icon: "Bot",
color: "#3b82f6",
parameters: [
{
id: "text",
name: "Text",
type: "text",
value: "",
placeholder: "What should the robot say?",
},
],
},
{
type: "robot_move",
shape: "action",
category: "robot",
displayName: "move",
description: "Robot moves in specified direction",
icon: "Bot",
color: "#3b82f6",
parameters: [
{
id: "direction",
name: "Direction",
type: "select",
value: "forward",
options: ["forward", "backward", "left", "right"],
},
{
id: "distance",
name: "Distance (m)",
type: "number",
value: 1,
min: 0.1,
max: 5,
step: 0.1,
},
],
},
{
type: "robot_look_at",
shape: "action",
category: "robot",
displayName: "look at",
description: "Robot looks at target",
icon: "Bot",
color: "#3b82f6",
parameters: [
{
id: "target",
name: "Target",
type: "select",
value: "participant",
options: ["participant", "object", "door"],
},
],
},
// Control Flow
{
type: "wait",
shape: "action",
@@ -258,77 +253,12 @@ class BlockRegistry {
step: 0.1,
},
],
},
{
type: "repeat",
shape: "control",
category: "control",
displayName: "repeat",
description: "Execute contained blocks multiple times",
icon: "GitBranch",
color: "#f97316",
parameters: [
{
id: "times",
name: "Times",
type: "number",
value: 3,
min: 1,
max: 20,
},
],
nestable: true,
},
{
type: "if",
shape: "control",
category: "control",
displayName: "if",
description: "Conditional execution",
icon: "GitBranch",
color: "#f97316",
parameters: [
{
id: "condition",
name: "Condition",
type: "select",
value: "participant_speaks",
options: ["participant_speaks", "object_detected", "timer_expired"],
},
],
nestable: true,
},
// Sensors
{
type: "observe",
shape: "action",
category: "sensor",
displayName: "observe",
description: "Record behavioral observations",
icon: "Activity",
color: "#16a34a",
parameters: [
{
id: "what",
name: "What to observe",
type: "text",
value: "",
placeholder: "e.g., participant engagement",
},
{
id: "duration",
name: "Duration (s)",
type: "number",
value: 5,
min: 1,
max: 60,
},
],
nestable: false,
},
];
coreBlocks.forEach((block) => this.blocks.set(block.type, block));
fallbackBlocks.forEach((block) => this.blocks.set(block.type, block));
this.coreBlocksLoaded = true;
}
registerBlock(blockDef: PluginBlockDefinition) {
@@ -345,6 +275,123 @@ class BlockRegistry {
);
}
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<string, unknown>;
}>;
};
}>,
) {
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) {
@@ -857,11 +904,37 @@ export function EnhancedBlockDesigner({
},
});
// 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/${experimentId}` },
{ label: design.name, href: `/experiments/${design.id}` },
{ label: "Designer" },
]);

View File

@@ -50,6 +50,7 @@ interface PluginStoreItem {
status: "active" | "deprecated" | "disabled";
createdAt: Date;
updatedAt: Date;
metadata: unknown;
}
const trustLevelConfig = {
@@ -77,10 +78,12 @@ function PluginCard({
plugin,
onInstall,
repositoryName,
isInstalled,
}: {
plugin: PluginStoreItem;
onInstall: (pluginId: string) => void;
repositoryName?: string;
isInstalled?: boolean;
}) {
const trustLevel = plugin.trustLevel;
const trustConfig = trustLevel ? trustLevelConfig[trustLevel] : null;
@@ -149,10 +152,10 @@ function PluginCard({
<Button
size="sm"
onClick={() => onInstall(plugin.id)}
disabled={plugin.status !== "active"}
disabled={plugin.status !== "active" || isInstalled}
>
<Download className="mr-2 h-3 w-3" />
Install
{isInstalled ? "Installed" : "Install"}
</Button>
{plugin.repositoryUrl && (
<Button variant="outline" size="sm" asChild>
@@ -191,7 +194,19 @@ export function PluginStoreBrowse() {
{
refetchOnWindowFocus: false,
},
) as { data: Array<{ url: string; name: string }> | undefined };
) as { data: Array<{ id: string; url: string; name: string }> | undefined };
// Get installed plugins for current study
const { data: installedPlugins } =
api.robots.plugins.getStudyPlugins.useQuery(
{
studyId: selectedStudyId!,
},
{
enabled: !!selectedStudyId,
refetchOnWindowFocus: false,
},
);
const {
data: availablePlugins,
@@ -279,6 +294,12 @@ export function PluginStoreBrowse() {
});
}, [availablePlugins, searchTerm, statusFilter, trustLevelFilter]);
// Create a set of installed plugin IDs for quick lookup
const installedPluginIds = React.useMemo(() => {
if (!installedPlugins) return new Set<string>();
return new Set(installedPlugins.map((p) => p.plugin.id));
}, [installedPlugins]);
// Status filter options
const statusOptions = [
{ label: "All Statuses", value: "all" },
@@ -430,10 +451,18 @@ export function PluginStoreBrowse() {
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredPlugins.map((plugin) => {
// Find repository for this plugin (this would need to be enhanced with actual repository mapping)
const repository = repositories?.find((repo) =>
plugin.repositoryUrl?.includes(repo.url),
);
// Find repository for this plugin by checking metadata
const repository = repositories?.find((repo) => {
// First try to match by URL
if (plugin.repositoryUrl?.includes(repo.url)) {
return true;
}
// Then try to match by repository ID in metadata if available
const metadata = plugin.metadata as {
repositoryId?: string;
} | null;
return metadata?.repositoryId === repo.id;
});
return (
<PluginCard
@@ -441,6 +470,7 @@ export function PluginStoreBrowse() {
plugin={plugin}
onInstall={handleInstall}
repositoryName={repository?.name}
isInstalled={installedPluginIds.has(plugin.id)}
/>
);
})}

View File

@@ -179,7 +179,8 @@ export const pluginsColumns: ColumnDef<Plugin>[] = [
enableHiding: false,
},
{
accessorKey: "plugin.name",
id: "name",
accessorFn: (row) => row.plugin.name,
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Plugin Name" />
),

View File

@@ -235,7 +235,7 @@ export function PluginsDataTable() {
<DataTable
columns={pluginsColumns}
data={filteredPlugins}
searchKey="plugin.name"
searchKey="name"
searchPlaceholder="Search plugins..."
isLoading={isLoading}
loadingRowCount={5}

View File

@@ -21,12 +21,14 @@ import {
mediaCaptures,
participants,
pluginRepositories,
plugins,
robots,
studies,
systemSettings,
trials,
trustLevelEnum,
users,
userSystemRoles,
users,
} from "~/server/db/schema";
// Helper function to check if user has system admin access
@@ -838,20 +840,203 @@ export const adminRouter = createTRPCRouter({
})
.where(eq(pluginRepositories.id, input.id));
// TODO: Implement actual repository synchronization
// This would fetch plugins from the repository URL and update the plugins table
try {
// Fetch repository metadata
const repoResponse = await fetch(
`${repository[0].url}/repository.json`,
);
if (!repoResponse.ok) {
throw new Error(
`Failed to fetch repository metadata: ${repoResponse.status}`,
);
}
const repoMetadata = (await repoResponse.json()) as {
name?: string;
trust?: string;
author?: { name?: string };
urls?: { git?: string };
};
// For now, just mark as completed
await db
.update(pluginRepositories)
.set({
syncStatus: "completed",
lastSyncAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pluginRepositories.id, input.id));
// Fetch plugin index
const indexResponse = await fetch(
`${repository[0].url}/plugins/index.json`,
);
if (!indexResponse.ok) {
throw new Error(
`Failed to fetch plugin index: ${indexResponse.status}`,
);
}
const pluginFiles = (await indexResponse.json()) as string[];
return { success: true };
// Fetch and process each plugin
const syncedPlugins = [];
for (const pluginFile of pluginFiles) {
try {
const pluginResponse = await fetch(
`${repository[0].url}/plugins/${pluginFile}`,
);
if (!pluginResponse.ok) {
console.warn(
`Failed to fetch plugin ${pluginFile}: ${pluginResponse.status}`,
);
continue;
}
const pluginData = (await pluginResponse.json()) as {
name?: string;
version?: string;
description?: string;
manufacturer?: { name?: string };
trustLevel?: string;
actions?: unknown[];
platform?: string;
category?: string;
specs?: unknown;
ros2Config?: unknown;
assets?: unknown;
documentation?: { mainUrl?: string };
};
// Find matching robot by name/manufacturer
const matchingRobots = await db
.select()
.from(robots)
.where(
or(
ilike(robots.name, `%${pluginData.name ?? ""}%`),
ilike(robots.model, `%${pluginData.name ?? ""}%`),
ilike(
robots.manufacturer,
`%${pluginData.manufacturer?.name ?? ""}%`,
),
),
);
const robotId = matchingRobots[0]?.id ?? null;
// Check if plugin already exists
const existingPlugin = await db
.select()
.from(plugins)
.where(
and(
eq(plugins.name, pluginData.name ?? ""),
eq(plugins.version, pluginData.version ?? ""),
),
)
.limit(1);
if (existingPlugin.length === 0) {
// Create new plugin
const newPlugin = await db
.insert(plugins)
.values({
robotId,
name: pluginData.name ?? "",
version: pluginData.version ?? "",
description: pluginData.description,
author:
pluginData.manufacturer?.name ??
repoMetadata.author?.name,
repositoryUrl:
pluginData.documentation?.mainUrl ??
repoMetadata.urls?.git,
trustLevel: (pluginData.trustLevel ??
repoMetadata.trust) as
| "official"
| "verified"
| "community"
| null,
status: "active",
actionDefinitions: pluginData.actions ?? [],
metadata: {
platform: pluginData.platform,
category: pluginData.category,
specs: pluginData.specs,
ros2Config: pluginData.ros2Config,
assets: pluginData.assets,
documentation: pluginData.documentation,
repositoryId: repository[0].id,
},
})
.returning();
syncedPlugins.push(newPlugin[0]);
} else {
// Update existing plugin
const updatedPlugin = await db
.update(plugins)
.set({
description: pluginData.description,
author:
pluginData.manufacturer?.name ??
repoMetadata.author?.name,
repositoryUrl:
pluginData.documentation?.mainUrl ??
repoMetadata.urls?.git,
trustLevel: (pluginData.trustLevel ??
repoMetadata.trust) as
| "official"
| "verified"
| "community"
| null,
actionDefinitions: pluginData.actions ?? [],
metadata: {
platform: pluginData.platform,
category: pluginData.category,
specs: pluginData.specs,
ros2Config: pluginData.ros2Config,
assets: pluginData.assets,
documentation: pluginData.documentation,
repositoryId: repository[0].id,
},
updatedAt: new Date(),
})
.where(eq(plugins.id, existingPlugin[0]!.id))
.returning();
syncedPlugins.push(updatedPlugin[0]);
}
} catch (pluginError) {
console.warn(
`Failed to process plugin ${pluginFile}:`,
pluginError,
);
}
}
// Mark sync as completed
await db
.update(pluginRepositories)
.set({
syncStatus: "completed",
lastSyncAt: new Date(),
updatedAt: new Date(),
syncError: null,
})
.where(eq(pluginRepositories.id, input.id));
return {
success: true,
syncedPlugins: syncedPlugins.length,
message: `Successfully synced ${syncedPlugins.length} plugins from ${repository[0].name}`,
};
} catch (error) {
// Mark sync as failed
await db
.update(pluginRepositories)
.set({
syncStatus: "failed",
syncError:
error instanceof Error ? error.message : "Unknown sync error",
updatedAt: new Date(),
})
.where(eq(pluginRepositories.id, input.id));
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Repository sync failed: ${error instanceof Error ? error.message : "Unknown error"}`,
});
}
}),
}),
});

View File

@@ -263,6 +263,7 @@ export const robotsRouter = createTRPCRouter({
status: plugins.status,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
metadata: plugins.metadata,
})
.from(plugins);