ruby_reactor 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -2
- data/README.md +72 -3
- data/Rakefile +27 -2
- data/documentation/images/failed_order_processing.png +0 -0
- data/documentation/images/payment_workflow.png +0 -0
- data/documentation/interrupts.md +161 -0
- data/gui/.gitignore +24 -0
- data/gui/README.md +73 -0
- data/gui/eslint.config.js +23 -0
- data/gui/index.html +13 -0
- data/gui/package-lock.json +5925 -0
- data/gui/package.json +46 -0
- data/gui/postcss.config.js +6 -0
- data/gui/public/vite.svg +1 -0
- data/gui/src/App.css +42 -0
- data/gui/src/App.tsx +51 -0
- data/gui/src/assets/react.svg +1 -0
- data/gui/src/components/DagVisualizer.tsx +424 -0
- data/gui/src/components/Dashboard.tsx +163 -0
- data/gui/src/components/ErrorBoundary.tsx +47 -0
- data/gui/src/components/ReactorDetail.tsx +135 -0
- data/gui/src/components/StepInspector.tsx +492 -0
- data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
- data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
- data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
- data/gui/src/globals.d.ts +7 -0
- data/gui/src/index.css +14 -0
- data/gui/src/lib/utils.ts +13 -0
- data/gui/src/main.tsx +14 -0
- data/gui/src/test/setup.ts +11 -0
- data/gui/tailwind.config.js +11 -0
- data/gui/tsconfig.app.json +28 -0
- data/gui/tsconfig.json +7 -0
- data/gui/tsconfig.node.json +26 -0
- data/gui/vite.config.ts +8 -0
- data/gui/vitest.config.ts +13 -0
- data/lib/ruby_reactor/async_router.rb +6 -2
- data/lib/ruby_reactor/context.rb +35 -9
- data/lib/ruby_reactor/dependency_graph.rb +2 -0
- data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
- data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
- data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
- data/lib/ruby_reactor/dsl/map_builder.rb +8 -0
- data/lib/ruby_reactor/dsl/reactor.rb +12 -0
- data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
- data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
- data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
- data/lib/ruby_reactor/executor/result_handler.rb +117 -39
- data/lib/ruby_reactor/executor/retry_manager.rb +1 -0
- data/lib/ruby_reactor/executor/step_executor.rb +38 -4
- data/lib/ruby_reactor/executor.rb +86 -13
- data/lib/ruby_reactor/interrupt_result.rb +20 -0
- data/lib/ruby_reactor/map/collector.rb +0 -2
- data/lib/ruby_reactor/map/element_executor.rb +3 -0
- data/lib/ruby_reactor/map/execution.rb +28 -1
- data/lib/ruby_reactor/map/helpers.rb +44 -6
- data/lib/ruby_reactor/reactor.rb +187 -1
- data/lib/ruby_reactor/registry.rb +25 -0
- data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
- data/lib/ruby_reactor/step/compose_step.rb +22 -6
- data/lib/ruby_reactor/step/map_step.rb +30 -3
- data/lib/ruby_reactor/storage/adapter.rb +32 -0
- data/lib/ruby_reactor/storage/redis_adapter.rb +154 -11
- data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
- data/lib/ruby_reactor/version.rb +1 -1
- data/lib/ruby_reactor/web/api.rb +206 -0
- data/lib/ruby_reactor/web/application.rb +53 -0
- data/lib/ruby_reactor/web/config.ru +5 -0
- data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
- data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
- data/lib/ruby_reactor/web/public/index.html +14 -0
- data/lib/ruby_reactor/web/public/vite.svg +1 -0
- data/lib/ruby_reactor.rb +94 -28
- data/llms-full.txt +66 -0
- data/llms.txt +7 -0
- metadata +63 -2
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { Terminal, Box, ArrowRight, ArrowRightCircle, AlertCircle, RotateCcw, History, ChevronLeft, CheckCircle, ChevronDown, ChevronUp, Play } from 'lucide-react';
|
|
3
|
+
import { apiUrl } from '../lib/utils';
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
interface UndoStackItem {
|
|
8
|
+
step_name: string;
|
|
9
|
+
arguments: any;
|
|
10
|
+
result: any;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface StepInspectorProps {
|
|
14
|
+
stepName: string | null;
|
|
15
|
+
structure: Record<string, any>;
|
|
16
|
+
results: Record<string, any>;
|
|
17
|
+
trace: any[];
|
|
18
|
+
inputs: Record<string, any>;
|
|
19
|
+
error?: any;
|
|
20
|
+
undoStack?: UndoStackItem[];
|
|
21
|
+
stepAttempts?: Record<string, number>;
|
|
22
|
+
composedContexts?: Record<string, any>;
|
|
23
|
+
onClose?: () => void;
|
|
24
|
+
reactorId?: string;
|
|
25
|
+
reactorStatus?: string;
|
|
26
|
+
onAction?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export default function StepInspector({
|
|
30
|
+
stepName,
|
|
31
|
+
structure,
|
|
32
|
+
results,
|
|
33
|
+
trace,
|
|
34
|
+
error,
|
|
35
|
+
undoStack = [],
|
|
36
|
+
stepAttempts = {},
|
|
37
|
+
composedContexts = {},
|
|
38
|
+
onClose,
|
|
39
|
+
reactorId,
|
|
40
|
+
reactorStatus,
|
|
41
|
+
onAction
|
|
42
|
+
}: StepInspectorProps) {
|
|
43
|
+
const [showFullBacktrace, setShowFullBacktrace] = useState(false);
|
|
44
|
+
const [resumePayload, setResumePayload] = useState('');
|
|
45
|
+
const [resumeError, setResumeError] = useState<string | null>(null);
|
|
46
|
+
const [isResuming, setIsResuming] = useState(false);
|
|
47
|
+
|
|
48
|
+
const handleResume = async () => {
|
|
49
|
+
if (!reactorId || !stepName) return;
|
|
50
|
+
|
|
51
|
+
setIsResuming(true);
|
|
52
|
+
setResumeError(null);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
// Validate JSON
|
|
56
|
+
let payload = {};
|
|
57
|
+
if (resumePayload.trim()) {
|
|
58
|
+
try {
|
|
59
|
+
payload = JSON.parse(resumePayload);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
setResumeError("Invalid JSON payload");
|
|
62
|
+
setIsResuming(false);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const response = await fetch(apiUrl(`/api/reactors/${reactorId}/continue`), {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({
|
|
71
|
+
payload: payload,
|
|
72
|
+
step_name: stepName.split('.').pop()
|
|
73
|
+
})
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const data = await response.json();
|
|
77
|
+
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
setResumeError(data.error || "Failed to resume");
|
|
80
|
+
} else {
|
|
81
|
+
setResumePayload('');
|
|
82
|
+
if (onAction) onAction();
|
|
83
|
+
if (onClose) onClose();
|
|
84
|
+
}
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error("Failed to resume", e);
|
|
87
|
+
setResumeError("An unexpected error occurred");
|
|
88
|
+
} finally {
|
|
89
|
+
setIsResuming(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Resolve recursive data based on path
|
|
94
|
+
const resolvedData = useMemo(() => {
|
|
95
|
+
if (!stepName) return null;
|
|
96
|
+
|
|
97
|
+
const parts = stepName.split('.');
|
|
98
|
+
|
|
99
|
+
const resolve = (pathParts: string[], struct: any, context: any): any => {
|
|
100
|
+
const part = pathParts[0];
|
|
101
|
+
const isLast = pathParts.length === 1;
|
|
102
|
+
const stepConfig = struct?.[part];
|
|
103
|
+
|
|
104
|
+
if (isLast) {
|
|
105
|
+
const results = context?.intermediate_results || {};
|
|
106
|
+
const trace = context?.execution_trace || [];
|
|
107
|
+
const attempts = context?.retry_context?.step_attempts || {};
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
stepConfig,
|
|
111
|
+
result: results[part],
|
|
112
|
+
attempts: attempts[part] || 0,
|
|
113
|
+
trace: trace.filter((t: any) => t.step === part),
|
|
114
|
+
context: context
|
|
115
|
+
};
|
|
116
|
+
} else {
|
|
117
|
+
const nestedData = context?.composed_contexts?.[part];
|
|
118
|
+
const nestedContext = nestedData?.context?.value || nestedData?.context;
|
|
119
|
+
|
|
120
|
+
return resolve(
|
|
121
|
+
pathParts.slice(1),
|
|
122
|
+
stepConfig?.nested_structure,
|
|
123
|
+
nestedContext
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// Initial context mock for root
|
|
129
|
+
const rootContext = {
|
|
130
|
+
composed_contexts: composedContexts,
|
|
131
|
+
failure_reason: error,
|
|
132
|
+
intermediate_results: results,
|
|
133
|
+
execution_trace: trace,
|
|
134
|
+
retry_context: { step_attempts: stepAttempts }
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
return resolve(parts, structure, rootContext);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error("Error resolving step data:", e);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}, [stepName, structure, results, trace, stepAttempts, composedContexts, error]);
|
|
144
|
+
|
|
145
|
+
const stepConfig = resolvedData?.stepConfig;
|
|
146
|
+
const result = resolvedData?.result;
|
|
147
|
+
const isFailedStep = (resolvedData?.context?.failure_reason?.step_name === stepName?.split('.').pop());
|
|
148
|
+
const attempts = resolvedData?.attempts || 0;
|
|
149
|
+
const retries = attempts > 1 ? attempts - 1 : 0;
|
|
150
|
+
|
|
151
|
+
// Find relevant trace events
|
|
152
|
+
const stepEvents = resolvedData?.trace || [];
|
|
153
|
+
|
|
154
|
+
const lastEvent = stepEvents[stepEvents.length - 1];
|
|
155
|
+
const stepArgs = lastEvent?.arguments || (isFailedStep ? resolvedData?.context?.failure_reason?.step_arguments : {});
|
|
156
|
+
|
|
157
|
+
// Calculate combined undo history (executed + pending) recursively
|
|
158
|
+
const groupedUndoHistory = useMemo(() => {
|
|
159
|
+
interface UndoItem {
|
|
160
|
+
step_name: string;
|
|
161
|
+
result: any;
|
|
162
|
+
status: 'executed' | 'pending';
|
|
163
|
+
timestamp: string | null;
|
|
164
|
+
type: 'undo' | 'compensate';
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface ReactorUndoGroup {
|
|
168
|
+
reactorName: string;
|
|
169
|
+
items: UndoItem[];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const groups: ReactorUndoGroup[] = [];
|
|
173
|
+
|
|
174
|
+
const collectUndos = (currentTrace: any[], currentUndoStack: any[], currentComposedContexts: any, reactorName: string) => {
|
|
175
|
+
const executedUndos: UndoItem[] = currentTrace
|
|
176
|
+
.filter(e => e.type === 'undo' || e.type === 'compensate')
|
|
177
|
+
.map(e => ({
|
|
178
|
+
step_name: e.step,
|
|
179
|
+
result: e.result,
|
|
180
|
+
status: 'executed' as const,
|
|
181
|
+
timestamp: e.timestamp?._type === 'Time' ? e.timestamp.value : null,
|
|
182
|
+
type: e.type as 'undo' | 'compensate'
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
const pendingUndos: UndoItem[] = (currentUndoStack || []).map(item => ({
|
|
186
|
+
step_name: item.step_name,
|
|
187
|
+
result: null,
|
|
188
|
+
status: 'pending' as const,
|
|
189
|
+
timestamp: null,
|
|
190
|
+
type: 'undo' as const
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
const items = [...executedUndos, ...pendingUndos.reverse()];
|
|
194
|
+
if (items.length > 0) {
|
|
195
|
+
groups.push({ reactorName, items });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check for undos in composed contexts
|
|
199
|
+
Object.entries(currentComposedContexts || {}).forEach(([name, data]: [string, any]) => {
|
|
200
|
+
const nestedContext = data?.context?.value || data?.context;
|
|
201
|
+
if (nestedContext) {
|
|
202
|
+
collectUndos(
|
|
203
|
+
nestedContext.execution_trace || [],
|
|
204
|
+
nestedContext.undo_stack || [],
|
|
205
|
+
nestedContext.composed_contexts || {},
|
|
206
|
+
name
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
collectUndos(trace, undoStack, composedContexts, 'Root Reactor');
|
|
213
|
+
return groups;
|
|
214
|
+
}, [trace, undoStack, composedContexts]);
|
|
215
|
+
|
|
216
|
+
if (!stepName) {
|
|
217
|
+
return (
|
|
218
|
+
<div className="h-full flex flex-col bg-slate-900/50 backdrop-blur-sm rounded-xl border border-slate-800 overflow-hidden">
|
|
219
|
+
<div className="px-6 py-4 border-b border-slate-800 bg-slate-900/80">
|
|
220
|
+
<div className="flex items-center gap-3">
|
|
221
|
+
<div className="p-2 bg-slate-800 rounded-lg">
|
|
222
|
+
<History className="w-5 h-5 text-slate-400" />
|
|
223
|
+
</div>
|
|
224
|
+
<div>
|
|
225
|
+
<h2 className="font-bold text-white text-lg">Execution Overview</h2>
|
|
226
|
+
<div className="text-xs text-slate-500 font-mono mt-0.5">Global State</div>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
|
232
|
+
{groupedUndoHistory.length > 0 ? (
|
|
233
|
+
<div>
|
|
234
|
+
<h3 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
|
235
|
+
<RotateCcw className="w-4 h-4" />
|
|
236
|
+
Compensation History
|
|
237
|
+
</h3>
|
|
238
|
+
<div className="space-y-6">
|
|
239
|
+
{groupedUndoHistory.map((group, groupIdx) => (
|
|
240
|
+
<div key={groupIdx} className="space-y-3">
|
|
241
|
+
<div className="flex items-center gap-2 px-2">
|
|
242
|
+
<div className="h-px flex-1 bg-slate-800"></div>
|
|
243
|
+
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500 bg-slate-900/50 px-2 py-0.5 rounded border border-slate-800">
|
|
244
|
+
{group.reactorName}
|
|
245
|
+
</span>
|
|
246
|
+
<div className="h-px flex-1 bg-slate-800"></div>
|
|
247
|
+
</div>
|
|
248
|
+
<div className="space-y-2">
|
|
249
|
+
{group.items.map((item, idx) => (
|
|
250
|
+
<div key={idx} className={`rounded-lg p-3 border flex items-start gap-3 ${item.status === 'executed'
|
|
251
|
+
? 'bg-slate-950/50 border-slate-800'
|
|
252
|
+
: 'bg-slate-900/30 border-slate-800/50 border-dashed opacity-75'
|
|
253
|
+
}`}>
|
|
254
|
+
<div className={`p-1.5 rounded mt-0.5 ${item.status === 'executed'
|
|
255
|
+
? 'bg-emerald-500/10 text-emerald-400'
|
|
256
|
+
: 'bg-slate-700/50 text-slate-500'
|
|
257
|
+
}`}>
|
|
258
|
+
{item.status === 'executed' ? <CheckCircle className="w-3 h-3" /> : <Box className="w-3 h-3" />}
|
|
259
|
+
</div>
|
|
260
|
+
<div className="min-w-0 flex-1">
|
|
261
|
+
<div className="flex items-center justify-between gap-2">
|
|
262
|
+
<span className={`font-medium text-sm ${item.status === 'executed' ? 'text-slate-300' : 'text-slate-500'
|
|
263
|
+
}`}>
|
|
264
|
+
{item.step_name}
|
|
265
|
+
</span>
|
|
266
|
+
<span className="text-[10px] uppercase font-mono tracking-wider px-1.5 py-0.5 rounded bg-slate-800 text-slate-500 flex items-center gap-1.5">
|
|
267
|
+
{item.type && (
|
|
268
|
+
<span className={`font-bold ${item.type === 'compensate' ? 'text-amber-400' : 'text-indigo-400'}`}>
|
|
269
|
+
{item.type}
|
|
270
|
+
</span>
|
|
271
|
+
)}
|
|
272
|
+
<span className="opacity-50">|</span>
|
|
273
|
+
{item.status}
|
|
274
|
+
</span>
|
|
275
|
+
</div>
|
|
276
|
+
{item.status === 'executed' && item.result && (
|
|
277
|
+
<div className="mt-2 bg-black/30 rounded border border-white/5 p-2 font-mono text-xs text-slate-400 overflow-x-auto">
|
|
278
|
+
{JSON.stringify(item.result, null, 2)}
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
))}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
))}
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
) : (
|
|
290
|
+
<div className="h-40 flex flex-col items-center justify-center text-slate-500 border border-slate-800/50 border-dashed rounded-xl bg-slate-900/30">
|
|
291
|
+
<RotateCcw className="w-6 h-6 mb-2 opacity-50" />
|
|
292
|
+
<p className="text-center font-medium text-sm">Undo History Empty</p>
|
|
293
|
+
<p className="text-center text-xs mt-1 text-slate-600">No compensations executed or pending.</p>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div className="h-full flex flex-col bg-slate-900/50 backdrop-blur-sm rounded-xl border border-slate-800 overflow-hidden">
|
|
303
|
+
<div className="px-6 py-4 border-b border-slate-800 bg-slate-900/80">
|
|
304
|
+
<div className="flex items-center justify-between">
|
|
305
|
+
<div className="flex items-center gap-3">
|
|
306
|
+
{onClose && (
|
|
307
|
+
<button
|
|
308
|
+
onClick={onClose}
|
|
309
|
+
className="p-1 hover:bg-white/10 rounded-full text-slate-400 hover:text-white transition-colors"
|
|
310
|
+
>
|
|
311
|
+
<ChevronLeft className="w-5 h-5" />
|
|
312
|
+
</button>
|
|
313
|
+
)}
|
|
314
|
+
<div className="p-2 bg-indigo-500/10 rounded-lg">
|
|
315
|
+
<Box className="w-5 h-5 text-indigo-400" />
|
|
316
|
+
</div>
|
|
317
|
+
<div>
|
|
318
|
+
<h2 className="font-bold text-white text-lg">{stepName?.split('.').pop()}</h2>
|
|
319
|
+
<div className="flex items-center gap-2 text-xs text-slate-500 font-mono mt-0.5">
|
|
320
|
+
<span className="uppercase tracking-wider text-indigo-400">{stepConfig?.type || 'UNKNOWN'}</span>
|
|
321
|
+
{stepConfig?.async && <span className="bg-slate-800 px-1.5 py-0.5 rounded text-slate-400">ASYNC</span>}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
{retries > 0 && (
|
|
326
|
+
<div className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-500/10 border border-amber-500/20 rounded-md">
|
|
327
|
+
<span className="text-xs font-medium text-amber-500">Retries: {retries}</span>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<div className="flex-1 overflow-y-auto p-6 space-y-8">
|
|
334
|
+
|
|
335
|
+
{/* Resume Action */}
|
|
336
|
+
{reactorStatus === 'paused' && stepConfig?.type === 'interrupt' && (
|
|
337
|
+
<div className="bg-emerald-500/5 rounded-lg border border-emerald-500/20 p-4">
|
|
338
|
+
<h3 className="text-sm font-medium text-emerald-400 mb-2 flex items-center gap-2">
|
|
339
|
+
<Play className="w-4 h-4" />
|
|
340
|
+
Resume Execution
|
|
341
|
+
</h3>
|
|
342
|
+
<p className="text-xs text-slate-400 mb-3">
|
|
343
|
+
This step is an interrupt point. You can provide a payload and resume execution from here.
|
|
344
|
+
</p>
|
|
345
|
+
|
|
346
|
+
<div className="space-y-3">
|
|
347
|
+
<div>
|
|
348
|
+
<label className="text-xs font-mono text-slate-500 mb-1.5 block">Payload (JSON)</label>
|
|
349
|
+
<textarea
|
|
350
|
+
value={resumePayload}
|
|
351
|
+
onChange={(e) => setResumePayload(e.target.value)}
|
|
352
|
+
placeholder='{"key": "value"}'
|
|
353
|
+
className="w-full h-24 bg-slate-950 border border-slate-800 rounded-lg p-3 text-sm font-mono text-slate-200 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 font-mono placeholder-slate-700"
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
{resumeError && (
|
|
358
|
+
<div className="text-xs text-red-400 flex items-center gap-1.5 bg-red-500/10 p-2 rounded border border-red-500/20">
|
|
359
|
+
<AlertCircle className="w-3 h-3" />
|
|
360
|
+
{resumeError}
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
363
|
+
|
|
364
|
+
<button
|
|
365
|
+
onClick={handleResume}
|
|
366
|
+
disabled={isResuming}
|
|
367
|
+
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium shadow-lg shadow-emerald-500/20 transition-all hover:-translate-y-0.5 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
368
|
+
>
|
|
369
|
+
{isResuming ? 'Resuming...' : 'Resume Reactor'}
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{/* Error Section */}
|
|
376
|
+
{isFailedStep && resolvedData?.context?.failure_reason && (
|
|
377
|
+
<div>
|
|
378
|
+
<h3 className="text-sm font-medium text-red-500 mb-3 flex items-center gap-2">
|
|
379
|
+
<AlertCircle className="w-4 h-4" />
|
|
380
|
+
Failure Details
|
|
381
|
+
</h3>
|
|
382
|
+
<div className="bg-red-500/10 rounded-lg p-4 font-mono text-xs border border-red-500/20 text-red-300 overflow-x-auto space-y-2">
|
|
383
|
+
<div className="flex flex-col gap-1">
|
|
384
|
+
{resolvedData.context.failure_reason.exception_class && (
|
|
385
|
+
<span className="text-[10px] uppercase font-bold tracking-wider text-red-400 opacity-70">
|
|
386
|
+
{resolvedData.context.failure_reason.exception_class}
|
|
387
|
+
</span>
|
|
388
|
+
)}
|
|
389
|
+
<div className="font-bold text-sm leading-relaxed">{resolvedData.context.failure_reason.message}</div>
|
|
390
|
+
</div>
|
|
391
|
+
|
|
392
|
+
{resolvedData.context.failure_reason.validation_errors && (
|
|
393
|
+
<div className="pt-3 mt-3 border-t border-red-500/10">
|
|
394
|
+
<span className="text-[10px] uppercase font-bold tracking-widest text-red-400/50 mb-2 block">Validation Errors</span>
|
|
395
|
+
<div className="space-y-2 bg-red-950/20 rounded p-2">
|
|
396
|
+
{Object.entries(resolvedData.context.failure_reason.validation_errors).map(([field, messages]: [string, any]) => (
|
|
397
|
+
<div key={field} className="flex flex-col">
|
|
398
|
+
<span className="font-bold text-red-400 text-xs">{field}:</span>
|
|
399
|
+
<div className="pl-2">
|
|
400
|
+
{Array.isArray(messages) ? (
|
|
401
|
+
messages.map((msg: string, i: number) => (
|
|
402
|
+
<div key={i} className="text-red-300/90">- {msg}</div>
|
|
403
|
+
))
|
|
404
|
+
) : (
|
|
405
|
+
<div className="text-red-300/90">- {String(messages)}</div>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
))}
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
|
|
414
|
+
{resolvedData.context.failure_reason.backtrace && (
|
|
415
|
+
<div className="pt-3 mt-3 border-t border-red-500/10">
|
|
416
|
+
<div className="flex items-center justify-between mb-2">
|
|
417
|
+
<span className="text-[10px] uppercase font-bold tracking-widest text-red-400/50">Stack Trace</span>
|
|
418
|
+
<button
|
|
419
|
+
onClick={() => setShowFullBacktrace(!showFullBacktrace)}
|
|
420
|
+
className="flex items-center gap-1 text-[10px] font-bold text-red-400/70 hover:text-red-400 transition-colors uppercase tracking-wider"
|
|
421
|
+
>
|
|
422
|
+
{showFullBacktrace ? (
|
|
423
|
+
<><ChevronUp className="w-3 h-3" /> Show Less</>
|
|
424
|
+
) : (
|
|
425
|
+
<><ChevronDown className="w-3 h-3" /> Show More ({resolvedData.context.failure_reason.backtrace.length} lines)</>
|
|
426
|
+
)}
|
|
427
|
+
</button>
|
|
428
|
+
</div>
|
|
429
|
+
<div className="text-red-400/70 whitespace-pre-wrap leading-relaxed max-h-[300px] overflow-y-auto custom-scrollbar">
|
|
430
|
+
{showFullBacktrace
|
|
431
|
+
? resolvedData.context.failure_reason.backtrace.join('\n')
|
|
432
|
+
: resolvedData.context.failure_reason.backtrace.slice(0, 5).join('\n')
|
|
433
|
+
}
|
|
434
|
+
{!showFullBacktrace && resolvedData.context.failure_reason.backtrace.length > 5 && (
|
|
435
|
+
<div className="mt-1 text-red-400/30 italic">... and {resolvedData.context.failure_reason.backtrace.length - 5} more lines</div>
|
|
436
|
+
)}
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
|
|
444
|
+
{/* Dependencies */}
|
|
445
|
+
{stepConfig?.depends_on?.length > 0 && (
|
|
446
|
+
<div>
|
|
447
|
+
<h3 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
|
448
|
+
<ArrowRightCircle className="w-4 h-4" />
|
|
449
|
+
Dependencies
|
|
450
|
+
</h3>
|
|
451
|
+
<div className="flex flex-wrap gap-2">
|
|
452
|
+
{stepConfig.depends_on.map((dep: string) => (
|
|
453
|
+
<span key={dep} className="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 font-mono">
|
|
454
|
+
{dep}
|
|
455
|
+
</span>
|
|
456
|
+
))}
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
)}
|
|
460
|
+
|
|
461
|
+
{/* Inputs / Arguments */}
|
|
462
|
+
<div>
|
|
463
|
+
<h3 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
|
464
|
+
<ArrowRight className="w-4 h-4" />
|
|
465
|
+
Arguments
|
|
466
|
+
</h3>
|
|
467
|
+
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs border border-slate-800 overflow-x-auto">
|
|
468
|
+
<pre className="text-slate-300">{JSON.stringify(stepArgs, null, 2)}</pre>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
{/* Result */}
|
|
473
|
+
<div>
|
|
474
|
+
<h3 className="text-sm font-medium text-slate-400 mb-3 flex items-center gap-2">
|
|
475
|
+
<Terminal className="w-4 h-4" />
|
|
476
|
+
Result
|
|
477
|
+
</h3>
|
|
478
|
+
{result !== undefined ? (
|
|
479
|
+
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs border border-slate-800 overflow-x-auto">
|
|
480
|
+
<pre className="text-emerald-400">{JSON.stringify(result, null, 2)}</pre>
|
|
481
|
+
</div>
|
|
482
|
+
) : (
|
|
483
|
+
<div className="bg-slate-950/50 rounded-lg p-4 font-mono text-xs border border-slate-800/50 text-slate-600 italic">
|
|
484
|
+
{isFailedStep ? <span className="text-red-400">Step Failed</span> : 'No result (Pending or Failed)'}
|
|
485
|
+
</div>
|
|
486
|
+
)}
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
);
|
|
492
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import DagVisualizer from '../DagVisualizer.tsx';
|
|
4
|
+
|
|
5
|
+
// Mock ReactFlow to inspect nodes
|
|
6
|
+
vi.mock('@xyflow/react', async () => {
|
|
7
|
+
const actual = await vi.importActual('@xyflow/react');
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
ReactFlow: vi.fn(({ nodes }) => (
|
|
11
|
+
<div data-testid="react-flow-mock">
|
|
12
|
+
{nodes.map((n: any) => (
|
|
13
|
+
<div key={n.id} data-testid={`node-${n.id}`} data-status={n.data.status} data-label={n.data.label}>
|
|
14
|
+
{n.id}
|
|
15
|
+
</div>
|
|
16
|
+
))}
|
|
17
|
+
</div>
|
|
18
|
+
)),
|
|
19
|
+
Handle: () => <div />,
|
|
20
|
+
Position: { Top: 'top', Bottom: 'bottom' },
|
|
21
|
+
Background: () => <div />,
|
|
22
|
+
Controls: () => <div />,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('DagVisualizer', () => {
|
|
27
|
+
const mockStructure = {
|
|
28
|
+
step1: { type: 'step', depends_on: [] },
|
|
29
|
+
sub_reactor: {
|
|
30
|
+
type: 'compose',
|
|
31
|
+
depends_on: ['step1'],
|
|
32
|
+
nested_structure: {
|
|
33
|
+
inner_step: { type: 'step', depends_on: [] },
|
|
34
|
+
deep_reactor: {
|
|
35
|
+
type: 'compose',
|
|
36
|
+
nested_structure: {
|
|
37
|
+
deep_step: { type: 'step' }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const mockResults = {
|
|
45
|
+
step1: 'done',
|
|
46
|
+
sub_reactor: 'sub_done'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const mockComposedContexts = {
|
|
50
|
+
sub_reactor: {
|
|
51
|
+
context: {
|
|
52
|
+
value: {
|
|
53
|
+
status: 'completed',
|
|
54
|
+
intermediate_results: {
|
|
55
|
+
inner_step: 'inner_done',
|
|
56
|
+
deep_reactor: 'deep_done'
|
|
57
|
+
},
|
|
58
|
+
composed_contexts: {
|
|
59
|
+
deep_reactor: {
|
|
60
|
+
context: {
|
|
61
|
+
value: {
|
|
62
|
+
status: 'completed',
|
|
63
|
+
intermediate_results: {
|
|
64
|
+
deep_step: 'deep_value'
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
it('generates unique path-based IDs for nested nodes', () => {
|
|
76
|
+
render(
|
|
77
|
+
<DagVisualizer
|
|
78
|
+
structure={mockStructure}
|
|
79
|
+
steps={[]}
|
|
80
|
+
onStepSelect={() => { }}
|
|
81
|
+
selectedStep={null}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Root nodes should have simple IDs
|
|
86
|
+
expect(screen.queryByTestId('node-step1')).toBeInTheDocument();
|
|
87
|
+
expect(screen.queryByTestId('node-sub_reactor')).toBeInTheDocument();
|
|
88
|
+
|
|
89
|
+
// Nested nodes should have path-based IDs
|
|
90
|
+
expect(screen.queryByTestId('node-sub_reactor.inner_step')).toBeInTheDocument();
|
|
91
|
+
expect(screen.queryByTestId('node-sub_reactor.deep_reactor.deep_step')).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('correctly resolves status for deeply nested nodes using composedContexts', () => {
|
|
95
|
+
const { getByTestId } = render(
|
|
96
|
+
<DagVisualizer
|
|
97
|
+
structure={mockStructure}
|
|
98
|
+
steps={[]}
|
|
99
|
+
results={mockResults}
|
|
100
|
+
composedContexts={mockComposedContexts}
|
|
101
|
+
reactorStatus="running"
|
|
102
|
+
onStepSelect={() => { }}
|
|
103
|
+
selectedStep={null}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// root step1 is completed
|
|
108
|
+
expect(getByTestId('node-step1').getAttribute('data-status')).toBe('completed');
|
|
109
|
+
|
|
110
|
+
// inner_step in sub_reactor is completed
|
|
111
|
+
expect(getByTestId('node-sub_reactor.inner_step').getAttribute('data-status')).toBe('completed');
|
|
112
|
+
|
|
113
|
+
// deep_step in deep_reactor is completed
|
|
114
|
+
expect(getByTestId('node-sub_reactor.deep_reactor.deep_step').getAttribute('data-status')).toBe('completed');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('marks unreached steps as cancelled if reactor failed', () => {
|
|
118
|
+
const struct = {
|
|
119
|
+
step1: { type: 'step' },
|
|
120
|
+
step2: { type: 'step', depends_on: ['step1'] }
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const { getByTestId } = render(
|
|
124
|
+
<DagVisualizer
|
|
125
|
+
structure={struct}
|
|
126
|
+
steps={[]}
|
|
127
|
+
results={{}}
|
|
128
|
+
reactorStatus="failed"
|
|
129
|
+
onStepSelect={() => { }}
|
|
130
|
+
selectedStep={null}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
expect(getByTestId('node-step1').getAttribute('data-status')).toBe('cancelled');
|
|
135
|
+
expect(getByTestId('node-step2').getAttribute('data-status')).toBe('cancelled');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
// End of file
|