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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -2
  3. data/README.md +72 -3
  4. data/Rakefile +27 -2
  5. data/documentation/images/failed_order_processing.png +0 -0
  6. data/documentation/images/payment_workflow.png +0 -0
  7. data/documentation/interrupts.md +161 -0
  8. data/gui/.gitignore +24 -0
  9. data/gui/README.md +73 -0
  10. data/gui/eslint.config.js +23 -0
  11. data/gui/index.html +13 -0
  12. data/gui/package-lock.json +5925 -0
  13. data/gui/package.json +46 -0
  14. data/gui/postcss.config.js +6 -0
  15. data/gui/public/vite.svg +1 -0
  16. data/gui/src/App.css +42 -0
  17. data/gui/src/App.tsx +51 -0
  18. data/gui/src/assets/react.svg +1 -0
  19. data/gui/src/components/DagVisualizer.tsx +424 -0
  20. data/gui/src/components/Dashboard.tsx +163 -0
  21. data/gui/src/components/ErrorBoundary.tsx +47 -0
  22. data/gui/src/components/ReactorDetail.tsx +135 -0
  23. data/gui/src/components/StepInspector.tsx +492 -0
  24. data/gui/src/components/__tests__/DagVisualizer.test.tsx +140 -0
  25. data/gui/src/components/__tests__/ReactorDetail.test.tsx +111 -0
  26. data/gui/src/components/__tests__/StepInspector.test.tsx +408 -0
  27. data/gui/src/globals.d.ts +7 -0
  28. data/gui/src/index.css +14 -0
  29. data/gui/src/lib/utils.ts +13 -0
  30. data/gui/src/main.tsx +14 -0
  31. data/gui/src/test/setup.ts +11 -0
  32. data/gui/tailwind.config.js +11 -0
  33. data/gui/tsconfig.app.json +28 -0
  34. data/gui/tsconfig.json +7 -0
  35. data/gui/tsconfig.node.json +26 -0
  36. data/gui/vite.config.ts +8 -0
  37. data/gui/vitest.config.ts +13 -0
  38. data/lib/ruby_reactor/async_router.rb +6 -2
  39. data/lib/ruby_reactor/context.rb +35 -9
  40. data/lib/ruby_reactor/dependency_graph.rb +2 -0
  41. data/lib/ruby_reactor/dsl/compose_builder.rb +8 -0
  42. data/lib/ruby_reactor/dsl/interrupt_builder.rb +48 -0
  43. data/lib/ruby_reactor/dsl/interrupt_step_config.rb +21 -0
  44. data/lib/ruby_reactor/dsl/map_builder.rb +8 -0
  45. data/lib/ruby_reactor/dsl/reactor.rb +12 -0
  46. data/lib/ruby_reactor/dsl/step_builder.rb +4 -0
  47. data/lib/ruby_reactor/executor/compensation_manager.rb +60 -27
  48. data/lib/ruby_reactor/executor/graph_manager.rb +2 -0
  49. data/lib/ruby_reactor/executor/result_handler.rb +117 -39
  50. data/lib/ruby_reactor/executor/retry_manager.rb +1 -0
  51. data/lib/ruby_reactor/executor/step_executor.rb +38 -4
  52. data/lib/ruby_reactor/executor.rb +86 -13
  53. data/lib/ruby_reactor/interrupt_result.rb +20 -0
  54. data/lib/ruby_reactor/map/collector.rb +0 -2
  55. data/lib/ruby_reactor/map/element_executor.rb +3 -0
  56. data/lib/ruby_reactor/map/execution.rb +28 -1
  57. data/lib/ruby_reactor/map/helpers.rb +44 -6
  58. data/lib/ruby_reactor/reactor.rb +187 -1
  59. data/lib/ruby_reactor/registry.rb +25 -0
  60. data/lib/ruby_reactor/sidekiq_workers/worker.rb +1 -1
  61. data/lib/ruby_reactor/step/compose_step.rb +22 -6
  62. data/lib/ruby_reactor/step/map_step.rb +30 -3
  63. data/lib/ruby_reactor/storage/adapter.rb +32 -0
  64. data/lib/ruby_reactor/storage/redis_adapter.rb +154 -11
  65. data/lib/ruby_reactor/utils/code_extractor.rb +31 -0
  66. data/lib/ruby_reactor/version.rb +1 -1
  67. data/lib/ruby_reactor/web/api.rb +206 -0
  68. data/lib/ruby_reactor/web/application.rb +53 -0
  69. data/lib/ruby_reactor/web/config.ru +5 -0
  70. data/lib/ruby_reactor/web/public/assets/index-VdeLgH9k.js +19 -0
  71. data/lib/ruby_reactor/web/public/assets/index-_z-6BvuM.css +1 -0
  72. data/lib/ruby_reactor/web/public/index.html +14 -0
  73. data/lib/ruby_reactor/web/public/vite.svg +1 -0
  74. data/lib/ruby_reactor.rb +94 -28
  75. data/llms-full.txt +66 -0
  76. data/llms.txt +7 -0
  77. 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