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.
Files changed (105) hide show
  1. checksums.yaml +7 -0
  2. data/.builds/ruby-3.2.yml +51 -0
  3. data/.builds/ruby-3.3.yml +51 -0
  4. data/.builds/ruby-3.4.yml +51 -0
  5. data/.builds/ruby-4.0.0.yml +51 -0
  6. data/.pre-commit-config.yaml +16 -0
  7. data/.rubocop.yml +8 -0
  8. data/AGENTS.md +108 -0
  9. data/CHANGELOG.md +214 -0
  10. data/LICENSE +304 -0
  11. data/LICENSES/AGPL-3.0-or-later.txt +235 -0
  12. data/LICENSES/CC-BY-SA-4.0.txt +170 -0
  13. data/LICENSES/CC0-1.0.txt +121 -0
  14. data/LICENSES/LGPL-3.0-or-later.txt +304 -0
  15. data/LICENSES/MIT-0.txt +16 -0
  16. data/LICENSES/MIT.txt +18 -0
  17. data/README.md +183 -0
  18. data/REUSE.toml +24 -0
  19. data/Rakefile +16 -0
  20. data/Steepfile +13 -0
  21. data/doc/concepts/application_architecture.md +197 -0
  22. data/doc/concepts/application_testing.md +49 -0
  23. data/doc/concepts/async_work.md +164 -0
  24. data/doc/concepts/commands.md +530 -0
  25. data/doc/concepts/message_processing.md +51 -0
  26. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  27. data/doc/contributors/WIP/implementation_plan.md +409 -0
  28. data/doc/contributors/WIP/init_callable_proposal.md +344 -0
  29. data/doc/contributors/WIP/mvu_tea_implementations_research.md +373 -0
  30. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  31. data/doc/contributors/WIP/task.md +36 -0
  32. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  33. data/doc/contributors/design/commands_and_outlets.md +214 -0
  34. data/doc/contributors/kit-no-outlet.md +238 -0
  35. data/doc/contributors/priorities.md +38 -0
  36. data/doc/custom.css +22 -0
  37. data/doc/getting_started/quickstart.md +56 -0
  38. data/doc/images/.gitkeep +0 -0
  39. data/doc/images/verify_readme_usage.png +0 -0
  40. data/doc/images/widget_cmd_exec.png +0 -0
  41. data/doc/index.md +25 -0
  42. data/examples/app_fractal_dashboard/README.md +60 -0
  43. data/examples/app_fractal_dashboard/app.rb +63 -0
  44. data/examples/app_fractal_dashboard/dashboard/base.rb +73 -0
  45. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +86 -0
  46. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +87 -0
  47. data/examples/app_fractal_dashboard/dashboard/update_router.rb +43 -0
  48. data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +81 -0
  49. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  50. data/examples/app_fractal_dashboard/fragments/custom_shell_output.rb +90 -0
  51. data/examples/app_fractal_dashboard/fragments/disk_usage.rb +47 -0
  52. data/examples/app_fractal_dashboard/fragments/network_panel.rb +45 -0
  53. data/examples/app_fractal_dashboard/fragments/ping.rb +47 -0
  54. data/examples/app_fractal_dashboard/fragments/stats_panel.rb +45 -0
  55. data/examples/app_fractal_dashboard/fragments/system_info.rb +47 -0
  56. data/examples/app_fractal_dashboard/fragments/uptime.rb +47 -0
  57. data/examples/verify_readme_usage/README.md +54 -0
  58. data/examples/verify_readme_usage/app.rb +47 -0
  59. data/examples/widget_command_system/README.md +70 -0
  60. data/examples/widget_command_system/app.rb +132 -0
  61. data/exe/.gitkeep +0 -0
  62. data/lib/rooibos/command/all.rb +69 -0
  63. data/lib/rooibos/command/batch.rb +77 -0
  64. data/lib/rooibos/command/custom.rb +104 -0
  65. data/lib/rooibos/command/http.rb +192 -0
  66. data/lib/rooibos/command/lifecycle.rb +134 -0
  67. data/lib/rooibos/command/outlet.rb +157 -0
  68. data/lib/rooibos/command/wait.rb +80 -0
  69. data/lib/rooibos/command.rb +546 -0
  70. data/lib/rooibos/error.rb +55 -0
  71. data/lib/rooibos/message/all.rb +45 -0
  72. data/lib/rooibos/message/http_response.rb +61 -0
  73. data/lib/rooibos/message/system/batch.rb +61 -0
  74. data/lib/rooibos/message/system/stream.rb +67 -0
  75. data/lib/rooibos/message/timer.rb +46 -0
  76. data/lib/rooibos/message.rb +38 -0
  77. data/lib/rooibos/router.rb +403 -0
  78. data/lib/rooibos/runtime.rb +396 -0
  79. data/lib/rooibos/shortcuts.rb +49 -0
  80. data/lib/rooibos/test_helper.rb +56 -0
  81. data/lib/rooibos/version.rb +12 -0
  82. data/lib/rooibos.rb +121 -0
  83. data/mise.toml +8 -0
  84. data/rbs_collection.lock.yaml +108 -0
  85. data/rbs_collection.yaml +15 -0
  86. data/sig/concurrent.rbs +72 -0
  87. data/sig/examples/verify_readme_usage/app.rbs +19 -0
  88. data/sig/examples/widget_command_system/app.rbs +26 -0
  89. data/sig/open3.rbs +17 -0
  90. data/sig/rooibos/command.rbs +265 -0
  91. data/sig/rooibos/error.rbs +13 -0
  92. data/sig/rooibos/message.rbs +121 -0
  93. data/sig/rooibos/router.rbs +153 -0
  94. data/sig/rooibos/runtime.rbs +75 -0
  95. data/sig/rooibos/shortcuts.rbs +16 -0
  96. data/sig/rooibos/test_helper.rbs +10 -0
  97. data/sig/rooibos/version.rbs +8 -0
  98. data/sig/rooibos.rbs +46 -0
  99. data/tasks/example_viewer.html.erb +172 -0
  100. data/tasks/resources/build.yml.erb +53 -0
  101. data/tasks/resources/index.html.erb +44 -0
  102. data/tasks/resources/rubies.yml +7 -0
  103. data/tasks/steep.rake +11 -0
  104. data/vendor/goodcop/base.yml +1047 -0
  105. 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