mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
Pre-conf work 2025
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import { db } from "~/server/db";
|
||||
import {
|
||||
experiments,
|
||||
participants,
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
mediaCaptures,
|
||||
users,
|
||||
} from "~/server/db/schema";
|
||||
import { TrialExecutionEngine } from "~/server/services/trial-execution";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
async function checkTrialAccess(
|
||||
@@ -77,6 +78,9 @@ async function checkTrialAccess(
|
||||
return trial[0];
|
||||
}
|
||||
|
||||
// Global execution engine instance
|
||||
const executionEngine = new TrialExecutionEngine(db);
|
||||
|
||||
export const trialsRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
@@ -412,25 +416,31 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Start trial
|
||||
const [trial] = await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
// Use execution engine to start trial
|
||||
const result = await executionEngine.startTrial(input.id, userId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: result.error ?? "Failed to start trial",
|
||||
});
|
||||
}
|
||||
|
||||
// Return updated trial data
|
||||
const trial = await db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, input.id))
|
||||
.returning();
|
||||
.limit(1);
|
||||
|
||||
// Log trial start event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_started",
|
||||
timestamp: new Date(),
|
||||
data: { userId },
|
||||
});
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found after start",
|
||||
});
|
||||
}
|
||||
|
||||
return trial;
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
complete: protectedProcedure
|
||||
@@ -488,24 +498,31 @@ export const trialsRouter = createTRPCRouter({
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const [trial] = await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "aborted",
|
||||
completedAt: new Date(),
|
||||
})
|
||||
// Use execution engine to abort trial
|
||||
const result = await executionEngine.abortTrial(input.id, input.reason);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: result.error ?? "Failed to complete trial",
|
||||
});
|
||||
}
|
||||
|
||||
// Return updated trial data
|
||||
const trial = await db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, input.id))
|
||||
.returning();
|
||||
.limit(1);
|
||||
|
||||
// Log trial abort event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_aborted",
|
||||
timestamp: new Date(),
|
||||
data: { userId, reason: input.reason },
|
||||
});
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found after abort",
|
||||
});
|
||||
}
|
||||
|
||||
return trial;
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
logEvent: protectedProcedure
|
||||
@@ -789,4 +806,84 @@ export const trialsRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
}),
|
||||
// Trial Execution Procedures
|
||||
executeCurrentStep: protectedProcedure
|
||||
.input(z.object({ trialId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
|
||||
|
||||
const result = await executionEngine.executeCurrentStep(input.trialId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: result.error ?? "Failed to reset trial",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
advanceToNextStep: protectedProcedure
|
||||
.input(z.object({ trialId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
|
||||
|
||||
const result = await executionEngine.advanceToNextStep(input.trialId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: result.error ?? "Failed to advance to next step",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}),
|
||||
|
||||
getExecutionStatus: protectedProcedure
|
||||
.input(z.object({ trialId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
|
||||
|
||||
const status = executionEngine.getTrialStatus(input.trialId);
|
||||
const currentStep = executionEngine.getCurrentStep(input.trialId);
|
||||
|
||||
return {
|
||||
status,
|
||||
currentStep,
|
||||
};
|
||||
}),
|
||||
|
||||
getCurrentStep: protectedProcedure
|
||||
.input(z.object({ trialId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
|
||||
|
||||
return executionEngine.getCurrentStep(input.trialId);
|
||||
}),
|
||||
|
||||
completeWizardAction: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
actionId: z.string(),
|
||||
data: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await checkTrialAccess(ctx.db, ctx.session.user.id, input.trialId);
|
||||
|
||||
// Log wizard action completion
|
||||
await ctx.db.insert(trialEvents).values({
|
||||
trialId: input.trialId,
|
||||
eventType: "wizard_action_completed",
|
||||
actionId: input.actionId,
|
||||
data: input.data,
|
||||
timestamp: new Date(),
|
||||
createdBy: ctx.session.user.id,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -477,11 +477,16 @@ export const usersRouter = createTRPCRouter({
|
||||
role.role === "wizard" ||
|
||||
role.role === "researcher" ||
|
||||
role.role === "administrator",
|
||||
)?.role || "wizard",
|
||||
)?.role ?? "wizard",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(wizardUsers.values());
|
||||
return Array.from(wizardUsers.values()) as Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: "wizard" | "researcher" | "administrator";
|
||||
}>;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type DefaultSession } from "next-auth";
|
||||
import { type DefaultSession, type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -38,9 +38,10 @@ declare module "next-auth" {
|
||||
*
|
||||
* @see https://next-auth.js.org/configuration/options
|
||||
*/
|
||||
export const authConfig = {
|
||||
|
||||
export const authConfig: NextAuthConfig = {
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
strategy: "jwt" as const,
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
pages: {
|
||||
@@ -87,17 +88,17 @@ export const authConfig = {
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt: ({ token, user }: { token: any; user: any }) => {
|
||||
jwt: async ({ token, user }) => {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session: async ({ session, token }: { session: any; token: any }) => {
|
||||
if (token.id) {
|
||||
session: async ({ session, token }) => {
|
||||
if (token.id && typeof token.id === 'string') {
|
||||
// Fetch user roles from database
|
||||
const userWithRoles = await db.query.users.findFirst({
|
||||
where: eq(users.id, token.id as string),
|
||||
where: eq(users.id, token.id),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
@@ -117,7 +118,7 @@ export const authConfig = {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: token.id as string,
|
||||
id: token.id,
|
||||
roles:
|
||||
userWithRoles?.systemRoles?.map((sr) => ({
|
||||
role: sr.role,
|
||||
@@ -130,4 +131,4 @@ export const authConfig = {
|
||||
return session;
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
};
|
||||
|
||||
763
src/server/services/trial-execution.ts
Normal file
763
src/server/services/trial-execution.ts
Normal file
@@ -0,0 +1,763 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
/* eslint-disable @typescript-eslint/no-base-to-string */
|
||||
|
||||
import { type db } from "~/server/db";
|
||||
import { trials, steps, actions, trialEvents } from "~/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
|
||||
export type TrialStatus =
|
||||
| "scheduled"
|
||||
| "in_progress"
|
||||
| "completed"
|
||||
| "aborted"
|
||||
| "failed";
|
||||
|
||||
export interface ExecutionContext {
|
||||
trialId: string;
|
||||
experimentId: string;
|
||||
participantId: string;
|
||||
wizardId?: string;
|
||||
currentStepIndex: number;
|
||||
startTime: Date;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StepDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
condition?: string;
|
||||
actions: ActionDefinition[];
|
||||
}
|
||||
|
||||
export interface ActionDefinition {
|
||||
id: string;
|
||||
stepId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
orderIndex: number;
|
||||
parameters: Record<string, unknown>;
|
||||
timeout?: number;
|
||||
required: boolean;
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: Record<string, unknown>;
|
||||
duration?: number;
|
||||
nextStepIndex?: number;
|
||||
}
|
||||
|
||||
export interface ActionExecutionResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: Record<string, unknown>;
|
||||
duration: number;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export class TrialExecutionEngine {
|
||||
private db: typeof db;
|
||||
private activeTrials = new Map<string, ExecutionContext>();
|
||||
private stepDefinitions = new Map<string, StepDefinition[]>();
|
||||
|
||||
constructor(database: typeof db) {
|
||||
this.db = database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a trial for execution
|
||||
*/
|
||||
async initializeTrial(trialId: string): Promise<ExecutionContext> {
|
||||
// Get trial details
|
||||
const [trial] = await this.db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, trialId));
|
||||
|
||||
if (!trial) {
|
||||
throw new Error(`Trial ${trialId} not found`);
|
||||
}
|
||||
|
||||
if (trial.status === "completed" || trial.status === "aborted") {
|
||||
throw new Error(`Trial ${trialId} is already ${trial.status}`);
|
||||
}
|
||||
|
||||
// Load experiment steps and actions
|
||||
const experimentSteps = await this.loadExperimentProtocol(
|
||||
trial.experimentId,
|
||||
);
|
||||
this.stepDefinitions.set(trialId, experimentSteps);
|
||||
|
||||
// Create execution context
|
||||
const context: ExecutionContext = {
|
||||
trialId,
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial.participantId || "",
|
||||
wizardId: trial.wizardId || undefined,
|
||||
currentStepIndex: 0,
|
||||
startTime: new Date(),
|
||||
variables: {},
|
||||
};
|
||||
|
||||
this.activeTrials.set(trialId, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load experiment protocol (steps and actions) from database
|
||||
*/
|
||||
private async loadExperimentProtocol(
|
||||
experimentId: string,
|
||||
): Promise<StepDefinition[]> {
|
||||
// Get all steps for the experiment
|
||||
const stepRecords = await this.db
|
||||
.select()
|
||||
.from(steps)
|
||||
.where(eq(steps.experimentId, experimentId))
|
||||
.orderBy(asc(steps.orderIndex));
|
||||
|
||||
const stepDefinitions: StepDefinition[] = [];
|
||||
|
||||
for (const step of stepRecords) {
|
||||
// Get all actions for this step
|
||||
const actionRecords = await this.db
|
||||
.select()
|
||||
.from(actions)
|
||||
.where(eq(actions.stepId, step.id))
|
||||
.orderBy(asc(actions.orderIndex));
|
||||
|
||||
const actionDefinitions: ActionDefinition[] = actionRecords.map(
|
||||
(action: any) => ({
|
||||
id: action.id,
|
||||
stepId: action.stepId,
|
||||
name: action.name,
|
||||
description: action.description || undefined,
|
||||
type: action.type,
|
||||
orderIndex: action.orderIndex,
|
||||
parameters: (action.parameters as Record<string, unknown>) || {},
|
||||
timeout: action.timeout || undefined,
|
||||
required: action.required || true,
|
||||
condition: action.condition || undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
stepDefinitions.push({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
description: step.description || undefined,
|
||||
type: step.type,
|
||||
orderIndex: step.orderIndex,
|
||||
condition: (step.conditions as string) || undefined,
|
||||
actions: actionDefinitions,
|
||||
});
|
||||
}
|
||||
|
||||
return stepDefinitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start trial execution
|
||||
*/
|
||||
async startTrial(
|
||||
trialId: string,
|
||||
wizardId?: string,
|
||||
): Promise<ExecutionResult> {
|
||||
try {
|
||||
let context = this.activeTrials.get(trialId);
|
||||
if (!context) {
|
||||
context = await this.initializeTrial(trialId);
|
||||
}
|
||||
|
||||
if (wizardId) {
|
||||
context.wizardId = wizardId;
|
||||
}
|
||||
|
||||
// Update trial status in database
|
||||
await this.db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "in_progress",
|
||||
startedAt: context.startTime,
|
||||
wizardId: context.wizardId,
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
|
||||
// Log trial start event
|
||||
await this.logTrialEvent(trialId, "trial_started", {
|
||||
wizardId: context.wizardId,
|
||||
startTime: context.startTime.toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
trialId,
|
||||
status: "in_progress",
|
||||
currentStepIndex: context.currentStepIndex,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error starting trial",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the current step
|
||||
*/
|
||||
async executeCurrentStep(trialId: string): Promise<ExecutionResult> {
|
||||
const context = this.activeTrials.get(trialId);
|
||||
if (!context) {
|
||||
return { success: false, error: "Trial not initialized" };
|
||||
}
|
||||
|
||||
const steps = this.stepDefinitions.get(trialId);
|
||||
if (!steps || context.currentStepIndex >= steps.length) {
|
||||
return await this.completeTrial(trialId);
|
||||
}
|
||||
|
||||
const step = steps[context.currentStepIndex];
|
||||
if (!step) {
|
||||
return { success: false, error: "Invalid step index" };
|
||||
}
|
||||
|
||||
try {
|
||||
// Check step condition
|
||||
if (step.condition && !this.evaluateCondition(step.condition, context)) {
|
||||
// Skip this step
|
||||
return await this.advanceToNextStep(trialId);
|
||||
}
|
||||
|
||||
// Log step start
|
||||
await this.logTrialEvent(trialId, "step_started", {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: context.currentStepIndex,
|
||||
});
|
||||
|
||||
// Execute all actions in the step
|
||||
const actionResults = await this.executeStepActions(trialId, step);
|
||||
|
||||
const failedActions = actionResults.filter(
|
||||
(result) => !result.success && result.required,
|
||||
);
|
||||
if (failedActions.length > 0) {
|
||||
throw new Error(
|
||||
`Step failed: ${failedActions.map((f) => f.error).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Log step completion
|
||||
await this.logTrialEvent(trialId, "step_completed", {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: context.currentStepIndex,
|
||||
actionResults: actionResults.map((r) => ({
|
||||
success: r.success,
|
||||
duration: r.duration,
|
||||
})),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
actionResults,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await this.logTrialEvent(trialId, "step_failed", {
|
||||
stepId: step.id,
|
||||
stepName: step.name,
|
||||
stepIndex: context.currentStepIndex,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown error executing step",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute all actions within a step
|
||||
*/
|
||||
private async executeStepActions(
|
||||
trialId: string,
|
||||
step: StepDefinition,
|
||||
): Promise<Array<ActionExecutionResult & { required: boolean }>> {
|
||||
const context = this.activeTrials.get(trialId)!;
|
||||
const results: Array<ActionExecutionResult & { required: boolean }> = [];
|
||||
|
||||
for (const action of step.actions) {
|
||||
// Check action condition
|
||||
if (
|
||||
action.condition &&
|
||||
!this.evaluateCondition(action.condition, context)
|
||||
) {
|
||||
results.push({
|
||||
success: true,
|
||||
completed: false,
|
||||
duration: 0,
|
||||
data: { skipped: true, reason: "condition not met" },
|
||||
required: action.required,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.executeAction(trialId, action);
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
await this.logTrialEvent(trialId, "action_executed", {
|
||||
actionId: action.id,
|
||||
actionName: action.name,
|
||||
actionType: action.type,
|
||||
stepId: step.id,
|
||||
duration,
|
||||
success: result.success,
|
||||
data: result.data,
|
||||
});
|
||||
|
||||
results.push({
|
||||
...result,
|
||||
duration,
|
||||
required: action.required,
|
||||
});
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
await this.logTrialEvent(trialId, "action_failed", {
|
||||
actionId: action.id,
|
||||
actionName: action.name,
|
||||
actionType: action.type,
|
||||
stepId: step.id,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
|
||||
results.push({
|
||||
success: false,
|
||||
completed: false,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
required: action.required,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single action
|
||||
*/
|
||||
private async executeAction(
|
||||
trialId: string,
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
// This is where we'd dispatch to different action executors based on action.type
|
||||
// For now, we'll implement basic action types and mock robot actions
|
||||
|
||||
switch (action.type) {
|
||||
case "wait":
|
||||
return await this.executeWaitAction(action);
|
||||
|
||||
case "wizard_say":
|
||||
return await this.executeWizardAction(trialId, action);
|
||||
|
||||
case "wizard_gesture":
|
||||
return await this.executeWizardAction(trialId, action);
|
||||
|
||||
case "observe_behavior":
|
||||
return await this.executeObservationAction(trialId, action);
|
||||
|
||||
default:
|
||||
// Check if it's a robot action (contains plugin prefix)
|
||||
if (action.type.includes(".")) {
|
||||
return await this.executeRobotAction(trialId, action);
|
||||
}
|
||||
|
||||
// Unknown action type - log and continue
|
||||
return {
|
||||
success: true,
|
||||
completed: true,
|
||||
duration: 0,
|
||||
data: {
|
||||
message: `Action type '${action.type}' not implemented yet`,
|
||||
parameters: action.parameters,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute wait action
|
||||
*/
|
||||
private async executeWaitAction(
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
const duration = (action.parameters.duration as number) || 1000;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
success: true,
|
||||
completed: true,
|
||||
duration,
|
||||
data: { waitDuration: duration },
|
||||
});
|
||||
}, duration);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute wizard action (requires human input)
|
||||
*/
|
||||
private async executeWizardAction(
|
||||
trialId: string,
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
// For wizard actions, we return immediately but mark as requiring wizard input
|
||||
// The wizard interface will handle the actual execution
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completed: false, // Requires wizard confirmation
|
||||
duration: 0,
|
||||
data: {
|
||||
requiresWizardInput: true,
|
||||
actionType: action.type,
|
||||
parameters: action.parameters,
|
||||
instructions: this.getWizardInstructions(action),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute observation action
|
||||
*/
|
||||
private async executeObservationAction(
|
||||
trialId: string,
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
// Observation actions typically require wizard input to record observations
|
||||
|
||||
return {
|
||||
success: true,
|
||||
completed: false,
|
||||
duration: 0,
|
||||
data: {
|
||||
requiresWizardInput: true,
|
||||
actionType: action.type,
|
||||
parameters: action.parameters,
|
||||
observationType: action.parameters.type || "behavior",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute robot action through plugin system
|
||||
*/
|
||||
private async executeRobotAction(
|
||||
trialId: string,
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
try {
|
||||
// Parse plugin.action format
|
||||
const [pluginId, actionType] = action.type.split(".");
|
||||
|
||||
// TODO: Integrate with actual robot plugin system
|
||||
// For now, simulate robot action execution
|
||||
|
||||
const simulationDelay = Math.random() * 2000 + 500; // 500ms - 2.5s
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
// Simulate success/failure
|
||||
const success = Math.random() > 0.1; // 90% success rate
|
||||
|
||||
resolve({
|
||||
success,
|
||||
completed: true,
|
||||
duration: simulationDelay,
|
||||
data: {
|
||||
pluginId,
|
||||
actionType,
|
||||
parameters: action.parameters,
|
||||
robotResponse: success
|
||||
? "Action completed successfully"
|
||||
: "Robot action failed",
|
||||
},
|
||||
error: success ? undefined : "Simulated robot failure",
|
||||
});
|
||||
}, simulationDelay);
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
completed: false,
|
||||
duration: 0,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Robot action execution failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next step
|
||||
*/
|
||||
async advanceToNextStep(trialId: string): Promise<ExecutionResult> {
|
||||
const context = this.activeTrials.get(trialId);
|
||||
if (!context) {
|
||||
return { success: false, error: "Trial not initialized" };
|
||||
}
|
||||
|
||||
const steps = this.stepDefinitions.get(trialId);
|
||||
if (!steps) {
|
||||
return { success: false, error: "No steps loaded for trial" };
|
||||
}
|
||||
|
||||
const previousStepIndex = context.currentStepIndex;
|
||||
context.currentStepIndex++;
|
||||
|
||||
await this.logTrialEvent(trialId, "step_transition", {
|
||||
fromStepIndex: previousStepIndex,
|
||||
toStepIndex: context.currentStepIndex,
|
||||
});
|
||||
|
||||
// Check if we've completed all steps
|
||||
if (context.currentStepIndex >= steps.length) {
|
||||
return await this.completeTrial(trialId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nextStepIndex: context.currentStepIndex,
|
||||
data: { previousStepIndex, currentStepIndex: context.currentStepIndex },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the trial
|
||||
*/
|
||||
async completeTrial(trialId: string): Promise<ExecutionResult> {
|
||||
const context = this.activeTrials.get(trialId);
|
||||
if (!context) {
|
||||
return { success: false, error: "Trial not initialized" };
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - context.startTime.getTime();
|
||||
|
||||
try {
|
||||
// Update trial in database
|
||||
await this.db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "completed",
|
||||
completedAt: endTime,
|
||||
duration: Math.round(duration / 1000), // Convert to seconds
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
|
||||
// Log completion
|
||||
await this.logTrialEvent(trialId, "trial_completed", {
|
||||
endTime: endTime.toISOString(),
|
||||
duration,
|
||||
totalSteps: this.stepDefinitions.get(trialId)?.length || 0,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeTrials.delete(trialId);
|
||||
this.stepDefinitions.delete(trialId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
trialId,
|
||||
status: "completed",
|
||||
duration,
|
||||
endTime: endTime.toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to complete trial",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort the trial
|
||||
*/
|
||||
async abortTrial(trialId: string, reason?: string): Promise<ExecutionResult> {
|
||||
const context = this.activeTrials.get(trialId);
|
||||
if (!context) {
|
||||
return { success: false, error: "Trial not initialized" };
|
||||
}
|
||||
|
||||
const endTime = new Date();
|
||||
const duration = endTime.getTime() - context.startTime.getTime();
|
||||
|
||||
try {
|
||||
await this.db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "aborted",
|
||||
completedAt: endTime,
|
||||
duration: Math.round(duration / 1000),
|
||||
})
|
||||
.where(eq(trials.id, trialId));
|
||||
|
||||
await this.logTrialEvent(trialId, "trial_aborted", {
|
||||
reason: reason || "Manual abort",
|
||||
endTime: endTime.toISOString(),
|
||||
duration,
|
||||
stepIndex: context.currentStepIndex,
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.activeTrials.delete(trialId);
|
||||
this.stepDefinitions.delete(trialId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
trialId,
|
||||
status: "aborted",
|
||||
reason,
|
||||
duration,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to abort trial",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current execution status
|
||||
*/
|
||||
getTrialStatus(trialId: string): ExecutionContext | null {
|
||||
return this.activeTrials.get(trialId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current step definition
|
||||
*/
|
||||
getCurrentStep(trialId: string): StepDefinition | null {
|
||||
const context = this.activeTrials.get(trialId);
|
||||
const steps = this.stepDefinitions.get(trialId);
|
||||
|
||||
if (!context || !steps || context.currentStepIndex >= steps.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return steps[context.currentStepIndex] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log trial event to database
|
||||
*/
|
||||
private async logTrialEvent(
|
||||
trialId: string,
|
||||
eventType: string,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.db.insert(trialEvents).values({
|
||||
trialId,
|
||||
eventType,
|
||||
data: data as any, // TODO: Fix typing
|
||||
timestamp: new Date(),
|
||||
createdBy: this.activeTrials.get(trialId)?.wizardId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to log trial event:", error);
|
||||
// Don't throw - logging failures shouldn't stop execution
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate condition (simple implementation)
|
||||
*/
|
||||
private evaluateCondition(
|
||||
condition: string,
|
||||
context: ExecutionContext,
|
||||
): boolean {
|
||||
try {
|
||||
// Simple condition evaluation - in production, use a safer evaluator
|
||||
// For now, support basic variable checks
|
||||
if (condition.includes("variables.")) {
|
||||
// Replace variables in condition with actual values
|
||||
let evaluableCondition = condition;
|
||||
Object.entries(context.variables).forEach(([key, value]) => {
|
||||
evaluableCondition = evaluableCondition.replace(
|
||||
`variables.${key}`,
|
||||
JSON.stringify(value),
|
||||
);
|
||||
});
|
||||
|
||||
// Basic evaluation - in production, use a proper expression evaluator
|
||||
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
||||
return new Function("return " + evaluableCondition)();
|
||||
}
|
||||
|
||||
return true; // Default to true if condition can't be evaluated
|
||||
} catch (error) {
|
||||
console.warn("Failed to evaluate condition:", condition, error);
|
||||
return true; // Fail open
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wizard instructions for an action
|
||||
*/
|
||||
private getWizardInstructions(action: ActionDefinition): string {
|
||||
switch (action.type) {
|
||||
case "wizard_say":
|
||||
return `Say: "${action.parameters.text || "Please speak to the participant"}"`;
|
||||
|
||||
case "wizard_gesture":
|
||||
return `Perform gesture: ${action.parameters.gesture || "as specified in the protocol"}`;
|
||||
|
||||
case "observe_behavior":
|
||||
return `Observe and record: ${action.parameters.behavior || "participant behavior"}`;
|
||||
|
||||
default:
|
||||
return `Execute: ${action.name}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user