@@ -257,6 +257,9 @@ class ExecutionError(ShellflowError):
257257EXIT_SSH_CONFIG_FAILURE = 3
258258EXIT_TIMEOUT_FAILURE = 4
259259
260+ # Maximum output lines per command for verbose mode
261+ MAX_OUTPUT_LINES = 20
262+
260263FAILURE_PARSE = "parse"
261264FAILURE_RUNTIME = "runtime"
262265FAILURE_SSH_CONFIG = "ssh_config"
@@ -758,6 +761,105 @@ def _iter_display_context(context: ExecutionContext) -> list[str]:
758761 return lines
759762
760763
764+ def _truncate_output_lines (output : str , max_lines : int = MAX_OUTPUT_LINES ) -> str :
765+ """Truncate output to maximum number of lines."""
766+ lines = output .splitlines ()
767+ if len (lines ) <= max_lines :
768+ return output
769+ truncated = lines [:max_lines ]
770+ remaining = len (lines ) - max_lines
771+ truncated .append (f"... ({ remaining } more line{ 's' if remaining > 1 else '' } truncated)" )
772+ return "\n " .join (truncated )
773+
774+
775+ def _execute_single_command (
776+ command : str ,
777+ context : ExecutionContext ,
778+ shell : str | None ,
779+ no_input : bool ,
780+ is_remote : bool = False ,
781+ host : str | None = None ,
782+ ssh_config : SSHConfig | None = None ,
783+ ) -> tuple [str , int , str , str ]:
784+ """Execute a single command and return output, exit code, stdout, stderr.
785+
786+ Returns:
787+ Tuple of (combined_output, exit_code, stdout, stderr)
788+ """
789+ env = context .to_shell_env ()
790+ script_lines = ["set -e" ]
791+
792+ # Add shell bootstrap for non-interactive shells
793+ if shell :
794+ script_lines .extend (_build_shell_bootstrap (shell ))
795+
796+ # Add the single command
797+ script_lines .append (command )
798+
799+ script = "\n " .join (script_lines )
800+
801+ run_kwargs : dict [str , Any ] = {
802+ "capture_output" : True ,
803+ "text" : True ,
804+ "env" : env ,
805+ }
806+
807+ try :
808+ if is_remote and host :
809+ # Remote execution via SSH
810+ ssh_args = ["ssh" ]
811+ if no_input :
812+ ssh_args .append ("-n" )
813+
814+ if ssh_config :
815+ if ssh_config .port and ssh_config .port != 22 :
816+ ssh_args .extend (["-p" , str (ssh_config .port )])
817+ if ssh_config .user :
818+ ssh_args .extend (["-l" , ssh_config .user ])
819+ if ssh_config .identity_file :
820+ ssh_args .extend (["-i" , str (Path (ssh_config .identity_file ).expanduser ())])
821+
822+ ssh_config_path = _get_ssh_config_path ()
823+ if ssh_config_path .exists ():
824+ ssh_args .extend (["-F" , str (ssh_config_path )])
825+
826+ exec_shell = shell or "bash"
827+ ssh_args .extend (["-o" , "BatchMode=yes" , host , exec_shell , "-l" , "-s" , "-e" ])
828+
829+ result = subprocess .run (
830+ ssh_args ,
831+ input = script ,
832+ capture_output = True ,
833+ text = True ,
834+ )
835+ # Local execution
836+ elif no_input :
837+ result = subprocess .run (
838+ ["/bin/bash" , "-se" , "-c" , script ],
839+ stdin = subprocess .DEVNULL ,
840+ ** run_kwargs ,
841+ )
842+ else :
843+ result = subprocess .run (
844+ ["/bin/bash" , "-se" ],
845+ input = script ,
846+ ** run_kwargs ,
847+ )
848+
849+ stdout = result .stdout .strip () if result .stdout else ""
850+ stderr = result .stderr .strip () if result .stderr else ""
851+ combined = _combine_output (stdout , stderr )
852+ return combined , result .returncode , stdout , stderr
853+
854+ except subprocess .TimeoutExpired as e :
855+ stdout = _stringify_subprocess_stream (e .output ).strip ()
856+ stderr = _stringify_subprocess_stream (e .stderr ).strip ()
857+ combined = _combine_output (stdout , stderr )
858+ return combined , - 1 , stdout , stderr
859+ except (OSError , subprocess .SubprocessError ) as e :
860+ return str (e ), - 1 , "" , str (e )
861+
862+
761863def execute_local (
762864 block : Block ,
763865 context : ExecutionContext ,
@@ -1182,11 +1284,99 @@ def _write_audit_log(path: Path, run_result: RunResult) -> None:
11821284# =============================================================================
11831285
11841286
1287+ def _execute_block_commands_sequential (
1288+ block : Block ,
1289+ context : ExecutionContext ,
1290+ no_input : bool ,
1291+ verbose : bool ,
1292+ _block_index : int , # Unused but kept for API consistency
1293+ _total_blocks : int , # Unused but kept for API consistency
1294+ ) -> ExecutionResult :
1295+ """Execute block commands sequentially, printing output after each command.
1296+
1297+ Returns:
1298+ ExecutionResult combining all command outputs.
1299+ """
1300+ # ANSI color codes
1301+ RED = "\033 [91m"
1302+ DIM = "\033 [90m"
1303+ RESET = "\033 [0m"
1304+
1305+ all_outputs : list [str ] = []
1306+ all_stdout : list [str ] = []
1307+ all_stderr : list [str ] = []
1308+ final_exit_code = 0
1309+ success = True
1310+
1311+ commands_to_execute = _iter_display_commands (block .commands )
1312+ ssh_config = None
1313+ if block .is_remote :
1314+ host = block .host
1315+ if host :
1316+ ssh_config = read_ssh_config (host )
1317+
1318+ for cmd in commands_to_execute :
1319+ # Print the command being executed
1320+ if verbose :
1321+ print (f"{ DIM } $ { cmd } { RESET } " )
1322+
1323+ # Execute single command
1324+ output , exit_code , stdout , stderr = _execute_single_command (
1325+ command = cmd ,
1326+ context = context ,
1327+ shell = block .shell ,
1328+ no_input = no_input ,
1329+ is_remote = block .is_remote ,
1330+ host = block .host ,
1331+ ssh_config = ssh_config ,
1332+ )
1333+
1334+ # Store outputs
1335+ all_outputs .append (output )
1336+ if stdout :
1337+ all_stdout .append (stdout )
1338+ if stderr :
1339+ all_stderr .append (stderr )
1340+
1341+ # Print output immediately with truncation
1342+ if verbose and output :
1343+ truncated = _truncate_output_lines (output , MAX_OUTPUT_LINES )
1344+ print (truncated )
1345+
1346+ # Check for failure
1347+ if exit_code != 0 :
1348+ final_exit_code = exit_code
1349+ success = False
1350+ if verbose :
1351+ print (f"{ RED } ✗ Command failed with exit code { exit_code } { RESET } \n " )
1352+ break
1353+
1354+ combined_output = "\n " .join (filter (None , all_outputs ))
1355+ combined_stdout = "\n " .join (filter (None , all_stdout ))
1356+ combined_stderr = "\n " .join (filter (None , all_stderr ))
1357+
1358+ # Update context
1359+ context .last_output = combined_output
1360+ context .success = success
1361+
1362+ return ExecutionResult (
1363+ success = success ,
1364+ output = combined_output ,
1365+ exit_code = final_exit_code ,
1366+ error_message = "" if success else f"Exit code: { final_exit_code } " ,
1367+ stdout = combined_stdout ,
1368+ stderr = combined_stderr ,
1369+ failure_kind = None if success else FAILURE_RUNTIME ,
1370+ no_input = no_input ,
1371+ )
1372+
1373+
11851374def run_script ( # noqa: PLR0915
11861375 blocks : list [Block ],
11871376 verbose : bool = False ,
11881377 no_input : bool = False ,
11891378 dry_run : bool = False ,
1379+ sequential_output : bool = True , # New parameter for sequential output
11901380) -> RunResult :
11911381 """Run a list of blocks sequentially.
11921382
@@ -1196,6 +1386,7 @@ def run_script( # noqa: PLR0915
11961386 Args:
11971387 blocks: List of blocks to execute.
11981388 verbose: Whether to print progress information.
1389+ sequential_output: Whether to print command output sequentially after each command.
11991390
12001391 Returns:
12011392 RunResult with success status and execution info.
@@ -1247,7 +1438,60 @@ def run_script( # noqa: PLR0915
12471438 block_id = _make_block_id (i )
12481439 events .append (_make_block_started_event (run_id , block_id , i , block , total_blocks ))
12491440
1250- # Print block info if verbose
1441+ # Execute the block
1442+ if sequential_output and verbose :
1443+ # Use sequential execution with per-command output
1444+ attempt_count = 0
1445+ max_attempts = block .retry_count + 1
1446+ while True :
1447+ attempt_count += 1
1448+ started_at = time .perf_counter ()
1449+
1450+ result = _execute_block_commands_sequential (block , context , no_input , verbose , i , len (blocks ))
1451+ result = _finalize_block_result (result , block , i , started_at )
1452+ result .attempts = attempt_count
1453+
1454+ if result .success or result .timed_out or attempt_count >= max_attempts :
1455+ break
1456+
1457+ if verbose :
1458+ print (f"{ YELLOW } ↻ Retrying attempt { attempt_count + 1 } /{ max_attempts } { RESET } " )
1459+
1460+ block_results .append (result )
1461+ events .append (_make_block_finished_event (run_id , result , block , total_blocks ))
1462+ blocks_executed += 1
1463+
1464+ # Fail fast on error
1465+ if not result .success :
1466+ failure_kind = _failure_kind_for_result (result )
1467+ exit_code = _exit_code_for_failure (failure_kind )
1468+ events .append (
1469+ _make_run_finished_event (
1470+ run_id ,
1471+ success = False ,
1472+ exit_code = exit_code ,
1473+ blocks_executed = blocks_executed ,
1474+ total_blocks = total_blocks ,
1475+ failure_kind = failure_kind ,
1476+ no_input = no_input ,
1477+ )
1478+ )
1479+ return RunResult (
1480+ success = False ,
1481+ blocks_executed = blocks_executed ,
1482+ error_message = f"Block { i } failed: { result .error_message } " ,
1483+ block_results = block_results ,
1484+ run_id = run_id ,
1485+ schema_version = SCHEMA_VERSION ,
1486+ exit_code = exit_code ,
1487+ failure_kind = failure_kind ,
1488+ no_input = no_input ,
1489+ events = events ,
1490+ )
1491+
1492+ continue # Skip the old execution path
1493+
1494+ # Print block info if verbose (old path - for non-sequential mode or when not verbose)
12511495 if verbose :
12521496 if block .is_local :
12531497 print (f"{ BLUE } [{ i } /{ len (blocks )} ] LOCAL{ RESET } " )
@@ -1295,7 +1539,8 @@ def run_script( # noqa: PLR0915
12951539
12961540 if verbose :
12971541 if result .output :
1298- print (result .output )
1542+ truncated = _truncate_output_lines (result .output , MAX_OUTPUT_LINES )
1543+ print (truncated )
12991544 if result .success :
13001545 print (f"{ GREEN } ✓ Success{ RESET } \n " )
13011546 else :
0 commit comments