Skip to content

Commit 25082e2

Browse files
authored
feat(errors): implement error handling and reporting for AutoAtomTransformer (#751)
* feat(errors): add structured transform error reporting with frontend integration - Introduced `error_registry.py` to collect structured errors during AST transformation - Errors include type, filename, line number, message, and optionally component ID or atom name - Thread-safe via lock; supports register/get/clear - Updated `transform_source` to catch and register `SyntaxError` as transform errors - Enhanced error handling in `register_display_dependency_resolver` with error registration - `ScriptRunner` now: - Clears previous errors at script start - Sends either "components" or "errors:result" message based on presence of errors - Frontend: - Added `ErrorsReport.jsx` component for displaying transform errors - Updated `Dashboard.jsx` to render error fragments and align empty state formatting - `App.jsx` now handles new `errors:result` message type and routes errors to dashboard * feat(errors): add structured runtime error reporting and display improvements - Add `_safe_register_error` method to AST transformer to capture structured transformation errors - Wrap most lifting and analysis methods with try/except to register errors instead of crashing - Extend error object metadata to include component ID, atom name, and extra context - Improve debug logging and error traceability throughout the AST transformer - Add `has_errors()` to BasePreswaldService for script fallback error checks - Update ErrorsReport frontend to support collapsible/expandable error lists with CSS transitions - Style error list in `components.css` and refactor JSX to allow toggling long error output - Improve resilience and recovery logic in `ScriptRunner.compile_and_run()` when runtime errors occur * chore(transformer): minor cleanup and import reordering - Moved `register_error` import closer to usage in `reactive_runtime.py` - Removed unused imports from `registry.py` and reordered for clarity - Replaced bare `except` with `except Exception as e` for better debugging - Added `# noqa` directives for intentional style suppressions - No functional changes
1 parent 3501837 commit 25082e2

File tree

9 files changed

+772
-261
lines changed

9 files changed

+772
-261
lines changed

frontend/src/App.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { comm } from './utils/websocket';
1010
const App = () => {
1111
const [components, setComponents] = useState({ rows: [] });
1212
const [error, setError] = useState(null);
13+
const [transformErrors, setTransformErrors] = useState([]);
1314
const [config, setConfig] = useState(null);
1415
const [isConnected, setIsConnected] = useState(false);
1516
const [areComponentsLoading, setAreComponentsLoading] = useState(true);
@@ -52,6 +53,10 @@ const App = () => {
5253
handleError(message.content);
5354
break;
5455

56+
case 'errors:result':
57+
handleTransformErrors(message.errors, message.components);
58+
break;
59+
5560
case 'connection_status':
5661
updateConnectionStatus(message);
5762
break;
@@ -123,6 +128,15 @@ const App = () => {
123128
}
124129
};
125130

131+
const handleTransformErrors = (errorContents, components = null) => {
132+
console.error('[App] Received transform errors:', {errorContents, components});
133+
setAreComponentsLoading(false);
134+
setTransformErrors(errorContents || []);
135+
if (components) {
136+
refreshComponentsList(components);
137+
}
138+
};
139+
126140
const handleComponentUpdate = (componentId, value) => {
127141
try {
128142
comm.updateComponentState(componentId, value);
@@ -157,6 +171,7 @@ const App = () => {
157171
<Dashboard
158172
components={components}
159173
error={error}
174+
transformErrors={transformErrors}
160175
handleComponentUpdate={handleComponentUpdate}
161176
/>
162177
)}

frontend/src/components.css

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,11 @@
196196
}
197197

198198
.dashboard-error {
199-
@apply mb-4;
199+
@apply w-full text-sm mb-4;
200+
}
201+
202+
.dashboard-error-fragment {
203+
@apply w-full;
200204
}
201205

202206
.dashboard-loading-container {
@@ -219,6 +223,10 @@
219223
@apply flex items-center justify-center h-64;
220224
}
221225

226+
.dashboard-empty-with-errors {
227+
@apply flex flex-col items-center justify-center space-y-4 h-64;
228+
}
229+
222230
.dashboard-empty-text {
223231
@apply text-sm text-muted-foreground;
224232
}
@@ -828,3 +836,21 @@
828836
border-radius: 1px;
829837
transition: background-color 0.2s ease;
830838
}
839+
840+
/* Error Report */
841+
.error-report-container {
842+
@apply overflow-y-auto transition-[max-height] duration-300 ease-in-out;
843+
max-height: 10rem;
844+
}
845+
846+
.error-report-container.expanded {
847+
max-height: 80vh; /* Limit to 80% of the viewport height */
848+
}
849+
850+
.error-report-list {
851+
@apply list-disc list-inside space-y-1;
852+
}
853+
854+
.error-report-toggle {
855+
@apply text-xs text-muted-foreground hover:underline mt-2 cursor-pointer;
856+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
3+
4+
const ErrorsReport = ({ errors }) => {
5+
const [expanded, setExpanded] = useState(false);
6+
const [wasOverflowingWhenCollapsed, setWasOverflowingWhenCollapsed] = useState(false);
7+
const containerRef = useRef(null);
8+
const EXPAND_COLLAPSE_TRANSITION_MS = 300; // must match duration in .error-report-container
9+
10+
useEffect(() => {
11+
const el = containerRef.current;
12+
if (!el) return;
13+
14+
// Only measure overflow when not expanded
15+
if (!expanded) {
16+
const timeout = setTimeout(() => {
17+
const isOverflowing = el.scrollHeight > el.clientHeight;
18+
setWasOverflowingWhenCollapsed(isOverflowing);
19+
}, EXPAND_COLLAPSE_TRANSITION_MS);
20+
21+
return () => clearTimeout(timeout);
22+
}
23+
}, [errors, expanded]);
24+
25+
if (!errors || errors.length === 0) return null;
26+
27+
return (
28+
<Alert variant="destructive" className="dashboard-error space-y-2">
29+
<AlertTitle>Errors detected during source transformation</AlertTitle>
30+
<AlertDescription>
31+
<div
32+
ref={containerRef}
33+
className={`error-report-container ${expanded ? 'expanded' : ''}`}
34+
>
35+
<ul className="error-report-list">
36+
{errors.map((err, idx) => (
37+
<li key={idx}>
38+
<strong>{err.filename}:{err.lineno}</strong> - {err.message}
39+
</li>
40+
))}
41+
</ul>
42+
</div>
43+
{wasOverflowingWhenCollapsed && (
44+
<button
45+
className="error-report-toggle"
46+
onClick={() => setExpanded(prev => !prev)}
47+
>
48+
{expanded ? 'Show less' : 'Show all'}
49+
</button>
50+
)}
51+
</AlertDescription>
52+
</Alert>
53+
);
54+
};
55+
56+
export { ErrorsReport as default, ErrorsReport };

frontend/src/components/pages/Dashboard.jsx

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import React from 'react';
22

33
import { Alert, AlertDescription } from '@/components/ui/alert';
44

5+
import { ErrorsReport } from '../ErrorsReport'
56
import DynamicComponents from '../DynamicComponents';
67
import LoadingState from '../LoadingState';
78

8-
const Dashboard = ({ components, error, handleComponentUpdate }) => {
9-
console.log('[Dashboard] Rendering with:', { components, error });
9+
const Dashboard = ({ components, error, transformErrors, handleComponentUpdate }) => {
10+
console.log('[Dashboard] Rendering with:', { components, error, transformErrors });
1011

1112
const isValidComponents =
1213
components &&
@@ -15,34 +16,45 @@ const Dashboard = ({ components, error, handleComponentUpdate }) => {
1516
components.rows.every((row) => Array.isArray(row));
1617

1718
const renderContent = () => {
18-
if (error) {
19-
return (
20-
<Alert variant="destructive" className="dashboard-error">
21-
<AlertDescription>{error}</AlertDescription>
22-
</Alert>
23-
);
24-
}
25-
26-
if (!isValidComponents) {
27-
return (
28-
<div className="dashboard-loading-container">
29-
<LoadingState
30-
isConnected={true}
31-
customText={!components ? 'Loading components' : 'Invalid components data'}
32-
/>
33-
</div>
34-
);
19+
const showTransformErrorBanner = transformErrors && transformErrors.length > 0;
20+
21+
const errorFragment = showTransformErrorBanner ? (
22+
<div className="dashboard-error-fragment">
23+
<ErrorsReport errors={transformErrors} />
24+
</div>
25+
) : null;
26+
27+
const emptyWrapperClass = showTransformErrorBanner
28+
? 'dashboard-empty-with-errors' : 'dashboard-empty';
29+
30+
if (error || !isValidComponents) {
31+
const alertMessage = error ?? 'Invalid components data'
32+
return (
33+
<div className={`${emptyWrapperClass}`}>
34+
<Alert variant="destructive" className="dashboard-error">
35+
<AlertDescription>{alertMessage}</AlertDescription>
36+
</Alert>
37+
38+
{errorFragment}
39+
</div>
40+
)
3541
}
3642

3743
if (components.rows.length === 0) {
3844
return (
39-
<div className="dashboard-empty">
45+
<div className={`${emptyWrapperClass}`}>
46+
{errorFragment}
4047
<p className="dashboard-empty-text">No components to display</p>
4148
</div>
42-
);
49+
)
4350
}
4451

45-
return <DynamicComponents components={components} onComponentUpdate={handleComponentUpdate} />;
52+
return (
53+
<>
54+
{errorFragment}
55+
<DynamicComponents components={components} onComponentUpdate={handleComponentUpdate} />
56+
</>
57+
)
4658
};
4759

4860
return <div className="dashboard-container">{renderContent()}</div>;

preswald/engine/base_service.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
)
1616
from preswald.interfaces.workflow import Workflow, Atom
1717
from preswald.interfaces.component_return import ComponentReturn
18+
from preswald.interfaces.render.error_registry import get_errors, clear_errors
1819
from .managers.data import DataManager
1920
from .managers.layout import LayoutManager
2021

@@ -304,6 +305,15 @@ def get_rendered_components(self):
304305
rows = self._layout_manager.get_layout()
305306
return {"rows": rows}
306307

308+
def get_errors(self, type: str | None = None, filename: str | None = None):
309+
return get_errors(type=type, filename=filename)
310+
311+
def clear_errors(self, type: str | None = None):
312+
return clear_errors(type=type)
313+
314+
def has_errors(self, type: str | None = None, filename: str | None = None):
315+
return len(get_errors(type=type, filename=filename))
316+
307317
def get_workflow(self) -> Workflow:
308318
return self._workflow
309319

preswald/engine/runner.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ async def rerun(self, new_widget_states: dict[str, Any] | None = None):
137137
return
138138

139139
if not self._service.is_reactivity_enabled:
140-
logger.info("[ScriptRunner] Reactivity disabled — rerunning entire script with updated widget state")
140+
logger.info("[ScriptRunner] Reactivity disabled. Rerunning entire script with updated widget state")
141141
return await self.run_script()
142142

143143
try:
@@ -171,7 +171,7 @@ async def rerun(self, new_widget_states: dict[str, Any] | None = None):
171171
workflow.context.set_variable(producer_atom, new_value)
172172

173173
if not changed_atoms and not affected_atoms:
174-
logger.warning("[ScriptRunner] No atoms affected — falling back to full script rerun")
174+
logger.warning("[ScriptRunner] No atoms affected. Falling back to full script rerun")
175175
if logger.isEnabledFor(logging.DEBUG):
176176
logger.debug(f"[ScriptRunner] changed_atoms = {changed_atoms}, component_ids = {changed_component_ids}")
177177

@@ -324,6 +324,7 @@ async def run_script(self):
324324
# Ensure we run the script from a clear state
325325
workflow = self._service.get_workflow()
326326
workflow.reset()
327+
self._service.clear_errors()
327328

328329
logger.info(f"[ScriptRunner] Starting script execution {self.script_path=} {self._run_count=}")
329330

@@ -355,16 +356,17 @@ def compile_and_run(src_code, script_path, script_globals, execution_context):
355356
# Attempt reactive transformation
356357
tree, _ = transform_source(raw_code, filename=self.script_path)
357358
self._script_globals["workflow"] = workflow
358-
compile_and_run(tree, self.script_path, self._script_globals, "(reactive)")
359-
workflow.execute_relevant_atoms()
359+
if tree:
360+
compile_and_run(tree, self.script_path, self._script_globals, "(reactive)")
361+
workflow.execute_relevant_atoms()
360362
else:
361363
compile_and_run(raw_code, self.script_path, self._script_globals, "(non-reactive)")
362364
workflow.reset() # just to be safe
363365

364366
except Exception as transform_error:
365367
if logger.isEnabledFor(logging.WARNING):
366368
logger.warning(
367-
"[ScriptRunner] AST transform or reactive execution failed — falling back to full script rerun\n%s",
369+
"[ScriptRunner] AST transform or reactive execution failed. Falling back to full script rerun\n%s",
368370
traceback.format_exc()
369371
)
370372

@@ -376,8 +378,12 @@ def compile_and_run(src_code, script_path, script_globals, execution_context):
376378
"workflow": workflow,
377379
"widget_states": self.widget_states,
378380
}
379-
380-
compile_and_run(raw_code, self.script_path, self._script_globals, "(fallback, non-reactive)")
381+
try:
382+
compile_and_run(raw_code, self.script_path, self._script_globals, "(fallback, non-reactive)")
383+
except Exception as e:
384+
logger.error('[ScriptRunner] Full script rerun fallback failed', traceback.format_exc() );
385+
if not self._service.has_errors():
386+
raise e
381387

382388
os.chdir(current_working_dir)
383389

@@ -400,8 +406,15 @@ def compile_and_run(src_code, script_path, script_globals, execution_context):
400406
logger.warning(f"[ScriptRunner] No producer atom found {component_id=}")
401407
continue
402408

403-
if components:
404-
await self.send_message({"type": "components", "components": components})
409+
errors = self._service.get_errors(type="ast_transform", filename=self.script_path)
410+
message_type = "errors:result" if len(errors) else "components"
411+
412+
if (components and row_count) or len(errors):
413+
await self.send_message({
414+
"type": message_type,
415+
"errors": errors or [],
416+
"components": components or []
417+
})
405418
if logger.isEnabledFor(logging.DEBUG):
406419
logger.debug(f"[ScriptRunner] Components sent to frontend {components=}")
407420
workflow.debug_print_dag()

0 commit comments

Comments
 (0)