diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 94081205..75a1b8cd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom'; +import { TooltipProvider } from '@/components/ui/tooltip'; import Layout from './components/Layout'; import LoadingState from './components/LoadingState'; import Dashboard from './components/pages/Dashboard'; @@ -88,8 +89,9 @@ const App = () => { const currentState = comm.getComponentState(component.id); return { ...component, - value: currentState !== undefined ? currentState : component.value, - error: null, + value: component.error ? component.value : ( + currentState !== undefined ? currentState : component.value + ), }; }) ); @@ -163,20 +165,22 @@ const App = () => { }; return ( - - - {!isConnected || areComponentsLoading ? ( - - ) : ( - - )} - - + + + + {!isConnected || areComponentsLoading ? ( + + ) : ( + + )} + + + ); }; diff --git a/frontend/src/components/DynamicComponents.jsx b/frontend/src/components/DynamicComponents.jsx index c7434e08..1991eaaf 100644 --- a/frontend/src/components/DynamicComponents.jsx +++ b/frontend/src/components/DynamicComponents.jsx @@ -81,18 +81,22 @@ const MemoizedComponent = memo( ); case 'button': + // TODO: the backend defines size as a layout property. All components have + // this layout propty, but the semantic difference causes naming collisions + // preventing the ability to override a widgets real size property. + // For now, I am overriding the button's size property to 'default' return ( { if (component.onClick) { handleUpdate(componentId, true); } }} - disabled={component.disabled} isLoading={component.loading} - variant={component.variant} id={componentId} + size='default' > {component.label} @@ -148,10 +152,9 @@ const MemoizedComponent = memo( return ( ); @@ -258,8 +261,7 @@ const MemoizedComponent = memo( diff --git a/frontend/src/components/widgets/AlertWidget.jsx b/frontend/src/components/widgets/AlertWidget.jsx index 3a6b2206..c2392d90 100644 --- a/frontend/src/components/widgets/AlertWidget.jsx +++ b/frontend/src/components/widgets/AlertWidget.jsx @@ -1,9 +1,8 @@ import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react'; - import React from 'react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; - +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; const levelConfig = { @@ -29,16 +28,34 @@ const levelConfig = { }, }; -const AlertWidget = ({ id, message, level = 'info', className }) => { +const AlertWidget = ({ id, message, level = 'info', className, error }) => { const config = levelConfig[level] || levelConfig.info; const Icon = config.icon; return ( - - - {config.title} - {message} - +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + + + {config.title} + {message} + +
); }; diff --git a/frontend/src/components/widgets/BigNumberWidget.jsx b/frontend/src/components/widgets/BigNumberWidget.jsx index b597d9dd..ba657e7c 100644 --- a/frontend/src/components/widgets/BigNumberWidget.jsx +++ b/frontend/src/components/widgets/BigNumberWidget.jsx @@ -1,8 +1,13 @@ -import { ArrowDown, ArrowUp } from 'lucide-react'; - +import { ArrowDown, ArrowUp, AlertTriangle } from 'lucide-react'; import React from 'react'; -// Utility to format large numbers +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + const formatNumber = (num) => { if (Math.abs(num) >= 1e9) return (num / 1e9).toFixed(1) + 'B'; if (Math.abs(num) >= 1e6) return (num / 1e6).toFixed(1) + 'M'; @@ -10,25 +15,48 @@ const formatNumber = (num) => { return num; }; -const BigNumberCard = ({ id, label, value, delta, unit }) => { +const BigNumberCard = ({ id, label, value, delta, unit, error, className }) => { const deltaNumber = parseFloat(delta); const isPositive = deltaNumber >= 0; - const displayDelta = typeof delta === 'string' ? delta : `${isPositive ? '+' : ''}${delta}${unit ?? ''}`; return ( -
+
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} +
{label}
-
- {formatNumber(value)} - {unit ?? ''} +
+ {error + ? unavailable + : Number.isFinite(value) + ? `${formatNumber(value)}${unit ?? ''}` + : String(value)}
{delta !== undefined && (
{isPositive ? : } {displayDelta} diff --git a/frontend/src/components/widgets/ButtonWidget.jsx b/frontend/src/components/widgets/ButtonWidget.jsx index a11435af..bc77699b 100644 --- a/frontend/src/components/widgets/ButtonWidget.jsx +++ b/frontend/src/components/widgets/ButtonWidget.jsx @@ -1,7 +1,8 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; - +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; const ButtonWidget = ({ @@ -13,28 +14,54 @@ const ButtonWidget = ({ variant = 'default', size = 'default', className, + error, ...props }) => { return ( - + + +
); }; export default ButtonWidget; + diff --git a/frontend/src/components/widgets/ChatWidget.jsx b/frontend/src/components/widgets/ChatWidget.jsx index 0f9d9abd..ef949845 100644 --- a/frontend/src/components/widgets/ChatWidget.jsx +++ b/frontend/src/components/widgets/ChatWidget.jsx @@ -1,6 +1,6 @@ 'use client'; -import { Bot, Loader2, Send, Settings, User } from 'lucide-react'; +import { AlertTriangle, Bot, Loader2, Send, Settings, User } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; import { createChatCompletion } from '@/services/openai'; @@ -18,7 +19,9 @@ const ChatWidget = ({ value = { messages: [] }, onChange, className, + error, }) => { + const messages = useMemo(() => value?.messages || [], [value?.messages]); const placeholder = 'Type your message here...'; const messagesEndRef = useRef(null); @@ -28,7 +31,8 @@ const ChatWidget = ({ const [showSettings, setShowSettings] = useState(false); const [apiKey, setApiKey] = useState(''); const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const [chatError, setChatError] = useState(null); + const hasApiKey = useMemo(() => !!sessionStorage.getItem('openai_api_key'), []); // Add this state to store the processed context @@ -92,23 +96,23 @@ const ChatWidget = ({ - Source Name: ${sourceName} - Number of Records: ${rowCount} - Available Columns: ${columns.join(', ')} - + Sample Data Preview: ${JSON.stringify(sampleData, null, 2)} - + Your responsibilities: 1. Analyze the data structure and relationships 2. Provide detailed insights based on the available information 3. Answer questions specifically referencing this dataset 4. Highlight any patterns or anomalies you observe 5. Make data-driven recommendations when appropriate - + Please ensure your responses are: - Accurate and based on the provided data - Clear and well-structured - Include specific examples from the dataset when relevant - Highlight any assumptions or limitations in your analysis - + When answering questions, always reference specific data points to support your conclusions.`; } catch (error) { console.error('Error formatting source context:', error); @@ -131,7 +135,7 @@ const ChatWidget = ({ const handleSubmit = async (e) => { e.preventDefault(); if (!inputValue.trim()) return; - setError(null); + setChatError(null); const newMessage = { role: 'user', @@ -152,7 +156,7 @@ const ChatWidget = ({ }; onChange?.({ messages: [...newMessages, assistantMessage] }); } catch (err) { - setError(err.message || 'An error occurred'); + setChatError(err.message || 'An error occurred'); console.error('Failed to get AI response:', err); } finally { setIsLoading(false); @@ -174,11 +178,27 @@ const ChatWidget = ({ }; return ( +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} @@ -304,9 +324,9 @@ const ChatWidget = ({
- {error && ( + {chatError && (
- {error} + {chatError}
)} @@ -336,6 +356,7 @@ const ChatWidget = ({
+
); }; diff --git a/frontend/src/components/widgets/CheckboxWidget.jsx b/frontend/src/components/widgets/CheckboxWidget.jsx index 5efd988e..04147748 100644 --- a/frontend/src/components/widgets/CheckboxWidget.jsx +++ b/frontend/src/components/widgets/CheckboxWidget.jsx @@ -1,8 +1,10 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; const CheckboxWidget = ({ @@ -13,6 +15,7 @@ const CheckboxWidget = ({ onChange, className, disabled = false, + error, }) => { const handleCheckedChange = (checked) => { console.log('[CheckboxWidget] Change event:', { @@ -37,14 +40,34 @@ const CheckboxWidget = ({ }; return ( -
+
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + -
+
diff --git a/frontend/src/components/widgets/DataVisualizationWidget.jsx b/frontend/src/components/widgets/DataVisualizationWidget.jsx index a2c92094..80d4d3fe 100644 --- a/frontend/src/components/widgets/DataVisualizationWidget.jsx +++ b/frontend/src/components/widgets/DataVisualizationWidget.jsx @@ -1,16 +1,15 @@ import Plotly from 'plotly.js-dist'; - import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import Plot from 'react-plotly.js'; -import { Alert, AlertDescription } from '@/components/ui/alert'; +import { AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Progress } from '@/components/ui/progress'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; - import { FEATURES } from '../../config/features'; import { debounce, @@ -22,7 +21,13 @@ import { const INITIAL_POINTS_THRESHOLD = 1000; const PROGRESSIVE_LOADING_CHUNK_SIZE = 500; -const DataVisualizationWidget = ({ id, data: rawData, content, error, className }) => { +const DataVisualizationWidget = ({ + id, + data: rawData, + content, + error, + className, +}) => { const [isLoading, setIsLoading] = useState(true); const [plotError, setPlotError] = useState(null); const [processedData, setProcessedData] = useState(null); @@ -127,7 +132,6 @@ const DataVisualizationWidget = ({ id, data: rawData, content, error, className } }); }); - processDataInChunks(traces, PROGRESSIVE_LOADING_CHUNK_SIZE, (chunk, index, total) => { setProcessedData((prevData) => ({ ...data, @@ -137,11 +141,9 @@ const DataVisualizationWidget = ({ id, data: rawData, content, error, className ...prevData.data.slice(index + chunk.length), ], })); - processedPoints += chunk.reduce((acc, trace) => { return acc + (Array.isArray(trace.x) ? trace.x.length : 0); }, 0); - setLoadedDataPercentage((processedPoints / totalPoints) * 100); }); } @@ -180,18 +182,38 @@ const DataVisualizationWidget = ({ id, data: rawData, content, error, className setShowDropdown(false); }; - if (error || plotError) { - return ( - - Error: {error || plotError} - - ); - } + const hasAnyError = !!(error || plotError); return ( - + + {hasAnyError && ( + + +
+ +
+
+ + {(error || plotError)?.toString()} + +
+ )} - {!inView || isLoading ? ( + {hasAnyError ? ( +
+ + {error || plotError || 'Unable to render plot.'} + +
+ ) : !inView || isLoading ? (

Loading visualization...

diff --git a/frontend/src/components/widgets/GenericWidget.jsx b/frontend/src/components/widgets/GenericWidget.jsx index 780c3986..b9c2ce53 100644 --- a/frontend/src/components/widgets/GenericWidget.jsx +++ b/frontend/src/components/widgets/GenericWidget.jsx @@ -1,8 +1,11 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; -const GenericWidget = ({ id, value, mimetype = 'text/plain' }) => { - const cleanMime = mimetype.split(';')[0].trim().toLowerCase(); +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; +const GenericWidget = ({ id, value, mimetype = 'text/plain', error, className }) => { + const cleanMime = mimetype.split(';')[0].trim().toLowerCase(); const renderContent = () => { if (!value) { return
No content to display.
; @@ -60,8 +63,24 @@ const GenericWidget = ({ id, value, mimetype = 'text/plain' }) => { return (
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} {renderContent()}
); diff --git a/frontend/src/components/widgets/ImageWidget.jsx b/frontend/src/components/widgets/ImageWidget.jsx index 928de99d..e3860d46 100644 --- a/frontend/src/components/widgets/ImageWidget.jsx +++ b/frontend/src/components/widgets/ImageWidget.jsx @@ -1,7 +1,43 @@ import React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; -const ImageWidget = ({ id, src, alt = '', className }) => { - return {alt}; +const ImageWidget = ({ id, src, alt = '', className, error }) => { + return ( +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + {error ? ( +
+ Image unavailable +
+ ) : ( + {alt} + )} +
+ ); }; export default ImageWidget; diff --git a/frontend/src/components/widgets/JSONViewerWidget.jsx b/frontend/src/components/widgets/JSONViewerWidget.jsx index 1692647e..035c5805 100644 --- a/frontend/src/components/widgets/JSONViewerWidget.jsx +++ b/frontend/src/components/widgets/JSONViewerWidget.jsx @@ -1,14 +1,21 @@ -import { Check, Copy } from 'lucide-react'; - +import { Check, Copy, AlertTriangle } from 'lucide-react'; import React, { useState } from 'react'; import { JSONTree } from 'react-json-tree'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -const JSONViewerWidget = ({ id, data, title, expanded = true, className }) => { +const JSONViewerWidget = ({ + id, + data, + title, + expanded = true, + className, + error, +}) => { const [copied, setCopied] = useState(false); const theme = { @@ -36,14 +43,33 @@ const JSONViewerWidget = ({ id, data, title, expanded = true, className }) => { const jsonString = JSON.stringify(data, null, 2); navigator.clipboard.writeText(jsonString); setCopied(true); - setTimeout(() => setCopied(false), 1500); // Reset after 1.5s + setTimeout(() => setCopied(false), 1500); } catch (err) { console.error('Failed to copy JSON:', err); } }; return ( - + + {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )}
{title &&

{title}

} @@ -69,13 +95,17 @@ const JSONViewerWidget = ({ id, data, title, expanded = true, className }) => { )}
- expanded} - hideRoot={true} - /> + {error ? ( +
Unable to display JSON.
+ ) : ( + expanded} + hideRoot={true} + /> + )}
); diff --git a/frontend/src/components/widgets/MarkdownRendererWidget.jsx b/frontend/src/components/widgets/MarkdownRendererWidget.jsx index a06fdedd..e57dc8f1 100644 --- a/frontend/src/components/widgets/MarkdownRendererWidget.jsx +++ b/frontend/src/components/widgets/MarkdownRendererWidget.jsx @@ -1,4 +1,5 @@ -import { Link2Icon } from 'lucide-react'; +import { Link2Icon, AlertTriangle } from 'lucide-react'; + import rehypeKatex from 'rehype-katex'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; @@ -11,6 +12,11 @@ import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Card, CardContent } from '@/components/ui/card'; +import { + Tooltip, + TooltipTrigger, + TooltipContent, +} from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; @@ -38,16 +44,29 @@ const MarkdownRendererWidget = ({ id, markdown, value, error, className }) => { } }, []); - if (error) { - return ( - - Error: {error} - - ); - } - return ( - + + {/* Error badge (top-right) */} + { error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + { +const MatplotlibWidget = ({ id, label, image, className, error }) => { return ( - - {_label &&

{_label}

} - {image ? ( - Matplotlib Plot + + {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + {label &&

{label}

} + {error ? ( +
+ Plot unavailable +
+ ) : image ? ( + Matplotlib Plot ) : (

No plot available

)} diff --git a/frontend/src/components/widgets/PlaygroundWidget.jsx b/frontend/src/components/widgets/PlaygroundWidget.jsx index b2b39f43..9e45a150 100644 --- a/frontend/src/components/widgets/PlaygroundWidget.jsx +++ b/frontend/src/components/widgets/PlaygroundWidget.jsx @@ -2,6 +2,7 @@ import Editor from '@monaco-editor/react'; import { ChevronLeftIcon, ChevronRightIcon, PlayIcon } from '@radix-ui/react-icons'; import React, { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -16,6 +17,9 @@ import { TableRow, } from '@/components/ui/table'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + const ROWS_PER_PAGE = 10; const editorOptions = { @@ -58,6 +62,7 @@ export default function PlaygroundWidget({ id, error, data = null, + className, }) { const [query, setQuery] = useState(); const [currentPage, setCurrentPage] = useState(1); @@ -82,14 +87,39 @@ export default function PlaygroundWidget({ }, [value]); return ( -
- +
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + +
{label}
- @@ -109,8 +139,8 @@ export default function PlaygroundWidget({
{error ? ( -
- +
+ {error}
diff --git a/frontend/src/components/widgets/ProgressWidget.jsx b/frontend/src/components/widgets/ProgressWidget.jsx index bd8d91fe..87533ae7 100644 --- a/frontend/src/components/widgets/ProgressWidget.jsx +++ b/frontend/src/components/widgets/ProgressWidget.jsx @@ -1,22 +1,48 @@ 'use client'; import React from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Progress } from '@/components/ui/progress'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; -const ProgressWidget = ({ id, value = 0, label }) => { +const ProgressWidget = ({ id, value = 0, label, error, className }) => { // Ensure value is between 0 and 100 const normalizedValue = Math.min(Math.max(parseFloat(value) || 0, 0), 100); return ( -
+
+ {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} {label && (
{label} {Math.round(normalizedValue)}%
)} - + {error ? ( +
Unable to display progress.
+ ) : ( + + )}
); }; diff --git a/frontend/src/components/widgets/SelectboxWidget.jsx b/frontend/src/components/widgets/SelectboxWidget.jsx index 8c58c3a8..dae1f5d6 100644 --- a/frontend/src/components/widgets/SelectboxWidget.jsx +++ b/frontend/src/components/widgets/SelectboxWidget.jsx @@ -1,5 +1,8 @@ 'use client'; +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; + import { Select, SelectContent, @@ -7,27 +10,82 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; const SelectboxWidget = ({ id, + label, options = [], value, onChange, placeholder = 'Select an option', -}) => ( - -); + error, + className, +}) => { + + const _options = Array.isArray(options) ? options : []; + + return ( +
+ {label && ( + + )} + + {error && ( + + +
+ +
+
+ + {error.toString()} + +
+ )} + + + {error && ( +
+ Unable to display options. +
+ )} +
+ ); +}; export default SelectboxWidget; diff --git a/preswald/engine/base_service.py b/preswald/engine/base_service.py index 7c32988a..26bccace 100644 --- a/preswald/engine/base_service.py +++ b/preswald/engine/base_service.py @@ -1,6 +1,8 @@ import logging import os import time +import contextvars + from collections.abc import Callable from threading import Lock from typing import Any, Callable, Dict, Optional @@ -47,6 +49,8 @@ def __init__(self): self._workflow = Workflow(service=self) self._current_atom: Optional[str] = None self._reactivity_enabled = True + self._reactivity_stack = [] + self._render_tracking_suppressed: contextvars.ContextVar[bool] = contextvars.ContextVar("render_tracking_suppressed", default=False) # Initialize session tracking self.script_runners: dict[str, ScriptRunner] = {} @@ -103,6 +107,10 @@ def script_path(self, path: str): self._script_path = os.path.abspath(path) self._initialize_data_manager(path) + @property + def is_render_tracking_suppressed(self) -> bool: + return self._render_tracking_suppressed.get() + @property def is_reactivity_enabled(self): return self._reactivity_enabled @@ -212,11 +220,50 @@ def clear_components(self): def disable_reactivity(self): self._reactivity_enabled = False - logger.info("[SERVICE] Reactivity disabled for fallback execution") + logger.debug("[SERVICE] Reactivity disabled for fallback execution") def enable_reactivity(self): self._reactivity_enabled = True - logger.info("[SERVICE] Reactivity re-enabled") + logger.debug("[SERVICE] Reactivity re-enabled") + + @contextmanager + def reactivity_disabled(self): + """ + Context manager to temporarily disable reactivity and restore it on exit. + + Useful for wrapping small blocks of code that should not trigger reactive behavior, + such as fallback rendering or side-effect only computation. + + Example: + with service.reactivity_disabled(): + ... + """ + self._reactivity_stack.append(self._reactivity_enabled) + self._reactivity_enabled = False + logger.debug("[SERVICE] Reactivity temporarily disabled (context)") + + try: + yield + finally: + previous_state = self._reactivity_stack.pop() if self._reactivity_stack else True + self._reactivity_enabled = previous_state + logger.debug(f"[SERVICE] Reactivity restored to {self._reactivity_enabled}") + + @contextmanager + def render_tracking_suppressed(self): + """ + Context manager to temporarily suppress render tracking effects + from @with_render_tracking. + + This is useful when falling back to rebuild a component and just want the raw output. + """ + token = self._render_tracking_suppressed.set(True) + logger.debug("[SERVICE] Render tracking temporarily suppressed") + try: + yield + finally: + self._render_tracking_suppressed.reset(token) + logger.debug("[SERVICE] Render tracking restored") def force_recompute(self, atom_names: set[str]) -> None: """ diff --git a/preswald/engine/render_tracking.py b/preswald/engine/render_tracking.py index c8fc720f..d36d5853 100644 --- a/preswald/engine/render_tracking.py +++ b/preswald/engine/render_tracking.py @@ -61,13 +61,16 @@ def wrapper(*args, **kwargs): logger.debug(f"[with_render_tracking] Using provided component_id {component_id}:{atom_name}") else: identifier = kwargs.get("identifier") - callsite_hint = kwargs.get("callsite_hint") - component_id = generate_stable_id(component_type, callsite_hint=callsite_hint, identifier=identifier) + hint = kwargs.get('hint') + component_id = generate_stable_id(component_type, callsite_hint=hint, identifier=identifier) atom_name = generate_stable_atom_name_from_component_id(component_id) kwargs["component_id"] = component_id logger.debug(f"[with_render_tracking] Generated component_id {component_id}:{atom_name}") service = PreswaldService.get_instance() + if service.is_render_tracking_suppressed: + return func(*args, **kwargs) + if not service.is_reactivity_enabled: result = func(*args, **kwargs) diff --git a/preswald/engine/runner.py b/preswald/engine/runner.py index 7289a1e4..c95f68ea 100644 --- a/preswald/engine/runner.py +++ b/preswald/engine/runner.py @@ -92,9 +92,8 @@ async def start(self, script_path: str): self._run_count = 0 if reactivity_explicitly_disabled(): - self._service.disable_reactivity() - else: logger.info("[ScriptRunner] Reactivity is disabled by configuration") + self._service.disable_reactivity() try: await self.run_script() diff --git a/preswald/engine/transformers/reactive_runtime.py b/preswald/engine/transformers/reactive_runtime.py index d872a21d..96959263 100644 --- a/preswald/engine/transformers/reactive_runtime.py +++ b/preswald/engine/transformers/reactive_runtime.py @@ -2287,7 +2287,7 @@ def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.FunctionDef: # noqa: N # Attach atom decorator callsite_metadata = self._build_callsite_metadata(node, self.filename) - atom_name = generate_stable_id("_auto_atom", callsite_hint=callsite_metadata["callsite_hint"]) + atom_name = generate_stable_id("_auto_atom", callsite_hint=callsite_metadata["hint"]) decorator = self._create_workflow_atom_decorator(atom_name, callsite_deps=[], callsite_metadata=callsite_metadata) node.decorator_list.insert(0, decorator) @@ -2483,24 +2483,49 @@ def _build_atom_function( - Explicit return override via `return_target` Any other AST node types passed as `call_expr` will result in a safe transformation error. + + Args: + atom_name (str): + The name to assign to the generated atom function. + + component_id (str): + The stable component ID used to uniquely identify the output component + associated with this atom. + + callsite_deps (list[str]): + The list of atom names this atom depends on. These are used to + generate the function parameters as `param0`, `param1`, etc. + + call_expr (ast.AST | list[ast.stmt]): + The lifted expression or block of statements that form the body of the atom. + Typically this is an `ast.Call`, `ast.Expr`, `ast.Assign`, or list of such nodes. + Used to build the body and return value of the generated function. + + return_target (str | list[str] | tuple[str, ...] | ast.expr | None, optional): + Optional override for the return value of the function. + If provided, this value is returned instead of whatever is in `call_expr`. + + callsite_node (ast.AST | None, optional): + The original AST node from the user script that corresponds to the component call. + Used to extract human readable metadata such as filename, line number, and source. + Unlike `call_expr`, this reflects the original user authored code before rewriting. """ # Create function parameters: (param0, param1, ...) args_ast = self._make_param_args(callsite_deps) - callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename) - decorator = self._create_workflow_atom_decorator( - atom_name, - callsite_deps, - callsite_metadata=callsite_metadata - ) - # Normalize call_expr into a body list if isinstance(call_expr, list): body = call_expr elif isinstance(call_expr, ast.Assign) or isinstance(call_expr, ast.Expr): body = [call_expr] elif isinstance(call_expr, ast.Call): + callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename, call_expr=call_expr) + decorator = self._create_workflow_atom_decorator( + atom_name, + callsite_deps, + callsite_metadata=callsite_metadata + ) return_stmt = ast.Return(value=call_expr) return ast.FunctionDef( name=atom_name, @@ -2517,6 +2542,13 @@ def _build_atom_function( ) return None + callsite_metadata = self._build_callsite_metadata(callsite_node, self.filename) + decorator = self._create_workflow_atom_decorator( + atom_name, + callsite_deps, + callsite_metadata=callsite_metadata + ) + # Append appropriate return statement if isinstance(return_target, str): body.append(ast.Return(value=ast.Name(id=return_target, ctx=ast.Load()))) @@ -2710,12 +2742,27 @@ def generate_component_and_atom_name(self, func_name: str, stmt: ast.stmt | None logger.debug(f"[AST] Generated names {func_name=} {callsite_hint=} {component_id=} {atom_name=}") return component_id, atom_name - def _build_callsite_metadata(self, node: ast.AST, filename: str) -> dict: + def _build_callsite_metadata(self, node: ast.AST, filename: str, *, call_expr: ast.Call = None) -> dict: """ - Constructs callsite metadata (filename, lineno, source) for a given AST node. + Construct metadata describing the source location of an AST callsite. + + This function extracts the filename, line number, and source code line associated + with the given AST node. If `call_expr` is provided, it also includes the full + unparsed source string of the lifted expression, which can be used for fallback + reconstruction or debugging. + + Args: + node: The AST node for which to generate location metadata. + filename: The absolute or relative path to the source file. + call_expr: Optional `ast.Call` expression to include full source reconstruction. Returns: - A dict with keys: callsite_filename, callsite_lineno, callsite_source + A dictionary containing: + - "file": The source filename. + - "lineno": The line number in the source file (1-based). + - "src": The stripped source line at the given location, if available. + - "hint": A string in the form "filename:lineno" for human readable logging. + - "lifted_component_src" (optional): The full unparsed source string of the call expression. """ lineno = getattr(node, "lineno", None) source = "" @@ -2724,13 +2771,18 @@ def _build_callsite_metadata(self, node: ast.AST, filename: str) -> dict: if 0 < lineno <= len(self._source_lines): source = self._source_lines[lineno - 1].strip() - return { - "callsite_filename": filename, - "callsite_lineno": lineno, - "callsite_source": source, - "callsite_hint": f"{filename}:{lineno}" if filename and lineno else None, + callsite_metadata = { + "file": filename, + "lineno": lineno, + "src": source, # human readable debugging hint + "hint": f"{filename}:{lineno}" if filename and lineno else None, } + if call_expr is not None and isinstance(call_expr, ast.Call): + callsite_metadata['lifted_component_src'] = ast.unparse(call_expr).strip() + + return callsite_metadata + def annotate_parents(tree: ast.AST) -> ast.AST: """ diff --git a/preswald/engine/transformers/transformer_utils.py b/preswald/engine/transformers/transformer_utils.py new file mode 100644 index 00000000..b4859ef5 --- /dev/null +++ b/preswald/engine/transformers/transformer_utils.py @@ -0,0 +1,194 @@ +import ast +import logging +import collections.abc +from typing import Any + +from preswald.interfaces.component_return import ComponentReturn +from preswald.utils import ( + generate_stable_id, +) + + +logger = logging.getLogger(__name__) + + +def get_call_func_name(call_node: ast.Call) -> str | None: + """ + Extract the function name from an AST call node. + + This utility handles both direct function calls like `slider(...)` + and attribute based calls like `preswald.slider(...)`. + + Args: + call_node: An `ast.Call` node representing a function invocation. + + Returns: + The name of the function being called, or None if it cannot be + determined. + """ + if isinstance(call_node.func, ast.Name): + return call_node.func.id + elif isinstance(call_node.func, ast.Attribute): + return call_node.func.attr + return None + +def extract_call_args(call_node: ast.Call) -> tuple[list[Any], dict[str, Any]]: + """ + Extract positional and keyword arguments from an AST call node. + + This function attempts to statically extract values from `ast.Call` nodes + for common literal types like constants and f-strings. Non literal values + are returned as AST dumps for diagnostic or fallback purposes. + + Args: + call_node: An `ast.Call` node representing a function invocation. + + Returns: + A tuple containing: + - A list of extracted positional arguments. + - A dictionary of extracted keyword arguments. + """ + args = [] + kwargs = {} + for arg in call_node.args: + if isinstance(arg, ast.Constant): + args.append(arg.value) + elif isinstance(arg, ast.JoinedStr): + # fallback to unparsed string representation + args.append(ast.unparse(arg).strip()) + else: + args.append(ast.dump(arg)) # or handle other node types more gracefully + + for kw in call_node.keywords: + if isinstance(kw.value, ast.Constant): + kwargs[kw.arg] = kw.value.value + elif isinstance(kw.value, ast.JoinedStr): + kwargs[kw.arg] = ast.unparse(kw.value).strip() + else: + kwargs[kw.arg] = ast.dump(kw.value) + + return args, kwargs + + +def build_component_from_args(name: str, args: list, kwargs: dict) -> ComponentReturn: + """ + Reconstruct a Preswald component by invoking its registered constructor. + + This function is used to rebuild a component from a known function name, + positional arguments, and keyword arguments. It is typically called during + runtime error recovery or fallback rendering when the original component + failed to compute. + + Only components defined in `preswald.interfaces.components` and marked with + the `_preswald_component_type` attribute are considered valid. The function + must return a `ComponentReturn`, which encapsulates both the visible value + and the component metadata. + + If reconstruction fails at any point, the function is not a valid component, + or the return type is incorrect, a fallback error component is returned instead. + + Args: + name: Name of the component function + args: Positional arguments for the component constructor + kwargs: Keyword arguments for the component constructor + + Returns: + ComponentReturn: A fully constructed component if successful, + or a fallback error component otherwise. + """ + from preswald.interfaces import components + + try: + fn = getattr(components, name, None) + if fn is None or not callable(fn): + raise ValueError(f"No component function found with name '{name}'") + + _preswald_component_type = getattr(fn, "_preswald_component_type", None) + if _preswald_component_type is None: + raise ValueError(f"Name matched function that is not a preswald component type: '{name=}'") + + result = fn(*args, **kwargs) + + if not isinstance(result, ComponentReturn): + raise ValueError(f"The Result of named function is not a ComponentReturn type: '{name=}'") + + return result + + except Exception as e: + component_id = kwargs.get('component_id', None) + if not component_id: + component_id = generate_stable_id(prefix=name, callsite_hint=callsite_hint) + component = { + "id": component_id, + "type": name, + "error": f"[Rebuild Error] {e!s}" + } + component.update(_filter_kwargs_for_fallback(kwargs)) + return ComponentReturn(None, component) + + +def rebuild_component_from_source( + lifted_component_src: str, + callsite_hint: str, + *, + force_render: bool=False +) -> ComponentReturn: + """ + Reconstruct a Preswald component from its lifted AST call expression. + + This function is primarily used during runtime error recovery to reconstruct + a component from source code that was previously extracted and stored from the + original callsite. It parses the expression, extracts its arguments, and invokes + the appropriate Preswald component function. + + The reconstructed component is wrapped in a `ComponentReturn` and can be safely + sent to the frontend, even when the original component computation failed. + + Render tracking is temporarily suppressed during reconstruction to avoid duplicate + layout registration or state interference. + + Args: + lifted_component_src: Source code string of the original component call expression. + callsite_hint: A 'filename:lineno' style hint used to generate a stable component ID. + + Keyword Args: + force_render: If True, the returned component will include `'shouldRender': True`. + + Returns: + ComponentReturn: The reconstructed component wrapped in a `ComponentReturn` object. + + Raises: + ValueError: If the source is not a valid single call expression. + Exception: If component reconstruction fails for any reason. + """ + expr_module = ast.parse(lifted_component_src, mode="exec") + if ( + len(expr_module.body) == 1 and + isinstance(expr_module.body[0], ast.Expr) and + isinstance(expr_module.body[0].value, ast.Call) + ): + expr_ast = expr_module.body[0].value + name = get_call_func_name(expr_ast) + args, kwargs = extract_call_args(expr_ast) + + kwargs["component_id"] = generate_stable_id(prefix=name, callsite_hint=callsite_hint) + + from preswald.engine.service import PreswaldService + + service = PreswaldService.get_instance() + with service.render_tracking_suppressed(): + component_return = build_component_from_args(name, args, kwargs) + if force_render: + component_return.component['shouldRender'] = True + + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"[transform_utils.rebuild_component_from_source] Reconstructed component: {component_return=}") + return component_return + + raise ValueError("Invalid lifted_component_src: unable to parse call expression.") + +def _safe_for_fallback(value): + return isinstance(value, (str, int, float, bool, type(None))) + +def _filter_kwargs_for_fallback(kwargs): + return {k: v for k, v in kwargs.items() if isinstance(k, str) and _safe_for_fallback(v)} diff --git a/preswald/interfaces/component_return.py b/preswald/interfaces/component_return.py index 36bbafa5..a7a99f3e 100644 --- a/preswald/interfaces/component_return.py +++ b/preswald/interfaces/component_return.py @@ -8,6 +8,10 @@ def __init__(self, value, component): self.value = value self._preswald_component = component + @property + def component(self) -> dict: + return self._preswald_component + def __str__(self): return str(self.value) def __float__(self): return float(self.value) def __bool__(self): return bool(self.value) diff --git a/preswald/interfaces/components.py b/preswald/interfaces/components.py index f0338faf..a000bf6b 100644 --- a/preswald/interfaces/components.py +++ b/preswald/interfaces/components.py @@ -378,7 +378,8 @@ def json_viewer( parsed_data = data serializable_data = convert_to_serializable(parsed_data) except Exception as e: - serializable_data = {"error": f"Invalid JSON: {e!s}"} + serializable_data = {"error": "Invalid JSON"} + raise component = { "type": "json_viewer", @@ -401,6 +402,7 @@ def matplotlib(fig: plt.Figure | None = None, label: str = "plot", component_id: ax.plot([0, 1, 2], [0, 1, 4]) # Save the figure as a base64-encoded PNG + buf = io.BytesIO() fig.savefig(buf, format="png") buf.seek(0) @@ -413,9 +415,7 @@ def matplotlib(fig: plt.Figure | None = None, label: str = "plot", component_id: "image": img_b64, # Store the image data } - return ComponentReturn( - component_id, component - ) # Returning ID for potential tracking + return ComponentReturn(component_id, component) @with_render_tracking("playground") diff --git a/preswald/interfaces/workflow.py b/preswald/interfaces/workflow.py index 5eed74e0..142ea684 100644 --- a/preswald/interfaces/workflow.py +++ b/preswald/interfaces/workflow.py @@ -18,6 +18,7 @@ from preswald.interfaces.component_return import ComponentReturn from preswald.interfaces.tracked_value import TrackedValue from preswald.interfaces.render.error_registry import register_error +from preswald.engine.transformers.transformer_utils import rebuild_component_from_source logger = logging.getLogger(__name__) @@ -163,44 +164,14 @@ def wrapped_func(*args, **kwargs): f"Atom {self.name} failed with error: {e!s}", exc_info=True ) - callsite_filename = self.callsite_metadata.get('callsite_filename') - callsite_lineno = self.callsite_metadata.get('callsite_lineno') - callsite_source = self.callsite_metadata.get('callsite_source') - # if callsite info was not provided, attempt to - # capture this info where the atom is defined - if not callsite_filename or not callsite_lineno: - callsite_filename, callsite_lineno = get_user_code_callsite(e) - self.callsite_metadata['callsite_filename'] = callsite_filename - self.callsite_metadata['callsite_lineno'] = callsite_lineno - - - # if callsite source was not provided, attempt to - # capture this info where the atom was defined - # - # TODO(preswald): Centralize source line buffering per filename in the service layer. - # This would allow both the AST transformer and Atom class to fetch source snippets - # without reopening the file. Until then, skip this fallback to avoid redundant I/O. - # - # if not callsite_source and callsite_filename: - # try: - # with open(callsite_filename, 'r') as f: - # lines = f.readlines() - # lineno = callsite_lineno or 0 - # callsite_source = lines[lineno - 1].strip() if 0 < lineno <= len(lines) else "" - # self.callsite_metadata['callsite_source'] = callsite_source - - # except Exception: - # pass - register_error( type="runtime", - filename=callsite_filename or "", - lineno=callsite_lineno or 0, - source=callsite_source or "", + filename=self.callsite_metadata.get("file", ""), + lineno=self.callsite_metadata.get("lineno", 0), + source=self.callsite_metadata.get("src", ""), message=str(e), atom_name=self.name, ) - raise finally: end_time = time.time() @@ -313,6 +284,7 @@ def decorator(func): force_recompute=force_recompute, callsite_metadata=callsite_metadata or {}, ) + self.atoms[atom_name] = atom logger.info(f"[DAG] Atom registration complete {atom_name=} -> {atom_deps=}") @@ -344,12 +316,19 @@ def execute( logger.info(f"[DAG] Atoms to recompute {atoms_to_recompute=}") + failed_atoms: set[str] = set() + for atom_name in execution_order: if self._is_rerun and recompute_atoms and atom_name not in atoms_to_recompute: logger.info(f"[DAG] Skipping atom (not affected) {atom_name=}") continue + # Skip if any dependency failed atom = self.atoms[atom_name] + if any(dep in failed_atoms for dep in atom.dependencies): + logger.warning(f"[DAG] Skipping atom due to failed dependency {atom_name=}") + continue + if atom_name in atoms_to_recompute: atom.force_recompute = True @@ -358,8 +337,8 @@ def execute( atom.force_recompute = False if result.status == AtomStatus.FAILED: - logger.error(f"[DAG] Execution halted due to failure {atom_name=}") - break + logger.error(f"[DAG] Atom failed during execution {atom_name=}") + failed_atoms.add(atom_name) return self.context.results finally: @@ -396,6 +375,15 @@ def register_component_producer(self, component_id: str, atom_name: str): else: logger.warning(f"[DAG] Skipping producer registration for unknown atom {atom_name=}") + def _register_component_producer(self, atom: Atom, candidate: Any) -> None: + if self._service: + if isinstance(candidate, ComponentReturn): + self.register_component_producer(candidate.component['id'], atom.name) + elif isinstance(candidate, tuple): + for item in candidate: + if isinstance(item, ComponentReturn): + self.register_component_producer(item.component['id'], atom.name) + def _get_affected_atoms(self, changed_atoms: set[str]) -> set[str]: """ Computes the full set of atoms that must be recomputed when a given set of atoms change. @@ -568,15 +556,7 @@ def _execute_atom_inner(self, atom: Atom, dependency_values: dict[str, Any], inp result = atom.func(*args) - if self._service: - if isinstance(result, ComponentReturn): - logger.info('[DEBUG] - register_component_producer from workflow _execute_inner') - self.register_component_producer(result.component_id, atom.name) - elif isinstance(result, tuple): - for item in result: - if isinstance(item, ComponentReturn): - logger.info('[DEBUG] - register_component_producer from workflow _execute_inner. result is tuple.') - self.register_component_producer(item.component_id, atom.name) + self._register_component_producer(atom, result) end_time = time.time() atom_result = AtomResult( @@ -597,9 +577,27 @@ def _execute_atom_inner(self, atom: Atom, dependency_values: dict[str, Any], inp logger.warning(f"[DAG] Atom execution failed, retrying {atom.name=} {attempts=} delay={delay:.2f}") time.sleep(delay) else: + component = None + lifted_component_src = atom.callsite_metadata.get("lifted_component_src") + if lifted_component_src: + try: + callsite_hint = atom.callsite_metadata.get("hint") + component_return = rebuild_component_from_source(lifted_component_src, callsite_hint, force_render=True) + component_return.component['error'] = str(e) + self._register_component_producer(atom, component_return) + component = component_return.component + except Exception as rebuild_error: + logger.error(f"[DAG] Failed to rebuild component for {atom.name}: {rebuild_error=}") + + component = { + "type": "unkown", + "error": f"[Runtime Error] {str(e)}" + } + return AtomResult( status=AtomStatus.FAILED, error=e, + value=component, attempts=attempts, start_time=start_time, end_time=current_time,