blob: 8541d525a9ce1c96031260a8985479057195197b [file]
// Copyright (C) 2025 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// QUERY EXECUTION MODEL
// ====================
//
// The Data Explorer uses a two-phase execution model with centralized control
// in QueryExecutionService.processNode().
//
// CENTRALIZED ARCHITECTURE
// ------------------------
// All autoExecute logic is handled by QueryExecutionService.processNode():
//
// | autoExecute | manual | Behavior |
// |-------------|--------|-----------------------------------------------|
// | true | false | Analyze + execute if query changed |
// | true | true | Analyze + execute (forced) |
// | false | false | Skip everything (save engine queries) |
// | false | true | Analyze + execute (user clicked "Run Query") |
//
// PHASE 1: ANALYSIS (Validation)
// ------------------------------
// When node state changes, NodePanel calls service.processNode({ manual: false }).
// The service decides whether to analyze based on autoExecute flag.
// If analysis runs:
// 1. Sends structured queries to the engine
// 2. Engine VALIDATES the query and returns generated SQL (doesn't execute)
// 3. Returns a Query object: {sql, textproto, modules, preambles, columns}
//
// PHASE 2: EXECUTION (Materialization)
// ------------------------------------
// The service decides whether to execute based on autoExecute and manual flags.
// If execution runs:
// 1. Materializes the query into a PERFETTO table
// 2. SQL = modules + preambles + query.sql
// 3. Table name: _exp_materialized_{sanitizedNodeId}
// 4. Creates SQLDataSource pointing to the materialized table
// 5. Fetches metadata (COUNT and column info) from materialized table
// 6. SQLDataSource handles server-side pagination, filtering, sorting
// 7. Updates node.state.issues with any errors/warnings
// 8. For SqlSourceNode, updates available columns
//
// Auto-execute is set to FALSE for:
// - SqlSourceNode: User writes SQL manually, should control execution
// - IntervalIntersectNode: Multi-node operation, potentially expensive
// - UnionNode: Multi-node operation, potentially expensive
// - FilterDuringNode: Multi-node operation, potentially expensive
//
// STATE MANAGEMENT
// ---------------
// - this.query: Current validated query (from analysis phase)
// * Single source of truth for query state
// * Updated by both automatic analysis (NodePanel) and manual execution (Builder)
// * Passed to NodePanel as a prop for rendering SQL/Proto tabs
// - this.response: Query results from execution
// - this.dataSource: Wrapped data source for DataGrid display
//
// QUERY STATE FLOW
// ----------------
// Automatic execution (autoExecute=true):
// 1. NodePanel.updateQuery() → processNode({ manual: false })
// 2. onAnalysisComplete → sets NodePanel.currentQuery
// 3. onAnalysisComplete → calls onQueryAnalyzed callback → sets Builder.query
// 4. Builder passes query as prop to NodePanel
// 5. NodePanel.renderContent() uses attrs.query ?? this.currentQuery
//
// Manual execution (autoExecute=false):
// 1. User clicks "Run Query" button → Builder calls processNode({ manual: true })
// 2. onAnalysisComplete → sets Builder.query
// 3. onAnalysisComplete → calls onNodeQueryAnalyzed callback → sets Builder.query
// 4. Builder passes query as prop to NodePanel
// 5. NodePanel.renderContent() uses attrs.query (this.currentQuery may be undefined)
//
// This ensures SQL/Proto tabs display correctly for both automatic and manual execution.
//
// RACE CONDITION PREVENTION
// -------------------------
// The onNodeQueryAnalyzed callback captures the selected node at creation time to prevent
// stale query leakage when rapidly switching between nodes:
//
// Without validation:
// 1. User selects Node A → async analysis starts → captures callback
// 2. User quickly switches to Node B → Node A destroyed, new callback created
// 3. Node A's analysis completes in background → old callback fires
// 4. Old callback sets this.query = queryForNodeA
// 5. Node B incorrectly displays Node A's query
//
// With validation (callbackNode === this.previousSelectedNode):
// - Old callbacks from destroyed nodes are ignored
// - Only queries from the currently selected node update the display
import m from 'mithril';
import {classNames} from '../../../base/classnames';
import {Button} from '../../../widgets/button';
import {Icons} from '../../../base/semantic_icons';
import {Icon} from '../../../widgets/icon';
import type {Trace} from '../../../public/trace';
import type {SqlModules} from '../../dev.perfetto.SqlModules/sql_modules';
import type {QueryNode, Query} from '../query_node';
import {getPrimarySelectedNode} from '../selection_utils';
import {isAQuery, queryToRun} from './query_builder_utils';
import {NodePanel} from './node_panel';
import {Graph, type GraphCallbacks} from './graph/graph';
import {ResultsPanel} from './results_panel';
import {
DrawerPanel,
DrawerPanelVisibility,
} from '../../../widgets/drawer_panel';
import {SQLDataSource} from '../../../components/widgets/datagrid/sql_data_source';
import {createSimpleSchema} from '../../../components/widgets/datagrid/sql_schema';
import type {QueryResponse} from '../../../components/query_table/queries';
import QueryPagePlugin from '../../dev.perfetto.QueryPage';
import {SqlSourceNode} from './nodes/sources/sql_source';
import {findErrors, findWarnings} from './query_builder_utils';
import {NodeIssues} from './node_issues';
import {ResultsPanelEmptyState, RoundActionButton} from './widgets';
import type {UIFilter} from './operations/filter';
import type {QueryExecutionService} from './query_execution_service';
import type {Column} from '../../../components/widgets/datagrid/model';
import {ResizeHandle} from '../../../widgets/resize_handle';
import {getAllDownstreamNodes, getAllNodes} from './graph_utils';
import {Popup, PopupPosition} from '../../../widgets/popup';
import type {DataSource} from '../../../components/widgets/datagrid/data_source';
import {NavigationSidePanel} from './navigation_sidepanel';
// Side panel width - must match --pf-qb-side-panel-width in builder.scss
const SIDE_PANEL_WIDTH = 60;
export interface BuilderAttrs {
readonly trace: Trace;
readonly sqlModules: SqlModules;
readonly queryExecutionService: QueryExecutionService;
// Graph data & callbacks (forwarded to Graph component).
readonly graphCallbacks: GraphCallbacks;
readonly rootNodes: QueryNode[];
readonly selectedNodes: ReadonlySet<string>;
readonly nodeLayouts: Map<string, {x: number; y: number}>;
readonly labels: ReadonlyArray<{
id: string;
x: number;
y: number;
width: number;
text: string;
}>;
readonly loadGeneration?: number;
// Builder-specific callbacks.
readonly isExplorerCollapsed?: boolean;
readonly sidebarWidth?: number;
readonly onRootNodeCreated: (node: QueryNode) => void;
readonly onExplorerCollapsedChange?: (collapsed: boolean) => void;
readonly onSidebarWidthChange?: (width: number) => void;
readonly onFilterAdd: (
node: QueryNode,
filter: UIFilter | UIFilter[],
filterOperator?: 'AND' | 'OR',
) => void;
readonly onColumnAdd?: (node: QueryNode, column: Column) => void;
// Starting templates (when page is empty)
readonly onLoadEmptyTemplate?: () => void;
readonly onLoadExampleByPath?: (jsonPath: string) => void;
readonly onLoadDataExplorerTemplate?: () => void;
readonly onLoadRecentGraph?: (json: string) => void;
// Node state change callback
readonly onNodeStateChange?: () => void;
// Undo / Redo
readonly onUndo?: () => void;
readonly onRedo?: () => void;
readonly canUndo?: boolean;
readonly canRedo?: boolean;
// Called when the execute function is ready (or cleared).
// Allows parent to trigger query execution via keyboard shortcuts (Ctrl+Enter).
// Receives undefined when no node is selected.
readonly onExecuteReady?: (
executeFn: (() => Promise<void>) | undefined,
) => void;
}
enum SelectedView {
kInfo = 0,
kModify = 1,
kResult = 2,
}
export class Builder implements m.ClassComponent<BuilderAttrs> {
private trace: Trace;
private queryExecutionService: QueryExecutionService;
private query?: Query | Error;
private isQueryRunning: boolean = false;
private isAnalyzing: boolean = false;
private previousSelectedNode?: QueryNode;
// Stores selected node for keyboard shortcuts. This duplicates attrs.selectedNode
// because we need access outside of view() for executeSelectedNode() public method.
// Updated in view() to stay synchronized with attrs.
private selectedNode?: QueryNode;
// Stores all nodes for use in executeSelectedNode() which needs to pass
// allNodes to prevent auto-drop of disconnected graphs.
private rootNodes: QueryNode[] = [];
private response?: QueryResponse;
private dataSource?: DataSource;
private drawerVisibility = DrawerPanelVisibility.COLLAPSED;
private selectedView: SelectedView = SelectedView.kInfo;
private readonly MIN_SIDEBAR_WIDTH = 250;
private readonly MAX_SIDEBAR_WIDTH = 800;
private readonly DEFAULT_SIDEBAR_WIDTH = 500;
private hasEverSelectedNode = false;
private onNodeQueryAnalyzed?: (query: Query | Error | undefined) => void;
constructor({attrs}: m.Vnode<BuilderAttrs>) {
this.trace = attrs.trace;
// Use the shared QueryExecutionService from parent
this.queryExecutionService = attrs.queryExecutionService;
}
private handleSidebarResize(attrs: BuilderAttrs, deltaPx: number) {
const currentWidth = attrs.sidebarWidth ?? this.DEFAULT_SIDEBAR_WIDTH;
// Subtract delta because the handle is on the left edge of the sidebar
// Dragging left (negative delta) = narrower sidebar (positive change)
// Dragging right (positive delta) = wider sidebar (negative change)
const newWidth = Math.max(
this.MIN_SIDEBAR_WIDTH,
Math.min(this.MAX_SIDEBAR_WIDTH, currentWidth - deltaPx),
);
attrs.onSidebarWidthChange?.(newWidth);
}
view({attrs}: m.CVnode<BuilderAttrs>) {
const {trace, rootNodes} = attrs;
const selectedNode = getPrimarySelectedNode(attrs.selectedNodes, rootNodes);
// Store selectedNode and rootNodes for keyboard shortcuts (executeSelectedNode)
this.selectedNode = selectedNode;
this.rootNodes = rootNodes;
// Notify parent when execute function changes (when selectedNode changes)
if (selectedNode !== this.previousSelectedNode) {
if (selectedNode !== undefined) {
// Provide execute function bound to the current instance
attrs.onExecuteReady?.(() => this.executeSelectedNode());
} else {
// Clear execute function when no node is selected
attrs.onExecuteReady?.(undefined);
}
}
if (
selectedNode !== undefined &&
selectedNode !== this.previousSelectedNode
) {
this.resetQueryState();
this.isQueryRunning = false;
this.isAnalyzing = false;
// Show drawer the first time any node is selected
if (!this.hasEverSelectedNode) {
this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
this.hasEverSelectedNode = true;
}
const hasModifyPanel = selectedNode.nodeSpecificModify() != null;
// If current view is Info, switch to Modify (if available) when selecting a new node
if (this.selectedView === SelectedView.kInfo && hasModifyPanel) {
this.selectedView = SelectedView.kModify;
}
// If current view is Modify but modify panel is not available, switch to Info
if (this.selectedView === SelectedView.kModify && !hasModifyPanel) {
this.selectedView = SelectedView.kInfo;
}
}
const isExplorerCollapsed = attrs.isExplorerCollapsed ?? false;
const sidebarWidth = attrs.sidebarWidth ?? this.DEFAULT_SIDEBAR_WIDTH;
// When transitioning to unselected state with collapsed explorer, reappear at minimum size
if (
selectedNode === undefined &&
this.previousSelectedNode !== undefined &&
isExplorerCollapsed
) {
attrs.onExplorerCollapsedChange?.(false);
attrs.onSidebarWidthChange?.(this.MIN_SIDEBAR_WIDTH);
}
this.previousSelectedNode = selectedNode;
const layoutClasses =
classNames(
'pf-query-builder-layout',
isExplorerCollapsed && 'explorer-collapsed',
) || '';
// Create the onQueryAnalyzed callback and save it so manual execution can also use it
// Capture the current selected node to prevent race conditions when switching nodes rapidly
const callbackNode = selectedNode;
this.onNodeQueryAnalyzed = (query: Query | Error | undefined) => {
// Only update query if we're still on the same node
// This prevents stale queries from Node A being applied when we've switched to Node B
if (callbackNode === this.previousSelectedNode) {
this.query = query;
}
};
const hasMultipleNodesSelected = attrs.selectedNodes.size > 1;
const explorer = hasMultipleNodesSelected
? m(
'.pf-unselected-explorer',
m(ResultsPanelEmptyState, {
icon: 'info',
title: `${attrs.selectedNodes.size} nodes selected`,
}),
)
: selectedNode
? m(NodePanel, {
// The key to force mithril to re-create the component when the
// selected node changes, preventing state from leaking between
// different nodes.
key: selectedNode.nodeId,
trace,
node: selectedNode,
queryExecutionService: this.queryExecutionService,
allNodes: getAllNodes(rootNodes),
resolveNode: (nodeId: string) =>
this.resolveNode(nodeId, rootNodes),
query: this.query, // Pass the query state from Builder (single source of truth)
onQueryAnalyzed: this.onNodeQueryAnalyzed,
onAnalysisStateChange: (isAnalyzing: boolean) => {
this.isAnalyzing = isAnalyzing;
if (isAnalyzing) {
// Clear dataSource at the START of analysis to prevent stale
// queries with old columns while the table is being re-materialized.
this.dataSource = undefined;
}
},
onExecutionStart: () => {
this.isQueryRunning = true;
},
onExecutionSuccess: (result) => {
this.handleExecutionSuccess(selectedNode, result);
},
onExecutionError: (error) => {
this.handleQueryError(selectedNode, error);
this.isQueryRunning = false;
m.redraw();
},
onchange: () => {
// When a node's state changes, notify all downstream nodes
// to update their columns and UI. This ensures that when e.g.
// a column is renamed in ModifyColumnsNode, the AggregationNode
// sees the new column name.
const downstreamNodes = getAllDownstreamNodes(selectedNode);
for (const node of downstreamNodes) {
// Skip the node itself (it's included in downstream nodes)
if (node.nodeId === selectedNode.nodeId) continue;
node.onPrevNodesUpdated?.();
}
attrs.onNodeStateChange?.();
},
onUngroupNode: attrs.graphCallbacks.onUngroupNode,
isCollapsed: isExplorerCollapsed,
selectedView: this.selectedView,
onViewChange: (view: number) => {
this.selectedView = view;
},
})
: m(
'.pf-unselected-explorer',
m(NavigationSidePanel, {
selectedNode,
onAddSourceNode: attrs.graphCallbacks.onAddSourceNode,
onLoadExampleByPath: attrs.onLoadExampleByPath,
onLoadDataExplorerTemplate: attrs.onLoadDataExplorerTemplate,
onLoadEmptyTemplate: attrs.onLoadEmptyTemplate,
onLoadRecentGraph: attrs.onLoadRecentGraph,
}),
);
return m(DrawerPanel, {
className: layoutClasses,
visibility: this.drawerVisibility,
onVisibilityChange: (v) => {
this.drawerVisibility = v;
},
startingHeight: 300,
drawerContent: hasMultipleNodesSelected
? m(ResultsPanelEmptyState, {
icon: 'info',
title: `${attrs.selectedNodes.size} nodes selected`,
})
: selectedNode?.customResultsPanel?.() ??
(selectedNode
? m(ResultsPanel, {
trace: this.trace,
query: this.query,
node: selectedNode,
response: this.response,
dataSource: this.dataSource,
sqlModules: attrs.sqlModules,
queryExecutionService: this.queryExecutionService,
isQueryRunning: this.isQueryRunning,
isAnalyzing: this.isAnalyzing,
isStale: this.queryExecutionService.isNodeStale(
selectedNode.nodeId,
),
onchange: () => {
attrs.onNodeStateChange?.();
},
onFilterAdd: (filter, filterOperator) => {
attrs.onFilterAdd(selectedNode, filter, filterOperator);
},
onColumnAdd: attrs.onColumnAdd
? (column) => attrs.onColumnAdd?.(selectedNode, column)
: undefined,
isFullScreen:
this.drawerVisibility === DrawerPanelVisibility.FULLSCREEN,
onFullScreenToggle: () => {
if (
this.drawerVisibility === DrawerPanelVisibility.FULLSCREEN
) {
this.drawerVisibility = DrawerPanelVisibility.VISIBLE;
} else {
this.drawerVisibility = DrawerPanelVisibility.FULLSCREEN;
}
},
onExecute: async () => {
await this.executeSelectedNode();
},
onExportToTimeline: async () => {
await this.exportToTimeline(selectedNode);
},
})
: m(ResultsPanelEmptyState, {
icon: 'info',
title: 'Select a node to see the data',
})),
mainContent: [
m(
'.pf-qb-node-graph',
m(Graph, {
...attrs.graphCallbacks,
rootNodes,
selectedNodes: attrs.selectedNodes,
nodeLayouts: attrs.nodeLayouts,
labels: attrs.labels,
loadGeneration: attrs.loadGeneration,
}),
selectedNode &&
m(
'.pf-qb-floating-controls',
!selectedNode.validate() &&
m(
Popup,
{
trigger: m(
'.pf-qb-floating-warning',
m(Icon, {
icon: Icons.Warning,
filled: true,
className: 'pf-qb-warning-icon',
title: 'Click to see error details',
}),
),
position: PopupPosition.BottomEnd,
showArrow: true,
},
m(
'.pf-error-details',
selectedNode.context.issues?.getTitle() ??
'No error details',
),
),
),
m(
'.pf-qb-floating-controls-bottom',
attrs.onUndo &&
RoundActionButton({
icon: Icons.Undo,
title: 'Undo (Ctrl+Z)',
onclick: attrs.onUndo,
disabled: !attrs.canUndo,
}),
attrs.onRedo &&
RoundActionButton({
icon: Icons.Redo,
title: 'Redo (Ctrl+Shift+Z)',
onclick: attrs.onRedo,
disabled: !attrs.canRedo,
}),
),
),
m(ResizeHandle, {
direction: 'horizontal',
onResize: (deltaPx) => this.handleSidebarResize(attrs, deltaPx),
}),
m(
'.pf-qb-explorer',
{
style: {
width: isExplorerCollapsed
? '0'
: `${sidebarWidth + (selectedNode ? 0 : SIDE_PANEL_WIDTH)}px`,
},
},
explorer,
),
selectedNode &&
m(
'.pf-qb-side-panel',
m(Button, {
icon: Icons.Info,
title: 'Info',
className:
this.selectedView === SelectedView.kInfo && !isExplorerCollapsed
? 'pf-active'
: '',
onclick: () => {
if (
this.selectedView === SelectedView.kInfo &&
!isExplorerCollapsed
) {
attrs.onExplorerCollapsedChange?.(true);
} else {
this.selectedView = SelectedView.kInfo;
attrs.onExplorerCollapsedChange?.(false);
}
},
}),
selectedNode.nodeSpecificModify() != null &&
m(Button, {
icon: Icons.Edit,
title: 'Edit',
className:
this.selectedView === SelectedView.kModify &&
!isExplorerCollapsed
? 'pf-active'
: '',
onclick: () => {
if (
this.selectedView === SelectedView.kModify &&
!isExplorerCollapsed
) {
attrs.onExplorerCollapsedChange?.(true);
} else {
this.selectedView = SelectedView.kModify;
attrs.onExplorerCollapsedChange?.(false);
}
},
}),
m(Button, {
icon: 'code',
title: 'Result',
className:
this.selectedView === SelectedView.kResult &&
!isExplorerCollapsed
? 'pf-active'
: '',
onclick: () => {
if (
this.selectedView === SelectedView.kResult &&
!isExplorerCollapsed
) {
attrs.onExplorerCollapsedChange?.(true);
} else {
this.selectedView = SelectedView.kResult;
attrs.onExplorerCollapsedChange?.(false);
}
},
}),
),
],
});
}
private resolveNode(
nodeId: string,
rootNodes: QueryNode[],
): QueryNode | undefined {
const queue: QueryNode[] = [...rootNodes];
const visited = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current.nodeId)) {
continue;
}
visited.add(current.nodeId);
if (current.nodeId === nodeId) {
return current;
}
queue.push(...current.nextNodes);
}
return undefined;
}
/**
* Handles successful query execution by updating UI state.
* Called from both automatic execution (via NodePanel) and manual execution (via onExecute).
*/
private handleExecutionSuccess(
node: QueryNode,
result: {
tableName: string;
rowCount: number;
columns: string[];
durationMs: number;
},
) {
const engine = this.queryExecutionService.getEngine();
const query = this.query;
this.response = {
query: isAQuery(query) ? queryToRun(query) : '',
totalRowCount: result.rowCount,
durationMs: result.durationMs,
columns: result.columns,
rows: [],
statementCount: 1,
statementWithOutputCount: 1,
lastStatementSql: isAQuery(query) ? query.sql : '',
};
this.dataSource = new SQLDataSource({
engine,
sqlSchema: createSimpleSchema(result.tableName),
rootSchemaName: 'query',
});
this.isQueryRunning = false;
if (isAQuery(query)) {
this.setNodeIssuesFromResponse(node, query, this.response);
}
if (node instanceof SqlSourceNode && this.response !== undefined) {
node.onQueryExecuted(this.response.columns);
}
m.redraw();
}
/**
* Creates callbacks for processNode() when manually executing a query.
* Used by onExecute to avoid duplicating callback logic.
*/
private createManualExecutionCallbacks(node: QueryNode) {
return {
onAnalysisStart: () => {
this.isAnalyzing = true;
// Clear dataSource at the START of analysis to prevent stale
// queries with old columns while the table is being re-materialized.
this.dataSource = undefined;
m.redraw();
},
onAnalysisComplete: (query: Query | Error | undefined) => {
// Update query state via callback (with validation to prevent stale query leakage)
this.onNodeQueryAnalyzed?.(query);
this.isAnalyzing = false;
m.redraw();
},
onExecutionStart: () => {
this.isQueryRunning = true;
m.redraw();
},
onExecutionSuccess: (result: {
tableName: string;
rowCount: number;
columns: string[];
durationMs: number;
}) => {
this.handleExecutionSuccess(node, result);
},
onExecutionError: (error: unknown) => {
this.handleQueryError(node, error);
this.isQueryRunning = false;
m.redraw();
},
};
}
/**
* Executes the selected node's query manually.
* Public method called when user clicks "Run Query" button or presses Ctrl+Enter
* keyboard shortcut (invoked via parent component's keyboard handler).
*/
async executeSelectedNode(): Promise<void> {
const selectedNode = this.selectedNode;
if (selectedNode === undefined) {
return;
}
if (!selectedNode.validate()) {
console.warn(
`Cannot execute query: node ${selectedNode.nodeId} failed validation`,
);
return;
}
// Use the centralized service with manual=true.
// The service handles both analysis and execution.
await this.queryExecutionService.processNode(
selectedNode,
this.trace.engine,
getAllNodes(this.rootNodes),
{
manual: true, // User explicitly requested execution
...this.createManualExecutionCallbacks(selectedNode),
},
);
}
private async exportToTimeline(node: QueryNode) {
// Fetch table name from TP
const tableName = await this.queryExecutionService.getTableName(
node.nodeId,
);
if (!tableName) {
console.warn('Cannot export to timeline: no materialized table');
return;
}
// Use the materialized table instead of re-running the original query
this.trace.plugins.getPlugin(QueryPagePlugin).addQueryResultsTab(
{
query: `SELECT * FROM ${tableName}`,
title: 'Data Explorer Query',
},
'data_explorer',
);
// Navigate to the timeline page
this.trace.navigate('#!/viewer');
}
private setNodeIssuesFromResponse(
node: QueryNode,
query: Query,
response: QueryResponse,
) {
const error = findErrors(query, response);
const warning = findWarnings(response, node);
const noDataWarning =
response.totalRowCount === 0
? new Error('Query returned no rows')
: undefined;
if (error || warning || noDataWarning) {
if (!node.context.issues) {
node.context.issues = new NodeIssues();
}
node.context.issues.queryError = error;
node.context.issues.responseError = warning;
node.context.issues.dataError = noDataWarning;
// Clear any previous execution error since we got a successful response
node.context.issues.clearExecutionError();
} else {
node.context.issues = undefined;
}
}
/**
* Resets the query execution state.
* Used when switching nodes or after query execution errors.
*/
private resetQueryState() {
this.dataSource = undefined;
this.response = undefined;
this.query = undefined;
// Clear any pending execution in the service
this.queryExecutionService.clearPendingExecution();
}
private handleQueryError(node: QueryNode, e: unknown) {
console.error('Failed to run query:', e);
// Clear response and data source but keep query so Retry can re-execute
this.dataSource = undefined;
this.response = undefined;
if (!node.context.issues) {
node.context.issues = new NodeIssues();
}
// Use executionError (not queryError) so error persists across re-renders
// that trigger validate() - queryError gets cleared during validation
node.context.issues.executionError =
e instanceof Error ? e : new Error(String(e));
}
}