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,104 @@
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 Command
10
+ # Mixin for user-defined custom commands.
11
+ #
12
+ # Custom commands extend Rooibos with side effects: WebSockets, gRPC, database polls,
13
+ # background tasks. The runtime dispatches them in threads and routes results
14
+ # back as messages.
15
+ #
16
+ # Include this module to identify your class as a command. The runtime uses
17
+ # +rooibos_command?+ to distinguish commands from plain models. Override
18
+ # +rooibos_cancellation_grace_period+ if your cleanup takes longer than 100 milliseconds.
19
+ #
20
+ # Use it to build real-time features, long-polling connections, or background workers.
21
+ #
22
+ # === Example
23
+ #
24
+ #--
25
+ # SPDX-SnippetBegin
26
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
27
+ # SPDX-License-Identifier: MIT-0
28
+ #++
29
+ # class WebSocketCommand
30
+ # include Rooibos::Command::Custom
31
+ #
32
+ # def initialize(url)
33
+ # @url = url
34
+ # end
35
+ #
36
+ # def call(out, token)
37
+ # ws = WebSocket::Client.new(@url)
38
+ # ws.on_message { |msg| out.put(:ws_message, msg) }
39
+ # ws.connect
40
+ #
41
+ # until token.canceled?
42
+ # ws.ping
43
+ # sleep 1
44
+ # end
45
+ #
46
+ # ws.close
47
+ # end
48
+ #
49
+ # # WebSocket close handshake needs extra time
50
+ # def rooibos_cancellation_grace_period
51
+ # 5.0
52
+ # end
53
+ # end
54
+ #--
55
+ # SPDX-SnippetEnd
56
+ #++
57
+ module Custom
58
+ # Brand predicate for command identification.
59
+ #
60
+ # The runtime calls this to distinguish commands from plain models.
61
+ # Returns <tt>true</tt> unconditionally.
62
+ #
63
+ # You do not need to override this method.
64
+ def rooibos_command?
65
+ true
66
+ end
67
+
68
+ # Cleanup time after cancellation is requested. In seconds.
69
+ #
70
+ # When the runtime cancels your command (app exit, navigation, explicit cancel),
71
+ # it calls <tt>token.cancel!</tt> and waits this long for your command to stop.
72
+ # If your command does not exit within this window, it is force-killed.
73
+ #
74
+ # *This is NOT a lifetime limit.* Your command runs indefinitely until cancelled.
75
+ # A WebSocket open for 15 minutes is fine. This timeout only applies to the
76
+ # cleanup phase after cancellation is requested.
77
+ #
78
+ # Override this method to specify how long your cleanup takes:
79
+ #
80
+ # - <tt>0.5</tt> — Quick HTTP abort, no cleanup needed
81
+ # - <tt>2.0</tt> — Default, suitable for most commands
82
+ # - <tt>5.0</tt> — WebSocket close handshake with remote server
83
+ # - <tt>Float::INFINITY</tt> — Never force-kill (database transactions)
84
+ #
85
+ # === Example
86
+ #
87
+ #--
88
+ # SPDX-SnippetBegin
89
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
90
+ # SPDX-License-Identifier: MIT-0
91
+ #++
92
+ # # Database transactions should never be interrupted mid-write
93
+ # def rooibos_cancellation_grace_period
94
+ # Float::INFINITY
95
+ # end
96
+ #--
97
+ # SPDX-SnippetEnd
98
+ #++
99
+ def rooibos_cancellation_grace_period
100
+ 0.1
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,192 @@
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 "net/http"
9
+ require "uri"
10
+
11
+ module Rooibos
12
+ module Command
13
+ # Alias to Message::HttpResponse for backwards compatibility.
14
+ # New code should use Rooibos::Message::HttpResponse.
15
+ HttpResponse = Message::HttpResponse
16
+
17
+ # An HTTP request command.
18
+ Http = Data.define(:method, :url, :envelope, :headers, :body, :timeout, :parser) do
19
+ include Custom
20
+
21
+ def self.new(*args, method: nil, url: nil, envelope: nil, headers: nil, body: nil, timeout: nil, parser: nil,
22
+ get: nil, post: nil, put: nil, patch: nil, delete: nil
23
+ )
24
+ # Auto-splat single hash argument
25
+ return new(**args.first) if args.size == 1 && args.first.is_a?(Hash)
26
+
27
+ # Auto-spread single array argument
28
+ return new(*args.first) if args.size == 1 && args.first.is_a?(Array)
29
+
30
+ # DWIM: parse positional args and keyword method shortcuts
31
+ method_keywords = { get:, post:, put:, patch:, delete: }.compact
32
+ method, url, envelope, body = parse_dwim_args(args, method, url, envelope, body, method_keywords)
33
+
34
+ # Ractor validation
35
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(url)
36
+ raise Rooibos::Error::Invariant,
37
+ "URL is not Ractor-shareable: #{url.inspect}\n" \
38
+ "Use a frozen string or Ractor.make_shareable."
39
+ end
40
+
41
+ if RatatuiRuby::Debug.enabled? && headers && !Ractor.shareable?(headers)
42
+ raise Rooibos::Error::Invariant,
43
+ "Headers are not Ractor-shareable: #{headers.inspect}\n" \
44
+ "Use Ractor.make_shareable or freeze the hash and its contents."
45
+ end
46
+
47
+ if RatatuiRuby::Debug.enabled? && body && !Ractor.shareable?(body)
48
+ raise Rooibos::Error::Invariant,
49
+ "Body is not Ractor-shareable: #{body.inspect}\n" \
50
+ "Use a frozen string or Ractor.make_shareable."
51
+ end
52
+
53
+ if RatatuiRuby::Debug.enabled? && envelope && !Ractor.shareable?(envelope)
54
+ raise Rooibos::Error::Invariant,
55
+ "Envelope is not Ractor-shareable: #{envelope.inspect}\n" \
56
+ "Use a frozen string, symbol, or Ractor.make_shareable."
57
+ end
58
+
59
+ if RatatuiRuby::Debug.enabled? && timeout && !Ractor.shareable?(timeout)
60
+ raise Rooibos::Error::Invariant,
61
+ "Timeout is not Ractor-shareable: #{timeout.inspect}\n" \
62
+ "Use a number or Ractor.make_shareable."
63
+ end
64
+
65
+ # Parser validation
66
+ if parser && !parser.respond_to?(:call)
67
+ raise ArgumentError, "parser: must respond to :call"
68
+ end
69
+
70
+ if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parser)
71
+ raise Rooibos::Error::Invariant,
72
+ "Parser is not Ractor-shareable: #{parser.inspect}\n" \
73
+ "Use a frozen Method object or Ractor.make_shareable."
74
+ end
75
+
76
+ # Method validation
77
+ unless %i[get post put patch delete].include?(method)
78
+ raise ArgumentError, "Unsupported HTTP method: #{method.inspect}"
79
+ end
80
+
81
+ instance = allocate
82
+ instance.__send__(:initialize, method:, url:, envelope:, headers:, body:, timeout: timeout || 10, parser:)
83
+ instance
84
+ end
85
+
86
+ # Net::HTTP is blocking; no cooperative cancellation possible.
87
+ # Grace period = 0 means runtime can force-kill immediately.
88
+ def rooibos_cancellation_grace_period = 0
89
+
90
+ def self.parse_dwim_args(args, method_kw, url_kw, envelope_kw, body_kw, method_keywords)
91
+ # Handle keyword method shortcuts: get: 'url'
92
+ if method_keywords.any?
93
+ method_key, url = method_keywords.first
94
+ # Check for conflicts with method: keyword
95
+ if method_kw && method_kw != method_key
96
+ raise ArgumentError, "Conflicting method specified: #{method_key}: and method: #{method_kw}"
97
+ end
98
+ # Check for conflicts with url: keyword
99
+ if url_kw && url_kw != url
100
+ raise ArgumentError, "Conflicting url specified: #{method_key}: provides url but url: also given"
101
+ end
102
+ return [method_key, url, url, body_kw]
103
+ end
104
+
105
+ # If keywords provided, use them directly
106
+ return [method_kw, url_kw, envelope_kw, body_kw] if args.empty?
107
+
108
+ case args
109
+ in [String => url]
110
+ # Http.new('url') → GET, URL as envelope
111
+ [:get, url, url, body_kw]
112
+ in [String => url, Symbol => envelope]
113
+ # Http.new('url', :tag) → GET, custom envelope
114
+ [:get, url, envelope, body_kw]
115
+ in [Symbol => method, String => url]
116
+ # Http.new(:delete, 'url') → method, URL as envelope
117
+ [method, url, url, body_kw]
118
+ in [Symbol => method, String => url, Symbol => envelope]
119
+ # Http.new(:delete, 'url', :tag) → method, custom envelope
120
+ [method, url, envelope, body_kw]
121
+ in [Symbol => method, String => url, String => body]
122
+ # Http.new(:post, 'url', 'body') → method, URL as envelope, body
123
+ [method, url, url, body]
124
+ in [Symbol => method, String => url, String => body, Symbol => envelope]
125
+ # Http.new(:post, 'url', 'body', :tag) → method, custom envelope, body
126
+ [method, url, envelope, body]
127
+ else
128
+ raise ArgumentError, "Invalid arguments for Http.new: #{args.inspect}"
129
+ end
130
+ end
131
+ private_class_method :parse_dwim_args
132
+
133
+ def call(out, token)
134
+ return if token.canceled?
135
+
136
+ uri = URI(url)
137
+ http = Net::HTTP.new(uri.host, uri.port)
138
+ http.use_ssl = uri.scheme == "https"
139
+ if timeout
140
+ http.open_timeout = timeout
141
+ http.read_timeout = timeout
142
+ end
143
+
144
+ klass = case method
145
+ when :get then Net::HTTP::Get
146
+ when :post then Net::HTTP::Post
147
+ when :put then Net::HTTP::Put
148
+ when :patch then Net::HTTP::Patch
149
+ when :delete then Net::HTTP::Delete
150
+ end
151
+ request = klass.new(uri)
152
+ request.body = body if body
153
+ headers&.each { |key, value| request[key] = value }
154
+ response = http.request(request)
155
+
156
+ response_body = response.body.freeze
157
+ response_headers = response.each_header.to_h.freeze
158
+ response_status = response.code.to_i
159
+
160
+ # Invoke parser with positional params if provided
161
+ parsed_body = if parser
162
+ parser.call(response_body, response_headers, response_status)
163
+ else
164
+ response_body
165
+ end
166
+
167
+ # Validate parsed body is Ractor-shareable in debug mode
168
+ if RatatuiRuby::Debug.enabled? && parser && !Ractor.shareable?(parsed_body)
169
+ raise Rooibos::Error::Invariant,
170
+ "Parsed body is not Ractor-shareable: #{parsed_body.class}\n" \
171
+ "Parser must return frozen/shareable data. Use .freeze or Ractor.make_shareable."
172
+ end
173
+
174
+ out.put(Ractor.make_shareable(HttpResponse.new(
175
+ envelope:,
176
+ status: response_status,
177
+ body: parsed_body,
178
+ headers: response_headers,
179
+ error: nil
180
+ )))
181
+ rescue => e
182
+ out.put(Ractor.make_shareable(HttpResponse.new(
183
+ envelope:,
184
+ status: nil,
185
+ body: nil,
186
+ headers: nil,
187
+ error: e.message.freeze
188
+ )))
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,134 @@
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 Command
10
+ # Coordinates command execution across the runtime.
11
+ #
12
+ # Commands run off the main thread. Both the runtime and nested commands
13
+ # via <tt>Outlet#source</tt> share cancellation tokens. Racing results
14
+ # against cancellation is repetitive. Tracking active commands is tedious.
15
+ #
16
+ # This class centralizes that logic. It races results against cancellation
17
+ # and timeout. Commands that ignore cancellation are orphaned until
18
+ # process exit. Cooperative cancellation is the only way to exit cleanly.
19
+ #
20
+ # The framework creates one instance at startup. All outlets share it.
21
+ class Lifecycle
22
+ # :nodoc: Internal representation of a tracked async command.
23
+ Entry = Data.define(:future, :origin)
24
+
25
+ # Creates a lifecycle manager.
26
+ #
27
+ # The runtime creates one at startup. All outlets share it. Child commands
28
+ # from <tt>Outlet#source</tt> inherit the same lifecycle for consistent
29
+ # thread management.
30
+ def initialize
31
+ @active = Concurrent::Map.new
32
+ end
33
+
34
+ # Runs a command synchronously, returning its result.
35
+ #
36
+ # Spawns a thread, races the result against cancellation and timeout.
37
+ # On cancellation, waits the grace period then kills the thread if needed.
38
+ #
39
+ # [command] Callable with <tt>call(out, token)</tt>.
40
+ # [token] Parent's cancellation token.
41
+ # [timeout] Max wait seconds for the result.
42
+ #
43
+ # Returns the child's message, or <tt>nil</tt> if cancelled or timed out.
44
+ # Raises if the child raised.
45
+ def run_sync(command, token, timeout:)
46
+ return nil if token.canceled?
47
+
48
+ child_channel = Concurrent::Promises::Channel.new
49
+ child_outlet = Outlet.new(child_channel, lifecycle: self)
50
+
51
+ exception = nil
52
+ Concurrent::Promises.future do
53
+ command.call(child_outlet, token)
54
+ rescue => e
55
+ exception = e
56
+ end
57
+
58
+ # Race: pop result vs cancellation vs timeout
59
+ pop_future = Concurrent::Promises.future { child_channel.pop(timeout, :timeout) }
60
+ Concurrent::Promises.any_event(pop_future, token.origin).wait
61
+
62
+ # Cooperative cancellation only — misbehaving commands are orphaned
63
+ return nil if token.canceled?
64
+
65
+ if exception
66
+ raise exception.is_a?(Exception) ? exception : RuntimeError.new(exception.to_s)
67
+ end
68
+
69
+ result = pop_future.value
70
+ return nil if result == :timeout
71
+
72
+ result
73
+ end
74
+
75
+ # Runs a command asynchronously, tracking it for later cancellation.
76
+ #
77
+ # Spawns a future that executes the command. Tracks the command in the
78
+ # active map for cancellation support. Errors are pushed to the channel
79
+ # as <tt>Command::Error</tt> messages.
80
+ #
81
+ # [command] Callable with <tt>call(out, token)</tt>.
82
+ # [channel] Channel to push results and errors to.
83
+ #
84
+ # Returns a hash with <tt>:future</tt> and <tt>:origin</tt> for tracking.
85
+ def run_async(command, channel)
86
+ cancellation, origin = Concurrent::Cancellation.new
87
+ outlet = Outlet.new(channel, lifecycle: self)
88
+
89
+ future = Concurrent::Promises.future do
90
+ command.call(outlet, cancellation)
91
+ rescue => e
92
+ channel.push Command::Error.new(command:, exception: e)
93
+ end
94
+
95
+ entry = Entry.new(future:, origin:)
96
+ @active[command] = entry
97
+ entry
98
+ end
99
+
100
+ # Cancels a running command, waiting for its grace period.
101
+ #
102
+ # Signals cancellation, waits for the command's grace period, then
103
+ # removes it from tracking. Does nothing if the command isn't tracked.
104
+ #
105
+ # [command] The command to cancel (must be the same object passed to run_async).
106
+ def cancel(command)
107
+ entry = @active[command]
108
+ return unless entry&.future&.pending?
109
+
110
+ entry.origin.resolve # Signal cancellation
111
+
112
+ grace = command.respond_to?(:rooibos_cancellation_grace_period) ?
113
+ command.rooibos_cancellation_grace_period : 0.1
114
+ entry.future.wait(grace.finite? ? grace : nil)
115
+
116
+ @active.delete(command)
117
+ end
118
+
119
+ # Cancels all active commands and waits for them to complete.
120
+ #
121
+ # Iterates through all tracked commands, signals cancellation, and waits
122
+ # for each command's grace period. Called at runtime shutdown.
123
+ def shutdown
124
+ @active.each do |command, entry|
125
+ entry.origin.resolve # Signal cancellation
126
+
127
+ grace = command.respond_to?(:rooibos_cancellation_grace_period) ?
128
+ command.rooibos_cancellation_grace_period : 0.1
129
+ entry.future.wait(grace.finite? ? grace : nil)
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,157 @@
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 "ratatui_ruby"
9
+
10
+ module Rooibos
11
+ module Command
12
+ # Messaging gateway for custom commands.
13
+ #
14
+ # Custom commands run in background threads. They produce results that the
15
+ # main loop consumes.
16
+ #
17
+ # Managing queues and message formats manually is tedious. It scatters queue
18
+ # logic across your codebase and makes mistakes easy.
19
+ #
20
+ # This class wraps the queue with a clean API. Call +put+ to send tagged
21
+ # messages. Debug mode validates Ractor-shareability.
22
+ #
23
+ # Use it to send results from HTTP requests, WebSocket streams, or database polls.
24
+ #
25
+ # === Example (One-Shot)
26
+ #
27
+ # Commands run in their own thread. Blocking calls work fine:
28
+ #
29
+ #--
30
+ # SPDX-SnippetBegin
31
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
32
+ # SPDX-License-Identifier: MIT-0
33
+ #++
34
+ # class FetchUserCommand
35
+ # include Rooibos::Command::Custom
36
+ #
37
+ # def initialize(user_id)
38
+ # @user_id = user_id
39
+ # end
40
+ #
41
+ # def call(out, _token)
42
+ # response = Net::HTTP.get(URI("https://api.example.com/users/#{@user_id}"))
43
+ # user = JSON.parse(response)
44
+ # out.put(:user_fetched, Ractor.make_shareable(user: user))
45
+ # rescue => e
46
+ # out.put(:user_fetch_failed, error: e.message.freeze)
47
+ # end
48
+ # end
49
+ #--
50
+ # SPDX-SnippetEnd
51
+ #++
52
+ #
53
+ # === Example (Long-Running)
54
+ #
55
+ # Commands that loop check the cancellation token:
56
+ #
57
+ #--
58
+ # SPDX-SnippetBegin
59
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
60
+ # SPDX-License-Identifier: MIT-0
61
+ #++
62
+ # class PollerCommand
63
+ # include Rooibos::Command::Custom
64
+ #
65
+ # def call(out, token)
66
+ # until token.canceled?
67
+ # data = fetch_batch
68
+ # out.put(:batch, Ractor.make_shareable(data))
69
+ # sleep 5
70
+ # end
71
+ # out.put(:poller_stopped)
72
+ # end
73
+ # end
74
+ #--
75
+ # SPDX-SnippetEnd
76
+ #++
77
+ class Outlet
78
+ # Creates an outlet for the given message queue.
79
+ #
80
+ # The runtime provides the message queue and lifecycle. Custom commands receive
81
+ # the outlet as their first argument.
82
+ #
83
+ # [message_queue] A <tt>Concurrent::Promises::Channel</tt> or compatible object.
84
+ # [lifecycle] A <tt>Lifecycle</tt> for managing nested command execution.
85
+ def initialize(message_queue, lifecycle:)
86
+ @message_queue = message_queue
87
+ @live = lifecycle
88
+ end
89
+
90
+ # :nodoc: Internal infrastructure for nested command lifecycle sharing.
91
+ attr_reader :live
92
+
93
+ # Sends a message to the runtime.
94
+ #
95
+ # Custom commands produce results. Those results feed back into your
96
+ # update function. This method handles the wiring.
97
+ #
98
+ # Call with one argument to send it directly. Call with multiple
99
+ # arguments and they arrive as an array.
100
+ #
101
+ # Use it for complex data flows or transports Rooibos doesn't ship with.
102
+ #
103
+ # === Example
104
+ #
105
+ # out.put(:done) # Update receives :done
106
+ # out.put(current_user) # Update receives current_user
107
+ # out.put(:user, alice) # Update receives [:user, alice]
108
+ #
109
+ # Debug mode validates Ractor-shareability.
110
+ def put(*args)
111
+ message = (args.size == 1) ? args.first : args.freeze
112
+
113
+ if RatatuiRuby::Debug.enabled? && !Ractor.shareable?(message)
114
+ raise Rooibos::Error::Invariant,
115
+ "Message is not Ractor-shareable: #{message.inspect}\n" \
116
+ "Use Ractor.make_shareable or Object#freeze."
117
+ end
118
+
119
+ @message_queue.push(message)
120
+ end
121
+
122
+ # Runs a child command synchronously within a custom command.
123
+ #
124
+ # Use this to orchestrate multi-step workflows: fetch one result, then
125
+ # use it to compose the next command.
126
+ #
127
+ # The child runs asynchronously in a future. This method blocks until
128
+ # the child calls +put+, cancellation occurs, or the timeout expires.
129
+ #
130
+ # [command] A callable (lambda or Custom command) with +call(out, token)+.
131
+ # [token] The parent's cancellation token, passed through to the child.
132
+ # [timeout] Max seconds to wait for the child's result (default: 30.0).
133
+ #
134
+ # Returns the message from the child, or +nil+ if cancelled/timed out.
135
+ # Raises if the child command raised an exception.
136
+ #
137
+ # === Example
138
+ #
139
+ #--
140
+ # SPDX-SnippetBegin
141
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
142
+ # SPDX-License-Identifier: MIT-0
143
+ #++
144
+ # def call(out, token)
145
+ # user_result = out.source(fetch_user_cmd, token)
146
+ # return if user_result.nil?
147
+ # out.put(:user_loaded, user: user_result)
148
+ # end
149
+ #--
150
+ # SPDX-SnippetEnd
151
+ #++
152
+ def source(command, token, timeout: 30.0)
153
+ @live.run_sync(command, token, timeout:)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,80 @@
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 Command
10
+ # A one-shot timer command.
11
+ #
12
+ # Applications need timed events. Notification auto-dismissal,
13
+ # debounced search, and animation frames all depend on delays.
14
+ # Building timers from scratch with threads is error-prone.
15
+ # Cancellation is tricky.
16
+ #
17
+ # This command waits, then sends a message. It responds to
18
+ # cancellation cooperatively. When cancelled, it sends
19
+ # <tt>Command.cancel(self)</tt> so you know the timer stopped.
20
+ #
21
+ # Use it for delayed actions, debounced inputs, or animation loops.
22
+ #
23
+ # === Example: Notification dismissal
24
+ #
25
+ # def update(msg, model)
26
+ # case msg
27
+ # in :save_clicked
28
+ # [model.with(notification: "Saved!"), Command.wait(3.0, :dismiss)]
29
+ # in :dismiss
30
+ # [model.with(notification: nil), nil]
31
+ # in Command::Cancel
32
+ # [model.with(notification: nil), nil] # User navigated away
33
+ # end
34
+ # end
35
+ #
36
+ # === Example: Animation loop
37
+ #
38
+ # def update(msg, model)
39
+ # case msg
40
+ # in :start_animation
41
+ # [model.with(frame: 0), Command.tick(0.1, :animate)]
42
+ # in :animate
43
+ # frame = (model.frame + 1) % 10
44
+ # [model.with(frame:), Command.tick(0.1, :animate)]
45
+ # end
46
+ # end
47
+ Wait = Data.define(:seconds, :envelope) do
48
+ include Custom
49
+
50
+ # Cooperative cancellation needs no grace period.
51
+ # The command responds instantly to cancellation via
52
+ # <tt>Concurrent::Cancellation.timeout</tt>.
53
+ def rooibos_cancellation_grace_period
54
+ 0
55
+ end
56
+
57
+ # Executes the timer.
58
+ #
59
+ # Waits for <tt>seconds</tt>, then sends <tt>TimerResponse</tt>.
60
+ # If cancelled, sends <tt>Command.cancel(self)</tt> instead.
61
+ #
62
+ # [out] Outlet for sending messages.
63
+ # [token] Cancellation token from the runtime.
64
+ def call(out, token)
65
+ start_time = Time.now
66
+ timer_cancellation, _origin = Concurrent::Cancellation.timeout(seconds)
67
+ combined = token.join(timer_cancellation)
68
+ combined.origin.wait
69
+
70
+ if token.canceled?
71
+ out.put(Command.cancel(self))
72
+ else
73
+ elapsed = Time.now - start_time
74
+ response = Message::Timer.new(envelope:, elapsed:)
75
+ out.put(Ractor.make_shareable(response))
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end