Files
Atomizer/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts

760 lines
23 KiB
TypeScript

/**
* 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<void>;
reloadSpec: () => Promise<void>;
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<void>;
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
// Patch operations
patchSpec: (path: string, value: unknown) => Promise<void>;
patchSpecOptimistic: (path: string, value: unknown) => void;
// Node operations
addNode: (
type: 'designVar' | 'extractor' | 'objective' | 'constraint',
data: Record<string, unknown>
) => Promise<string>;
updateNode: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
removeNode: (nodeId: string) => Promise<void>;
updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise<void>;
// Edge operations
addEdge: (source: string, target: string) => Promise<void>;
removeEdge: (source: string, target: string) => Promise<void>;
// Custom function
addCustomFunction: (
name: string,
code: string,
outputs: string[],
description?: string
) => Promise<string>;
// Validation
validateSpec: () => Promise<SpecValidationReport>;
// 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<string, unknown>
): 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<string, unknown>
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<SpecValidationReport> {
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<string, unknown> = newSpec as unknown as Record<string, unknown>;
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<string, unknown>;
} else {
current = current[part] as Record<string, unknown>;
}
}
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<SpecStore>()(
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);
});