/** * useSpecStore - Zustand store for AtomizerSpec v2.0 * * Central state management for the unified configuration system. * All spec modifications flow through this store and sync with backend. * * Features: * - Load spec from backend API * - Optimistic updates with rollback on error * - Patch operations via JSONPath * - Node CRUD operations * - Hash-based conflict detection */ import { create } from 'zustand'; import { devtools, subscribeWithSelector } from 'zustand/middleware'; import { AtomizerSpec, DesignVariable, Extractor, Objective, Constraint, CanvasPosition, SpecValidationReport, SpecModification, } from '../types/atomizer-spec'; // API base URL const API_BASE = '/api'; // ============================================================================ // Types // ============================================================================ interface SpecStoreState { // Spec data spec: AtomizerSpec | null; studyId: string | null; hash: string | null; // Loading state isLoading: boolean; error: string | null; // Validation validation: SpecValidationReport | null; // Selection state (for canvas) selectedNodeId: string | null; selectedEdgeId: string | null; // Dirty tracking isDirty: boolean; pendingChanges: SpecModification[]; } interface SpecStoreActions { // Loading loadSpec: (studyId: string) => Promise; reloadSpec: () => Promise; clearSpec: () => void; // WebSocket integration - set spec directly without API call setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void; // Local draft integration (S2) - set spec locally (no API call) and mark dirty setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => void; // Full spec operations saveSpec: (spec: AtomizerSpec) => Promise; replaceSpec: (spec: AtomizerSpec) => Promise; // Patch operations patchSpec: (path: string, value: unknown) => Promise; patchSpecOptimistic: (path: string, value: unknown) => void; // Node operations addNode: ( type: 'designVar' | 'extractor' | 'objective' | 'constraint', data: Record ) => Promise; updateNode: (nodeId: string, updates: Record) => Promise; removeNode: (nodeId: string) => Promise; updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise; // Edge operations addEdge: (source: string, target: string) => Promise; removeEdge: (source: string, target: string) => Promise; // Custom function addCustomFunction: ( name: string, code: string, outputs: string[], description?: string ) => Promise; // Validation validateSpec: () => Promise; // Selection selectNode: (nodeId: string | null) => void; selectEdge: (edgeId: string | null) => void; clearSelection: () => void; // Utility getNodeById: (nodeId: string) => DesignVariable | Extractor | Objective | Constraint | null; setError: (error: string | null) => void; } type SpecStore = SpecStoreState & SpecStoreActions; // ============================================================================ // API Functions // ============================================================================ async function fetchSpec(studyId: string): Promise<{ spec: AtomizerSpec; hash: string }> { const response = await fetch(`${API_BASE}/studies/${studyId}/spec`); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Failed to load spec' })); throw new Error(error.detail || `HTTP ${response.status}`); } const spec = await response.json(); // Get hash const hashResponse = await fetch(`${API_BASE}/studies/${studyId}/spec/hash`); const { hash } = await hashResponse.json(); return { spec, hash }; } async function patchSpecApi( studyId: string, path: string, value: unknown ): Promise<{ hash: string; modified: string }> { const response = await fetch(`${API_BASE}/studies/${studyId}/spec`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, value, modified_by: 'canvas' }), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Patch failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } return response.json(); } async function addNodeApi( studyId: string, type: string, data: Record ): Promise<{ node_id: string }> { const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type, data, modified_by: 'canvas' }), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Add node failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } return response.json(); } async function updateNodeApi( studyId: string, nodeId: string, updates: Record ): Promise { const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ updates, modified_by: 'canvas' }), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Update node failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } } async function deleteNodeApi(studyId: string, nodeId: string): Promise { const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}?modified_by=canvas`, { method: 'DELETE', }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Delete node failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } } async function addEdgeApi(studyId: string, source: string, target: string): Promise { const response = await fetch( `${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`, { method: 'POST' } ); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Add edge failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } } async function removeEdgeApi(studyId: string, source: string, target: string): Promise { const response = await fetch( `${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`, { method: 'DELETE' } ); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Remove edge failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } } async function addCustomFunctionApi( studyId: string, name: string, code: string, outputs: string[], description?: string ): Promise<{ node_id: string }> { const response = await fetch(`${API_BASE}/studies/${studyId}/spec/custom-functions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, code, outputs, description, modified_by: 'claude' }), }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Add custom function failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } return response.json(); } async function validateSpecApi(studyId: string): Promise { const response = await fetch(`${API_BASE}/studies/${studyId}/spec/validate`, { method: 'POST', }); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Validation failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } return response.json(); } // ============================================================================ // Helper Functions // ============================================================================ function applyPatchLocally(spec: AtomizerSpec, path: string, value: unknown): AtomizerSpec { // Deep clone spec const newSpec = JSON.parse(JSON.stringify(spec)) as AtomizerSpec; // Parse path and apply value const parts = path.split(/\.|\[|\]/).filter(Boolean); let current: Record = newSpec as unknown as Record; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; const index = parseInt(part, 10); if (!isNaN(index)) { current = (current as unknown as unknown[])[index] as Record; } else { current = current[part] as Record; } } const finalKey = parts[parts.length - 1]; const index = parseInt(finalKey, 10); if (!isNaN(index)) { (current as unknown as unknown[])[index] = value; } else { current[finalKey] = value; } return newSpec; } function findNodeById( spec: AtomizerSpec, nodeId: string ): DesignVariable | Extractor | Objective | Constraint | null { // Check design variables const dv = spec.design_variables.find((d) => d.id === nodeId); if (dv) return dv; // Check extractors const ext = spec.extractors.find((e) => e.id === nodeId); if (ext) return ext; // Check objectives const obj = spec.objectives.find((o) => o.id === nodeId); if (obj) return obj; // Check constraints const con = spec.constraints?.find((c) => c.id === nodeId); if (con) return con; return null; } // ============================================================================ // Store // ============================================================================ export const useSpecStore = create()( devtools( subscribeWithSelector((set, get) => ({ // Initial state spec: null, studyId: null, hash: null, isLoading: false, error: null, validation: null, selectedNodeId: null, selectedEdgeId: null, isDirty: false, pendingChanges: [], // ===================================================================== // Loading Actions // ===================================================================== loadSpec: async (studyId: string) => { set({ isLoading: true, error: null, studyId }); try { const { spec, hash } = await fetchSpec(studyId); set({ spec, hash, isLoading: false, isDirty: false, pendingChanges: [], }); } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to load spec', }); } }, reloadSpec: async () => { const { studyId } = get(); if (!studyId) return; set({ isLoading: true, error: null }); try { const { spec, hash } = await fetchSpec(studyId); set({ spec, hash, isLoading: false, isDirty: false, pendingChanges: [], }); } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to reload spec', }); } }, clearSpec: () => { set({ spec: null, studyId: null, hash: null, isLoading: false, error: null, validation: null, selectedNodeId: null, selectedEdgeId: null, isDirty: false, pendingChanges: [], }); }, // Set spec directly from WebSocket (no API call) setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => { const currentStudyId = studyId || get().studyId; console.log('[useSpecStore] Setting spec from WebSocket:', spec.meta?.study_name); set({ spec, studyId: currentStudyId, isLoading: false, isDirty: false, error: null, }); }, // Set spec locally as a draft (no API call). This is used by DraftManager (S2). // Marks the spec as dirty to indicate "not published". setSpecLocalDraft: (spec: AtomizerSpec, studyId?: string) => { const currentStudyId = studyId || get().studyId; console.log('[useSpecStore] Setting spec from local draft:', spec.meta?.study_name); set({ spec, studyId: currentStudyId, isLoading: false, isDirty: true, error: null, }); }, // ===================================================================== // Full Spec Operations // ===================================================================== saveSpec: async (spec: AtomizerSpec) => { const { studyId, hash } = get(); if (!studyId) throw new Error('No study loaded'); set({ isLoading: true, error: null }); try { const response = await fetch( `${API_BASE}/studies/${studyId}/spec?modified_by=canvas${hash ? `&expected_hash=${hash}` : ''}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(spec), } ); if (!response.ok) { const error = await response.json().catch(() => ({ detail: 'Save failed' })); throw new Error(error.detail || `HTTP ${response.status}`); } const result = await response.json(); set({ spec, hash: result.hash, isLoading: false, isDirty: false, pendingChanges: [], }); } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Save failed', }); throw error; } }, replaceSpec: async (spec: AtomizerSpec) => { await get().saveSpec(spec); }, // ===================================================================== // Patch Operations // ===================================================================== patchSpec: async (path: string, value: unknown) => { const { studyId, spec } = get(); if (!studyId || !spec) throw new Error('No study loaded'); // Optimistic update const oldSpec = spec; const newSpec = applyPatchLocally(spec, path, value); set({ spec: newSpec, isDirty: true }); try { const result = await patchSpecApi(studyId, path, value); set({ hash: result.hash, isDirty: false }); } catch (error) { // Rollback on error set({ spec: oldSpec, isDirty: false }); const message = error instanceof Error ? error.message : 'Patch failed'; set({ error: message }); throw error; } }, patchSpecOptimistic: (path: string, value: unknown) => { const { spec, studyId } = get(); if (!spec) return; // Apply locally immediately const newSpec = applyPatchLocally(spec, path, value); set({ spec: newSpec, isDirty: true, pendingChanges: [...get().pendingChanges, { operation: 'set', path, value }], }); // Sync with backend (fire and forget, but handle errors) if (studyId) { patchSpecApi(studyId, path, value) .then((result) => { set({ hash: result.hash }); // Remove from pending set({ pendingChanges: get().pendingChanges.filter( (c) => !(c.path === path && c.value === value) ), }); }) .catch((error) => { console.error('Patch sync failed:', error); set({ error: error.message }); }); } }, // ===================================================================== // Node Operations // ===================================================================== addNode: async (type, data) => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); set({ isLoading: true, error: null }); try { const result = await addNodeApi(studyId, type, data); // Reload spec to get new state await get().reloadSpec(); return result.node_id; } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Add node failed', }); throw error; } }, updateNode: async (nodeId, updates) => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); try { await updateNodeApi(studyId, nodeId, updates); await get().reloadSpec(); } catch (error) { const message = error instanceof Error ? error.message : 'Update failed'; set({ error: message }); throw error; } }, removeNode: async (nodeId) => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); set({ isLoading: true, error: null }); try { await deleteNodeApi(studyId, nodeId); await get().reloadSpec(); // Clear selection if deleted node was selected if (get().selectedNodeId === nodeId) { set({ selectedNodeId: null }); } } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Delete failed', }); throw error; } }, updateNodePosition: async (nodeId, position) => { const { studyId, spec } = get(); if (!studyId || !spec) return; // Find the node type and index let path: string | null = null; const dvIndex = spec.design_variables.findIndex((d) => d.id === nodeId); if (dvIndex >= 0) { path = `design_variables[${dvIndex}].canvas_position`; } if (!path) { const extIndex = spec.extractors.findIndex((e) => e.id === nodeId); if (extIndex >= 0) { path = `extractors[${extIndex}].canvas_position`; } } if (!path) { const objIndex = spec.objectives.findIndex((o) => o.id === nodeId); if (objIndex >= 0) { path = `objectives[${objIndex}].canvas_position`; } } if (!path && spec.constraints) { const conIndex = spec.constraints.findIndex((c) => c.id === nodeId); if (conIndex >= 0) { path = `constraints[${conIndex}].canvas_position`; } } if (path) { // Use optimistic update for smooth dragging get().patchSpecOptimistic(path, position); } }, // ===================================================================== // Edge Operations // ===================================================================== addEdge: async (source, target) => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); try { await addEdgeApi(studyId, source, target); await get().reloadSpec(); } catch (error) { const message = error instanceof Error ? error.message : 'Add edge failed'; set({ error: message }); throw error; } }, removeEdge: async (source, target) => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); try { await removeEdgeApi(studyId, source, target); await get().reloadSpec(); } catch (error) { const message = error instanceof Error ? error.message : 'Remove edge failed'; set({ error: message }); throw error; } }, // ===================================================================== // Custom Function // ===================================================================== addCustomFunction: async (name, code, outputs, description) => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); set({ isLoading: true, error: null }); try { const result = await addCustomFunctionApi(studyId, name, code, outputs, description); await get().reloadSpec(); return result.node_id; } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Add custom function failed', }); throw error; } }, // ===================================================================== // Validation // ===================================================================== validateSpec: async () => { const { studyId } = get(); if (!studyId) throw new Error('No study loaded'); try { const validation = await validateSpecApi(studyId); set({ validation }); return validation; } catch (error) { const message = error instanceof Error ? error.message : 'Validation failed'; set({ error: message }); throw error; } }, // ===================================================================== // Selection // ===================================================================== selectNode: (nodeId) => { set({ selectedNodeId: nodeId, selectedEdgeId: null }); }, selectEdge: (edgeId) => { set({ selectedEdgeId: edgeId, selectedNodeId: null }); }, clearSelection: () => { set({ selectedNodeId: null, selectedEdgeId: null }); }, // ===================================================================== // Utility // ===================================================================== getNodeById: (nodeId) => { const { spec } = get(); if (!spec) return null; return findNodeById(spec, nodeId); }, setError: (error) => { set({ error }); }, })), { name: 'spec-store' } ) ); // ============================================================================ // Selector Hooks // ============================================================================ export const useSpec = () => useSpecStore((state) => state.spec); export const useSpecLoading = () => useSpecStore((state) => state.isLoading); export const useSpecError = () => useSpecStore((state) => state.error); export const useSpecValidation = () => useSpecStore((state) => state.validation); export const useSelectedNodeId = () => useSpecStore((state) => state.selectedNodeId); export const useSelectedEdgeId = () => useSpecStore((state) => state.selectedEdgeId); export const useSpecHash = () => useSpecStore((state) => state.hash); export const useSpecIsDirty = () => useSpecStore((state) => state.isDirty); // Computed selectors export const useDesignVariables = () => useSpecStore((state) => state.spec?.design_variables ?? []); export const useExtractors = () => useSpecStore((state) => state.spec?.extractors ?? []); export const useObjectives = () => useSpecStore((state) => state.spec?.objectives ?? []); export const useConstraints = () => useSpecStore((state) => state.spec?.constraints ?? []); export const useCanvasEdges = () => useSpecStore((state) => state.spec?.canvas?.edges ?? []); export const useSelectedNode = () => useSpecStore((state) => { if (!state.spec || !state.selectedNodeId) return null; return findNodeById(state.spec, state.selectedNodeId); });