@@ -163,30 +163,67 @@ async def _ignore_tree_changed(params: dict) -> None:
163163
164164 result_formats = ["string" , "json" ] if save_results else ["string" ]
165165
166- progress_token = str (uuid .uuid4 ())
167- client .on_notification (
168- "actions/progress" ,
169- _make_progress_handler (sys .stderr .isatty ()),
170- )
166+ # Multi-project runs stream partial results so each project's output
167+ # is printed as soon as that project finishes (completion order).
168+ # Single-project runs use progress notifications instead (no streaming).
169+ use_streaming = project_paths is None or len (project_paths ) > 1
170+
171+ batch_options = {
172+ "concurrently" : concurrently ,
173+ "resultFormats" : result_formats ,
174+ "trigger" : "user" ,
175+ "devEnv" : dev_env ,
176+ }
177+
178+ if use_streaming :
179+ partial_result_token = str (uuid .uuid4 ())
180+ streaming_results : dict [str , dict ] = {}
181+
182+ async def _on_partial_result (params : dict ) -> None :
183+ value = params .get ("value" , {}) if params else {}
184+ project_str = value .get ("project" , "" )
185+ results = value .get ("results" , {})
186+ streaming_results [project_str ] = results
187+ block = _format_project_block (project_str , results , source_to_name )
188+ click .echo (block , nl = False )
189+
190+ client .on_notification ("actions/partialResult" , _on_partial_result )
191+
192+ try :
193+ batch_result = await client .run_batch (
194+ action_sources = action_sources ,
195+ projects = project_paths ,
196+ params = action_payload ,
197+ params_by_project = params_by_project or None ,
198+ options = batch_options ,
199+ partial_result_token = partial_result_token ,
200+ )
201+ except ApiError as exc :
202+ raise RunFailed (str (exc )) from exc
171203
172- try :
173- batch_result = await client .run_batch (
174- action_sources = action_sources ,
175- projects = project_paths ,
176- params = action_payload ,
177- params_by_project = params_by_project or None ,
178- options = {
179- "concurrently" : concurrently ,
180- "resultFormats" : result_formats ,
181- "trigger" : "user" ,
182- "devEnv" : dev_env ,
183- },
184- progress_token = progress_token ,
204+ return _build_streaming_result (
205+ streaming_results , batch_result .get ("returnCode" , 0 )
206+ )
207+ else :
208+ progress_token = str (uuid .uuid4 ())
209+ client .on_notification (
210+ "actions/progress" ,
211+ _make_progress_handler (sys .stderr .isatty ()),
185212 )
186- except ApiError as exc :
187- raise RunFailed (str (exc )) from exc
188213
189- return _build_run_result (batch_result , source_to_name )
214+ try :
215+ batch_result = await client .run_batch (
216+ action_sources = action_sources ,
217+ projects = project_paths ,
218+ params = action_payload ,
219+ params_by_project = params_by_project or None ,
220+ options = batch_options ,
221+ progress_token = progress_token ,
222+ )
223+ except ApiError as exc :
224+ raise RunFailed (str (exc )) from exc
225+
226+ return _build_run_result (batch_result , source_to_name )
190227 finally :
191228 await client .close ()
192229 finally :
@@ -247,6 +284,66 @@ def _build_run_result(
247284 )
248285
249286
287+ def _format_project_block (
288+ project_path_str : str ,
289+ actions_results : dict ,
290+ source_to_name : dict [str , str ] | None = None ,
291+ ) -> str :
292+ """Format one project's action results as a printable block.
293+
294+ Always includes the project path header (streaming callers are multi-project
295+ by definition).
296+ """
297+ run_many_actions = len (actions_results ) > 1
298+ project_output_parts : list [str ] = []
299+
300+ for action_source , action_data in actions_results .items ():
301+ result_by_format = action_data .get ("resultByFormat" , {})
302+ return_code = action_data .get ("returnCode" , 0 )
303+ response = runner_client .RunActionResponse (
304+ result_by_format = result_by_format ,
305+ return_code = return_code ,
306+ )
307+ display_name = (source_to_name or {}).get (action_source , action_source )
308+ action_output = ""
309+ if run_many_actions :
310+ action_output += f"{ click .style (display_name , bold = True )} :"
311+ action_output += utils .run_result_to_str (response .text (), display_name )
312+ project_output_parts .append (action_output )
313+
314+ block = "" .join (project_output_parts )
315+ block = f"{ click .style (project_path_str , bold = True , underline = True )} \n " + block
316+ return block
317+
318+
319+ def _build_streaming_result (
320+ streaming_results : dict [str , dict ],
321+ overall_return_code : int ,
322+ ) -> utils .RunActionsResult :
323+ """Build a RunActionsResult from collected partial-result notifications.
324+
325+ Output is empty because each project block was already printed to stdout as
326+ the notification arrived. ``result_by_project`` is populated for callers
327+ that need the structured data (e.g. ``--save-results``).
328+ """
329+ result_by_project : dict [pathlib .Path , dict [str , runner_client .RunActionResponse ]] = {}
330+ for project_path_str , actions_results in streaming_results .items ():
331+ project_path = pathlib .Path (project_path_str )
332+ project_responses : dict [str , runner_client .RunActionResponse ] = {}
333+ for action_source , action_data in actions_results .items ():
334+ project_responses [action_source ] = runner_client .RunActionResponse (
335+ result_by_format = action_data .get ("resultByFormat" , {}),
336+ return_code = action_data .get ("returnCode" , 0 ),
337+ )
338+ result_by_project [project_path ] = project_responses
339+
340+ return utils .RunActionsResult (
341+ output = "" ,
342+ return_code = overall_return_code ,
343+ result_by_project = result_by_project ,
344+ )
345+
346+
250347def _resolve_mapped_payload_fields (
251348 map_payload_fields : set [str ],
252349 action_payload : dict [str , typing .Any ],
0 commit comments