rooibos 0.5.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 +7 -0
- data/.builds/ruby-3.2.yml +51 -0
- data/.builds/ruby-3.3.yml +51 -0
- data/.builds/ruby-3.4.yml +51 -0
- data/.builds/ruby-4.0.0.yml +51 -0
- data/.pre-commit-config.yaml +16 -0
- data/.rubocop.yml +8 -0
- data/AGENTS.md +108 -0
- data/CHANGELOG.md +214 -0
- data/LICENSE +304 -0
- data/LICENSES/AGPL-3.0-or-later.txt +235 -0
- data/LICENSES/CC-BY-SA-4.0.txt +170 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/LGPL-3.0-or-later.txt +304 -0
- data/LICENSES/MIT-0.txt +16 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +183 -0
- data/REUSE.toml +24 -0
- data/Rakefile +16 -0
- data/Steepfile +13 -0
- data/doc/concepts/application_architecture.md +197 -0
- data/doc/concepts/application_testing.md +49 -0
- data/doc/concepts/async_work.md +164 -0
- data/doc/concepts/commands.md +530 -0
- data/doc/concepts/message_processing.md +51 -0
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
- data/doc/contributors/WIP/implementation_plan.md +409 -0
- data/doc/contributors/WIP/init_callable_proposal.md +344 -0
- data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -0
- data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
- data/doc/contributors/WIP/task.md +36 -0
- data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
- data/doc/contributors/design/commands_and_outlets.md +214 -0
- data/doc/contributors/kit-no-outlet.md +238 -0
- data/doc/contributors/priorities.md +38 -0
- data/doc/custom.css +22 -0
- data/doc/getting_started/quickstart.md +56 -0
- data/doc/images/.gitkeep +0 -0
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_cmd_exec.png +0 -0
- data/doc/index.md +25 -0
- data/examples/app_fractal_dashboard/README.md +60 -0
- data/examples/app_fractal_dashboard/app.rb +63 -0
- data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
- data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
- data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
- data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
- data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
- data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
- data/examples/verify_readme_usage/README.md +54 -0
- data/examples/verify_readme_usage/app.rb +47 -0
- data/examples/widget_command_system/README.md +70 -0
- data/examples/widget_command_system/app.rb +132 -0
- data/exe/.gitkeep +0 -0
- data/lib/rooibos/command/all.rb +69 -0
- data/lib/rooibos/command/batch.rb +77 -0
- data/lib/rooibos/command/custom.rb +104 -0
- data/lib/rooibos/command/http.rb +192 -0
- data/lib/rooibos/command/lifecycle.rb +134 -0
- data/lib/rooibos/command/outlet.rb +157 -0
- data/lib/rooibos/command/wait.rb +80 -0
- data/lib/rooibos/command.rb +546 -0
- data/lib/rooibos/error.rb +55 -0
- data/lib/rooibos/message/all.rb +45 -0
- data/lib/rooibos/message/http_response.rb +61 -0
- data/lib/rooibos/message/system/batch.rb +61 -0
- data/lib/rooibos/message/system/stream.rb +67 -0
- data/lib/rooibos/message/timer.rb +46 -0
- data/lib/rooibos/message.rb +38 -0
- data/lib/rooibos/router.rb +403 -0
- data/lib/rooibos/runtime.rb +396 -0
- data/lib/rooibos/shortcuts.rb +49 -0
- data/lib/rooibos/test_helper.rb +56 -0
- data/lib/rooibos/version.rb +12 -0
- data/lib/rooibos.rb +121 -0
- data/mise.toml +8 -0
- data/rbs_collection.lock.yaml +108 -0
- data/rbs_collection.yaml +15 -0
- data/sig/concurrent.rbs +72 -0
- data/sig/examples/verify_readme_usage/app.rbs +19 -0
- data/sig/examples/widget_command_system/app.rbs +26 -0
- data/sig/open3.rbs +17 -0
- data/sig/rooibos/command.rbs +265 -0
- data/sig/rooibos/error.rbs +13 -0
- data/sig/rooibos/message.rbs +121 -0
- data/sig/rooibos/router.rbs +153 -0
- data/sig/rooibos/runtime.rbs +75 -0
- data/sig/rooibos/shortcuts.rbs +16 -0
- data/sig/rooibos/test_helper.rbs +10 -0
- data/sig/rooibos/version.rbs +8 -0
- data/sig/rooibos.rbs +46 -0
- data/tasks/example_viewer.html.erb +172 -0
- data/tasks/resources/build.yml.erb +53 -0
- data/tasks/resources/index.html.erb +44 -0
- data/tasks/resources/rubies.yml +7 -0
- data/tasks/steep.rake +11 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +241 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
require "concurrent-edge"
|
|
9
|
+
require_relative "command/custom"
|
|
10
|
+
require_relative "command/outlet"
|
|
11
|
+
require_relative "command/lifecycle"
|
|
12
|
+
require_relative "command/wait"
|
|
13
|
+
require_relative "command/batch"
|
|
14
|
+
require_relative "command/all"
|
|
15
|
+
require_relative "command/http"
|
|
16
|
+
|
|
17
|
+
module Rooibos
|
|
18
|
+
# Commands represent side effects.
|
|
19
|
+
#
|
|
20
|
+
# The MVU pattern separates logic from effects. Your update function returns a pure
|
|
21
|
+
# model transformation. Side effects go in commands. The runtime executes them.
|
|
22
|
+
#
|
|
23
|
+
# Commands produce **messages**, not callbacks. The +tag+ argument names the message
|
|
24
|
+
# so your update function can pattern-match on it. This keeps all logic in +update+
|
|
25
|
+
# and ensures messages are Ractor-shareable.
|
|
26
|
+
#
|
|
27
|
+
# === Examples
|
|
28
|
+
#
|
|
29
|
+
# # Terminate the application
|
|
30
|
+
# [model, Command.exit]
|
|
31
|
+
#
|
|
32
|
+
# # Run a shell command; produces [:got_files, {stdout:, stderr:, status:}]
|
|
33
|
+
# [model, Command.system("ls -la", :got_files)]
|
|
34
|
+
#
|
|
35
|
+
# # No side effect
|
|
36
|
+
# [model, nil]
|
|
37
|
+
module Command
|
|
38
|
+
# Sentinel value for application termination.
|
|
39
|
+
#
|
|
40
|
+
# The runtime detects this before dispatching. It breaks the loop immediately.
|
|
41
|
+
class Exit < Data.define
|
|
42
|
+
include Custom
|
|
43
|
+
|
|
44
|
+
# Stub - Exit is a sentinel handled by runtime before dispatch.
|
|
45
|
+
def call(_out, _token)
|
|
46
|
+
raise "Exit command should never be dispatched"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Creates a quit command.
|
|
51
|
+
#
|
|
52
|
+
# Returns a sentinel the runtime detects to terminate the application.
|
|
53
|
+
#
|
|
54
|
+
# === Example
|
|
55
|
+
#
|
|
56
|
+
# def update(message, model)
|
|
57
|
+
# case message
|
|
58
|
+
# in { type: :key, code: "q" }
|
|
59
|
+
# [model, Command.exit]
|
|
60
|
+
# else
|
|
61
|
+
# [model, nil]
|
|
62
|
+
# end
|
|
63
|
+
# end
|
|
64
|
+
def self.exit
|
|
65
|
+
Exit.new
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Creates a fresh cancellation that never fires.
|
|
69
|
+
#
|
|
70
|
+
# Some I/O operations cannot be cancelled mid-execution. Ruby's <tt>Net::HTTP</tt>
|
|
71
|
+
# blocks until completion or timeout — there is no way to interrupt it.
|
|
72
|
+
#
|
|
73
|
+
# A shared singleton would be unsafe. If any code path accidentally resolves
|
|
74
|
+
# the origin, all commands using it become cancelled.
|
|
75
|
+
#
|
|
76
|
+
# Use it for commands that wrap non-cancellable blocking I/O.
|
|
77
|
+
#
|
|
78
|
+
# === Example
|
|
79
|
+
#
|
|
80
|
+
# token = Command.uncancellable
|
|
81
|
+
# HttpCommand.new(url).call(outlet, token)
|
|
82
|
+
def self.uncancellable
|
|
83
|
+
cancellation, _origin = Concurrent::Cancellation.new(Concurrent::Promises.resolvable_event)
|
|
84
|
+
cancellation
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Sentinel value for command cancellation.
|
|
88
|
+
#
|
|
89
|
+
# Long-running commands (WebSocket listeners, database pollers) run until stopped.
|
|
90
|
+
# Stopping them requires signaling from outside the command. The runtime tracks
|
|
91
|
+
# active commands by their object identity and routes cancel requests.
|
|
92
|
+
#
|
|
93
|
+
# This type carries the handle (command object) to cancel. The runtime pattern-matches
|
|
94
|
+
# on <tt>Command::Cancel</tt> and signals the token.
|
|
95
|
+
class Cancel < Data.define(:handle)
|
|
96
|
+
include Custom
|
|
97
|
+
|
|
98
|
+
# Stub - Cancel is a sentinel handled by runtime before dispatch.
|
|
99
|
+
def call(_out, _token)
|
|
100
|
+
raise "Cancel command should never be dispatched"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Request cancellation of a running command.
|
|
105
|
+
#
|
|
106
|
+
# The model stores the command handle (the command object itself). Returning
|
|
107
|
+
# <tt>Command.cancel(handle)</tt> signals the runtime to stop it.
|
|
108
|
+
#
|
|
109
|
+
# [handle] The command object to cancel.
|
|
110
|
+
#
|
|
111
|
+
# === Example
|
|
112
|
+
#
|
|
113
|
+
# # Dispatch and store handle
|
|
114
|
+
# cmd = FetchData.new(url)
|
|
115
|
+
# [model.with(active_fetch: cmd), cmd]
|
|
116
|
+
#
|
|
117
|
+
# # User clicks cancel
|
|
118
|
+
# when :cancel_clicked
|
|
119
|
+
# [model.with(active_fetch: nil), Command.cancel(model.active_fetch)]
|
|
120
|
+
def self.cancel(handle)
|
|
121
|
+
Cancel.new(handle:)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Error message from a failed command.
|
|
125
|
+
#
|
|
126
|
+
# Commands run in background threads. Exceptions bubble up silently.
|
|
127
|
+
# Your update function never sees them. Backtraces in STDERR corrupt the TUI.
|
|
128
|
+
#
|
|
129
|
+
# The runtime catches exceptions and wraps them in Error messages.
|
|
130
|
+
# Pattern match on Error in your update function. Display the error, log it, or recover.
|
|
131
|
+
#
|
|
132
|
+
# Use it to surface failures from HTTP requests, file I/O, or external processes.
|
|
133
|
+
#
|
|
134
|
+
# === Examples
|
|
135
|
+
#
|
|
136
|
+
# Update = ->(message, model) {
|
|
137
|
+
# case message
|
|
138
|
+
# in Command::Error[command:, exception:]
|
|
139
|
+
# # Show error toast
|
|
140
|
+
# [model.with(error: exception.message), nil]
|
|
141
|
+
# in Command::Error[command: Command::Http, exception:]
|
|
142
|
+
# # Retry HTTP request
|
|
143
|
+
# [model, command]
|
|
144
|
+
# in Command::Error
|
|
145
|
+
# # Log and continue
|
|
146
|
+
# warn "Command failed: #{message.exception}"
|
|
147
|
+
# [model, nil]
|
|
148
|
+
# end
|
|
149
|
+
# }
|
|
150
|
+
class Error < Data.define(:command, :exception); end
|
|
151
|
+
|
|
152
|
+
# Creates an error sentinel.
|
|
153
|
+
#
|
|
154
|
+
# The runtime produces this automatically when a command raises.
|
|
155
|
+
# Use this factory for testing or for commands that want to signal
|
|
156
|
+
# error completion without raising.
|
|
157
|
+
#
|
|
158
|
+
# [command] The command that failed.
|
|
159
|
+
# [exception] The exception that was raised.
|
|
160
|
+
#
|
|
161
|
+
# === Example
|
|
162
|
+
#
|
|
163
|
+
# def update(message, model)
|
|
164
|
+
# case message
|
|
165
|
+
# in Command::Error(command:, exception:)
|
|
166
|
+
# model.with(error: "#{command.class} failed: #{exception.message}")
|
|
167
|
+
# end
|
|
168
|
+
# end
|
|
169
|
+
def self.error(command, exception)
|
|
170
|
+
Error.new(command:, exception:)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Runs a shell command and routes its output back as messages.
|
|
174
|
+
#
|
|
175
|
+
# Apps run external tools: linters, compilers, scripts, system utilities.
|
|
176
|
+
# The runtime dispatches the command in a thread, so the UI stays responsive.
|
|
177
|
+
# Batch mode (default) waits for completion; streaming mode shows output live.
|
|
178
|
+
# Orphaned child processes linger and waste resources, so cancellation sends
|
|
179
|
+
# <tt>SIGTERM</tt> for graceful shutdown, then <tt>SIGKILL</tt> to prevent orphans.
|
|
180
|
+
#
|
|
181
|
+
# Use it to run builds, lint files, execute scripts, or invoke any CLI tool.
|
|
182
|
+
#
|
|
183
|
+
# === Batch Mode (default)
|
|
184
|
+
#
|
|
185
|
+
# A single message arrives when the command finishes:
|
|
186
|
+
# <tt>[tag, {stdout:, stderr:, status:}]</tt>
|
|
187
|
+
#
|
|
188
|
+
# === Streaming Mode
|
|
189
|
+
#
|
|
190
|
+
# Messages arrive incrementally:
|
|
191
|
+
# - <tt>[tag, :stdout, line]</tt> for each stdout line
|
|
192
|
+
# - <tt>[tag, :stderr, line]</tt> for each stderr line
|
|
193
|
+
# - <tt>[tag, :complete, {status:}]</tt> when the command finishes
|
|
194
|
+
# - <tt>[tag, :error, {message:}]</tt> if the command cannot start
|
|
195
|
+
#
|
|
196
|
+
# The <tt>status</tt> is the integer exit code (0 = success).
|
|
197
|
+
class System < Data.define(:command, :envelope, :stream)
|
|
198
|
+
include Custom
|
|
199
|
+
|
|
200
|
+
# Returns true if streaming mode is enabled.
|
|
201
|
+
def stream?
|
|
202
|
+
stream
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Executes the shell command and sends results via outlet.
|
|
206
|
+
#
|
|
207
|
+
# In batch mode, sends a single message with all output.
|
|
208
|
+
# In streaming mode, sends incremental messages as output arrives.
|
|
209
|
+
# Respects cancellation token by sending SIGTERM (then SIGKILL) to child.
|
|
210
|
+
def call(out, token)
|
|
211
|
+
require "open3"
|
|
212
|
+
|
|
213
|
+
if stream?
|
|
214
|
+
stream_execution(out, token)
|
|
215
|
+
else
|
|
216
|
+
batch_execution(out)
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private def batch_execution(out)
|
|
221
|
+
stdout, stderr, status = Open3.capture3(command)
|
|
222
|
+
message = Message::System::Batch.new(
|
|
223
|
+
envelope:,
|
|
224
|
+
stdout:,
|
|
225
|
+
stderr:,
|
|
226
|
+
status: status.exitstatus
|
|
227
|
+
)
|
|
228
|
+
out.put(Ractor.make_shareable(message))
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private def stream_execution(out, token)
|
|
232
|
+
Open3.popen3(command) do |stdin, stdout, stderr, wait_thr|
|
|
233
|
+
stdin.close
|
|
234
|
+
pid = wait_thr.pid
|
|
235
|
+
|
|
236
|
+
# Track which streams are still open
|
|
237
|
+
streams = { stdout => :stdout, stderr => :stderr }
|
|
238
|
+
|
|
239
|
+
until streams.empty?
|
|
240
|
+
# Check cancellation before blocking on IO.select
|
|
241
|
+
if token.canceled? && wait_thr.alive?
|
|
242
|
+
begin
|
|
243
|
+
Process.kill("TERM", pid)
|
|
244
|
+
rescue Errno::ESRCH
|
|
245
|
+
# Already dead
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Wait up to 0.05s for any stream to have data
|
|
250
|
+
ready = IO.select(streams.keys, nil, nil, 0.05)
|
|
251
|
+
next unless ready
|
|
252
|
+
|
|
253
|
+
ready[0].each do |io|
|
|
254
|
+
stream_type = streams[io]
|
|
255
|
+
begin
|
|
256
|
+
line = io.read_nonblock(8192, exception: false)
|
|
257
|
+
case line
|
|
258
|
+
when :wait_readable
|
|
259
|
+
next
|
|
260
|
+
when nil, ""
|
|
261
|
+
# EOF - stream closed
|
|
262
|
+
streams.delete(io)
|
|
263
|
+
else
|
|
264
|
+
# Split into lines and send each
|
|
265
|
+
line.each_line do |l|
|
|
266
|
+
msg = Message::System::Stream.new(
|
|
267
|
+
envelope:,
|
|
268
|
+
stream: stream_type,
|
|
269
|
+
content: l.freeze,
|
|
270
|
+
status: nil
|
|
271
|
+
)
|
|
272
|
+
out.put(Ractor.make_shareable(msg))
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
rescue EOFError
|
|
276
|
+
streams.delete(io)
|
|
277
|
+
rescue IOError
|
|
278
|
+
# Stream forcibly closed
|
|
279
|
+
streams.delete(io)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Wait for process to finish
|
|
285
|
+
wait_thr.join
|
|
286
|
+
|
|
287
|
+
status = wait_thr.value.exitstatus
|
|
288
|
+
msg = Message::System::Stream.new(
|
|
289
|
+
envelope:,
|
|
290
|
+
stream: :complete,
|
|
291
|
+
content: nil,
|
|
292
|
+
status:
|
|
293
|
+
)
|
|
294
|
+
out.put(Ractor.make_shareable(msg))
|
|
295
|
+
end
|
|
296
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
297
|
+
msg = Message::System::Stream.new(
|
|
298
|
+
envelope:,
|
|
299
|
+
stream: :error,
|
|
300
|
+
content: e.message,
|
|
301
|
+
status: nil
|
|
302
|
+
)
|
|
303
|
+
out.put(Ractor.make_shareable(msg))
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Creates a shell execution command.
|
|
308
|
+
#
|
|
309
|
+
# [command] Shell command string to execute.
|
|
310
|
+
# [tag] Symbol or class to tag the result message.
|
|
311
|
+
# [stream] If <tt>true</tt>, the runtime sends incremental stdout/stderr
|
|
312
|
+
# messages as they arrive. If <tt>false</tt> (default), waits for
|
|
313
|
+
# completion and sends a single message with all output.
|
|
314
|
+
#
|
|
315
|
+
# === Example (Batch Mode)
|
|
316
|
+
#
|
|
317
|
+
# # Return this from update:
|
|
318
|
+
# [model.with(loading: true), Command.system("ls -la", :got_files)]
|
|
319
|
+
#
|
|
320
|
+
# # Then handle it later:
|
|
321
|
+
# def update(message, model)
|
|
322
|
+
# case message
|
|
323
|
+
# in [:got_files, {stdout:, status: 0}]
|
|
324
|
+
# [model.with(files: stdout.lines), nil]
|
|
325
|
+
# in [:got_files, {stderr:, status:}]
|
|
326
|
+
# [model.with(error: stderr), nil]
|
|
327
|
+
# end
|
|
328
|
+
# end
|
|
329
|
+
#
|
|
330
|
+
# === Example (Streaming Mode)
|
|
331
|
+
#
|
|
332
|
+
# # Return this from update:
|
|
333
|
+
# [model.with(loading: true), Command.system("tail -f log.txt", :log, stream: true)]
|
|
334
|
+
#
|
|
335
|
+
# # Then handle incremental messages:
|
|
336
|
+
# def update(message, model)
|
|
337
|
+
# case message
|
|
338
|
+
# in [:log, :stdout, line]
|
|
339
|
+
# [model.with(lines: [*model.lines, line]), nil]
|
|
340
|
+
# in [:log, :stderr, line]
|
|
341
|
+
# [model.with(errors: [*model.errors, line]), nil]
|
|
342
|
+
# in [:log, :complete, {status:}]
|
|
343
|
+
# [model.with(loading: false, exit_status: status), nil]
|
|
344
|
+
# in [:log, :error, {message:}]
|
|
345
|
+
# [model.with(loading: false, error: message), nil]
|
|
346
|
+
# end
|
|
347
|
+
# end
|
|
348
|
+
def self.system(command, envelope, stream: false)
|
|
349
|
+
System.new(command:, envelope:, stream:)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Wraps another command's result with a transformation.
|
|
353
|
+
#
|
|
354
|
+
# Fractal Architecture requires composition. Child fragments produce commands
|
|
355
|
+
# with their own tags. Parent fragments need those results routed back with
|
|
356
|
+
# a parent prefix. Without transformation, update functions become
|
|
357
|
+
# monolithic "God Reducers" that know about every child's internals.
|
|
358
|
+
#
|
|
359
|
+
# This command wraps an inner command and transforms its result message.
|
|
360
|
+
# The parent fragment delegates to the child, then intercepts the result and
|
|
361
|
+
# adds its routing prefix. Clean separation. No coupling.
|
|
362
|
+
#
|
|
363
|
+
# Use it to compose child fragments that return their own commands.
|
|
364
|
+
class Mapped < Data.define(:inner_command, :mapper)
|
|
365
|
+
include Custom
|
|
366
|
+
|
|
367
|
+
# Grace period delegates to inner command.
|
|
368
|
+
def rooibos_cancellation_grace_period
|
|
369
|
+
inner_command.respond_to?(:rooibos_cancellation_grace_period) ?
|
|
370
|
+
inner_command.rooibos_cancellation_grace_period : 0.1
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Executes the inner command, waits for result, and transforms it.
|
|
374
|
+
def call(out, token)
|
|
375
|
+
inner_channel = Concurrent::Promises::Channel.new
|
|
376
|
+
inner_outlet = Outlet.new(inner_channel, lifecycle: out.live)
|
|
377
|
+
|
|
378
|
+
# Dispatch inner command
|
|
379
|
+
if inner_command.respond_to?(:call)
|
|
380
|
+
inner_command.call(inner_outlet, token)
|
|
381
|
+
else
|
|
382
|
+
raise ArgumentError, "Inner command must respond to #call"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Transform result and send
|
|
386
|
+
inner_message = inner_channel.pop
|
|
387
|
+
transformed = mapper.call(inner_message)
|
|
388
|
+
out.put(*transformed)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Creates a mapped command for Fractal Architecture composition.
|
|
393
|
+
#
|
|
394
|
+
# Wraps an inner command. When the inner command completes, the +mapper+ block
|
|
395
|
+
# transforms the result into a parent message. This prevents monolithic update
|
|
396
|
+
# functions (the "God Reducer" anti-pattern).
|
|
397
|
+
#
|
|
398
|
+
# [inner_command] The child command to wrap.
|
|
399
|
+
# [mapper] Block that transforms child message to parent message.
|
|
400
|
+
#
|
|
401
|
+
# === Example
|
|
402
|
+
#
|
|
403
|
+
# # Child returns Command.execute that produces [:got_files, {...}]
|
|
404
|
+
# child_command = Command.system("ls", :got_files)
|
|
405
|
+
#
|
|
406
|
+
# # Parent wraps to route as [:sidebar, :got_files, {...}]
|
|
407
|
+
# parent_command = Command.map(child_command) { |child_result| [:sidebar, *child_result] }
|
|
408
|
+
def self.map(inner_command, &mapper)
|
|
409
|
+
Mapped.new(inner_command:, mapper:)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Gives a callable unique identity for cancellation.
|
|
413
|
+
#
|
|
414
|
+
# Reusable procs and lambdas share identity. Dispatch them twice, and
|
|
415
|
+
# +Command.cancel+ would cancel both. Wrap them to get distinct handles.
|
|
416
|
+
#
|
|
417
|
+
# The callable must be Ractor-shareable (cannot capture mutable state).
|
|
418
|
+
# Create resources like database connections inside the callable, not in
|
|
419
|
+
# the closure. See the Custom Commands guide for details.
|
|
420
|
+
#
|
|
421
|
+
# [callable] Proc, lambda, or any object responding to +call(out, token)+.
|
|
422
|
+
# If omitted, the block is used.
|
|
423
|
+
# [grace_period] Cleanup time override. Default: 2.0 seconds.
|
|
424
|
+
#
|
|
425
|
+
# === Example
|
|
426
|
+
#
|
|
427
|
+
# # With callable
|
|
428
|
+
# cmd = Command.custom(->(out, token) { out.put(:fetched, data) })
|
|
429
|
+
#
|
|
430
|
+
# # With block
|
|
431
|
+
# cmd = Command.custom(grace_period: 5.0) do |out, token|
|
|
432
|
+
# until token.canceled?
|
|
433
|
+
# out.put(:tick, Time.now)
|
|
434
|
+
# sleep 1
|
|
435
|
+
# end
|
|
436
|
+
# end
|
|
437
|
+
def self.custom(callable = nil, grace_period: nil, &block)
|
|
438
|
+
c = callable || block
|
|
439
|
+
|
|
440
|
+
# Debug mode: validate that callable can be made shareable (fail fast)
|
|
441
|
+
if RatatuiRuby::Debug.enabled?
|
|
442
|
+
begin
|
|
443
|
+
c = Ractor.make_shareable(c)
|
|
444
|
+
rescue Ractor::IsolationError
|
|
445
|
+
raise Rooibos::Error::Invariant,
|
|
446
|
+
"Command.custom requires a Ractor-shareable callable. " \
|
|
447
|
+
"#{c.class} is not shareable. Use Ractor.make_shareable or define at top-level."
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
# Production mode: skip validation (Ractors not yet used, avoid overhead)
|
|
451
|
+
|
|
452
|
+
Wrapped.new(callable: c, grace_period:)
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Creates a one-shot timer command.
|
|
456
|
+
#
|
|
457
|
+
# Waits for +seconds+ then sends +TimerResponse+ to the update function.
|
|
458
|
+
# Use for delayed actions like notification dismissal or debounced search.
|
|
459
|
+
#
|
|
460
|
+
# [seconds] Duration to wait (Float or Integer).
|
|
461
|
+
# [envelope] Symbol to tag the result message.
|
|
462
|
+
def self.wait(seconds, envelope)
|
|
463
|
+
Wait.new(seconds:, envelope:)
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Creates a recurring timer command.
|
|
467
|
+
#
|
|
468
|
+
# Identical to +wait+, but semantically used for animation frames where
|
|
469
|
+
# the update function re-dispatches to continue the animation loop.
|
|
470
|
+
#
|
|
471
|
+
# [interval] Duration between ticks (Float or Integer).
|
|
472
|
+
# [tag] Symbol to tag the result message.
|
|
473
|
+
singleton_class.alias_method :tick, :wait
|
|
474
|
+
|
|
475
|
+
# Creates a parallel batch command.
|
|
476
|
+
#
|
|
477
|
+
# Applications fetch data from multiple sources. Dashboard panels load
|
|
478
|
+
# users, stats, and notifications. Waiting sequentially is slow.
|
|
479
|
+
# Managing threads and error handling manually is error-prone.
|
|
480
|
+
#
|
|
481
|
+
# This command runs children in parallel. Each child sends its own messages
|
|
482
|
+
# independently. The batch completes when all children finish or when
|
|
483
|
+
# cancellation fires.
|
|
484
|
+
#
|
|
485
|
+
# Use it for parallel fetches, concurrent refreshes, or any work that
|
|
486
|
+
# does not need coordinated results.
|
|
487
|
+
#
|
|
488
|
+
# [commands] One or more commands to run in parallel. Pass multiple
|
|
489
|
+
# arguments or a single array.
|
|
490
|
+
#
|
|
491
|
+
# === Example
|
|
492
|
+
#
|
|
493
|
+
# # Variadic syntax
|
|
494
|
+
# Command.batch(
|
|
495
|
+
# Command.http(:get, "/users", :users),
|
|
496
|
+
# Command.http(:get, "/stats", :stats),
|
|
497
|
+
# )
|
|
498
|
+
#
|
|
499
|
+
# # Array syntax
|
|
500
|
+
# Command.batch([cmd1, cmd2, cmd3])
|
|
501
|
+
def self.batch(*)
|
|
502
|
+
Batch.new(*)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Creates an aggregating parallel command.
|
|
506
|
+
#
|
|
507
|
+
# Applications load dashboards that combine user, settings, and stats.
|
|
508
|
+
# Fire-and-forget loses correlation. This command waits for all children
|
|
509
|
+
# and returns their results together in a single message.
|
|
510
|
+
#
|
|
511
|
+
# [commands] One or more commands to run in parallel. Pass multiple
|
|
512
|
+
# arguments or a single array.
|
|
513
|
+
#
|
|
514
|
+
# === Example
|
|
515
|
+
#
|
|
516
|
+
# # Variadic syntax
|
|
517
|
+
# Command.all(
|
|
518
|
+
# Command.http(:get, "/users", :_),
|
|
519
|
+
# Command.http(:get, "/stats", :_),
|
|
520
|
+
# )
|
|
521
|
+
# # Produces: [:all, [user_result, stats_result]]
|
|
522
|
+
def self.all(envelope, *)
|
|
523
|
+
All.new(envelope, *)
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Creates an HTTP request command.
|
|
527
|
+
# Supports DWIM arity - see Http.new for patterns.
|
|
528
|
+
def self.http(*, **)
|
|
529
|
+
Http.new(*, **)
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# :nodoc:
|
|
533
|
+
class Wrapped < Data.define(:callable, :grace_period)
|
|
534
|
+
include Custom
|
|
535
|
+
def rooibos_cancellation_grace_period
|
|
536
|
+
grace_period || super
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# :nodoc:
|
|
540
|
+
def call(out, token)
|
|
541
|
+
callable.call(out, token)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
private_constant :Wrapped
|
|
545
|
+
end
|
|
546
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
# Base error class for Rooibos.
|
|
10
|
+
#
|
|
11
|
+
# All library-specific exceptions inherit from this class.
|
|
12
|
+
# Catch this to handle any Rooibos error generically.
|
|
13
|
+
#
|
|
14
|
+
# === Example
|
|
15
|
+
#
|
|
16
|
+
#--
|
|
17
|
+
# SPDX-SnippetBegin
|
|
18
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
19
|
+
# SPDX-License-Identifier: MIT-0
|
|
20
|
+
#++
|
|
21
|
+
# begin
|
|
22
|
+
# Rooibos.run(MyApp)
|
|
23
|
+
# rescue Rooibos::Error => e
|
|
24
|
+
# puts "Rooibos error: #{e.message}"
|
|
25
|
+
# end
|
|
26
|
+
#--
|
|
27
|
+
# SPDX-SnippetEnd
|
|
28
|
+
#++
|
|
29
|
+
class Error < StandardError
|
|
30
|
+
# Invariant violation.
|
|
31
|
+
#
|
|
32
|
+
# The library enforces rules about valid states and contracts.
|
|
33
|
+
# Breaking these rules raises this error.
|
|
34
|
+
#
|
|
35
|
+
# Common causes:
|
|
36
|
+
# - Providing conflicting API parameters (e.g., both fragment and model/view/update)
|
|
37
|
+
# - Callable return type mismatch (e.g., view returns <tt>nil</tt> instead of a widget)
|
|
38
|
+
#
|
|
39
|
+
# To resolve, check the method's documented contract. Ensure
|
|
40
|
+
# state preconditions are met and return types are correct.
|
|
41
|
+
#
|
|
42
|
+
# === Example
|
|
43
|
+
#
|
|
44
|
+
#--
|
|
45
|
+
# SPDX-SnippetBegin
|
|
46
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
47
|
+
# SPDX-License-Identifier: MIT-0
|
|
48
|
+
#++
|
|
49
|
+
# Rooibos.run(model: Model.new, view: nil, update: Update) # => raises Error::Invariant
|
|
50
|
+
#--
|
|
51
|
+
# SPDX-SnippetEnd
|
|
52
|
+
#++
|
|
53
|
+
class Invariant < Error; end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
module Message
|
|
10
|
+
# Response from Command.all aggregating parallel execution.
|
|
11
|
+
#
|
|
12
|
+
# Running multiple commands in parallel needs aggregated results. Without
|
|
13
|
+
# structured responses, handling mixed success/failure requires manual
|
|
14
|
+
# array parsing.
|
|
15
|
+
#
|
|
16
|
+
# This response collects all child results and provides predicates for
|
|
17
|
+
# success checking. Include Predicates for safe predicate calls.
|
|
18
|
+
#
|
|
19
|
+
# Use it to handle <tt>Command.all</tt> completions.
|
|
20
|
+
#
|
|
21
|
+
# === Example
|
|
22
|
+
#
|
|
23
|
+
# case msg
|
|
24
|
+
# in { type: :all, envelope: :parallel, results: }
|
|
25
|
+
# model.with(outputs: results)
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
All = Data.define(:envelope, :results, :nested) do
|
|
29
|
+
include Predicates
|
|
30
|
+
|
|
31
|
+
# Returns <tt>true</tt> for all responses.
|
|
32
|
+
def all?
|
|
33
|
+
true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Deconstructs for pattern matching.
|
|
37
|
+
#
|
|
38
|
+
# Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, <tt>:results</tt>,
|
|
39
|
+
# and <tt>:nested</tt>.
|
|
40
|
+
def deconstruct_keys(_keys)
|
|
41
|
+
{ type: :all, envelope:, results:, nested: }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#--
|
|
4
|
+
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
5
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
6
|
+
#++
|
|
7
|
+
|
|
8
|
+
module Rooibos
|
|
9
|
+
module Message
|
|
10
|
+
# Response from an HTTP command.
|
|
11
|
+
#
|
|
12
|
+
# HTTP requests return status codes, bodies, and headers. Errors may occur.
|
|
13
|
+
# Without structured responses, handling success vs. error requires manual
|
|
14
|
+
# checks on raw data.
|
|
15
|
+
#
|
|
16
|
+
# This response includes predicates for common checks and deconstructs for
|
|
17
|
+
# pattern matching. Include Predicates for safe predicate calls on any message.
|
|
18
|
+
#
|
|
19
|
+
# Use it to handle +Command.http+ completions.
|
|
20
|
+
#
|
|
21
|
+
# === Example
|
|
22
|
+
#
|
|
23
|
+
# case msg
|
|
24
|
+
# in { type: :http, envelope: :users, status:, body: }
|
|
25
|
+
# model.with(users: JSON.parse(body))
|
|
26
|
+
# in { type: :http, envelope: :users, error: }
|
|
27
|
+
# model.with(error:)
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
HttpResponse = Data.define(:envelope, :status, :body, :headers, :error) do
|
|
31
|
+
include Predicates
|
|
32
|
+
|
|
33
|
+
# Returns +true+ for HTTP responses.
|
|
34
|
+
def http?
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns +true+ if status is 2xx.
|
|
39
|
+
def success?
|
|
40
|
+
status&.between?(200, 299)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns +true+ if an error occurred.
|
|
44
|
+
def error?
|
|
45
|
+
!!error
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Deconstructs for pattern matching.
|
|
49
|
+
#
|
|
50
|
+
# Returns a hash with <tt>:type</tt>, <tt>:envelope</tt>, and either
|
|
51
|
+
# <tt>:error</tt> or <tt>:status</tt>, <tt>:body</tt>, <tt>:headers</tt>.
|
|
52
|
+
def deconstruct_keys(_keys)
|
|
53
|
+
if error
|
|
54
|
+
{ type: :http, envelope:, error: }
|
|
55
|
+
else
|
|
56
|
+
{ type: :http, envelope:, status:, body:, headers: }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|