feat(workflows): Phase 3b — execution overlay + live visualization
All checks were successful
Stuffle/agos-client/pipeline/head This commit looks good

Frontend execution infrastructure:
- ExecutionOverlay: status badges on nodes with 7 states
  (pending/queued/running/completed/failed/skipped/waiting)
- Pulsing border + glow animation on running/waiting nodes
  (CSS @keyframes: execBorderPulse, execPulse, execDot)
- NodeExecutionDetail: per-node inspector showing status, duration,
  cost, attempt, active handles, error, and JSON output
- ExecutionTimeline: chronological step log with expandable details,
  status-sorted, per-step duration/cost metrics
- ExecutionControls: top-center toolbar showing status, progress
  (X/Y steps), duration, total cost, close button
- useExecutionSSE: SSE subscription hook for real-time updates
  (node_started/completed/failed/skipped, execution_completed)
- Execution state types: WfNodeExecutionStatus, WfExecutionStatus,
  WfNodeExecutionState, WfExecutionState, WfExecutionSSEEvent
- Zustand store: startExecution, updateNodeExecution,
  completeExecution, clearExecution, executionOverlayVisible
- Wired through DAGBuilder → WorkflowCanvas → WorkflowNode

Also fixes:
- TokenLedger: pass isAdmin prop to RowDetail component

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-08-21 02:37:40 +05:30
parent c3e3c0420a
commit cb04ee4b87
14 changed files with 1005 additions and 50 deletions

View File

@@ -9,6 +9,7 @@ import {
import { usageApi, agentsApi } from '@/api/client'
import type { ApiTokenUsageItem } from '@/api/client'
import { StatCard, LoadingState, ErrorState } from '@/components/agos-ux'
import { useMe } from '@/hooks/useMe'
import { UsageCharts } from './UsageCharts'
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -52,10 +53,10 @@ function truncId(id: string | null) {
// ── Expandable Row Detail ─────────────────────────────────────────────────────
function RowDetail({ item }: { item: ApiTokenUsageItem }) {
function RowDetail({ item, isAdmin }: { item: ApiTokenUsageItem; isAdmin: boolean }) {
return (
<tr>
<td colSpan={11} style={{ padding: 0 }}>
<td colSpan={isAdmin ? 11 : 10} style={{ padding: 0 }}>
<div style={{
padding: '10px 16px 12px', background: 'var(--nbl-hover-bg)',
display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(170px, 1fr))',
@@ -95,7 +96,9 @@ function DetailField({ label, value }: { label: string; value: string | null })
// ── Ledger Table ──────────────────────────────────────────────────────────────
function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentNames: Record<string, string> }) {
type LedgerMode = 'llm' | 'deductions'
function LedgerTable({ items, agentNames, isAdmin }: { items: ApiTokenUsageItem[]; agentNames: Record<string, string>; isAdmin: boolean }) {
const [expandedId, setExpandedId] = useState<string | null>(null)
if (items.length === 0) {
@@ -111,7 +114,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--nbl-border)' }}>
{['', 'Model', 'Stream', 'Prompt', 'Completion', 'Cache', 'Total', 'Cost', 'Agent', 'Session', 'When'].map(h => (
{['', 'Model', ...(isAdmin ? ['Stream'] : []), 'Prompt', 'Completion', 'Cache', 'Total', 'Cost', 'Agent', 'Session', 'When'].map(h => (
<th key={h || 'expand'} style={{
padding: '8px 8px', textAlign: 'left',
color: 'var(--nbl-text-ghost)', fontWeight: 600,
@@ -139,24 +142,26 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
}}>
{item.model || '—'}
</td>
<td style={{ padding: '8px 8px', color: 'var(--nbl-text-muted)' }}>
<span style={{
padding: '2px 6px', borderRadius: 4, fontSize: 10,
background: item.stream === 'platform'
? 'var(--nbl-agos-bg)'
: item.stream === 'offered'
? 'var(--nbl-green-bg)'
: 'var(--nbl-amber-bg)',
color: item.stream === 'platform'
? 'var(--nbl-agos)'
: item.stream === 'offered'
? 'var(--nbl-green)'
: 'var(--nbl-amber)',
fontWeight: 600,
}}>
{item.stream}
</span>
</td>
{isAdmin && (
<td style={{ padding: '8px 8px', color: 'var(--nbl-text-muted)' }}>
<span style={{
padding: '2px 6px', borderRadius: 4, fontSize: 10,
background: item.stream === 'platform'
? 'var(--nbl-agos-bg)'
: item.stream === 'offered'
? 'var(--nbl-green-bg)'
: 'var(--nbl-amber-bg)',
color: item.stream === 'platform'
? 'var(--nbl-agos)'
: item.stream === 'offered'
? 'var(--nbl-green)'
: 'var(--nbl-amber)',
fontWeight: 600,
}}>
{item.stream}
</span>
</td>
)}
<td style={{ padding: '8px 8px', fontFamily: 'var(--font-mono)', color: 'var(--nbl-cyan)', fontSize: 11 }}>
<ArrowUpRight size={9} style={{ marginRight: 2 }} />
{fmtTokens(item.prompt_tokens)}
@@ -192,7 +197,7 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
{fmtRelTime(item.created_at)}
</td>
</tr>
{expanded && <RowDetail item={item} />}
{expanded && <RowDetail item={item} isAdmin={isAdmin} />}
</Fragment>
)
})}
@@ -204,8 +209,8 @@ function LedgerTable({ items, agentNames }: { items: ApiTokenUsageItem[]; agentN
// ── Section Card ──────────────────────────────────────────────────────────────
function SectionCard({ title, icon: Icon, children }: {
title: string; icon: React.ElementType; children: React.ReactNode
function SectionCard({ title, icon: Icon, children, action }: {
title: string; icon: React.ElementType; children: React.ReactNode; action?: React.ReactNode
}) {
return (
<div style={{
@@ -213,20 +218,53 @@ function SectionCard({ title, icon: Icon, children }: {
borderRadius: 10, overflow: 'hidden',
}}>
<div style={{
padding: '12px 16px', borderBottom: '1px solid var(--nbl-border)',
padding: '10px 16px', borderBottom: '1px solid var(--nbl-border)',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<Icon size={13} style={{ color: 'var(--nbl-agos)' }} />
<span style={{
fontSize: 11, fontWeight: 700, color: 'var(--nbl-text-muted)',
textTransform: 'uppercase', letterSpacing: '0.07em',
textTransform: 'uppercase', letterSpacing: '0.07em', flex: 1,
}}>{title}</span>
{action}
</div>
<div style={{ padding: '12px 16px' }}>{children}</div>
</div>
)
}
// Pill toggle — two options, one selected at a time
function PillToggle<T extends string>({ value, options, onChange }: {
value: T
options: { value: T; label: string }[]
onChange: (v: T) => void
}) {
return (
<div style={{
display: 'inline-flex', borderRadius: 6, overflow: 'hidden',
border: '1px solid var(--nbl-border)',
background: 'var(--nbl-hover-bg)',
}}>
{options.map(opt => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
style={{
padding: '3px 10px', fontSize: 10, fontWeight: 600,
border: 'none', cursor: 'pointer', whiteSpace: 'nowrap',
letterSpacing: '0.04em',
background: value === opt.value ? 'var(--nbl-agos)' : 'transparent',
color: value === opt.value ? '#fff' : 'var(--nbl-text-muted)',
transition: 'background 0.15s, color 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
)
}
// ── Chart Panel Width ─────────────────────────────────────────────────────────
const CHART_PANEL_WIDTH = 500
@@ -236,6 +274,8 @@ const CHART_PANEL_WIDTH = 500
export function TokenLedger() {
const [page, setPage] = useState(1)
const [panelOpen, setPanelOpen] = useState(true)
const [ledgerMode, setLedgerMode] = useState<LedgerMode>('llm')
const { isAdmin } = useMe()
const { data: ledgerData, isLoading, error, refetch } = useQuery({
queryKey: ['token-ledger', page],
@@ -243,7 +283,6 @@ export function TokenLedger() {
staleTime: 15_000,
})
const items = ledgerData?.items ?? []
const total = ledgerData?.total ?? 0
// Aggregate data for KPIs + charts panel
@@ -299,6 +338,18 @@ export function TokenLedger() {
return sorted[0].group_key || '—'
}, [modelItems])
// Filter ledger rows by mode:
// 'llm' — real model calls (has model OR has prompt/completion tokens)
// 'deductions'— quota accounting rows (stream=platform, no model, 0 tokens)
const items = useMemo(() => {
const all = ledgerData?.items ?? []
if (ledgerMode === 'llm') {
return all.filter(i => i.model || i.prompt_tokens > 0 || i.completion_tokens > 0)
}
// deductions: stream=platform with no model and 0 actual tokens
return all.filter(i => i.stream === 'platform' && !i.model && i.prompt_tokens === 0 && i.completion_tokens === 0)
}, [ledgerData, ledgerMode])
return (
<div style={{
display: 'flex', height: '100%', width: '100%', overflow: 'hidden',
@@ -384,8 +435,17 @@ export function TokenLedger() {
{error && <ErrorState message={`Failed to load token usage: ${(error as Error).message}`} />}
{!isLoading && !error && (
<SectionCard title="Usage Log" icon={Layers}>
<LedgerTable items={items} agentNames={agentNames} />
<SectionCard title="Usage Log" icon={Layers} action={
<PillToggle<LedgerMode>
value={ledgerMode}
onChange={setLedgerMode}
options={[
{ value: 'llm', label: 'LLM Calls' },
{ value: 'deductions', label: 'Quota Deductions' },
]}
/>
}>
<LedgerTable items={items} agentNames={agentNames} isAdmin={isAdmin} />
{total > 50 && (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
@@ -427,6 +487,7 @@ export function TokenLedger() {
agentData={agentAgg?.items ?? []}
dayData={dayAgg?.items ?? []}
streamData={streamAgg?.items ?? []}
isAdmin={isAdmin}
/>
</div>
)}

View File

@@ -62,11 +62,14 @@ function fmtUsd(n: number) {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
const GROUP_OPTIONS = [
const GROUP_OPTIONS_BASE = [
{ value: 'model', label: 'Model' },
{ value: 'agent_id', label: 'Agent' },
{ value: 'app_id', label: 'App' },
{ value: 'day', label: 'Day' },
]
const GROUP_OPTIONS_ADMIN = [
...GROUP_OPTIONS_BASE,
{ value: 'stream', label: 'Stream' },
]
@@ -311,6 +314,27 @@ function buildHeatmapOptions(dayData: ApiTokenUsageAggregateItem[]): Highcharts.
}) as Highcharts.Options
}
// ── Dist pill toggle ─────────────────────────────────────────────────────────
function DistPillToggle({ value, onChange }: { value: 'model' | 'stream'; onChange: (v: 'model' | 'stream') => void }) {
return (
<div style={{
display: 'inline-flex', borderRadius: 5, overflow: 'hidden',
border: '1px solid var(--nbl-border)',
}}>
{(['model', 'stream'] as const).map(v => (
<button key={v} onClick={() => onChange(v)} style={{
padding: '2px 8px', fontSize: 9, fontWeight: 700, border: 'none',
cursor: 'pointer', letterSpacing: '0.04em', textTransform: 'uppercase',
background: value === v ? 'var(--nbl-agos)' : 'transparent',
color: value === v ? '#fff' : 'var(--nbl-text-muted)',
fontFamily: 'inherit', transition: 'background 0.15s',
}}>{v}</button>
))}
</div>
)
}
// ── Collapsible Section ──────────────────────────────────────────────────────
function Section({ title, icon: Icon, open, onToggle, action, children }: {
@@ -365,13 +389,15 @@ interface UsageChartsProps {
agentData: ApiTokenUsageAggregateItem[]
dayData: ApiTokenUsageAggregateItem[]
streamData: ApiTokenUsageAggregateItem[]
isAdmin: boolean
}
export function UsageCharts({ modelData, agentData, dayData, streamData }: UsageChartsProps) {
export function UsageCharts({ modelData, agentData, dayData, streamData, isAdmin }: UsageChartsProps) {
const [openSections, setOpenSections] = useState<Record<string, boolean>>({
costs: true, distribution: true, models: true, activity: true,
})
const [groupBy, setGroupBy] = useState('model')
const [distView, setDistView] = useState<'model' | 'stream'>('model')
const chartRefModelDonut = useRef<HighchartsReact.RefObject>(null)
const chartRefStreamDonut = useRef<HighchartsReact.RefObject>(null)
const chartRefBar = useRef<HighchartsReact.RefObject>(null)
@@ -419,7 +445,7 @@ export function UsageCharts({ modelData, agentData, dayData, streamData }: Usage
action={
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Filter size={9} style={{ color: 'var(--nbl-text-ghost)' }} />
<SimpleSelect size="sm" value={groupBy} onChange={setGroupBy} options={GROUP_OPTIONS} />
<SimpleSelect size="sm" value={groupBy} onChange={setGroupBy} options={isAdmin ? GROUP_OPTIONS_ADMIN : GROUP_OPTIONS_BASE} />
</div>
}
>
@@ -435,11 +461,16 @@ export function UsageCharts({ modelData, agentData, dayData, streamData }: Usage
icon={PieChart}
open={openSections.distribution}
onToggle={() => toggle('distribution')}
action={isAdmin ? (
<DistPillToggle value={distView} onChange={setDistView} />
) : undefined}
>
<ChartLabel>By Model</ChartLabel>
<HighchartsReact highcharts={Highcharts} options={modelDonutOpts} ref={chartRefModelDonut} />
<ChartLabel style={{ marginTop: 8 }}>By Stream</ChartLabel>
<HighchartsReact highcharts={Highcharts} options={streamDonutOpts} ref={chartRefStreamDonut} />
{(!isAdmin || distView === 'model') && (
<HighchartsReact highcharts={Highcharts} options={modelDonutOpts} ref={chartRefModelDonut} />
)}
{isAdmin && distView === 'stream' && (
<HighchartsReact highcharts={Highcharts} options={streamDonutOpts} ref={chartRefStreamDonut} />
)}
</Section>
{/* Models */}

View File

@@ -22,6 +22,10 @@ import { coerceNodeToType } from './node-registry'
import { WorkflowCanvas } from './WorkflowCanvas'
import { NodePalette } from './palette/NodePalette'
import { InspectorPanel } from './inspector/InspectorPanel'
import { ExecutionControls } from './execution/ExecutionControls'
import { ExecutionTimeline } from './execution/ExecutionTimeline'
import { NodeExecutionDetail } from './execution/NodeExecutionDetail'
import { EXECUTION_OVERLAY_STYLES } from './execution/ExecutionOverlay'
const FLOATING_PANEL_STYLE = {
background: 'color-mix(in srgb, var(--nbl-surface) 92%, rgba(0, 0, 0, 0.28))',
@@ -73,6 +77,8 @@ export function DAGBuilder({
const rightPanelCollapsed = store(s => s.rightPanelCollapsed)
const leftPanelPinned = store(s => s.leftPanelPinned)
const leftPanelHovered = store(s => s.leftPanelHovered)
const executionState = store(s => s.executionState)
const executionOverlayVisible = store(s => s.executionOverlayVisible)
const selectedNode = selectedNodeId
? definition.nodes.find(n => n.id === selectedNodeId)
@@ -168,8 +174,16 @@ export function DAGBuilder({
/* ── Render ─────────────────────────────────────────────────────────── */
const execNodeStates = executionOverlayVisible && executionState
? executionState.node_states
: undefined
const selectedNodeExec = selectedNodeId && executionState?.node_states[selectedNodeId]
return (
<div style={{ display: 'flex', height: '100%', position: 'relative', overflow: 'hidden' }}>
{/* Inject execution keyframe styles */}
{executionOverlayVisible && <style>{EXECUTION_OVERLAY_STYLES}</style>}
<div style={{ flex: 1 }}>
<ReactFlowProvider>
<WorkflowCanvas
@@ -187,6 +201,7 @@ export function DAGBuilder({
onConnect={handleConnect}
onRemoveEdge={handleRemoveEdge}
onAddNode={handleAddNode}
execStateByNode={execNodeStates}
>
{/* Left panel: Node Palette */}
<Panel position="top-left" style={{ margin: 16 }}>
@@ -199,7 +214,7 @@ export function DAGBuilder({
/>
</Panel>
{/* Right panel: Inspector */}
{/* Right panel: Inspector or Execution Detail */}
<Panel position="top-right" style={{ margin: 16, height: rightPanelCollapsed ? 'auto' : 'calc(100% - 32px)' }}>
<InspectorPanel
definition={definition}
@@ -212,10 +227,40 @@ export function DAGBuilder({
onUpdateTrigger={handleUpdateTrigger}
onUpdateProvider={handleUpdateProvider}
onUpdateTemplate={handleUpdateTemplate}
footer={rightStackFooter}
footer={
<>
{/* Execution detail for selected node */}
{selectedNodeExec && executionOverlayVisible && (
<div style={{ borderTop: '1px solid var(--nbl-border)', paddingTop: 10, marginTop: 4 }}>
<NodeExecutionDetail nodeExecState={selectedNodeExec} />
</div>
)}
{/* Execution timeline */}
{executionOverlayVisible && executionState && (
<div style={{ borderTop: '1px solid var(--nbl-border)', paddingTop: 10, marginTop: 4 }}>
<ExecutionTimeline
executionState={executionState}
nodes={definition.nodes}
onSelectNode={handleSelectNode}
/>
</div>
)}
{rightStackFooter}
</>
}
/>
</Panel>
{/* Execution controls overlay (top-center) */}
{executionOverlayVisible && executionState && (
<Panel position="top-center" style={{ margin: 16, zIndex: 10, pointerEvents: 'none' }}>
<ExecutionControls
executionState={executionState}
onClose={() => store.getState().clearExecution()}
/>
</Panel>
)}
{/* Bottom-left hint */}
<Panel position="bottom-left" style={{ margin: 16, zIndex: 6 }}>
<div style={{ ...FLOATING_PANEL_STYLE, padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--nbl-text-muted)', maxWidth: 340 }}>

View File

@@ -31,7 +31,7 @@ import {
MarkerType,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import type { WfDefinition, WfNodeType } from '../types'
import type { WfDefinition, WfNodeType, WfNodeExecutionState } from '../types'
import { PALETTE_DRAG_FORMAT } from './palette/NodePalette'
import { validateConnection, validateGraph } from './graph-validation'
import { FLOW_NODE_TYPES } from './flow-node-types'
@@ -58,6 +58,8 @@ interface WorkflowCanvasProps {
onConnect: (source: string, target: string) => void
onRemoveEdge: (edgeId: string) => void
onAddNode?: (type: WfNodeType, label: string) => void
/** Per-node execution state (for live execution overlay) */
execStateByNode?: Record<string, WfNodeExecutionState>
/** Panels rendered inside ReactFlow (required for correct positioning) */
children?: React.ReactNode
}
@@ -77,6 +79,7 @@ export function WorkflowCanvas({
onConnect,
onRemoveEdge,
onAddNode,
execStateByNode,
children,
}: WorkflowCanvasProps) {
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
@@ -98,8 +101,8 @@ export function WorkflowCanvas({
}, [definition])
const rawNodes = useMemo(
() => toFlowNodes(definition, selectedNodeId, onDeleteNode, validationByNode),
[definition, selectedNodeId, onDeleteNode, validationByNode],
() => toFlowNodes(definition, selectedNodeId, onDeleteNode, validationByNode, execStateByNode),
[definition, selectedNodeId, onDeleteNode, validationByNode, execStateByNode],
)
const rawEdges = useMemo(
() => toFlowEdges(definition),

View File

@@ -2,6 +2,7 @@ import { AlertCircle, AlertTriangle, X, Zap } from 'lucide-react'
import { type NodeProps, Handle, Position } from '@xyflow/react'
import { NODE_ICON, NODE_W, NODE_H, NODE_TYPE_LABEL, NODE_SHAPE_BY_FAMILY, getNodeFamilyColor, type WfNodeData } from './workflow-node-constants'
import { getNodeFamily } from '../types'
import { NodeExecutionBadge, getNodeExecBorderColor, isNodeExecPulsing } from './execution/ExecutionOverlay'
const HANDLE_STYLE = {
width: 16,
@@ -29,26 +30,35 @@ export function WorkflowNode({ id, data }: NodeProps) {
? 'polygon(50% 0%, 96% 50%, 50% 100%, 4% 50%)'
: undefined
const execBorderColor = getNodeExecBorderColor(d.execState)
const execPulsing = isNodeExecPulsing(d.execState)
const borderColor = execBorderColor ?? (d.selected ? color : `color-mix(in srgb, ${color} 24%, var(--nbl-border))`)
return (
<div
style={{
width: NODE_W,
height: NODE_H,
background: `linear-gradient(180deg, color-mix(in srgb, var(--nbl-surface) 92%, ${color} 8%), color-mix(in srgb, var(--nbl-surface) 98%, transparent))`,
border: `1.5px solid ${d.selected ? color : `color-mix(in srgb, ${color} 24%, var(--nbl-border))`}`,
border: `1.5px solid ${borderColor}`,
borderRadius,
clipPath,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '0 14px',
boxShadow: d.selected
? `0 0 0 3px color-mix(in srgb, ${color} 18%, transparent), 0 16px 34px rgba(0, 0, 0, 0.18)`
: '0 10px 28px rgba(0, 0, 0, 0.12)',
boxShadow: execPulsing
? `0 0 12px 3px color-mix(in srgb, ${execBorderColor} 30%, transparent), 0 10px 28px rgba(0, 0, 0, 0.12)`
: d.selected
? `0 0 0 3px color-mix(in srgb, ${color} 18%, transparent), 0 16px 34px rgba(0, 0, 0, 0.18)`
: '0 10px 28px rgba(0, 0, 0, 0.12)',
cursor: 'pointer',
transition: 'border-color 0.15s, box-shadow 0.15s, transform 0.15s',
position: 'relative',
transform: d.selected ? 'translateY(-1px)' : 'translateY(0)',
animation: execPulsing ? 'execBorderPulse 1.5s ease-in-out infinite' : undefined,
// @ts-expect-error -- CSS custom property for the animation color
'--exec-glow-color': execBorderColor,
}}
>
{d.type !== 'start' && <Handle type="target" position={Position.Left} style={{ ...HANDLE_STYLE, background: color, left: -9 }} />}
@@ -97,7 +107,8 @@ export function WorkflowNode({ id, data }: NodeProps) {
</button>
)}
{d.type !== 'end' && <Handle type="source" position={Position.Right} style={{ ...HANDLE_STYLE, background: color, right: -9 }} />}
{d.validationBadge && (
{d.execState && <NodeExecutionBadge nodeExecState={d.execState} />}
{d.validationBadge && !d.execState && (
<div
title={d.validationBadge.messages.join('\n')}
style={{

View File

@@ -1,6 +1,6 @@
import dagre from '@dagrejs/dagre'
import { MarkerType, type Edge, type Node } from '@xyflow/react'
import type { WfDefinition, WfEdge } from '../types'
import type { WfDefinition, WfEdge, WfNodeExecutionState } from '../types'
import type { WfNodeData } from './workflow-node-constants'
import { NODE_H, NODE_W } from './workflow-node-constants'
@@ -29,6 +29,7 @@ export function toFlowNodes(
selectedId: string | null,
onDelete: (id: string) => void,
validationByNode?: Map<string, { severity: 'error' | 'warning'; count: number; messages: string[] }>,
execStateByNode?: Record<string, WfNodeExecutionState>,
): Node[] {
return def.nodes.map((node) => ({
id: node.id,
@@ -39,6 +40,7 @@ export function toFlowNodes(
selected: node.id === selectedId,
onDelete,
validationBadge: validationByNode?.get(node.id),
execState: execStateByNode?.[node.id],
} as WfNodeData,
}))
}

View File

@@ -0,0 +1,104 @@
/**
* ExecutionControls — toolbar for execution actions (shown during active execution).
*
* Actions: view timeline, cancel, close overlay.
*/
import { X, Clock, DollarSign, CheckCircle2, AlertTriangle, Loader2 } from 'lucide-react'
import type { WfExecutionState } from '../../types'
interface ExecutionControlsProps {
executionState: WfExecutionState
onClose: () => void
}
export function ExecutionControls({
executionState,
onClose,
}: ExecutionControlsProps) {
const isRunning = executionState.status === 'running'
const isDone = executionState.status === 'completed'
const isFailed = executionState.status === 'failed' || executionState.status === 'cancelled'
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
borderRadius: 10,
background: 'var(--nbl-bg-elevated)',
border: '1px solid var(--nbl-border)',
fontSize: 10,
pointerEvents: 'auto',
}}
>
{/* Status indicator */}
{isRunning && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--nbl-cyan)' }}>
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite' }} />
<span style={{ fontWeight: 700 }}>Running</span>
</span>
)}
{isDone && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--nbl-green)' }}>
<CheckCircle2 size={12} />
<span style={{ fontWeight: 700 }}>Completed</span>
</span>
)}
{isFailed && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4, color: 'var(--nbl-red)' }}>
<AlertTriangle size={12} />
<span style={{ fontWeight: 700 }}>{executionState.status === 'cancelled' ? 'Cancelled' : 'Failed'}</span>
</span>
)}
{/* Separator */}
<span style={{ width: 1, height: 14, background: 'var(--nbl-border)' }} />
{/* Progress */}
<span style={{ color: 'var(--nbl-text-muted)', fontWeight: 600 }}>
{executionState.completed_nodes}/{executionState.total_nodes}
</span>
{/* Duration */}
{executionState.duration_ms > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--nbl-text-muted)' }}>
<Clock size={9} />
{(executionState.duration_ms / 1000).toFixed(1)}s
</span>
)}
{/* Cost */}
{executionState.total_cost_usd > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3, color: 'var(--nbl-text-muted)' }}>
<DollarSign size={9} />
${executionState.total_cost_usd.toFixed(4)}
</span>
)}
{/* Close */}
<button
onClick={onClose}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 22,
height: 22,
borderRadius: 6,
border: '1px solid var(--nbl-border)',
background: 'var(--nbl-bg)',
color: 'var(--nbl-text-muted)',
cursor: 'pointer',
padding: 0,
marginLeft: 4,
}}
title="Close execution overlay"
>
<X size={12} />
</button>
</div>
)
}

View File

@@ -0,0 +1,134 @@
/**
* ExecutionOverlay — renders execution status on top of WorkflowNode.
*
* Shown as colored badges/indicators on each node:
* - pending: dim gray
* - queued: light gray, pulsing
* - running: blue, pulsing border
* - completed: green
* - failed: red
* - skipped: gray, dashed
* - waiting: amber, pulsing
*/
import type { WfNodeExecutionState, WfNodeExecutionStatus } from '../../types'
const STATUS_COLORS: Record<WfNodeExecutionStatus, { bg: string; border: string; text: string }> = {
pending: { bg: 'transparent', border: 'var(--nbl-border)', text: 'var(--nbl-text-ghost)' },
queued: { bg: 'transparent', border: 'var(--nbl-text-ghost)', text: 'var(--nbl-text-ghost)' },
running: { bg: 'color-mix(in srgb, var(--nbl-cyan) 12%, transparent)', border: 'var(--nbl-cyan)', text: 'var(--nbl-cyan)' },
completed: { bg: 'color-mix(in srgb, var(--nbl-green) 12%, transparent)', border: 'var(--nbl-green)', text: 'var(--nbl-green)' },
failed: { bg: 'color-mix(in srgb, var(--nbl-red) 12%, transparent)', border: 'var(--nbl-red)', text: 'var(--nbl-red)' },
skipped: { bg: 'transparent', border: 'var(--nbl-text-ghost)', text: 'var(--nbl-text-ghost)' },
waiting: { bg: 'color-mix(in srgb, var(--nbl-amber) 12%, transparent)', border: 'var(--nbl-amber)', text: 'var(--nbl-amber)' },
}
const STATUS_LABELS: Record<WfNodeExecutionStatus, string> = {
pending: 'Pending',
queued: 'Queued',
running: 'Running',
completed: 'Done',
failed: 'Failed',
skipped: 'Skipped',
waiting: 'Waiting',
}
interface NodeExecutionBadgeProps {
nodeExecState: WfNodeExecutionState | undefined
}
export function NodeExecutionBadge({ nodeExecState }: NodeExecutionBadgeProps) {
if (!nodeExecState) return null
const { status, duration_ms, cost_usd } = nodeExecState
const colors = STATUS_COLORS[status]
const isPulsing = status === 'running' || status === 'queued' || status === 'waiting'
return (
<div
style={{
position: 'absolute',
top: -8,
right: -8,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '2px 6px',
borderRadius: 8,
background: colors.bg,
border: `1px solid ${colors.border}`,
color: colors.text,
fontSize: 9,
fontWeight: 700,
letterSpacing: '0.02em',
animation: isPulsing ? 'execPulse 1.5s ease-in-out infinite' : undefined,
zIndex: 10,
pointerEvents: 'none',
}}
>
{isPulsing && (
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: colors.text,
animation: 'execDot 1s ease-in-out infinite',
}}
/>
)}
{STATUS_LABELS[status]}
{duration_ms != null && status === 'completed' && (
<span style={{ opacity: 0.7, fontWeight: 500 }}>
{duration_ms < 1000 ? `${duration_ms}ms` : `${(duration_ms / 1000).toFixed(1)}s`}
</span>
)}
{cost_usd != null && cost_usd > 0 && status === 'completed' && (
<span style={{ opacity: 0.7, fontWeight: 500 }}>
${cost_usd.toFixed(4)}
</span>
)}
</div>
)
}
/**
* Returns the CSS border color for a node based on its execution status.
* Used by WorkflowNode to change its border during execution.
*/
export function getNodeExecBorderColor(
nodeExecState: WfNodeExecutionState | undefined,
): string | undefined {
if (!nodeExecState) return undefined
const { status } = nodeExecState
if (status === 'pending') return undefined // use default
return STATUS_COLORS[status].border
}
/**
* Returns whether a node should show pulsing animation.
*/
export function isNodeExecPulsing(
nodeExecState: WfNodeExecutionState | undefined,
): boolean {
if (!nodeExecState) return false
return nodeExecState.status === 'running' || nodeExecState.status === 'waiting'
}
/**
* Execution overlay CSS keyframes — inject once.
*/
export const EXECUTION_OVERLAY_STYLES = `
@keyframes execPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes execDot {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.4); }
}
@keyframes execBorderPulse {
0%, 100% { box-shadow: 0 0 0 0 transparent; }
50% { box-shadow: 0 0 8px 2px var(--exec-glow-color, var(--nbl-cyan)); }
}
`

View File

@@ -0,0 +1,212 @@
/**
* ExecutionTimeline — chronological step log showing execution progress.
*
* Each step shows: status icon, node label, duration, cost.
* Expandable to show input/output.
*/
import { useState } from 'react'
import {
CheckCircle2, Loader2, AlertTriangle, Ban, Clock,
ChevronDown, ChevronRight, DollarSign,
} from 'lucide-react'
import type { WfExecutionState, WfNodeExecutionState, WfNode } from '../../types'
const STATUS_CONFIGS = {
pending: { icon: null, color: 'var(--nbl-text-ghost)', label: 'Pending' },
queued: { icon: Loader2, color: 'var(--nbl-text-ghost)', label: 'Queued' },
running: { icon: Loader2, color: 'var(--nbl-cyan)', label: 'Running' },
completed: { icon: CheckCircle2, color: 'var(--nbl-green)', label: 'Done' },
failed: { icon: AlertTriangle, color: 'var(--nbl-red)', label: 'Failed' },
skipped: { icon: Ban, color: 'var(--nbl-text-ghost)', label: 'Skipped' },
waiting: { icon: Clock, color: 'var(--nbl-amber)', label: 'Waiting' },
} as const
interface ExecutionTimelineProps {
executionState: WfExecutionState
nodes: WfNode[]
onSelectNode?: (nodeId: string) => void
}
export function ExecutionTimeline({
executionState,
nodes,
onSelectNode,
}: ExecutionTimelineProps) {
// Order nodes in execution order: completed first, running, then pending
const orderedNodes = [...nodes].sort((a, b) => {
const sa = executionState.node_states[a.id]
const sb = executionState.node_states[b.id]
const order = { completed: 0, failed: 1, running: 2, waiting: 3, queued: 4, pending: 5, skipped: 6 }
return (order[sa?.status ?? 'pending'] ?? 5) - (order[sb?.status ?? 'pending'] ?? 5)
})
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Summary */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 0', fontSize: 10, color: 'var(--nbl-text-muted)',
borderBottom: '1px solid var(--nbl-border)', marginBottom: 4,
}}>
<span style={{ fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--nbl-text-ghost)' }}>
Timeline
</span>
<span>
{executionState.completed_nodes}/{executionState.total_nodes} steps
{executionState.total_cost_usd > 0 && ` · $${executionState.total_cost_usd.toFixed(4)}`}
{executionState.duration_ms > 0 && ` · ${(executionState.duration_ms / 1000).toFixed(1)}s`}
</span>
</div>
{/* Steps */}
{orderedNodes.map(node => {
const nodeExec = executionState.node_states[node.id]
if (!nodeExec) return null
return (
<TimelineStep
key={node.id}
node={node}
nodeExec={nodeExec}
onSelect={() => onSelectNode?.(node.id)}
/>
)
})}
</div>
)
}
function TimelineStep({
node,
nodeExec,
onSelect,
}: {
node: WfNode
nodeExec: WfNodeExecutionState
onSelect: () => void
}) {
const [expanded, setExpanded] = useState(false)
const cfg = STATUS_CONFIGS[nodeExec.status]
const Icon = cfg.icon
const isAnimated = nodeExec.status === 'running' || nodeExec.status === 'queued'
return (
<div style={{ borderRadius: 8, overflow: 'hidden' }}>
<button
onClick={() => { onSelect(); setExpanded(e => !e) }}
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '6px 8px',
border: 'none',
background: expanded ? 'var(--nbl-bg-elevated)' : 'transparent',
cursor: 'pointer',
borderRadius: 8,
color: 'var(--nbl-text)',
fontSize: 11,
textAlign: 'left',
}}
>
{/* Status dot/icon */}
{Icon ? (
<Icon
size={12}
style={{
color: cfg.color,
flexShrink: 0,
animation: isAnimated ? 'spin 1s linear infinite' : undefined,
}}
/>
) : (
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
border: `2px solid ${cfg.color}`,
flexShrink: 0,
}}
/>
)}
{/* Label */}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.label}
</span>
{/* Metrics */}
<span style={{ fontSize: 9, color: 'var(--nbl-text-muted)', display: 'flex', gap: 6, flexShrink: 0 }}>
{nodeExec.duration_ms != null && (
<span style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Clock size={8} />
{nodeExec.duration_ms < 1000 ? `${nodeExec.duration_ms}ms` : `${(nodeExec.duration_ms / 1000).toFixed(1)}s`}
</span>
)}
{nodeExec.cost_usd != null && nodeExec.cost_usd > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<DollarSign size={8} />
${nodeExec.cost_usd.toFixed(4)}
</span>
)}
</span>
{/* Expand indicator */}
{expanded
? <ChevronDown size={10} style={{ color: 'var(--nbl-text-ghost)', flexShrink: 0 }} />
: <ChevronRight size={10} style={{ color: 'var(--nbl-text-ghost)', flexShrink: 0 }} />}
</button>
{/* Expanded detail */}
{expanded && (
<div style={{
padding: '4px 8px 8px 26px',
fontSize: 10,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}>
{nodeExec.error && (
<div style={{
padding: 6,
borderRadius: 6,
background: 'color-mix(in srgb, var(--nbl-red) 8%, var(--nbl-bg))',
border: '1px solid color-mix(in srgb, var(--nbl-red) 20%, transparent)',
color: 'var(--nbl-red)',
fontFamily: 'var(--nbl-mono)',
fontSize: 9,
whiteSpace: 'pre-wrap',
maxHeight: 80,
overflow: 'auto',
}}>
{nodeExec.error}
</div>
)}
{nodeExec.output && nodeExec.status === 'completed' && (
<pre style={{
padding: 6,
borderRadius: 6,
background: 'var(--nbl-bg-elevated)',
border: '1px solid var(--nbl-border)',
fontFamily: 'var(--nbl-mono)',
fontSize: 9,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 100,
overflow: 'auto',
margin: 0,
}}>
{JSON.stringify(nodeExec.output, null, 2)}
</pre>
)}
{nodeExec.active_handles && nodeExec.active_handles.length > 0 && (
<div style={{ color: 'var(--nbl-text-muted)' }}>
Branch: {nodeExec.active_handles.join(', ')}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,122 @@
/**
* NodeExecutionDetail — shows detailed input/output/cost/logs for a
* running or completed node.
*
* Shown in the inspector panel when execution overlay is active and a
* node is selected.
*/
import { Clock, DollarSign, AlertTriangle, CheckCircle2, Loader2, Ban } from 'lucide-react'
import type { WfNodeExecutionState } from '../../types'
const SECTION_TITLE_STYLE = {
fontSize: 10,
color: 'var(--nbl-text-ghost)',
textTransform: 'uppercase' as const,
letterSpacing: '0.08em',
marginBottom: 4,
} as const
const STATUS_ICON_MAP = {
pending: null,
queued: <Loader2 size={12} style={{ color: 'var(--nbl-text-ghost)', animation: 'spin 1s linear infinite' }} />,
running: <Loader2 size={12} style={{ color: 'var(--nbl-cyan)', animation: 'spin 1s linear infinite' }} />,
completed: <CheckCircle2 size={12} style={{ color: 'var(--nbl-green)' }} />,
failed: <AlertTriangle size={12} style={{ color: 'var(--nbl-red)' }} />,
skipped: <Ban size={12} style={{ color: 'var(--nbl-text-ghost)' }} />,
waiting: <Clock size={12} style={{ color: 'var(--nbl-amber)' }} />,
} as const
interface NodeExecutionDetailProps {
nodeExecState: WfNodeExecutionState
}
export function NodeExecutionDetail({ nodeExecState }: NodeExecutionDetailProps) {
const { status, output, error, cost_usd, duration_ms, attempt, active_handles, node_type } = nodeExecState
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Status header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{STATUS_ICON_MAP[status]}
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--nbl-text)', textTransform: 'capitalize' }}>
{status}
</span>
<span style={{ fontSize: 10, color: 'var(--nbl-text-muted)', marginLeft: 'auto' }}>
{node_type}
</span>
</div>
{/* Metrics row */}
<div style={{ display: 'flex', gap: 12, fontSize: 10, color: 'var(--nbl-text-muted)' }}>
{duration_ms != null && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<Clock size={10} />
{duration_ms < 1000 ? `${duration_ms}ms` : `${(duration_ms / 1000).toFixed(2)}s`}
</span>
)}
{cost_usd != null && cost_usd > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
<DollarSign size={10} />
${cost_usd.toFixed(6)}
</span>
)}
{attempt != null && attempt > 1 && (
<span>Attempt #{attempt}</span>
)}
{active_handles && active_handles.length > 0 && (
<span>Branch: {active_handles.join(', ')}</span>
)}
</div>
{/* Error */}
{error && (
<div>
<div style={SECTION_TITLE_STYLE}>Error</div>
<div
style={{
padding: 8,
borderRadius: 8,
background: 'color-mix(in srgb, var(--nbl-red) 8%, var(--nbl-bg))',
border: '1px solid color-mix(in srgb, var(--nbl-red) 20%, transparent)',
color: 'var(--nbl-red)',
fontSize: 10,
fontFamily: 'var(--nbl-mono)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 120,
overflow: 'auto',
}}
>
{error}
</div>
</div>
)}
{/* Output */}
{output && status === 'completed' && (
<div>
<div style={SECTION_TITLE_STYLE}>Output</div>
<pre
style={{
padding: 8,
borderRadius: 8,
background: 'var(--nbl-bg-elevated)',
border: '1px solid var(--nbl-border)',
color: 'var(--nbl-text)',
fontSize: 10,
fontFamily: 'var(--nbl-mono)',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 200,
overflow: 'auto',
margin: 0,
}}
>
{JSON.stringify(output, null, 2)}
</pre>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,77 @@
/**
* useExecutionSSE — subscribes to SSE events for live execution updates.
*
* Connects to the backend SSE endpoint when an execution starts and
* dispatches events to the workflow editor store.
*/
import { useEffect, useLayoutEffect, useRef } from 'react'
import type { WfNodeExecutionState, WfExecutionStatus } from '../../types'
interface SSECallbacks {
onNodeStarted: (nodeId: string, state: WfNodeExecutionState) => void
onNodeCompleted: (nodeId: string, state: WfNodeExecutionState) => void
onNodeFailed: (nodeId: string, state: WfNodeExecutionState) => void
onNodeSkipped: (nodeId: string, state: WfNodeExecutionState) => void
onExecutionCompleted: (status: WfExecutionStatus, result?: Record<string, unknown>) => void
}
export function useExecutionSSE(
executionId: string | null,
callbacks: SSECallbacks,
) {
const callbacksRef = useRef(callbacks)
useLayoutEffect(() => { callbacksRef.current = callbacks })
useEffect(() => {
if (!executionId) return
const url = `/api/workflows/executions/${executionId}/stream`
let eventSource: EventSource | null = null
try {
eventSource = new EventSource(url)
eventSource.addEventListener('node_started', (event: MessageEvent) => {
const data = JSON.parse(event.data) as WfNodeExecutionState
callbacksRef.current.onNodeStarted(data.node_id, data)
})
eventSource.addEventListener('node_completed', (event: MessageEvent) => {
const data = JSON.parse(event.data) as WfNodeExecutionState
callbacksRef.current.onNodeCompleted(data.node_id, data)
})
eventSource.addEventListener('node_failed', (event: MessageEvent) => {
const data = JSON.parse(event.data) as WfNodeExecutionState
callbacksRef.current.onNodeFailed(data.node_id, data)
})
eventSource.addEventListener('node_skipped', (event: MessageEvent) => {
const data = JSON.parse(event.data) as WfNodeExecutionState
callbacksRef.current.onNodeSkipped(data.node_id, data)
})
eventSource.addEventListener('execution_completed', (event: MessageEvent) => {
const data = JSON.parse(event.data) as {
status: WfExecutionStatus
total_cost_usd?: number
duration_ms?: number
}
callbacksRef.current.onExecutionCompleted(data.status, data)
eventSource?.close()
})
eventSource.onerror = () => {
// SSE connection lost — don't auto-reconnect for now
eventSource?.close()
}
} catch {
// EventSource not supported or URL invalid
}
return () => {
eventSource?.close()
}
}, [executionId])
}

View File

@@ -3,7 +3,7 @@ import {
ArrowLeftRight, Repeat, Merge, Timer, Globe, Code2, ShieldCheck, Bell,
} from 'lucide-react'
import type { ElementType } from 'react'
import type { WfNodeType, WfNode, WfNodeFamily } from '../types'
import type { WfNodeType, WfNode, WfNodeFamily, WfNodeExecutionState } from '../types'
import { getNodeFamily } from '../types'
export const NODE_W = 240
@@ -103,4 +103,5 @@ export type WfNodeData = WfNode & {
selected: boolean
onDelete?: (id: string) => void
validationBadge?: NodeValidationBadge
execState?: WfNodeExecutionState
}

View File

@@ -1,5 +1,8 @@
import { create } from 'zustand'
import type { WfDefinition, WfNode, WfEdge, WfNodeType, WfProvider, WfTrigger } from '../types'
import type {
WfDefinition, WfNode, WfEdge, WfNodeType, WfProvider, WfTrigger,
WfExecutionState, WfNodeExecutionState, WfExecutionStatus,
} from '../types'
import { buildDefaultNode, findInsertionAnchor } from '../components/node-registry'
import { validateConnection } from '../components/graph-validation'
@@ -83,6 +86,29 @@ interface WorkflowEditorState {
/** Update left panel pinned/hovered */
setLeftPanelPinned: (pinned: boolean) => void
setLeftPanelHovered: (hovered: boolean) => void
/* ── Execution state ───────────────────────────────────────────────── */
/** Current execution state (null when not running) */
executionState: WfExecutionState | null
/** Whether the execution overlay is visible */
executionOverlayVisible: boolean
/** Start a new execution (set state to running) */
startExecution: (executionId: string, workflowId: string) => void
/** Update per-node execution state (from SSE events) */
updateNodeExecution: (nodeId: string, state: WfNodeExecutionState) => void
/** Complete the execution (from SSE event) */
completeExecution: (status: WfExecutionStatus, result?: Record<string, unknown>) => void
/** Clear execution state */
clearExecution: () => void
/** Toggle execution overlay visibility */
setExecutionOverlayVisible: (visible: boolean) => void
}
/* ────────────────────────────────────────────────────────────────────────────
@@ -101,6 +127,8 @@ export function createWorkflowEditorStore(initialDefinition: WfDefinition) {
rightPanelCollapsed: false,
leftPanelPinned: true,
leftPanelHovered: false,
executionState: null,
executionOverlayVisible: false,
/* ── Definition replacement ──────────────────────────────────────── */
@@ -362,6 +390,77 @@ export function createWorkflowEditorStore(initialDefinition: WfDefinition) {
get().pasteClipboard()
},
/* ── Execution state ────────────────────────────────────────────── */
startExecution: (executionId, workflowId) => {
const { definition } = get()
const nodeStates: Record<string, WfNodeExecutionState> = {}
for (const node of definition.nodes) {
nodeStates[node.id] = {
node_id: node.id,
node_type: node.type,
status: 'pending',
}
}
set({
executionState: {
execution_id: executionId,
workflow_id: workflowId,
status: 'running',
node_states: nodeStates,
total_cost_usd: 0,
duration_ms: 0,
completed_nodes: 0,
total_nodes: definition.nodes.length,
started_at: new Date().toISOString(),
},
executionOverlayVisible: true,
})
},
updateNodeExecution: (nodeId, nodeState) => {
const { executionState } = get()
if (!executionState) return
const updatedNodeStates = {
...executionState.node_states,
[nodeId]: nodeState,
}
const completedNodes = Object.values(updatedNodeStates).filter(
s => s.status === 'completed' || s.status === 'skipped',
).length
const totalCost = Object.values(updatedNodeStates).reduce(
(sum, s) => sum + (s.cost_usd ?? 0), 0,
)
set({
executionState: {
...executionState,
node_states: updatedNodeStates,
completed_nodes: completedNodes,
total_cost_usd: totalCost,
},
})
},
completeExecution: (status, result) => {
const { executionState } = get()
if (!executionState) return
set({
executionState: {
...executionState,
status,
duration_ms: (result?.duration_ms as number) ?? executionState.duration_ms,
total_cost_usd: (result?.total_cost_usd as number) ?? executionState.total_cost_usd,
},
})
},
clearExecution: () => set({
executionState: null,
executionOverlayVisible: false,
}),
setExecutionOverlayVisible: (visible) => set({ executionOverlayVisible: visible }),
/* ── Panel state ─────────────────────────────────────────────────── */
setRightPanelCollapsed: (collapsed) => set({ rightPanelCollapsed: collapsed }),

View File

@@ -207,6 +207,59 @@ export type WfDefinition = {
edges: WfEdge[]
}
/* ── Execution state types ──────────────────────────────────────────── */
export type WfNodeExecutionStatus =
| 'pending'
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'skipped'
| 'waiting'
export type WfExecutionStatus =
| 'running'
| 'completed'
| 'failed'
| 'cancelled'
| 'paused'
export type WfNodeExecutionState = {
node_id: string
node_type: string
status: WfNodeExecutionStatus
output?: Record<string, unknown>
error?: string
cost_usd?: number
duration_ms?: number
attempt?: number
active_handles?: string[]
}
export type WfExecutionState = {
execution_id: string
workflow_id: string
status: WfExecutionStatus
node_states: Record<string, WfNodeExecutionState>
total_cost_usd: number
duration_ms: number
completed_nodes: number
total_nodes: number
started_at?: string
}
export type WfExecutionSSEEvent = {
type:
| 'execution_started'
| 'execution_completed'
| 'node_started'
| 'node_completed'
| 'node_failed'
| 'node_skipped'
data: Record<string, unknown>
}
export function getNodeFamily(type: WfNodeType): WfNodeFamily {
if (type === 'start' || type === 'end') return 'system'
if (type === 'text_input' || type === 'file_input' || type === 'image_input') return 'input'