rooibos 0.5.0 → 0.6.1
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 +4 -4
- data/.builds/ruby-3.2.yml +9 -5
- data/.builds/ruby-3.3.yml +9 -5
- data/.builds/ruby-3.4.yml +9 -5
- data/.builds/ruby-4.0.0.yml +9 -5
- data/AGENTS.md +1 -1
- data/CHANGELOG.md +57 -0
- data/README.md +2 -2
- data/README.rdoc +374 -0
- data/REUSE.toml +5 -0
- data/Rakefile +1 -1
- data/doc/best_practices/forms_and_validation.md +20 -0
- data/doc/best_practices/http_workflows.md +20 -0
- data/doc/best_practices/index.md +26 -0
- data/doc/best_practices/lists_and_tables.md +20 -0
- data/doc/best_practices/modal_dialogs.md +20 -0
- data/doc/best_practices/no_stateful_widgets.md +184 -0
- data/doc/best_practices/orchestration.md +20 -0
- data/doc/best_practices/streaming_data.md +20 -0
- data/doc/contributors/design/commands_and_outlets.md +1 -1
- data/doc/contributors/documentation_plan.md +616 -0
- data/doc/contributors/documentation_stub_audit.md +112 -0
- data/doc/contributors/documentation_style.md +275 -0
- data/doc/contributors/e2e_pty.md +168 -0
- data/doc/contributors/specs/earliest_tutorial_steps_per_story.md +70 -0
- data/doc/contributors/specs/file_browser.md +789 -0
- data/doc/contributors/specs/file_browser_stories.md +774 -0
- data/doc/contributors/specs/tutorials_to_stories.rb +167 -0
- data/doc/contributors/todo/scrollbar.md +118 -0
- data/doc/contributors/tutorial_old/01_project_setup.md +20 -0
- data/doc/contributors/tutorial_old/02_hello_world.md +24 -0
- data/doc/contributors/tutorial_old/03_adding_state.md +26 -0
- data/doc/contributors/tutorial_old/06_organizing_your_code.md +20 -0
- data/doc/contributors/tutorial_old/07_your_first_command.md +21 -0
- data/doc/contributors/tutorial_old/08_the_preview_pane.md +20 -0
- data/doc/contributors/tutorial_old/09_loading_states.md +20 -0
- data/doc/contributors/tutorial_old/10_testing_your_app.md +20 -0
- data/doc/contributors/tutorial_old/11_polish_and_refine.md +20 -0
- data/doc/contributors/tutorial_old/12_going_further.md +20 -0
- data/doc/contributors/tutorial_old/index.md +20 -0
- data/doc/essentials/commands.md +20 -0
- data/doc/essentials/index.md +31 -0
- data/doc/essentials/messages.md +21 -0
- data/doc/essentials/models.md +21 -0
- data/doc/essentials/shortcuts.md +19 -0
- data/doc/essentials/the_elm_architecture.md +24 -0
- data/doc/essentials/the_runtime.md +21 -0
- data/doc/essentials/update_functions.md +20 -0
- data/doc/essentials/views.md +22 -0
- data/doc/getting_started/for_go_developers.md +16 -0
- data/doc/getting_started/for_python_developers.md +16 -0
- data/doc/getting_started/for_rails_developers.md +17 -0
- data/doc/getting_started/for_ratatui_ruby_developers.md +17 -0
- data/doc/getting_started/for_react_developers.md +17 -0
- data/doc/getting_started/index.md +52 -0
- data/doc/getting_started/install.md +20 -0
- data/doc/getting_started/quickstart.md +9 -45
- data/doc/getting_started/ruby_primer.md +19 -0
- data/doc/getting_started/why_rooibos.md +20 -0
- data/doc/index.md +79 -11
- data/doc/scaling_up/async_patterns.md +20 -0
- data/doc/scaling_up/command_composition.md +20 -0
- data/doc/scaling_up/custom_commands.md +21 -0
- data/doc/scaling_up/fractal_architecture.md +20 -0
- data/doc/scaling_up/index.md +30 -0
- data/doc/scaling_up/message_routing.md +20 -0
- data/doc/scaling_up/ractor_safety.md +20 -0
- data/doc/scaling_up/testing.md +21 -0
- data/doc/troubleshooting/common_errors.md +20 -0
- data/doc/troubleshooting/debugging.md +21 -0
- data/doc/troubleshooting/index.md +23 -0
- data/doc/troubleshooting/performance.md +20 -0
- data/doc/tutorial/01_project_setup.md +44 -0
- data/doc/tutorial/02_hello_world.md +45 -0
- data/doc/tutorial/03_static_file_list.md +44 -0
- data/doc/tutorial/04_arrow_navigation.md +47 -0
- data/doc/tutorial/05_real_files.md +45 -0
- data/doc/tutorial/06_safe_refactoring.md +21 -0
- data/doc/tutorial/07_red_first_tdd.md +26 -0
- data/doc/tutorial/08_file_metadata.md +42 -0
- data/doc/tutorial/09_text_preview.md +44 -0
- data/doc/tutorial/10_directory_tree.md +42 -0
- data/doc/tutorial/11_pane_focus.md +40 -0
- data/doc/tutorial/12_sorting.md +41 -0
- data/doc/tutorial/13_filtering.md +43 -0
- data/doc/tutorial/14_toggle_hidden.md +41 -0
- data/doc/tutorial/15_text_input_widget.md +43 -0
- data/doc/tutorial/16_rename_files.md +42 -0
- data/doc/tutorial/17_confirmation_dialogs.md +43 -0
- data/doc/tutorial/18_progress_indicators.md +43 -0
- data/doc/tutorial/19_atomic_operations.md +42 -0
- data/doc/tutorial/20_external_editor.md +42 -0
- data/doc/tutorial/21_modal_overlays.md +41 -0
- data/doc/tutorial/22_error_handling.md +43 -0
- data/doc/tutorial/23_terminal_capabilities.md +53 -0
- data/doc/tutorial/24_mouse_events.md +43 -0
- data/doc/tutorial/25_resize_events.md +43 -0
- data/doc/tutorial/26_loading_states.md +42 -0
- data/doc/tutorial/27_performance.md +43 -0
- data/doc/tutorial/28_color_schemes.md +47 -0
- data/doc/tutorial/29_configuration.md +124 -0
- data/doc/tutorial/30_going_further.md +17 -0
- data/doc/tutorial/index.md +17 -0
- data/examples/app_file_browser/app.rb +40 -0
- data/examples/app_fractal_dashboard/dashboard/update_manual.rb +7 -7
- data/examples/app_fractal_dashboard/fragments/custom_shell_input.rb +5 -5
- data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +1 -1
- data/examples/app_fractal_dashboard/fragments/disk_usage.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/network_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/ping.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/stats_panel.rb +4 -4
- data/examples/app_fractal_dashboard/fragments/system_info.rb +2 -2
- data/examples/app_fractal_dashboard/fragments/uptime.rb +2 -2
- data/examples/verify_website_first_app/app.rb +85 -0
- data/examples/verify_website_hello_mvu/app.rb +31 -0
- data/examples/widget_command_system/app.rb +15 -13
- data/exe/rooibos +10 -0
- data/generate_tutorial_stubs.rb +126 -0
- data/lib/rooibos/cli/commands/new.rb +373 -0
- data/lib/rooibos/cli/commands/run.rb +98 -0
- data/lib/rooibos/cli.rb +78 -0
- data/lib/rooibos/command/all.rb +76 -23
- data/lib/rooibos/command/batch.rb +61 -34
- data/lib/rooibos/command/custom.rb +84 -1
- data/lib/rooibos/command/http.rb +121 -55
- data/lib/rooibos/command/lifecycle.rb +5 -5
- data/lib/rooibos/command/open.rb +93 -0
- data/lib/rooibos/command/outlet.rb +105 -3
- data/lib/rooibos/command/wait.rb +9 -6
- data/lib/rooibos/command.rb +114 -89
- data/lib/rooibos/message/batch.rb +39 -0
- data/lib/rooibos/message/canceled.rb +51 -0
- data/lib/rooibos/message/error.rb +48 -0
- data/lib/rooibos/message/open.rb +30 -0
- data/lib/rooibos/message.rb +84 -4
- data/lib/rooibos/router.rb +11 -14
- data/lib/rooibos/runtime.rb +40 -43
- data/lib/rooibos/shortcuts.rb +47 -0
- data/lib/rooibos/test_helper.rb +71 -6
- data/lib/rooibos/version.rb +1 -1
- data/lib/rooibos/welcome.rb +237 -0
- data/lib/rooibos.rb +4 -3
- data/mise.toml +1 -1
- data/rbs_collection.lock.yaml +2 -2
- data/sig/concurrent.rbs +4 -0
- data/sig/gem.rbs +20 -0
- data/sig/rooibos/cli.rbs +42 -0
- data/sig/rooibos/command.rbs +59 -7
- data/sig/rooibos/message.rbs +66 -2
- data/sig/rooibos/shortcuts.rbs +14 -0
- data/sig/rooibos/test_helper.rbs +6 -2
- data/sig/rooibos/welcome.rbs +75 -0
- data/tasks/install.rake +29 -0
- data/tasks/resources/build.yml.erb +2 -0
- metadata +274 -38
- data/doc/concepts/application_architecture.md +0 -197
- data/doc/concepts/application_testing.md +0 -49
- data/doc/concepts/async_work.md +0 -164
- data/doc/concepts/commands.md +0 -530
- data/doc/concepts/message_processing.md +0 -51
- data/doc/contributors/WIP/decomposition_strategies_analysis.md +0 -258
- data/doc/contributors/WIP/implementation_plan.md +0 -409
- data/doc/contributors/WIP/init_callable_proposal.md +0 -344
- data/doc/contributors/WIP/runtime_refactoring_status.md +0 -47
- data/doc/contributors/WIP/task.md +0 -36
- data/doc/contributors/WIP/v0.4.0_todo.md +0 -468
- data/doc/contributors/kit-no-outlet.md +0 -238
- data/doc/contributors/priorities.md +0 -38
- data/doc/images/.gitkeep +0 -0
- data/exe/.gitkeep +0 -0
- /data/doc/contributors/{WIP → design}/mvu_tea_implementations_research.md +0 -0
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
-
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
-
-->
|
|
5
|
-
|
|
6
|
-
# Application Architecture
|
|
7
|
-
|
|
8
|
-
Build robust TUI applications with Rooibos patterns.
|
|
9
|
-
|
|
10
|
-
## Core Concepts
|
|
11
|
-
|
|
12
|
-
_This section is incomplete. Check the source files._
|
|
13
|
-
|
|
14
|
-
## Thread and Ractor Safety
|
|
15
|
-
|
|
16
|
-
### The Strategic Context
|
|
17
|
-
|
|
18
|
-
Ruby 4.0 introduces [Ractors](https://docs.ruby-lang.org/en/4.0/Ractor.html)—
|
|
19
|
-
true parallel actors that forbid shared mutable state. Code that passes
|
|
20
|
-
mutable objects between threads crashes in a Ractor world.
|
|
21
|
-
|
|
22
|
-
Rooibos prepares you today. The runtime enforces Ractor-shareability on every
|
|
23
|
-
Model and Message *now*, using standard threads. Pass a mutable object,
|
|
24
|
-
and it raises an error immediately. Write Ractor-safe code today; upgrade
|
|
25
|
-
to Ruby 4.0 without changes tomorrow.
|
|
26
|
-
|
|
27
|
-
Enforce immutability rules before you strictly need them, and the migration
|
|
28
|
-
is invisible.
|
|
29
|
-
|
|
30
|
-
### The Problem
|
|
31
|
-
|
|
32
|
-
Ruby's Ractor model prevents data races by forbidding shared mutable state.
|
|
33
|
-
Mutable objects cause runtime errors:
|
|
34
|
-
|
|
35
|
-
```
|
|
36
|
-
RatatuiRuby::Error::Invariant: Model is not Ractor-shareable.
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
### The Solution
|
|
40
|
-
|
|
41
|
-
Use `Ractor.make_shareable`. It recursively freezes everything:
|
|
42
|
-
|
|
43
|
-
```ruby
|
|
44
|
-
Ractor.make_shareable(model.with(text: "#{model.text}#{char}"))
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
For constants, wrap INITIAL:
|
|
48
|
-
|
|
49
|
-
```ruby
|
|
50
|
-
INITIAL = Ractor.make_shareable(
|
|
51
|
-
Model.new(text: "", running: false, chunks: [])
|
|
52
|
-
)
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
For collection updates:
|
|
56
|
-
|
|
57
|
-
```ruby
|
|
58
|
-
new_chunks = Ractor.make_shareable([*model.chunks, new_item])
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
### The Lightweight Alternative
|
|
62
|
-
|
|
63
|
-
When you know exactly what's mutable, `.freeze` is shorter:
|
|
64
|
-
|
|
65
|
-
```ruby
|
|
66
|
-
[model.with(text: "#{model.text}#{char}".freeze), nil]
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Foot-Guns
|
|
70
|
-
|
|
71
|
-
#### frozen_string_literal Only Affects Literals
|
|
72
|
-
|
|
73
|
-
The magic comment freezes strings that appear directly in source code.
|
|
74
|
-
Computed strings are mutable.
|
|
75
|
-
|
|
76
|
-
```ruby
|
|
77
|
-
# frozen_string_literal: true
|
|
78
|
-
|
|
79
|
-
"literal" # frozen ✓
|
|
80
|
-
"#{var}" # mutable ✗
|
|
81
|
-
str.chop # mutable ✗
|
|
82
|
-
str + other # mutable ✗
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
#### Data.define Needs Shareable Values
|
|
86
|
-
|
|
87
|
-
`Data.define` creates frozen instances. The instance is Ractor-shareable
|
|
88
|
-
only when all its values are shareable.
|
|
89
|
-
|
|
90
|
-
### Quick Reference
|
|
91
|
-
|
|
92
|
-
| Pattern | Code |
|
|
93
|
-
|---------|------|
|
|
94
|
-
| Make anything shareable | `Ractor.make_shareable(obj)` |
|
|
95
|
-
| Freeze a string | `str.freeze` |
|
|
96
|
-
| INITIAL constant | `Ractor.make_shareable(Model.new(...))` |
|
|
97
|
-
| Array update | `Ractor.make_shareable([*old, new])` |
|
|
98
|
-
|
|
99
|
-
### Debugging
|
|
100
|
-
|
|
101
|
-
See this error?
|
|
102
|
-
|
|
103
|
-
```
|
|
104
|
-
RatatuiRuby::Error::Invariant: Model is not Ractor-shareable.
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Wrap the returned model with `Ractor.make_shareable`.
|
|
108
|
-
|
|
109
|
-
## Modals and Command Result Routing
|
|
110
|
-
|
|
111
|
-
Modals capture keyboard input. They overlay the main UI and intercept keypresses until dismissed. But async commands keep running in the background. When their results arrive, they look like any other message.
|
|
112
|
-
|
|
113
|
-
It's tempting to intercept *all* messages when the modal is active. This swallows those command results.
|
|
114
|
-
|
|
115
|
-
### The Scenario
|
|
116
|
-
|
|
117
|
-
1. User presses "u" to fetch uptime. The runtime dispatches an async command.
|
|
118
|
-
2. User presses "c" to open a modal dialog.
|
|
119
|
-
3. The uptime command completes. It sends `[:network, :uptime, { stdout:, ... }]`.
|
|
120
|
-
4. The modal intercept sees the modal is active. It routes that message to the modal.
|
|
121
|
-
5. The modal ignores it.
|
|
122
|
-
6. The uptime panel never updates.
|
|
123
|
-
|
|
124
|
-
### The Fix
|
|
125
|
-
|
|
126
|
-
Route command results before modal interception. Modals intercept user input, not async results.
|
|
127
|
-
|
|
128
|
-
<!-- SPDX-SnippetBegin -->
|
|
129
|
-
<!--
|
|
130
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
131
|
-
SPDX-License-Identifier: MIT-0
|
|
132
|
-
-->
|
|
133
|
-
```ruby
|
|
134
|
-
# Wrong: modal intercepts everything
|
|
135
|
-
UPDATE = lambda do |message, model|
|
|
136
|
-
if Modal.active?(model.modal)
|
|
137
|
-
# Swallows command results
|
|
138
|
-
return Modal::UPDATE.call(message, model.modal)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
case message
|
|
142
|
-
in [:network, *rest]
|
|
143
|
-
# Never reached while modal is open
|
|
144
|
-
end
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Correct: route command results first
|
|
148
|
-
UPDATE = lambda do |message, model|
|
|
149
|
-
# 1. Route async command results (always)
|
|
150
|
-
case message
|
|
151
|
-
in [:network, *rest]
|
|
152
|
-
return [model.with(network: new_network), command]
|
|
153
|
-
in [:stats, *rest]
|
|
154
|
-
return [model.with(stats: new_stats), command]
|
|
155
|
-
else
|
|
156
|
-
nil
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# 2. Modal intercepts user input
|
|
160
|
-
if Modal.active?(model.modal)
|
|
161
|
-
return Modal::UPDATE.call(message, model.modal)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
# 3. Handle other input
|
|
165
|
-
case message
|
|
166
|
-
in _ if message.q? then Command.exit
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
```
|
|
170
|
-
<!-- SPDX-SnippetEnd -->
|
|
171
|
-
|
|
172
|
-
### The Router DSL
|
|
173
|
-
|
|
174
|
-
`Rooibos::Router` handles this correctly. Routes declared with `route :prefix, to: ChildModule` process before keymap handlers. Command results flow through even when guards block keyboard input.
|
|
175
|
-
|
|
176
|
-
<!-- SPDX-SnippetBegin -->
|
|
177
|
-
<!--
|
|
178
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
179
|
-
SPDX-License-Identifier: MIT-0
|
|
180
|
-
-->
|
|
181
|
-
|
|
182
|
-
```ruby
|
|
183
|
-
|
|
184
|
-
module Dashboard
|
|
185
|
-
include Rooibos::Router
|
|
186
|
-
|
|
187
|
-
route :stats, to: StatsPanel
|
|
188
|
-
route :network, to: NetworkPanel
|
|
189
|
-
|
|
190
|
-
keymap do
|
|
191
|
-
only when: MODAL_INACTIVE do
|
|
192
|
-
key "u", -> { Uptime.fetch_command }
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
```
|
|
197
|
-
<!-- SPDX-SnippetEnd -->
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
-
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
-
-->
|
|
5
|
-
# Application Testing Guide
|
|
6
|
-
|
|
7
|
-
This guide explains how to test your RatatuiRuby applications using the provided `RatatuiRuby::TestHelper`.
|
|
8
|
-
|
|
9
|
-
## Overview
|
|
10
|
-
|
|
11
|
-
You need to verify that your application looks and behaves correctly. Manually checking every character on a terminal screen is tedious. Dealing with race conditions and complex state management in tests creates friction.
|
|
12
|
-
|
|
13
|
-
The `TestHelper` module solves this. It provides a headless "test terminal" to capture output and a suite of robust assertions to verify state.
|
|
14
|
-
|
|
15
|
-
Use it to write fast, deterministic tests for your TUI applications.
|
|
16
|
-
|
|
17
|
-
## Setup
|
|
18
|
-
|
|
19
|
-
First, require the test helper in your test file or `test_helper.rb`:
|
|
20
|
-
|
|
21
|
-
<!-- SPDX-SnippetBegin -->
|
|
22
|
-
<!--
|
|
23
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
24
|
-
SPDX-License-Identifier: MIT-0
|
|
25
|
-
-->
|
|
26
|
-
```ruby
|
|
27
|
-
require "ratatui_ruby/test_helper"
|
|
28
|
-
require "minitest/autorun" # or your preferred test framework
|
|
29
|
-
```
|
|
30
|
-
<!-- SPDX-SnippetEnd -->
|
|
31
|
-
|
|
32
|
-
Then, include the module in your test class:
|
|
33
|
-
|
|
34
|
-
<!-- SPDX-SnippetBegin -->
|
|
35
|
-
<!--
|
|
36
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
37
|
-
SPDX-License-Identifier: MIT-0
|
|
38
|
-
-->
|
|
39
|
-
```ruby
|
|
40
|
-
class MyApplicationTest < Minitest::Test
|
|
41
|
-
include RatatuiRuby::TestHelper
|
|
42
|
-
# ...
|
|
43
|
-
end
|
|
44
|
-
```
|
|
45
|
-
<!-- SPDX-SnippetEnd -->
|
|
46
|
-
|
|
47
|
-
## Writing Tests
|
|
48
|
-
|
|
49
|
-
_Because this gem is in pre-release, it lacks documentation. Please check the source files and automated tests._
|
data/doc/concepts/async_work.md
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
3
|
-
SPDX-License-Identifier: CC-BY-SA-4.0
|
|
4
|
-
-->
|
|
5
|
-
# Async Work
|
|
6
|
-
|
|
7
|
-
## Context
|
|
8
|
-
|
|
9
|
-
Your application does concurrent work. It fetches from multiple APIs. It reads from sockets. It processes streams. These operations overlap in time.
|
|
10
|
-
|
|
11
|
-
## Problem
|
|
12
|
-
|
|
13
|
-
Threads are hard. Exceptions in spawned threads vanish silently. The main thread never learns what happened. Your application hangs, waiting for messages that will never arrive. Debugging this is miserable.
|
|
14
|
-
|
|
15
|
-
## Solution
|
|
16
|
-
|
|
17
|
-
Rooibos handles concurrency for you. Two patterns cover nearly every case. Use them instead of raw threads.
|
|
18
|
-
|
|
19
|
-
## Pattern 1: Command Orchestration
|
|
20
|
-
|
|
21
|
-
Compose child commands instead of spawning threads. Use <tt>out.source</tt> for sequential steps. Use <tt>Command.all</tt> for parallel steps.
|
|
22
|
-
|
|
23
|
-
<!-- SPDX-SnippetBegin -->
|
|
24
|
-
<!--
|
|
25
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
26
|
-
SPDX-License-Identifier: MIT-0
|
|
27
|
-
-->
|
|
28
|
-
```ruby
|
|
29
|
-
class LoadDashboard < Data.define(:user_id, :tag)
|
|
30
|
-
include Rooibos::Command::Custom
|
|
31
|
-
|
|
32
|
-
def call(out, token)
|
|
33
|
-
# Step 1: Authenticate (sequential - we need the token first)
|
|
34
|
-
auth = out.source(Authenticate.new(user_id:, tag: :_), token)
|
|
35
|
-
return if auth.nil? || token.canceled?
|
|
36
|
-
|
|
37
|
-
# Step 2: Fetch dashboard data in parallel, waiting for all to complete
|
|
38
|
-
dashboard = out.source(
|
|
39
|
-
Command.all(:_, [
|
|
40
|
-
FetchProfile.new(token: auth[:token], tag: :profile),
|
|
41
|
-
FetchNotifications.new(token: auth[:token], tag: :notifications),
|
|
42
|
-
FetchWeather.new(tag: :weather)
|
|
43
|
-
]),
|
|
44
|
-
token
|
|
45
|
-
)
|
|
46
|
-
return if dashboard.nil? || token.canceled?
|
|
47
|
-
|
|
48
|
-
# Step 3: Send a message to the update with the dashboard data
|
|
49
|
-
out.put(tag, dashboard.results)
|
|
50
|
-
return if token.canceled?
|
|
51
|
-
|
|
52
|
-
# Step 4: Log the access (sequential - after we have data)
|
|
53
|
-
out.source(LogAccess.new(user_id:, tag: :_), token)
|
|
54
|
-
return if token.canceled?
|
|
55
|
-
|
|
56
|
-
# COMING SOON: out.source_nonblock
|
|
57
|
-
# Step 5: Watch for new data via HTTP Server-Sent Events, handling parallel streaming data
|
|
58
|
-
# 5a: Pass messages from the StreamNotifications custom command directly to LoadDashboard's out.put
|
|
59
|
-
notifications = out.source_nonblocki(
|
|
60
|
-
StreamNotifications.new(:user_id, auth[:token]),
|
|
61
|
-
token,
|
|
62
|
-
)
|
|
63
|
-
# 5b: Do work to the messages before sending them to the update function
|
|
64
|
-
deltas = out.source_nonblock(
|
|
65
|
-
StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]),
|
|
66
|
-
token,
|
|
67
|
-
DeltaPostProcessor.new(:user_id)
|
|
68
|
-
)
|
|
69
|
-
# 5c: block this command on both async outsourced commands finishing
|
|
70
|
-
out.last(notifications, deltas)
|
|
71
|
-
return if token.canceled?
|
|
72
|
-
|
|
73
|
-
# Step 6: Log completion
|
|
74
|
-
out.source(LogAccess.new(user_id:, tag: :_, finished: true), token)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
```
|
|
78
|
-
<!-- SPDX-SnippetEnd -->
|
|
79
|
-
|
|
80
|
-
<tt>out.source</tt> blocks until the child command finishes. Pass <tt>Command.all</tt> to run children in parallel. Exceptions propagate correctly. Cancellation stops the workflow at any point.
|
|
81
|
-
|
|
82
|
-
Use this for any multi-step workflow with dependencies between stages.
|
|
83
|
-
|
|
84
|
-
## Pattern 2: Multiplexed I/O
|
|
85
|
-
|
|
86
|
-
Read from multiple sources without threads. Ruby's <tt>IO.select</tt> waits for any of several IOs to become ready.
|
|
87
|
-
|
|
88
|
-
<!-- SPDX-SnippetBegin -->
|
|
89
|
-
<!--
|
|
90
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
91
|
-
SPDX-License-Identifier: MIT-0
|
|
92
|
-
-->
|
|
93
|
-
```ruby
|
|
94
|
-
class MultiSocketReader < Data.define(:sockets, :tag)
|
|
95
|
-
include Rooibos::Command::Custom
|
|
96
|
-
|
|
97
|
-
def call(out, token)
|
|
98
|
-
remaining = sockets.dup
|
|
99
|
-
|
|
100
|
-
until remaining.empty? || token.canceled?
|
|
101
|
-
# Wait up to 0.1s for any socket to have data
|
|
102
|
-
ready = IO.select(remaining, nil, nil, 0.1)
|
|
103
|
-
next unless ready
|
|
104
|
-
|
|
105
|
-
ready[0].each do |socket|
|
|
106
|
-
data = socket.read_nonblock(4096, exception: false)
|
|
107
|
-
case data
|
|
108
|
-
when :wait_readable
|
|
109
|
-
next
|
|
110
|
-
when nil
|
|
111
|
-
remaining.delete(socket)
|
|
112
|
-
else
|
|
113
|
-
out.put(:data, { socket: socket, chunk: data })
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
out.put(tag, :complete)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
```
|
|
122
|
-
<!-- SPDX-SnippetEnd -->
|
|
123
|
-
|
|
124
|
-
<tt>IO.select</tt> multiplexes reads across sockets, pipes, or files. One thread handles many connections. No spawned threads means no silent failures.
|
|
125
|
-
|
|
126
|
-
Use this for chat clients, log tailers, or any multi-stream scenario.
|
|
127
|
-
|
|
128
|
-
## Why Not Threads?
|
|
129
|
-
|
|
130
|
-
You might wonder: "Why can't I just spawn a thread?"
|
|
131
|
-
|
|
132
|
-
<!-- SPDX-SnippetBegin -->
|
|
133
|
-
<!--
|
|
134
|
-
SPDX-FileCopyrightText: 2026 Kerrick Long
|
|
135
|
-
SPDX-License-Identifier: MIT-0
|
|
136
|
-
-->
|
|
137
|
-
```ruby
|
|
138
|
-
# ❌ Don't do this
|
|
139
|
-
def call(out, token)
|
|
140
|
-
Thread.new do
|
|
141
|
-
data = fetch_something
|
|
142
|
-
out.put(:result, data)
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
```
|
|
146
|
-
<!-- SPDX-SnippetEnd -->
|
|
147
|
-
|
|
148
|
-
This looks harmless. It hides a trap.
|
|
149
|
-
|
|
150
|
-
If <tt>fetch_something</tt> raises, the exception happens in the spawned thread. Ruby logs it. The main thread never sees it. Your <tt>call</tt> method returns. The runtime considers the command complete. But <tt>out.put</tt> never ran. Your update function waits for <tt>:result</tt> forever.
|
|
151
|
-
|
|
152
|
-
The runtime cannot protect you from this. Threads spawned inside your command escape its error handling. The framework wraps <tt>call</tt> in a rescue. It does not wrap threads you create.
|
|
153
|
-
|
|
154
|
-
Use the patterns above instead. They keep errors visible.
|
|
155
|
-
|
|
156
|
-
## Choosing the Right Pattern
|
|
157
|
-
|
|
158
|
-
| Situation | Pattern |
|
|
159
|
-
|-----------|---------|
|
|
160
|
-
| Multi-step workflows | <tt>out.source</tt> + <tt>Command.all</tt> |
|
|
161
|
-
| Read from multiple sockets/pipes | <tt>IO.select</tt> |
|
|
162
|
-
| One blocking operation | Just do it in <tt>call</tt> |
|
|
163
|
-
|
|
164
|
-
Commands already run off the main thread. You rarely need additional concurrency. When you do, these patterns handle the hard parts.
|