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,163 @@
1
+ import useSWR from 'swr';
2
+ import { useState } from 'react';
3
+ import { Link } from 'react-router-dom';
4
+ import { Activity, Clock, AlertCircle, CheckCircle2, Search, Filter } from 'lucide-react';
5
+ import { cn, apiUrl } from '../lib/utils';
6
+
7
+ const fetcher = (url: string) => fetch(url).then((res) => res.json());
8
+
9
+ export default function Dashboard() {
10
+ const { data: reactors, error, isLoading } = useSWR(apiUrl('/api/reactors'), fetcher, { refreshInterval: 2000 });
11
+ const [search, setSearch] = useState('');
12
+ const [statusFilter, setStatusFilter] = useState('all');
13
+
14
+ const filteredReactors = reactors?.filter((reactor: any) => {
15
+ const matchesSearch =
16
+ reactor.id.toLowerCase().includes(search.toLowerCase()) ||
17
+ reactor.class.toLowerCase().includes(search.toLowerCase());
18
+
19
+ const matchesStatus = statusFilter === 'all' || reactor.status === statusFilter;
20
+
21
+ return matchesSearch && matchesStatus;
22
+ });
23
+
24
+ if (error) return (
25
+ <div className="p-4 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 flex items-center gap-2">
26
+ <AlertCircle className="w-5 h-5" />
27
+ Failed to load reactors
28
+ </div>
29
+ );
30
+
31
+ return (
32
+ <div className="space-y-6">
33
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
34
+ <div>
35
+ <h1 className="text-2xl font-bold text-white tracking-tight">Reactor Executions</h1>
36
+ <p className="text-slate-400 text-sm mt-1">Monitor and manage your saga orchestrations.</p>
37
+ </div>
38
+
39
+ <div className="flex items-center gap-3">
40
+ <div className="relative">
41
+ <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
42
+ <input
43
+ type="text"
44
+ placeholder="Search ID or Class..."
45
+ value={search}
46
+ onChange={(e) => setSearch(e.target.value)}
47
+ className="bg-slate-900/50 border border-slate-800 text-sm rounded-lg pl-9 pr-4 py-2 text-slate-200 placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 w-full sm:w-64 transition-all"
48
+ />
49
+ </div>
50
+
51
+ <div className="relative">
52
+ <Filter className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none" />
53
+ <select
54
+ value={statusFilter}
55
+ onChange={(e) => setStatusFilter(e.target.value)}
56
+ className="appearance-none bg-slate-900/50 border border-slate-800 text-slate-300 rounded-lg pl-9 pr-8 py-2 hover:bg-slate-800 hover:text-white transition-colors text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/50 cursor-pointer"
57
+ >
58
+ <option value="all">All Status</option>
59
+ <option value="running">Running</option>
60
+ <option value="completed">Completed</option>
61
+ <option value="failed">Failed</option>
62
+ <option value="cancelled">Cancelled</option>
63
+ </select>
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ <div className="bg-slate-900/50 backdrop-blur-sm rounded-xl border border-slate-800 overflow-hidden shadow-xl shadow-black/20">
69
+ {isLoading ? (
70
+ <div className="p-12 flex items-center justify-center text-slate-500 animate-pulse">
71
+ Loading reactor data...
72
+ </div>
73
+ ) : (
74
+ <div className="overflow-x-auto">
75
+ <table className="w-full text-left text-sm">
76
+ <thead className="bg-slate-900/80 border-b border-slate-800 text-slate-400 font-medium pb-4">
77
+ <tr>
78
+ <th className="px-6 py-4 font-medium">Reactor ID</th>
79
+ <th className="px-6 py-4 font-medium">Class Name</th>
80
+ <th className="px-6 py-4 font-medium">Status</th>
81
+ <th className="px-6 py-4 font-medium">Failure</th>
82
+ <th className="px-6 py-4 font-medium">Started</th>
83
+ <th className="px-6 py-4 text-right font-medium">Actions</th>
84
+ </tr>
85
+ </thead>
86
+ <tbody className="divide-y divide-slate-800/50">
87
+ {filteredReactors?.map((reactor: any) => (
88
+ <tr key={reactor.id} className="group hover:bg-slate-800/30 transition-colors">
89
+ <td className="px-6 py-4 font-mono text-slate-500 group-hover:text-indigo-400 transition-colors">
90
+ <Link to={`/reactors/${reactor.id}`} className="block relative">
91
+ <span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1 h-0 group-hover:h-4 bg-indigo-500 rounded-full transition-all duration-300 opacity-0 group-hover:opacity-100"></span>
92
+ {reactor.id.substring(0, 8)}...
93
+ </Link>
94
+ </td>
95
+ <td className="px-6 py-4 text-slate-200 font-medium">{reactor.class}</td>
96
+ <td className="px-6 py-4">
97
+ <StatusBadge status={reactor.status} />
98
+ </td>
99
+ <td className="px-6 py-4 text-slate-400 font-mono text-xs">
100
+ {reactor.status === 'failed' && reactor.failure && (
101
+ <div className="flex flex-col">
102
+ <span className="text-rose-400 font-medium">{reactor.failure.step_name}</span>
103
+ <span className="text-slate-500 text-[10px] leading-tight opacity-70">{reactor.failure.exception_class}</span>
104
+ </div>
105
+ )}
106
+ </td>
107
+ <td className="px-6 py-4 text-slate-500 tabular-nums">
108
+ {new Date(reactor.created_at).toLocaleString()}
109
+ </td>
110
+ <td className="px-6 py-4 text-right">
111
+ <Link
112
+ to={`/reactors/${reactor.id}`}
113
+ className="inline-flex items-center gap-1.5 text-xs font-medium text-slate-500 hover:text-indigo-400 transition-colors px-3 py-1.5 rounded-md hover:bg-indigo-500/10"
114
+ >
115
+ Inspect
116
+ <span className="sr-only">{reactor.id}</span>
117
+ </Link>
118
+ </td>
119
+ </tr>
120
+ ))}
121
+ {filteredReactors?.length === 0 && (
122
+ <tr>
123
+ <td colSpan={6} className="px-6 py-24 text-center">
124
+ <div className="flex flex-col items-center gap-3 text-slate-500">
125
+ <div className="p-4 bg-slate-900 rounded-full border border-slate-800">
126
+ <Activity className="w-6 h-6 text-slate-600" />
127
+ </div>
128
+ <p>No active reactors found matching your filters.</p>
129
+ </div>
130
+ </td>
131
+ </tr>
132
+ )}
133
+ </tbody>
134
+ </table>
135
+ </div>
136
+ )}
137
+ </div>
138
+ </div>
139
+ );
140
+ }
141
+
142
+ function StatusBadge({ status }: { status: string }) {
143
+ const styles = {
144
+ running: "bg-indigo-500/10 text-indigo-400 border-indigo-500/20 shadow-[0_0_10px_rgba(99,102,241,0.15)]",
145
+ completed: "bg-teal-500/10 text-teal-400 border-teal-500/20 shadow-[0_0_10px_rgba(20,184,166,0.15)]",
146
+ failed: "bg-rose-500/10 text-rose-400 border-rose-500/20 shadow-[0_0_10px_rgba(244,63,94,0.15)]",
147
+ cancelled: "bg-slate-800 text-slate-400 border-slate-700",
148
+ }[status] || "bg-slate-800 text-slate-400 border-slate-700";
149
+
150
+ const icons = {
151
+ running: <Activity className="w-3 h-3 animate-pulse" />,
152
+ completed: <CheckCircle2 className="w-3 h-3" />,
153
+ failed: <AlertCircle className="w-3 h-3" />,
154
+ cancelled: <Clock className="w-3 h-3" />,
155
+ }[status];
156
+
157
+ return (
158
+ <span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border backdrop-blur-md transition-all", styles)}>
159
+ {icons}
160
+ <span className="capitalize tracking-wide">{status}</span>
161
+ </span>
162
+ );
163
+ }
@@ -0,0 +1,47 @@
1
+ import { Component } from 'react';
2
+ import type { ErrorInfo, ReactNode } from 'react';
3
+
4
+ interface Props {
5
+ children?: ReactNode;
6
+ }
7
+
8
+ interface State {
9
+ hasError: boolean;
10
+ error: Error | null;
11
+ errorInfo: ErrorInfo | null;
12
+ }
13
+
14
+ export class ErrorBoundary extends Component<Props, State> {
15
+ public state: State = {
16
+ hasError: false,
17
+ error: null,
18
+ errorInfo: null
19
+ };
20
+
21
+ public static getDerivedStateFromError(error: Error): State {
22
+ return { hasError: true, error, errorInfo: null };
23
+ }
24
+
25
+ public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
26
+ console.error("Uncaught error:", error, errorInfo);
27
+ this.setState({ error, errorInfo });
28
+ }
29
+
30
+ public render() {
31
+ if (this.state.hasError) {
32
+ return (
33
+ <div className="p-8 bg-slate-950 min-h-screen text-red-500">
34
+ <h1 className="text-2xl font-bold mb-4">Something went wrong.</h1>
35
+ <pre className="bg-slate-900 p-4 rounded overflow-auto text-sm mb-4">
36
+ {this.state.error && this.state.error.toString()}
37
+ </pre>
38
+ <pre className="bg-slate-900 p-4 rounded overflow-auto text-xs text-slate-500">
39
+ {this.state.errorInfo && this.state.errorInfo.componentStack}
40
+ </pre>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return this.props.children;
46
+ }
47
+ }
@@ -0,0 +1,135 @@
1
+ import useSWR, { mutate } from 'swr';
2
+ import { useParams, Link } from 'react-router-dom';
3
+ import { ChevronLeft, Play, XOctagon, AlertCircle } from 'lucide-react';
4
+ import { useState } from 'react';
5
+ import { apiUrl } from '../lib/utils';
6
+ import DagVisualizer from './DagVisualizer';
7
+ import StepInspector from './StepInspector';
8
+
9
+ const fetcher = (url: string) => fetch(url).then((res) => res.json());
10
+
11
+
12
+ export default function ReactorDetail() {
13
+ const { id } = useParams();
14
+ const { data: reactor, error, isLoading } = useSWR(id ? apiUrl(`/api/reactors/${id}`) : null, fetcher, { refreshInterval: 1000 });
15
+ const [selectedStep, setSelectedStep] = useState<string | null>(null);
16
+
17
+ const handleAction = async (action: 'retry' | 'cancel') => {
18
+ if (!id) return;
19
+ try {
20
+ await fetch(apiUrl(`/api/reactors/${id}/${action}`), { method: 'POST' });
21
+ mutate(apiUrl(`/api/reactors/${id}`));
22
+ } catch (e) {
23
+ console.error(`Failed to ${action}`, e);
24
+ }
25
+ };
26
+
27
+
28
+
29
+ if (error) return <div className="p-4 text-red-500 bg-red-500/10 border border-red-500/20 rounded-lg">Failed to load reactor</div>;
30
+ if (isLoading) return <div className="p-4 text-slate-500 animate-pulse">Loading reactor details...</div>;
31
+
32
+ if (reactor?.status === 'failed') {
33
+ console.log('ReactorDetail: reactor is failed. Error:', reactor.error);
34
+ }
35
+
36
+ return (
37
+ <div className="space-y-6 h-[calc(100vh-8rem)] flex flex-col relative">
38
+ <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800 pb-6 shrink-0">
39
+ <div className="flex items-center gap-4">
40
+ <Link to="/" className="p-2 hover:bg-white/5 rounded-full text-slate-400 hover:text-white transition-colors">
41
+ <ChevronLeft className="w-5 h-5" />
42
+ </Link>
43
+ <div>
44
+ <div className="flex items-center gap-3">
45
+ <h1 className="text-2xl font-bold text-white tracking-tight">{reactor.class}</h1>
46
+ <span className="px-2 py-0.5 rounded text-xs font-mono bg-slate-800 text-slate-400 border border-slate-700">#{id}</span>
47
+ </div>
48
+ <div className="flex items-center gap-2 mt-1.5">
49
+ <span className="text-sm text-slate-400">Status: <span className={`font-medium ${reactor.status === 'failed' ? 'text-red-400' :
50
+ reactor.status === 'completed' ? 'text-emerald-400' :
51
+ reactor.status === 'paused' ? 'text-amber-400' :
52
+ 'text-slate-200'
53
+ }`}>{reactor.status}</span></span>
54
+ {reactor.retry_count > 0 && (
55
+ <span className="text-sm text-slate-400 ml-3 pl-3 border-l border-slate-700">
56
+ Retries: <span className="font-medium text-amber-400">{reactor.retry_count}</span>
57
+ </span>
58
+ )}
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <div className="flex items-center gap-2 ml-auto">
64
+
65
+ <button
66
+ onClick={() => handleAction('retry')}
67
+ className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg shadow-lg shadow-indigo-500/20 text-sm font-medium transition-all hover:-translate-y-0.5 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
68
+ disabled={reactor.status === 'running'}
69
+ >
70
+ <Play className="w-4 h-4" />
71
+ Retry Execution
72
+ </button>
73
+ <button
74
+ onClick={() => handleAction('cancel')}
75
+ className="flex items-center gap-2 px-4 py-2 bg-slate-900 border border-slate-700 text-slate-300 rounded-lg hover:bg-slate-800 hover:text-white text-sm font-medium transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
76
+ disabled={reactor.status !== 'running' && reactor.status !== 'paused'}
77
+ >
78
+ <XOctagon className="w-4 h-4" />
79
+ Cancel
80
+ </button>
81
+ </div>
82
+ </div>
83
+
84
+ {reactor.status === 'failed' && reactor.error && (
85
+ <div className="px-2">
86
+ <div className="px-4 py-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
87
+ <AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />
88
+ <div className="space-y-1 overflow-hidden">
89
+ <h3 className="text-sm font-medium text-red-500">
90
+ Workflow Failed
91
+ {reactor.error.step_name && <span className="text-red-400"> at step <span className="font-mono bg-red-500/10 px-1 rounded">{reactor.error.step_name}</span></span>}
92
+ </h3>
93
+ <p className="text-xs text-red-400/80 font-mono truncate">{reactor.error.message}</p>
94
+ </div>
95
+ </div>
96
+ </div>
97
+ )}
98
+
99
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0">
100
+ <div className="lg:col-span-2 h-full">
101
+ <DagVisualizer
102
+ structure={reactor.structure}
103
+ steps={reactor.steps}
104
+ selectedStep={selectedStep}
105
+ onStepSelect={setSelectedStep}
106
+ reactorStatus={reactor.status}
107
+ error={reactor.error}
108
+ results={reactor.intermediate_results}
109
+ composedContexts={reactor.composed_contexts}
110
+ />
111
+ </div>
112
+
113
+ <div className="h-full">
114
+ <StepInspector
115
+ stepName={selectedStep}
116
+ structure={reactor.structure}
117
+ results={reactor.intermediate_results}
118
+ inputs={reactor.inputs}
119
+ trace={reactor.steps}
120
+ error={reactor.error}
121
+ undoStack={reactor.undo_stack}
122
+ stepAttempts={reactor.step_attempts}
123
+ composedContexts={reactor.composed_contexts}
124
+ onClose={() => setSelectedStep(null)}
125
+ reactorId={id}
126
+ reactorStatus={reactor.status}
127
+ onAction={() => mutate(apiUrl(`/api/reactors/${id}`))}
128
+ />
129
+ </div>
130
+ </div>
131
+
132
+
133
+ </div>
134
+ );
135
+ }