ratatui_ruby-tea 0.3.0 → 0.4.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +51 -7
  3. data/CHANGELOG.md +109 -0
  4. data/README.md +25 -5
  5. data/Rakefile +1 -1
  6. data/Steepfile +3 -3
  7. data/doc/concepts/async_work.md +164 -0
  8. data/doc/concepts/commands.md +528 -0
  9. data/doc/concepts/message_processing.md +51 -0
  10. data/doc/contributors/WIP/decomposition_strategies_analysis.md +258 -0
  11. data/doc/contributors/WIP/implementation_plan.md +405 -0
  12. data/doc/contributors/WIP/init_callable_proposal.md +341 -0
  13. data/doc/contributors/WIP/mvu_tea_implementations_research.md +372 -0
  14. data/doc/contributors/WIP/runtime_refactoring_status.md +47 -0
  15. data/doc/contributors/WIP/task.md +36 -0
  16. data/doc/contributors/WIP/v0.4.0_todo.md +468 -0
  17. data/doc/contributors/design/commands_and_outlets.md +214 -0
  18. data/doc/contributors/kit-no-outlet.md +237 -0
  19. data/doc/contributors/priorities.md +22 -24
  20. data/examples/app_fractal_dashboard/app.rb +3 -7
  21. data/examples/app_fractal_dashboard/dashboard/base.rb +15 -16
  22. data/examples/app_fractal_dashboard/dashboard/update_helpers.rb +8 -8
  23. data/examples/app_fractal_dashboard/dashboard/update_manual.rb +11 -11
  24. data/examples/app_fractal_dashboard/dashboard/update_router.rb +4 -4
  25. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_input.rb +8 -4
  26. data/examples/app_fractal_dashboard/fragments/custom_shell_modal.rb +82 -0
  27. data/examples/app_fractal_dashboard/{bags → fragments}/custom_shell_output.rb +8 -4
  28. data/examples/app_fractal_dashboard/{bags → fragments}/disk_usage.rb +13 -10
  29. data/examples/app_fractal_dashboard/{bags → fragments}/network_panel.rb +12 -12
  30. data/examples/app_fractal_dashboard/{bags → fragments}/ping.rb +12 -8
  31. data/examples/app_fractal_dashboard/{bags → fragments}/stats_panel.rb +12 -12
  32. data/examples/app_fractal_dashboard/{bags → fragments}/system_info.rb +11 -7
  33. data/examples/app_fractal_dashboard/{bags → fragments}/uptime.rb +11 -7
  34. data/examples/verify_readme_usage/README.md +7 -4
  35. data/examples/verify_readme_usage/app.rb +7 -4
  36. data/lib/ratatui_ruby/tea/command/all.rb +71 -0
  37. data/lib/ratatui_ruby/tea/command/batch.rb +79 -0
  38. data/lib/ratatui_ruby/tea/command/custom.rb +106 -0
  39. data/lib/ratatui_ruby/tea/command/http.rb +194 -0
  40. data/lib/ratatui_ruby/tea/command/lifecycle.rb +136 -0
  41. data/lib/ratatui_ruby/tea/command/outlet.rb +159 -0
  42. data/lib/ratatui_ruby/tea/command/wait.rb +82 -0
  43. data/lib/ratatui_ruby/tea/command.rb +416 -13
  44. data/lib/ratatui_ruby/tea/message/all.rb +47 -0
  45. data/lib/ratatui_ruby/tea/message/http_response.rb +63 -0
  46. data/lib/ratatui_ruby/tea/message/system/batch.rb +63 -0
  47. data/lib/ratatui_ruby/tea/message/system/stream.rb +69 -0
  48. data/lib/ratatui_ruby/tea/message/timer.rb +48 -0
  49. data/lib/ratatui_ruby/tea/message.rb +40 -0
  50. data/lib/ratatui_ruby/tea/router.rb +155 -87
  51. data/lib/ratatui_ruby/tea/runtime.rb +329 -150
  52. data/lib/ratatui_ruby/tea/shortcuts.rb +2 -2
  53. data/lib/ratatui_ruby/tea/test_helper.rb +58 -0
  54. data/lib/ratatui_ruby/tea/version.rb +1 -1
  55. data/lib/ratatui_ruby/tea.rb +44 -10
  56. data/rbs_collection.lock.yaml +108 -0
  57. data/rbs_collection.yaml +15 -0
  58. data/sig/concurrent.rbs +72 -0
  59. data/sig/examples/verify_readme_usage/app.rbs +1 -1
  60. data/sig/examples/widget_command_system/app.rbs +1 -1
  61. data/sig/open3.rbs +17 -0
  62. data/sig/ratatui_ruby/tea/command.rbs +226 -6
  63. data/sig/ratatui_ruby/tea/message.rbs +123 -0
  64. data/sig/ratatui_ruby/tea/router.rbs +110 -54
  65. data/sig/ratatui_ruby/tea/runtime.rbs +63 -12
  66. data/sig/ratatui_ruby/tea/shortcuts.rbs +18 -0
  67. data/sig/ratatui_ruby/tea/test_helper.rbs +12 -0
  68. data/sig/ratatui_ruby/tea/version.rbs +10 -0
  69. data/sig/ratatui_ruby/tea.rbs +39 -7
  70. data/tasks/steep.rake +11 -0
  71. metadata +75 -12
  72. data/examples/app_fractal_dashboard/bags/custom_shell_modal.rb +0 -73
@@ -0,0 +1,47 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+
4
+ SPDX-License-Identifier: CC-BY-SA-4.0
5
+ -->
6
+
7
+ # Runtime Refactoring - Final Status
8
+
9
+ ## ✅ Completed
10
+
11
+ 1. **CHANGE LOG documented** - Breaking changes added:
12
+ - Runtime API signature (positional fragment, fps param, removed argv/env, renamed init to command)
13
+ - Model validation timing (now validates immediately after Init)
14
+
15
+ 2. **RBS Signatures** - Fully updated for new signature
16
+
17
+ 3. **Tea.run signatures** - All test files updated from `fragment:` to positional
18
+
19
+ 4. **Model Ractor-shareability** - All tests fixed with `Ractor.make_shareable(..., copy: true)`
20
+
21
+ ## ⚠️ Remaining Issues (3 errors from agent_rake)
22
+
23
+ ### 1. test_runtime.rb:237 - Test body deleted by accident
24
+ **File**: `test/test_runtime.rb`
25
+ **Issue**: Deleted lines 237-245 which contained the test body for `test_init_triggers_update_before_first_event`
26
+
27
+ **Fix needed**: Restore test body. The test should validate that `command:` parameter gets dispatched at startup.
28
+
29
+ ### 2. test_fragment_first_api.rb:48 - Missing with_argv helper
30
+ **File**: `test/test_fragment_first_api.rb`
31
+ **Issue**: `NoMethodError: undefined method 'with_argv'`
32
+
33
+ **Fix needed**: Add `with_argv` helper to `/Users/kerrick/Developer/ratatui_ruby/test/test_helper.rb` (already documented earlier in conversation)
34
+
35
+ ### 3. test_runtime_timer.rb:155 - Cancel assertion
36
+ **File**: `test/test_runtime_timer.rb`
37
+ **Issue**: Assertion compares Cancel object vs Wait command directly
38
+
39
+ **Current**: `assert_same original_cmd, cancel_msg`
40
+ **Should be**: `assert_same original_cmd, cancel_msg.handle`
41
+
42
+ The sed command ran but didn't match because cancel_msg is on its own line.
43
+
44
+ ## Summary
45
+
46
+ **272 runs, 779 assertions, 2 failures, 1 errors from 95+ errors**
47
+ Major progress! Just need to fix these 3 small issues.
@@ -0,0 +1,36 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+
4
+ SPDX-License-Identifier: CC-BY-SA-4.0
5
+ -->
6
+
7
+ # Task: Update Tests and Docs for Runtime Refactoring
8
+
9
+ ## Signature Changes
10
+ - [x] Update RBS signatures for new Runtime.run signature
11
+ - [x] Add with_argv helper to ratatui_ruby test_helper
12
+ - [x] Update Tea.run wrapper signature
13
+
14
+ ## Test Updates
15
+ - [x] Update test_fragment_first_api.rb - change fragment: to positional
16
+ - [x] Fix argv/env tests to use with_argv/with_env helpers
17
+ - [x] Fix all Command.custom tests to use Ractor-shareable callables
18
+ - [ ] Fix test_init_triggers_update_before_first_event - still failing
19
+ - [ ] Fix test_fragment_first_api_passes_argv_and_env_to_init - ENV hash comparison issue
20
+ - [ ] Update test_snapshots.rb - change all Tea.run calls (15+ occurrences)
21
+ - [ ] Update test_fractal_dashboard.rb - change Tea.run calls
22
+ - [ ] Update all other test files using Tea.run
23
+ - [ ] Add test for fps: parameter
24
+
25
+ ## Example Updates
26
+ - [ ] Update verify_readme_usage/app.rb
27
+ - [ ] Update app_fractal_dashboard Init callables (remove argv/env params)
28
+
29
+ ## Current Status
30
+ **3 failures, 1 error**: Making final fixes
31
+ - test_init_triggers_update_before_first_event - still needs investigation
32
+ - test_fragment_first_api_passes_argv_and_env_to_init - ENV not being passed correctly
33
+ - test_cancelled_wait_acknowledges_cancellation - intermittent
34
+ - test_custom_accepts_block - fixing Command.custom wrapper pattern
35
+
36
+ Restructuring Command.custom tests to demonstrate automatic shareability handling.
@@ -0,0 +1,468 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # v0.4.0 Remaining TODOs
7
+
8
+ Outstanding items before releasing v0.4.0. Each section is self-contained with enough detail to implement independently.
9
+
10
+ ---
11
+
12
+ ## 1. ~~Rename `tag` → `envelope` in Command.system and Command.all~~ ✅ DONE
13
+
14
+ ---
15
+
16
+ ## 2. ~~Command.all Must Emit Message::All Instead of Raw Arrays~~ ✅ DONE
17
+
18
+ ---
19
+
20
+ # v0.4.1 Remaining TODOs
21
+
22
+ Outstanding items before releasing v0.4.1. Each section is self-contained with enough detail to implement independently.
23
+
24
+ ---
25
+
26
+ ## 3. Add Default `deconstruct_keys` to Command::Custom
27
+
28
+ ### Problem
29
+ Design doc (lines 786-797) specifies that custom commands should have a default `deconstruct_keys` for hash-based pattern matching. Currently missing from `lib/ratatui_ruby/tea/command/custom.rb`.
30
+
31
+ ### Implementation
32
+
33
+ Add to `lib/ratatui_ruby/tea/command/custom.rb` in the `Custom` module:
34
+
35
+ ```ruby
36
+ # Methods inherited from common ancestor classes that are infrastructure,
37
+ # not domain-specific query methods. Computed once from bare prototypes.
38
+ INFRASTRUCTURE_METHODS = begin
39
+ bare_data = Data.define(:_)
40
+ bare_struct = Struct.new(:_)
41
+
42
+ methods = Object.public_instance_methods +
43
+ bare_data.public_instance_methods +
44
+ bare_struct.public_instance_methods
45
+
46
+ # Include OpenStruct only if already loaded (don't require it ourselves)
47
+ if defined?(OpenStruct)
48
+ methods += OpenStruct.new(_: nil).public_instance_methods
49
+ end
50
+
51
+ methods.uniq.freeze
52
+ end
53
+ private_constant :INFRASTRUCTURE_METHODS
54
+
55
+ # Deconstructs for pattern matching.
56
+ #
57
+ # Introspects the object and builds a hash from all public query methods
58
+ # (following CQS: methods with no side effects that return values).
59
+ # Excludes infrastructure methods from Object, Data, and Struct to focus
60
+ # on domain-specific fields.
61
+ #
62
+ # Always includes :type as a snake_case symbol of the class name.
63
+ # Data.define members are automatically included since they generate
64
+ # public accessor methods.
65
+ #
66
+ # This is a naive but practical default. Override for:
67
+ # - Hot paths (introspects methods on every call)
68
+ # - Ghost methods via method_missing/respond_to_missing?
69
+ # - Methods with optional arguments (only zero-arity detected)
70
+ #
71
+ # === Example
72
+ #
73
+ # # For a Data.define(:envelope, :status, :body) command:
74
+ # case msg
75
+ # in { type: :http_response, envelope: :users, status: 200 }
76
+ # # handle success
77
+ # end
78
+ def deconstruct_keys(keys)
79
+ # Snake-case class name as type discriminator
80
+ type_name = self.class.name.split("::").last
81
+ .gsub(/([a-z])([A-Z])/, '\1_\2')
82
+ .downcase
83
+ .to_sym
84
+
85
+ # Find public query methods: zero arity, not infrastructure, not commands/setters
86
+ query_methods = (public_methods - INFRASTRUCTURE_METHODS)
87
+ .select { |m| method(m).arity.zero? }
88
+ .reject { |m| m.to_s.end_with?("=", "!") }
89
+
90
+ # Build hash: filter by requested keys if provided
91
+ result = { type: type_name }
92
+ query_methods.each do |m|
93
+ result[m] = public_send(m) if keys.nil? || keys.include?(m)
94
+ end
95
+ result
96
+ end
97
+ ```
98
+
99
+ This provides a smart default that:
100
+ - Computes infrastructure methods once from bare `Data.define`, `Struct`, and `OpenStruct` prototypes
101
+ - Catches `members`, `with`, `to_h`, `deconstruct`, `deconstruct_keys`, etc. automatically
102
+ - No hardcoded method lists — if Ruby adds new methods to these classes, they're excluded
103
+ - Includes all Data.define members automatically (they generate accessor methods)
104
+ - Excludes commands (methods ending in `!`) and setters (ending in `=`)
105
+ - Respects the `keys` argument for performance (only calls methods for requested keys)
106
+
107
+ ### Limitations
108
+
109
+ This is a **naive but practical** default:
110
+
111
+ 1. **Performance**: Introspects methods on every call. For hot paths, app devs should override with a static implementation.
112
+ 2. **Metaprogramming**: Ghost methods via `method_missing`/`respond_to_missing?` don't appear in `public_methods`. RBS introspection could help but is overkill for a default.
113
+ 3. **Custom accessors**: Only zero-arity methods are included. Methods with optional args won't be detected.
114
+
115
+ **App devs who need more control should override `deconstruct_keys` directly.**
116
+
117
+ ---
118
+
119
+ ## 4. Update Doc Comments to Show Hash Pattern Matching
120
+
121
+ ### Problem
122
+ Doc comments in `lib/ratatui_ruby/tea/command.rb` show array-based pattern matching (old style) instead of hash-based (new style per Appendix A).
123
+
124
+ ### Files to Update
125
+
126
+ **lib/ratatui_ruby/tea/command.rb** — Lines 326-350 (Command.system examples):
127
+
128
+ ```ruby
129
+ # Current (wrong):
130
+ # in [:got_files, {stdout:, status: 0}]
131
+ # [model.with(files: stdout.lines), nil]
132
+ # in [:log, :stdout, line]
133
+ # [model.with(lines: [*model.lines, line]), nil]
134
+
135
+ # Correct:
136
+ # in { type: :system, envelope: :got_files, stdout:, status: 0 }
137
+ # [model.with(files: stdout.lines), nil]
138
+ # in { type: :system, envelope: :log, stream: :stdout, content: line }
139
+ # [model.with(lines: [*model.lines, line]), nil]
140
+ ```
141
+
142
+ Also update:
143
+ - `lib/ratatui_ruby/tea.rb` lines 106, 112: Change `::UPDATE` → `::Update` in examples
144
+ - `lib/ratatui_ruby/tea/router.rb` lines 19-20, 37: Change `INITIAL`, `UPDATE`, `VIEW` → `Init`, `Update`, `View` in docstrings
145
+
146
+ ---
147
+
148
+ ## 5. Update widget_command_system Example
149
+
150
+ ### Problem
151
+ `examples/widget_command_system/app.rb` uses outdated conventions:
152
+ - Screaming-case constants: `INITIAL`, `VIEW`, `UPDATE`
153
+ - Old `Tea.run` API: `Tea.run(model:, view:, update:)`
154
+
155
+ ### Implementation
156
+
157
+ 1. Rename constants (lines 28, 34, 100):
158
+ - `INITIAL` → `Init` (make it a lambda: `Init = -> { Model.new(...) }`)
159
+ - `VIEW` → `View`
160
+ - `UPDATE` → `Update`
161
+
162
+ 2. Change `Tea.run` call (line 128):
163
+ ```ruby
164
+ # Current:
165
+ RatatuiRuby::Tea.run(model: INITIAL, view: VIEW, update: UPDATE)
166
+
167
+ # Correct (Fragment-first API):
168
+ RatatuiRuby::Tea.run(WidgetCommandSystem)
169
+ ```
170
+
171
+ 3. Update pattern matching in `Update` lambda (lines 103-106) to use hash-based format:
172
+ ```ruby
173
+ # Current:
174
+ in [:got_output, { stdout:, status: 0 }]
175
+
176
+ # Correct:
177
+ in { type: :system, envelope: :got_output, stdout:, status: 0 }
178
+ ```
179
+
180
+ 4. Fix variable shadowing bugs on lines 117, 121: `command` vs `cmd`
181
+
182
+ ---
183
+
184
+ ## 6. Tea.delegate Array Wrapping (Lower Priority)
185
+
186
+ ### Problem
187
+ Line 853 of design doc states: "Tea's delegate helper should probably not wrap in arrays."
188
+
189
+ Currently `Tea.delegate` (lib/ratatui_ruby/tea.rb:116) passes `rest = message[1..]` as an array to child update. The design suggests passing unwrapped messages.
190
+
191
+ ### Consideration
192
+ This may be intentional for consistency. Review whether child updates expect array or unwrapped messages before changing. If changing:
193
+
194
+ ```ruby
195
+ # Current:
196
+ rest = message[1..]
197
+ new_child, command = child_update.call(rest, child_model)
198
+
199
+ # Potentially:
200
+ rest = message[1] # Single value, not array
201
+ new_child, command = child_update.call(rest, child_model)
202
+ ```
203
+
204
+ **Note:** This change may break existing code. Audit all `Tea.delegate` callers first.
205
+
206
+ ---
207
+
208
+ ## 7. Missing Tests (Technical Debt)
209
+
210
+ ### Phase 3: Mixed Command Types Test
211
+ Add test demonstrating `Command.batch` or `Command.all` with heterogeneous child commands:
212
+
213
+ ```ruby
214
+ def test_batch_with_mixed_command_types
215
+ cmd = Command.batch(
216
+ Command.http(:get, "/api/users", :users),
217
+ Command.wait(0.1, :timer),
218
+ Command.system("echo hello", :shell)
219
+ )
220
+ # Verify all three types execute and return properly
221
+ end
222
+ ```
223
+
224
+ ### Phase 5: Sync→Parallel→Sync Flow Test
225
+ Add test demonstrating `Outlet#source` orchestration:
226
+
227
+ ```ruby
228
+ def test_source_sync_parallel_sync_flow
229
+ # Custom command that:
230
+ # 1. source(single_command) - sync
231
+ # 2. source(Command.all([...]) - parallel
232
+ # 3. source(single_command) - sync again
233
+ # Verify correct sequencing and result handling
234
+ end
235
+ ```
236
+
237
+ ---
238
+
239
+ ## 8. Update test_widget_command_system.rb
240
+
241
+ ### Problem
242
+ Uses `::UPDATE` and `::INITIAL` screaming-case (lines 14, 17, 28, 31).
243
+
244
+ ### Implementation
245
+ After updating the example (TODO #5), update the tests to use `::Update` and `::Init`.
246
+
247
+ ---
248
+
249
+ ## Verification
250
+
251
+ After implementing all items, run:
252
+
253
+ ```bash
254
+ bundle exec rake test
255
+ ```
256
+
257
+ All tests should pass. The following patterns should work in update functions:
258
+
259
+ ```ruby
260
+ case msg
261
+ in { type: :http, envelope: :users, status: 200, body: }
262
+ # HTTP success
263
+ in { type: :system, envelope: :files, stdout:, status: 0 }
264
+ # System batch success
265
+ in { type: :timer, envelope: :dismiss, elapsed: }
266
+ # Timer completed
267
+ in { type: :all, envelope: :dashboard, results: }
268
+ # Aggregated parallel results
269
+ end
270
+ ```
271
+
272
+ ---
273
+
274
+ ## 9. Add `out.source_nonblock` for Parallel Streaming Commands
275
+
276
+ ### Context
277
+
278
+ Custom commands can orchestrate work using `out.source(cmd, token)` for sequential steps and `Command.all` for parallel-but-wait-for-all patterns. However, there's no way to run multiple streaming commands in parallel where each emits messages live as they arrive.
279
+
280
+ ### Problem
281
+
282
+ Consider a dashboard that:
283
+ 1. Authenticates
284
+ 2. Fetches initial data
285
+ 3. Opens two Server-Sent Events (SSE) streams for live updates
286
+ 4. Post-processes each stream's chunks differently
287
+ 5. Emits processed chunks to the update function as they arrive
288
+
289
+ With current primitives:
290
+ - `out.source` blocks — can only read one stream at a time
291
+ - `Command.all` waits for ALL to complete — no live streaming
292
+ - `Command.batch` only works from `update`, not inside custom commands
293
+ - Raw `Thread.new` causes silent hangs if threads crash (documented in `doc/concepts/async_work.md`)
294
+
295
+ Developers are stuck.
296
+
297
+ ### Solution
298
+
299
+ Add `out.source_nonblock` to spawn async child commands that stream messages live:
300
+
301
+ ```ruby
302
+ # API:
303
+ handle = out.source_nonblock(command, token) # messages pass directly to runtime
304
+ handle = out.source_nonblock(command, token, processor) # messages go through processor first
305
+ out.last(handle1, handle2, ...) # block until all handles complete
306
+ ```
307
+
308
+ ### Usage Example
309
+
310
+ ```ruby
311
+ class LoadDashboard < Data.define(:user_id, :tag)
312
+ include RatatuiRuby::Tea::Command::Custom
313
+
314
+ def call(out, token)
315
+ # Sequential: authenticate first
316
+ auth = out.source(Authenticate.new(user_id:, tag: :_), token)
317
+ return if auth.nil? || token.canceled?
318
+
319
+ # Sequential: fetch initial data
320
+ dashboard = out.source(
321
+ Command.all(:_, [FetchProfile.new(...), FetchNotifications.new(...)]),
322
+ token
323
+ )
324
+ return if dashboard.nil? || token.canceled?
325
+ out.put(tag, dashboard.results)
326
+
327
+ # Parallel streaming: two SSE connections
328
+ # 5a: Pass messages directly to update function (no processing)
329
+ h1 = out.source_nonblock(StreamNotifications.new(auth[:token]), token)
330
+
331
+ # 5b: Post-process messages before they reach update function
332
+ h2 = out.source_nonblock(
333
+ StreamDashboardDeltas.new(dashboard.results[:profile][:id], auth[:token]),
334
+ token,
335
+ DeltaPostProcessor.new(user_id) # Ractor-shareable callable
336
+ )
337
+
338
+ # 5c: Block until both streams complete (or are cancelled)
339
+ out.last(h1, h2)
340
+ end
341
+ end
342
+
343
+ # The processor is a Ractor-shareable callable:
344
+ DeltaPostProcessor = Data.define(:user_id) do
345
+ def call(chunk, out)
346
+ processed = transform(chunk)
347
+ out.put(:delta, { user_id:, data: processed })
348
+ end
349
+
350
+ def transform(chunk)
351
+ # post-processing logic
352
+ end
353
+ end
354
+ ```
355
+
356
+ ### Implementation
357
+
358
+ #### 1. Add `AsyncHandle` class
359
+
360
+ In `lib/ratatui_ruby/tea/command/outlet.rb`:
361
+
362
+ ```ruby
363
+ AsyncHandle = Data.define(:future, :channel) do
364
+ def done?
365
+ future.resolved?
366
+ end
367
+ end
368
+ ```
369
+
370
+ #### 2. Add `source_nonblock` to `Outlet`
371
+
372
+ ```ruby
373
+ def source_nonblock(command, token, processor = nil)
374
+ channel = Concurrent::Promises::Channel.new
375
+
376
+ # Create outlet that writes to channel instead of parent queue
377
+ child_outlet = if processor
378
+ ProcessorOutlet.new(channel, processor, @message_queue)
379
+ else
380
+ ChannelOutlet.new(channel, @message_queue)
381
+ end
382
+
383
+ # Spawn child command
384
+ future = Concurrent::Promises.future do
385
+ command.call(child_outlet, token)
386
+ channel.push(:done)
387
+ rescue => e
388
+ @message_queue.push Command::Error.new(command:, exception: e)
389
+ channel.push(:done)
390
+ end
391
+
392
+ (@pending_async ||= []) << AsyncHandle.new(future:, channel:)
393
+ AsyncHandle.new(future:, channel:)
394
+ end
395
+ ```
396
+
397
+ #### 3. Add outlet variants
398
+
399
+ ```ruby
400
+ # Passes messages directly to runtime queue
401
+ ChannelOutlet = Data.define(:channel, :message_queue) do
402
+ def put(*args)
403
+ message = (args.size == 1) ? args.first : args.freeze
404
+ message_queue.push(message)
405
+ end
406
+ end
407
+
408
+ # Invokes processor, which calls out.put on a Ractor-safe outlet
409
+ ProcessorOutlet = Data.define(:channel, :processor, :message_queue) do
410
+ def put(*args)
411
+ message = (args.size == 1) ? args.first : args.freeze
412
+ # Processor receives message and a simple outlet for its output
413
+ output_outlet = ChannelOutlet.new(channel, message_queue)
414
+ processor.call(message, output_outlet)
415
+ rescue => e
416
+ message_queue.push Command::Error.new(command: processor, exception: e)
417
+ end
418
+ end
419
+ ```
420
+
421
+ #### 4. Add `last` method
422
+
423
+ ```ruby
424
+ def last(*handles)
425
+ handles = @pending_async if handles.empty?
426
+ handles.each do |handle|
427
+ handle.future.wait
428
+ end
429
+ @pending_async&.clear
430
+ end
431
+ ```
432
+
433
+ ### Ractor Safety
434
+
435
+ - **Child command**: runs in Ractor/future, must be shareable (existing requirement)
436
+ - **Processor**: must be Ractor-shareable (e.g., `Data.define` with shareable members)
437
+ - **Messages**: must be shareable (existing requirement)
438
+ - **The block IS NOT USED**: callbacks are shareable callables, not closures
439
+
440
+ The processor runs in the child's context (Ractor/future). It receives a Ractor-safe outlet that pushes to a channel. The runtime reads the channel and delivers messages — no closure crosses the Ractor boundary.
441
+
442
+ ### Tests
443
+
444
+ Add to `test/test_outlet.rb`:
445
+
446
+ ```ruby
447
+ def test_source_nonblock_streams_messages_live
448
+ # Custom command that:
449
+ # 1. source(single_command) - sync
450
+ # 2. source_nonblock(streaming_cmd1), source_nonblock(streaming_cmd2)
451
+ # 3. last(h1, h2)
452
+ # Verify messages arrive interleaved as streams produce them
453
+ end
454
+
455
+ def test_source_nonblock_with_processor_transforms_messages
456
+ # Verify processor.call is invoked for each message
457
+ # Verify transformed output reaches update function
458
+ end
459
+
460
+ def test_source_nonblock_crash_produces_command_error
461
+ # Verify child crash doesn't hang — becomes Command::Error
462
+ end
463
+
464
+ def test_source_nonblock_processor_crash_produces_command_error
465
+ # Verify processor crash doesn't hang — becomes Command::Error
466
+ end
467
+ ```
468
+