Files
hristudio/src/components/experiments/designer/state/store.ts
T

657 lines
20 KiB
TypeScript
Executable File

"use client";
/**
* Experiment Designer Zustand Store
*
* Centralized state management for the redesigned experiment designer.
* Responsibilities:
* - Steps & actions structural state
* - Selection state (step / action)
* - Dirty tracking
* - Hashing & drift (incremental design hash computation)
* - Validation issue storage
* - Plugin action signature drift detection
* - Save / conflict / versioning control flags
*
* This store intentionally avoids direct network calls; consumers orchestrate
* server mutations & pass results back into the store (pure state container).
*/
import { create } from "zustand";
import type {
ExperimentStep,
ExperimentAction,
} from "~/lib/experiment-designer/types";
import {
computeIncrementalDesignHash,
type IncrementalHashMaps,
type IncrementalHashResult,
computeActionSignature,
} from "./hashing";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export interface ValidationIssue {
entityId: string;
severity: "error" | "warning" | "info";
message: string;
code?: string;
}
export type VersionStrategy = "auto" | "forceIncrement" | "none";
export interface ConflictState {
serverHash: string;
localHash: string;
at: Date;
}
export interface DesignerState {
// Core structural
steps: ExperimentStep[];
// Selection
selectedStepId?: string;
selectedActionId?: string;
// Dirty tracking (entity IDs)
dirtyEntities: Set<string>;
// Hashing
lastPersistedHash?: string;
currentDesignHash?: string;
lastValidatedHash?: string;
incremental?: IncrementalHashMaps;
// Validation & drift
validationIssues: Record<string, ValidationIssue[]>;
actionSignatureIndex: Map<string, string>; // actionType or instance -> signature hash
actionSignatureDrift: Set<string>; // action instance IDs with drift
// Saving & conflicts
pendingSave: boolean;
conflict?: ConflictState;
versionStrategy: VersionStrategy;
autoSaveEnabled: boolean;
// Flags
busyHashing: boolean;
busyValidating: boolean;
/* ---------------------- DnD Projection (Transient) ----------------------- */
insertionProjection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null;
setInsertionProjection: (
projection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null,
) => void;
/* ------------------------------ Mutators --------------------------------- */
// Selection
selectStep: (id?: string) => void;
selectAction: (stepId: string, actionId?: string) => void;
// Steps
setSteps: (steps: ExperimentStep[]) => void;
upsertStep: (step: ExperimentStep) => void;
removeStep: (stepId: string) => void;
reorderStep: (from: number, to: number) => void;
// Actions
upsertAction: (
stepId: string,
action: ExperimentAction,
parentId?: string | null,
index?: number,
) => void;
removeAction: (stepId: string, actionId: string) => void;
reorderAction: (stepId: string, from: number, to: number) => void;
moveAction: (
stepId: string,
actionId: string,
newParentId: string | null,
newIndex: number,
) => void;
// Dirty
markDirty: (id: string) => void;
clearDirty: (id: string) => void;
clearAllDirty: () => void;
// Hashing
recomputeHash: (options?: {
forceFull?: boolean;
}) => Promise<IncrementalHashResult | null>;
setPersistedHash: (hash: string) => void;
setValidatedHash: (hash: string) => void;
// Validation
setValidationIssues: (entityId: string, issues: ValidationIssue[]) => void;
clearValidationIssues: (entityId: string) => void;
clearAllValidationIssues: () => void;
// Drift detection (action definition signature)
setActionSignature: (actionId: string, signature: string) => void;
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) => void;
clearActionSignatureDrift: (actionId: string) => void;
// Save workflow
setPendingSave: (pending: boolean) => void;
recordConflict: (serverHash: string, localHash: string) => void;
clearConflict: () => void;
setVersionStrategy: (strategy: VersionStrategy) => void;
setAutoSaveEnabled: (enabled: boolean) => void;
// Bulk apply from server (authoritative sync after save/fetch)
applyServerSync: (payload: {
steps: ExperimentStep[];
persistedHash?: string;
validatedHash?: string;
}) => void;
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
function cloneActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({
...a,
children: a.children ? cloneActions(a.children) : undefined,
}));
}
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps.map((s) => ({
...s,
actions: cloneActions(s.actions),
}));
}
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
return steps.map((s, idx) => ({ ...s, order: idx }));
}
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
// ExperimentAction type does not define orderIndex; preserve array order only
return actions.map((a) => ({ ...a }));
}
function findActionById(
list: ExperimentAction[],
id: string,
): ExperimentAction | null {
for (const action of list) {
if (action.id === id) return action;
if (action.children) {
const found = findActionById(action.children, id);
if (found) return found;
}
}
return null;
}
function updateActionInTree(
list: ExperimentAction[],
action: ExperimentAction,
): ExperimentAction[] {
return list.map((a) => {
if (a.id === action.id) return { ...action };
if (a.children) {
return { ...a, children: updateActionInTree(a.children, action) };
}
return a;
});
}
// Immutable removal
function removeActionFromTree(
list: ExperimentAction[],
id: string,
): ExperimentAction[] {
return list
.filter((a) => a.id !== id)
.map((a) => ({
...a,
children: a.children ? removeActionFromTree(a.children, id) : undefined,
}));
}
// Immutable insertion
function insertActionIntoTree(
list: ExperimentAction[],
action: ExperimentAction,
parentId: string | null,
index: number,
): ExperimentAction[] {
if (!parentId) {
// Insert at root level
const copy = [...list];
copy.splice(index, 0, action);
return copy;
}
return list.map((a) => {
if (a.id === parentId) {
const children = a.children ? [...a.children] : [];
children.splice(index, 0, action);
return { ...a, children };
}
if (a.children) {
return {
...a,
children: insertActionIntoTree(a.children, action, parentId, index),
};
}
return a;
});
}
/* -------------------------------------------------------------------------- */
/* Store Implementation */
/* -------------------------------------------------------------------------- */
export const createDesignerStore = (props: {
initialSteps?: ExperimentStep[];
}) =>
create<DesignerState>((set, get) => ({
steps: props.initialSteps
? reindexSteps(cloneSteps(props.initialSteps))
: [],
dirtyEntities: new Set<string>(),
validationIssues: {},
actionSignatureIndex: new Map(),
actionSignatureDrift: new Set(),
pendingSave: false,
versionStrategy: "auto_minor" as VersionStrategy,
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */
selectStep: (id) =>
set({
selectedStepId: id,
selectedActionId: id ? get().selectedActionId : undefined,
}),
selectAction: (stepId, actionId) =>
set({
selectedStepId: stepId,
selectedActionId: actionId,
}),
/* -------------------------------- Steps ---------------------------------- */
setSteps: (steps) =>
set(() => ({
steps: reindexSteps(cloneSteps(steps)),
dirtyEntities: new Set<string>(), // assume authoritative load
})),
upsertStep: (step) =>
set((state) => {
const idx = state.steps.findIndex((s) => s.id === step.id);
let steps: ExperimentStep[];
if (idx >= 0) {
steps = [...state.steps];
steps[idx] = { ...step };
} else {
steps = [...state.steps, { ...step, order: state.steps.length }];
}
return {
steps: reindexSteps(steps),
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
};
}),
removeStep: (stepId) =>
set((state) => {
const steps = state.steps.filter((s) => s.id !== stepId);
const dirty = new Set(state.dirtyEntities);
dirty.add(stepId);
return {
steps: reindexSteps(steps),
dirtyEntities: dirty,
selectedStepId:
state.selectedStepId === stepId ? undefined : state.selectedStepId,
selectedActionId: undefined,
};
}),
reorderStep: (from: number, to: number) =>
set((state: DesignerState) => {
if (
from < 0 ||
to < 0 ||
from >= state.steps.length ||
to >= state.steps.length ||
from === to
) {
return state;
}
const stepsDraft = [...state.steps];
const [moved] = stepsDraft.splice(from, 1);
if (!moved) return state;
stepsDraft.splice(to, 0, moved);
const reindexed = reindexSteps(stepsDraft);
return {
steps: reindexed,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
...reindexed.map((s) => s.id),
]),
};
}),
/* ------------------------------- Actions --------------------------------- */
upsertAction: (
stepId: string,
action: ExperimentAction,
parentId: string | null = null,
index?: number,
) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
if (s.id !== stepId) return s;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return {
...s,
actions: updateActionInTree(s.actions, action),
};
}
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
return {
...s,
actions: insertActionIntoTree(
s.actions,
action,
parentId,
insertIndex,
),
};
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
action.id,
stepId,
]),
};
}),
removeAction: (stepId: string, actionId: string) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: removeActionFromTree(s.actions, actionId),
}
: s,
);
const dirty = new Set<string>(state.dirtyEntities);
dirty.add(actionId);
dirty.add(stepId);
return {
steps: stepsDraft,
dirtyEntities: dirty,
selectedActionId:
state.selectedActionId === actionId
? undefined
: state.selectedActionId,
};
}),
moveAction: (
stepId: string,
actionId: string,
newParentId: string | null,
newIndex: number,
) =>
set((state: DesignerState) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
const actionToMove = findActionById(s.actions, actionId);
if (!actionToMove) return s;
const pruned = removeActionFromTree(s.actions, actionId);
const inserted = insertActionIntoTree(
pruned,
actionToMove,
newParentId,
newIndex,
);
return { ...s, actions: inserted };
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
...state.dirtyEntities,
stepId,
actionId,
]),
};
}),
reorderAction: (stepId: string, from: number, to: number) =>
get().moveAction(
stepId,
get().steps.find((s) => s.id === stepId)?.actions[from]?.id!,
null,
to,
), // Legacy compat support (only works for root level reorder)
setInsertionProjection: (projection) =>
set({ insertionProjection: projection }),
/* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) =>
set((state: DesignerState) => ({
dirtyEntities: state.dirtyEntities.has(id)
? state.dirtyEntities
: new Set<string>([...state.dirtyEntities, id]),
})),
clearDirty: (id: string) =>
set((state: DesignerState) => {
if (!state.dirtyEntities.has(id)) return state;
const next = new Set(state.dirtyEntities);
next.delete(id);
return { dirtyEntities: next };
}),
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
/* ------------------------------- Hashing --------------------------------- */
recomputeHash: async (options?: { forceFull?: boolean }) => {
const { steps, incremental } = get();
if (steps.length === 0) {
set({ currentDesignHash: undefined });
return null;
}
set({ busyHashing: true });
try {
const result = await computeIncrementalDesignHash(
steps,
options?.forceFull ? undefined : incremental,
);
set({
currentDesignHash: result.designHash,
incremental: {
actionHashes: result.actionHashes,
stepHashes: result.stepHashes,
},
});
return result;
} finally {
set({ busyHashing: false });
}
},
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
/* ----------------------------- Validation -------------------------------- */
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
set((state: DesignerState) => ({
validationIssues: {
...state.validationIssues,
[entityId]: issues,
},
})),
clearValidationIssues: (entityId: string) =>
set((state: DesignerState) => {
if (!state.validationIssues[entityId]) return state;
const next = { ...state.validationIssues };
delete next[entityId];
return { validationIssues: next };
}),
clearAllValidationIssues: () => set({ validationIssues: {} }),
/* ------------------------- Action Signature Drift ------------------------ */
setActionSignature: (actionId: string, signature: string) =>
set((state: DesignerState) => {
const index = new Map(state.actionSignatureIndex);
index.set(actionId, signature);
return { actionSignatureIndex: index };
}),
detectActionSignatureDrift: (
action: ExperimentAction,
latestSignature: string,
) =>
set((state: DesignerState) => {
const current = state.actionSignatureIndex.get(action.id);
if (!current) {
const idx = new Map(state.actionSignatureIndex);
idx.set(action.id, latestSignature);
return { actionSignatureIndex: idx };
}
if (current === latestSignature) return {};
const drift = new Set(state.actionSignatureDrift);
drift.add(action.id);
return { actionSignatureDrift: drift };
}),
clearActionSignatureDrift: (actionId: string) =>
set((state: DesignerState) => {
if (!state.actionSignatureDrift.has(actionId)) return state;
const next = new Set(state.actionSignatureDrift);
next.delete(actionId);
return { actionSignatureDrift: next };
}),
/* ------------------------------- Save Flow -------------------------------- */
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
recordConflict: (serverHash: string, localHash: string) =>
set({
conflict: { serverHash, localHash, at: new Date() },
pendingSave: false,
}),
clearConflict: () => set({ conflict: undefined }),
setVersionStrategy: (strategy: VersionStrategy) =>
set({ versionStrategy: strategy }),
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
/* ------------------------------ Server Sync ------------------------------ */
applyServerSync: (payload: {
steps: ExperimentStep[];
persistedHash?: string;
validatedHash?: string;
}) =>
set((state: DesignerState) => {
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
const dirty = new Set<string>();
return {
steps: syncedSteps,
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
dirtyEntities: dirty,
conflict: undefined,
};
}),
}));
export const useDesignerStore = createDesignerStore({});
/* -------------------------------------------------------------------------- */
/* Convenience Selectors */
/* -------------------------------------------------------------------------- */
export const useDesignerSteps = (): ExperimentStep[] =>
useDesignerStore((s) => s.steps);
export const useDesignerSelection = (): {
selectedStepId: string | undefined;
selectedActionId: string | undefined;
} =>
useDesignerStore((s) => ({
selectedStepId: s.selectedStepId,
selectedActionId: s.selectedActionId,
}));
export const useDesignerHashes = (): {
currentDesignHash: string | undefined;
lastPersistedHash: string | undefined;
lastValidatedHash: string | undefined;
} =>
useDesignerStore((s) => ({
currentDesignHash: s.currentDesignHash,
lastPersistedHash: s.lastPersistedHash,
lastValidatedHash: s.lastValidatedHash,
}));
export const useDesignerDrift = (): {
hasDrift: boolean;
actionSignatureDrift: Set<string>;
} =>
useDesignerStore((s) => ({
hasDrift:
!!s.lastValidatedHash &&
!!s.currentDesignHash &&
s.currentDesignHash !== s.lastValidatedHash,
actionSignatureDrift: s.actionSignatureDrift,
}));
/* -------------------------------------------------------------------------- */
/* Signature Helper (on-demand) */
/* -------------------------------------------------------------------------- */
/**
* Compute a signature for an action definition or instance (schema + provenance).
* Store modules can call this to register baseline signatures.
*/
export async function computeBaselineActionSignature(
action: ExperimentAction,
): Promise<string> {
return computeActionSignature({
type: action.type,
category: action.category,
parameterSchemaRaw: action.parameterSchemaRaw,
execution: action.execution,
baseActionId: action.source.baseActionId,
pluginVersion: action.source.pluginVersion,
pluginId: action.source.pluginId,
});
}