mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
chore: commit full workspace changes (designer modularization, diagnostics fixes, docs updates, seed script cleanup)
This commit is contained in:
160
src/lib/experiment-designer/block-converter.ts
Normal file
160
src/lib/experiment-designer/block-converter.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
// Convert step-based design to database records
|
||||
export function convertStepsToDatabase(
|
||||
steps: ExperimentStep[],
|
||||
): ConvertedStep[] {
|
||||
return steps.map((step, index) => ({
|
||||
name: step.name,
|
||||
description: step.description,
|
||||
type: mapStepTypeToDatabase(step.type),
|
||||
orderIndex: index,
|
||||
durationEstimate: calculateStepDuration(step.actions),
|
||||
required: true,
|
||||
conditions: step.trigger.conditions,
|
||||
actions: step.actions.map((action, actionIndex) =>
|
||||
convertActionToDatabase(action, actionIndex),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
// Map designer step types to database step types
|
||||
function mapStepTypeToDatabase(
|
||||
stepType: ExperimentStep["type"],
|
||||
): "wizard" | "robot" | "parallel" | "conditional" {
|
||||
switch (stepType) {
|
||||
case "sequential":
|
||||
return "wizard"; // Default to wizard for sequential
|
||||
case "parallel":
|
||||
return "parallel";
|
||||
case "conditional":
|
||||
case "loop":
|
||||
return "conditional";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate step duration from actions
|
||||
function calculateStepDuration(actions: ExperimentAction[]): number {
|
||||
let total = 0;
|
||||
|
||||
for (const action of actions) {
|
||||
switch (action.type) {
|
||||
case "wizard_speak":
|
||||
case "robot_speak":
|
||||
// Estimate based on text length if available
|
||||
const text = action.parameters.text as string;
|
||||
if (text) {
|
||||
total += Math.max(2, text.length / 10); // ~10 chars per second
|
||||
} else {
|
||||
total += 3;
|
||||
}
|
||||
break;
|
||||
case "wait":
|
||||
total += (action.parameters.duration as number) || 2;
|
||||
break;
|
||||
case "robot_move":
|
||||
total += 5; // Movement takes longer
|
||||
break;
|
||||
case "wizard_gesture":
|
||||
total += 2;
|
||||
break;
|
||||
case "observe":
|
||||
total += (action.parameters.duration as number) || 5;
|
||||
break;
|
||||
default:
|
||||
total += 2; // Default duration
|
||||
}
|
||||
}
|
||||
|
||||
return Math.max(1, Math.round(total));
|
||||
}
|
||||
|
||||
// Estimate action timeout
|
||||
function estimateActionTimeout(action: ExperimentAction): number {
|
||||
switch (action.type) {
|
||||
case "wizard_speak":
|
||||
case "robot_speak":
|
||||
return 30;
|
||||
case "robot_move":
|
||||
return 60;
|
||||
case "wait": {
|
||||
const duration = action.parameters.duration as number | undefined;
|
||||
return (duration ?? 2) + 10; // Add buffer
|
||||
}
|
||||
case "observe":
|
||||
return 120; // Observation can take longer
|
||||
default:
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
// Database conversion types (same as before)
|
||||
export interface ConvertedStep {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: "wizard" | "robot" | "parallel" | "conditional";
|
||||
orderIndex: number;
|
||||
durationEstimate?: number;
|
||||
required: boolean;
|
||||
conditions: Record<string, unknown>;
|
||||
actions: ConvertedAction[];
|
||||
}
|
||||
|
||||
export interface ConvertedAction {
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
parameters: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
// Provenance & execution metadata (flattened for now)
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
transport?: ExecutionDescriptor["transport"];
|
||||
ros2?: ExecutionDescriptor["ros2"];
|
||||
rest?: ExecutionDescriptor["rest"];
|
||||
retryable?: boolean;
|
||||
parameterSchemaRaw?: unknown;
|
||||
sourceKind?: "core" | "plugin";
|
||||
category?: string;
|
||||
}
|
||||
|
||||
// Deprecated legacy compatibility function removed to eliminate unsafe any usage.
|
||||
// If needed, implement a proper migration path elsewhere.
|
||||
|
||||
/**
|
||||
* Convert a single experiment action into a ConvertedAction (DB shape),
|
||||
* preserving provenance and execution metadata for reproducibility.
|
||||
*/
|
||||
export function convertActionToDatabase(
|
||||
action: ExperimentAction,
|
||||
orderIndex: number,
|
||||
): ConvertedAction {
|
||||
return {
|
||||
name: action.name,
|
||||
description: `${action.type} action`,
|
||||
type: action.type,
|
||||
orderIndex,
|
||||
parameters: action.parameters,
|
||||
timeout: estimateActionTimeout(action),
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
transport: action.execution.transport,
|
||||
ros2: action.execution.ros2,
|
||||
rest: action.execution.rest,
|
||||
retryable: action.execution.retryable,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
sourceKind: action.source.kind,
|
||||
category: action.category,
|
||||
};
|
||||
}
|
||||
314
src/lib/experiment-designer/execution-compiler.ts
Normal file
314
src/lib/experiment-designer/execution-compiler.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Execution Compiler Utilities
|
||||
*
|
||||
* Purpose:
|
||||
* - Produce a deterministic execution graph snapshot from the visual design
|
||||
* - Generate an integrity hash capturing provenance & structural identity
|
||||
* - Extract normalized plugin dependency list (pluginId@version)
|
||||
*
|
||||
* These utilities are used on the server prior to saving an experiment so that
|
||||
* trial execution can rely on an immutable compiled artifact. This helps ensure
|
||||
* reproducibility by decoupling future plugin updates from already designed
|
||||
* experiment protocols.
|
||||
*
|
||||
* NOTE:
|
||||
* - This module intentionally performs only pure / synchronous operations.
|
||||
* - Any plugin resolution or database queries should happen in a higher layer
|
||||
* before invoking these functions.
|
||||
*/
|
||||
|
||||
import type {
|
||||
ExperimentDesign,
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
/* ---------- Public Types ---------- */
|
||||
|
||||
export interface CompiledExecutionGraph {
|
||||
version: number;
|
||||
generatedAt: string; // ISO timestamp
|
||||
steps: CompiledExecutionStep[];
|
||||
pluginDependencies: string[];
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface CompiledExecutionStep {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
type: ExperimentStep["type"];
|
||||
trigger: {
|
||||
type: string;
|
||||
conditions: Record<string, unknown>;
|
||||
};
|
||||
actions: CompiledExecutionAction[];
|
||||
estimatedDuration?: number;
|
||||
}
|
||||
|
||||
export interface CompiledExecutionAction {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
category: string;
|
||||
provenance: {
|
||||
sourceKind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
parameters: Record<string, unknown>;
|
||||
parameterSchemaRaw?: unknown;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/* ---------- Compile Entry Point ---------- */
|
||||
|
||||
/**
|
||||
* Compile an ExperimentDesign into a reproducible execution graph + hash.
|
||||
*/
|
||||
export function compileExecutionDesign(
|
||||
design: ExperimentDesign,
|
||||
opts: { hashAlgorithm?: "sha256" | "sha1" } = {},
|
||||
): CompiledExecutionGraph {
|
||||
const pluginDependencies = collectPluginDependencies(design);
|
||||
|
||||
const compiledSteps: CompiledExecutionStep[] = design.steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => compileStep(step));
|
||||
|
||||
const structuralSignature = buildStructuralSignature(
|
||||
design,
|
||||
compiledSteps,
|
||||
pluginDependencies,
|
||||
);
|
||||
|
||||
const hash = stableHash(structuralSignature, opts.hashAlgorithm ?? "sha256");
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
steps: compiledSteps,
|
||||
pluginDependencies,
|
||||
hash,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Step / Action Compilation ---------- */
|
||||
|
||||
function compileStep(step: ExperimentStep): CompiledExecutionStep {
|
||||
const compiledActions: CompiledExecutionAction[] = step.actions.map(
|
||||
(action, index) => compileAction(action, index),
|
||||
);
|
||||
|
||||
return {
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
order: step.order,
|
||||
type: step.type,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditions: step.trigger.conditions ?? {},
|
||||
},
|
||||
actions: compiledActions,
|
||||
estimatedDuration: step.estimatedDuration,
|
||||
};
|
||||
}
|
||||
|
||||
function compileAction(
|
||||
action: ExperimentAction,
|
||||
_index: number, // index currently unused (reserved for future ordering diagnostics)
|
||||
): CompiledExecutionAction {
|
||||
return {
|
||||
id: action.id,
|
||||
name: action.name,
|
||||
type: action.type,
|
||||
category: action.category,
|
||||
provenance: {
|
||||
sourceKind: action.source.kind,
|
||||
pluginId: action.source.pluginId,
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
robotId: action.source.robotId,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: action.execution,
|
||||
parameters: action.parameters,
|
||||
parameterSchemaRaw: action.parameterSchemaRaw,
|
||||
timeout: action.execution.timeoutMs,
|
||||
retryable: action.execution.retryable,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------- Plugin Dependency Extraction ---------- */
|
||||
|
||||
export function collectPluginDependencies(design: ExperimentDesign): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
for (const action of step.actions) {
|
||||
if (action.source.kind === "plugin" && action.source.pluginId) {
|
||||
const versionPart = action.source.pluginVersion
|
||||
? `@${action.source.pluginVersion}`
|
||||
: "";
|
||||
set.add(`${action.source.pluginId}${versionPart}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
/* ---------- Integrity Hash Generation ---------- */
|
||||
|
||||
/**
|
||||
* Build a minimal, deterministic JSON-serializable representation capturing:
|
||||
* - Step ordering, ids, types, triggers
|
||||
* - Action ordering, ids, types, provenance, execution transport, parameters (keys only for hash)
|
||||
* - Plugin dependency list
|
||||
*
|
||||
* Parameter values are not fully included (only key presence) to avoid hash churn
|
||||
* on mutable text fields while preserving structural identity. If full parameter
|
||||
* value hashing is desired, adjust `summarizeParametersForHash`.
|
||||
*/
|
||||
function buildStructuralSignature(
|
||||
design: ExperimentDesign,
|
||||
steps: CompiledExecutionStep[],
|
||||
pluginDependencies: string[],
|
||||
): unknown {
|
||||
return {
|
||||
experimentId: design.id,
|
||||
version: design.version,
|
||||
steps: steps.map((s) => ({
|
||||
id: s.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
// Include condition keys only for stability
|
||||
conditionKeys: Object.keys(s.trigger.conditions).sort(),
|
||||
},
|
||||
actions: s.actions.map((a) => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
category: a.category,
|
||||
provenance: a.provenance,
|
||||
transport: a.execution.transport,
|
||||
timeout: a.timeout,
|
||||
retryable: a.retryable ?? false,
|
||||
parameterKeys: summarizeParametersForHash(a.parameters),
|
||||
})),
|
||||
})),
|
||||
pluginDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeParametersForHash(params: Record<string, unknown>): string[] {
|
||||
return Object.keys(params).sort();
|
||||
}
|
||||
|
||||
/* ---------- Stable Hash Implementation ---------- */
|
||||
|
||||
/**
|
||||
* Simple stable hash using built-in Web Crypto if available; falls back
|
||||
* to a lightweight JS implementation (FNV-1a) for environments without
|
||||
* crypto.subtle (e.g. some test runners).
|
||||
*
|
||||
* This is synchronous; if crypto.subtle is present it still uses
|
||||
* a synchronous wrapper by blocking on the Promise with deasync style
|
||||
* simulation (not implemented) so we default to FNV-1a here for portability.
|
||||
*/
|
||||
function stableHash(value: unknown, algorithm: "sha256" | "sha1"): string {
|
||||
// Use a deterministic JSON stringify
|
||||
const json = JSON.stringify(value);
|
||||
// FNV-1a 64-bit (represented as hex)
|
||||
let hashHigh = 0xcbf29ce4;
|
||||
let hashLow = 0x84222325; // Split 64-bit for simple JS accumulation
|
||||
|
||||
for (let i = 0; i < json.length; i++) {
|
||||
const c = json.charCodeAt(i);
|
||||
// XOR low part
|
||||
hashLow ^= c;
|
||||
// 64-bit FNV prime: 1099511628211 -> split multiply
|
||||
// (hash * prime) mod 2^64
|
||||
// Multiply low
|
||||
let low =
|
||||
(hashLow & 0xffff) * 0x1b3 +
|
||||
(((hashLow >>> 16) * 0x1b3) & 0xffff) * 0x10000;
|
||||
// Include high
|
||||
low +=
|
||||
((hashHigh & 0xffff) * 0x1b3 +
|
||||
(((hashHigh >>> 16) * 0x1b3) & 0xffff) * 0x10000) &
|
||||
0xffffffff;
|
||||
// Rotate values (approximate 64-bit handling)
|
||||
hashHigh ^= low >>> 13;
|
||||
hashHigh &= 0xffffffff;
|
||||
hashLow = low & 0xffffffff;
|
||||
}
|
||||
|
||||
// Combine into hex; algorithm param reserved for future (differing strategies)
|
||||
const highHex = (hashHigh >>> 0).toString(16).padStart(8, "0");
|
||||
const lowHex = (hashLow >>> 0).toString(16).padStart(8, "0");
|
||||
return `${algorithm}-${highHex}${lowHex}`;
|
||||
}
|
||||
|
||||
/* ---------- Validation Helpers (Optional Use) ---------- */
|
||||
|
||||
/**
|
||||
* Lightweight structural sanity checks prior to compilation.
|
||||
* Returns array of issues; empty array means pass.
|
||||
*/
|
||||
export function validateDesignStructure(design: ExperimentDesign): string[] {
|
||||
const issues: string[] = [];
|
||||
if (!design.steps.length) {
|
||||
issues.push("No steps defined");
|
||||
}
|
||||
const seenStepIds = new Set<string>();
|
||||
for (const step of design.steps) {
|
||||
if (seenStepIds.has(step.id)) {
|
||||
issues.push(`Duplicate step id: ${step.id}`);
|
||||
} else {
|
||||
seenStepIds.add(step.id);
|
||||
}
|
||||
if (!step.actions.length) {
|
||||
issues.push(`Step "${step.name}" has no actions`);
|
||||
}
|
||||
const seenActionIds = new Set<string>();
|
||||
for (const action of step.actions) {
|
||||
if (seenActionIds.has(action.id)) {
|
||||
issues.push(`Duplicate action id in step "${step.name}": ${action.id}`);
|
||||
} else {
|
||||
seenActionIds.add(action.id);
|
||||
}
|
||||
if (!action.type) {
|
||||
issues.push(`Action "${action.id}" missing type`);
|
||||
}
|
||||
if (!action.source?.kind) {
|
||||
issues.push(`Action "${action.id}" missing provenance`);
|
||||
}
|
||||
if (!action.execution?.transport) {
|
||||
issues.push(`Action "${action.id}" missing execution transport`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level convenience wrapper: validate + compile; throws on issues.
|
||||
*/
|
||||
export function validateAndCompile(
|
||||
design: ExperimentDesign,
|
||||
): CompiledExecutionGraph {
|
||||
const issues = validateDesignStructure(design);
|
||||
if (issues.length) {
|
||||
const error = new Error(
|
||||
`Design validation failed:\n- ${issues.join("\n- ")}`,
|
||||
);
|
||||
(error as { issues?: string[] }).issues = issues;
|
||||
throw error;
|
||||
}
|
||||
return compileExecutionDesign(design);
|
||||
}
|
||||
166
src/lib/experiment-designer/types.ts
Normal file
166
src/lib/experiment-designer/types.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// Core experiment designer types
|
||||
|
||||
export type StepType = "sequential" | "parallel" | "conditional" | "loop";
|
||||
|
||||
export type ActionCategory = "wizard" | "robot" | "observation" | "control";
|
||||
|
||||
export type ActionType =
|
||||
| "wizard_speak"
|
||||
| "wizard_gesture"
|
||||
| "robot_move"
|
||||
| "robot_speak"
|
||||
| "wait"
|
||||
| "observe"
|
||||
| "collect_data"
|
||||
// Namespaced plugin action types will use pattern: pluginId.actionId
|
||||
| (string & {});
|
||||
|
||||
export type TriggerType =
|
||||
| "trial_start"
|
||||
| "participant_action"
|
||||
| "timer"
|
||||
| "previous_step";
|
||||
|
||||
export interface ActionParameter {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "number" | "select" | "boolean";
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number; // numeric increment if relevant
|
||||
}
|
||||
|
||||
export interface ActionDefinition {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ActionCategory;
|
||||
icon: string;
|
||||
color: string;
|
||||
parameters: ActionParameter[];
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string; // original internal action id inside plugin repo
|
||||
};
|
||||
execution?: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown; // snapshot of original schema for validation/audit
|
||||
}
|
||||
|
||||
export interface ExperimentAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
duration?: number;
|
||||
category: ActionCategory;
|
||||
source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
};
|
||||
execution: ExecutionDescriptor;
|
||||
parameterSchemaRaw?: unknown;
|
||||
}
|
||||
|
||||
export interface StepTrigger {
|
||||
type: TriggerType;
|
||||
conditions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ExperimentStep {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: StepType;
|
||||
order: number;
|
||||
trigger: StepTrigger;
|
||||
actions: ExperimentAction[];
|
||||
estimatedDuration?: number;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export interface ExperimentDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
compiledAt?: Date; // when an execution plan was compiled
|
||||
integrityHash?: string; // hash of action definitions for reproducibility
|
||||
}
|
||||
|
||||
// Trigger options for UI
|
||||
export const TRIGGER_OPTIONS = [
|
||||
{ value: "trial_start" as const, label: "Trial starts" },
|
||||
{ value: "participant_action" as const, label: "Participant acts" },
|
||||
{ value: "timer" as const, label: "After timer" },
|
||||
{ value: "previous_step" as const, label: "Previous step completes" },
|
||||
];
|
||||
|
||||
// Step type options for UI
|
||||
export const STEP_TYPE_OPTIONS = [
|
||||
{
|
||||
value: "sequential" as const,
|
||||
label: "Sequential",
|
||||
description: "Actions run one after another",
|
||||
},
|
||||
{
|
||||
value: "parallel" as const,
|
||||
label: "Parallel",
|
||||
description: "Actions run at the same time",
|
||||
},
|
||||
{
|
||||
value: "conditional" as const,
|
||||
label: "Conditional",
|
||||
description: "Actions run if condition is met",
|
||||
},
|
||||
{
|
||||
value: "loop" as const,
|
||||
label: "Loop",
|
||||
description: "Actions repeat multiple times",
|
||||
},
|
||||
];
|
||||
|
||||
// Execution descriptors (appended)
|
||||
|
||||
export interface ExecutionDescriptor {
|
||||
transport: "ros2" | "rest" | "internal";
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
ros2?: Ros2Execution;
|
||||
rest?: RestExecution;
|
||||
}
|
||||
|
||||
export interface Ros2Execution {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
payloadMapping?: unknown; // mapping definition retained for transform at runtime
|
||||
}
|
||||
|
||||
export interface RestExecution {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
query?: Record<string, string | number | boolean>;
|
||||
bodyTemplate?: unknown;
|
||||
}
|
||||
326
src/lib/experiment-designer/visual-design-guard.ts
Normal file
326
src/lib/experiment-designer/visual-design-guard.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
ExperimentStep,
|
||||
ExperimentAction,
|
||||
StepType,
|
||||
TriggerType,
|
||||
ActionCategory,
|
||||
ExecutionDescriptor,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Visual Design Guard
|
||||
*
|
||||
* Provides a robust Zod-based parsing/normalization pipeline that:
|
||||
* - Accepts a loosely-typed visualDesign.steps payload coming from the client
|
||||
* - Normalizes and validates it into strongly typed arrays for internal processing
|
||||
* - Strips unknown fields (preserves only what we rely on)
|
||||
* - Ensures provenance + execution descriptors are structurally sound
|
||||
*
|
||||
* This replaces ad-hoc runtime filtering in the experiments update mutation.
|
||||
*
|
||||
* Usage:
|
||||
* const { steps, issues } = parseVisualDesignSteps(rawSteps);
|
||||
* if (issues.length) -> reject request
|
||||
* else -> steps (ExperimentStep[]) is now safe for conversion & compilation
|
||||
*/
|
||||
|
||||
// Enumerations (reuse domain model semantics without hard binding to future expansions)
|
||||
const stepTypeEnum = z.enum(["sequential", "parallel", "conditional", "loop"]);
|
||||
const triggerTypeEnum = z.enum([
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
"timer",
|
||||
"previous_step",
|
||||
]);
|
||||
const actionCategoryEnum = z.enum([
|
||||
"wizard",
|
||||
"robot",
|
||||
"observation",
|
||||
"control",
|
||||
]);
|
||||
|
||||
// Provenance
|
||||
const actionSourceSchema = z
|
||||
.object({
|
||||
kind: z.enum(["core", "plugin"]),
|
||||
pluginId: z.string().min(1).optional(),
|
||||
pluginVersion: z.string().min(1).optional(),
|
||||
robotId: z.string().min(1).nullable().optional(),
|
||||
baseActionId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Execution descriptor
|
||||
const executionDescriptorSchema = z
|
||||
.object({
|
||||
transport: z.enum(["ros2", "rest", "internal"]),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
retryable: z.boolean().optional(),
|
||||
ros2: z
|
||||
.object({
|
||||
topic: z.string().min(1).optional(),
|
||||
messageType: z.string().min(1).optional(),
|
||||
service: z.string().min(1).optional(),
|
||||
action: z.string().min(1).optional(),
|
||||
qos: z
|
||||
.object({
|
||||
reliability: z.string().optional(),
|
||||
durability: z.string().optional(),
|
||||
history: z.string().optional(),
|
||||
depth: z.number().int().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
payloadMapping: z.unknown().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
rest: z
|
||||
.object({
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
path: z.string().min(1),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Action parameter snapshot is a free-form structure retained for audit
|
||||
const parameterSchemaRawSchema = z.unknown().optional();
|
||||
|
||||
// Action schema (loose input → normalized internal)
|
||||
const visualActionInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
type: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
category: actionCategoryEnum.optional(),
|
||||
parameters: z.record(z.string(), z.unknown()).default({}),
|
||||
source: actionSourceSchema.optional(),
|
||||
execution: executionDescriptorSchema.optional(),
|
||||
parameterSchemaRaw: parameterSchemaRawSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Trigger schema
|
||||
const triggerSchema = z
|
||||
.object({
|
||||
type: triggerTypeEnum,
|
||||
conditions: z.record(z.string(), z.unknown()).default({}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Step schema
|
||||
const visualStepInputSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
type: stepTypeEnum,
|
||||
order: z.number().int().nonnegative().optional(),
|
||||
trigger: triggerSchema.optional(),
|
||||
actions: z.array(visualActionInputSchema),
|
||||
expanded: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Array schema root
|
||||
const visualDesignStepsSchema = z.array(visualStepInputSchema);
|
||||
|
||||
/**
|
||||
* Parse & normalize raw steps payload.
|
||||
*/
|
||||
export function parseVisualDesignSteps(raw: unknown): {
|
||||
steps: ExperimentStep[];
|
||||
issues: string[];
|
||||
} {
|
||||
const issues: string[] = [];
|
||||
|
||||
const parsed = visualDesignStepsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
const zodErr = parsed.error;
|
||||
issues.push(
|
||||
...zodErr.issues.map(
|
||||
(issue) =>
|
||||
`steps${
|
||||
issue.path.length ? "." + issue.path.join(".") : ""
|
||||
}: ${issue.message} (code=${issue.code})`,
|
||||
),
|
||||
);
|
||||
return { steps: [], issues };
|
||||
}
|
||||
|
||||
// Normalize to internal ExperimentStep[] shape
|
||||
const inputSteps = parsed.data;
|
||||
|
||||
const normalized: ExperimentStep[] = inputSteps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
||||
// Default provenance
|
||||
const source: {
|
||||
kind: "core" | "plugin";
|
||||
pluginId?: string;
|
||||
pluginVersion?: string;
|
||||
robotId?: string | null;
|
||||
baseActionId?: string;
|
||||
} = a.source
|
||||
? {
|
||||
kind: a.source.kind,
|
||||
pluginId: a.source.pluginId,
|
||||
pluginVersion: a.source.pluginVersion,
|
||||
robotId: a.source.robotId ?? null,
|
||||
baseActionId: a.source.baseActionId,
|
||||
}
|
||||
: { kind: "core" };
|
||||
|
||||
// Default execution
|
||||
const execution: ExecutionDescriptor = a.execution
|
||||
? {
|
||||
transport: a.execution.transport,
|
||||
timeoutMs: a.execution.timeoutMs,
|
||||
retryable: a.execution.retryable,
|
||||
ros2: a.execution.ros2,
|
||||
rest: a.execution.rest
|
||||
? {
|
||||
method: a.execution.rest.method,
|
||||
path: a.execution.rest.path,
|
||||
headers: a.execution.rest.headers
|
||||
? Object.fromEntries(
|
||||
Object.entries(a.execution.rest.headers).filter(
|
||||
(kv): kv is [string, string] =>
|
||||
typeof kv[1] === "string",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: { transport: "internal" };
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type, // dynamic (pluginId.actionId)
|
||||
name: a.name,
|
||||
parameters: a.parameters ?? {},
|
||||
duration: undefined,
|
||||
category: (a.category ?? "wizard") as ActionCategory,
|
||||
source: {
|
||||
kind: source.kind,
|
||||
pluginId: source.kind === "plugin" ? source.pluginId : undefined,
|
||||
pluginVersion:
|
||||
source.kind === "plugin" ? source.pluginVersion : undefined,
|
||||
robotId: source.kind === "plugin" ? (source.robotId ?? null) : null,
|
||||
baseActionId:
|
||||
source.kind === "plugin" ? source.baseActionId : undefined,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: a.parameterSchemaRaw,
|
||||
};
|
||||
});
|
||||
|
||||
// Construct step
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
type: s.type as StepType,
|
||||
order: typeof s.order === "number" ? s.order : idx,
|
||||
trigger: {
|
||||
type: (s.trigger?.type ?? "previous_step") as TriggerType,
|
||||
conditions: s.trigger?.conditions ?? {},
|
||||
},
|
||||
actions,
|
||||
estimatedDuration: undefined,
|
||||
expanded: s.expanded ?? true,
|
||||
};
|
||||
});
|
||||
|
||||
// Basic structural checks
|
||||
const seenStepIds = new Set<string>();
|
||||
for (const st of normalized) {
|
||||
if (seenStepIds.has(st.id)) {
|
||||
issues.push(`Duplicate step id: ${st.id}`);
|
||||
}
|
||||
seenStepIds.add(st.id);
|
||||
if (!st.actions.length) {
|
||||
issues.push(`Step "${st.name}" has no actions`);
|
||||
}
|
||||
const seenActionIds = new Set<string>();
|
||||
for (const act of st.actions) {
|
||||
if (seenActionIds.has(act.id)) {
|
||||
issues.push(`Duplicate action id in step "${st.name}": ${act.id}`);
|
||||
}
|
||||
seenActionIds.add(act.id);
|
||||
if (!act.source.kind) {
|
||||
issues.push(`Action "${act.id}" missing source.kind`);
|
||||
}
|
||||
if (!act.execution.transport) {
|
||||
issues.push(`Action "${act.id}" missing execution transport`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { steps: normalized, issues };
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate aggregate duration (in seconds) from normalized steps.
|
||||
* Uses simple additive heuristic: sum each step's summed action durations
|
||||
* if present; falls back to rough defaults for certain action patterns.
|
||||
*/
|
||||
export function estimateDesignDurationSeconds(steps: ExperimentStep[]): number {
|
||||
let total = 0;
|
||||
for (const step of steps) {
|
||||
let stepSum = 0;
|
||||
for (const action of step.actions) {
|
||||
const t = classifyDuration(action);
|
||||
stepSum += t;
|
||||
}
|
||||
total += stepSum;
|
||||
}
|
||||
return Math.max(1, Math.round(total));
|
||||
}
|
||||
|
||||
function classifyDuration(action: ExperimentAction): number {
|
||||
// Heuristic mapping (could be evolved to plugin-provided estimates)
|
||||
switch (true) {
|
||||
case action.type.startsWith("wizard_speak"):
|
||||
case action.type.startsWith("robot_speak"): {
|
||||
const text = action.parameters.text as string | undefined;
|
||||
if (text && text.length > 0) {
|
||||
return Math.max(2, Math.round(text.length / 10));
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
case action.type.startsWith("wait"): {
|
||||
const d = action.parameters.duration as number | undefined;
|
||||
return d && d > 0 ? d : 2;
|
||||
}
|
||||
case action.type.startsWith("robot_move"):
|
||||
return 5;
|
||||
case action.type.startsWith("wizard_gesture"):
|
||||
return 2;
|
||||
case action.type.startsWith("observe"): {
|
||||
const d = action.parameters.duration as number | undefined;
|
||||
return d && d > 0 ? d : 5;
|
||||
}
|
||||
default:
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: validates, returns steps or throws with issues attached.
|
||||
*/
|
||||
export function assertVisualDesignSteps(raw: unknown): ExperimentStep[] {
|
||||
const { steps, issues } = parseVisualDesignSteps(raw);
|
||||
if (issues.length) {
|
||||
const err = new Error(
|
||||
`Visual design validation failed:\n- ${issues.join("\n- ")}`,
|
||||
);
|
||||
(err as { issues?: string[] }).issues = issues;
|
||||
throw err;
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
Reference in New Issue
Block a user