diff --git a/docs/index.md b/docs/index.md index ade640014f..0f2a41d082 100644 --- a/docs/index.md +++ b/docs/index.md @@ -77,6 +77,7 @@ reports script working-with-files process +process-typed channel workflow module @@ -174,6 +175,7 @@ developer/packages tutorials/rnaseq-nf tutorials/data-lineage tutorials/workflow-outputs +tutorials/static-types tutorials/metrics tutorials/flux ``` diff --git a/docs/migrations/25-10.md b/docs/migrations/25-10.md index 517f458d1a..819e6cfe22 100644 --- a/docs/migrations/25-10.md +++ b/docs/migrations/25-10.md @@ -16,16 +16,16 @@ The `params` block is a new way to declare pipeline parameters in a Nextflow scr ```nextflow params { - // Path to input data. - input: Path + // Path to input data. + input: Path - // Whether to save intermediate files. - save_intermeds: Boolean = false + // Whether to save intermediate files. + save_intermeds: Boolean = false } workflow { - println "params.input = ${params.input}" - println "params.save_intermeds = ${params.save_intermeds}" + println "params.input = ${params.input}" + println "params.save_intermeds = ${params.save_intermeds}" } ``` @@ -39,19 +39,36 @@ Type annotations are a way to denote the *type* of a variable. They help documen ```nextflow workflow RNASEQ { - take: - reads: Channel - index: Value + take: + reads: Channel + index: Value - main: - samples_ch = QUANT( reads, index ) + main: + samples_ch = QUANT( reads, index ) - emit: - samples: Channel = samples_ch + emit: + samples: Channel = samples_ch } def isSraId(id: String) -> Boolean { - return id.startsWith('SRA') + return id.startsWith('SRA') +} + +// feature flag required for typed processes +nextflow.preview.types = true + +process fastqc { + input: + (id, fastq_1, fastq_2): Tuple + + output: + logs = tuple(id, file('fastqc_logs')) + + script: + """ + mkdir fastqc_logs + fastqc -o fastqc_logs -f fastq -q ${fastq_1} ${fastq_2} + """ } ``` @@ -59,6 +76,7 @@ The following declarations can be annotated with types: - Pipeline parameters (the `params` block) - Workflow takes and emits +- Process inputs and outputs - Function parameters and returns - Local variables - Closure parameters @@ -66,7 +84,28 @@ The following declarations can be annotated with types: Type annotations can refer to any of the {ref}`standard types `. -Type annotations can be appended with `?` to denote that the value can be `null`: +Some types use *generic type parameters* to work with different data types in a type-safe way. For example: + +- `List` and `Channel` use the generic type `E` to specify the element type in the list or channel +- `Map` uses the generic types `K` for the key type and `V` for the value type in the map + +The following examples show types with type parameters: + +```nextflow +// List where E is String +def sequences: List = ['ATCG', 'GCTA', 'TTAG'] + +// List where E is Path +def fastqs: List = [file('sample1.fastq'), file('sample2.fastq')] + +// Map where K is String and V is Integer +def readCounts: Map = [sample1: 1000, sample2: 1500] + +// Channel where E is Path +def ch_bams: Channel = channel.fromPath('*.bam') +``` + +Type annotations can be appended with `?` to denote values can be `null`: ```nextflow def x_opt: String? = null @@ -78,6 +117,8 @@ In the type system, queue channels are represented as `Channel`, while value cha Nextflow supports Groovy-style type annotations using the ` ` syntax, but this approach is deprecated in {ref}`strict syntax `. While Groovy-style annotations remain valid for functions and local variables, the language server and `nextflow lint` automatically convert them to Nextflow-style annotations during code formatting. ::: +See {ref}`migrating-static-types` for details. + ## Enhancements

Nextflow plugin registry

@@ -113,7 +154,7 @@ workflow { This syntax is simpler and easier to use with the {ref}`strict syntax `. See {ref}`workflow-handlers` for details. -

Improved handling of dynamic directives

+

Simpler syntax for dynamic directives

The {ref}`strict syntax ` allows dynamic process directives to be specified without a closure: @@ -131,6 +172,8 @@ process hello { } ``` +Dynamic process settings in configuration files must still be specified with closures. + See {ref}`dynamic-directives` for details.

Configurable date formatting

diff --git a/docs/process-typed.md b/docs/process-typed.md new file mode 100644 index 0000000000..bea15e56e9 --- /dev/null +++ b/docs/process-typed.md @@ -0,0 +1,251 @@ +(process-typed-page)= + +# Processes (typed) + +:::{versionadded} 25.10.0 +::: + +Typed processes use a new syntax for inputs and outputs based on static types. + +```nextflow +nextflow.preview.types = true + +process hello { + input: + message: String + + output: + file('hello.txt') + + script: + """ + echo '${message}' > hello.txt + """ +} +``` + +In order to use this feature, you'll need to: + +1. Enable {ref}`strict syntax ` by setting `NXF_SYNTAX_PARSER=v2`. +2. Set `nextflow.preview.types = true` in every script that uses typed processes. + +See {ref}`syntax-process-typed` for the complete syntax reference and {ref}`migrating-static-types` to migrate existing code to static types. + +## Inputs + +The `input:` section declares process inputs. In typed processes, each input declaration consists of a name and type: + +```nextflow +process fastqc { + input: + (meta, fastq): Tuple + extra_args: String + + script: + """ + echo 'meta: ${meta}` + echo 'fastq: ${fastq}' + echo 'extra_args: ${extra_args}' + """ +} +``` + +All {ref}`standard types ` except for the dataflow types (`Channel` and `Value`) can be used as type annotations in processes. + +### File inputs + +Nextflow automatically stages `Path` inputs and `Path` collections (such as `Set`) into the task directory. + +By default, tasks fail if any input receives a `null` value. To allow `null` values, add `?` to the type annotation: + +```nextflow +process cat_opt { + input: + input: Path? + + stage: + stageAs 'input.txt', input + + output: + stdout() + + script: + ''' + [[ -f input.txt ]] && cat input.txt || echo 'empty input' + ''' +} +``` + +### Stage directives + +The `stage:` section defines custom staging behavior using *stage directives*. It should be specified after the `input:` section. These directives serve the same purpose as input qualifiers such as `env` and `stdin` in the legacy syntax. + +The `env` directive declares an environment variable in terms of task inputs: + +```nextflow +process echo_env { + input: + hello: String + + stage: + env 'HELLO', hello + + script: + ''' + echo "$HELLO world!" + ''' +} +``` + +The `stdin` directive defines the standard input of the task script: + +```nextflow +process cat { + input: + message: String + + stage: + stdin message + + script: + """ + cat - + """ +} +``` + +The `stageAs` directive stages an input file (or files) under a custom file pattern: + +```nextflow +process blast { + input: + fasta: Path + + stage: + stageAs 'query.fa', fasta + + script: + """ + blastp -query query.fa -db nr + """ +} +``` + +The file pattern can also reference task inputs: + +```nextflow +process grep { + input: + id: String + fasta: Path + + stage: + stageAs "${id}.fa", fasta + + script: + """ + cat ${id}.fa | grep '>' + """ +} +``` + +See {ref}`process-reference-typed` for available stage directives. + +## Outputs + +The `output:` section declares the outputs of a typed process. Each output declaration consists of a name, an optional type, and an output value: + +```nextflow +process echo { + input: + message: String + + output: + out_env: String = env('MESSAGE') + out_file: Path = file('message.txt') + out_std: String = stdout() + + script: + """ + export MESSAGE='${message}' + + echo \$MESSAGE > message.txt + + cat message.txt + """ +} +``` + +When there is only one output, the name can be omitted: + +```nextflow +process echo { + input: + message: String + + output: + stdout() + + script: + """ + echo '${message}' + """ +} +``` + +See {ref}`process-reference-typed` for available output functions. + +### File outputs + +You can use the `file()` and `files()` functions in the `output:` section to get a single file or collection of files from the task directory. + +By default, the `file()` function fails if the specified file is not present in the task directory. You can specify `optional: true` to allow missing files. The `file()` function returns `null` for missing files. For example: + +```nextflow +process foo { + output: + file('output.txt', optional: true) + + script: + """ + exit 0 + """ +} +``` + +## Topics + +The `topic:` section emits values to {ref}`topic channels `. A topic emission consists of an output value and a topic name: + +```nextflow +process cat { + input: + message: Path + + output: + stdout() + + topic: + tuple('bash', eval('bash --version')) >> 'versions' + tuple('cat', eval('cat --version')) >> 'versions' + + script: + """ + cat ${message} + """ +} +``` + +Topic emissions can use the same {ref}`output functions ` as the `output:` section. + +## Script + +The `script:` and `exec:` sections behave the same way as {ref}`legacy processes `. + +## Stub + +The `stub:` section behaves the same way as {ref}`legacy processes `. + +## Directives + +Directives behave the same way as {ref}`legacy processes `. diff --git a/docs/reference/feature-flags.md b/docs/reference/feature-flags.md index 1ba95d40ad..5c73df9709 100644 --- a/docs/reference/feature-flags.md +++ b/docs/reference/feature-flags.md @@ -60,3 +60,9 @@ Feature flags are used to introduce experimental or other opt-in features. They This feature flag is no longer required to use topic channels. ::: : When `true`, enables {ref}`topic channels ` feature. + +`nextflow.preview.types` +: :::{versionadded} 25.10.0 + ::: +: When `true`, enables the use of {ref}`typed processes `. +: This feature flag must be enabled in every script that uses typed processes. Legacy processes can not be defined in scripts that enable this feature flag. diff --git a/docs/reference/process.md b/docs/reference/process.md index 33953b2518..43eff92099 100644 --- a/docs/reference/process.md +++ b/docs/reference/process.md @@ -50,9 +50,84 @@ The following task properties are defined in the process body: Additionally, the [directive values](#directives) for the given task can be accessed via `task.`. +(process-reference-typed)= + +## Inputs and outputs (typed) + +:::{versionadded} 25.10.0 +::: + +:::{note} +Typed processes require the `nextflow.preview.types` feature flag to be enabled in every script that uses them. +::: + +### Stage directives + +The following directives can be used in the `stage:` section of a typed process: + +`env( name: String, String value )` +: Declares an environment variable with the specified name and value in the task environment. + +`stageAs( filePattern: String, value: Path )` +: Stages a file into the task directory under the given alias. + +`stageAs( filePattern: String, value: Iterable )` +: Stages a collection of files into the task directory under the given alias. + +`stdin( value: String )` +: Stages the given value as the standard input (i.e., `stdin`) to the task script. + +### Outputs + +The following functions are available in the `output:` and `topic:` sections of a typed process: + +`env( name: String ) -> String` +: Returns the value of an environment variable from the task environment. + +`eval( command: String ) -> String` +: Returns the standard output of the specified command, which is executed in the task environment after the task script completes. + +`file( pattern: String, [options] ) -> Path` +: Returns a file from the task environment that matches the specified pattern. + +: Available options: + + `followLinks: Boolean` + : When `true`, target files are returned in place of any matching symlink (default: `true`). + + `glob: Boolean` + : When `true`, the file name is interpreted as a glob pattern (default: `true`). + + `hidden: Boolean` + : When `true`, hidden files are included in the matching output files (default: `false`). + + `includeInputs: Boolean` + : When `true` and the file name is a glob pattern, any input files matching the pattern are also included in the output (default: `false`). + + `maxDepth: Integer` + : Maximum number of directory levels to visit (default: no limit). + + `optional: Boolean` + : When `true`, the task will not fail if the given file is missing (default: `false`). + + `type: String` + : Type of paths returned, either `file`, `dir` or `any` (default: `any`, or `file` if the given file name contains a double star (`**`)). + +`files( pattern: String, [options] ) -> Set` +: Returns files from the task environment that match the given pattern. + +: Supports the same options as `file()` (except for `optional`). + +`stdout() -> String` +: Returns the standard output of the task script. + +(process-reference-legacy)= + +## Inputs and outputs (legacy) + (process-reference-inputs)= -## Inputs +### Inputs `val( identifier )` @@ -103,7 +178,7 @@ Additionally, the [directive values](#directives) for the given task can be acce (process-reference-outputs)= -## Outputs +### Outputs `val( value )` diff --git a/docs/reference/stdlib-types.md b/docs/reference/stdlib-types.md index a1bfe07bd4..2005b32c4e 100644 --- a/docs/reference/stdlib-types.md +++ b/docs/reference/stdlib-types.md @@ -10,7 +10,7 @@ This page describes the standard types in the Nextflow standard library. *Implements the {ref}`stdlib-types-iterable` trait.* -A bag is an unordered collection. +A bag is an unordered collection of values of type `E`. The following operations are supported for bags: @@ -44,7 +44,7 @@ Booleans in Nextflow can be backed by any of the following Java types: `boolean` ## Channel\ -A channel (also known as a *dataflow channel* or *queue channel*) is an asynchronous sequence of values. It is used to facilitate dataflow logic in a workflow. +A channel (also known as a *dataflow channel* or *queue channel*) is an asynchronous sequence of values of type `E`. It is used to facilitate dataflow logic in a workflow. See {ref}`dataflow-page` for an overview of dataflow types. See {ref}`operator-page` for the available methods for channels. @@ -266,7 +266,7 @@ Iterables in Nextflow are backed by the [Java](https://docs.oracle.com/en/java/j *Implements the {ref}`stdlib-types-iterable` trait.* -A list is an ordered collection of elements. See {ref}`script-list` for an overview of lists. +A list is an ordered collection of values of type `E`. See {ref}`script-list` for an overview of lists. The following operations are supported for lists: @@ -349,7 +349,7 @@ Lists in Nextflow are backed by the [Java](https://docs.oracle.com/en/java/javas ## Map\ -A map associates or "maps" keys to values. Each key can map to at most one value -- a map cannot contain duplicate keys. See {ref}`script-map` for an overview of maps. +A map associates or "maps" keys of type `K` to values of type `V`. Each key can map to at most one value -- a map cannot contain duplicate keys. See {ref}`script-map` for an overview of maps. The following operations are supported for maps: @@ -814,7 +814,7 @@ The following methods are available for splitting and counting the records in fi *Implements the {ref}`stdlib-types-iterable` trait.* -A set is an unordered collection that cannot contain duplicate elements. +A set is an unordered collection of values of type `E` which cannot contain duplicates. A set can be created from a list using the `toSet()` method: @@ -995,7 +995,7 @@ The following operations are supported for tuples: ## Value\ -A dataflow value (also known as a *value channel*) is an asynchronous value. It is used to facilitate dataflow logic in a workflow. +A dataflow value (also known as a *value channel*) is an asynchronous value of type `V`. It is used to facilitate dataflow logic in a workflow. See {ref}`dataflow-page` for an overview of dataflow types. diff --git a/docs/reference/syntax.md b/docs/reference/syntax.md index 5d794b1d62..02319d2ba7 100644 --- a/docs/reference/syntax.md +++ b/docs/reference/syntax.md @@ -110,7 +110,7 @@ The following definitions can be included: ### Params block -The params block consists of one or more *parameter declarations*. A parameter declaration consists of a name and an optional default value: +The params block consists of one or more *parameter declarations*. A parameter declaration consists of a name, type, and an optional default value: ```nextflow params { @@ -262,6 +262,54 @@ The script and stub sections must return a string in the same manner as a [funct See {ref}`process-page` for more information on the semantics of each process section. +(syntax-process-typed)= + +### Process (typed) + +A typed process is a process that uses static types for inputs and/or outputs: + +```nextflow +process greet { + input: + greeting: String + name: String + + stage: + env 'NAME', name + + output: + stdout() + + topic: + eval('bash --version') >> 'versions' + + script: + """ + echo "${greeting}, \${NAME}!" + """ +} +``` + +Typed processes may specify the following sections: + +`input:` +: Consists of one or more process inputs. Each input has a name and type. + +`stage:` +: Consists of one or more stage directives. See {ref}`process-reference-typed` for the set of available stage directives. + +`output:` +: Consists of one or more *output statements*. An output statement can be a [variable name](#variable), an [assignment](#assignment), or an [expression statement](#expression-statement). An output statement must be the only output if it is an expression statement. See {ref}`process-reference-typed` for the set of available output functions. + +`topic:` +: Consists of one or more *topic statements*. A topic statement is a right-shift expression with an output value on the left side and a string on the right side. + +:::{note} +Typed processes use the same behavior as legacy processes for all other sections. +::: + +See {ref}`process-typed-page` for more information on the semantics of typed processes. + (syntax-function)= ### Function diff --git a/docs/tutorials/static-types.md b/docs/tutorials/static-types.md new file mode 100644 index 0000000000..ca3357c8c8 --- /dev/null +++ b/docs/tutorials/static-types.md @@ -0,0 +1,353 @@ +(migrating-static-types)= + +# Migrating to static types + +Nextflow 25.10 introduces the ability to use *static types* in a Nextflow pipeline. This tutorial demonstrates how to migrate to static types using the [rnaseq-nf](https://github.com/nextflow-io/rnaseq-nf) pipeline as an example. + +:::{note} +Static types in Nextflow 25.10 are optional. All existing code will continue to work. +::: + +## Overview + +Static types allow you to specify the types of variables and parameters in Nextflow code. This language feature serves two purposes: + +1. **Better documentation**: Type annotations serve as code documentation, making your code more precise and easier to understand. + +2. **Better validation**: The Nextflow language server uses these type annotations to perform *type checking*, which allows it to identify type-related errors during development without requiring code execution. + +While Nextflow inherited type annotations from Groovy, types were limited to functions and local variables and weren't supported by Nextflow-specific constructs, such as processes, workflows, and pipeline parameters. Additionally, the Groovy type system is significantly larger and more complex than necessary for Nextflow pipelines. + +Nextflow 25.10 provides a native way to specify types at every level of a pipeline, from pipeline parameters to process inputs and outputs, using the {ref}`standard types ` in the Nextflow standard library. + +## Developer tooling + +Static types work best with the [Nextflow language server](https://github.com/nextflow-io/language-server) and [Nextflow VS Code extension](https://marketplace.visualstudio.com/items?itemName=nextflow.nextflow). + +:::{tip} +See {ref}`devenv-page` for instructions on how to setup VS Code and the Nextflow extension. +::: + +### Type checking + +Static type checking is currently available through the language server as an experimental feature. + +### Automatic migration + +The Nextflow VS Code extension provides a command for automatically migrating Nextflow pipelines to static types. To migrate a script, open the Command Palette, search for **Convert script to static types**, and select it. + +:::{note} +The extension can also convert an entire project using the **Convert pipeline to static types** command. +::: + +The conversion consists of the following steps: + +- Legacy parameter declarations are converted to a `params` block. If a `nextflow_schema.json` file is present, it is used to infer parameter types. + +- Legacy processes are converted to typed processes. + +The language server uses the following rules when attempting to convert a legacy process: + +- When input or output types cannot be inferred (e.g., `val` inputs and outputs), the type remains unspecified and the language server reports an error. However, if a process includes an nf-core [meta.yml](https://nf-co.re/docs/guidelines/components/modules#documentation), the language server uses it to infer the appropriate type. + +- File inputs (`file` and `path` qualifiers) are converted to `Path` or `Set` based on (1) the `arity` option or (2) stage name when specified. To ensure accurate conversion, specify the `arity` option in the legacy syntax and review the converted code to verify the correct type is used. + +- File outputs (`file` and `path` qualifiers) are converted to `file()` or `files()` based on the `arity` option when specified. To ensure accurate conversion, specify the `arity` option in the legacy syntax and review the converted code to verify the correct output function is used. + +## Example: rnaseq-nf + +This section demonstrates how to migrate a pipeline to static types using the [rnaseq-nf](https://github.com/nextflow-io/rnaseq-nf) pipeline as an example. The completed migration is available in the [preview-25-10](https://github.com/nextflow-io/rnaseq-nf/tree/preview-25-10) branch. + +See {ref}`rnaseq-nf-page` for an introduction to the rnaseq-nf pipeline. + +:::{tip} +While much of this migration can be performed automatically by the language server, this example is intended to provide a concrete example of a pipeline that uses static types. Always review generated code for correctness. +::: + +### Migrating pipeline parameters + +The pipeline defines the following parameters in the main script using the legacy syntax: + +```nextflow +params.reads = "$baseDir/data/ggal/ggal_gut_{1,2}.fq" +params.transcriptome = "$baseDir/data/ggal/ggal_1_48850000_49020000.Ggal71.500bpflank.fa" +params.outdir = "results" +params.multiqc = "$baseDir/multiqc" +``` + +The pipeline also has a `nextflow_schema.json` schema with the following properties: + +```json +"reads": { + "type": "string", + "description": "The input read-pair files", + "default": "${projectDir}/data/ggal/ggal_gut_{1,2}.fq" +}, +"transcriptome": { + "type": "string", + "format": "file-path", + "description": "The input transcriptome file", + "default": "${projectDir}/data/ggal/ggal_1_48850000_49020000.Ggal71.500bpflank.fa" +}, +"outdir": { + "type": "string", + "format": "directory-path", + "description": "The output directory where the results will be saved", + "default": "results" +}, +"multiqc": { + "type": "string", + "format": "directory-path", + "description": "Directory containing the configuration for MultiQC", + "default": "${projectDir}/multiqc" +} +``` + +To migrate the pipeline parameters, use the schema and legacy parameters to define the equivalent `params` block: + +```nextflow +params { + // The input read-pair files + reads: String = "${projectDir}/data/ggal/ggal_gut_{1,2}.fq" + + // The input transcriptome file + transcriptome: Path = "${projectDir}/data/ggal/ggal_1_48850000_49020000.Ggal71.500bpflank.fa" + + // The output directory where the results will be saved + outdir: Path = 'results' + + // Directory containing the configuration for MultiQC + multiqc: Path = "${projectDir}/multiqc" +} +``` + +See {ref}`workflow-params-def` for more information about the `params` block. + +### Migrating workflows + +The type of each workflow input can be inferred by examining how the workflow is called. + +```nextflow +workflow RNASEQ { + take: + read_pairs_ch + transcriptome + + main: + // ... + + emit: + fastqc = fastqc_ch + quant = quant_ch +} +``` + +The `RNASEQ` workflow is called by the entry workflow with the following arguments: + +```nextflow +workflow { + read_pairs_ch = channel.fromFilePairs(params.reads, checkIfExists: true, flat: true) + + (fastqc_ch, quant_ch) = RNASEQ(read_pairs_ch, params.transcriptome) + + // ... +} +``` + +You can determine the type of each input as follows: + +- The channel `read_pairs_ch` has type `Channel`, where `E` is the type of each value in the channel. The `fromFilePairs()` factory with `flat: true` emits tuples containing a sample ID and two file paths. Therefore the type of `read_pairs_ch` is `Channel>`. + +- The parameter `params.transcriptome` has type `Path` as defined in the `params` block. + +To update the workflow inputs, specify the input types as follows: + +```nextflow +workflow RNASEQ { + take: + read_pairs_ch: Channel> + transcriptome: Path + + // ... +} +``` + +:::{note} +Type annotations can become long for tuples with many elements. Future Nextflow versions will introduce alternative data types (i.e., record types) that have more concise annotations and provide a better overall experience with static types. The focus of Nextflow 25.10 is to enable type checking for existing code with minimal changes. +::: + +You can also specify types for workflow outputs. This is typically not required for type checking, since the output type can usually be inferred from the input types and workflow logic. However, explicit type annotations are still useful as a sanity check -- if the declared output type doesn't match the assigned value's type, the language server can report an error. + +You can use the same approach for inputs to determine the type of each output: + +- The variable `fastqc_ch` comes from the output of `FASTQC`. This process is called with a channel (`read_pairs_ch`), so it also emits a channel. Each task returns a tuple containing an id and an output file, so the element type is `Tuple`. Therefore, the type of `fastqc_ch` is `Channel>`. + +- The variable `quant_ch` comes from the output of `QUANT`. THis process is called with a channel (`read_pairs_ch`), so it also emits a channel. Each task returns a tuple containing an id and an output file, so the element type is `Tuple`. Therefore, the type of `quant_ch` is `Channel>`. + +To update the workflow outputs, specify the output types as follows: + +```nextflow +workflow RNASEQ { + take: + read_pairs_ch: Channel> + transcriptome: Path + + main: + index = INDEX(transcriptome) + fastqc_ch = FASTQC(read_pairs_ch) + quant_ch = QUANT(index, read_pairs_ch) + + emit: + fastqc: Channel> = fastqc_ch + quant: Channel> = quant_ch +} +``` + +### Migrating processes + +See {ref}`process-typed-page` for an overview of typed process inputs and outputs. + +

FASTQC

+ +The `FASTQ` process is defined with the following inputs and outputs: + +```nextflow +process FASTQC { + // ... + + input: + tuple val(id), path(fastq_1), path(fastq_2) + + output: + path "fastqc_${id}_logs" + + // ... +} +``` + +To migrate this process, rewrite the inputs and outputs as follows: + +```nextflow +process FASTQC { + // ... + + input: + (id, fastq_1, fastq_2): Tuple + + output: + file("fastqc_${id}_logs") + + // ... +} +``` + +In the above: + +- Inputs of type `Path` are treated like `path` inputs in the legacy syntax. Additionally, if `fastq_1` or `fastq_2` were marked as `Path?`, they could be null, which is not supported by `path` inputs. + +- Outputs are normally defined as assignments, similar to workflow emits. In this case, however, the name was omitted since there is only one output. + +- Values in the `output:` section can use several specialized functions for {ref}`process outputs `. Here, `file()` is the process output function (not the standard library function). + +:::{note} +Other process sections, such as the directives and the `script:` block, are not shown because they do not require changes. As long as the inputs and outputs declare and reference the same variable names and file patterns, the other process sections will behave the same as before. +::: + +

QUANT

+ +The `QUANT` process is defined with the following inputs and outputs: + +```nextflow +process QUANT { + // ... + + input: + tuple val(id), path(fastq_1), path(fastq_2) + path index + + output: + path "quant_${id}" + + // ... +} +``` + +To migrate this process, rewrite the inputs and outputs as follows: + +```nextflow +process QUANT { + // ... + + input: + (id, fastq_1, fastq_2): Tuple + index: Path + + output: + file("quant_${id}") + + // ... +} +``` + +

MULTIQC

+ +The `MULTIQC` process is defined with the following inputs and outputs: + +```nextflow +process MULTIQC { + // ... + + input: + path '*' + path config + + output: + path 'multiqc_report.html' + + // ... +} +``` + +To migrate this process, rewrite the inputs and outputs as follows: + +```nextflow +process MULTIQC { + // ... + + input: + logs: Set + config: Path + + stage: + stageAs logs, '*' + + output: + file('multiqc_report.html') + + // ... +} +``` + +In a typed process, file patterns for `path` inputs must be declared using a *stage directive*. In this example, the first input uses the variable name `logs`, and the `stageAs` directive stages the input using the glob pattern `*`. + +In this case, you can omit the stage directive because `*` matches Nextflow's default staging behavior. Inputs of type `Path` or a `Path` collection (e.g., `Set`) are staged by default using the pattern `'*'`. + +:::{note} +In the legacy syntax, you can use the `arity` option to specify whether a `path` qualifier expects a single file or collection of files. When using typed inputs and outputs, the type determines this behavior, i.e., `Path` vs `Set`. +::: + +:::{note} +While `List` and `Bag` are also valid path collection types, `Set` is recommended because it most accurately represents an unordered collection of files. You should only use `List` when you want the collection to be ordered. +::: + +

INDEX

+ +Apply the same migration principles from the previous processes to migrate `INDEX`. + +## Additional resources + +See the following links to learn more about static types: + +- {ref}`process-typed-page` +- {ref}`stdlib-types` +- {ref}`syntax-process-typed` diff --git a/modules/nextflow/src/main/groovy/nextflow/NextflowMeta.groovy b/modules/nextflow/src/main/groovy/nextflow/NextflowMeta.groovy index 31dcf8a092..bf7456291f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/NextflowMeta.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/NextflowMeta.groovy @@ -45,6 +45,7 @@ class NextflowMeta { boolean output boolean recursion boolean moduleBinaries + boolean types @Deprecated void setDsl( float num ) { diff --git a/modules/nextflow/src/main/groovy/nextflow/Session.groovy b/modules/nextflow/src/main/groovy/nextflow/Session.groovy index 2a07576f09..5f8a40cb41 100644 --- a/modules/nextflow/src/main/groovy/nextflow/Session.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/Session.groovy @@ -61,13 +61,13 @@ import nextflow.processor.TaskFault import nextflow.processor.TaskHandler import nextflow.processor.TaskProcessor import nextflow.script.BaseScript -import nextflow.script.ProcessConfig import nextflow.script.ProcessFactory import nextflow.script.ScriptBinding import nextflow.script.ScriptFile import nextflow.script.ScriptMeta import nextflow.script.ScriptRunner import nextflow.script.WorkflowMetadata +import nextflow.script.dsl.ProcessConfigBuilder import nextflow.spack.SpackConfig import nextflow.trace.AnsiLogObserver import nextflow.trace.TraceObserver @@ -992,7 +992,7 @@ class Session implements ISession { * @return {@code true} if the name specified belongs to the list of process names or {@code false} otherwise */ protected boolean checkValidProcessName(Collection processNames, String selector, List errorMessage) { - final matches = processNames.any { name -> ProcessConfig.matchesSelector(name, selector) } + final matches = processNames.any { name -> ProcessConfigBuilder.matchesSelector(name, selector) } if( matches ) return true diff --git a/modules/nextflow/src/main/groovy/nextflow/dag/DAG.groovy b/modules/nextflow/src/main/groovy/nextflow/dag/DAG.groovy index 1d51ae9909..c48ecfc10f 100644 --- a/modules/nextflow/src/main/groovy/nextflow/dag/DAG.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/dag/DAG.groovy @@ -40,6 +40,8 @@ import nextflow.script.params.OutParam import nextflow.script.params.OutputsList import nextflow.script.params.TupleInParam import nextflow.script.params.TupleOutParam +import nextflow.script.params.v2.ProcessInputsDef +import nextflow.script.params.v2.ProcessOutputsDef import java.util.concurrent.atomic.AtomicLong @@ -104,6 +106,19 @@ class DAG { addVertex( Type.PROCESS, label, normalizeInputs(inputs), normalizeOutputs(outputs), process ) } + void addProcessNode( String label, ProcessInputsDef inputs, ProcessOutputsDef outputs, TaskProcessor process=null ) { + assert label + assert inputs + assert outputs + final normalizedInputs = inputs.getParams().collect { p -> + new ChannelHandler(channel: p.getChannel(), label: p.getName()) + } + final normalizedOutputs = outputs.getParams().collect { p -> + new ChannelHandler(channel: p.getChannel(), label: p.getName()) + } + addVertex( Type.PROCESS, label, normalizedInputs, normalizedOutputs, process ) + } + /** * Creates a new DAG vertex representing a dataflow operator * diff --git a/modules/nextflow/src/main/groovy/nextflow/dag/NodeMarker.groovy b/modules/nextflow/src/main/groovy/nextflow/dag/NodeMarker.groovy index c7885ee71a..4b48d55706 100644 --- a/modules/nextflow/src/main/groovy/nextflow/dag/NodeMarker.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/dag/NodeMarker.groovy @@ -23,6 +23,8 @@ import nextflow.Session import nextflow.processor.TaskProcessor import nextflow.script.params.InputsList import nextflow.script.params.OutputsList +import nextflow.script.params.v2.ProcessInputsDef +import nextflow.script.params.v2.ProcessOutputsDef /** * Helper class to mark DAG node with the proper labels * @@ -51,6 +53,11 @@ class NodeMarker { session.dag.addProcessNode( process.name, inputs, outputs, process ) } + static void addProcessNode( TaskProcessor process, ProcessInputsDef inputs, ProcessOutputsDef outputs ) { + if( session && session.dag && !session.aborted ) + session.dag.addProcessNode( process.name, inputs, outputs, process ) + } + /** * Creates a new DAG vertex representing a dataflow operator * diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskEnvCollector.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskEnvCollector.groovy new file mode 100644 index 0000000000..44d90369c4 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskEnvCollector.groovy @@ -0,0 +1,83 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.processor + +import java.nio.file.Path +import java.util.regex.Matcher + +import groovy.transform.CompileStatic +import nextflow.exception.ProcessEvalException +/** + * Implements the collection of environment variables + * from the environment of a task execution. + * + * @author Paolo Di Tommaso + * @author Ben Sherman + */ +@CompileStatic +class TaskEnvCollector { + + private Path workDir + + private Map evalCmds + + TaskEnvCollector(Path workDir, Map evalCmds) { + this.workDir = workDir + this.evalCmds = evalCmds + } + + /** + * Load the values for `env` and `eval` outputs from the `.command.env` file. + */ + Map collect() { + final env = workDir.resolve(TaskRun.CMD_ENV).text + final result = new HashMap(50) + Matcher matcher + // `current` represents the current capturing env variable name + String current = null + for( String line : env.readLines() ) { + // Opening condition: + // line should match a KEY=VALUE syntax + if( !current && (matcher = (line=~/([a-zA-Z_][a-zA-Z0-9_]*)=(.*)/)) ) { + final key = matcher.group(1) + final value = matcher.group(2) + if (!key) continue + result.put(key, value) + current = key + } + // Closing condition: + // line should match /KEY/ or /KEY/=exit_status + else if( current && (matcher = (line=~/\/${current}\/(?:=exit:(\d+))?/)) ) { + final status = matcher.group(1) as Integer ?: 0 + // when exit status is defined and it is a non-zero, it should be interpreted + // as a failure of the execution of the output command; in this case the variable + // holds the std error message + if( evalCmds != null && status ) { + final cmd = evalCmds.get(current) + final out = result[current] + throw new ProcessEvalException("Unable to evaluate output", cmd, out, status) + } + // reset current key + current = null + } + else if( current && line != null ) { + result[current] += '\n' + line + } + } + return result + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskFileCollector.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskFileCollector.groovy new file mode 100644 index 0000000000..205f79ce79 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskFileCollector.groovy @@ -0,0 +1,151 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.processor + +import java.nio.file.LinkOption +import java.nio.file.Path +import java.nio.file.NoSuchFileException + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.exception.MissingFileException +import nextflow.file.FileHelper +import nextflow.file.FilePatternSplitter +/** + * Implements the collection of output files from a task + * work directory. + * + * @author Paolo Di Tommaso + * @author Ben Sherman + */ +@Slf4j +@CompileStatic +class TaskFileCollector { + + private static final Map DEFAULT_OPTS = [followLinks: true, glob: true] + + private List filePatterns + + private Map opts + + private TaskRun task + + private Path workDir + + TaskFileCollector(List filePatterns, Map opts, TaskRun task) { + this.filePatterns = filePatterns + this.opts = DEFAULT_OPTS + opts + this.task = task + this.workDir = task.getTargetDir() + } + + List collect() { + final List allFiles = [] + boolean inputsExcluded = false + + for( String filePattern : filePatterns ) { + List result = null + + final splitter = opts.glob ? FilePatternSplitter.glob().parse(filePattern) : null + if( splitter?.isPattern() ) { + result = fetchResultFiles(filePattern, workDir) + if( result && !opts.includeInputs ) { + result = excludeStagedInputs(result) + log.trace "Process ${task.lazyName()} > after removing staged inputs: ${result}" + inputsExcluded |= (result.size()==0) + } + } + else { + final path = opts.glob ? splitter.strip(filePattern) : filePattern + final file = workDir.resolve(path) + final exists = checkFileExists(file) + if( exists ) + result = List.of(file) + else + log.debug "Process `${task.lazyName()}` is unable to find [${file.class.simpleName}]: `$file` (pattern: `$filePattern`)" + } + + if( result ) { + allFiles.addAll(result) + } + else if( !opts.optional ) { + def msg = "Missing output file(s) `$filePattern` expected by process `${task.lazyName()}`" + if( inputsExcluded ) + msg += " (note: input files are not included in the default matching set)" + throw new MissingFileException(msg) + } + } + + return allFiles + } + + /** + * Collect the file(s) matching the specified name or glob pattern + * in the given task work directory. + * + * @param pattern + * @param workDir + */ + protected List fetchResultFiles(String pattern, Path workDir) { + final opts = visitOptions(pattern) + + List files = [] + try { + FileHelper.visitFiles(opts, workDir, pattern) { Path it -> files.add(it) } + } + catch( NoSuchFileException e ) { + throw new MissingFileException("Cannot access directory: '$workDir'", e) + } + + return files.sort() + } + + protected Map visitOptions(String pattern) { + return [ + relative: false, + hidden: opts.hidden ?: pattern.startsWith('.'), + followLinks: opts.followLinks, + maxDepth: opts.maxDepth, + type: opts.type ? opts.type : ( pattern.contains('**') ? 'file' : 'any' ) + ] + } + + /** + * Remove each path in the given list whose name matches the name of + * an input file for the specified {@code TaskRun} + * + * @param collectedFiles + */ + protected List excludeStagedInputs(List collectedFiles) { + + final List allStagedFiles = task.getStagedInputs() + final List result = new ArrayList<>(collectedFiles.size()) + + for( int i = 0; i < collectedFiles.size(); i++ ) { + final file = collectedFiles.get(i) + final relativeName = workDir.relativize(file).toString() + if( !allStagedFiles.contains(relativeName) ) + result.add(file) + } + + return result + } + + protected boolean checkFileExists(Path file) { + return opts.followLinks ? file.exists() : file.exists(LinkOption.NOFOLLOW_LINKS) + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskInputResolver.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskInputResolver.groovy new file mode 100644 index 0000000000..2c1d6721bc --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskInputResolver.groovy @@ -0,0 +1,301 @@ +/* + * Copyright 2013-2025, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.processor + +import java.nio.file.Path +import java.util.regex.Matcher +import java.util.regex.Pattern + +import groovy.transform.CompileStatic +import groovy.util.logging.Slf4j +import nextflow.Nextflow +import nextflow.exception.ProcessUnrecoverableException +import nextflow.executor.Executor +import nextflow.file.FileHelper +import nextflow.file.FileHolder +import nextflow.file.FilePorter +import nextflow.file.LogicalDataPath +import nextflow.script.ScriptType +import nextflow.script.params.FileInParam +import nextflow.script.params.v2.ProcessFileInput +import nextflow.util.ArrayBag +import nextflow.util.BlankSeparatedList +/** + * Implements the resolution of input files for a task. + * + * @author Paolo Di Tommaso + * @author Ben Sherman + */ +@Slf4j +@CompileStatic +class TaskInputResolver { + + private TaskRun task + + private FilePorter.Batch foreignFiles + + private Executor executor + + private int count = 0 + + TaskInputResolver(TaskRun task, FilePorter.Batch foreignFiles, Executor executor) { + this.task = task + this.foreignFiles = foreignFiles + this.executor = executor + } + + /** + * Resolve a file input for a legacy process. + * + * @param param + * @param value + */ + List resolve(FileInParam param, Object value) { + final ctx = task.context + final normalized = normalizeInputToFiles(value, count, param.isPathQualifier()) + final resolved = expandWildcards( param.getFilePattern(ctx), normalized ) + + // add to context if the path was declared with a variable name + if( param.name ) + ctx.put( param.name, singleItemOrList(resolved, param.isSingle(), task.type) ) + + count += resolved.size() + + return resolved + } + + /** + * Resolve a file input for a typed process. + * + * @param fileInput + * @param value + */ + List resolve(ProcessFileInput fileInput, Object value) { + final ctx = task.context + final normalized = normalizeInputToFiles(value, count, true) + final resolved = expandWildcards( fileInput.getFilePattern(ctx), normalized ) + + // update param value in task context if applicable + for( final param : task.inputs.keySet() ) { + if( task.inputs[param] == value ) + ctx.put( param.name, singleItemOrList(resolved, value instanceof Path, task.type) ) + } + + count += resolved.size() + + return resolved + } + + protected List normalizeInputToFiles( Object obj, int count, boolean coerceToPath ) { + + Collection allItems = obj instanceof Collection ? obj : [obj] + def len = allItems.size() + + // use a bag so that cache hash key is not affected by file entries order + def files = new ArrayBag(len) + for( def item : allItems ) { + + if( item instanceof Path || coerceToPath ) { + final path = resolvePath(item) + final target = executor.isForeignFile(path) ? foreignFiles.addToForeign(path) : path + final holder = new FileHolder(target) + files << holder + } + else { + files << normalizeInputToFile(item, "input.${++count}") + } + } + + return files + } + + protected static Path resolvePath(Object item) { + final result = normalizeToPath(item) + return result instanceof LogicalDataPath + ? result.toTargetPath() + : result + } + + protected static Path normalizeToPath( obj ) { + if( obj instanceof Path ) + return obj + + if( obj == null ) + throw new ProcessUnrecoverableException("Path value cannot be null") + + if( !(obj instanceof CharSequence) ) + throw new ProcessUnrecoverableException("Not a valid path value type: ${obj.getClass().getName()} ($obj)") + + def str = obj.toString().trim() + if( str.contains('\n') ) + throw new ProcessUnrecoverableException("Path value cannot contain a new-line character: $str") + if( str.startsWith('/') ) + return FileHelper.asPath(str) + if( FileHelper.getUrlProtocol(str) ) + return FileHelper.asPath(str) + if( !str ) + throw new ProcessUnrecoverableException("Path value cannot be empty") + + throw new ProcessUnrecoverableException("Not a valid path value: '$str'") + } + + /** + * An input file parameter can be provided with any value other than a file. + * This function normalize a generic value to a {@code Path} create a temporary file + * in the for it. + * + * @param input The input value + * @param altName The name to be used when a temporary file is created. + * @return The {@code Path} that will be staged in the task working folder + */ + protected static FileHolder normalizeInputToFile( Object input, String altName ) { + /* + * when it is a local file, just return a reference holder to it + */ + if( input instanceof Path ) { + return new FileHolder(input) + } + + /* + * default case, convert the input object to a string and save + * to a local file + */ + def source = input?.toString() ?: '' + def result = Nextflow.tempFile(altName) + result.text = source + return new FileHolder(source, result) + } + + /** + * An input file name may contain wildcards characters which have to be handled coherently + * given the number of files specified. + * + * @param name A file name with may contain a wildcard character star {@code *} or question mark {@code ?}. + * Only one occurrence can be specified for star or question mark wildcards. + * + * @param value Any value that have to be managed as an input files. Values other than {@code Path} are converted + * to a string value, using the {@code #toString} method and saved in the local file-system. Value of type {@code Collection} + * are expanded to multiple values accordingly. + * + * @return + */ + protected static List expandWildcards( String name, List files ) { + assert files != null + + // use an unordered so that cache hash key is not affected by file entries order + final result = new ArrayBag(files.size()) + if( files.size()==0 ) { return result } + + if( !name || name == '*' ) { + result.addAll(files) + return result + } + + if( !name.contains('*') && !name.contains('?') && files.size()>1 ) { + /* + * When name do not contain any wildcards *BUT* multiple files are provide + * it is managed like having a 'star' at the end of the file name + */ + name += '*' + } + + for( int i=0; i items, boolean single, ScriptType type ) { + assert items != null + + if( items.size() == 1 && single ) { + return makePath(items[0],type) + } + + def result = new ArrayList(items.size()) + for( int i=0; i + */ +@Slf4j +@CompileStatic +class TaskOutputResolver implements Map { + + private Map declaredFiles + + private TaskRun task + + @Delegate + private Map delegate + + TaskOutputResolver(Map declaredFiles, TaskRun task) { + this.declaredFiles = declaredFiles + this.task = task + this.delegate = task.context + } + + /** + * Get an environment variable from the task environment. + * + * The underscore is needed to prevent calls from being dispatched + * to Nextflow.env(). + * + * @param name + */ + String _env(String name) { + final result = env0(null).get(name) + + if( result == null ) + throw new MissingValueException("Missing environment variable: $name") + + return result + } + + /** + * Get the result of an eval command from the task environment. + * + * @param name + */ + String eval(String name) { + final evalCmds = task.getOutputEvals() + final result = env0(evalCmds).get(name) + + if( result == null ) + throw new MissingValueException("Missing result of eval command: '${evalCmds.get(name)}'") + + return result + } + + @Memoized + private Map env0(Map evalCmds) { + new TaskEnvCollector(task.workDir, evalCmds).collect() + } + + /** + * Get a file from the task environment. + * + * The underscore is needed to prevent calls from being dispatched + * to Nextflow.file(). + * + * @param key + */ + Path _file(Map opts=[:], String key) { + final param = declaredFiles.get(key) + final filePattern = param.getFilePattern(delegate) + if( filePattern.startsWith('/') ) + throw new IllegalArgumentException("Process output file '${filePattern}' in `${task.lazyName()}` is an absolute path") + + final allFiles = files0(filePattern, opts) + if( allFiles.isEmpty() && opts.optional ) + return null + if( allFiles.size() != 1 ) + throw new IllegalArityException("Process output file '${filePattern}' in `${task.lazyName()}` yielded ${allFiles.size()} files but expected only one") + + final result = allFiles.first() + task.outputFiles.add(result) + return result + } + + /** + * Get a collection of files from the task environment. + * + * The underscore is needed to prevent calls from being dispatched + * to Nextflow.files(). + * + * @param key + */ + Set _files(Map opts=[:], String key) { + final param = declaredFiles.get(key) + final filePattern = param.getFilePattern(delegate) + if( filePattern.startsWith('/') ) + throw new IllegalArgumentException("Process output glob '${filePattern}' in `${task.lazyName()}` is an absolute path") + + final allFiles = files0(filePattern, opts) + task.outputFiles.addAll(allFiles) + return allFiles.toSet() + } + + @Memoized + private List files0(String filePattern, Map opts) { + new TaskFileCollector([filePattern], opts, task).collect() + } + + /** + * Get the standard output from the task environment. + */ + Object stdout() { + final value = task.@stdout + + if( value == null ) + throw new IllegalArgumentException("Missing 'stdout' for process > ${task.lazyName()}") + + if( value instanceof Path && !value.exists() ) + throw new MissingFileException("Missing 'stdout' file: ${value.toUriString()} for process > ${task.lazyName()}") + + return value instanceof Path ? value.text : value?.toString() + } + + /** + * Get a variable from the task context. + * + * @param name + */ + @Override + @CompileDynamic + Object get(Object name) { + try { + return InvokerHelper.getProperty(delegate, name) + } + catch( MissingPropertyException e ) { + throw new MissingValueException("Missing variable in process output: ${e.property}") + } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy index a6718402ee..ff7321402b 100644 --- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskProcessor.groovy @@ -19,15 +19,12 @@ import static nextflow.processor.ErrorStrategy.* import java.lang.reflect.InvocationTargetException import java.nio.file.FileSystems -import java.nio.file.LinkOption import java.nio.file.NoSuchFileException import java.nio.file.Path -import java.nio.file.Paths import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicIntegerArray import java.util.concurrent.atomic.LongAdder -import java.util.regex.Matcher import java.util.regex.Pattern import ch.artecat.grengine.Grengine @@ -50,7 +47,6 @@ import groovyx.gpars.dataflow.operator.PoisonPill import groovyx.gpars.dataflow.stream.DataflowStreamWriteAdapter import groovyx.gpars.group.PGroup import nextflow.NF -import nextflow.Nextflow import nextflow.Session import nextflow.ast.TaskCmdXform import nextflow.ast.TaskTemplateVarsXform @@ -75,14 +71,14 @@ import nextflow.extension.CH import nextflow.extension.DataflowHelper import nextflow.file.FileHelper import nextflow.file.FileHolder -import nextflow.file.FilePatternSplitter import nextflow.file.FilePorter -import nextflow.file.LogicalDataPath import nextflow.plugin.Plugins import nextflow.processor.tip.TaskTipProvider import nextflow.script.BaseScript import nextflow.script.BodyDef import nextflow.script.ProcessConfig +import nextflow.script.ProcessConfigV1 +import nextflow.script.ProcessConfigV2 import nextflow.script.ScriptMeta import nextflow.script.ScriptType import nextflow.script.bundle.ResourcesBundle @@ -104,9 +100,10 @@ import nextflow.script.params.TupleInParam import nextflow.script.params.TupleOutParam import nextflow.script.params.ValueInParam import nextflow.script.params.ValueOutParam +import nextflow.script.params.v2.ProcessInput +import nextflow.script.params.v2.ProcessTupleInput +import nextflow.script.types.Types import nextflow.trace.TraceRecord -import nextflow.util.ArrayBag -import nextflow.util.BlankSeparatedList import nextflow.util.CacheHelper import nextflow.util.Escape import nextflow.util.HashBuilder @@ -136,8 +133,6 @@ class TaskProcessor { final private static Pattern ENV_VAR_NAME = ~/[a-zA-Z_]+[a-zA-Z0-9_]*/ - final private static Pattern QUESTION_MARK = ~/(\?+)/ - @TestOnly private static volatile TaskProcessor currentProcessor0 @TestOnly static TaskProcessor currentProcessor() { currentProcessor0 } @@ -212,10 +207,6 @@ class TaskProcessor { */ protected volatile boolean completed - protected boolean allScalarValues - - protected boolean hasEachParams - /** * The state is maintained by using an agent */ @@ -250,7 +241,7 @@ class TaskProcessor { private static LockManager lockManager = new LockManager() - private List> fairBuffers = new ArrayList<>() + private List fairBuffers = new ArrayList<>() private int currentEmission @@ -327,6 +318,10 @@ class TaskProcessor { */ ProcessConfig getConfig() { config } + private ProcessConfigV1 configV1() { config as ProcessConfigV1 } + + private ProcessConfigV2 configV2() { config as ProcessConfigV2 } + /** * @return The current {@code Session} instance */ @@ -363,9 +358,15 @@ class TaskProcessor { BodyDef getTaskBody() { taskBody } Set getDeclaredNames() { - Set result = new HashSet<>(20) - result.addAll(config.getInputs().getNames()) - result.addAll(config.getOutputs().getNames()) + final result = new HashSet(20) + if( config instanceof ProcessConfigV1 ) { + result.addAll(config.getInputs().getNames()) + result.addAll(config.getOutputs().getNames()) + } + else if( config instanceof ProcessConfigV2 ) { + result.addAll(config.getInputs().getParams()*.getName()) + result.addAll(config.getOutputs().getParams()*.getName()) + } return result } @@ -417,23 +418,19 @@ class TaskProcessor { log.warn(msg) } - /** - * Launch the 'script' define by the code closure as a local bash script - * - * @param code A {@code Closure} returning a bash script e.g. - *
-     *              {
-     *                 """
-     *                 #!/bin/bash
-     *                 do this ${x}
-     *                 do that ${y}
-     *                 :
-     *                 """
-     *              }
-     *
-     * @return {@code this} instance
-     */
+    protected boolean allScalarValues
+
+    protected boolean hasEachParams
+
     def run() {
+        if( config instanceof ProcessConfigV1 )
+            runV1()
+        else if( config instanceof ProcessConfigV2 )
+            runV2()
+    }
+
+    def runV1() {
+        final config = configV1()
 
         // -- check that the task has a body
         if ( !taskBody )
@@ -469,19 +466,7 @@ class TaskProcessor {
         }
 
         // the state agent
-        state = new Agent<>(new StateObj(name))
-        state.addListener { StateObj old, StateObj obj ->
-            try {
-                log.trace "<$name> Process state changed to: $obj -- finished: ${obj.isFinished()}"
-                if( !completed && obj.isFinished() ) {
-                    terminateProcess()
-                    completed = true
-                }
-            }
-            catch( Throwable e ) {
-                session.abort(e)
-            }
-        }
+        createStateObj()
 
         // register the processor
         // note: register the task *before* creating (and starting the dataflow operator) in order
@@ -501,14 +486,24 @@ class TaskProcessor {
         return result.size() == 1 ? result[0] : result
     }
 
-    /**
-     * Template method which extending classes have to override in order to
-     * create the underlying *dataflow* operator associated with this processor
-     *
-     * See {@code DataflowProcessor}
-     */
+    protected void createStateObj() {
+        state = new Agent<>(new StateObj(name))
+        state.addListener { StateObj old, StateObj obj ->
+            try {
+                log.trace "<$name> Process state changed to: $obj -- finished: ${obj.isFinished()}"
+                if( !completed && obj.isFinished() ) {
+                    terminateProcess()
+                    completed = true
+                }
+            }
+            catch( Throwable e ) {
+                session.abort(e)
+            }
+        }
+    }
 
     protected void createOperator() {
+        def config = configV1()
         def opInputs = new ArrayList(config.getInputs().getChannels())
 
         /*
@@ -592,10 +587,6 @@ class TaskProcessor {
     }
 
     private start(DataflowProcessor op) {
-        if( !NF.dsl2 ) {
-            op.start()
-            return
-        }
         session.addIgniter {
             log.debug "Starting process > $name"
             op.start()
@@ -609,6 +600,49 @@ class TaskProcessor {
         return result
     }
 
+    void runV2() {
+        // -- check that the task has a body
+        if ( !taskBody )
+            throw new IllegalStateException("Missing task body for process `$name`")
+
+        // create the state agent
+        createStateObj()
+
+        // register the processor
+        session.processRegister(this)
+
+        // determine whether the process is executed only once
+        final inputs = config.getInputs().getChannels()
+        this.singleton = config.getInputs().isSingleton()
+
+        // create inputs with control channel
+        final control = CH.queue()
+        control.bind(Boolean.TRUE)
+
+        final opInputs = inputs + [control]
+        this.openPorts = createPortsArray(opInputs.size())
+
+        // The thread pool used by GPars. The thread pool to be used is set in the static
+        // initializer of {@link nextflow.cli.CmdRun} class. See also {@link nextflow.util.CustomPoolFactory}
+        final group = Dataflow.retrieveCurrentDFPGroup()
+
+        // note: do not specify the output channels in the operator declaration
+        // this allows us to manage them independently from the operator life-cycle
+        final interceptor = new TaskProcessorInterceptor(opInputs, singleton)
+        final params = [inputs: opInputs, maxForks: session.poolSize, listeners: [interceptor] ]
+        final invoke = new InvokeTaskAdapter(this, opInputs.size())
+        this.operator = new DataflowOperator(group, params, invoke)
+        session.allOperators << operator
+
+        // notify the creation of a new vertex the execution DAG
+        NodeMarker.addProcessNode(this, configV2().getInputs(), configV2().getOutputs())
+
+        // start the operator
+        start(operator)
+
+        session.notifyProcessCreate(this)
+    }
+
     /**
      * The processor execution body
      *
@@ -630,13 +664,10 @@ class TaskProcessor {
         // -- set the task instance as the current in this thread
         currentTask.set(task)
 
-        // -- validate input lengths
-        validateInputTuples(values)
-
         // -- map the inputs to a map and use to delegate closure values interpolation
-        final secondPass = [:]
-        int count = makeTaskContextStage1(task, secondPass, values)
-        final foreignFiles = makeTaskContextStage2(task, secondPass, count)
+        final foreignFiles = session.filePorter.newBatch(executor.getStageDir())
+
+        resolveTaskInputs(task, values, foreignFiles)
 
         // verify that `when` guard, when specified, is satisfied
         if( !checkWhenGuard(task) )
@@ -657,28 +688,6 @@ class TaskProcessor {
         checkCachedOrLaunchTask(task, hash, resumable)
     }
 
-    @Memoized
-    private List getDeclaredInputTuple() {
-        getConfig().getInputs().ofType(TupleInParam)
-    }
-
-    protected void validateInputTuples( List values ) {
-
-        def declaredSets = getDeclaredInputTuple()
-        for( int i=0; i
+        configV1().getInputs().each { InParam param ->
             if( param instanceof TupleInParam )
                 param.inner.each { task.setInput(it)  }
             else if( param instanceof EachInParam )
@@ -770,15 +786,13 @@ class TaskProcessor {
                 task.setInput(param)
         }
 
-        config.getOutputs().each { OutParam param ->
+        configV1().getOutputs().each { OutParam param ->
             if( param instanceof TupleOutParam ) {
                 param.inner.each { task.setOutput(it) }
             }
             else
                 task.setOutput(param)
         }
-
-        return task
     }
 
     /**
@@ -863,17 +877,7 @@ class TaskProcessor {
         }
 
         // -- when store path is set, only output params of type 'file' can be specified
-        final ctx = task.context
-        def invalid = task.getOutputs().keySet().any {
-            if( it instanceof ValueOutParam ) {
-                return false
-            }
-            if( it instanceof FileOutParam ) {
-                return false
-            }
-            return true
-        }
-        if( invalid ) {
+        if( isInvalidStoreDir(task) ) {
             checkWarn "[${safeTaskName(task)}] storeDir can only be used with `val` and `path` outputs"
             return false
         }
@@ -908,6 +912,26 @@ class TaskProcessor {
         }
     }
 
+    protected boolean isInvalidStoreDir(TaskRun task) {
+        final ctx = task.context
+
+        if( config instanceof ProcessConfigV1 ) {
+            return task.getOutputs().keySet().any { param ->
+                if( param instanceof ValueOutParam )
+                    return false
+                if( param instanceof FileOutParam )
+                    return false
+                return true
+            }
+        }
+
+        if( config instanceof ProcessConfigV2 ) {
+            return config.getOutputs().getFiles().isEmpty()
+        }
+
+        return false
+    }
+
     /**
      * Check whenever the outputs for the specified task already exist
      *
@@ -962,16 +986,16 @@ class TaskProcessor {
         }
 
         try {
-            // -- expose task exit status to make accessible as output value
+            // -- set task properties in order to resolve task outputs
+            task.workDir = folder
+            task.stdout = stdoutFile
             task.config.exitStatus = exitCode
             // -- check if all output resources are available
-            collectOutputs(task, folder, stdoutFile, task.context)
+            collectOutputs(task)
 
             // set the exit code in to the task object
             task.cached = true
             task.hash = hash
-            task.workDir = folder
-            task.stdout = stdoutFile
             if( exitCode != null ) {
                 task.exitStatus = exitCode
             }
@@ -1321,7 +1345,11 @@ class TaskProcessor {
     final protected synchronized void sendPoisonPill() {
         log.trace "<$name> Sending a poison pill(s)"
 
-        for( DataflowWriteChannel channel : config.getOutputs().getChannels() ){
+        final channels = config instanceof ProcessConfigV2
+            ? configV2().getOutputs().getChannels()
+            : configV1().getOutputs().getChannels()
+
+        for( DataflowWriteChannel channel : channels ){
 
             if( channel instanceof DataflowQueue ) {
                 channel.bind( PoisonPill.instance )
@@ -1396,12 +1424,29 @@ class TaskProcessor {
         }
     }
 
+    @CompileStatic
     private void publishOutputs0( TaskRun task, PublishDir publish ) {
 
         if( publish.overwrite == null ) {
             publish.overwrite = !task.cached
         }
 
+        final files = getPublishFiles(task)
+
+        publish.apply(files, task)
+    }
+
+    @CompileStatic
+    private Set getPublishFiles(TaskRun task) {
+        if( config instanceof ProcessConfigV1 )
+            return getPublishFilesV1(task)
+        if( config instanceof ProcessConfigV2 )
+            return task.outputFiles
+        return null
+    }
+
+    @CompileStatic
+    private Set getPublishFilesV1(TaskRun task) {
         HashSet files = []
         def outputs = task.getOutputsByType(FileOutParam)
         for( Map.Entry entry : outputs ) {
@@ -1416,8 +1461,7 @@ class TaskProcessor {
                 throw new IllegalArgumentException("Unknown output file object [${value.class.name}]: ${value}")
             }
         }
-
-        publish.apply(files, task)
+        return files
     }
 
     /**
@@ -1426,9 +1470,64 @@ class TaskProcessor {
      */
     synchronized protected void bindOutputs( TaskRun task ) {
 
+        // bind the output
+        if( isFair0 ) {
+            fairBindOutputs0(task)
+        }
+        else {
+            bindOutputs0(task)
+        }
+
+        // -- finally prints out the task output when 'debug' is true
+        if( task.config.debug ) {
+            task.echoStdout(session)
+        }
+    }
+
+    protected void fairBindOutputs0(TaskRun task) {
+        synchronized (isFair0) {
+            // decrement -1 because tasks are 1-based
+            final index = task.index-1
+            // store the task emission values in a buffer
+            fairBuffers[index-currentEmission] = task
+            // check if the current task index matches the expected next emission index
+            if( currentEmission == index ) {
+                while( task!=null ) {
+                    // bind the emission values
+                    bindOutputs0(task)
+                    // remove the head and try with the following
+                    fairBuffers.remove(0)
+                    // increase the index of the next emission
+                    currentEmission++
+                    // take the next task 
+                    task = fairBuffers[0]
+                }
+            }
+        }
+    }
+
+    protected void bindOutputs0(TaskRun task) {
+        if( config instanceof ProcessConfigV2 )
+            bindOutputsV2(task)
+        else if( config instanceof ProcessConfigV1 )
+            bindOutputsV1(task)
+    }
+
+    @CompileStatic
+    protected void bindOutputsV2(TaskRun task) {
+        for( final param : configV2().getOutputs().getParams() ) {
+            final value = task.outputs[param]
+
+            log.trace "Process $name > Emitting output: ${param.name} = ${value}"
+            param.getChannel().bind(value)
+        }
+    }
+
+    protected void bindOutputsV1(TaskRun task) {
+
         // -- creates the map of all tuple values to bind
         Map tuples = [:]
-        for( OutParam param : config.getOutputs() ) {
+        for( OutParam param : configV1().getOutputs() ) {
             tuples.put(param.index, [])
         }
 
@@ -1460,45 +1559,8 @@ class TaskProcessor {
             }
         }
 
-        // bind the output
-        if( isFair0 ) {
-            fairBindOutputs0(tuples, task)
-        }
-        else {
-            bindOutputs0(tuples)
-        }
-
-        // -- finally prints out the task output when 'debug' is true
-        if( task.config.debug ) {
-            task.echoStdout(session)
-        }
-    }
-
-    protected void fairBindOutputs0(Map emissions, TaskRun task) {
-        synchronized (isFair0) {
-            // decrement -1 because tasks are 1-based
-            final index = task.index-1
-            // store the task emission values in a buffer
-            fairBuffers[index-currentEmission] = emissions
-            // check if the current task index matches the expected next emission index
-            if( currentEmission == index ) {
-                while( emissions!=null ) {
-                    // bind the emission values
-                    bindOutputs0(emissions)
-                    // remove the head and try with the following
-                    fairBuffers.remove(0)
-                    // increase the index of the next emission
-                    currentEmission++
-                    // take the next emissions 
-                    emissions = fairBuffers[0]
-                }
-            }
-        }
-    }
-
-    protected void bindOutputs0(Map tuples) {
         // -- bind out the collected values
-        for( OutParam param : config.getOutputs() ) {
+        for( OutParam param : configV1().getOutputs() ) {
             final outValue = tuples[param.index]
             if( outValue == null )
                 throw new IllegalStateException()
@@ -1527,31 +1589,53 @@ class TaskProcessor {
         }
     }
 
-    protected void collectOutputs( TaskRun task ) {
-        collectOutputs( task, task.getTargetDir(), task.@stdout, task.context )
-    }
-
     /**
      * Once the task has completed this method is invoked to collected all the task results
      *
      * @param task
      */
-    final protected void collectOutputs( TaskRun task, Path workDir, def stdout, Map context ) {
+    @CompileStatic
+    protected void collectOutputs( TaskRun task ) {
+        if( config instanceof ProcessConfigV2 )
+            collectOutputsV2( task )
+        else if( config instanceof ProcessConfigV1 )
+            collectOutputsV1( task, task.getTargetDir() )
+    }
+
+    @CompileStatic
+    protected void collectOutputsV2(TaskRun task) {
+        final declaredOutputs = configV2().getOutputs()
+        final resolver = new TaskOutputResolver(declaredOutputs.getFiles(), task)
+
+        for( final param : declaredOutputs.getParams() ) {
+            final value = resolver.resolveLazy(param.getLazyValue())
+            task.setOutput(param, value)
+        }
+
+        for( final topic : declaredOutputs.getTopics() ) {
+            final value = resolver.resolveLazy(topic.getLazyValue())
+            topic.getChannel().bind(value)
+        }
+
+        task.canBind = true
+    }
+
+    final protected void collectOutputsV1( TaskRun task, Path workDir ) {
         log.trace "<$name> collecting output: ${task.outputs}"
 
         for( OutParam param : task.outputs.keySet() ) {
 
             switch( param ) {
                 case StdOutParam:
-                    collectStdOut(task, (StdOutParam)param, stdout)
+                    collectStdOut(task, (StdOutParam)param, task.@stdout)
                     break
 
                 case FileOutParam:
-                    collectOutFiles(task, (FileOutParam)param, workDir, context)
+                    collectOutFiles(task, (FileOutParam)param, workDir)
                     break
 
                 case ValueOutParam:
-                    collectOutValues(task, (ValueOutParam)param, context)
+                    collectOutValues(task, (ValueOutParam)param, task.context)
                     break
 
                 case EnvOutParam:
@@ -1603,41 +1687,7 @@ class TaskProcessor {
     @CompileStatic
     @Memoized(maxCacheSize = 10_000)
     protected Map collectOutEnvMap(Path workDir, Map outEvals) {
-        final env = workDir.resolve(TaskRun.CMD_ENV).text
-        final result = new HashMap(50)
-        Matcher matcher
-        // `current` represent the current capturing env variable name
-        String current=null
-        for(String line : env.readLines() ) {
-            // Opening condition:
-            // line should match a KEY=VALUE syntax
-            if( !current && (matcher = (line=~/([a-zA-Z_][a-zA-Z0-9_]*)=(.*)/)) ) {
-                final k = matcher.group(1)
-                final v = matcher.group(2)
-                if (!k) continue
-                result.put(k,v)
-                current = k
-            }
-            // Closing condition:
-            // line should match /KEY/  or  /KEY/=exit_status
-            else if( current && (matcher = (line=~/\/${current}\/(?:=exit:(\d+))?/)) ) {
-                final status = matcher.group(1) as Integer ?: 0
-                // when exit status is defined and it is a non-zero, it should be interpreted
-                // as a failure of the execution of the output command; in this case the variable
-                // holds the std error message
-                if( outEvals!=null && status ) {
-                    final cmd = outEvals.get(current)
-                    final out = result[current]
-                    throw new ProcessEvalException("Unable to evaluate output", cmd, out, status)
-                }
-                // reset current key
-                current = null
-            }
-            else if( current && line!=null) {
-                result[current] += '\n' + line
-            }
-        }
-        return result
+        return new TaskEnvCollector(workDir, outEvals).collect()
     }
 
     /**
@@ -1660,46 +1710,20 @@ class TaskProcessor {
         task.setOutput(param, stdout)
     }
 
-    protected void collectOutFiles( TaskRun task, FileOutParam param, Path workDir, Map context ) {
+    protected void collectOutFiles( TaskRun task, FileOutParam param, Path workDir ) {
 
-        final List allFiles = []
         // type file parameter can contain a multiple files pattern separating them with a special character
-        def entries = param.getFilePatterns(context, task.workDir)
-        boolean inputsRemovedFlag = false
-        // for each of them collect the produced files
-        for( String filePattern : entries ) {
-            List result = null
-
-            def splitter = param.glob ? FilePatternSplitter.glob().parse(filePattern) : null
-            if( splitter?.isPattern() ) {
-                result = fetchResultFiles(param, filePattern, workDir)
-                // filter the inputs
-                if( result && !param.includeInputs ) {
-                    result = filterByRemovingStagedInputs(task, result, workDir)
-                    log.trace "Process ${safeTaskName(task)} > after removing staged inputs: ${result}"
-                    inputsRemovedFlag |= (result.size()==0)
-                }
-            }
-            else {
-                def path = param.glob ? splitter.strip(filePattern) : filePattern
-                def file = workDir.resolve(path)
-                def exists = checkFileExists(file, param.followLinks)
-                if( exists )
-                    result = List.of(file)
-                else
-                    log.debug "Process `${safeTaskName(task)}` is unable to find [${file.class.simpleName}]: `$file` (pattern: `$filePattern`)"
-            }
-
-            if( result )
-                allFiles.addAll(result)
-
-            else if( !param.optional && (!param.arity || param.arity.min > 0) ) {
-                def msg = "Missing output file(s) `$filePattern` expected by process `${safeTaskName(task)}`"
-                if( inputsRemovedFlag )
-                    msg += " (note: input files are not included in the default matching set)"
-                throw new MissingFileException(msg)
-            }
-        }
+        final filePatterns = param.getFilePatterns(task.context, task.workDir)
+        final opts = [
+            followLinks: param.followLinks,
+            glob: param.glob,
+            hidden: param.hidden,
+            includeInputs: param.includeInputs,
+            maxDepth: param.maxDepth,
+            optional: param.optional || param.arity?.min == 0,
+            type: param.type,
+        ]
+        final allFiles = collectOutFiles0(task, filePatterns, opts)
 
         if( !param.isValidArity(allFiles.size()) )
             throw new IllegalArityException("Incorrect number of output files for process `${safeTaskName(task)}` -- expected ${param.arity}, found ${allFiles.size()}")
@@ -1708,8 +1732,8 @@ class TaskProcessor {
 
     }
 
-    protected boolean checkFileExists(Path file, boolean followLinks) {
-        followLinks ? file.exists() : file.exists(LinkOption.NOFOLLOW_LINKS)
+    protected List collectOutFiles0(TaskRun task, List filePatterns, Map opts) {
+        return new TaskFileCollector(filePatterns, opts, task).collect()
     }
 
     protected void collectOutValues( TaskRun task, ValueOutParam param, Map ctx ) {
@@ -1728,91 +1752,6 @@ class TaskProcessor {
 
     }
 
-    /**
-     * Collect the file(s) with the name specified, produced by the execution
-     *
-     * @param workDir The job working path
-     * @param namePattern The file name, it may include file name wildcards
-     * @return The list of files matching the specified name in lexicographical order
-     * @throws MissingFileException when no matching file is found
-     */
-    @PackageScope
-    List fetchResultFiles( FileOutParam param, String namePattern, Path workDir ) {
-        assert namePattern
-        assert workDir
-
-        List files = []
-        def opts = visitOptions(param, namePattern)
-        // scan to find the file with that name
-        try {
-            FileHelper.visitFiles(opts, workDir, namePattern) { Path it -> files.add(it) }
-        }
-        catch( NoSuchFileException e ) {
-            throw new MissingFileException("Cannot access directory: '$workDir'", e)
-        }
-
-        return files.sort()
-    }
-
-    /**
-     * Given a {@link FileOutParam} object create the option map for the
-     * {@link FileHelper#visitFiles(java.util.Map, java.nio.file.Path, java.lang.String, groovy.lang.Closure)} method
-     *
-     * @param param A task {@link FileOutParam}
-     * @param namePattern A file glob pattern
-     * @return A {@link Map} object holding the traverse options for the {@link FileHelper#visitFiles(java.util.Map, java.nio.file.Path, java.lang.String, groovy.lang.Closure)} method
-     */
-    @PackageScope
-    Map visitOptions( FileOutParam param, String namePattern ) {
-        final opts = [:]
-        opts.relative = false
-        opts.hidden = param.hidden ?: namePattern.startsWith('.')
-        opts.followLinks = param.followLinks
-        opts.maxDepth = param.maxDepth
-        opts.type = param.type ? param.type : ( namePattern.contains('**') ? 'file' : 'any' )
-        return opts
-    }
-
-    /**
-     * Given a list of {@code Path} removes all the hidden file i.e. the ones which names starts with a dot char
-     * @param files A list of {@code Path}
-     * @return The result list not containing hidden file entries
-     */
-    @PackageScope
-    List filterByRemovingHiddenFiles( List files ) {
-        files.findAll { !it.getName().startsWith('.') }
-    }
-
-    /**
-     * Given a list of {@code Path} removes all the entries which name match the name of
-     * file used as input for the specified {@code TaskRun}
-     *
-     * See TaskRun#getStagedInputs
-     *
-     * @param task
-     *      A {@link TaskRun} object representing the task executed
-     * @param collectedFiles
-     *      Collection of candidate output files
-     * @return
-     *      List of the actual output files (not including any input matching an output file name pattern)
-     */
-    @PackageScope
-    List filterByRemovingStagedInputs( TaskRun task, List collectedFiles, Path workDir ) {
-
-        // get the list of input files
-        final List allStaged = task.getStagedInputs()
-        final List result = new ArrayList<>(collectedFiles.size())
-
-        for( int i=0; i normalizeInputToFiles( Object obj, int count, boolean coerceToPath, FilePorter.Batch foreignFiles ) {
-
-        Collection allItems = obj instanceof Collection ? obj : [obj]
-        def len = allItems.size()
-
-        // use a bag so that cache hash key is not affected by file entries order
-        def files = new ArrayBag(len)
-        for( def item : allItems ) {
-
-            if( item instanceof Path || coerceToPath ) {
-                final path = resolvePath(item)
-                final target = executor.isForeignFile(path) ? foreignFiles.addToForeign(path) : path
-                final holder = new FileHolder(target)
-                files << holder
-            }
-            else {
-                files << normalizeInputToFile(item, "input.${++count}")
-            }
-        }
-
-        return files
-    }
-
-    protected singleItemOrList( List items, boolean single, ScriptType type ) {
-        assert items != null
-
-        if( items.size() == 1 && single ) {
-            return makePath(items[0],type)
-        }
-
-        def result = new ArrayList(items.size())
-        for( int i=0; i expandWildcards( String name, List files ) {
-        assert files != null
-
-        // use an unordered so that cache hash key is not affected by file entries order
-        final result = new ArrayBag(files.size())
-        if( files.size()==0 ) { return result }
-
-        if( !name || name == '*' ) {
-            result.addAll(files)
-            return result
-        }
-
-        if( !name.contains('*') && !name.contains('?') && files.size()>1 ) {
-            /*
-             * When name do not contain any wildcards *BUT* multiple files are provide
-             * it is managed like having a 'star' at the end of the file name
-             */
-            name += '*'
-        }
-
-        for( int i=0; i fileParams = [:]
 
         task.inputs.keySet().each { InParam param ->
 
@@ -2122,16 +1879,19 @@ class TaskProcessor {
 
             switch(param) {
                 case ValueInParam:
-                    contextMap.put( param.name, val )
+                    task.context.put(param.name, val)
                     break
 
                 case FileInParam:
-                    secondPass[param] = val
+                    fileParams[param] = val
                     return // <-- leave it, because we do not want to add this 'val' at this stage
 
                 case StdInParam:
+                    task.stdin = val
+                    break
+
                 case EnvInParam:
-                    // nothing to do
+                    task.inputEnv.put(param.name, val?.toString())
                     break
 
                 default:
@@ -2142,60 +1902,126 @@ class TaskProcessor {
             task.setInput(param, val)
         }
 
-        return count
-    }
-
-    final protected FilePorter.Batch makeTaskContextStage2( TaskRun task, Map secondPass, int count ) {
-
-        final ctx = task.context
-        final allNames = new HashMap()
-
-        final FilePorter.Batch foreignFiles = session.filePorter.newBatch(executor.getStageDir())
-
         // -- all file parameters are processed in a second pass
         //    so that we can use resolve the variables that eventually are in the file name
-        for( Map.Entry entry : secondPass.entrySet() ) {
+        final resolver = new TaskInputResolver(task, foreignFiles, executor)
+
+        for( Map.Entry entry : fileParams.entrySet() ) {
             final param = entry.getKey()
             final val = entry.getValue()
-            final fileParam = param as FileInParam
-            final normalized = normalizeInputToFiles(val, count, fileParam.isPathQualifier(), foreignFiles)
-            final resolved = expandWildcards( fileParam.getFilePattern(ctx), normalized )
+            final resolved = resolver.resolve(param, val)
 
             if( !param.isValidArity(resolved.size()) )
                 throw new IllegalArityException("Incorrect number of input files for process `${safeTaskName(task)}` -- expected ${param.arity}, found ${resolved.size()}")
 
-            ctx.put( param.name, singleItemOrList(resolved, param.isSingle(), task.type) )
-            count += resolved.size()
-            for( FileHolder item : resolved ) {
-                Integer num = allNames.getOrCreate(item.stageName, 0) +1
-                allNames.put(item.stageName,num)
-            }
-
             // add the value to the task instance context
             task.setInput(param, resolved)
+            task.inputFiles.addAll(resolved)
         }
 
         // -- set the delegate map as context in the task config
         //    so that lazy directives will be resolved against it
-        task.config.context = ctx
+        task.config.context = task.context
 
         // check conflicting file names
-        def conflicts = allNames.findAll { name, num -> num>1 }
+        checkConflicts(task.inputFiles)
+    }
+
+    private void checkConflicts(List allFiles) {
+        final allNames = new HashMap()
+        for( final holder : allFiles ) {
+            final num = allNames.getOrCreate(holder.stageName, 0) + 1
+            allNames.put(holder.stageName, num)
+        }
+
+        final conflicts = allNames.findAll { name, num -> num > 1 }
         if( conflicts ) {
             log.debug("Process $name > collision check staging file names: $allNames")
             def message = "Process `$name` input file name collision -- There are multiple input files for each of the following file names: ${conflicts.keySet().join(', ')}"
             throw new ProcessUnrecoverableException(message)
         }
-        return foreignFiles
     }
 
-    protected void makeTaskContextStage3( TaskRun task, HashCode hash, Path folder ) {
-        // set hash-code & working directory
-        task.hash = hash
-        task.workDir = folder
-        task.config.workDir = folder
-        task.config.hash = hash.toString()
-        task.config.name = task.getName()
+    @CompileStatic
+    private void resolveTaskInputsV2(TaskRun task, List values, FilePorter.Batch foreignFiles) {
+        final declaredInputs = configV2().getInputs()
+        final ctx = task.context
+
+        // -- remove control input
+        values = values.init()
+
+        // -- validate input lengths
+        if( declaredInputs.size() != values.size() )
+            throw new ProcessUnrecoverableException("Process `$name` declares ${declaredInputs.size()} inputs but received ${values.size()} -- offending value: $values")
+
+        // -- add input params to task context
+        for( int i = 0; i < declaredInputs.getParams().size(); i++ ) {
+            final param = declaredInputs.getParams()[i]
+            final value = values[i]
+            if( value == null && !param.optional )
+                throw new ProcessUnrecoverableException("[${safeTaskName(task)}] input at index ${i} cannot be null -- append `?` to the type annotation to mark it as nullable")
+            if( param instanceof ProcessTupleInput )
+                assignTaskTupleInput(task, param, value, i)
+            else
+                assignTaskInput(task, param, value, i)
+        }
+
+        // -- resolve environment vars
+        for( final entry : declaredInputs.getEnv() ) {
+            final value = ctx.resolveLazy(entry.value)
+            task.inputEnv.put(entry.key, value?.toString())
+        }
+
+        // -- resolve stdin
+        task.stdin = ctx.resolveLazy(declaredInputs.stdin)
+
+        // -- resolve input files
+        final resolver = new TaskInputResolver(task, foreignFiles, executor)
+        final resolvedValues = new HashSet<>()
+
+        for( final fileInput : declaredInputs.getFiles() ) {
+            final value = fileInput.resolve(ctx)
+            // allow nullable file inputs
+            if( value == null )
+                continue
+            // user-defined file stagers take precedence over default file stagers
+            if( resolvedValues.contains(value) )
+                continue
+            final resolved = resolver.resolve(fileInput, value)
+            task.inputFiles.addAll(resolved)
+            resolvedValues.add(value)
+        }
+
+        // -- set the delegate map as context in the task config
+        //    so that lazy directives will be resolved against it
+        task.config.context = ctx
+    }
+
+    @CompileStatic
+    private void assignTaskTupleInput(TaskRun task, ProcessTupleInput param, Object value, int index) {
+        if( value !instanceof List ) {
+            throw new ProcessUnrecoverableException("[${safeTaskName(task)}] input at index ${index} expected a tuple but received: ${value} [${value.class.simpleName}]")
+        }
+        final tupleParams = param.getComponents()
+        final tupleValues = value as List
+        if( tupleParams.size() != tupleValues.size() ) {
+            throw new ProcessUnrecoverableException("[${safeTaskName(task)}] input at index ${index} expected a tuple with ${tupleParams.size()} elements but received ${tupleValues.size()} -- offending value: $tupleValues")
+        }
+        for( int i = 0; i < tupleParams.size(); i++ ) {
+            assignTaskInput(task, tupleParams[i], tupleValues[i], index)
+        }
+    }
+
+    @CompileStatic
+    private void assignTaskInput(TaskRun task, ProcessInput param, Object value, int index) {
+        if( value != null ) {
+            final expectedType = param.type
+            final actualType = value.getClass()
+            if( expectedType != null && !expectedType.isAssignableFrom(actualType) )
+                log.warn "[${safeTaskName(task)}] invalid argument type at index ${index} -- expected a ${Types.getName(expectedType)} but got a ${Types.getName(actualType)}"
+        }
+        task.context.put(param.getName(), value)
+        task.setInput(param, value)
     }
 
     final protected HashCode createTaskHashKey(TaskRun task) {
@@ -2347,7 +2173,12 @@ class TaskProcessor {
     final protected void submitTask( TaskRun task, HashCode hash, Path folder ) {
         log.trace "[${safeTaskName(task)}] actual run folder: ${folder}"
 
-        makeTaskContextStage3(task, hash, folder)
+        // set name, hash, and working directory
+        task.hash = hash
+        task.workDir = folder
+        task.config.workDir = folder
+        task.config.hash = hash.toString()
+        task.config.name = task.getName()
 
         // when no collector is define OR it's a task retry, then submit directly for execution
         if( !arrayCollector || task.config.getAttempt() > 1 )
@@ -2475,10 +2306,18 @@ class TaskProcessor {
 
         def statusStr = !completed && !terminated ? 'status=ACTIVE' : ( completed && terminated ? 'status=TERMINATED' : "completed=$completed; terminated=$terminated" )
         result << "  $statusStr\n"
+
         // add extra info about port statuses
+        if( config instanceof ProcessConfigV1 )
+            dumpOpenPorts(result)
+
+        return result.toString()
+    }
+
+    void dumpOpenPorts(StringBuilder result) {
         for( int i=0; i channel, final int index, final Object message) {
             // apparently auto if-guard instrumented by @Slf4j is not honoured in inner classes - add it explicitly
-            if( log.isTraceEnabled() ) {
-                def channelName = config.getInputs()?.names?.get(index)
+            if( log.isTraceEnabled() && config instanceof ProcessConfigV1 ) {
+                def channelName = configV1().getInputs()?.names?.get(index)
                 def taskName = currentTask.get()?.name ?: name
                 log.trace "<${taskName}> Message arrived -- ${channelName} => ${message}"
             }
@@ -2580,8 +2417,8 @@ class TaskProcessor {
         @Override
         Object controlMessageArrived(final DataflowProcessor processor, final DataflowReadChannel channel, final int index, final Object message) {
             // apparently auto if-guard instrumented by @Slf4j is not honoured in inner classes - add it explicitly
-            if( log.isTraceEnabled() ) {
-                def channelName = config.getInputs()?.names?.get(index)
+            if( log.isTraceEnabled() && config instanceof ProcessConfigV1 ) {
+                def channelName = configV1().getInputs()?.names?.get(index)
                 def taskName = currentTask.get()?.name ?: name
                 log.trace "<${taskName}> Control message arrived ${channelName} => ${message}"
             }
diff --git a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy
index 543e06b80d..63469740c8 100644
--- a/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/processor/TaskRun.groovy
@@ -24,6 +24,7 @@ import java.util.function.Function
 
 import com.google.common.hash.HashCode
 import groovy.transform.Memoized
+import groovy.transform.Memoized
 import groovy.util.logging.Slf4j
 import nextflow.Session
 import nextflow.conda.CondaCache
@@ -40,19 +41,19 @@ import nextflow.exception.ProcessUnrecoverableException
 import nextflow.file.FileHelper
 import nextflow.file.FileHolder
 import nextflow.script.BodyDef
+import nextflow.script.ProcessConfigV1
+import nextflow.script.ProcessConfigV2
 import nextflow.script.ScriptType
 import nextflow.script.TaskClosure
 import nextflow.script.bundle.ResourcesBundle
 import nextflow.script.params.CmdEvalParam
-import nextflow.script.params.EnvInParam
 import nextflow.script.params.EnvOutParam
-import nextflow.script.params.FileInParam
 import nextflow.script.params.FileOutParam
 import nextflow.script.params.InParam
 import nextflow.script.params.OutParam
-import nextflow.script.params.StdInParam
 import nextflow.script.params.ValueOutParam
 import nextflow.spack.SpackCache
+import nextflow.util.ArrayBag
 /**
  * Models a task instance
  *
@@ -102,14 +103,7 @@ class TaskRun implements Cloneable {
 
     void setInput( InParam param, Object value = null ) {
         assert param
-
         inputs[param] = value
-
-        // copy the value to the task 'input' attribute
-        // it will be used to pipe it to the process stdin
-        if( param instanceof StdInParam) {
-            stdin = value
-        }
     }
 
     void setOutput( OutParam param, Object value = null ) {
@@ -117,9 +111,24 @@ class TaskRun implements Cloneable {
         outputs[param] = value
     }
 
+    /**
+     * The map of input environment vars
+     *
+     * @see TaskProcessor#resolveTaskInputs()
+     */
+    Map inputEnv = [:]
+
+    /**
+     * The list of input files
+     *
+     * @see TaskProcessor#resolveTaskInputs()
+     */
+    List inputFiles = new ArrayBag()
 
     /**
      * The value to be piped to the process stdin
+     *
+     * @see TaskProcessor#resolveTaskInputs()
      */
     def stdin
 
@@ -138,6 +147,14 @@ class TaskRun implements Cloneable {
      */
     boolean cached
 
+    /**
+     * The list of resolved output files
+     *
+     * @see TaskOutputResolver#_file()
+     * @see TaskOutputResolver#_files()
+     */
+    Set outputFiles = []
+
     /**
      * Task produced standard output
      */
@@ -408,6 +425,9 @@ class TaskRun implements Cloneable {
             : getScript()
     }
 
+    boolean hasTypedInputsOutputs() {
+        return processor.config instanceof ProcessConfigV2
+    }
 
     /**
      * Check whenever there are values to be cached
@@ -425,18 +445,11 @@ class TaskRun implements Cloneable {
         return false
     }
 
-    Map> getInputFiles() {
-        (Map>) getInputsByType( FileInParam )
-    }
-
     /**
      * Return the list of all input files staged as inputs by this task execution
      */
     List getStagedInputs()  {
-        getInputFiles()
-                .values()
-                .flatten()
-                .collect { it.stageName }
+        return inputFiles.collect { it.stageName }
     }
 
     /**
@@ -444,12 +457,9 @@ class TaskRun implements Cloneable {
      */
     Map getInputFilesMap() {
 
-        final allFiles = getInputFiles().values()
-        final result = new HashMap(allFiles.size())
-        for( List entry : allFiles ) {
-            if( entry ) for( FileHolder it : entry ) {
-                result[ it.stageName ] = it.storePath
-            }
+        final result = new HashMap(inputFiles.size())
+        for( final holder : inputFiles ) {
+            result[ holder.stageName ] = holder.storePath
         }
 
         return result
@@ -459,18 +469,24 @@ class TaskRun implements Cloneable {
      * Look at the {@code nextflow.script.FileOutParam} which name is the expected
      *  output name
      */
+    @Memoized
     List getOutputFilesNames() {
-        // note: use an explicit function instead of a closure or lambda syntax, otherwise
-        // when calling this method from a subclass it will result into a MissingMethodExeception
-        // see  https://issues.apache.org/jira/browse/GROOVY-2433
-        cache0.computeIfAbsent('outputFileNames', new Function>() {
-            @Override
-            List apply(String s) {
-                return getOutputFilesNames0()
-            }})
+        if( hasTypedInputsOutputs() )
+            return getOutputFilesNamesV2()
+        else
+            return getOutputFilesNamesV1()
+    }
+
+    private List getOutputFilesNamesV2() {
+        final config = processor.config as ProcessConfigV2
+        final declaredOutputs = config.getOutputs()
+        final result = []
+        for( final param : declaredOutputs.files.values() )
+            result.add( param.getFilePattern(context) )
+        return result.unique()
     }
 
-    private List getOutputFilesNames0() {
+    private List getOutputFilesNamesV1() {
         def result = []
 
         for( FileOutParam param : getOutputsByType(FileOutParam).keySet() ) {
@@ -480,22 +496,6 @@ class TaskRun implements Cloneable {
         return result.unique()
     }
 
-    /**
-     * Get the map of *input* objects by the given {@code InParam} type
-     *
-     * @param types One or more subclass of {@code InParam}
-     * @return An associative array containing all the objects for the specified type
-     */
-    def  Map getInputsByType( Class... types ) {
-
-        def result = [:]
-        for( def it : inputs ) {
-            if( types.contains(it.key.class) )
-                result << it
-        }
-        return result
-    }
-
     /**
      * Get the map of *output* objects by the given {@code InParam} type
      *
@@ -511,17 +511,6 @@ class TaskRun implements Cloneable {
         return result
     }
 
-    /**
-     * @return A map containing the task environment defined as input declaration by this task
-     */
-    protected Map getInputEnvironment() {
-        final Map environment = [:]
-        getInputsByType( EnvInParam ).each { param, value ->
-            environment.put( param.name, value?.toString() )
-        }
-        return environment
-    }
-
     /**
      * @return A map representing the task execution environment
      */
@@ -531,7 +520,7 @@ class TaskRun implements Cloneable {
         // IMPORTANT: when copying the environment map a LinkedHashMap must be used to preserve
         // the insertion order of the env entries (ie. export FOO=1; export BAR=$FOO)
         final result = new LinkedHashMap( getProcessor().getProcessEnvironment() )
-        result.putAll( getInputEnvironment() )
+        result.putAll( inputEnv )
         return result
     }
 
@@ -603,6 +592,19 @@ class TaskRun implements Cloneable {
     }
 
     List getOutputEnvNames() {
+        if( hasTypedInputsOutputs() )
+            return getOutputEnvNamesV2()
+        else
+            return getOutputEnvNamesV1()
+    }
+
+    private List getOutputEnvNamesV2() {
+        final config = processor.config as ProcessConfigV2
+        final declaredOutputs = config.getOutputs()
+        return new ArrayList(declaredOutputs.getEnv())
+    }
+
+    private List getOutputEnvNamesV1() {
         final items = getOutputsByType(EnvOutParam)
         if( !items )
             return List.of()
@@ -620,6 +622,28 @@ class TaskRun implements Cloneable {
      * output and the value the command the executed.
      */
     Map getOutputEvals() {
+        if( hasTypedInputsOutputs() )
+            return getOutputEvalsV2()
+        else
+            return getOutputEvalsV1()
+    }
+
+    private Map getOutputEvalsV2() {
+        final config = processor.config as ProcessConfigV2
+        final declaredOutputs = config.getOutputs()
+        final evalCmds = declaredOutputs.getEval()
+        final result = new LinkedHashMap(evalCmds.size())
+        for( String name : evalCmds.keySet() ) {
+            final target = evalCmds[name]
+            final evalCmd = target instanceof Closure
+                ? target.cloneWith(context).call()
+                : target.toString()
+            result.put(name, evalCmd)
+        }
+        return result
+    }
+
+    private Map getOutputEvalsV1() {
         final items = getOutputsByType(CmdEvalParam)
         final result = new LinkedHashMap(items.size())
         for( CmdEvalParam it : items.keySet() ) {
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy b/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy
index 1269bbad30..666f6a17a4 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/BaseScript.groovy
@@ -19,11 +19,14 @@ package nextflow.script
 import java.lang.reflect.InvocationTargetException
 import java.nio.file.Paths
 
+import groovy.transform.CompileStatic
 import groovy.util.logging.Slf4j
 import nextflow.NF
 import nextflow.NextflowMeta
 import nextflow.Session
 import nextflow.exception.AbortOperationException
+import nextflow.script.dsl.ProcessDslV1
+import nextflow.script.dsl.ProcessDslV2
 import nextflow.secret.SecretsLoader
 
 /**
@@ -32,6 +35,7 @@ import nextflow.secret.SecretsLoader
  * @author Paolo Di Tommaso 
  */
 @Slf4j
+@CompileStatic
 abstract class BaseScript extends Script implements ExecutionContext {
 
     private Session session
@@ -98,6 +102,11 @@ abstract class BaseScript extends Script implements ExecutionContext {
         binding.setVariable( 'secrets', SecretsLoader.secretContext() )
     }
 
+    /**
+     * Define a params block.
+     *
+     * @param body
+     */
     protected void params(Closure body) {
         if( entryFlow )
             throw new IllegalStateException("Workflow params definition must be defined before the entry workflow")
@@ -113,18 +122,44 @@ abstract class BaseScript extends Script implements ExecutionContext {
         dsl.apply(session)
     }
 
-    protected process( String name, Closure body ) {
-        final process = new ProcessDef(this,body,name)
+    /**
+     * Define a legacy process.
+     *
+     * @param name
+     * @param body
+     */
+    protected void process(String name, Closure body) {
+        final dsl = new ProcessDslV1(this, name)
+        final cl = (Closure)body.clone()
+        cl.setDelegate(dsl)
+        cl.setResolveStrategy(Closure.DELEGATE_FIRST)
+        final taskBody = cl.call()
+        final process = dsl.withBody(taskBody).build()
+        meta.addDefinition(process)
+    }
+
+    /**
+     * Define a typed process.
+     *
+     * @param name
+     * @param body
+     */
+    protected void processV2(String name, Closure body) {
+        final dsl = new ProcessDslV2(this, name)
+        final cl = (Closure)body.clone()
+        cl.setDelegate(dsl)
+        cl.setResolveStrategy(Closure.DELEGATE_FIRST)
+        final taskBody = cl.call()
+        final process = dsl.withBody(taskBody).build()
         meta.addDefinition(process)
     }
 
     /**
-     * Workflow main entry point
+     * Define an entry workflow.
      *
-     * @param body The implementation body of the workflow
-     * @return The result of workflow execution
+     * @param workflowBody
      */
-    protected workflow(Closure workflowBody) {
+    protected void workflow(Closure workflowBody) {
         // launch the execution
         final workflow = new WorkflowDef(this, workflowBody)
         // capture the main (unnamed) workflow definition
@@ -133,12 +168,23 @@ abstract class BaseScript extends Script implements ExecutionContext {
         meta.addDefinition(workflow)
     }
 
-    protected workflow(String name, Closure workflowDef) {
-        final workflow = new WorkflowDef(this,workflowDef,name)
+    /**
+     * Define a named workflow.
+     *
+     * @param name
+     * @param workflowBody
+     */
+    protected void workflow(String name, Closure workflowBody) {
+        final workflow = new WorkflowDef(this,workflowBody,name)
         meta.addDefinition(workflow)
     }
 
-    protected output(Closure closure) {
+    /**
+     * Define an output block.
+     *
+     * @param closure
+     */
+    protected void output(Closure closure) {
         if( !NF.outputDefinitionEnabled )
             throw new IllegalStateException("Workflow output definition requires the `nextflow.preview.output` feature flag")
         if( !entryFlow )
@@ -170,7 +216,7 @@ abstract class BaseScript extends Script implements ExecutionContext {
         binding.invokeMethod(name, args)
     }
 
-    private run0() {
+    private Object run0() {
         final result = runScript()
         if( meta.isModule() ) {
             return result
@@ -193,7 +239,7 @@ abstract class BaseScript extends Script implements ExecutionContext {
             if( meta.hasExecutableProcesses() ) {
                 // Create a workflow to execute the process (single process or first of multiple)
                 final handler = new ProcessEntryHandler(this, session, meta)
-                entryFlow = handler.createAutoProcessWorkflow()
+                entryFlow = handler.createAutoProcessEntry()
             }
             else {
                 return result
@@ -268,7 +314,7 @@ abstract class BaseScript extends Script implements ExecutionContext {
             return
 
         if( session?.ansiLog )
-            log.info(String.printf(msg, arg))
+            log.info(String.format(msg, arg))
         else
             super.printf(msg, arg)
     }
@@ -279,7 +325,7 @@ abstract class BaseScript extends Script implements ExecutionContext {
             return
 
         if( session?.ansiLog )
-            log.info(String.printf(msg, args))
+            log.info(String.format(msg, args))
         else
             super.printf(msg, args)
     }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy
index 84c52c3a91..b3e00601c6 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfig.groovy
@@ -16,41 +16,16 @@
 
 package nextflow.script
 
-import static nextflow.util.CacheHelper.*
-
-import java.util.regex.Pattern
-
 import groovy.transform.PackageScope
 import groovy.util.logging.Slf4j
 import nextflow.Const
-import nextflow.ast.NextflowDSLImpl
-import nextflow.exception.ConfigParseException
-import nextflow.exception.IllegalConfigException
-import nextflow.exception.IllegalDirectiveException
 import nextflow.executor.BashWrapperBuilder
-import nextflow.processor.ConfigList
 import nextflow.processor.ErrorStrategy
 import nextflow.processor.TaskConfig
-import nextflow.script.params.CmdEvalParam
-import nextflow.script.params.DefaultInParam
-import nextflow.script.params.DefaultOutParam
-import nextflow.script.params.EachInParam
-import nextflow.script.params.EnvInParam
-import nextflow.script.params.EnvOutParam
-import nextflow.script.params.FileInParam
-import nextflow.script.params.FileOutParam
-import nextflow.script.params.InParam
-import nextflow.script.params.InputsList
-import nextflow.script.params.OutParam
-import nextflow.script.params.OutputsList
-import nextflow.script.params.StdInParam
-import nextflow.script.params.StdOutParam
-import nextflow.script.params.TupleInParam
-import nextflow.script.params.TupleOutParam
-import nextflow.script.params.ValueInParam
-import nextflow.script.params.ValueOutParam
 import nextflow.util.TestOnly
 
+import static nextflow.util.CacheHelper.HashMode
+
 /**
  * Holds the process configuration properties
  *
@@ -59,66 +34,6 @@ import nextflow.util.TestOnly
 @Slf4j
 class ProcessConfig implements Map, Cloneable {
 
-    static final public transient LABEL_REGEXP = ~/[a-zA-Z]([a-zA-Z0-9_]*[a-zA-Z0-9]+)?/
-
-    static final public List DIRECTIVES = [
-            'accelerator',
-            'afterScript',
-            'arch',
-            'array',
-            'beforeScript',
-            'cache',
-            'cleanup',
-            'clusterOptions',
-            'conda',
-            'container',
-            'containerOptions',
-            'cpus',
-            'debug',
-            'disk',
-            'echo', // deprecated
-            'errorStrategy',
-            'executor',
-            'ext',
-            'fair',
-            'label',
-            'machineType',
-            'maxSubmitAwait',
-            'maxErrors',
-            'maxForks',
-            'maxRetries',
-            'memory',
-            'module',
-            'penv',
-            'pod',
-            'publishDir',
-            'queue',
-            'resourceLabels',
-            'resourceLimits',
-            'scratch',
-            'secret',
-            'shell',
-            'spack',
-            'stageInMode',
-            'stageOutMode',
-            'storeDir',
-            'tag',
-            'time',
-            // input-output qualifiers
-            'file',
-            'val',
-            'each',
-            'env',
-            'stdin',
-            'stdout',
-    ]
-
-    /**
-     * Names of directives that can be used more than once in the process definition
-     */
-    @PackageScope
-    static final List repeatableDirectives = ['label','module','pod','publishDir']
-
     /**
      * Default directives values
      */
@@ -155,16 +70,6 @@ class ProcessConfig implements Map, Cloneable {
      */
     private boolean throwExceptionOnMissingProperty
 
-    /**
-     * List of process input definitions
-     */
-    private inputs = new InputsList()
-
-    /**
-     * List of process output definitions
-     */
-    private outputs = new OutputsList()
-
     /**
      * Initialize the taskConfig object with the defaults values
      *
@@ -190,8 +95,6 @@ class ProcessConfig implements Map, Cloneable {
     ProcessConfig clone() {
         def copy = (ProcessConfig)super.clone()
         copy.@configProperties = new LinkedHashMap<>(configProperties)
-        copy.@inputs = inputs.clone()
-        copy.@outputs = outputs.clone()
         return copy
     }
 
@@ -220,68 +123,10 @@ class ProcessConfig implements Map, Cloneable {
         return this
     }
 
-    private void checkName(String name) {
-        if( DIRECTIVES.contains(name) )
-            return
-        if( name == NextflowDSLImpl.PROCESS_WHEN )
-            return
-        if( name == NextflowDSLImpl.PROCESS_STUB )
-            return
-
-        String message = "Unknown process directive: `$name`"
-        def alternatives = DIRECTIVES.closest(name)
-        if( alternatives.size()==1 ) {
-            message += '\n\nDid you mean of these?'
-            alternatives.each {
-                message += "\n        $it"
-            }
-        }
-        throw new IllegalDirectiveException(message)
-    }
-
-    Object invokeMethod(String name, Object args) {
-        /*
-         * This is need to patch #497 -- what is happening is that when in the config file
-         * is defined a directive like `memory`, `cpus`, etc in by using a closure,
-         * this closure is interpreted as method definition and it get invoked if a
-         * directive with the same name is defined in the process definition.
-         * To avoid that the offending property is removed from the map before the method
-         * is evaluated.
-         */
-        if( configProperties.get(name) instanceof Closure )
-            configProperties.remove(name)
-
-        this.metaClass.invokeMethod(this,name,args)
-    }
-
-    def methodMissing( String name, def args ) {
-        checkName(name)
-
-        if( args instanceof Object[] ) {
-            if( args.size()==1 ) {
-                configProperties[ name ] = args[0]
-            }
-            else {
-                configProperties[ name ] = args.toList()
-            }
-        }
-        else {
-            configProperties[ name ] = args
-        }
-
-        return this
-    }
-
     @Override
     Object getProperty( String name ) {
 
         switch( name ) {
-            case 'inputs':
-                return getInputs()
-
-            case 'outputs':
-                return getOutputs()
-
             case 'cacheable':
                 return isCacheable()
 
@@ -302,367 +147,14 @@ class ProcessConfig implements Map, Cloneable {
 
     }
 
-    Object put( String name, Object value ) {
-
-        if( name in repeatableDirectives  ) {
-            final result = configProperties.get(name)
-            configProperties.remove(name)
-            this.metaClass.invokeMethod(this, name, value)
-            return result
-        }
-        else {
-            return configProperties.put(name,value)
-        }
-    }
-
-    @PackageScope
     BaseScript getOwnerScript() { ownerScript }
 
+    String getProcessName() { processName }
+
     TaskConfig createTaskConfig() {
         return new TaskConfig(configProperties)
     }
 
-    /**
-     * Apply the settings defined in the configuration file for the given annotation label, for example:
-     *
-     * ```
-     * process {
-     *     withLabel: foo {
-     *         cpus = 1
-     *         memory = 2.gb
-     *     }
-     * }
-     * ```
-     *
-     * @param configDirectives
-     *      A map object modelling the setting defined defined by the user in the nextflow configuration file
-     * @param labels
-     *      All the labels representing the object holding the configuration setting to apply
-     */
-    protected void applyConfigSelectorWithLabels(Map configDirectives, List labels ) {
-        final prefix = 'withLabel:'
-        for( String rule : configDirectives.keySet() ) {
-            if( !rule.startsWith(prefix) )
-                continue
-            final pattern = rule.substring(prefix.size()).trim()
-            if( !matchesLabels(labels, pattern) )
-                continue
-
-            log.debug "Config settings `$rule` matches labels `${labels.join(',')}` for process with name $processName"
-            def settings = configDirectives.get(rule)
-            if( settings instanceof Map ) {
-                applyConfigSettings(settings)
-            }
-            else if( settings != null ) {
-                throw new ConfigParseException("Unknown config settings for process labeled ${labels.join(',')} -- settings=$settings ")
-            }
-        }
-    }
-
-    static boolean matchesLabels( List labels, String pattern ) {
-        final isNegated = pattern.startsWith('!')
-        if( isNegated )
-            pattern = pattern.substring(1).trim()
-
-        final regex = Pattern.compile(pattern)
-        for (label in labels) {
-            if (regex.matcher(label).matches()) {
-                return !isNegated
-            }
-        }
-
-        return isNegated
-    }
-
-    protected void applyConfigSelectorWithName(Map configDirectives, String target ) {
-        final prefix = 'withName:'
-        for( String rule : configDirectives.keySet() ) {
-            if( !rule.startsWith(prefix) )
-                continue
-            final pattern = rule.substring(prefix.size()).trim()
-            if( !matchesSelector(target, pattern) )
-                continue
-
-            log.debug "Config settings `$rule` matches process $processName"
-            def settings = configDirectives.get(rule)
-            if( settings instanceof Map ) {
-                applyConfigSettings(settings)
-            }
-            else if( settings != null ) {
-                throw new ConfigParseException("Unknown config settings for process with name: $target  -- settings=$settings ")
-            }
-        }
-    }
-
-    static boolean matchesSelector( String name, String pattern ) {
-        final isNegated = pattern.startsWith('!')
-        if( isNegated )
-            pattern = pattern.substring(1).trim()
-        return Pattern.compile(pattern).matcher(name).matches() ^ isNegated
-    }
-
-    /**
-     * Apply the process configuration provided in the nextflow configuration file
-     * to the process instance
-     *
-     * @param configProcessScope The process configuration settings specified
-     *      in the configuration file as {@link Map} object
-     * @param simpleName The process name
-     */
-    void applyConfig(Map configProcessScope, String baseName, String simpleName, String fullyQualifiedName) {
-        // -- Apply the directives defined in the config object using the`withLabel:` syntax
-        final processLabels = this.getLabels() ?: ['']
-        this.applyConfigSelectorWithLabels(configProcessScope, processLabels)
-
-        // -- apply setting defined in the config file using the process base name
-        this.applyConfigSelectorWithName(configProcessScope, baseName)
-
-        // -- apply setting defined in the config file using the process simple name
-        if( simpleName && simpleName!=baseName )
-            this.applyConfigSelectorWithName(configProcessScope, simpleName)
-
-        // -- apply setting defined in the config file using the process qualified name (ie. with the execution scope)
-        if( fullyQualifiedName && (fullyQualifiedName!=simpleName || fullyQualifiedName!=baseName) )
-            this.applyConfigSelectorWithName(configProcessScope, fullyQualifiedName)
-
-        // -- Apply defaults
-        this.applyConfigDefaults(configProcessScope)
-
-        // -- check for conflicting settings
-        if( this.scratch && this.stageInMode == 'rellink' ) {
-            log.warn("Directives `scratch` and `stageInMode=rellink` conflict with each other -- Enforcing default stageInMode for process `$simpleName`")
-            this.remove('stageInMode')
-        }
-    }
-
-    void applyConfigLegacy(Map configProcessScope, String processName) {
-        applyConfig(configProcessScope, processName, null, null)
-    }
-
-
-    /**
-     * Apply the settings defined in the configuration file to the actual process configuration object
-     *
-     * @param settings
-     *      A map object modelling the setting defined defined by the user in the nextflow configuration file
-     */
-    protected void applyConfigSettings(Map settings) {
-        if( !settings )
-            return
-
-        for( Entry entry: settings ) {
-            if( entry.key.startsWith("withLabel:") || entry.key.startsWith("withName:"))
-                continue
-
-            if( !DIRECTIVES.contains(entry.key) )
-                log.warn "Unknown directive `$entry.key` for process `$processName`"
-
-            if( entry.key == 'params' ) // <-- patch issue #242
-                continue
-
-            if( entry.key == 'ext' ) {
-                if( this.getProperty('ext') instanceof Map ) {
-                    // update missing 'ext' properties found in 'process' scope
-                    def ext = this.getProperty('ext') as Map
-                    entry.value.each { String k, v -> ext[k] = v }
-                }
-                continue
-            }
-
-            this.put(entry.key,entry.value)
-        }
-    }
-
-    /**
-     * Apply the process settings defined globally in the process config scope
-     *
-     * @param processDefaults
-     *      A map object representing the setting to be applied to the process
-     *      (provided it does not already define a different value for
-     *      the same config setting).
-     *
-     */
-    protected void applyConfigDefaults( Map processDefaults ) {
-        for( String key : processDefaults.keySet() ) {
-            if( key == 'params' )
-                continue
-            final value = processDefaults.get(key)
-            final current = this.getProperty(key)
-            if( key == 'ext' ) {
-                if( value instanceof Map && current instanceof Map ) {
-                    final ext = current as Map
-                    value.each { k,v -> if(!ext.containsKey(k)) ext.put(k,v) }
-                }
-            }
-            else if( !this.containsKey(key) || (DEFAULT_CONFIG.containsKey(key) && current==DEFAULT_CONFIG.get(key)) ) {
-                this.put(key, value)
-            }
-        }
-    }
-
-    /**
-     * Type shortcut to {@code #configProperties.inputs}
-     */
-    InputsList getInputs() {
-        inputs
-    }
-
-    /**
-     * Type shortcut to {@code #configProperties.outputs}
-     */
-    OutputsList getOutputs() {
-        outputs
-    }
-
-    /**
-     * Implements the process {@code debug} directive.
-     */
-    ProcessConfig debug( value ) {
-        configProperties.debug = value
-        return this
-    }
-
-    /**
-     * Implements the process {@code echo} directive for backwards compatibility.
-     *
-     * note: without this method definition {@link BaseScript#echo} will be invoked
-     */
-    ProcessConfig echo( value ) {
-        log.warn1('The `echo` directive has been deprecated - use to `debug` instead')
-        configProperties.debug = value
-        return this
-    }
-
-    /// input parameters
-
-    InParam _in_val( obj ) {
-        new ValueInParam(this).bind(obj)
-    }
-
-    InParam _in_file( obj ) {
-        new FileInParam(this).bind(obj)
-    }
-
-    InParam _in_path( Map opts=null, obj ) {
-        new FileInParam(this)
-                .setPathQualifier(true)
-                .setOptions(opts)
-                .bind(obj)
-    }
-
-    InParam _in_each( obj ) {
-        new EachInParam(this).bind(obj)
-    }
-
-    InParam _in_tuple( Object... obj ) {
-        new TupleInParam(this).bind(obj)
-    }
-
-    InParam _in_stdin( obj = null ) {
-        def result = new StdInParam(this)
-        if( obj ) result.bind(obj)
-        result
-    }
-
-    InParam _in_env( obj ) {
-        new EnvInParam(this).bind(obj)
-    }
-
-
-    /// output parameters
-
-    OutParam _out_val( Object obj ) {
-        new ValueOutParam(this).bind(obj)
-    }
-
-    OutParam _out_val( Map opts, Object obj ) {
-        new ValueOutParam(this)
-                .setOptions(opts)
-                .bind(obj)
-    }
-
-    OutParam _out_env( Object obj ) {
-        new EnvOutParam(this).bind(obj)
-    }
-
-    OutParam _out_env( Map opts, Object obj ) {
-        new EnvOutParam(this)
-                .setOptions(opts)
-                .bind(obj)
-    }
-
-    OutParam _out_eval(Object obj ) {
-        new CmdEvalParam(this).bind(obj)
-    }
-
-    OutParam _out_eval(Map opts, Object obj ) {
-        new CmdEvalParam(this)
-            .setOptions(opts)
-            .bind(obj)
-    }
-
-    OutParam _out_file( Object obj ) {
-        // note: check that is a String type to avoid to force
-        // the evaluation of GString object to a string
-        if( obj instanceof String && obj == '-' )
-            new StdOutParam(this).bind(obj)
-
-        else
-            new FileOutParam(this).bind(obj)
-    }
-
-    OutParam _out_path( Map opts=null, Object obj ) {
-        // note: check that is a String type to avoid to force
-        // the evaluation of GString object to a string
-        if( obj instanceof String && obj == '-' ) {
-            new StdOutParam(this)
-                    .setOptions(opts)
-                    .bind(obj)
-        }
-        else {
-            new FileOutParam(this)
-                    .setPathQualifier(true)
-                    .setOptions(opts)
-                    .bind(obj)
-        }
-    }
-
-    OutParam _out_tuple( Object... obj ) {
-        new TupleOutParam(this) .bind(obj)
-    }
-
-    OutParam _out_tuple( Map opts, Object... obj ) {
-        new TupleOutParam(this)
-                .setOptions(opts)
-                .bind(obj)
-    }
-
-    OutParam _out_stdout( Map opts ) {
-        new StdOutParam(this)
-                .setOptions(opts)
-                .bind('-')
-    }
-
-    OutParam _out_stdout( obj = null ) {
-        def result = new StdOutParam(this).bind('-')
-        if( obj ) {
-            result.into(obj)
-        }
-        result
-    }
-
-    /**
-     * Defines a special *dummy* input parameter, when no inputs are
-     * provided by the user for the current task
-     */
-    void fakeInput() {
-        new DefaultInParam(this)
-    }
-
-    void fakeOutput() {
-        new DefaultOutParam(this)
-    }
-
     boolean isCacheable() {
         def value = configProperties.cache
         if( value == null )
@@ -681,73 +173,6 @@ class ProcessConfig implements Map, Cloneable {
         HashMode.of(configProperties.cache) ?: HashMode.DEFAULT()
     }
 
-    protected boolean isValidLabel(String lbl) {
-        def p = lbl.indexOf('=')
-        if( p==-1 )
-            return LABEL_REGEXP.matcher(lbl).matches()
-
-        def left = lbl.substring(0,p)
-        def right = lbl.substring(p+1)
-        return LABEL_REGEXP.matcher(left).matches() && LABEL_REGEXP.matcher(right).matches()
-    }
-
-    /**
-     * Implements the process {@code label} directive.
-     *
-     * Note this directive  can be specified (invoked) more than one time in
-     * the process context.
-     *
-     * @param lbl
-     *      The label to be attached to the process.
-     * @return
-     *      The {@link ProcessConfig} instance itself.
-     */
-    ProcessConfig label(String lbl) {
-        if( !lbl ) return this
-
-        // -- check that label has a valid syntax
-        if( !isValidLabel(lbl) )
-            throw new IllegalConfigException("Not a valid process label: $lbl -- Label must consist of alphanumeric characters or '_', must start with an alphabetic character and must end with an alphanumeric character")
-
-        // -- get the current label, it must be a list
-        def allLabels = (List)configProperties.get('label')
-        if( !allLabels ) {
-            allLabels = new ConfigList()
-            configProperties.put('label', allLabels)
-        }
-
-        // -- avoid duplicates
-        if( !allLabels.contains(lbl) )
-            allLabels.add(lbl)
-        return this
-    }
-
-    /**
-     * Implements the process {@code label} directive.
-     *
-     * Note this directive  can be specified (invoked) more than one time in
-     * the process context.
-     *
-     * @param map
-     *      The map to be attached to the process.
-     * @return
-     *      The {@link ProcessConfig} instance itself.
-     */
-    ProcessConfig resourceLabels(Map map) {
-        if( !map )
-            return this
-
-        // -- get the current sticker, it must be a Map
-        def allLabels = (Map)configProperties.get('resourceLabels')
-        if( !allLabels ) {
-            allLabels = [:]
-        }
-        // -- merge duplicates
-        allLabels += map
-        configProperties.put('resourceLabels', allLabels)
-        return this
-    }
-
     Map getResourceLabels() {
         (configProperties.get('resourceLabels') ?: Collections.emptyMap()) as Map
     }
@@ -769,242 +194,10 @@ class ProcessConfig implements Map, Cloneable {
             throw new IllegalArgumentException("Unexpected value for directive `fair` -- offending value: $value")
     }
 
-    ProcessConfig secret(String name) {
-        if( !name )
-            return this
-
-        // -- get the current label, it must be a list
-        def allSecrets = (List)configProperties.get('secret')
-        if( !allSecrets ) {
-            allSecrets = new ConfigList()
-            configProperties.put('secret', allSecrets)
-        }
-
-        // -- avoid duplicates
-        if( !allSecrets.contains(name) )
-            allSecrets.add(name)
-        return this
-    }
-
     List getSecret() {
         (List) configProperties.get('secret') ?: Collections.emptyList()
     }
 
-    /**
-     * Implements the process {@code module} directive.
-     *
-     * See also http://modules.sourceforge.net
-     *
-     * @param moduleName
-     *      The module name to be used to execute the process.
-     * @return
-     *      The {@link ProcessConfig} instance itself.
-     */
-    ProcessConfig module( moduleName ) {
-        // when no name is provided, just exit
-        if( !moduleName )
-            return this
-
-        def result = (List)configProperties.module
-        if( result == null ) {
-            result = new ConfigList()
-            configProperties.put('module', result)
-        }
-
-        if( moduleName instanceof List )
-            result.addAll(moduleName)
-        else
-            result.add(moduleName)
-        return this
-    }
-
-    /**
-     * Implements the {@code errorStrategy} directive
-     *
-     * @see ErrorStrategy
-     *
-     * @param strategy
-     *      A string representing the error strategy to be used.
-     * @return
-     *      The {@link ProcessConfig} instance itself.
-     */
-    ProcessConfig errorStrategy( strategy ) {
-        if( strategy instanceof CharSequence && !ErrorStrategy.isValid(strategy) ) {
-            throw new IllegalArgumentException("Unknown error strategy '${strategy}' ― Available strategies are: ${ErrorStrategy.values().join(',').toLowerCase()}")
-        }
-
-        configProperties.put('errorStrategy', strategy)
-        return this
-    }
-
-    /**
-     * Allow the user to specify publishDir directive as a map eg:
-     *
-     *     publishDir path:'/some/dir', mode: 'copy'
-     *
-     * @param params
-     *      A map representing the the publishDir setting
-     * @return
-     *      The {@link ProcessConfig} instance itself
-     */
-    ProcessConfig publishDir(Map params) {
-        if( !params )
-            return this
-
-        def dirs = (List)configProperties.get('publishDir')
-        if( !dirs ) {
-            dirs = new ConfigList()
-            configProperties.put('publishDir', dirs)
-        }
-
-        dirs.add(params)
-        return this
-    }
-
-    /**
-     * Allow the user to specify publishDir directive with a path and a list of named parameters, eg:
-     *
-     *     publishDir '/some/dir', mode: 'copy'
-     *
-     * @param params
-     *      A map representing the publishDir properties
-     * @param target
-     *      The target publishDir path
-     * @return
-     *      The {@link ProcessConfig} instance itself
-     */
-    ProcessConfig publishDir(Map params, target) {
-        params.put('path', target)
-        publishDir( params )
-    }
-
-    /**
-     * Allow the user to specify the publishDir as a string path, eg:
-     *
-     *      publishDir '/some/dir'
-     *
-     * @param target
-     *      The target publishDir path
-     * @return
-     *      The {@link ProcessConfig} instance itself
-     */
-    ProcessConfig publishDir( target ) {
-        if( target instanceof List ) {
-            for( Object item : target ) { publishDir(item) }
-        }
-        else if( target instanceof Map ) {
-            publishDir( target as Map )
-        }
-        else {
-            publishDir([path: target])
-        }
-        return this
-    }
-
-    /**
-     * Allow use to specify K8s `pod` options
-     *
-     * @param entry
-     *      A map object representing pod config options
-     * @return
-     *      The {@link ProcessConfig} instance itself
-     */
-    ProcessConfig pod( Map entry ) {
-
-        if( !entry )
-            return this
-
-        def allOptions = (List)configProperties.get('pod')
-        if( !allOptions ) {
-            allOptions = new ConfigList()
-            configProperties.put('pod', allOptions)
-        }
-
-        allOptions.add(entry)
-        return this
-
-    }
-
-    ProcessConfig accelerator( Map params, value )  {
-        if( value instanceof Number ) {
-            if( params.limit==null )
-                params.limit=value
-            else if( params.request==null )
-                params.request=value
-        }
-        else if( value != null )
-            throw new IllegalArgumentException("Not a valid `accelerator` directive value: $value [${value.getClass().getName()}]")
-        accelerator(params)
-        return this
-    }
-
-    ProcessConfig accelerator( value ) {
-        if( value instanceof Number )
-            configProperties.put('accelerator', [limit: value])
-        else if( value instanceof Map )
-            configProperties.put('accelerator', value)
-        else if( value != null )
-            throw new IllegalArgumentException("Not a valid `accelerator` directive value: $value [${value.getClass().getName()}]")
-        return this
-    }
-
-    /**
-     * Allow user to specify `disk` directive as a value with a list of options, eg:
-     *
-     *     disk 375.GB, type: 'local-ssd'
-     *
-     * @param opts
-     *      A map representing the disk options
-     * @param value
-     *      The default disk value
-     * @return
-     *      The {@link ProcessConfig} instance itself
-     */
-    ProcessConfig disk( Map opts, value )  {
-        opts.request = value
-        return disk(opts)
-    }
-
-    /**
-     * Allow user to specify `disk` directive as a value or a list of options, eg:
-     *
-     *     disk 100.GB
-     *     disk request: 375.GB, type: 'local-ssd'
-     *
-     * @param value
-     *      The default disk value or map of options
-     * @return
-     *      The {@link ProcessConfig} instance itself
-     */
-    ProcessConfig disk( value ) {
-        if( value instanceof Map || value instanceof Closure )
-            configProperties.put('disk', value)
-        else
-            configProperties.put('disk', [request: value])
-        return this
-    }
-
-    ProcessConfig arch( Map params, value )  {
-        if( value instanceof String ) {
-            if( params.name==null )
-                params.name=value
-        }
-        else if( value != null )
-            throw new IllegalArgumentException("Not a valid `arch` directive value: $value [${value.getClass().getName()}]")
-        arch(params)
-        return this
-    }
-
-    ProcessConfig arch( value ) {
-        if( value instanceof String )
-            configProperties.put('arch', [name: value])
-        else if( value instanceof Map )
-            configProperties.put('arch', value)
-        else if( value != null )
-            throw new IllegalArgumentException("Not a valid `arch` directive value: $value [${value.getClass().getName()}]")
-        return this
-    }
-
     int getArray() {
         final value = configProperties.get('array')
         if( value == null )
@@ -1024,15 +217,4 @@ class ProcessConfig implements Map, Cloneable {
         }
     }
 
-    private static final List VALID_RESOURCE_LIMITS = List.of('cpus', 'memory', 'disk', 'time')
-
-    ProcessConfig resourceLimits( Map entries ) {
-        for( entry in entries )
-            if( entry.key !in VALID_RESOURCE_LIMITS )
-                throw new IllegalArgumentException("Not a valid directive in `resourceLimits`: $entry.key")
-
-        configProperties.put('resourceLimits', entries)
-        return this
-    }
-
 }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfigV1.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfigV1.groovy
new file mode 100644
index 0000000000..1b9854554a
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfigV1.groovy
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script
+
+import nextflow.script.params.DefaultInParam
+import nextflow.script.params.DefaultOutParam
+import nextflow.script.params.InputsList
+import nextflow.script.params.OutputsList
+
+/**
+ * Specialization of process config for legacy processes.
+ *
+ * @author Paolo Di Tommaso 
+ */
+class ProcessConfigV1 extends ProcessConfig {
+
+    private InputsList inputs = new InputsList()
+
+    private OutputsList outputs = new OutputsList()
+
+    ProcessConfigV1(BaseScript script, String name) {
+        super(script, name)
+    }
+
+    @Override
+    ProcessConfigV1 clone() {
+        final copy = (ProcessConfigV1)super.clone()
+        copy.@inputs = inputs.clone()
+        copy.@outputs = outputs.clone()
+        return copy
+    }
+
+    InputsList getInputs() {
+        return inputs
+    }
+
+    OutputsList getOutputs() {
+        return outputs
+    }
+
+    /**
+     * Defines a special *dummy* input parameter, when no inputs are
+     * provided by the user for the current task
+     */
+    void fakeInput() {
+        new DefaultInParam(this)
+    }
+
+    void fakeOutput() {
+        new DefaultOutParam(this)
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfigV2.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfigV2.groovy
new file mode 100644
index 0000000000..16b6cd0953
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessConfigV2.groovy
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script
+
+import nextflow.script.params.v2.ProcessInputsDef
+import nextflow.script.params.v2.ProcessOutputsDef
+
+/**
+ * Specialization of process config for typed processes.
+ *
+ * @author Ben Sherman 
+ */
+class ProcessConfigV2 extends ProcessConfig {
+
+    private ProcessInputsDef inputs = new ProcessInputsDef()
+
+    private ProcessOutputsDef outputs = new ProcessOutputsDef()
+
+    ProcessConfigV2(BaseScript script, String name) {
+        super(script, name)
+    }
+
+    @Override
+    ProcessConfigV2 clone() {
+        final copy = (ProcessConfigV2)super.clone()
+        copy.@inputs = inputs.clone()
+        copy.@outputs = outputs.clone()
+        return copy
+    }
+
+    ProcessInputsDef getInputs() {
+        return inputs
+    }
+
+    ProcessOutputsDef getOutputs() {
+        return outputs
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy
index fc537c46e4..c35b2431a8 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessDef.groovy
@@ -18,17 +18,20 @@ package nextflow.script
 
 import groovy.transform.CompileStatic
 import groovy.util.logging.Slf4j
+import groovyx.gpars.dataflow.DataflowBroadcast
+import groovyx.gpars.dataflow.DataflowReadChannel
+import groovyx.gpars.dataflow.DataflowWriteChannel
 import nextflow.Const
 import nextflow.Global
 import nextflow.Session
 import nextflow.exception.ScriptRuntimeException
 import nextflow.extension.CH
+import nextflow.extension.CombineOp
 import nextflow.processor.TaskProcessor
+import nextflow.script.dsl.ProcessConfigBuilder
 import nextflow.script.params.BaseInParam
 import nextflow.script.params.BaseOutParam
 import nextflow.script.params.EachInParam
-import nextflow.script.params.InputsList
-import nextflow.script.params.OutputsList
 
 /**
  * Models a nextflow process definition
@@ -63,32 +66,28 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
      */
     private String baseName
 
-    /**
-     * The closure holding the process definition body
-     */
-    private Closure rawBody
-
     /**
      * The resolved process configuration
      */
-    private transient ProcessConfig processConfig
+    private ProcessConfig processConfig
 
     /**
      * The actual process implementation
      */
-    private transient BodyDef taskBody
+    private BodyDef taskBody
 
     /**
      * The result of the process execution
      */
     private transient ChannelOut output
 
-    ProcessDef(BaseScript owner, Closure body, String name ) {
+    ProcessDef(BaseScript owner, String name, ProcessConfig config, BodyDef taskBody) {
         this.owner = owner
-        this.rawBody = body
         this.simpleName = name
         this.processName = name
         this.baseName = name
+        this.processConfig = config
+        this.taskBody = taskBody
     }
 
     static String stripScope(String str) {
@@ -96,32 +95,16 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
     }
 
     protected void initialize() {
-        log.trace "Process config > $processName"
-        assert processConfig==null
-
-        // the config object
-        processConfig = new ProcessConfig(owner,processName)
-
-        // Invoke the code block which will return the script closure to the executed.
-        // As side effect will set all the property declarations in the 'taskConfig' object.
-        processConfig.throwExceptionOnMissingProperty(true)
-        final copy = (Closure)rawBody.clone()
-        copy.setResolveStrategy(Closure.DELEGATE_FIRST)
-        copy.setDelegate(processConfig)
-        taskBody = copy.call() as BodyDef
-        processConfig.throwExceptionOnMissingProperty(false)
-        if ( !taskBody )
-            throw new ScriptRuntimeException("Missing script in the specified process block -- make sure it terminates with the script string to be executed")
-
         // apply config settings to the process
-        processConfig.applyConfig((Map)session.config.process, baseName, simpleName, processName)
+        final configProcessScope = (Map)session.config.process
+        new ProcessConfigBuilder(processConfig).applyConfig(configProcessScope, baseName, simpleName, processName)
     }
 
     @Override
     ProcessDef clone() {
         def result = (ProcessDef)super.clone()
+        result.@processConfig = processConfig.clone()
         result.@taskBody = taskBody?.clone()
-        result.@rawBody = (Closure)rawBody?.clone()
         return result
     }
 
@@ -131,13 +114,10 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
         def result = clone()
         result.@processName = name
         result.@simpleName = stripScope(name)
+        result.@processConfig.processName = name
         return result
     }
 
-    private InputsList getDeclaredInputs() { processConfig.getInputs() }
-
-    private OutputsList getDeclaredOutputs() { processConfig.getOutputs() }
-
     BaseScript getOwner() { owner }
 
     String getName() { processName }
@@ -159,8 +139,7 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
     String getType() { 'process' }
 
     private String missMatchErrMessage(String name, int expected, int actual) {
-        final ch = expected > 1 ? "channels" : "channel"
-        return "Process `$name` declares ${expected} input ${ch} but ${actual} were specified"
+        return "Process `$name` declares ${expected} ${expected == 1 ? 'input' : 'inputs'} but was called with ${actual} ${actual == 1 ? 'argument' : 'arguments'}"
     }
 
     @Override
@@ -168,8 +147,24 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
         // initialise process config
         initialize()
 
+        // invoke process with legacy inputs/outputs
+        if( processConfig instanceof ProcessConfigV1 )
+            output = runV1(args, processConfig)
+
+        // invoke process with typed inputs/outputs
+        else if( processConfig instanceof ProcessConfigV2 )
+            output = runV2(args, processConfig)
+
+        // return process output
+        return output
+    }
+
+    private ChannelOut runV1(Object[] args, ProcessConfigV1 config) {
         // get params 
         final params = ChannelOut.spread(args)
+        final declaredInputs = config.getInputs()
+        final declaredOutputs = config.getOutputs()
+
         // sanity check
         if( params.size() != declaredInputs.size() )
             throw new ScriptRuntimeException(missMatchErrMessage(processName, declaredInputs.size(), params.size()))
@@ -207,7 +202,7 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
         }
 
         // make a copy of the output list because execution can change it
-        output = new ChannelOut(declaredOutputs.clone())
+        final output = new ChannelOut(declaredOutputs.clone())
 
         // start processor
         createTaskProcessor().run()
@@ -217,6 +212,54 @@ class ProcessDef extends BindableDef implements IterableDef, ChainableDef {
         return output
     }
 
+    private ChannelOut runV2(Object[] args0, ProcessConfigV2 config) {
+        final args = ChannelOut.spread(args0)
+        final declaredInputs = config.getInputs()
+        final declaredOutputs = config.getOutputs()
+
+        // validate arguments
+        if( args.size() != declaredInputs.size() )
+            throw new ScriptRuntimeException(missMatchErrMessage(processName, declaredInputs.size(), args.size()))
+
+        // set input channels
+        for( int i = 0; i < declaredInputs.size(); i++ )
+            declaredInputs[i].setChannel(createSourceChannel(args[i]))
+
+        // set output channels
+        final singleton = declaredInputs.isSingleton()
+
+        final feedbackChannels = getFeedbackChannels()
+        if( feedbackChannels && feedbackChannels.size() != declaredOutputs.size() )
+            throw new ScriptRuntimeException("Process `$processName` inputs and outputs do not have the same cardinality - Feedback loop is not supported"  )
+
+        final channels = new LinkedHashMap()
+        for( int i = 0; i < declaredOutputs.size(); i++ ) {
+            final param = declaredOutputs[i]
+            final ch = feedbackChannels ? feedbackChannels[i] : CH.create(singleton)
+            param.setChannel(ch)
+            channels.put(param.getName(), ch)
+        }
+
+        for( final topic : declaredOutputs.getTopics() ) {
+            final ch = CH.createTopicSource(topic.getTarget())
+            topic.setChannel(ch)
+        }
+
+        // start processor
+        createTaskProcessor().run()
+
+        return new ChannelOut(channels)
+    }
+
+    private DataflowReadChannel createSourceChannel(Object value) {
+        if( value instanceof DataflowReadChannel || value instanceof DataflowBroadcast )
+            return CH.getReadChannel(value)
+
+        final result = CH.value()
+        result.bind(value)
+        return result
+    }
+
     TaskProcessor createTaskProcessor() {
         if( !processConfig )
             initialize()
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy
index be5049cd16..0d80e0333c 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessEntryHandler.groovy
@@ -16,22 +16,32 @@
 
 package nextflow.script
 
+import java.nio.file.Path
+import groovy.transform.CompileStatic
 import groovy.util.logging.Slf4j
 import nextflow.Session
 import nextflow.Nextflow
+import nextflow.script.params.EnvInParam
+import nextflow.script.params.FileInParam
+import nextflow.script.params.InParam
+import nextflow.script.params.StdInParam
+import nextflow.script.params.TupleInParam
+import nextflow.script.params.v2.ProcessInput
+import nextflow.script.params.v2.ProcessTupleInput
 
 /**
  * Helper class for process entry execution feature.
- * 
+ *
  * This feature enables direct execution of Nextflow processes without explicit workflows:
  * - Single process scripts run automatically: `nextflow run script.nf --param value`
  * - Multi-process scripts run the first process automatically: `nextflow run script.nf --param value`
  * - Command-line parameters are mapped directly to process inputs
- * - Supports all standard Nextflow input types: val, path, env, tuple, each
+ * - Supports the following process input qualifiers: val, path, tuple, each
  *
  * @author Paolo Di Tommaso 
  */
 @Slf4j
+@CompileStatic
 class ProcessEntryHandler {
 
     private final BaseScript script
@@ -48,277 +58,82 @@ class ProcessEntryHandler {
      * Creates a workflow to execute a standalone process automatically.
      * For single process scripts, executes that process.
      * For multi-process scripts, executes the first process.
-     * 
+     *
      * @return WorkflowDef that executes the process with parameter mapping
      */
-    WorkflowDef createAutoProcessWorkflow() {
-        def processNames = meta.getLocalProcessNames()
+    WorkflowDef createAutoProcessEntry() {
+        final processNames = meta.getLocalProcessNames()
         if( processNames.isEmpty() ) {
             throw new IllegalStateException("No processes found for auto-execution")
         }
-        
+
         // Always pick the first process (whether single or multiple processes)
         final processName = processNames.first()
         final processDef = meta.getProcess(processName)
-        
-        return createProcessWorkflow(processDef)
+
+        return createProcessEntry(processDef)
     }
 
     /**
      * Creates a workflow to execute the specified process with automatic parameter mapping.
      */
-    private WorkflowDef createProcessWorkflow(ProcessDef processDef) {
+    private WorkflowDef createProcessEntry(ProcessDef processDef) {
         final processName = processDef.name
-        
-        // Create a simple workflow body that just executes the process
-        def workflowBodyClosure = { ->
+
+        final workflowBody = { ->
             // Create the workflow execution logic
-            def workflowExecutionClosure = { ->
+            final workflowExecutionClosure = { ->
                 // Get input parameter values and execute the process
-                def inputArgs = getProcessInputArguments(processDef)
-                def processResult = script.invokeMethod(processName, inputArgs as Object[])
-                
+                final inputArgs = getProcessArguments(processDef)
+                final processResult = script.invokeMethod(processName, inputArgs as Object[])
+
                 return processResult
             }
-            
+
             // Create the body definition with execution logic
-            def sourceCode = "    // Auto-generated process workflow\n    ${processName}(...)"
+            final sourceCode = "    // Auto-generated process entry\n    ${processName}(...)"
             return new BodyDef(workflowExecutionClosure, sourceCode, 'workflow')
         }
-        
-        // Create a simple workflow definition without complex emission logic
-        return new WorkflowDef(script, workflowBodyClosure)
+
+        return new WorkflowDef(script, workflowBody)
     }
 
     /**
      * Gets the input arguments for a process by parsing input parameter structures
      * and mapping them from session.params, supporting dot notation for complex inputs.
-     * 
+     *
      * @param processDef The ProcessDef object containing the process definition
      * @return List of parameter values to pass to the process
      */
-    private List getProcessInputArguments(ProcessDef processDef) {
+    private List getProcessArguments(ProcessDef processDef) {
         try {
             log.debug "Getting input arguments for process: ${processDef.name}"
             log.debug "Session params: ${session.params}"
-            
-            def inputStructures = parseProcessInputStructures(processDef)
-            log.debug "Parsed input structures: ${inputStructures}"
-            
-            if( inputStructures.isEmpty() ) {
-                log.debug "No input structures found, returning empty list"
-                return []
-            }
-            
-            // Parse complex parameters from session.params (handles dot notation)
-            def complexParams = parseComplexParameters(session.params)
-            log.debug "Complex parameters: ${complexParams}"
-            
-            // Map input structures to actual values
-            List inputArgs = []
-            for( def inputDef : inputStructures ) {
-                log.debug "Processing input definition: ${inputDef}"
-                if( inputDef.type == 'tuple' ) {
-                    // Handle tuple inputs - construct list with proper elements
-                    List tupleElements = []
-                    for( def element : inputDef.elements ) {
-                        log.debug "Getting value for tuple element: ${element}"
-                        def value = getValueForInput(element, complexParams)
-                        tupleElements.add(value)
-                    }
-                    log.debug "Constructed tuple: ${tupleElements}"
-                    inputArgs.add(tupleElements)
-                } else {
-                    // Handle simple inputs
-                    def value = getValueForInput(inputDef, complexParams)
-                    log.debug "Got simple input value: ${value}"
-                    inputArgs.add(value)
-                }
-            }
-            
+
+            final config = processDef.getProcessConfig()
+            final inputArgs = config instanceof ProcessConfigV1
+                ? getProcessArgumentsV1(config)
+                : getProcessArgumentsV2((ProcessConfigV2) config)
+
             log.debug "Final input arguments: ${inputArgs}"
             return inputArgs
-            
+
         } catch (Exception e) {
             log.error "Failed to get input arguments for process ${processDef.name}: ${e.message}"
             throw e
         }
     }
-    
-    /**
-     * Parses the process body to extract input parameter structures by intercepting
-     * Nextflow's internal compiled method calls (_in_val, _in_path, _in_tuple, etc.).
-     *
-     * @param processDef The ProcessDef containing the raw process body
-     * @return List of input structures with type and name information
-     */
-    private List parseProcessInputStructures(ProcessDef processDef) {
-        def inputStructures = []
-        
-        // Create delegate to capture Nextflow's internal input method calls
-        def delegate = new Object() {
-            def _in_val(tokenVar) { 
-                def varName = extractVariableName(tokenVar)
-                if( varName ) inputStructures.add([type: 'val', name: varName]) 
-            }
-            def _in_path(tokenVar) { 
-                def varName = extractVariableName(tokenVar)
-                if( varName ) inputStructures.add([type: 'path', name: varName]) 
-            }
-            def _in_file(tokenVar) { 
-                def varName = extractVariableName(tokenVar)
-                if( varName ) inputStructures.add([type: 'file', name: varName]) 
-            }
-            def _in_env(tokenVar) { 
-                def varName = extractVariableName(tokenVar)
-                if( varName ) inputStructures.add([type: 'env', name: varName]) 
-            }
-            def _in_each(tokenVar) { 
-                def varName = extractVariableName(tokenVar)
-                if( varName ) inputStructures.add([type: 'each', name: varName]) 
-            }
-            
-            def extractVariableName(token) {
-                if( token?.hasProperty('name') ) {
-                    return token.name.toString()
-                } else {
-                    // Try to extract from string representation
-                    def match = token.toString() =~ /TokenVar\(([^)]+)\)/
-                    return match ? match[0][1] : null
-                }
-            }
-            
-            def _in_tuple(Object... items) {
-                def tupleElements = []
-                for( item in items ) {
-                    log.debug "Processing tuple item: ${item} of class ${item?.getClass()?.getSimpleName()}"
-                    
-                    def itemType = 'val' // default
-                    def itemName = null
-                    
-                    // Handle different token call types by checking class name
-                    def className = item.getClass().getSimpleName()
-                    if( className == 'TokenValCall' ) {
-                        itemType = 'val'
-                        itemName = extractVariableNameFromToken(item)
-                    } else if( className == 'TokenPathCall' || className == 'TokenFileCall' ) {
-                        itemType = 'path'
-                        itemName = extractVariableNameFromToken(item)
-                    } else if( className == 'TokenEnvCall' ) {
-                        itemType = 'env'
-                        itemName = extractVariableNameFromToken(item)
-                    } else if( className == 'TokenEachCall' ) {
-                        itemType = 'each'
-                        itemName = extractVariableNameFromToken(item)
-                    } else {
-                        // Fallback: try to extract from string representation
-                        if( item.toString().contains('TokenValCall') ) {
-                            itemType = 'val'
-                            def tokenVar = item.toString().find(/TokenVar\(([^)]+)\)/) { match, varName -> varName }
-                            itemName = tokenVar
-                        }
-                    }
-                    
-                    if( itemName ) {
-                        log.debug "Parsed tuple element: ${itemName} (${itemType})"
-                        tupleElements.add([type: itemType, name: itemName])
-                    } else {
-                        log.warn "Could not parse tuple element: ${item} of class ${className}"
-                    }
-                }
-                log.debug "Parsed tuple with ${tupleElements.size()} elements: ${tupleElements}"
-                inputStructures.add([type: 'tuple', elements: tupleElements])
-            }
-            
-            def extractVariableNameFromToken(token) {
-                // Try to access the variable property directly
-                try {
-                    if( token.hasProperty('variable') && token.variable?.hasProperty('name') ) {
-                        return token.variable.name.toString()
-                    }
-                    if( token.hasProperty('target') && token.target?.hasProperty('name') ) {
-                        return token.target.name.toString()
-                    }
-                    if( token.hasProperty('name') ) {
-                        return token.name.toString()
-                    }
-                    // Fallback to string parsing
-                    def match = token.toString() =~ /TokenVar\(([^)]+)\)/
-                    return match ? match[0][1] : null
-                } catch( Exception e ) {
-                    log.debug "Error extracting variable name from ${token}: ${e.message}"
-                    return null
-                }
-            }
-            
-            // Handle legacy input block syntax for backward compatibility
-            def input(Closure inputBody) {
-                def inputDelegate = new Object() {
-                    def val(name) { 
-                        inputStructures.add([type: 'val', name: name.toString()]) 
-                    }
-                    def path(name) { 
-                        inputStructures.add([type: 'path', name: name.toString()]) 
-                    }
-                    def file(name) { 
-                        inputStructures.add([type: 'file', name: name.toString()]) 
-                    }
-                    def env(name) { 
-                        inputStructures.add([type: 'env', name: name.toString()]) 
-                    }
-                    def each(name) { 
-                        inputStructures.add([type: 'each', name: name.toString()]) 
-                    }
-                    def tuple(Object... items) {
-                        def tupleElements = []
-                        for( item in items ) {
-                            if( item instanceof String || (item instanceof groovy.lang.GString) ) {
-                                tupleElements.add([type: 'val', name: item.toString()])
-                            }
-                        }
-                        inputStructures.add([type: 'tuple', elements: tupleElements])
-                    }
-                    def methodMissing(String name, args) {
-                        for( arg in args ) {
-                            if( arg instanceof String || (arg instanceof groovy.lang.GString) ) {
-                                inputStructures.add([type: name, name: arg.toString()])
-                            }
-                        }
-                    }
-                }
-                inputBody.delegate = inputDelegate
-                inputBody.resolveStrategy = Closure.DELEGATE_FIRST
-                inputBody.call()
-            }
-            
-            // Ignore all other method calls during parsing
-            def methodMissing(String name, args) { /* ignore */ }
-        }
-        
-        // Execute the process body with our capturing delegate
-        def bodyClone = processDef.rawBody.clone()
-        bodyClone.delegate = delegate
-        bodyClone.resolveStrategy = Closure.DELEGATE_FIRST
-        
-        try {
-            bodyClone.call()
-        } catch (Exception e) {
-            // Ignore exceptions during parsing - we only want to capture input structures
-        }
-        
-        return inputStructures
-    }
-    
+
     /**
      * Parses complex parameters with dot notation support.
      * Converts flat parameters like --meta.id=1 --meta.name=test to nested maps.
-     * 
+     *
      * @param params Flat parameter map from session.params
      * @return Map with nested structures for complex parameters
      */
     private Map parseComplexParameters(Map params) {
         Map complexParams = [:]
-        
+
         params.each { key, value ->
             def parts = key.toString().split('\\.')
             if( parts.length > 1 ) {
@@ -336,10 +151,134 @@ class ProcessEntryHandler {
                 complexParams[key] = value
             }
         }
-        
+
         return complexParams
     }
-    
+
+    private List getProcessArgumentsV1(ProcessConfigV1 config) {
+        final declaredInputs = config.getInputs()
+
+        if( declaredInputs.isEmpty() ) {
+            return []
+        }
+
+        // Parse complex parameters from session.params (handles dot notation)
+        final complexParams = parseComplexParameters(session.params)
+        log.debug "Complex parameters: ${complexParams}"
+
+        // Map declared inputs to command-line arguments
+        List arguments = []
+        for( final param : declaredInputs ) {
+            if( param instanceof TupleInParam ) {
+                List tupleElements = []
+                for( final innerParam : param.inner ) {
+                    final value = getValueForInput(innerParam, complexParams)
+                    tupleElements.add(value)
+                }
+                arguments.add(tupleElements)
+            }
+            else {
+                final value = getValueForInput(param, complexParams)
+                arguments.add(value)
+            }
+        }
+
+        return arguments
+    }
+
+    private List getProcessArgumentsV2(ProcessConfigV2 config) {
+        final declaredInputs = config.getInputs().getParams()
+
+        if( declaredInputs.isEmpty() ) {
+            return []
+        }
+
+        // Parse complex parameters from session.params (handles dot notation)
+        final complexParams = parseComplexParameters(session.params)
+        log.debug "Complex parameters: ${complexParams}"
+
+        // Map declared inputs to command-line arguments
+        List arguments = []
+        for( final param : declaredInputs ) {
+            if( param instanceof ProcessTupleInput ) {
+                List tupleElements = []
+                for( final innerParam : param.getComponents() ) {
+                    final value = getValueForInput(innerParam, complexParams)
+                    tupleElements.add(value)
+                }
+                arguments.add(tupleElements)
+            }
+            else {
+                final value = getValueForInput(param, complexParams)
+                arguments.add(value)
+            }
+        }
+
+        return arguments
+    }
+
+    /**
+     * Gets the appropriate value for an input definition, handling type conversion.
+     *
+     * @param param Input declaration
+     * @param namedArgs Map of command-line arguments
+     * @return Properly typed value for the input
+     */
+    private Object getValueForInput(InParam param, Map namedArgs) {
+        final paramName = param.getName()
+        final paramValue = namedArgs.get(paramName)
+
+        if( paramValue == null ) {
+            throw new IllegalArgumentException("Missing required parameter: --${paramName}")
+        }
+
+        if( param instanceof ProcessInput ) {
+            return getTypedValueForInput(param.type, paramValue)
+        }
+
+        switch( param ) {
+            case FileInParam:
+                return parseFileInput(paramValue.toString())
+
+            case EnvInParam:
+                throw new IllegalArgumentException("Process `env` input qualifier is not supported by implicit process entry")
+
+            case StdInParam:
+                throw new IllegalArgumentException("Process `stdin` input qualifier is not supported by implicit process entry")
+
+            default:
+                return paramValue
+        }
+    }
+
+    private Object getTypedValueForInput(Class type, Object value) {
+        if( value !instanceof String )
+            return value
+
+        final str = (String) value
+
+        if( type == Boolean ) {
+            if( str.toLowerCase() == 'true' ) return Boolean.TRUE
+            if( str.toLowerCase() == 'false' ) return Boolean.FALSE
+        }
+
+        if( type == Integer || type == Float ) {
+            if( str.isInteger() ) return str.toInteger()
+            if( str.isLong() ) return str.toLong()
+        }
+
+        if( type == Float ) {
+            if( str.isFloat() ) return str.toFloat()
+            if( str.isDouble() ) return str.toDouble()
+        }
+
+        if( type == Path ) {
+            return Nextflow.file(str)
+        }
+
+        return value
+    }
+
     /**
      * Parses file input handling comma-separated values.
      * If the input contains commas, splits and returns a list of files.
@@ -360,41 +299,4 @@ class ProcessEntryHandler {
             return Nextflow.file(fileInput)
         }
     }
-    
-    /**
-     * Gets the appropriate value for an input definition, handling type conversion.
-     * 
-     * @param inputDef Input definition with type and name
-     * @param complexParams Parsed parameter map with nested structures
-     * @return Properly typed value for the input
-     */
-    private Object getValueForInput(Map inputDef, Map complexParams) {
-        def paramName = inputDef.name
-        def paramType = inputDef.type
-        def paramValue = complexParams.get(paramName)
-        
-        if( paramValue == null ) {
-            throw new IllegalArgumentException("Missing required parameter: --${paramName}")
-        }
-        
-        // Type-specific conversion
-        switch( paramType ) {
-            case 'path':
-            case 'file':
-                if( paramValue instanceof String || paramValue instanceof GString ) {
-                    return parseFileInput(paramValue.toString())
-                }
-                return paramValue
-                
-            case 'val':
-                // For val inputs, return as-is (could be Map for complex structures)
-                return paramValue
-                
-            case 'env':
-                return paramValue?.toString()
-                
-            default:
-                return paramValue
-        }
-    }
 }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/ProcessFactory.groovy b/modules/nextflow/src/main/groovy/nextflow/script/ProcessFactory.groovy
index 558e730b03..f7e72a4f2d 100755
--- a/modules/nextflow/src/main/groovy/nextflow/script/ProcessFactory.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/ProcessFactory.groovy
@@ -62,45 +62,4 @@ class ProcessFactory {
         new TaskProcessor(name, executor, session, owner, config, taskBody)
     }
 
-    /**
-     * Create a task processor
-     *
-     * @param name
-     *      The name of the process as defined in the script
-     * @param body
-     *      The process declarations provided by the user
-     * @param options
-     *      A map representing the named parameter specified after the process name eg:
-     *          `process foo(bar: 'x') {  }`
-     *      (not used)
-     * @return
-     *      The {@code Processor} instance
-     */
-    TaskProcessor createProcessor( String name, Closure body ) {
-        assert body
-        assert config.process instanceof Map
-
-        // -- the config object
-        final processConfig = new ProcessConfig(owner, name)
-        // Invoke the code block which will return the script closure to the executed.
-        // As side effect will set all the property declarations in the 'taskConfig' object.
-        processConfig.throwExceptionOnMissingProperty(true)
-        final copy = (Closure)body.clone()
-        copy.setResolveStrategy(Closure.DELEGATE_FIRST)
-        copy.setDelegate(processConfig)
-        final script = copy.call()
-        processConfig.throwExceptionOnMissingProperty(false)
-        if ( !script )
-            throw new IllegalArgumentException("Missing script in the specified process block -- make sure it terminates with the script string to be executed")
-
-        // -- apply settings from config file to process config
-        processConfig.applyConfigLegacy((Map)config.process, name)
-
-        // -- get the executor for the given process config
-        final execObj = executorFactory.getExecutor(name, processConfig, script, session)
-
-        // -- create processor class
-        newTaskProcessor( name, execObj, processConfig, script )
-    }
-
 }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy
new file mode 100644
index 0000000000..0b1bc85675
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessBuilder.groovy
@@ -0,0 +1,438 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import java.util.regex.Pattern
+
+import groovy.transform.TypeChecked
+import groovy.util.logging.Slf4j
+import nextflow.exception.IllegalConfigException
+import nextflow.exception.IllegalDirectiveException
+import nextflow.exception.ScriptRuntimeException
+import nextflow.processor.ConfigList
+import nextflow.processor.ErrorStrategy
+import nextflow.script.BaseScript
+import nextflow.script.BodyDef
+import nextflow.script.ProcessConfig
+import nextflow.script.ProcessDef
+
+/**
+ * Builder for {@link ProcessDef}.
+ *
+ * @see nextflow.script.dsl.ProcessDsl.DirectiveDsl
+ *
+ * @author Ben Sherman 
+ */
+@Slf4j
+@TypeChecked
+class ProcessBuilder {
+
+    static final List DIRECTIVES = [
+            'accelerator',
+            'afterScript',
+            'arch',
+            'array',
+            'beforeScript',
+            'cache',
+            'clusterOptions',
+            'conda',
+            'container',
+            'containerOptions',
+            'cpus',
+            'debug',
+            'disk',
+            'errorStrategy',
+            'executor',
+            'ext',
+            'fair',
+            'label',
+            'machineType',
+            'maxErrors',
+            'maxForks',
+            'maxRetries',
+            'maxSubmitAwait',
+            'memory',
+            'module',
+            'penv',
+            'pod',
+            'publishDir',
+            'queue',
+            'resourceLabels',
+            'resourceLimits',
+            'scratch',
+            'secret',
+            'shell',
+            'spack',
+            'stageInMode',
+            'stageOutMode',
+            'storeDir',
+            'tag',
+            'time'
+    ]
+
+    protected BaseScript ownerScript
+
+    protected String processName
+
+    protected BodyDef body
+
+    protected ProcessConfig config
+
+    ProcessBuilder(ProcessConfig config) {
+        this.ownerScript = config.getOwnerScript()
+        this.processName = config.getProcessName()
+        this.config = config
+    }
+
+    def methodMissing( String name, def args ) {
+        checkName(name)
+
+        if( args instanceof Object[] )
+            config.put(name, args.size()==1 ? args[0] : args.toList())
+        else
+            config.put(name, args)
+    }
+
+    private void checkName(String name) {
+        if( DIRECTIVES.contains(name) )
+            return
+        if( name == 'when' || name == 'stub' )
+            return
+
+        String message = "Unknown process directive: `$name`"
+        def alternatives = DIRECTIVES.closest(name)
+        if( alternatives.size()==1 ) {
+            message += '\n\nDid you mean one of these?'
+            alternatives.each {
+                message += "\n        $it"
+            }
+        }
+        throw new IllegalDirectiveException(message)
+    }
+
+    /// DIRECTIVES
+
+    void accelerator( Map params, value )  {
+        if( value instanceof Number ) {
+            if( params.limit==null )
+                params.limit=value
+            else if( params.request==null )
+                params.request=value
+        }
+        else if( value != null )
+            throw new IllegalArgumentException("Not a valid `accelerator` directive value: $value [${value.getClass().getName()}]")
+        accelerator(params)
+    }
+
+    void accelerator( value ) {
+        if( value instanceof Number )
+            config.put('accelerator', [limit: value])
+        else if( value instanceof Map )
+            config.put('accelerator', value)
+        else if( value != null )
+            throw new IllegalArgumentException("Not a valid `accelerator` directive value: $value [${value.getClass().getName()}]")
+    }
+
+    void arch( Map params, value )  {
+        if( value instanceof String ) {
+            if( params.name==null )
+                params.name=value
+        }
+        else if( value != null )
+            throw new IllegalArgumentException("Not a valid `arch` directive value: $value [${value.getClass().getName()}]")
+        arch(params)
+    }
+
+    void arch( value ) {
+        if( value instanceof String )
+            config.put('arch', [name: value])
+        else if( value instanceof Map )
+            config.put('arch', value)
+        else if( value != null )
+            throw new IllegalArgumentException("Not a valid `arch` directive value: $value [${value.getClass().getName()}]")
+    }
+
+    void debug(boolean value) {
+        config.debug = value
+    }
+
+    /**
+     * Implements the {@code disk} directive, e.g.:
+     *
+     *     disk 375.GB, type: 'local-ssd'
+     *
+     * @param opts
+     * @param value
+     */
+    void disk( Map opts, value )  {
+        opts.request = value
+        disk(opts)
+    }
+
+    /**
+     * Implements the {@code disk} directive, e.g.:
+     *
+     *     disk 100.GB
+     *     disk request: 375.GB, type: 'local-ssd'
+     *
+     * @param value
+     */
+    void disk( value ) {
+        if( value instanceof Map || value instanceof Closure )
+            config.put('disk', value)
+        else
+            config.put('disk', [request: value])
+    }
+
+    /**
+     * Implements the {@code echo} directive for backwards compatibility.
+     *
+     * note: without this method definition {@link BaseScript#echo} will be invoked
+     *
+     * @param value
+     */
+    void echo( value ) {
+        log.warn1('The `echo` directive has been deprecated - use `debug` instead')
+        config.put('debug', value)
+    }
+
+    /**
+     * Implements the {@code errorStrategy} directive.
+     *
+     * @param strategy
+     */
+    void errorStrategy( strategy ) {
+        if( strategy instanceof CharSequence && !ErrorStrategy.isValid(strategy) )
+            throw new IllegalArgumentException("Unknown error strategy '${strategy}' ― Available strategies are: ${ErrorStrategy.values().join(',').toLowerCase()}")
+
+        config.put('errorStrategy', strategy)
+    }
+
+    /**
+     * Implements the {@code label} directive.
+     *
+     * This directive can be specified (invoked) more than once in
+     * the process definition.
+     *
+     * @param value
+     */
+    void label(String value) {
+        if( !value ) return
+
+        // -- check that label has a valid syntax
+        if( !isValidLabel(value) )
+            throw new IllegalConfigException("Not a valid process label: $value -- Label must consist of alphanumeric characters or '_', must start with an alphabetic character and must end with an alphanumeric character")
+
+        // -- get the current label, it must be a list
+        def allLabels = (List)config.get('label')
+        if( !allLabels ) {
+            allLabels = new ConfigList()
+            config.put('label', allLabels)
+        }
+
+        // -- avoid duplicates
+        if( !allLabels.contains(value) )
+            allLabels.add(value)
+    }
+
+    private static final Pattern LABEL_REGEXP = ~/[a-zA-Z]([a-zA-Z0-9_]*[a-zA-Z0-9]+)?/
+
+    protected static boolean isValidLabel(String lbl) {
+        def p = lbl.indexOf('=')
+        if( p==-1 )
+            return LABEL_REGEXP.matcher(lbl).matches()
+
+        def left = lbl.substring(0,p)
+        def right = lbl.substring(p+1)
+        return LABEL_REGEXP.matcher(left).matches() && LABEL_REGEXP.matcher(right).matches()
+    }
+
+    /**
+     * Implements the {@code module} directive.
+     *
+     * See also http://modules.sourceforge.net
+     *
+     * @param value
+     */
+    void module( value ) {
+        if( !value ) return
+
+        def result = (List)config.module
+        if( result == null ) {
+            result = new ConfigList()
+            config.put('module', result)
+        }
+
+        if( value instanceof List )
+            result.addAll(value)
+        else
+            result.add(value)
+    }
+
+    /**
+     * Implements the {@code pod} directive.
+     *
+     * @param entry
+     */
+    void pod( Map entry ) {
+        if( !entry ) return
+
+        def allOptions = (List)config.get('pod')
+        if( !allOptions ) {
+            allOptions = new ConfigList()
+            config.put('pod', allOptions)
+        }
+
+        allOptions.add(entry)
+    }
+
+    /**
+     * Implements the {@code publishDir} directive as a map eg:
+     *
+     *     publishDir path: '/some/dir', mode: 'copy'
+     *
+     * This directive can be specified (invoked) multiple times in
+     * the process definition.
+     *
+     * @param params
+     */
+    void publishDir(Map params) {
+        if( !params ) return
+
+        def dirs = (List)config.get('publishDir')
+        if( !dirs ) {
+            dirs = new ConfigList()
+            config.put('publishDir', dirs)
+        }
+
+        dirs.add(params)
+    }
+
+    /**
+     * Implements the {@code publishDir} directive as a path with named parameters, eg:
+     *
+     *     publishDir '/some/dir', mode: 'copy'
+     *
+     * @param params
+     * @param path
+     */
+    void publishDir(Map params, CharSequence path) {
+        params.put('path', path)
+        publishDir( params )
+    }
+
+    /**
+     * Implements the {@code publishDir} directive as a string path, eg:
+     *
+     *     publishDir '/some/dir'
+     *
+     * @param target
+     */
+    void publishDir( target ) {
+        if( target instanceof List ) {
+            for( Object item : target ) { publishDir(item) }
+        }
+        else if( target instanceof Map ) {
+            publishDir( target as Map )
+        }
+        else {
+            publishDir([path: target])
+        }
+    }
+
+    /**
+     * Implements the {@code resourceLabels} directive.
+     *
+     * This directive can be specified (invoked) multiple times in
+     * the process definition.
+     *
+     * @param map
+     */
+    void resourceLabels(Map map) {
+        if( !map ) return
+
+        // -- get the current sticker, it must be a Map
+        def allLabels = (Map)config.get('resourceLabels')
+        if( !allLabels ) {
+            allLabels = [:]
+        }
+        // -- merge duplicates
+        allLabels += map
+        config.put('resourceLabels', allLabels)
+    }
+
+    private static final List VALID_RESOURCE_LIMITS = List.of('cpus', 'memory', 'disk', 'time')
+
+    /**
+     * Implements the {@code resourceLimits} directive.
+     *
+     * @param entries
+     */
+    void resourceLimits(Map entries) {
+        for( entry in entries ) {
+            if( entry.key !in VALID_RESOURCE_LIMITS )
+                throw new IllegalArgumentException("Not a valid directive in `resourceLimits`: $entry.key")
+        }
+
+        config.put('resourceLimits', entries)
+    }
+
+    /**
+     * Implements the {@code secret} directive.
+     *
+     * This directive can be specified (invoked) multiple times in
+     * the process definition.
+     *
+     * @param name
+     */
+    void secret(String name) {
+        if( !name ) return
+
+        // -- get the current label, it must be a list
+        def allSecrets = (List)config.get('secret')
+        if( !allSecrets ) {
+            allSecrets = new ConfigList()
+            config.put('secret', allSecrets)
+        }
+
+        // -- avoid duplicates
+        if( !allSecrets.contains(name) )
+            allSecrets.add(name)
+    }
+
+    /// SCRIPT
+
+    ProcessBuilder withBody(Closure closure, String section, String source='', List values=null) {
+        withBody(new BodyDef(closure, source, section, values))
+    }
+
+    ProcessBuilder withBody(BodyDef body) {
+        this.body = body
+        return this
+    }
+
+    ProcessConfig getConfig() {
+        return config
+    }
+
+    ProcessDef build() {
+        if ( !body )
+            throw new ScriptRuntimeException("Missing script in the specified process block -- make sure it terminates with the script string to be executed")
+        return new ProcessDef(ownerScript, processName, config, body)
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy
new file mode 100644
index 0000000000..9d8e4cbfe6
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessConfigBuilder.groovy
@@ -0,0 +1,249 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import java.util.regex.Pattern
+
+import groovy.transform.TypeChecked
+import groovy.util.logging.Slf4j
+import nextflow.exception.ConfigParseException
+import nextflow.script.ProcessConfig
+
+/**
+ * Builder for {@link ProcessConfig}.
+ *
+ * @author Ben Sherman 
+ */
+@Slf4j
+@TypeChecked
+class ProcessConfigBuilder extends ProcessBuilder {
+
+    ProcessConfigBuilder(ProcessConfig config) {
+        super(config)
+    }
+
+    /**
+     * Apply process config settings from the config file to a process.
+     *
+     * @param configProcessScope
+     * @param baseName
+     * @param simpleName
+     * @param fullyQualifiedName
+     */
+    void applyConfig(Map configProcessScope, String baseName, String simpleName, String fullyQualifiedName) {
+        // -- apply settings defined in the config object using the`withLabel:` syntax
+        final processLabels = config.getLabels() ?: ['']
+        applyConfigSelectorWithLabels(configProcessScope, processLabels)
+
+        // -- apply settings defined in the config file using the process base name
+        applyConfigSelectorWithName(configProcessScope, baseName)
+
+        // -- apply settings defined in the config file using the process simple name
+        if( simpleName && simpleName!=baseName )
+            applyConfigSelectorWithName(configProcessScope, simpleName)
+
+        // -- apply settings defined in the config file using the process fully qualified name (ie. with the execution scope)
+        if( fullyQualifiedName && (fullyQualifiedName!=simpleName || fullyQualifiedName!=baseName) )
+            applyConfigSelectorWithName(configProcessScope, fullyQualifiedName)
+
+        // -- apply defaults
+        applyConfigDefaults(configProcessScope)
+
+        // -- check for conflicting settings
+        if( config.scratch && config.stageInMode == 'rellink' ) {
+            log.warn("Directives `scratch` and `stageInMode=rellink` conflict with each other -- Enforcing default stageInMode for process `$simpleName`")
+            config.remove('stageInMode')
+        }
+    }
+
+    /**
+     * Apply the config settings in a label selector, for example:
+     *
+     * ```
+     * process {
+     *     withLabel: foo {
+     *         cpus = 1
+     *         memory = 2.gb
+     *     }
+     * }
+     * ```
+     *
+     * @param configDirectives
+     * @param labels
+     */
+    protected void applyConfigSelectorWithLabels(Map configDirectives, List labels) {
+        final prefix = 'withLabel:'
+        for( String rule : configDirectives.keySet() ) {
+            if( !rule.startsWith(prefix) )
+                continue
+            final pattern = rule.substring(prefix.size()).trim()
+            if( !matchesLabels(labels, pattern) )
+                continue
+
+            log.debug "Config settings `$rule` matches labels `${labels.join(',')}` for process with name $processName"
+            final settings = configDirectives.get(rule)
+            if( settings instanceof Map ) {
+                applyConfigSettings(settings)
+            }
+            else if( settings != null ) {
+                throw new ConfigParseException("Unknown config settings for process labeled ${labels.join(',')} -- settings=$settings ")
+            }
+        }
+    }
+
+    /**
+     * Determine whether any of the given labels match the
+     * given process withLabel selector.
+     *
+     * @param labels
+     * @param pattern
+     */
+    static boolean matchesLabels(List labels, String pattern) {
+        final isNegated = pattern.startsWith('!')
+        if( isNegated )
+            pattern = pattern.substring(1).trim()
+
+        final regex = Pattern.compile(pattern)
+        for (label in labels) {
+            if (regex.matcher(label).matches()) {
+                return !isNegated
+            }
+        }
+
+        return isNegated
+    }
+
+    /**
+     * Apply the config settings in a name selector, for example:
+     *
+     * ```
+     * process {
+     *     withName: foo {
+     *         cpus = 1
+     *         memory = 2.gb
+     *     }
+     * }
+     * ```
+     *
+     * @param configDirectives
+     * @param target
+     */
+    protected void applyConfigSelectorWithName(Map configDirectives, String target) {
+        final prefix = 'withName:'
+        for( String rule : configDirectives.keySet() ) {
+            if( !rule.startsWith(prefix) )
+                continue
+            final pattern = rule.substring(prefix.size()).trim()
+            if( !matchesSelector(target, pattern) )
+                continue
+
+            log.debug "Config settings `$rule` matches process $processName"
+            def settings = configDirectives.get(rule)
+            if( settings instanceof Map ) {
+                applyConfigSettings(settings)
+            }
+            else if( settings != null ) {
+                throw new ConfigParseException("Unknown config settings for process with name: $target  -- settings=$settings ")
+            }
+        }
+    }
+
+    /**
+     * Determine whether the given process name matches the
+     * given process withName selector.
+     *
+     * @param name
+     * @param pattern
+     */
+    static boolean matchesSelector(String name, String pattern) {
+        final isNegated = pattern.startsWith('!')
+        if( isNegated )
+            pattern = pattern.substring(1).trim()
+        return Pattern.compile(pattern).matcher(name).matches() ^ isNegated
+    }
+
+    /**
+     * Apply config settings to a process.
+     *
+     * @param settings
+     */
+    protected void applyConfigSettings(Map settings) {
+        if( !settings )
+            return
+
+        for( final entry : settings ) {
+            if( entry.key.startsWith("withLabel:") || entry.key.startsWith("withName:"))
+                continue
+
+            if( !DIRECTIVES.contains(entry.key) )
+                log.warn "Unknown directive `$entry.key` for process `$processName`"
+
+            if( entry.key == 'params' ) // <-- patch issue #242
+                continue
+
+            if( entry.key == 'ext' ) {
+                if( config.getProperty('ext') instanceof Map ) {
+                    // update missing 'ext' properties found in 'process' scope
+                    final ext = config.getProperty('ext') as Map
+                    entry.value.each { String k, v -> ext[k] = v }
+                }
+                continue
+            }
+
+            putWithRepeat(entry.key, entry.value)
+        }
+    }
+
+    /**
+     * Apply the global settings in the process config scope to a process.
+     *
+     * @param defaults
+     *      A map object representing the setting to be applied to the process
+     *      (provided it does not already define a different value for
+     *      the same config setting).
+     */
+    protected void applyConfigDefaults( Map defaults ) {
+        for( String key : defaults.keySet() ) {
+            if( key == 'params' )
+                continue
+            final value = defaults.get(key)
+            final current = config.getProperty(key)
+            if( key == 'ext' ) {
+                if( value instanceof Map && current instanceof Map ) {
+                    final ext = current as Map
+                    value.each { k,v -> if(!ext.containsKey(k)) ext.put(k,v) }
+                }
+            }
+            else if( !config.containsKey(key) || (ProcessConfig.DEFAULT_CONFIG.containsKey(key) && current==ProcessConfig.DEFAULT_CONFIG.get(key)) ) {
+                putWithRepeat(key, value)
+            }
+        }
+    }
+
+    private static final List REPEATABLE_DIRECTIVES = ['label','module','pod','publishDir']
+
+    protected void putWithRepeat( String name, Object value ) {
+        if( name in REPEATABLE_DIRECTIVES ) {
+            config.remove(name)
+            this.metaClass.invokeMethod(this, name, value)
+        }
+        else {
+            config.put(name, value)
+        }
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessDslV1.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessDslV1.groovy
new file mode 100644
index 0000000000..e18a96ace7
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessDslV1.groovy
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import groovy.transform.TypeChecked
+import nextflow.script.BaseScript
+import nextflow.script.ProcessConfigV1
+import nextflow.script.ProcessDef
+import nextflow.script.params.CmdEvalParam
+import nextflow.script.params.EachInParam
+import nextflow.script.params.EnvInParam
+import nextflow.script.params.EnvOutParam
+import nextflow.script.params.FileInParam
+import nextflow.script.params.FileOutParam
+import nextflow.script.params.InputsList
+import nextflow.script.params.OutputsList
+import nextflow.script.params.StdInParam
+import nextflow.script.params.StdOutParam
+import nextflow.script.params.TupleInParam
+import nextflow.script.params.TupleOutParam
+import nextflow.script.params.ValueInParam
+import nextflow.script.params.ValueOutParam
+
+/**
+ * Implements the DSL for legacy processes.
+ *
+ * @see nextflow.ast.NextflowDSLImpl
+ * @see nextflow.script.dsl.ProcessDsl.InputDslV1
+ * @see nextflow.script.dsl.ProcessDsl.OutputDslV1
+ *
+ * @author Ben Sherman 
+ */
+@TypeChecked
+class ProcessDslV1 extends ProcessBuilder {
+
+    ProcessDslV1(BaseScript ownerScript, String processName) {
+        super(new ProcessConfigV1(ownerScript, processName))
+    }
+
+    private ProcessConfigV1 configV1() {
+        return (ProcessConfigV1) config
+    }
+
+    /// INPUTS
+
+    void _in_each( obj ) {
+        new EachInParam(configV1()).bind(obj)
+    }
+
+    void _in_env( obj ) {
+        new EnvInParam(configV1()).bind(obj)
+    }
+
+    void _in_file( obj ) {
+        new FileInParam(configV1()).bind(obj)
+    }
+
+    void _in_path( Map opts=null, obj ) {
+        new FileInParam(configV1())
+                .setPathQualifier(true)
+                .setOptions(opts)
+                .bind(obj)
+    }
+
+    void _in_stdin( obj = null ) {
+        final result = new StdInParam(configV1())
+        if( obj )
+            result.bind(obj)
+    }
+
+    void _in_tuple( Object... obj ) {
+        new TupleInParam(configV1()).bind(obj)
+    }
+
+    void _in_val( obj ) {
+        new ValueInParam(configV1()).bind(obj)
+    }
+
+    /// OUTPUTS
+
+    void _out_env( Object obj ) {
+        new EnvOutParam(configV1()).bind(obj)
+    }
+
+    void _out_env( Map opts, Object obj ) {
+        new EnvOutParam(configV1())
+                .setOptions(opts)
+                .bind(obj)
+    }
+
+    void _out_eval(Object obj ) {
+        new CmdEvalParam(configV1()).bind(obj)
+    }
+
+    void _out_eval(Map opts, Object obj ) {
+        new CmdEvalParam(configV1())
+            .setOptions(opts)
+            .bind(obj)
+    }
+
+    void _out_file( Object obj ) {
+        // note: check that is a String type to avoid to force
+        // the evaluation of GString object to a string
+        if( obj instanceof String && obj == '-' )
+            new StdOutParam(configV1()).bind(obj)
+
+        else
+            new FileOutParam(configV1()).bind(obj)
+    }
+
+    void _out_path( Map opts=null, Object obj ) {
+        // note: check that is a String type to avoid to force
+        // the evaluation of GString object to a string
+        if( obj instanceof String && obj == '-' ) {
+            new StdOutParam(configV1())
+                    .setOptions(opts)
+                    .bind(obj)
+        }
+        else {
+            new FileOutParam(configV1())
+                    .setPathQualifier(true)
+                    .setOptions(opts)
+                    .bind(obj)
+        }
+    }
+
+    void _out_stdout( Map opts ) {
+        new StdOutParam(configV1())
+                .setOptions(opts)
+                .bind('-')
+    }
+
+    void _out_stdout( obj = null ) {
+        new StdOutParam(configV1()).bind('-')
+    }
+
+    void _out_tuple( Object... obj ) {
+        new TupleOutParam(configV1()) .bind(obj)
+    }
+
+    void _out_tuple( Map opts, Object... obj ) {
+        new TupleOutParam(configV1())
+                .setOptions(opts)
+                .bind(obj)
+    }
+
+    void _out_val( Object obj ) {
+        new ValueOutParam(configV1()).bind(obj)
+    }
+
+    void _out_val( Map opts, Object obj ) {
+        new ValueOutParam(configV1())
+                .setOptions(opts)
+                .bind(obj)
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessDslV2.groovy b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessDslV2.groovy
new file mode 100644
index 0000000000..c8fc883083
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/dsl/ProcessDslV2.groovy
@@ -0,0 +1,176 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import groovy.transform.TypeChecked
+import nextflow.script.BaseScript
+import nextflow.script.ProcessConfigV2
+import nextflow.script.ProcessDef
+import nextflow.script.params.v2.ProcessFileInput
+import nextflow.script.params.v2.ProcessFileOutput
+import nextflow.script.params.v2.ProcessInput
+import nextflow.script.params.v2.ProcessInputsDef
+import nextflow.script.params.v2.ProcessOutputsDef
+
+/**
+ * Implements the DSL for typed processes.
+ *
+ * @see nextflow.script.control.ScriptToGroovyVisitor
+ * @see nextflow.script.dsl.ProcessDsl.StageDsl
+ * @see nextflow.script.dsl.ProcessDsl.OutputDslV2
+ *
+ * @author Ben Sherman 
+ */
+@TypeChecked
+class ProcessDslV2 extends ProcessBuilder {
+
+    private ProcessInputsDef inputs
+
+    private ProcessOutputsDef outputs
+
+    ProcessDslV2(BaseScript ownerScript, String processName) {
+        super(new ProcessConfigV2(ownerScript, processName))
+        inputs = ((ProcessConfigV2) config).getInputs()
+        outputs = ((ProcessConfigV2) config).getOutputs()
+    }
+
+    /// INPUTS
+
+    /**
+     * Declare a process input.
+     *
+     * @param name
+     * @param type
+     * @param optional
+     */
+    void _input_(String name, Class type, boolean optional) {
+        inputs.addParam(name, type, optional)
+    }
+
+    /**
+     * Declare a process tuple input.
+     *
+     * @param components
+     * @param type
+     */
+    void _input_(List components, Class type) {
+        inputs.addTupleParam(components, type)
+    }
+
+    /// STAGE DIRECTIVES
+
+    /**
+     * Declare an environment variable in the task environment
+     * with the given name and value.
+     *
+     * @paran name
+     * @param value [String | Closure]
+     */
+    void env(String name, Object value) {
+        inputs.addEnv(name, value)
+    }
+
+    /**
+     * Declare a file or collection of files to be staged into
+     * the task directory.
+     *
+     * This method is automatically generated for Path inputs.
+     *
+     * @param value [Path | Collection | Closure]
+     */
+    void stageAs(Object value) {
+        inputs.addFile(new ProcessFileInput(null, value))
+    }
+
+    /**
+     * Declare a file or collection of files to be staged into
+     * the task directory under the given file pattern.
+     *
+     * @param filePattern [String | Closure]
+     * @param value       [Path | Collection | Closure]
+     */
+    void stageAs(Object filePattern, Object value) {
+        inputs.addFile(new ProcessFileInput(filePattern, value))
+    }
+
+    /**
+     * Declare a value as the standard input of the task script.
+     *
+     * @param value [String | Closure]
+     */
+    void stdin(Object value) {
+        inputs.setStdin(value)
+    }
+
+    /// OUTPUTS
+
+    /**
+     * Declare a process output.
+     *
+     * @param name
+     * @param type
+     * @param value
+     */
+    void _output_(String name, Class type, Object value) {
+        outputs.addParam(name, type, value)
+    }
+
+    /**
+     * Declare a value to be emitted to the given topic
+     * on task completion.
+     *
+     * @param value
+     * @param target
+     */
+    void _topic_(Object value, String target) {
+        outputs.addTopic(value, target)
+    }
+
+    /// UNSTAGE DIRECTIVES
+
+    /**
+     * Declare an output environment variable to be extracted from
+     * the task environment.
+     *
+     * @param name
+     */
+    void _unstage_env(String name) {
+        outputs.addEnv(name)
+    }
+
+    /**
+     * Declare an eval command to be executed in the task environment.
+     *
+     * @param key
+     * @param cmd [String | Closure]
+     */
+    void _unstage_eval(String key, Object cmd) {
+        outputs.addEval(key, cmd)
+    }
+
+    /**
+     * Declare an output file or collection of files to be unstaged
+     * from the task environment.
+     *
+     * @param key
+     * @param pattern [String | Closure]
+     */
+    void _unstage_files(String key, Object pattern) {
+        outputs.addFile(key, new ProcessFileOutput(pattern))
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/BaseInParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/BaseInParam.groovy
index a7ccdb1d6e..5491cd1ffe 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/BaseInParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/BaseInParam.groovy
@@ -24,7 +24,7 @@ import nextflow.NF
 import nextflow.exception.ProcessException
 import nextflow.exception.ScriptRuntimeException
 import nextflow.extension.CH
-import nextflow.script.ProcessConfig
+import nextflow.script.ProcessConfigV1
 import nextflow.script.TokenVar
 /**
  * Model a process generic input parameter
@@ -54,7 +54,7 @@ abstract class BaseInParam extends BaseParam implements InParam {
         return inChannel
     }
 
-    BaseInParam( ProcessConfig config ) {
+    BaseInParam( ProcessConfigV1 config ) {
         this(config.getOwnerScript().getBinding(), config.getInputs())
     }
 
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/BaseOutParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/BaseOutParam.groovy
index c811fa06fc..64ef786b90 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/BaseOutParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/BaseOutParam.groovy
@@ -21,7 +21,7 @@ import groovy.util.logging.Slf4j
 import groovyx.gpars.dataflow.DataflowWriteChannel
 import nextflow.NF
 import nextflow.extension.CH
-import nextflow.script.ProcessConfig
+import nextflow.script.ProcessConfigV1
 import nextflow.script.TokenVar
 import nextflow.util.ConfigHelper
 /**
@@ -50,7 +50,7 @@ abstract class BaseOutParam extends BaseParam implements OutParam {
         super(binding,list,ownerIndex)
     }
 
-    BaseOutParam( ProcessConfig config ) {
+    BaseOutParam( ProcessConfigV1 config ) {
         super(config.getOwnerScript().getBinding(), config.getOutputs())
     }
 
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultInParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultInParam.groovy
index d17b49c303..c644d94b07 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultInParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultInParam.groovy
@@ -17,7 +17,7 @@
 package nextflow.script.params
 
 import nextflow.extension.CH
-import nextflow.script.ProcessConfig
+import nextflow.script.ProcessConfigV1
 /**
  * Model a process default input parameter
  *
@@ -28,7 +28,7 @@ final class DefaultInParam extends ValueInParam {
     @Override
     String getTypeName() { 'default' }
 
-    DefaultInParam(ProcessConfig config) {
+    DefaultInParam(ProcessConfigV1 config) {
         super(config)
         // This must be a dataflow queue channel to which
         // just a value is bound -- No STOP value has to be emitted
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultOutParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultOutParam.groovy
index 1857a46cb7..86746e1149 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultOutParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/DefaultOutParam.groovy
@@ -18,7 +18,7 @@ package nextflow.script.params
 
 
 import groovyx.gpars.dataflow.DataflowQueue
-import nextflow.script.ProcessConfig
+import nextflow.script.ProcessConfigV1
 /**
  * Model a process default output parameter
  *
@@ -28,7 +28,7 @@ final class DefaultOutParam extends BaseOutParam {
 
     static enum Completion { DONE }
 
-    DefaultOutParam(ProcessConfig config ) {
+    DefaultOutParam(ProcessConfigV1 config ) {
         super(config)
         bind('-')
         setInto(new DataflowQueue())
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/EachInParam.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/EachInParam.groovy
index 5313a16e94..d976df1317 100644
--- a/modules/nextflow/src/main/groovy/nextflow/script/params/EachInParam.groovy
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/EachInParam.groovy
@@ -46,7 +46,9 @@ class EachInParam extends BaseInParam {
         final copy = (EachInParam)super.clone()
         copy.@inner = new ArrayList<>(inner.size())
         for( BaseInParam p : inner ) {
-            copy.@inner.add((BaseInParam)p.clone())
+            p = (BaseInParam)p.clone()
+            p.owner = copy
+            copy.@inner.add(p)
         }
         return copy
     }
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessFileInput.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessFileInput.groovy
new file mode 100644
index 0000000000..97053dfedc
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessFileInput.groovy
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+
+/**
+ * Models a process file input, which defines a file
+ * or set of files to be staged into a task work directory.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessFileInput {
+
+    /**
+     * File pattern which defines how the input files should be named
+     * when they are staged into a task directory.
+     */
+    private Object filePattern
+
+    /**
+     * Lazy expression (e.g. closure) which defines which files
+     * to stage in terms of the task inputs.
+     * It is evaluated for each task against the task context.
+     */
+    private Object value
+
+    ProcessFileInput(Object filePattern, Object value) {
+        this.filePattern = filePattern != null ? filePattern : '*'
+        this.value = value
+    }
+
+    String getFilePattern(Map ctx) {
+        return ctx.resolveLazy(filePattern)
+    }
+
+    Object resolve(Map ctx) {
+        return ctx.resolveLazy(value)
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessFileOutput.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessFileOutput.groovy
new file mode 100644
index 0000000000..3aa1533e0a
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessFileOutput.groovy
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+/**
+ * Models a process file output, which defines a file
+ * or set of files to be unstaged from a task work directory.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessFileOutput {
+
+    /**
+     * Lazy expression (e.g. closure) which defines which files
+     * to unstage from the task directory.
+     * It will be evaluated for each task against the task directory.
+     */
+    private Object filePattern
+
+    ProcessFileOutput(Object filePattern) {
+        this.filePattern = filePattern
+    }
+
+    String getFilePattern(Map context) {
+        return context.resolveLazy(filePattern)?.toString()
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessInput.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessInput.groovy
new file mode 100644
index 0000000000..991996ea6f
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessInput.groovy
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+import groovyx.gpars.dataflow.DataflowReadChannel
+import nextflow.script.params.InParam
+
+/**
+ * Models a process input declaration.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessInput implements InParam {
+
+    /**
+     * Parameter name under which the input value for each task
+     * will be added to the task context.
+     */
+    private String name
+
+    /**
+     * Parameter type which is used to validate task inputs
+     */
+    private Class type
+
+    /**
+     * Whether the input can be null.
+     */
+    private boolean optional
+
+    /**
+     * Input channel which is created when the process is invoked
+     * in a workflow.
+     */
+    DataflowReadChannel channel
+
+    ProcessInput(String name, Class type, boolean optional) {
+        this.name = name
+        this.type = type
+        this.optional = optional
+    }
+
+    @Override
+    String getName() {
+        return name
+    }
+
+    Class getType() {
+        return type
+    }
+
+    boolean isOptional() {
+        return optional
+    }
+
+    @Override
+    ProcessInput clone() {
+        (ProcessInput)super.clone()
+    }
+
+    /// LEGACY METHODS
+
+    @Override
+    DataflowReadChannel getInChannel() {
+        return channel
+    }
+
+    @Override
+    Object getRawChannel() {
+        throw new UnsupportedOperationException()
+    }
+
+    @Override
+    def decodeInputs( List values ) {
+        throw new UnsupportedOperationException()
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessInputsDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessInputsDef.groovy
new file mode 100644
index 0000000000..bd9d53a056
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessInputsDef.groovy
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+import groovyx.gpars.dataflow.DataflowReadChannel
+import nextflow.extension.CH
+
+/**
+ * Models the process `input` section, including the input
+ * declarations and staging directives.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessInputsDef implements Cloneable {
+
+    private List params = []
+
+    /**
+     * Environment variables which will be evaluated for each
+     * task against the task context and added to the task
+     * environment.
+     */
+    private Map env = [:]
+
+    /**
+     * Input files which will be evaluated for each task
+     * against the task context and staged into the task
+     * directory.
+     */
+    private List files = []
+
+    /**
+     * Lazy expression which will be evaluated for each task
+     * against the task context and provided as the standard
+     * input to the task.
+     */
+    Object stdin
+
+    void addParam(String name, Class type, boolean optional) {
+        params.add(new ProcessInput(name, type, optional))
+    }
+
+    void addTupleParam(List components, Class type) {
+        params.add(new ProcessTupleInput(components, type))
+    }
+
+    void addEnv(String name, Object value) {
+        env.put(name, value)
+    }
+
+    void addFile(ProcessFileInput file) {
+        files.add(file)
+    }
+
+    List getParams() {
+        return params
+    }
+
+    int size() {
+        return params.size()
+    }
+
+    ProcessInput getAt(int i) {
+        return params.get(i)
+    }
+
+    Map getEnv() {
+        return env
+    }
+
+    List getFiles() {
+        return files
+    }
+
+    List getChannels() {
+        final result = new ArrayList()
+        for( final param : params )
+            result.add(param.getChannel())
+        return result
+    }
+
+    boolean isSingleton() {
+        return params.every { param ->
+            !CH.isChannelQueue(param.getChannel())
+        }
+    }
+
+    @Override
+    ProcessInputsDef clone() {
+        final result = (ProcessInputsDef)super.clone()
+        result.params = new ArrayList<>(params.size())
+        for( final param : params ) {
+            result.params.add(param.clone())
+        }
+        return result
+    }
+
+}
\ No newline at end of file
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy
new file mode 100644
index 0000000000..73d2d50c8f
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutput.groovy
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+import groovyx.gpars.dataflow.DataflowWriteChannel
+import nextflow.script.params.OutParam
+
+/**
+ * Models a process output declaration.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessOutput implements OutParam {
+
+    /**
+     * Name of the output channel in the process outputs (i.e. `.out`).
+     */
+    private String name
+
+    /**
+     * Optional output type which is used to validate task outputs.
+     */
+    private Class type
+
+    /**
+     * Lazy expression (e.g. closure) which defines the output value
+     * in terms of the task context, including environment variables,
+     * files, and standard output.
+     * It will be evaluated for each task after it is executed. 
+     */
+    private Object value
+
+    /**
+     * Output channel which is created when the process is invoked
+     * in a workflow.
+     */
+    DataflowWriteChannel channel
+
+    ProcessOutput(String name, Class type, Object value) {
+        this.name = name
+        this.type = type
+        this.value = value
+    }
+
+    @Override
+    String getName() {
+        return name
+    }
+
+    Object getLazyValue() {
+        return value
+    }
+
+    @Override
+    ProcessOutput clone() {
+        (ProcessOutput)super.clone()
+    }
+
+    /// LEGACY METHODS
+
+    DataflowWriteChannel getOutChannel() {
+        return channel
+    }
+
+    short getIndex() {
+        throw new UnsupportedOperationException()
+    }
+
+    String getChannelEmitName() {
+        throw new UnsupportedOperationException()
+    }
+
+    String getChannelTopicName() {
+        throw new UnsupportedOperationException()
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutputsDef.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutputsDef.groovy
new file mode 100644
index 0000000000..ba2abc246f
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessOutputsDef.groovy
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+import groovyx.gpars.dataflow.DataflowWriteChannel
+
+/**
+ * Models the process outputs, including the `output`
+ * and `topic` sections.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessOutputsDef implements Cloneable {
+
+    private List params = []
+
+    private List topics = []
+
+    /**
+     * Environment variables which will be exported from the
+     * task environment for each task and made available to
+     * process outputs.
+     */
+    private Set env = []
+
+    /**
+     * Shell commands which will be executed in the task environment
+     * for each task and whose output will be made available
+     * to process outputs. The key corresponds to the environment
+     * variable to which the command output will be saved.
+     */
+    private Map eval = [:]
+
+    /**
+     * Output files which will be unstaged from the task
+     * directory for each task and made available to process
+     * outputs.
+     */
+    private Map files = [:]
+
+    void addParam(String name, Class type, Object value) {
+        params.add(new ProcessOutput(name, type, value))
+    }
+
+    void addTopic(Object value, String target) {
+        topics.add(new ProcessTopic(value, target))
+    }
+
+    void addEnv(String name) {
+        env.add(name)
+    }
+
+    void addEval(String name, Object value) {
+        eval.put(name, value)
+    }
+
+    void addFile(String key, ProcessFileOutput file) {
+        files.put(key, file)
+    }
+
+    List getParams() {
+        return params
+    }
+
+    int size() {
+        return params.size()
+    }
+
+    ProcessOutput getAt(int i) {
+        return params.get(i)
+    }
+
+    List getTopics() {
+        return topics
+    }
+
+    Set getEnv() {
+        return env
+    }
+
+    Map getEval() {
+        return eval
+    }
+
+    Map getFiles() {
+        return files
+    }
+
+    List getChannels() {
+        final result = new HashSet()
+        for( final param : params )
+            result.add(param.getOutChannel())
+        for( final topic : topics )
+            result.add(topic.getChannel())
+        return new ArrayList<>(result)
+    }
+
+    @Override
+    ProcessOutputsDef clone() {
+        final result = (ProcessOutputsDef)super.clone()
+        result.params = new ArrayList<>(params.size())
+        for( final param : params )
+            result.params.add(param.clone())
+        return result
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessTopic.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessTopic.groovy
new file mode 100644
index 0000000000..5c2c6adc2e
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessTopic.groovy
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+import groovyx.gpars.dataflow.DataflowWriteChannel
+import nextflow.script.params.OutParam
+
+/**
+ * Models a process topic emission.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessTopic {
+
+    /**
+     * Lazy expression (e.g. closure) which defines the output value
+     * in terms of the task context, including environment variables,
+     * files, and standard output.
+     * It will be evaluated for each task after it is executed. 
+     */
+    private Object value
+
+    /**
+     * Name of the target topic.
+     */
+    private String target
+
+    /**
+     * Topic channel which is bound when the process is invoked
+     * in a workflow.
+     */
+    DataflowWriteChannel channel
+
+    ProcessTopic(Object value, String target) {
+        this.value = value
+        this.target = target
+    }
+
+    Object getLazyValue() {
+        return value
+    }
+
+    String getTarget() {
+        return target
+    }
+
+    @Override
+    ProcessTopic clone() {
+        (ProcessTopic)super.clone()
+    }
+
+}
diff --git a/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessTupleInput.groovy b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessTupleInput.groovy
new file mode 100644
index 0000000000..78f9a47a37
--- /dev/null
+++ b/modules/nextflow/src/main/groovy/nextflow/script/params/v2/ProcessTupleInput.groovy
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import groovy.transform.CompileStatic
+import groovyx.gpars.dataflow.DataflowReadChannel
+
+/**
+ * Models a process tuple input, which declares a separate
+ * input for each tuple component.
+ *
+ * @author Ben Sherman 
+ */
+@CompileStatic
+class ProcessTupleInput extends ProcessInput {
+
+    private List components
+
+    ProcessTupleInput(List components, Class type) {
+        super("", type, false)
+        this.components = components
+    }
+
+    List getComponents() {
+        return components
+    }
+
+    @Override
+    ProcessTupleInput clone() {
+        (ProcessTupleInput)super.clone()
+    }
+
+    /// LEGACY METHODS
+
+    @Override
+    DataflowReadChannel getInChannel() {
+        throw new UnsupportedOperationException()
+    }
+
+    @Override
+    Object getRawChannel() {
+        throw new UnsupportedOperationException()
+    }
+
+    @Override
+    def decodeInputs( List values ) {
+        throw new UnsupportedOperationException()
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy
index 28b2c04cfb..b1ac5b4dc8 100644
--- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskConfigTest.groovy
@@ -23,6 +23,7 @@ import nextflow.exception.ProcessUnrecoverableException
 import nextflow.script.BaseScript
 import nextflow.script.ProcessConfig
 import nextflow.script.TaskClosure
+import nextflow.script.dsl.ProcessBuilder
 import nextflow.util.Duration
 import nextflow.util.MemoryUnit
 import spock.lang.Specification
@@ -96,12 +97,14 @@ class TaskConfigTest extends Specification {
     def testModules() {
         given:
         def config
+        def dsl
         def local
 
         when:
         config = new ProcessConfig([:])
-        config.module 't_coffee/10'
-        config.module( [ 'blast/2.2.1', 'clustalw/2'] )
+        dsl = new ProcessBuilder(config)
+        dsl.module 't_coffee/10'
+        dsl.module 'blast/2.2.1:clustalw/2'
         local = config.createTaskConfig()
         then:
         local.module == ['t_coffee/10', 'blast/2.2.1', 'clustalw/2']
@@ -110,8 +113,9 @@ class TaskConfigTest extends Specification {
 
         when:
         config = new ProcessConfig([:])
-        config.module 'a/1'
-        config.module 'b/2:c/3'
+        dsl = new ProcessBuilder(config)
+        dsl.module 'a/1'
+        dsl.module 'b/2:c/3'
         local = config.createTaskConfig()
         then:
         local.module == ['a/1','b/2','c/3']
@@ -119,9 +123,10 @@ class TaskConfigTest extends Specification {
 
         when:
         config = new ProcessConfig([:])
-        config.module { 'a/1' }
-        config.module { 'b/2:c/3' }
-        config.module 'd/4'
+        dsl = new ProcessBuilder(config)
+        dsl.module { 'a/1' }
+        dsl.module { 'b/2:c/3' }
+        dsl.module 'd/4'
         local = config.createTaskConfig()
         local.setContext([:])
         then:
@@ -130,7 +135,8 @@ class TaskConfigTest extends Specification {
 
         when:
         config = new ProcessConfig([:])
-        config.module = 'b/2:c/3'
+        dsl = new ProcessBuilder(config)
+        dsl.module 'b/2:c/3'
         local = config.createTaskConfig()
         then:
         local.module == ['b/2','c/3']
@@ -452,11 +458,13 @@ class TaskConfigTest extends Specification {
         setup:
         def script = Mock(BaseScript)
         ProcessConfig process
+        ProcessBuilder dsl
         PublishDir publish
 
         when:
         process = new ProcessConfig(script)
-        process.publishDir '/data'
+        dsl = new ProcessBuilder(process)
+        dsl.publishDir '/data'
         publish = process.createTaskConfig().getPublishDir()[0]
         then:
         publish.path == Paths.get('/data').complete()
@@ -466,7 +474,8 @@ class TaskConfigTest extends Specification {
 
         when:
         process = new ProcessConfig(script)
-        process.publishDir '/data', overwrite: false, mode: 'copy', pattern: '*.txt'
+        dsl = new ProcessBuilder(process)
+        dsl.publishDir '/data', overwrite: false, mode: 'copy', pattern: '*.txt'
         publish = process.createTaskConfig().getPublishDir()[0]
         then:
         publish.path == Paths.get('/data').complete()
@@ -476,7 +485,8 @@ class TaskConfigTest extends Specification {
 
         when:
         process = new ProcessConfig(script)
-        process.publishDir '/my/data', mode: 'copyNoFollow'
+        dsl = new ProcessBuilder(process)
+        dsl.publishDir '/my/data', mode: 'copyNoFollow'
         publish = process.createTaskConfig().getPublishDir()[0]
         then:
         publish.path == Paths.get('//my/data').complete()
@@ -484,8 +494,9 @@ class TaskConfigTest extends Specification {
 
         when:
         process = new ProcessConfig(script)
-        process.publishDir '/here'
-        process.publishDir '/there', pattern: '*.fq'
+        dsl = new ProcessBuilder(process)
+        dsl.publishDir '/here'
+        dsl.publishDir '/there', pattern: '*.fq'
         def dirs = process.createTaskConfig().getPublishDir()
         then:
         dirs.size() == 2 
@@ -537,8 +548,9 @@ class TaskConfigTest extends Specification {
 
         when:
         def process = new ProcessConfig(script)
-        process.pod secret: 'foo', mountPath: '/this'
-        process.pod secret: 'bar', env: 'BAR_XXX'
+        def dsl = new ProcessBuilder(process)
+        dsl.pod secret: 'foo', mountPath: '/this'
+        dsl.pod secret: 'bar', env: 'BAR_XXX'
         
         then:
         process.get('pod') == [
@@ -556,7 +568,8 @@ class TaskConfigTest extends Specification {
 
         when:
         def process = new ProcessConfig(script)
-        process.accelerator 5
+        def dsl = new ProcessBuilder(process)
+        dsl.accelerator 5
         def res = process.createTaskConfig().getAccelerator()
         then:
         res.limit == 5 
@@ -564,7 +577,8 @@ class TaskConfigTest extends Specification {
 
         when:
         process = new ProcessConfig(script)
-        process.accelerator 5, limit: 10, type: 'nvidia'
+        dsl = new ProcessBuilder(process)
+        dsl.accelerator 5, limit: 10, type: 'nvidia'
         res = process.createTaskConfig().getAccelerator()
         then:
         res.request == 5
@@ -578,8 +592,9 @@ class TaskConfigTest extends Specification {
 
         when:
         def process = new ProcessConfig(script)
-        process.secret 'alpha'
-        process.secret 'omega'
+        def dsl = new ProcessBuilder(process)
+        dsl.secret 'alpha'
+        dsl.secret 'omega'
 
         then:
         process.getSecret() == ['alpha', 'omega']
@@ -594,7 +609,8 @@ class TaskConfigTest extends Specification {
 
         when:
         def process = new ProcessConfig(script)
-        process.resourceLabels( region: 'eu-west-1', organization: 'A', user: 'this', team: 'that' )
+        def dsl = new ProcessBuilder(process)
+        dsl.resourceLabels( region: 'eu-west-1', organization: 'A', user: 'this', team: 'that' )
 
         then:
         process.get('resourceLabels') == [region: 'eu-west-1', organization: 'A', user: 'this', team: 'that']
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskEnvCollectorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskEnvCollectorTest.groovy
new file mode 100644
index 0000000000..43a8c30ee2
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskEnvCollectorTest.groovy
@@ -0,0 +1,75 @@
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.processor
+
+import nextflow.exception.ProcessEvalException
+import spock.lang.Specification
+import test.TestHelper
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class TaskEnvCollectorTest extends Specification {
+
+    def 'should parse env map' () {
+        given:
+        def workDir = TestHelper.createInMemTempDir()
+        def envFile = workDir.resolve(TaskRun.CMD_ENV)
+        envFile.text =  '''
+            ALPHA=one
+            /ALPHA/
+            DELTA=x=y
+            /DELTA/
+            OMEGA=
+            /OMEGA/
+            LONG=one
+            two
+            three
+            /LONG/=exit:0
+            '''.stripIndent()
+
+        when:
+        def result = new TaskEnvCollector(workDir, Map.of()).collect()
+        then:
+        result == [ALPHA:'one', DELTA: "x=y", OMEGA: '', LONG: 'one\ntwo\nthree']
+    }
+
+    def 'should parse env map with command error' () {
+        given:
+        def workDir = TestHelper.createInMemTempDir()
+        def envFile = workDir.resolve(TaskRun.CMD_ENV)
+        envFile.text =  '''
+            ALPHA=one
+            /ALPHA/
+            cmd_out_1=Hola
+            /cmd_out_1/=exit:0
+            cmd_out_2=This is an error message
+            for unknown reason
+            /cmd_out_2/=exit:100
+            '''.stripIndent()
+
+        when:
+        new TaskEnvCollector(workDir, [cmd_out_1: 'foo --this', cmd_out_2: 'bar --that']).collect()
+        then:
+        def e = thrown(ProcessEvalException)
+        e.message == 'Unable to evaluate output'
+        e.command == 'bar --that'
+        e.output == 'This is an error message\nfor unknown reason'
+        e.status == 100
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskFileCollectorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskFileCollectorTest.groovy
new file mode 100644
index 0000000000..a4fbe2c62c
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskFileCollectorTest.groovy
@@ -0,0 +1,233 @@
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.processor
+
+import java.nio.file.Files
+import java.nio.file.Path
+
+import nextflow.exception.MissingFileException
+import spock.lang.Specification
+import spock.lang.Unroll
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class TaskFileCollectorTest extends Specification {
+
+    def 'should filter staged inputs'() {
+
+        given:
+        def workDir = Path.of('/work/dir')
+        def task = Spy(new TaskRun(
+                config: new TaskConfig(),
+                workDir: workDir ))
+        def collector = new TaskFileCollector([], [:], task)
+
+        def FILE1 = workDir.resolve('alpha.txt')
+        def FILE2 = workDir.resolve('beta.txt')
+        def FILE3 = workDir.resolve('out/beta.txt')
+        def FILE4 = workDir.resolve('gamma.fasta')
+
+        when:
+        def result = collector.excludeStagedInputs([ FILE1, FILE2, FILE3, FILE4 ])
+        then:
+        1 * task.getStagedInputs() >> [ 'beta.txt' ]
+        and:
+        result == [ FILE1, FILE3, FILE4 ]
+
+    }
+
+
+    private List fetchResultFiles(Map opts=[:], String namePattern, Path folder) {
+        def collector = new TaskFileCollector([], opts, Mock(TaskRun))
+        return collector
+            .fetchResultFiles(namePattern, folder)
+            .collect { Path it -> folder.relativize(it).toString() }
+    }
+
+    def 'should return the list of output files'() {
+
+        given:
+        def folder = Files.createTempDirectory('test')
+        folder.resolve('file1.txt').text = 'file 1'
+        folder.resolve('file2.fa').text = 'file 2'
+        folder.resolve('.hidden.fa').text = 'hidden'
+        folder.resolve('dir1').mkdir()
+        folder.resolve('dir1').resolve('file3.txt').text = 'file 3'
+        folder.resolve('dir1')
+        folder.resolve('dir1').resolve('dir2').mkdirs()
+        folder.resolve('dir1').resolve('dir2').resolve('file4.fa').text = 'file '
+        Files.createSymbolicLink( folder.resolve('dir_link'), folder.resolve('dir1') )
+        and:
+        def result
+
+        when:
+        result = fetchResultFiles('*.fa', folder )
+        then:
+        result == ['file2.fa']
+
+        when:
+        result = fetchResultFiles('*.fa', folder, type: 'file')
+        then:
+        result == ['file2.fa']
+
+        when:
+        result = fetchResultFiles('*.fa', folder, type: 'dir')
+        then:
+        result == []
+
+        when:
+        result = fetchResultFiles('**.fa', folder)
+        then:
+        result == ['dir1/dir2/file4.fa', 'dir_link/dir2/file4.fa', 'file2.fa']
+
+        when:
+        result = fetchResultFiles('**.fa', folder, followLinks: false)
+        then:
+        result == ['dir1/dir2/file4.fa', 'file2.fa']
+
+        when:
+        result = fetchResultFiles('**.fa', folder, maxDepth: 1)
+        then:
+        result == ['file2.fa']
+
+        when:
+        result = fetchResultFiles('*', folder)
+        then:
+        result == ['dir1', 'dir_link', 'file1.txt', 'file2.fa']
+
+        when:
+        result = fetchResultFiles('*', folder, type: 'dir')
+        then:
+        result == ['dir1', 'dir_link']
+
+        when:
+        result = fetchResultFiles('*', folder, type: 'file')
+        then:
+        result == ['file1.txt', 'file2.fa']
+
+        when:
+        result = fetchResultFiles('*', folder, type: 'file', hidden: true)
+        then:
+        result == ['.hidden.fa', 'file1.txt', 'file2.fa']
+
+        when:
+        result = fetchResultFiles('.*', folder)
+        then:
+        result == ['.hidden.fa']
+
+        when:
+        result = fetchResultFiles('file{1,2}.{txt,fa}', folder)
+        then:
+        result == ['file1.txt', 'file2.fa']
+
+        cleanup:
+        folder?.deleteDir()
+
+    }
+
+    def defaultCollector(Map opts) {
+        return new TaskFileCollector([], opts, Mock(TaskRun))
+    }
+
+    def 'should create the map of path visit options'() {
+
+        given:
+        def collector
+
+        when:
+        collector = defaultCollector([:])
+        then:
+        collector.visitOptions('file.txt') == [type:'any', followLinks: true, maxDepth: null, hidden: false, relative: false]
+        collector.visitOptions('path/**') == [type:'file', followLinks: true, maxDepth: null, hidden: false, relative: false]
+        collector.visitOptions('.hidden_file') == [type:'any', followLinks: true, maxDepth: null, hidden: true, relative: false]
+
+        when:
+        collector = defaultCollector([type: 'dir'])
+        then:
+        collector.visitOptions('dir-name') == [type:'dir', followLinks: true, maxDepth: null, hidden: false, relative: false]
+
+        when:
+        collector = defaultCollector([hidden: true])
+        then:
+        collector.visitOptions('dir-name') == [type:'any', followLinks: true, maxDepth: null, hidden: true, relative: false]
+
+        when:
+        collector = defaultCollector([followLinks: false])
+        then:
+        collector.visitOptions('dir-name') == [type:'any', followLinks: false, maxDepth: null, hidden: false, relative: false]
+
+        when:
+        collector = defaultCollector([maxDepth: 5])
+        then:
+        collector.visitOptions('dir-name') == [type:'any', followLinks: true, maxDepth: 5, hidden: false, relative: false]
+    }
+
+    def 'should collect output files' () {
+        given:
+        def task = new TaskRun(
+                name: 'foo',
+                config: new TaskConfig(),
+                workDir: Path.of('/work') )
+        and:
+        def opts = [optional: OPTIONAL]
+        def collector = Spy(new TaskFileCollector([FILE_NAME], opts, task))
+
+        when:
+        def result = collector.collect()
+        then:
+        collector.fetchResultFiles(_,_) >> RESULTS
+        collector.checkFileExists(_) >> EXISTS
+        and:
+        result == EXPECTED
+
+        where:
+        FILE_NAME       | RESULTS                                   | EXISTS    | OPTIONAL  | EXPECTED
+        'file.txt'      | []                                        | true      | false     | [Path.of('/work/file.txt')]
+        '*'             | [Path.of('/work/file.txt')]               | true      | false     | [Path.of('/work/file.txt')]
+        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | true      | false     | [Path.of('/work/A'), Path.of('/work/B')]
+        '*'             | []                                        | true      | true      | []
+    }
+
+    @Unroll
+    def 'should report missing output file error' () {
+        given:
+        def task = new TaskRun(
+                name: 'foo',
+                config: new TaskConfig(),
+                workDir: Path.of('/work') )
+        and:
+        def opts = [optional: OPTIONAL]
+        def collector = Spy(new TaskFileCollector([FILE_NAME], opts, task))
+
+        when:
+        collector.collect()
+        then:
+        collector.fetchResultFiles(_,_) >> RESULTS
+        collector.checkFileExists(_) >> EXISTS
+        and:
+        def e = thrown(EXCEPTION)
+        e.message == ERROR
+
+        where:
+        FILE_NAME       | RESULTS                                   | EXISTS    | OPTIONAL  | EXCEPTION             | ERROR
+        'file.txt'      | null                                      | false     | false     | MissingFileException  | "Missing output file(s) `file.txt` expected by process `foo`"
+        '*'             | []                                        | true      | false     | MissingFileException  | "Missing output file(s) `*` expected by process `foo`"
+
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskInputsResolverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskInputsResolverTest.groovy
new file mode 100644
index 0000000000..3d175a5215
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskInputsResolverTest.groovy
@@ -0,0 +1,303 @@
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.processor
+
+import java.nio.file.FileSystems
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+
+import nextflow.Global
+import nextflow.ISession
+import nextflow.exception.ProcessUnrecoverableException
+import nextflow.executor.Executor
+import nextflow.file.FileHolder
+import nextflow.file.FilePorter
+import nextflow.script.ScriptType
+import nextflow.util.ArrayBag
+import spock.lang.Specification
+import spock.lang.Unroll
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class TaskInputResolverTest extends Specification {
+
+
+    def 'should return single item or collection'() {
+
+        setup:
+        def path1 = Paths.get('file1')
+        def path2 = Paths.get('file2')
+        def path3 = Paths.get('file3')
+
+        when:
+        def list = [ FileHolder.get(path1, 'x_file_1') ]
+        def result = TaskInputResolver.singleItemOrList(list, true, ScriptType.SCRIPTLET)
+        then:
+        result.toString() == 'x_file_1'
+
+        when:
+        list = [ FileHolder.get(path1, 'x_file_1') ]
+        result = TaskInputResolver.singleItemOrList(list, false, ScriptType.SCRIPTLET)
+        then:
+        result*.toString() == ['x_file_1']
+
+        when:
+        list = [ FileHolder.get(path1, 'x_file_1'), FileHolder.get(path2, 'x_file_2'), FileHolder.get(path3, 'x_file_3') ]
+        result = TaskInputResolver.singleItemOrList(list, false, ScriptType.SCRIPTLET)
+        then:
+        result*.toString() == [ 'x_file_1',  'x_file_2',  'x_file_3']
+
+    }
+
+
+    def 'should expand wildcards'() {
+
+        /*
+         * The name do not contain any wildcards *BUT* when multiple files are provide
+         * an index number is added to the specified name
+         */
+        when:
+        def list1 = TaskInputResolver.expandWildcards('file_name', [FileHolder.get('x')])
+        def list2 = TaskInputResolver.expandWildcards('file_name', [FileHolder.get('x'), FileHolder.get('y')] )
+        then:
+        list1 *. stageName  == ['file_name']
+        list2 *. stageName  == ['file_name1', 'file_name2']
+
+
+        /*
+         * The star wildcard: when a single item is provided, it is simply ignored
+         * When a collection of files is provided, the name is expanded to the index number
+         */
+        when:
+        list1 = TaskInputResolver.expandWildcards('file*.fa', [FileHolder.get('x')])
+        list2 = TaskInputResolver.expandWildcards('file_*.fa', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
+        then:
+        list1 instanceof ArrayBag
+        list2 instanceof ArrayBag
+        list1 *. stageName == ['file.fa']
+        list2 *. stageName == ['file_1.fa', 'file_2.fa', 'file_3.fa']
+
+        /*
+         * The question mark wildcards *always* expand to an index number
+         */
+        when:
+        def p0 = [FileHolder.get('0')]
+        def p1_p4 = (1..4).collect { FileHolder.get(it.toString()) }
+        def p1_p12 = (1..12).collect { FileHolder.get(it.toString()) }
+        list1 = TaskInputResolver.expandWildcards('file?.fa', p0 )
+        list2 = TaskInputResolver.expandWildcards('file_???.fa', p1_p4 )
+        def list3 = TaskInputResolver.expandWildcards('file_?.fa', p1_p12 )
+        then:
+        list1 instanceof ArrayBag
+        list2 instanceof ArrayBag
+        list3 instanceof ArrayBag
+        list1 *. stageName == ['file1.fa']
+        list2 *. stageName == ['file_001.fa', 'file_002.fa', 'file_003.fa', 'file_004.fa']
+        list3 *. stageName == ['file_1.fa', 'file_2.fa', 'file_3.fa', 'file_4.fa', 'file_5.fa', 'file_6.fa', 'file_7.fa', 'file_8.fa', 'file_9.fa', 'file_10.fa', 'file_11.fa', 'file_12.fa']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('*', [FileHolder.get('a')])
+        list2 = TaskInputResolver.expandWildcards('*', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
+        then:
+        list1 instanceof ArrayBag
+        list2 instanceof ArrayBag
+        list1 *. stageName == ['a']
+        list2 *. stageName == ['x','y','z']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('dir1/*', [FileHolder.get('a')])
+        list2 = TaskInputResolver.expandWildcards('dir2/*', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
+        then:
+        list1 instanceof ArrayBag
+        list2 instanceof ArrayBag
+        list1 *. stageName == ['dir1/a']
+        list2 *. stageName == ['dir2/x','dir2/y','dir2/z']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('/dir/file*.fa', [FileHolder.get('x')])
+        list2 = TaskInputResolver.expandWildcards('dir/file_*.fa', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
+        then:
+        list1 instanceof ArrayBag
+        list2 instanceof ArrayBag
+        list1 *. stageName == ['dir/file.fa']
+        list2 *. stageName == ['dir/file_1.fa', 'dir/file_2.fa', 'dir/file_3.fa']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('dir/*', [FileHolder.get('file.fa')])
+        list2 = TaskInputResolver.expandWildcards('dir/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
+        then:
+        list1 *. stageName == ['dir/file.fa']
+        list2 *. stageName == ['dir/titi.fa', 'dir/toto.fa']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('dir/*/*', [FileHolder.get('file.fa')])
+        list2 = TaskInputResolver.expandWildcards('dir/*/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
+        then:
+        list1 *. stageName == ['dir/1/file.fa']
+        list2 *. stageName == ['dir/1/titi.fa', 'dir/2/toto.fa']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('dir/foo*/*', [FileHolder.get('file.fa')])
+        list2 = TaskInputResolver.expandWildcards('dir/foo*/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
+        then:
+        list1 *. stageName == ['dir/foo1/file.fa']
+        list2 *. stageName == ['dir/foo1/titi.fa', 'dir/foo2/toto.fa']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('dir/??/*', [FileHolder.get('file.fa')])
+        list2 = TaskInputResolver.expandWildcards('dir/??/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
+        then:
+        list1 *. stageName == ['dir/01/file.fa']
+        list2 *. stageName == ['dir/01/titi.fa', 'dir/02/toto.fa']
+
+        when:
+        list1 = TaskInputResolver.expandWildcards('dir/bar??/*', [FileHolder.get('file.fa')])
+        list2 = TaskInputResolver.expandWildcards('dir/bar??/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
+        then:
+        list1 *. stageName == ['dir/bar01/file.fa']
+        list2 *. stageName == ['dir/bar01/titi.fa', 'dir/bar02/toto.fa']
+    }
+
+    @Unroll
+    def 'should expand wildcards rule' () {
+
+        expect:
+        TaskInputResolver.expandWildcards0(pattern, 'stage-name.txt', index, size ) == expected
+
+        where:
+        pattern             | index | size  | expected
+        // just wildcard
+        '*'                 | 1     | 1     | 'stage-name.txt'
+        '*'                 | 1     | 10    | 'stage-name.txt'
+        // wildcard on the file name and single item in the collection
+        'foo.txt'           | 1     | 1     | 'foo.txt'
+        'foo*.fa'           | 1     | 1     | 'foo.fa'
+        'foo?.fa'           | 1     | 1     | 'foo1.fa'
+        'foo??.fa'          | 1     | 1     | 'foo01.fa'
+        // wildcard on the file name and many items in the collection
+        'foo*.fa'           | 1     | 3     | 'foo1.fa'
+        'foo*.fa'           | 3     | 3     | 'foo3.fa'
+        'foo?.fa'           | 1     | 3     | 'foo1.fa'
+        'foo?.fa'           | 3     | 3     | 'foo3.fa'
+        'foo??.fa'          | 1     | 3     | 'foo01.fa'
+        'foo??.fa'          | 3     | 3     | 'foo03.fa'
+        // wildcard on parent path
+        'dir/*/foo.txt'     | 1     | 1     | 'dir/1/foo.txt'
+        'dir/foo*/bar.txt'  | 1     | 1     | 'dir/foo1/bar.txt'
+        'dir/foo?/bar.txt'  | 2     | 2     | 'dir/foo2/bar.txt'
+        'dir/foo??/bar.txt' | 2     | 2     | 'dir/foo02/bar.txt'
+        // wildcard on parent path and name
+        'dir/*/'            | 1     | 1     | 'dir/1/stage-name.txt'
+        'dir/*/*'           | 1     | 1     | 'dir/1/stage-name.txt'
+        'dir/*/*'           | 1     | 10    | 'dir/1/stage-name.txt'
+        'dir/*/foo*.txt'    | 1     | 1     | 'dir/1/foo.txt'
+        'dir/*/foo*.txt'    | 1     | 2     | 'dir/1/foo1.txt'
+        'dir/*/foo?.txt'    | 2     | 2     | 'dir/2/foo2.txt'
+        'dir/???/foo?.txt'  | 5     | 10    | 'dir/005/foo5.txt'
+    }
+
+    @Unroll
+    def 'should replace question marks' () {
+        expect:
+        TaskInputResolver.replaceQuestionMarkWildcards(pattern, index) == expected
+
+        where:
+        pattern         | index | expected
+        'foo.txt'       | 1     | 'foo.txt'
+        'foo?.txt'      | 1     | 'foo1.txt'
+        'foo???.txt'    | 2     | 'foo002.txt'
+        'foo?_???.txt'  | 3     | 'foo3_003.txt'
+        'foo??.txt'     | 9999  | 'foo9999.txt'
+
+    }
+
+    def "should return a file holder" () {
+
+        given:
+        FileHolder holder
+        def tempFolder = Files.createTempDirectory('test')
+        def localFile = Files.createTempFile(tempFolder, 'test','test')
+        Global.session = Mock(ISession)
+        Global.session.workDir >> tempFolder
+
+        /*
+         * when the input file is on the local file system
+         * simple return a reference to it in the holder object
+         */
+        when:
+        holder = TaskInputResolver.normalizeInputToFile(localFile,null)
+        then:
+        holder.sourceObj == localFile
+        holder.storePath == localFile.toRealPath()
+        holder.stageName == localFile.getFileName().toString()
+
+        /*
+         * any generic input that is not a file is converted to a string
+         * and save to the local file system
+         */
+        when:
+        holder = TaskInputResolver.normalizeInputToFile("text data string",'simple_file_name.txt')
+        then:
+        holder.sourceObj == "text data string"
+        holder.storePath.fileSystem == FileSystems.default
+        holder.storePath.text == "text data string"
+        holder.stageName == 'simple_file_name.txt'
+
+        cleanup:
+        tempFolder?.deleteDir()
+    }
+
+    def 'should normalise to path' () {
+        expect:
+        TaskInputResolver.normalizeToPath('/foo/bar') == '/foo/bar' as Path
+        and:
+        TaskInputResolver.normalizeToPath('file:///foo/bar') == '/foo/bar' as Path
+        and:
+        TaskInputResolver.normalizeToPath(Paths.get('foo.txt')) == Paths.get('foo.txt')
+
+        when:
+        TaskInputResolver.normalizeToPath('abc')
+        then:
+        thrown(ProcessUnrecoverableException)
+
+        when:
+        TaskInputResolver.normalizeToPath(null)
+        then:
+        thrown(ProcessUnrecoverableException)
+    }
+
+    def 'should normalize files' () {
+        given:
+        def batch = Mock(FilePorter.Batch)
+        def executor = Mock(Executor)
+        def PATH = Paths.get('/some/path')
+        def resolver = new TaskInputResolver(Mock(TaskRun), batch, executor)
+
+        when:
+        def result = resolver.normalizeInputToFiles(PATH.toString(), 0, true)
+        then:
+        1 * executor.isForeignFile(PATH) >> false
+        0 * batch.addToForeign(PATH) >> null
+        and:
+        result.size() == 1
+        result[0] == new FileHolder(PATH)
+
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskOutputResolverTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskOutputResolverTest.groovy
new file mode 100644
index 0000000000..ca95899d72
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskOutputResolverTest.groovy
@@ -0,0 +1,293 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.processor
+
+import java.nio.file.Path
+
+import nextflow.exception.IllegalArityException
+import nextflow.exception.MissingFileException
+import nextflow.exception.MissingValueException
+import nextflow.script.params.v2.ProcessFileOutput
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * @author Ben Sherman 
+ */
+class TaskOutputResolverTest extends Specification {
+
+    @TempDir
+    Path tempDir
+
+    def makeTask(Map holder = [:]) {
+        return Spy(new TaskRun(
+            name: 'test_task',
+            workDir: tempDir,
+            config: new TaskConfig(),
+            context: new TaskContext(holder: holder)
+        ))
+    }
+
+    def makeFileOutput(String pattern) {
+        return Mock(ProcessFileOutput) {
+            getFilePattern(_) >> pattern
+        }
+    }
+
+    def 'should get environment variable'() {
+        given:
+        def task = makeTask()
+        task.getOutputEvals() >> [:]
+        and:
+        def resolver = new TaskOutputResolver([:], task)
+        and:
+        def envFile = tempDir.resolve(TaskRun.CMD_ENV)
+        envFile.text = '''
+            FOO=bar
+            /FOO/
+            BAZ=qux
+            /BAZ/
+            '''.stripIndent()
+
+        when:
+        def result = resolver._env('FOO')
+        then:
+        result == 'bar'
+
+        when:
+        resolver._env('MISSING')
+        then:
+        thrown(MissingValueException)
+    }
+
+    def 'should get eval result'() {
+        given:
+        def task = makeTask()
+        task.getOutputEvals() >> [RESULT: 'echo hello']
+        and:
+        def resolver = new TaskOutputResolver([:], task)
+        and:
+        def envFile = tempDir.resolve(TaskRun.CMD_ENV)
+        envFile.text = 'RESULT=hello\n'
+
+        when:
+        def result = resolver.eval('RESULT')
+        then:
+        result == 'hello'
+
+        when:
+        resolver.eval('MISSING')
+        then:
+        thrown(MissingValueException)
+    }
+
+    def 'should get single file'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('output.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+        and:
+        def outputFile = tempDir.resolve('output.txt')
+        outputFile.text = ''
+
+        when:
+        def result = resolver._file('out')
+        then:
+        result == outputFile
+        task.outputFiles == [outputFile].toSet()
+    }
+
+    def 'should fail when file pattern is absolute path'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('/absolute/path/output.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+
+        when:
+        resolver._file('out')
+        then:
+        def e = thrown(IllegalArgumentException)
+        e.message.contains('is an absolute path')
+    }
+
+    def 'should fail when single file yields no matches'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('missing.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+
+        when:
+        resolver._file('out')
+        then:
+        def e = thrown(MissingFileException)
+        e.message.contains('Missing output file(s) `missing.txt`')
+    }
+
+    def 'should return null when optional single file yields no matches'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('missing.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+
+        when:
+        def result = resolver._file([optional: true], 'out')
+        then:
+        result == null
+    }
+
+    def 'should fail when single file yields multiple matches'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('*.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+        and:
+        tempDir.resolve('file1.txt').text = ''
+        tempDir.resolve('file2.txt').text = ''
+
+        when:
+        resolver._file('out')
+        then:
+        def e = thrown(IllegalArityException)
+        e.message.contains('yielded 2 files but expected only one')
+    }
+
+    def 'should get multiple files'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('*.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+        and:
+        def file1 = tempDir.resolve('file1.txt')
+        def file2 = tempDir.resolve('file2.txt')
+        file1.text = ''
+        file2.text = ''
+
+        when:
+        def result = resolver._files('out')
+        then:
+        result == [file1, file2] as Set
+        task.outputFiles.containsAll(result)
+    }
+
+    def 'should fail when files pattern is absolute path'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('/absolute/path/*.txt')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+
+        when:
+        resolver._files('out')
+        then:
+        def e = thrown(IllegalArgumentException)
+        e.message.contains('is an absolute path')
+    }
+
+    def 'should fail when files glob yields no matches'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('*.missing')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+
+        when:
+        resolver._files('out')
+        then:
+        def e = thrown(MissingFileException)
+        e.message.contains('Missing output file(s) `*.missing`')
+    }
+
+    def 'should return empty set when optional files glob yields no matches'() {
+        given:
+        def declaredFiles = [out: makeFileOutput('*.missing')]
+        def task = makeTask()
+        def resolver = new TaskOutputResolver(declaredFiles, task)
+
+        when:
+        def result = resolver._files([optional: true], 'out')
+        then:
+        result == [] as Set
+    }
+
+    def 'should get stdout from file'() {
+        given:
+        def stdoutFile = tempDir.resolve('.command.out')
+        stdoutFile.text = 'hello world'
+        and:
+        def task = makeTask()
+        task.stdout = stdoutFile
+        def resolver = new TaskOutputResolver([:], task)
+
+        expect:
+        resolver.stdout() == 'hello world'
+    }
+
+    def 'should get stdout from text'() {
+        given:
+        def task = makeTask()
+        task.stdout = 'direct value'
+        def resolver = new TaskOutputResolver([:], task)
+
+        expect:
+        resolver.stdout() == 'direct value'
+    }
+
+    def 'should fail when stdout is missing'() {
+        given:
+        def task = makeTask()
+        task.stdout = null
+        def resolver = new TaskOutputResolver([:], task)
+
+        when:
+        resolver.stdout()
+        then:
+        thrown(IllegalArgumentException)
+    }
+
+    def 'should fail when stdout file does not exist'() {
+        given:
+        def missingFile = tempDir.resolve('missing.out')
+        def task = makeTask()
+        task.stdout = missingFile
+        def resolver = new TaskOutputResolver([:], task)
+
+        when:
+        resolver.stdout()
+        then:
+        thrown(Exception)
+    }
+
+    def 'should get variable from context'() {
+        given:
+        def task = makeTask([foo: 'bar', baz: 123])
+        def resolver = new TaskOutputResolver([:], task)
+
+        expect:
+        resolver.get('foo') == 'bar'
+        resolver.get('baz') == 123
+    }
+
+    def 'should fail when variable is missing from context'() {
+        given:
+        def task = makeTask([foo: 'bar'])
+        def resolver = new TaskOutputResolver([:], task)
+
+        when:
+        resolver.get('missing')
+        then:
+        def e = thrown(MissingValueException)
+        e.message.contains('Missing variable in process output')
+    }
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy
index 500ff8d72c..0176f66b4a 100644
--- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskProcessorTest.groovy
@@ -16,7 +16,6 @@
 
 package nextflow.processor
 
-import java.nio.file.FileSystems
 import java.nio.file.Files
 import java.nio.file.Path
 import java.nio.file.Paths
@@ -24,12 +23,8 @@ import java.util.concurrent.ExecutorService
 
 import com.google.common.hash.HashCode
 import groovyx.gpars.agent.Agent
-import nextflow.Global
-import nextflow.ISession
 import nextflow.Session
 import nextflow.exception.IllegalArityException
-import nextflow.exception.MissingFileException
-import nextflow.exception.ProcessEvalException
 import nextflow.exception.ProcessException
 import nextflow.exception.ProcessUnrecoverableException
 import nextflow.executor.Executor
@@ -39,65 +34,33 @@ import nextflow.file.FilePorter
 import nextflow.script.BaseScript
 import nextflow.script.BodyDef
 import nextflow.script.ProcessConfig
+import nextflow.script.ProcessConfigV1
 import nextflow.script.ScriptType
 import nextflow.script.bundle.ResourcesBundle
 import nextflow.script.params.FileInParam
 import nextflow.script.params.FileOutParam
-import nextflow.util.ArrayBag
 import nextflow.util.CacheHelper
 import nextflow.util.MemoryUnit
 import spock.lang.Specification
 import spock.lang.Unroll
-import test.TestHelper
 /**
  *
  * @author Paolo Di Tommaso 
  */
 class TaskProcessorTest extends Specification {
 
-    static class DummyProcessor extends TaskProcessor {
-
-        DummyProcessor(String name, Session session, BaseScript script, ProcessConfig taskConfig) {
-            super(name, new NopeExecutor(session: session), session, script, taskConfig, new BodyDef({}, '..'))
-        }
-
-        @Override protected void createOperator() { }
+    def createProcessor(String name, Session session) {
+        return new DummyProcessor(name, session, Mock(BaseScript), new ProcessConfig([:]))
     }
 
+    static class DummyProcessor extends TaskProcessor {
 
-    def 'should filter hidden files'() {
-
-        setup:
-        def processor = [:] as TaskProcessor
-        def list = [ Paths.get('file.txt'), Paths.get('.hidden'), Paths.get('file.name') ]
-
-        when:
-        def result = processor.filterByRemovingHiddenFiles(list)
-        then:
-        result == [ Paths.get('file.txt'), Paths.get('file.name')  ]
-
-    }
-
-    def 'should filter staged inputs'() {
-
-        given:
-        def task = Spy(TaskRun)
-        def processor = [:] as TaskProcessor
-
-        def WORK_DIR = Paths.get('/work/dir')
-        def FILE1 = WORK_DIR.resolve('alpha.txt')
-        def FILE2 = WORK_DIR.resolve('beta.txt')
-        def FILE3 = WORK_DIR.resolve('out/beta.txt')
-        def FILE4 = WORK_DIR.resolve('gamma.fasta')
-
-        def collectedFiles = [ FILE1, FILE2, FILE3, FILE4 ]
-
-        when:
-        def result = processor.filterByRemovingStagedInputs(task, collectedFiles, WORK_DIR)
-        then:
-        1 * task.getStagedInputs() >> [ 'beta.txt' ]
-        result == [ FILE1, FILE3, FILE4 ]
+        DummyProcessor(String name, Session session, BaseScript script, ProcessConfig config) {
+            super(name, new NopeExecutor(session: session), session, script, config, new BodyDef({}, '..'))
+        }
 
+        @Override
+        protected void createOperator() { }
     }
 
 
@@ -111,7 +74,7 @@ class TaskProcessorTest extends Specification {
         when:
         def session = new Session([env: [X:"1", Y:"2"]])
         session.setBaseDir(home)
-        def processor = new DummyProcessor('task1', session, Mock(BaseScript), Mock(ProcessConfig))
+        def processor = createProcessor('task1', session)
         def builder = new ProcessBuilder()
         builder.environment().putAll( processor.getProcessEnvironment() )
         then:
@@ -123,7 +86,7 @@ class TaskProcessorTest extends Specification {
         when:
         session = new Session([env: [X:"1", Y:"2", PATH:'/some']])
         session.setBaseDir(home)
-        processor = new DummyProcessor('task1', session,  Mock(BaseScript), Mock(ProcessConfig))
+        processor = createProcessor('task1', session)
         builder = new ProcessBuilder()
         builder.environment().putAll( processor.getProcessEnvironment() )
         then:
@@ -180,205 +143,6 @@ class TaskProcessorTest extends Specification {
         i == null
     }
 
-    def 'should return single item or collection'() {
-
-        setup:
-        def processor = [:] as TaskProcessor
-        def path1 = Paths.get('file1')
-        def path2 = Paths.get('file2')
-        def path3 = Paths.get('file3')
-
-        when:
-        def list = [ FileHolder.get(path1, 'x_file_1') ]
-        def result = processor.singleItemOrList(list, true, ScriptType.SCRIPTLET)
-        then:
-        result.toString() == 'x_file_1'
-
-        when:
-        list = [ FileHolder.get(path1, 'x_file_1') ]
-        result = processor.singleItemOrList(list, false, ScriptType.SCRIPTLET)
-        then:
-        result*.toString() == ['x_file_1']
-
-        when:
-        list = [ FileHolder.get(path1, 'x_file_1'), FileHolder.get(path2, 'x_file_2'), FileHolder.get(path3, 'x_file_3') ]
-        result = processor.singleItemOrList(list, false, ScriptType.SCRIPTLET)
-        then:
-        result*.toString() == [ 'x_file_1',  'x_file_2',  'x_file_3']
-
-    }
-
-
-    def 'should expand wildcards'() {
-
-        setup:
-        def processor = [:] as TaskProcessor
-
-        /*
-         * The name do not contain any wildcards *BUT* when multiple files are provide
-         * an index number is added to the specified name
-         */
-        when:
-        def list1 = processor.expandWildcards('file_name', [FileHolder.get('x')])
-        def list2 = processor.expandWildcards('file_name', [FileHolder.get('x'), FileHolder.get('y')] )
-        then:
-        list1 *. stageName  == ['file_name']
-        list2 *. stageName  == ['file_name1', 'file_name2']
-
-
-        /*
-         * The star wildcard: when a single item is provided, it is simply ignored
-         * When a collection of files is provided, the name is expanded to the index number
-         */
-        when:
-        list1 = processor.expandWildcards('file*.fa', [FileHolder.get('x')])
-        list2 = processor.expandWildcards('file_*.fa', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
-        then:
-        list1 instanceof ArrayBag
-        list2 instanceof ArrayBag
-        list1 *. stageName == ['file.fa']
-        list2 *. stageName == ['file_1.fa', 'file_2.fa', 'file_3.fa']
-
-        /*
-         * The question mark wildcards *always* expand to an index number
-         */
-        when:
-        def p0 = [FileHolder.get('0')]
-        def p1_p4 = (1..4).collect { FileHolder.get(it.toString()) }
-        def p1_p12 = (1..12).collect { FileHolder.get(it.toString()) }
-        list1 = processor.expandWildcards('file?.fa', p0 )
-        list2 = processor.expandWildcards('file_???.fa', p1_p4 )
-        def list3 = processor.expandWildcards('file_?.fa', p1_p12 )
-        then:
-        list1 instanceof ArrayBag
-        list2 instanceof ArrayBag
-        list3 instanceof ArrayBag
-        list1 *. stageName == ['file1.fa']
-        list2 *. stageName == ['file_001.fa', 'file_002.fa', 'file_003.fa', 'file_004.fa']
-        list3 *. stageName == ['file_1.fa', 'file_2.fa', 'file_3.fa', 'file_4.fa', 'file_5.fa', 'file_6.fa', 'file_7.fa', 'file_8.fa', 'file_9.fa', 'file_10.fa', 'file_11.fa', 'file_12.fa']
-
-        when:
-        list1 = processor.expandWildcards('*', [FileHolder.get('a')])
-        list2 = processor.expandWildcards('*', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
-        then:
-        list1 instanceof ArrayBag
-        list2 instanceof ArrayBag
-        list1 *. stageName == ['a']
-        list2 *. stageName == ['x','y','z']
-
-        when:
-        list1 = processor.expandWildcards('dir1/*', [FileHolder.get('a')])
-        list2 = processor.expandWildcards('dir2/*', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
-        then:
-        list1 instanceof ArrayBag
-        list2 instanceof ArrayBag
-        list1 *. stageName == ['dir1/a']
-        list2 *. stageName == ['dir2/x','dir2/y','dir2/z']
-
-        when:
-        list1 = processor.expandWildcards('/dir/file*.fa', [FileHolder.get('x')])
-        list2 = processor.expandWildcards('dir/file_*.fa', [FileHolder.get('x'), FileHolder.get('y'), FileHolder.get('z')])
-        then:
-        list1 instanceof ArrayBag
-        list2 instanceof ArrayBag
-        list1 *. stageName == ['dir/file.fa']
-        list2 *. stageName == ['dir/file_1.fa', 'dir/file_2.fa', 'dir/file_3.fa']
-
-        when:
-        list1 = processor.expandWildcards('dir/*', [FileHolder.get('file.fa')])
-        list2 = processor.expandWildcards('dir/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
-        then:
-        list1 *. stageName == ['dir/file.fa']
-        list2 *. stageName == ['dir/titi.fa', 'dir/toto.fa']
-
-        when:
-        list1 = processor.expandWildcards('dir/*/*', [FileHolder.get('file.fa')])
-        list2 = processor.expandWildcards('dir/*/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
-        then:
-        list1 *. stageName == ['dir/1/file.fa']
-        list2 *. stageName == ['dir/1/titi.fa', 'dir/2/toto.fa']
-
-        when:
-        list1 = processor.expandWildcards('dir/foo*/*', [FileHolder.get('file.fa')])
-        list2 = processor.expandWildcards('dir/foo*/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
-        then:
-        list1 *. stageName == ['dir/foo1/file.fa']
-        list2 *. stageName == ['dir/foo1/titi.fa', 'dir/foo2/toto.fa']
-
-        when:
-        list1 = processor.expandWildcards('dir/??/*', [FileHolder.get('file.fa')])
-        list2 = processor.expandWildcards('dir/??/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
-        then:
-        list1 *. stageName == ['dir/01/file.fa']
-        list2 *. stageName == ['dir/01/titi.fa', 'dir/02/toto.fa']
-
-        when:
-        list1 = processor.expandWildcards('dir/bar??/*', [FileHolder.get('file.fa')])
-        list2 = processor.expandWildcards('dir/bar??/*', [FileHolder.get('titi.fa'), FileHolder.get('file.fq', 'toto.fa')])
-        then:
-        list1 *. stageName == ['dir/bar01/file.fa']
-        list2 *. stageName == ['dir/bar01/titi.fa', 'dir/bar02/toto.fa']
-    }
-
-    @Unroll
-    def 'should expand wildcards rule' () {
-
-        given:
-        def processor = [:] as TaskProcessor
-
-        expect:
-        processor.expandWildcards0(pattern, 'stage-name.txt', index, size ) == expected
-
-        where:
-        pattern             | index | size  | expected
-        // just wildcard
-        '*'                 | 1     | 1     | 'stage-name.txt'
-        '*'                 | 1     | 10    | 'stage-name.txt'
-        // wildcard on the file name and single item in the collection
-        'foo.txt'           | 1     | 1     | 'foo.txt'
-        'foo*.fa'           | 1     | 1     | 'foo.fa'
-        'foo?.fa'           | 1     | 1     | 'foo1.fa'
-        'foo??.fa'          | 1     | 1     | 'foo01.fa'
-        // wildcard on the file name and many items in the collection
-        'foo*.fa'           | 1     | 3     | 'foo1.fa'
-        'foo*.fa'           | 3     | 3     | 'foo3.fa'
-        'foo?.fa'           | 1     | 3     | 'foo1.fa'
-        'foo?.fa'           | 3     | 3     | 'foo3.fa'
-        'foo??.fa'          | 1     | 3     | 'foo01.fa'
-        'foo??.fa'          | 3     | 3     | 'foo03.fa'
-        // wildcard on parent path
-        'dir/*/foo.txt'     | 1     | 1     | 'dir/1/foo.txt'
-        'dir/foo*/bar.txt'  | 1     | 1     | 'dir/foo1/bar.txt'
-        'dir/foo?/bar.txt'  | 2     | 2     | 'dir/foo2/bar.txt'
-        'dir/foo??/bar.txt' | 2     | 2     | 'dir/foo02/bar.txt'
-        // wildcard on parent path and name
-        'dir/*/'            | 1     | 1     | 'dir/1/stage-name.txt'
-        'dir/*/*'           | 1     | 1     | 'dir/1/stage-name.txt'
-        'dir/*/*'           | 1     | 10    | 'dir/1/stage-name.txt'
-        'dir/*/foo*.txt'    | 1     | 1     | 'dir/1/foo.txt'
-        'dir/*/foo*.txt'    | 1     | 2     | 'dir/1/foo1.txt'
-        'dir/*/foo?.txt'    | 2     | 2     | 'dir/2/foo2.txt'
-        'dir/???/foo?.txt'  | 5     | 10    | 'dir/005/foo5.txt'
-    }
-
-    @Unroll
-    def 'should replace question marks' () {
-        given:
-        def processor = [:] as TaskProcessor
-
-        expect:
-        processor.replaceQuestionMarkWildcards(pattern, index) == expected
-
-        where:
-        pattern         | index | expected
-        'foo.txt'       | 1     | 'foo.txt'
-        'foo?.txt'      | 1     | 'foo1.txt'
-        'foo???.txt'    | 2     | 'foo002.txt'
-        'foo?_???.txt'  | 3     | 'foo3_003.txt'
-        'foo??.txt'     | 9999  | 'foo9999.txt'
-
-    }
-
     def 'should stage path'() {
 
         when:
@@ -441,45 +205,6 @@ class TaskProcessorTest extends Specification {
 
     }
 
-
-    def "should return a file holder" () {
-
-        given:
-        FileHolder holder
-        def tempFolder = Files.createTempDirectory('test')
-        def localFile = Files.createTempFile(tempFolder, 'test','test')
-        Global.session = Mock(ISession)
-        Global.session.workDir >> tempFolder
-        def processor = [:] as TaskProcessor
-
-        /*
-         * when the input file is on the local file system
-         * simple return a reference to it in the holder object
-         */
-        when:
-        holder = processor.normalizeInputToFile(localFile,null)
-        then:
-        holder.sourceObj == localFile
-        holder.storePath == localFile.toRealPath()
-        holder.stageName == localFile.getFileName().toString()
-
-        /*
-         * any generic input that is not a file is converted to a string
-         * and save to the local file system
-         */
-        when:
-        holder = processor.normalizeInputToFile("text data string",'simple_file_name.txt')
-        then:
-        holder.sourceObj == "text data string"
-        holder.storePath.fileSystem == FileSystems.default
-        holder.storePath.text == "text data string"
-        holder.stageName == 'simple_file_name.txt'
-
-        cleanup:
-        tempFolder?.deleteDir()
-    }
-
-
     def 'should return `ignore` strategy' () {
 
         given:
@@ -576,153 +301,6 @@ class TaskProcessorTest extends Specification {
     }
 
 
-    private List fetchResultFiles(TaskProcessor processor, FileOutParam param, String namePattern, Path folder ) {
-        processor
-                .fetchResultFiles(param, namePattern, folder)
-                .collect { Path it -> folder.relativize(it).toString() }
-    }
-
-    def 'should return the list of output files'() {
-
-        given:
-        def param
-        def result
-
-        def folder = Files.createTempDirectory('test')
-        folder.resolve('file1.txt').text = 'file 1'
-        folder.resolve('file2.fa').text = 'file 2'
-        folder.resolve('.hidden.fa').text = 'hidden'
-        folder.resolve('dir1').mkdir()
-        folder.resolve('dir1').resolve('file3.txt').text = 'file 3'
-        folder.resolve('dir1')
-        folder.resolve('dir1').resolve('dir2').mkdirs()
-        folder.resolve('dir1').resolve('dir2').resolve('file4.fa').text = 'file '
-        Files.createSymbolicLink( folder.resolve('dir_link'), folder.resolve('dir1') )
-
-        def processor = [:] as TaskProcessor
-
-        when:
-        result = fetchResultFiles(processor, Mock(FileOutParam), '*.fa', folder )
-        then:
-        result == ['file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setType('file')
-        result = fetchResultFiles(processor, param, '*.fa', folder)
-        then:
-        result  == ['file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setType('dir')
-        result = fetchResultFiles(processor, param, '*.fa', folder)
-        then:
-        result == []
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        result = fetchResultFiles(processor, param, '**.fa', folder)
-        then:
-        result == ['dir1/dir2/file4.fa', 'dir_link/dir2/file4.fa', 'file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setFollowLinks(false)
-        result = fetchResultFiles(processor, param, '**.fa', folder)
-        then:
-        result == ['dir1/dir2/file4.fa', 'file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setMaxDepth(1)
-        result = fetchResultFiles(processor, param, '**.fa', folder)
-        then:
-        result == ['file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        result = fetchResultFiles(processor, param, '*', folder)
-        then:
-        result == ['dir1', 'dir_link', 'file1.txt', 'file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setType('dir')
-        result = fetchResultFiles(processor, param, '*', folder)
-        then:
-        result == ['dir1', 'dir_link']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setType('file')
-        result = fetchResultFiles(processor, param, '*', folder)
-        then:
-        result == ['file1.txt', 'file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setType('file')
-        param.setHidden(true)
-        result = fetchResultFiles(processor, param, '*', folder)
-        then:
-        result == ['.hidden.fa', 'file1.txt', 'file2.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        result = fetchResultFiles(processor, param,'.*', folder)
-        then:
-        result == ['.hidden.fa']
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        result = fetchResultFiles(processor, param,'file{1,2}.{txt,fa}', folder)
-        then:
-        result == ['file1.txt', 'file2.fa']
-
-        cleanup:
-        folder?.deleteDir()
-
-    }
-
-    def 'should create the map of path visit options'() {
-
-        given:
-        def param
-        def processor = [:] as TaskProcessor
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        then:
-        processor.visitOptions(param,'file.txt') == [type:'any', followLinks: true, maxDepth: null, hidden: false, relative: false]
-        processor.visitOptions(param,'path/**') == [type:'file', followLinks: true, maxDepth: null, hidden: false, relative: false]
-        processor.visitOptions(param,'.hidden_file') == [type:'any', followLinks: true, maxDepth: null, hidden: true, relative: false]
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setType('dir')
-        then:
-        processor.visitOptions(param,'dir-name') == [type:'dir', followLinks: true, maxDepth: null, hidden: false, relative: false]
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setHidden(true)
-        then:
-        processor.visitOptions(param,'dir-name') == [type:'any', followLinks: true, maxDepth: null, hidden: true, relative: false]
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setFollowLinks(false)
-        then:
-        processor.visitOptions(param,'dir-name') == [type:'any', followLinks: false, maxDepth: null, hidden: false, relative: false]
-
-        when:
-        param = new FileOutParam(Mock(Binding), Mock(List))
-        param.setMaxDepth(5)
-        then:
-        processor.visitOptions(param,'dir-name') == [type:'any', followLinks: true, maxDepth: 5, hidden: false, relative: false]
-    }
-
     def 'should get bin files in the script command' () {
 
         given:
@@ -809,45 +387,6 @@ class TaskProcessorTest extends Specification {
 
     }
 
-    def 'should normalise to path' () {
-        given:
-        def proc = new TaskProcessor()
-
-        expect:
-        proc.normalizeToPath('/foo/bar') == '/foo/bar' as Path
-        and:
-        proc.normalizeToPath('file:///foo/bar') == '/foo/bar' as Path
-        and:
-        proc.normalizeToPath(Paths.get('foo.txt')) == Paths.get('foo.txt')
-
-        when:
-        proc.normalizeToPath('abc')
-        then:
-        thrown(ProcessUnrecoverableException)
-
-        when:
-        proc.normalizeToPath(null)
-        then:
-        thrown(ProcessUnrecoverableException)
-    }
-
-    def 'should normalize files' () {
-        given:
-        def batch = Mock(FilePorter.Batch)
-        def executor = Mock(Executor)
-        def PATH = Paths.get('/some/path')
-        def proc = new TaskProcessor(); proc.executor = executor
-
-        when:
-        def result = proc.normalizeInputToFiles(PATH.toString(), 0, true, batch)
-        then:
-        1 * executor.isForeignFile(PATH) >> false
-        0 * batch.addToForeign(PATH) >> null
-        result.size() == 1
-        result[0] == new FileHolder(PATH)
-
-    }
-
     def 'should get task directive vars' () {
         given:
         def processor = Spy(TaskProcessor)
@@ -882,112 +421,57 @@ class TaskProcessorTest extends Specification {
         processor.@config = Mock(ProcessConfig)
         processor.@isFair0 = true
         and:
-        def emission3 = new HashMap()
         def task3 = Mock(TaskRun) { getIndex()>>3 }
         and:
-        def emission2 = new HashMap()
         def task2 = Mock(TaskRun) { getIndex()>>2 }
         and:
-        def emission1 = new HashMap()
         def task1 = Mock(TaskRun) { getIndex()>>1 }
         and:
-        def emission5 = new HashMap()
         def task5 = Mock(TaskRun) { getIndex()>>5 }
         and:
-        def emission4 = new HashMap()
         def task4 = Mock(TaskRun) { getIndex()>>4 }
 
         when:
-        processor.fairBindOutputs0(emission3, task3)
+        processor.fairBindOutputs0(task3)
         then:
-        processor.@fairBuffers[2] == emission3
+        processor.@fairBuffers[2] == task3
         0 * processor.bindOutputs0(_)
 
         when:
-        processor.fairBindOutputs0(emission2, task2)
+        processor.fairBindOutputs0(task2)
         then:
-        processor.@fairBuffers[1] == emission2
+        processor.@fairBuffers[1] == task2
         0 * processor.bindOutputs0(_)
 
         when:
-        processor.fairBindOutputs0(emission5, task5)
+        processor.fairBindOutputs0(task5)
         then:
-        processor.@fairBuffers[4] == emission5
+        processor.@fairBuffers[4] == task5
         0 * processor.bindOutputs0(_)
 
         when:
-        processor.fairBindOutputs0(emission1, task1)
+        processor.fairBindOutputs0(task1)
         then:
-        1 * processor.bindOutputs0(emission1)
+        1 * processor.bindOutputs0(task1)
         then:
-        1 * processor.bindOutputs0(emission2)
+        1 * processor.bindOutputs0(task2)
         then:
-        1 * processor.bindOutputs0(emission3)
+        1 * processor.bindOutputs0(task3)
         and:
         processor.@fairBuffers.size() == 2 
         processor.@fairBuffers[0] == null
-        processor.@fairBuffers[1] == emission5
+        processor.@fairBuffers[1] == task5
 
         when:
-        processor.fairBindOutputs0(emission4, task4)
+        processor.fairBindOutputs0(task4)
         then:
-        1 * processor.bindOutputs0(emission4)
+        1 * processor.bindOutputs0(task4)
         then:
-        1 * processor.bindOutputs0(emission5)
+        1 * processor.bindOutputs0(task5)
         then:
         processor.@fairBuffers.size()==0
     }
 
-    def 'should parse env map' () {
-        given:
-        def workDir = TestHelper.createInMemTempDir()
-        def envFile = workDir.resolve(TaskRun.CMD_ENV)
-        envFile.text =  '''
-                        ALPHA=one
-                        /ALPHA/
-                        DELTA=x=y
-                        /DELTA/
-                        OMEGA=
-                        /OMEGA/
-                        LONG=one
-                        two
-                        three
-                        /LONG/=exit:0
-                        '''.stripIndent()
-        and:
-        def processor = Spy(TaskProcessor)
-
-        when:
-        def result = processor.collectOutEnvMap(workDir, Map.of())
-        then:
-        result == [ALPHA:'one', DELTA: "x=y", OMEGA: '', LONG: 'one\ntwo\nthree']
-    }
-
-    def 'should parse env map with command error' () {
-        given:
-        def workDir = TestHelper.createInMemTempDir()
-        def envFile = workDir.resolve(TaskRun.CMD_ENV)
-        envFile.text =  '''
-                        ALPHA=one
-                        /ALPHA/
-                        cmd_out_1=Hola
-                        /cmd_out_1/=exit:0
-                        cmd_out_2=This is an error message
-                        for unknown reason
-                        /cmd_out_2/=exit:100
-                        '''.stripIndent()
-        and:
-        def processor = Spy(TaskProcessor)
-
-        when:
-        processor.collectOutEnvMap(workDir, [cmd_out_1: 'foo --this', cmd_out_2: 'bar --that'])
-        then:
-        def e = thrown(ProcessEvalException)
-        e.message == 'Unable to evaluate output'
-        e.command == 'bar --that'
-        e.output == 'This is an error message\nfor unknown reason'
-        e.status == 100
-    }
     def 'should create a task preview' () {
         given:
         def config = new ProcessConfig([cpus: 10, memory: '100 GB'])
@@ -1005,11 +489,14 @@ class TaskProcessorTest extends Specification {
     }
 
     @Unroll
-    def 'should validate inputs arity' () {
+    def 'should apply input file arity' () {
         given:
         def executor = Mock(Executor)
-        def session = Mock(Session) {getFilePorter()>>Mock(FilePorter) }
-        def processor = Spy(new TaskProcessor(session:session, executor:executor))
+        executor.isForeignFile(_) >> false
+        def session = Mock(Session)
+        def config = new ProcessConfigV1(Mock(BaseScript), null)
+        def processor = Spy(new TaskProcessor(session:session, executor:executor, config:config))
+        def foreignFiles = Mock(FilePorter.Batch)
         and:
         def context = new TaskContext(holder: new HashMap())
         def task = new TaskRun(
@@ -1019,13 +506,15 @@ class TaskProcessorTest extends Specification {
                 config: new TaskConfig())
 
         when:
-        def param = new FileInParam(new Binding(), [])
+        def param = new FileInParam(config)
                 .setPathQualifier(true)
-                .bind(FILE_NAME) as FileInParam
+                .bind(FILE_NAME)
         if( ARITY )
             param.setArity(ARITY)
+        and:
+        task.setInput(param)
 
-        processor.makeTaskContextStage2(task, [(param):FILE_VALUE], 0 )
+        processor.resolveTaskInputs(task, [FILE_VALUE], foreignFiles )
         then:
         context.get(FILE_NAME) == EXPECTED
 
@@ -1047,11 +536,14 @@ class TaskProcessorTest extends Specification {
         'f*'            | ['/some/file1.txt', '/some/file2.txt']    | '1..*'   | [Path.of('/some/file1.txt'), Path.of('/some/file2.txt')]
     }
 
-    def 'should throw an arity error' () {
+    def 'should report input file arity error' () {
         given:
         def executor = Mock(Executor)
-        def session = Mock(Session) {getFilePorter()>>Mock(FilePorter) }
-        def processor = Spy(new TaskProcessor(session:session, executor:executor))
+        executor.isForeignFile(_) >> false
+        def session = Mock(Session)
+        def config = new ProcessConfigV1(Mock(BaseScript), null)
+        def processor = Spy(new TaskProcessor(session:session, executor:executor, config:config))
+        def foreignFiles = Mock(FilePorter.Batch)
         and:
         def context = new TaskContext(holder: new HashMap())
         def task = new TaskRun(
@@ -1061,13 +553,15 @@ class TaskProcessorTest extends Specification {
                 config: new TaskConfig())
 
         when:
-        def param = new FileInParam(new Binding(), [])
+        def param = new FileInParam(config)
                 .setPathQualifier(true)
-                .bind(FILE_NAME) as FileInParam
+                .bind(FILE_NAME)
         if( ARITY )
             param.setArity(ARITY)
+        and:
+        task.setInput(param)
 
-        processor.makeTaskContextStage2(task, [(param):FILE_VALUE], 0 )
+        processor.resolveTaskInputs(task, [FILE_VALUE], foreignFiles)
         then:
         def e = thrown(IllegalArityException)
         e.message == ERROR
@@ -1082,7 +576,7 @@ class TaskProcessorTest extends Specification {
         'f*'            | ['/a','/b']                               | '3'       | 'Incorrect number of input files for process `foo` -- expected 3, found 2'
     }
 
-    def 'should validate collect output files' () {
+    def 'should collect output files' () {
         given:
         def executor = Mock(Executor)
         def session = Mock(Session) {getFilePorter()>>Mock(FilePorter) }
@@ -1105,33 +599,32 @@ class TaskProcessorTest extends Specification {
         if( ARITY )
             param.setArity(ARITY)
         and:
-        processor.collectOutFiles(task, param, workDir, context)
+        processor.collectOutFiles(task, param, workDir)
         then:
-        processor.fetchResultFiles(_,_,_) >> RESULTS
-        processor.checkFileExists(_,_) >> EXISTS
+        processor.collectOutFiles0(_,_,_) >> RESULTS
         and:
         task.getOutputs().get(param) == EXPECTED
 
         where:
-        FILE_NAME       | RESULTS                                   | EXISTS    | OPTIONAL  | ARITY         | EXPECTED
-        'file.txt'      | null                                      | true      | false     | null          | Path.of('/work/file.txt')
-        '*'             | [Path.of('/work/file.txt')]               | true      | false     | null          | Path.of('/work/file.txt')
-        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | true      | false     | null          | [Path.of('/work/A'), Path.of('/work/B')]
-        '*'             | []                                        | true      | true      | null          | []
+        FILE_NAME       | RESULTS                                   | OPTIONAL  | ARITY         | EXPECTED
+        'file.txt'      | [Path.of('/work/file.txt')]               | false     | null          | Path.of('/work/file.txt')
+        '*'             | [Path.of('/work/file.txt')]               | false     | null          | Path.of('/work/file.txt')
+        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | false     | null          | [Path.of('/work/A'), Path.of('/work/B')]
+        '*'             | []                                        | true      | null          | []
         and:
-        'file.txt'      | null                                      | true      | false     | '1'           | Path.of('/work/file.txt')
-        '*'             | [Path.of('/work/file.txt')]               | true      | false     | '1'           | Path.of('/work/file.txt')
-        '*'             | [Path.of('/work/file.txt')]               | true      | false     | '1..*'        | [Path.of('/work/file.txt')]
-        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | true      | false     | '2'           | [Path.of('/work/A'), Path.of('/work/B')]
-        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | true      | false     | '1..*'        | [Path.of('/work/A'), Path.of('/work/B')]
-        '*'             | []                                        | true      | false     | '0..*'        | []
+        'file.txt'      | [Path.of('/work/file.txt')]               | false     | '1'           | Path.of('/work/file.txt')
+        '*'             | [Path.of('/work/file.txt')]               | false     | '1'           | Path.of('/work/file.txt')
+        '*'             | [Path.of('/work/file.txt')]               | false     | '1..*'        | [Path.of('/work/file.txt')]
+        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | false     | '2'           | [Path.of('/work/A'), Path.of('/work/B')]
+        '*'             | [Path.of('/work/A'), Path.of('/work/B')]  | false     | '1..*'        | [Path.of('/work/A'), Path.of('/work/B')]
+        '*'             | []                                        | false     | '0..*'        | []
     }
 
     @Unroll
-    def 'should report output error' () {
+    def 'should report output file arity error' () {
         given:
         def executor = Mock(Executor)
-        def session = Mock(Session) {getFilePorter()>>Mock(FilePorter) }
+        def session = Mock(Session)
         def processor = Spy(new TaskProcessor(session:session, executor:executor))
         and:
         def context = new TaskContext(holder: new HashMap())
@@ -1151,23 +644,19 @@ class TaskProcessorTest extends Specification {
         if( ARITY )
             param.setArity(ARITY)
         and:
-        processor.collectOutFiles(task, param, workDir, context)
+        processor.collectOutFiles(task, param, workDir)
         then:
-        processor.fetchResultFiles(_,_,_) >> RESULTS
-        processor.checkFileExists(_,_) >> EXISTS
+        processor.collectOutFiles0(_,_,_) >> RESULTS
         and:
         def e = thrown(EXCEPTION)
         e.message == ERROR
 
         where:
-        FILE_NAME       | RESULTS                                   | EXISTS    | OPTIONAL  | ARITY         | EXCEPTION             | ERROR
-        'file.txt'      | null                                      | false     | false     | null          | MissingFileException  | "Missing output file(s) `file.txt` expected by process `foo`"
-        '*'             | []                                        | true      | false     | null          | MissingFileException  | "Missing output file(s) `*` expected by process `foo`"
-        and:
-        'file.txt'      | null                                      | true      | false     | '2'           | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 2, found 1"
-        '*'             | [Path.of('/work/file.txt')]               | true      | false     | '2'           | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 2, found 1"
-        '*'             | [Path.of('/work/file.txt')]               | true      | false     | '2..*'        | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 2..*, found 1"
-        '*'             | []                                        | true      | true      | '1..*'        | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 1..*, found 0"
+        FILE_NAME       | RESULTS                                   | OPTIONAL  | ARITY         | EXCEPTION             | ERROR
+        'file.txt'      | [Path.of('/work/file.txt')]               | false     | '2'           | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 2, found 1"
+        '*'             | [Path.of('/work/file.txt')]               | false     | '2'           | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 2, found 1"
+        '*'             | [Path.of('/work/file.txt')]               | false     | '2..*'        | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 2..*, found 1"
+        '*'             | []                                        | true      | '1..*'        | IllegalArityException | "Incorrect number of output files for process `foo` -- expected 1..*, found 0"
 
     }
 
@@ -1177,13 +666,13 @@ class TaskProcessorTest extends Specification {
         def proc = Spy(new TaskProcessor(executor: exec))
         and:
         def task = Mock(TaskRun)
-        def hash = Mock(HashCode)
-        def path = Mock(Path)
+        def hash = HashCode.fromString('0123456789abcdef')
+        def workDir = Path.of('/work')
 
         when:
-        proc.submitTask(task, hash, path)
+        proc.submitTask(task, hash, workDir)
         then:
-        1 * proc.makeTaskContextStage3(task, hash, path) >> null
+        task.getConfig() >> new TaskConfig()
         and:
         1 * exec.submit(task)
     }
@@ -1195,25 +684,21 @@ class TaskProcessorTest extends Specification {
         def proc = Spy(new TaskProcessor(executor: exec, arrayCollector: collector))
         and:
         def task = Mock(TaskRun)
-        def hash = Mock(HashCode)
-        def path = Mock(Path)
+        def hash = HashCode.fromString('0123456789abcdef')
+        def workDir = Path.of('/work')
 
         when:
-        proc.submitTask(task, hash, path)
+        proc.submitTask(task, hash, workDir)
         then:
-        task.getConfig()>>Mock(TaskConfig) { getAttempt()>>1 }
-        and:
-        1 * proc.makeTaskContextStage3(task, hash, path) >> null
+        task.getConfig() >> new TaskConfig()
         and:
         1 * collector.collect(task)
         0 * exec.submit(task)
 
         when:
-        proc.submitTask(task, hash, path)
+        proc.submitTask(task, hash, workDir)
         then:
-        task.getConfig()>>Mock(TaskConfig) { getAttempt()>>2 }
-        and:
-        1 * proc.makeTaskContextStage3(task, hash, path) >> null
+        task.getConfig() >> new TaskConfig(attempt: 2)
         and:
         0 * collector.collect(task)
         1 * exec.submit(task)
@@ -1222,10 +707,7 @@ class TaskProcessorTest extends Specification {
     def 'should compute eval outputs content deterministically'() {
 
         setup:
-        def session = Mock(Session)
-        def script = Mock(BaseScript)
-        def config = Mock(ProcessConfig)
-        def processor = new DummyProcessor('test', session, script, config)
+        def processor = createProcessor('test', Mock(Session))
 
         when:
         def result1 = processor.computeEvalOutputsContent([
diff --git a/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy b/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy
index 6311db47b5..788088cdfd 100644
--- a/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/processor/TaskRunTest.groovy
@@ -58,35 +58,6 @@ class TaskRunTest extends Specification {
         new Session()
     }
 
-    def testGetInputsByType() {
-
-        setup:
-        def binding = new Binding('x': 1, 'y': 2)
-        def task = new TaskRun()
-        def list = []
-
-        task.setInput( new StdInParam(binding,list) )
-        task.setInput( new FileInParam(binding, list).bind(new TokenVar('x')), 'file1' )
-        task.setInput( new FileInParam(binding, list).bind(new TokenVar('y')), 'file2' )
-        task.setInput( new EnvInParam(binding, list).bind('z'), 'env' )
-
-
-        when:
-        def files = task.getInputsByType(FileInParam)
-        then:
-        files.size() == 2
-
-        files.keySet()[0] instanceof FileInParam
-        files.keySet()[1] instanceof FileInParam
-
-        files.keySet()[0].name == 'x'
-        files.keySet()[1].name == 'y'
-
-        files.values()[0] == 'file1'
-        files.values()[1] == 'file2'
-
-    }
-
     def testGetOutputsByType() {
 
         setup:
@@ -124,9 +95,11 @@ class TaskRunTest extends Specification {
 
         def x = new ValueInParam(binding, list).bind( new TokenVar('x') )
         def y = new FileInParam(binding, list).bind('y')
+        def y_files = [ new FileHolder(Paths.get('file_y_1')) ]
 
         task.setInput(x, 1)
-        task.setInput(y, [ new FileHolder(Paths.get('file_y_1')) ])
+        task.setInput(y, y_files)
+        task.inputFiles.addAll(y_files)
 
         expect:
         task.getInputFiles().size() == 1
@@ -144,9 +117,15 @@ class TaskRunTest extends Specification {
         def y = new FileInParam(binding, list).bind('y')
         def z = new FileInParam(binding, list).bind('z')
 
+        def y_files = [ new FileHolder(Paths.get('file_y_1')).withName('foo.txt') ]
+        def z_files = [ new FileHolder(Paths.get('file_y_2')).withName('bar.txt') ]
+
         task.setInput(x, 1)
-        task.setInput(y, [ new FileHolder(Paths.get('file_y_1')).withName('foo.txt') ])
-        task.setInput(z, [ new FileHolder(Paths.get('file_y_2')).withName('bar.txt') ])
+        task.setInput(y, y_files)
+        task.setInput(z, z_files)
+
+        task.inputFiles.addAll(y_files)
+        task.inputFiles.addAll(z_files)
 
         expect:
         task.getInputFilesMap() == ['foo.txt': Paths.get('file_y_1'), 'bar.txt': Paths.get('file_y_2')]
@@ -159,6 +138,7 @@ class TaskRunTest extends Specification {
         setup:
         def binding = new Binding()
         def task = new TaskRun()
+        task.processor = Mock(TaskProcessor)
         def list = []
 
         when:
@@ -641,7 +621,8 @@ class TaskRunTest extends Specification {
 
         given:
         def EXPECT = [FOO: 'hola', BAR: 'mundo', OMEGA: 'ooo',_OPTS:'any']
-        def task = Spy(TaskRun);
+        def task = Spy(TaskRun)
+        task.inputEnv = [BAR: 'mundo', OMEGA: 'ooo', _OPTS: 'any']
         def proc = Mock(TaskProcessor)
 
         when:
@@ -649,7 +630,6 @@ class TaskRunTest extends Specification {
         then:
         1 * task.getProcessor() >> proc
         1 * proc.getProcessEnvironment() >> [FOO: 'hola', BAR: 'world']
-        1 * task.getInputEnvironment() >> [BAR: 'mundo', OMEGA: 'ooo', _OPTS: 'any']
         env ==  EXPECT   // note: `BAR` in the process config should be overridden by `BAR` in the task input
         str(env) == str(EXPECT)
     }
@@ -692,6 +672,7 @@ class TaskRunTest extends Specification {
         def env1 = new EnvOutParam(new Binding(),[]).bind(new TokenVar('FOO'))
         def env2 = new EnvOutParam(new Binding(),[]).bind(new TokenVar('BAR'))
         def task = new TaskRun()
+        task.processor = Mock(TaskProcessor)
         task.outputs.put(env1, null)
         task.outputs.put(env2, null)
 
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ProcessConfigTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ProcessConfigTest.groovy
index 59dd323c7f..81a0811bad 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/ProcessConfigTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/ProcessConfigTest.groovy
@@ -16,20 +16,11 @@
 
 package nextflow.script
 
-import java.nio.file.Files
-
-import nextflow.scm.ProviderConfig
 import spock.lang.Specification
 import spock.lang.Unroll
 
-import nextflow.exception.IllegalDirectiveException
 import nextflow.processor.ErrorStrategy
-import nextflow.script.params.FileInParam
-import nextflow.script.params.StdInParam
-import nextflow.script.params.StdOutParam
-import nextflow.script.params.ValueInParam
 import nextflow.util.Duration
-import nextflow.util.MemoryUnit
 import static nextflow.util.CacheHelper.HashMode
 /**
  *
@@ -64,45 +55,12 @@ class ProcessConfigTest extends Specification {
         then:
         config.tag == 'val 1'
 
-        // setting list values
-        when:
-        config.tag 1,2,3
-        then:
-        config.tag == [1,2,3]
-
-        // setting named parameters attribute
-        when:
-        config.tag field1:'val1', field2: 'val2'
-        then:
-        config.tag == [field1:'val1', field2: 'val2']
-
         // generic value assigned like a 'plain' property
         when:
         config.tag = 99
         then:
         config.tag == 99
 
-        // maxDuration property
-        when:
-        config.time '1h'
-        then:
-        config.time == '1h'
-        config.createTaskConfig().time == new Duration('1h')
-
-        // maxMemory property
-        when:
-        config.memory '2GB'
-        then:
-        config.memory == '2GB'
-        config.createTaskConfig().memory == new MemoryUnit('2GB')
-
-        when:
-        config.stageInMode 'copy'
-        config.stageOutMode 'move'
-        then:
-        config.stageInMode == 'copy'
-        config.stageOutMode == 'move'
-
     }
 
     @Unroll
@@ -145,16 +103,6 @@ class ProcessConfigTest extends Specification {
 
     }
 
-    def 'should throw MissingPropertyException' () {
-        when:
-        def script = Mock(BaseScript)
-        def config = new ProcessConfig(script).throwExceptionOnMissingProperty(true)
-        def x = config.hola
-
-        then:
-        thrown(MissingPropertyException)
-    }
-
 
     def 'should check property existence' () {
 
@@ -171,59 +119,6 @@ class ProcessConfigTest extends Specification {
 
     }
 
-    def 'should create input directives' () {
-
-        setup:
-        def script = Mock(BaseScript)
-        def config = new ProcessConfig(script)
-
-        when:
-        config._in_file([infile:'filename.fa'])
-        config._in_val('x').setFrom(1)
-        config._in_stdin()
-
-        then:
-        config.getInputs().size() == 3
-
-        config.inputs.get(0) instanceof FileInParam
-        config.inputs.get(0).name == 'infile'
-        (config.inputs.get(0) as FileInParam).filePattern == 'filename.fa'
-
-        config.inputs.get(1) instanceof ValueInParam
-        config.inputs.get(1).name == 'x'
-
-        config.inputs.get(2).name == '-'
-        config.inputs.get(2) instanceof StdInParam
-
-        config.inputs.names == [ 'infile', 'x', '-' ]
-        config.inputs.ofType( FileInParam ) == [ config.getInputs().get(0) ]
-
-    }
-
-    def 'should create output directives' () {
-
-        setup:
-        def script = Mock(BaseScript)
-        def config = new ProcessConfig(script)
-
-        when:
-        config._out_stdout()
-        config._out_file(new TokenVar('file1')).setInto('ch1')
-        config._out_file(new TokenVar('file2')).setInto('ch2')
-        config._out_file(new TokenVar('file3')).setInto('ch3')
-
-        then:
-        config.outputs.size() == 4
-        config.outputs.names == ['-', 'file1', 'file2', 'file3']
-        config.outputs.ofType(StdOutParam).size() == 1
-
-        config.outputs[0] instanceof StdOutParam
-        config.outputs[1].name == 'file1'
-        config.outputs[2].name == 'file2'
-        config.outputs[3].name == 'file3'
-
-    }
-
 
     def 'should set cache attribute'() {
 
@@ -273,552 +168,4 @@ class ProcessConfigTest extends Specification {
 
     }
 
-    def 'should create PublishDir object' () {
-
-        setup:
-        BaseScript script = Mock(BaseScript)
-        ProcessConfig config
-
-        when:
-        config = new ProcessConfig(script)
-        config.publishDir '/data'
-        then:
-        config.get('publishDir')[0] == [path:'/data']
-
-        when:
-        config = new ProcessConfig(script)
-        config.publishDir '/data', mode: 'link', pattern: '*.bam'
-        then:
-        config.get('publishDir')[0] == [path: '/data', mode: 'link', pattern: '*.bam']
-
-        when:
-        config = new ProcessConfig(script)
-        config.publishDir path: '/data', mode: 'link', pattern: '*.bam'
-        then:
-        config.get('publishDir')[0] == [path: '/data', mode: 'link', pattern: '*.bam']
-    }
-
-    def 'should throw InvalidDirectiveException'() {
-
-        given:
-        def script = Mock(BaseScript)
-        def config = new ProcessConfig(script)
-
-        when:
-        config.hello 'world'
-
-        then:
-        def e = thrown(IllegalDirectiveException)
-        e.message ==
-                '''
-                Unknown process directive: `hello`
-
-                Did you mean of these?
-                        shell
-                '''
-                .stripIndent().trim()
-    }
-
-    def 'should set process secret'() {
-        when:
-        def config = new ProcessConfig([:])
-        then:
-        config.getSecret() == []
-
-        when:
-        config.secret('foo')
-        then:
-        config.getSecret() == ['foo']
-
-        when:
-        config.secret('bar')
-        then:
-        config.secret == ['foo', 'bar']
-        config.getSecret() == ['foo', 'bar']
-    }
-
-    def 'should set process labels'() {
-        when:
-        def config = new ProcessConfig([:])
-        then:
-        config.getLabels() == []
-
-        when:
-        config.label('foo')
-        then:
-        config.getLabels() == ['foo']
-
-        when:
-        config.label('bar')
-        then:
-        config.getLabels() == ['foo','bar']
-    }
-
-    def 'should apply resource labels config' () {
-        given:
-        def config = new ProcessConfig(Mock(BaseScript))
-        expect:
-        config.getResourceLabels() == [:]
-
-        when:
-        config.resourceLabels([foo: 'one', bar: 'two'])
-        then:
-        config.getResourceLabels() == [foo: 'one', bar: 'two']
-
-        when:
-        config.resourceLabels([foo: 'new one', baz: 'three'])
-        then:
-        config.getResourceLabels() == [foo: 'new one', bar: 'two', baz: 'three']
-
-    }
-
-    def 'should check a valid label' () {
-
-        expect:
-        new ProcessConfig([:]).isValidLabel(lbl) == result
-
-        where:
-        lbl         | result
-        'foo'       | true
-        'foo1'      | true
-        '1foo'      | false
-        '_foo'      | false
-        'foo1_'     | false
-        'foo_1'     | true
-        'foo-1'     | false
-        'foo.1'     | false
-        'a'         | true
-        'A'         | true
-        '1'         | false
-        '_'         | false
-        'a=b'       | true
-        'a=foo'     | true
-        'a=foo_1'   | true
-        'a=foo_'    | false
-        '_=foo'     | false
-        '=a'        | false
-        'a='        | false
-        'a=1'       | false
-
-    }
-
-    @Unroll
-    def 'should match selector: #SELECTOR with #TARGET' () {
-        expect:
-        ProcessConfig.matchesSelector(TARGET, SELECTOR) == EXPECTED
-
-        where:
-        SELECTOR        | TARGET    | EXPECTED
-        'foo'           | 'foo'     | true
-        'foo'           | 'bar'     | false
-        '!foo'          | 'bar'     | true
-        'a|b'           | 'a'       | true
-        'a|b'           | 'b'       | true
-        'a|b'           | 'z'       | false
-        'a*'            | 'a'       | true
-        'a*'            | 'aaaa'    | true
-        'a*'            | 'bbbb'    | false
-    }
-
-    def 'should apply config setting for a process label' () {
-        given:
-        def settings = [
-                'withLabel:short'  : [ cpus: 1, time: '1h'],
-                'withLabel:!short' : [ cpus: 32, queue: 'cn-long'],
-                'withLabel:foo'    : [ cpus: 2 ],
-                'withLabel:foo|bar': [ disk: '100GB' ],
-                'withLabel:gpu.+'  : [ cpus: 4 ],
-        ]
-
-        when:
-        def process = new ProcessConfig([:])
-        process.applyConfigSelectorWithLabels(settings, ['short'])
-        then:
-        process.cpus == 1
-        process.time == '1h'
-        process.size() == 2
-
-        when:
-        process = new ProcessConfig([:])
-        process.applyConfigSelectorWithLabels(settings, ['long'])
-        then:
-        process.cpus == 32
-        process.queue == 'cn-long'
-        process.size() == 2
-
-        when:
-        process = new ProcessConfig([:])
-        process.applyConfigSelectorWithLabels(settings, ['foo'])
-        then:
-        process.cpus == 2
-        process.disk == '100GB'
-        process.queue == 'cn-long'
-        process.size() == 3
-
-        when:
-        process = new ProcessConfig([:])
-        process.applyConfigSelectorWithLabels(settings, ['bar'])
-        then:
-        process.cpus == 32
-        process.disk == '100GB'
-        process.queue == 'cn-long'
-        process.size() == 3
-
-        when:
-        process = new ProcessConfig([:])
-        process.applyConfigSelectorWithLabels(settings, ['gpu-1'])
-        then:
-        process.cpus == 4
-        process.queue == 'cn-long'
-        process.size() == 2
-
-    }
-
-
-    def 'should apply config setting for a process name' () {
-        given:
-        def settings = [
-                'withName:alpha'        : [ cpus: 1, time: '1h'],
-                'withName:delta'        : [ cpus: 2 ],
-                'withName:delta|gamma'  : [ disk: '100GB' ],
-                'withName:omega.+'      : [ cpus: 4 ],
-        ]
-
-        when:
-        def process = new ProcessConfig([:])
-        process.applyConfigSelectorWithName(settings, 'xx')
-        then:
-        process.size() == 0
-
-        when:
-        process = new ProcessConfig([:])
-        process.applyConfigSelectorWithName(settings, 'alpha')
-        then:
-        process.cpus == 1
-        process.time == '1h'
-        process.size() == 2
-
-        when:
-        process =  new ProcessConfig([:])
-        process.applyConfigSelectorWithName(settings, 'delta')
-        then:
-        process.cpus == 2
-        process.disk == '100GB'
-        process.size() == 2
-
-        when:
-        process =  new ProcessConfig([:])
-        process.applyConfigSelectorWithName(settings, 'gamma')
-        then:
-        process.disk == '100GB'
-        process.size() == 1
-
-        when:
-        process = new ProcessConfig([:])
-        process.applyConfigSelectorWithName(settings, 'omega_x')
-        then:
-        process.cpus == 4
-        process.size() == 1
-    }
-
-
-    def 'should apply config process defaults' () {
-
-        when:
-        def process = new ProcessConfig(Mock(BaseScript))
-
-        // set process specific settings
-        process.queue = 'cn-el6'
-        process.memory = '10 GB'
-
-        // apply config defaults
-        process.applyConfigDefaults(
-                queue: 'def-queue',
-                container: 'ubuntu:latest'
-        )
-
-        then:
-        process.queue == 'cn-el6'
-        process.container == 'ubuntu:latest'
-        process.memory == '10 GB'
-        process.cacheable == true
-
-
-
-        when:
-        process = new ProcessConfig(Mock(BaseScript))
-        // set process specific settings
-        process.container = null
-        // apply process defaults
-        process.applyConfigDefaults(
-                queue: 'def-queue',
-                container: 'ubuntu:latest',
-                maxRetries: 5
-        )
-        then:
-        process.queue == 'def-queue'
-        process.container == null
-        process.maxRetries == 5
-
-
-
-        when:
-        process = new ProcessConfig(Mock(BaseScript))
-        // set process specific settings
-        process.maxRetries = 10
-        // apply process defaults
-        process.applyConfigDefaults(
-                queue: 'def-queue',
-                container: 'ubuntu:latest',
-                maxRetries: 5
-        )
-        then:
-        process.queue == 'def-queue'
-        process.container == 'ubuntu:latest'
-        process.maxRetries == 10
-    }
-
-
-    def 'should apply pod configs' () {
-
-        when:
-        def process =  new ProcessConfig([:])
-        process.applyConfigDefaults( pod: [secret: 'foo', mountPath: '/there'] )
-        then:
-        process.pod == [
-                [secret: 'foo', mountPath: '/there']
-        ]
-
-        when:
-        process =  new ProcessConfig([:])
-        process.applyConfigDefaults( pod: [
-                [secret: 'foo', mountPath: '/here'],
-                [secret: 'bar', mountPath: '/there']
-        ] )
-
-        then:
-        process.pod == [
-                [secret: 'foo', mountPath: '/here'],
-                [secret: 'bar', mountPath: '/there']
-        ]
-
-    }
-
-    def 'should clone config object' () {
-
-        given:
-        def config = new ProcessConfig(Mock(BaseScript))
-
-        when:
-        config.queue 'cn-el6'
-        config.container 'ubuntu:latest'
-        config.memory '10 GB'
-        config._in_val('foo')
-        config._in_file('sample.txt')
-        config._out_file('result.txt')
-
-        then:
-        config.queue == 'cn-el6'
-        config.container == 'ubuntu:latest'
-        config.memory == '10 GB'
-        config.getInputs().size() == 2
-        config.getOutputs().size() == 1
-
-        when:
-        def copy = config.clone()
-        copy.queue 'long'
-        copy.container 'debian:wheezy'
-        copy.memory '5 GB'
-        copy._in_val('bar')
-        copy._out_file('sample.bam')
-
-        then:
-        copy.queue == 'long'
-        copy.container == 'debian:wheezy'
-        copy.memory == '5 GB'
-        copy.getInputs().size() == 3
-        copy.getOutputs().size() == 2
-
-        // original config is not affected
-        config.queue == 'cn-el6'
-        config.container == 'ubuntu:latest'
-        config.memory == '10 GB'
-        config.getInputs().size() == 2
-        config.getOutputs().size() == 1
-    }
-
-    def 'should apply accelerator config' () {
-
-        given:
-        def process = new ProcessConfig(Mock(BaseScript))
-
-        when:
-        process.accelerator 5
-        then:
-        process.accelerator == [limit: 5]
-
-        when:
-        process.accelerator request: 1, limit: 5, type: 'nvida'
-        then:
-        process.accelerator == [request: 1, limit: 5, type: 'nvida']
-
-        when:
-        process.accelerator 5, type: 'nvida'
-        then:
-        process.accelerator == [limit: 5, type: 'nvida']
-
-        when:
-        process.accelerator 1, limit: 5
-        then:
-        process.accelerator == [request: 1, limit:5]
-
-        when:
-        process.accelerator 5, request: 1
-        then:
-        process.accelerator == [request: 1, limit:5]
-    }
-
-    def 'should apply disk config' () {
-
-        given:
-        def process = new ProcessConfig(Mock(BaseScript))
-
-        when:
-        process.disk '100 GB'
-        then:
-        process.disk == [request: '100 GB']
-
-        when:
-        process.disk '375 GB', type: 'local-ssd'
-        then:
-        process.disk == [request: '375 GB', type: 'local-ssd']
-
-        when:
-        process.disk request: '375 GB', type: 'local-ssd'
-        then:
-        process.disk == [request: '375 GB', type: 'local-ssd']
-    }
-
-    def 'should apply architecture config' () {
-
-        given:
-        def process = new ProcessConfig(Mock(BaseScript))
-
-        when:
-        process.arch 'linux/x86_64'
-        then:
-        process.arch == [name: 'linux/x86_64']
-
-        when:
-        process.arch 'linux/x86_64', target: 'zen3'
-        then:
-        process.arch == [name: 'linux/x86_64', target: 'zen3']
-
-        when:
-        process.arch name: 'linux/x86_64', target: 'zen3'
-        then:
-        process.arch == [name: 'linux/x86_64', target: 'zen3']
-    }
-
-    def 'should apply resourceLimits' () {
-        given:
-        def process = new ProcessConfig(Mock(BaseScript))
-
-        when:
-        process.resourceLimits time:'1h', memory: '2GB'
-        then:
-        process.resourceLimits == [time:'1h', memory: '2GB']
-    }
-
-
-    def 'should get default config path' () {
-        given:
-        ProviderConfig.env.remove('NXF_SCM_FILE')
-
-        when:
-        def path = ProviderConfig.getScmConfigPath()
-        then:
-        path.toString() == "${System.getProperty('user.home')}/.nextflow/scm"
-
-    }
-
-    def 'should get custom config path' () {
-        given:
-        def cfg = Files.createTempFile('test','config')
-        ProviderConfig.env.NXF_SCM_FILE = cfg.toString()
-
-        when:
-        def path = ProviderConfig.getScmConfigPath()
-        then:
-        path.toString() == cfg.toString()
-
-        cleanup:
-        ProviderConfig.env.remove('NXF_SCM_FILE')
-        cfg.delete()
-    }
-
-    def 'should not apply config on negative label' () {
-        given:
-        def settings = [
-                'withLabel:foo': [ cpus: 2 ],
-                'withLabel:!foo': [ cpus: 4 ],
-                'withLabel:!nodisk_.*': [ disk: '100.GB']
-        ]
-
-        when:
-        def p1 = new ProcessConfig([label: ['foo', 'other']])
-        p1.applyConfig(settings, "processName", null, null)
-        then:
-        p1.cpus == 2
-        p1.disk == '100.GB'
-
-        when:
-        def p2 = new ProcessConfig([label: ['foo', 'other', 'nodisk_label']])
-        p2.applyConfig(settings, "processName", null, null)
-        then:
-        p2.cpus == 2
-        !p2.disk
-
-        when:
-        def p3 = new ProcessConfig([label: ['other', 'nodisk_label']])
-        p3.applyConfig(settings, "processName", null, null)
-        then:
-        p3.cpus == 4
-        !p3.disk
-
-    }
-
-    def 'should throw exception for invalid error strategy' () {
-        when:
-        def process1 = new ProcessConfig(Mock(BaseScript))
-        process1.errorStrategy 'abort'
-
-        then:
-        def e1 = thrown(IllegalArgumentException)
-        e1.message == "Unknown error strategy 'abort' ― Available strategies are: terminate,finish,ignore,retry"
-
-    }
-
-    def 'should not throw exception for valid error strategy or closure' () {
-        when:
-        def process1 = new ProcessConfig(Mock(BaseScript))
-        process1.errorStrategy 'retry'
-
-        then:
-        def e1 = noExceptionThrown()
-
-        when:
-        def process2 = new ProcessConfig(Mock(BaseScript))
-        process2.errorStrategy 'terminate'
-
-        then:
-        def e2 = noExceptionThrown()
-
-        when:
-        def process3 = new ProcessConfig(Mock(BaseScript))
-        process3.errorStrategy { task.exitStatus==14 ? 'retry' : 'terminate' }
-
-        then:
-        def e3 = noExceptionThrown()
-    }
 }
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ProcessDefTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ProcessDefTest.groovy
index 6b8fc4805a..a862ac11b0 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/ProcessDefTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/ProcessDefTest.groovy
@@ -12,8 +12,9 @@ class ProcessDefTest extends Specification {
 
         given:
         def OWNER = Mock(BaseScript)
-        def BODY = { -> null }
-        def proc = new ProcessDef(OWNER, BODY, 'foo')
+        def BODY = new BodyDef({->}, '')
+        def CONFIG = new ProcessConfig(OWNER, 'foo')
+        def proc = new ProcessDef(OWNER, 'foo', CONFIG, BODY)
 
         when:
         def copy = proc.cloneWithName('foo_alias')
@@ -22,8 +23,8 @@ class ProcessDefTest extends Specification {
         copy.getSimpleName() == 'foo_alias'
         copy.getBaseName() == 'foo'
         copy.getOwner() == OWNER
-        copy.rawBody.class == BODY.class
-        !copy.rawBody.is(BODY)
+        copy.taskBody.class == BODY.class
+        !copy.taskBody.is(BODY)
 
         when:
         copy = proc.cloneWithName('flow1:flow2:foo')
@@ -32,26 +33,28 @@ class ProcessDefTest extends Specification {
         copy.getSimpleName() == 'foo'
         copy.getBaseName() == 'foo'
         copy.getOwner() == OWNER
-        copy.rawBody.class == BODY.class
-        !copy.rawBody.is(BODY)
+        copy.taskBody.class == BODY.class
+        !copy.taskBody.is(BODY)
     }
 
     def 'should apply process config' () {
         given:
         def OWNER = Mock(BaseScript)
-        def CONFIG = [
-                process:[
-                        cpus:2, memory: '1GB',
-                        'withName:foo': [memory: '3GB'],
-                        'withName:bar': [cpus:4, memory: '4GB'],
-                        'withName:flow1:flow2:flow3:bar': [memory: '8GB']
+        def CONFIG = new ProcessConfig(OWNER, 'foo')
+        def BODY = new BodyDef({->}, 'echo hello')
+        def proc = new ProcessDef(OWNER, 'foo', CONFIG, BODY)
+        and:
+        proc.session = Mock(Session) {
+            getConfig() >> [
+                process: [
+                    cpus: 2,
+                    memory: '1GB',
+                    'withName:foo': [memory: '3GB'],
+                    'withName:bar': [cpus: 4, memory: '4GB'],
+                    'withName:flow1:flow2:flow3:bar': [memory: '8GB']
                 ]
-        ]
-        def BODY = {->
-            return new BodyDef({->}, 'echo hello')
+            ]
         }
-        def proc = new ProcessDef(OWNER, BODY, 'foo')
-        proc.session = Mock(Session) { getConfig() >> CONFIG }
 
         when:
         def copy = proc.clone()
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy
index edeea60405..5366218988 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/ProcessEntryHandlerTest.groovy
@@ -18,6 +18,10 @@ package nextflow.script
 
 import java.nio.file.Path
 import nextflow.Session
+import nextflow.script.params.FileInParam
+import nextflow.script.params.InParam
+import nextflow.script.params.TupleInParam
+import nextflow.script.params.ValueInParam
 import spock.lang.Specification
 
 /**
@@ -108,8 +112,8 @@ class ProcessEntryHandlerTest extends Specification {
             'meta': [id: 'SAMPLE_001', name: 'TestSample'],
             'sampleId': 'SIMPLE_001'
         ]
-        def valInput = [type: 'val', name: 'meta']
-        def simpleInput = [type: 'val', name: 'sampleId']
+        def valInput = Mock(ValueInParam) { getName() >> 'meta' }
+        def simpleInput = Mock(ValueInParam) { getName() >> 'sampleId' }
 
         then:
         handler.getValueForInput(valInput, complexParams) == [id: 'SAMPLE_001', name: 'TestSample']
@@ -128,8 +132,8 @@ class ProcessEntryHandlerTest extends Specification {
             'fasta': '/path/to/file.fa',
             'dataFile': 'data.txt'
         ]
-        def pathInput = [type: 'path', name: 'fasta']
-        def fileInput = [type: 'file', name: 'dataFile']
+        def pathInput = Mock(FileInParam) { getName() >> 'fasta' }
+        def fileInput = Mock(FileInParam) { getName() >> 'dataFile' }
 
         then:
         def fastaResult = handler.getValueForInput(pathInput, complexParams)
@@ -151,7 +155,8 @@ class ProcessEntryHandlerTest extends Specification {
         def complexParams = [
             'meta': [id: 'SAMPLE_001']
         ]
-        def missingInput = [type: 'val', name: 'missing']
+        def missingInput = Mock(ValueInParam) { getName() >> 'missing' }
+        and:
         handler.getValueForInput(missingInput, complexParams)
 
         then:
@@ -174,33 +179,29 @@ class ProcessEntryHandlerTest extends Specification {
         def handler = new ProcessEntryHandler(script, session, meta)
 
         when:
-        // Mock input structures for tuple val(meta), path(fasta)
-        def inputStructures = [
-            [
-                type: 'tuple',
-                elements: [
-                    [type: 'val', name: 'meta'],
-                    [type: 'path', name: 'fasta']
-                ]
+        // Mock input declaration for tuple val(meta), path(fasta)
+        def tupleParam = Mock(TupleInParam) {
+            getInner() >> [
+                Mock(ValueInParam) { getName() >> 'meta' },
+                Mock(FileInParam) { getName() >> 'fasta' }
             ]
-        ]
+        }
 
         // Test the parameter mapping logic manually 
-        def complexParams = handler.parseComplexParameters(session.getParams())
-        def tupleInput = inputStructures[0]
+        def namedArgs = handler.parseComplexParameters(session.getParams())
         def tupleElements = []
         
-        for( def element : tupleInput.elements ) {
-            def value = handler.getValueForInput(element, complexParams)
+        for( def innerParam : tupleParam.inner ) {
+            def value = handler.getValueForInput(innerParam, namedArgs)
             tupleElements.add(value)
         }
 
         then:
-        complexParams.meta instanceof Map
-        complexParams.meta.id == 'SAMPLE_001'
-        complexParams.meta.name == 'TestSample'
-        complexParams.meta.other == 'some-value'
-        complexParams.fasta == '/path/to/file.fa'
+        namedArgs.meta instanceof Map
+        namedArgs.meta.id == 'SAMPLE_001'
+        namedArgs.meta.name == 'TestSample'
+        namedArgs.meta.other == 'some-value'
+        namedArgs.fasta == '/path/to/file.fa'
         
         tupleElements.size() == 2
         tupleElements[0] instanceof Map  // meta as map
@@ -230,23 +231,4 @@ class ProcessEntryHandlerTest extends Specification {
         '/path/to/file1.txt,,/path/to/file2.txt, ,/path/to/file3.txt'     | [Path.of('/path/to/file1.txt'), Path.of('/path/to/file2.txt'), Path.of('/path/to/file3.txt')]
         'file1.txt,file2.txt'                                             | [Path.of('file1.txt').toAbsolutePath(), Path.of('file2.txt').toAbsolutePath()]
     }
-
-    def 'should handle file input with GString' () {
-        given:
-        def session = Mock(Session)
-        def script = Mock(BaseScript)
-        def meta = Mock(ScriptMeta)
-        def handler = new ProcessEntryHandler(script, session, meta)
-        def inputDef = [name: 'input', type: 'file']
-        def complexParams = [input: "${'/path/to/file1.txt'},${'/path/to/file2.txt'}"]
-
-        when:
-        def result = handler.getValueForInput(inputDef, complexParams)
-
-        then:
-        result instanceof List
-        result.size() == 2
-        result[0].toString().contains('file1.txt')
-        result[1].toString().contains('file2.txt')
-    }
 }
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ScriptDslTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ScriptDslTest.groovy
index b35cb5d4f2..b164f0d995 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/ScriptDslTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/ScriptDslTest.groovy
@@ -359,7 +359,7 @@ class ScriptDslTest extends Dsl2Spec {
 
         then:
         def err = thrown(ScriptRuntimeException)
-        err.message == 'Process `bar` declares 1 input channel but 0 were specified'
+        err.message == 'Process `bar` declares 1 input but was called with 0 arguments'
     }
 
     def 'should report error accessing undefined out/a' () {
@@ -451,7 +451,7 @@ class ScriptDslTest extends Dsl2Spec {
 
         then:
         def err = thrown(ScriptRuntimeException)
-        err.message == "Process `bar` declares 1 input channel but 0 were specified"
+        err.message == "Process `bar` declares 1 input but was called with 0 arguments"
     }
 
     def 'should report error accessing undefined out/e' () {
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/ScriptMetaTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/ScriptMetaTest.groovy
index 197c821f2e..d85fa16218 100644
--- a/modules/nextflow/src/test/groovy/nextflow/script/ScriptMetaTest.groovy
+++ b/modules/nextflow/src/test/groovy/nextflow/script/ScriptMetaTest.groovy
@@ -40,13 +40,17 @@ class ScriptMetaTest extends Dsl2Spec {
         NF.init()
     }
 
+    def createProcessDef(BaseScript script, String name) {
+        new ProcessDef(script, name, new ProcessConfig(script, name), Mock(BodyDef))
+    }
+
     def 'should return all defined names' () {
 
         given:
         def script = new FooScript(new ScriptBinding())
 
-        def proc1 = new ProcessDef(script, Mock(Closure), 'proc1')
-        def proc2 = new ProcessDef(script, Mock(Closure), 'proc2')
+        def proc1 = createProcessDef(script, 'proc1')
+        def proc2 = createProcessDef(script, 'proc2')
         def func1 = new FunctionDef(name: 'func1', alias: 'func1')
         def work1 = new WorkflowDef(name:'work1')
 
@@ -83,19 +87,19 @@ class ScriptMetaTest extends Dsl2Spec {
 
         // defs in the root script
         def func1 = new FunctionDef(name: 'func1', alias: 'func1')
-        def proc1 = new ProcessDef(script1, Mock(Closure), 'proc1')
+        def proc1 = createProcessDef(script1, 'proc1')
         def work1 = new WorkflowDef(name:'work1')
         meta1.addDefinition(proc1, func1, work1)
 
         // defs in the second script imported in the root namespace
         def func2 = new FunctionDef(name: 'func2', alias: 'func2')
-        def proc2 = new ProcessDef(script2, Mock(Closure), 'proc2')
+        def proc2 = createProcessDef(script2, 'proc2')
         def work2 = new WorkflowDef(name:'work2')
         meta2.addDefinition(proc2, func2, work2)
 
         // defs in the third script imported in a separate namespace
         def func3 = new FunctionDef(name: 'func3', alias: 'func3')
-        def proc3 = new ProcessDef(script2, Mock(Closure), 'proc3')
+        def proc3 = createProcessDef(script2, 'proc3')
         def work3 = new WorkflowDef(name:'work3')
         meta3.addDefinition(proc3, func3, work3)
 
@@ -209,7 +213,7 @@ class ScriptMetaTest extends Dsl2Spec {
 
         // import module into main script
         def func2 = new FunctionDef(name: 'func1', alias: 'func1')
-        def proc2 = new ProcessDef(script2, Mock(Closure), 'proc1')
+        def proc2 = createProcessDef(script2, 'proc1')
         def work2 = new WorkflowDef(name: 'work1')
         meta2.addDefinition(proc2, func2, work2)
 
@@ -219,7 +223,7 @@ class ScriptMetaTest extends Dsl2Spec {
 
         // attempt to define duplicate components in main script
         def func1 = new FunctionDef(name: 'func1', alias: 'func1')
-        def proc1 = new ProcessDef(script1, Mock(Closure), 'proc1')
+        def proc1 = createProcessDef(script1, 'proc1')
         def work1 = new WorkflowDef(name: 'work1')
 
         when:
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy
new file mode 100644
index 0000000000..202de24d9a
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessBuilderTest.groovy
@@ -0,0 +1,313 @@
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import spock.lang.Specification
+
+import nextflow.exception.IllegalDirectiveException
+import nextflow.script.ProcessConfig
+import nextflow.util.Duration
+import nextflow.util.MemoryUnit
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class ProcessBuilderTest extends Specification {
+
+    def createBuilder() {
+        new ProcessBuilder(new ProcessConfig([:]))
+    }
+
+    def 'should set directives' () {
+
+        setup:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+
+        // setting list values
+        when:
+        builder.tag 1,2,3
+        then:
+        config.tag == [1,2,3]
+
+        // setting named parameters attribute
+        when:
+        builder.tag field1:'val1', field2: 'val2'
+        then:
+        config.tag == [field1:'val1', field2: 'val2']
+
+        // maxDuration property
+        when:
+        builder.time '1h'
+        then:
+        config.time == '1h'
+        config.createTaskConfig().time == new Duration('1h')
+
+        // maxMemory property
+        when:
+        builder.memory '2GB'
+        then:
+        config.memory == '2GB'
+        config.createTaskConfig().memory == new MemoryUnit('2GB')
+
+        when:
+        builder.stageInMode 'copy'
+        builder.stageOutMode 'move'
+        then:
+        config.stageInMode == 'copy'
+        config.stageOutMode == 'move'
+
+    }
+
+    def 'should create PublishDir object' () {
+
+        setup:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+
+        when:
+        builder.publishDir '/data'
+        then:
+        config.get('publishDir').last() == [path:'/data']
+
+        when:
+        builder.publishDir '/data', mode: 'link', pattern: '*.bam'
+        then:
+        config.get('publishDir').last() == [path: '/data', mode: 'link', pattern: '*.bam']
+
+        when:
+        builder.publishDir path: '/data', mode: 'link', pattern: '*.bam'
+        then:
+        config.get('publishDir').last() == [path: '/data', mode: 'link', pattern: '*.bam']
+    }
+
+    def 'should throw IllegalDirectiveException'() {
+
+        given:
+        def builder = createBuilder()
+
+        when:
+        builder.hello 'world'
+
+        then:
+        def e = thrown(IllegalDirectiveException)
+        e.message ==
+                '''
+                Unknown process directive: `hello`
+
+                Did you mean one of these?
+                        shell
+                '''
+                .stripIndent().trim()
+    }
+
+    def 'should set process secret'() {
+        when:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+        then:
+        config.getSecret() == []
+
+        when:
+        builder.secret 'foo'
+        then:
+        config.getSecret() == ['foo']
+
+        when:
+        builder.secret 'bar'
+        then:
+        config.secret == ['foo', 'bar']
+        config.getSecret() == ['foo', 'bar']
+    }
+
+    def 'should set process labels'() {
+        when:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+        then:
+        config.getLabels() == []
+
+        when:
+        builder.label 'foo'
+        then:
+        config.getLabels() == ['foo']
+
+        when:
+        builder.label 'bar'
+        then:
+        config.getLabels() == ['foo','bar']
+    }
+
+    def 'should apply resource labels config' () {
+        given:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+        expect:
+        config.getResourceLabels() == [:]
+
+        when:
+        builder.resourceLabels foo: 'one', bar: 'two'
+        then:
+        config.getResourceLabels() == [foo: 'one', bar: 'two']
+
+        when:
+        builder.resourceLabels foo: 'new one', baz: 'three'
+        then:
+        config.getResourceLabels() == [foo: 'new one', bar: 'two', baz: 'three']
+
+    }
+
+    def 'should check a valid label' () {
+
+        expect:
+        ProcessBuilder.isValidLabel(lbl) == result
+
+        where:
+        lbl         | result
+        'foo'       | true
+        'foo1'      | true
+        '1foo'      | false
+        '_foo'      | false
+        'foo1_'     | false
+        'foo_1'     | true
+        'foo-1'     | false
+        'foo.1'     | false
+        'a'         | true
+        'A'         | true
+        '1'         | false
+        '_'         | false
+        'a=b'       | true
+        'a=foo'     | true
+        'a=foo_1'   | true
+        'a=foo_'    | false
+        '_=foo'     | false
+        '=a'        | false
+        'a='        | false
+        'a=1'       | false
+
+    }
+
+    def 'should apply accelerator config' () {
+
+        given:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+
+        when:
+        builder.accelerator 5
+        then:
+        config.accelerator == [limit: 5]
+
+        when:
+        builder.accelerator request: 1, limit: 5, type: 'nvida'
+        then:
+        config.accelerator == [request: 1, limit: 5, type: 'nvida']
+
+        when:
+        builder.accelerator 5, type: 'nvida'
+        then:
+        config.accelerator == [limit: 5, type: 'nvida']
+
+        when:
+        builder.accelerator 1, limit: 5
+        then:
+        config.accelerator == [request: 1, limit:5]
+
+        when:
+        builder.accelerator 5, request: 1
+        then:
+        config.accelerator == [request: 1, limit:5]
+    }
+
+    def 'should apply disk config' () {
+
+        given:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+
+        when:
+        builder.disk '100 GB'
+        then:
+        config.disk == [request: '100 GB']
+
+        when:
+        builder.disk '375 GB', type: 'local-ssd'
+        then:
+        config.disk == [request: '375 GB', type: 'local-ssd']
+
+        when:
+        builder.disk request: '375 GB', type: 'local-ssd'
+        then:
+        config.disk == [request: '375 GB', type: 'local-ssd']
+    }
+
+    def 'should apply architecture config' () {
+
+        given:
+        def builder = createBuilder()
+        def config = builder.getConfig()
+
+        when:
+        builder.arch 'linux/x86_64'
+        then:
+        config.arch == [name: 'linux/x86_64']
+
+        when:
+        builder.arch 'linux/x86_64', target: 'zen3'
+        then:
+        config.arch == [name: 'linux/x86_64', target: 'zen3']
+
+        when:
+        builder.arch name: 'linux/x86_64', target: 'zen3'
+        then:
+        config.arch == [name: 'linux/x86_64', target: 'zen3']
+    }
+
+    def 'should throw exception for invalid error strategy' () {
+        when:
+        def builder = createBuilder()
+        builder.errorStrategy 'abort'
+
+        then:
+        def e = thrown(IllegalArgumentException)
+        e.message == "Unknown error strategy 'abort' ― Available strategies are: terminate,finish,ignore,retry"
+
+    }
+
+    def 'should not throw exception for valid error strategy or closure' () {
+        when:
+        def builder = createBuilder()
+        builder.errorStrategy 'retry'
+
+        then:
+        noExceptionThrown()
+
+        when:
+        builder = createBuilder()
+        builder.errorStrategy 'terminate'
+
+        then:
+        noExceptionThrown()
+
+        when:
+        builder = createBuilder()
+        builder.errorStrategy { task.exitStatus==14 ? 'retry' : 'terminate' }
+
+        then:
+        noExceptionThrown()
+    }
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy
new file mode 100644
index 0000000000..bfc6cacd3b
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessConfigBuilderTest.groovy
@@ -0,0 +1,259 @@
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import spock.lang.Specification
+import spock.lang.Unroll
+
+import nextflow.script.ProcessConfig
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class ProcessConfigBuilderTest extends Specification {
+
+    def createBuilder() {
+        new ProcessConfigBuilder(new ProcessConfig([:]))
+    }
+
+    @Unroll
+    def 'should match selector: #SELECTOR with #TARGET' () {
+        expect:
+        ProcessConfigBuilder.matchesSelector(TARGET, SELECTOR) == EXPECTED
+
+        where:
+        SELECTOR        | TARGET    | EXPECTED
+        'foo'           | 'foo'     | true
+        'foo'           | 'bar'     | false
+        '!foo'          | 'bar'     | true
+        'a|b'           | 'a'       | true
+        'a|b'           | 'b'       | true
+        'a|b'           | 'z'       | false
+        'a*'            | 'a'       | true
+        'a*'            | 'aaaa'    | true
+        'a*'            | 'bbbb'    | false
+    }
+
+    def 'should apply config setting for a process label' () {
+        given:
+        def settings = [
+                'withLabel:short'  : [ cpus: 1, time: '1h'],
+                'withLabel:!short' : [ cpus: 32, queue: 'cn-long'],
+                'withLabel:foo'    : [ cpus: 2 ],
+                'withLabel:foo|bar': [ disk: '100GB' ],
+                'withLabel:gpu.+'  : [ cpus: 4 ],
+        ]
+
+        when:
+        def config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithLabels(settings, ['short'])
+        then:
+        config.cpus == 1
+        config.time == '1h'
+        config.size() == 2
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithLabels(settings, ['long'])
+        then:
+        config.cpus == 32
+        config.queue == 'cn-long'
+        config.size() == 2
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithLabels(settings, ['foo'])
+        then:
+        config.cpus == 2
+        config.disk == '100GB'
+        config.queue == 'cn-long'
+        config.size() == 3
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithLabels(settings, ['bar'])
+        then:
+        config.cpus == 32
+        config.disk == '100GB'
+        config.queue == 'cn-long'
+        config.size() == 3
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithLabels(settings, ['gpu-1'])
+        then:
+        config.cpus == 4
+        config.queue == 'cn-long'
+        config.size() == 2
+
+    }
+
+
+    def 'should apply config setting for a process name' () {
+        given:
+        def settings = [
+                'withName:alpha'        : [ cpus: 1, time: '1h'],
+                'withName:delta'        : [ cpus: 2 ],
+                'withName:delta|gamma'  : [ disk: '100GB' ],
+                'withName:omega.+'      : [ cpus: 4 ],
+        ]
+
+        when:
+        def config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithName(settings, 'xx')
+        then:
+        config.size() == 0
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithName(settings, 'alpha')
+        then:
+        config.cpus == 1
+        config.time == '1h'
+        config.size() == 2
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithName(settings, 'delta')
+        then:
+        config.cpus == 2
+        config.disk == '100GB'
+        config.size() == 2
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithName(settings, 'gamma')
+        then:
+        config.disk == '100GB'
+        config.size() == 1
+
+        when:
+        config = new ProcessConfig([:])
+        new ProcessConfigBuilder(config).applyConfigSelectorWithName(settings, 'omega_x')
+        then:
+        config.cpus == 4
+        config.size() == 1
+    }
+
+
+    def 'should apply config process defaults' () {
+
+        when:
+        def builder = createBuilder()
+        builder.queue 'cn-el6'
+        builder.memory '10 GB'
+        builder.applyConfigDefaults(
+                queue: 'def-queue',
+                container: 'ubuntu:latest'
+        )
+        def config = builder.getConfig()
+
+        then:
+        config.queue == 'cn-el6'
+        config.container == 'ubuntu:latest'
+        config.memory == '10 GB'
+        config.cacheable == true
+
+
+
+        when:
+        builder = createBuilder()
+        builder.container null
+        builder.applyConfigDefaults(
+                queue: 'def-queue',
+                container: 'ubuntu:latest',
+                maxRetries: 5
+        )
+        config = builder.getConfig()
+        then:
+        config.queue == 'def-queue'
+        config.container == null
+        config.maxRetries == 5
+
+
+
+        when:
+        builder = createBuilder()
+        builder.maxRetries 10
+        builder.applyConfigDefaults(
+                queue: 'def-queue',
+                container: 'ubuntu:latest',
+                maxRetries: 5
+        )
+        config = builder.getConfig()
+        then:
+        config.queue == 'def-queue'
+        config.container == 'ubuntu:latest'
+        config.maxRetries == 10
+    }
+
+
+    def 'should apply pod configs' () {
+
+        when:
+        def builder = createBuilder()
+        builder.applyConfigDefaults( pod: [secret: 'foo', mountPath: '/there'] )
+        then:
+        builder.getConfig().pod == [
+                [secret: 'foo', mountPath: '/there']
+        ]
+
+        when:
+        builder = createBuilder()
+        builder.applyConfigDefaults( pod: [
+                [secret: 'foo', mountPath: '/here'],
+                [secret: 'bar', mountPath: '/there']
+        ] )
+        then:
+        builder.getConfig().pod == [
+                [secret: 'foo', mountPath: '/here'],
+                [secret: 'bar', mountPath: '/there']
+        ]
+
+    }
+
+    def 'should not apply config on negative label' () {
+        given:
+        def settings = [
+                'withLabel:foo': [ cpus: 2 ],
+                'withLabel:!foo': [ cpus: 4 ],
+                'withLabel:!nodisk_.*': [ disk: '100.GB']
+        ]
+
+        when:
+        def config = new ProcessConfig(label: ['foo', 'other'])
+        new ProcessConfigBuilder(config).applyConfig(settings, "processName", null, null)
+        then:
+        config.cpus == 2
+        config.disk == '100.GB'
+
+        when:
+        config = new ProcessConfig(label: ['foo', 'other', 'nodisk_label'])
+        new ProcessConfigBuilder(config).applyConfig(settings, "processName", null, null)
+        then:
+        config.cpus == 2
+        !config.disk
+
+        when:
+        config = new ProcessConfig(label: ['other', 'nodisk_label'])
+        new ProcessConfigBuilder(config).applyConfig(settings, "processName", null, null)
+        then:
+        config.cpus == 4
+        !config.disk
+
+    }
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessDslV1Test.groovy b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessDslV1Test.groovy
new file mode 100644
index 0000000000..974683bb22
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessDslV1Test.groovy
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import spock.lang.Specification
+
+import nextflow.script.BaseScript
+import nextflow.script.params.FileInParam
+import nextflow.script.params.StdInParam
+import nextflow.script.params.StdOutParam
+import nextflow.script.params.ValueInParam
+import nextflow.script.TokenVar
+/**
+ *
+ * @author Paolo Di Tommaso 
+ */
+class ProcessDslV1Test extends Specification {
+
+    def createDsl() {
+        new ProcessDslV1(Mock(BaseScript), null)
+    }
+
+    def 'should create input directives' () {
+
+        setup:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+
+        when:
+        dsl._in_file([infile:'filename.fa'])
+        dsl._in_val('x')
+        dsl._in_stdin()
+
+        then:
+        config.getInputs().size() == 3
+
+        config.getInputs().get(0) instanceof FileInParam
+        config.getInputs().get(0).name == 'infile'
+        (config.getInputs().get(0) as FileInParam).filePattern == 'filename.fa'
+
+        config.getInputs().get(1) instanceof ValueInParam
+        config.getInputs().get(1).name == 'x'
+
+        config.getInputs().get(2).name == '-'
+        config.getInputs().get(2) instanceof StdInParam
+
+        config.getInputs().names == [ 'infile', 'x', '-' ]
+        config.getInputs().ofType( FileInParam ) == [ config.getInputs().get(0) ]
+
+    }
+
+    def 'should create output directives' () {
+
+        setup:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+
+        when:
+        dsl._out_stdout()
+        dsl._out_file(new TokenVar('file1'))
+        dsl._out_file(new TokenVar('file2'))
+        dsl._out_file(new TokenVar('file3'))
+
+        then:
+        config.getOutputs().size() == 4
+        config.getOutputs().names == ['-', 'file1', 'file2', 'file3']
+        config.getOutputs().ofType(StdOutParam).size() == 1
+
+        config.getOutputs()[0] instanceof StdOutParam
+        config.getOutputs()[1].name == 'file1'
+        config.getOutputs()[2].name == 'file2'
+        config.getOutputs()[3].name == 'file3'
+
+    }
+
+    def 'should clone config object' () {
+
+        when:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+        dsl.queue 'cn-el6'
+        dsl.container 'ubuntu:latest'
+        dsl.memory '10 GB'
+        dsl._in_val('foo')
+        dsl._in_file('sample.txt')
+        dsl._out_file('result.txt')
+
+        then:
+        config.queue == 'cn-el6'
+        config.container == 'ubuntu:latest'
+        config.memory == '10 GB'
+        config.getInputs().size() == 2
+        config.getOutputs().size() == 1
+
+        when:
+        def copy = config.clone()
+        def builder = new ProcessConfigBuilder(copy)
+        builder.queue 'long'
+        builder.container 'debian:wheezy'
+        builder.memory '5 GB'
+
+        then:
+        copy.queue == 'long'
+        copy.container == 'debian:wheezy'
+        copy.memory == '5 GB'
+        copy.getInputs().size() == 2
+        copy.getOutputs().size() == 1
+
+        // original config is not affected
+        config.queue == 'cn-el6'
+        config.container == 'ubuntu:latest'
+        config.memory == '10 GB'
+        config.getInputs().size() == 2
+        config.getOutputs().size() == 1
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessDslV2Test.groovy b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessDslV2Test.groovy
new file mode 100644
index 0000000000..39df3f80f5
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/dsl/ProcessDslV2Test.groovy
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.dsl
+
+import java.nio.file.Path
+
+import nextflow.script.BaseScript
+import spock.lang.Specification
+/**
+ *
+ * @author Ben Sherman 
+ */
+class ProcessDslV2Test extends Specification {
+
+    def createDsl() {
+        new ProcessDslV2(Mock(BaseScript), null)
+    }
+
+    def 'should declare process inputs' () {
+
+        given:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+
+        when:
+        dsl._input_('infile', Path, false)
+        dsl._input_('x', String, false)
+        dsl._input_('y', String, false)
+        dsl.stageAs('filename.fa', { infile })
+        dsl.stdin { y }
+
+        then:
+        config.getInputs().size() == 3
+
+        config.getInputs().getParams().get(0).name == 'infile'
+        config.getInputs().getParams().get(0).type == Path
+        config.getInputs().getFiles().get(0).filePattern == 'filename.fa'
+
+        config.getInputs().getParams().get(1).name == 'x'
+        config.getInputs().getParams().get(1).type == String
+
+        config.getInputs().getParams().get(2).name == 'y'
+        config.getInputs().getParams().get(2).type == String
+
+    }
+
+    def 'should declare process outputs' () {
+
+        given:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+
+        when:
+        dsl._output_('x', String, { stdout() })
+        dsl._output_('file1', Path, { file('$path0') })
+        dsl._output_('file2', Path, { file('$path1') })
+        dsl._output_('file3', Path, { file('$path2') })
+        dsl._unstage_files('$path0', { file1 })
+        dsl._unstage_files('$path1', { file2 })
+        dsl._unstage_files('$path2', { file3 })
+
+        then:
+        config.getOutputs().getParams().size() == 4
+
+        config.getOutputs().getParams().get(0).name == 'x'
+        config.getOutputs().getParams().get(1).name == 'file1'
+        config.getOutputs().getParams().get(2).name == 'file2'
+        config.getOutputs().getParams().get(3).name == 'file3'
+        config.getOutputs().getFiles().keySet() == [ '$path0', '$path1', '$path2' ].toSet()
+    }
+
+    def 'should declare process topic emissions' () {
+
+        given:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+
+        when:
+        dsl._topic_({ stdout() }, 'versions')
+        dsl._topic_({ file('$path0') }, 'versions')
+        dsl._unstage_files('$path0', { file1 })
+
+        then:
+        config.getOutputs().getTopics().size() == 2
+
+        config.getOutputs().getTopics().get(0).target == 'versions'
+        config.getOutputs().getTopics().get(1).target == 'versions'
+        config.getOutputs().getFiles().keySet() == [ '$path0' ].toSet()
+    }
+
+    def 'should clone config object' () {
+
+        when:
+        def dsl = createDsl()
+        def config = dsl.getConfig()
+        dsl.queue 'cn-el6'
+        dsl.container 'ubuntu:latest'
+        dsl.memory '10 GB'
+        dsl._input_('foo', String, false)
+        dsl._input_('sample', Path, false)
+        dsl.stageAs('sample.txt', { sample })
+        dsl._output_('result', Path, { file('$file0') })
+        dsl._unstage_files('$file0', 'result.txt')
+
+        then:
+        config.queue == 'cn-el6'
+        config.container == 'ubuntu:latest'
+        config.memory == '10 GB'
+        config.getInputs().getParams().size() == 2
+        config.getOutputs().getParams().size() == 1
+
+        when:
+        def copy = config.clone()
+        def builder = new ProcessConfigBuilder(copy)
+        builder.queue 'long'
+        builder.container 'debian:wheezy'
+        builder.memory '5 GB'
+
+        then:
+        copy.queue == 'long'
+        copy.container == 'debian:wheezy'
+        copy.memory == '5 GB'
+        copy.getInputs().getParams().size() == 2
+        copy.getOutputs().getParams().size() == 1
+
+        // original config is not affected
+        config.queue == 'cn-el6'
+        config.container == 'ubuntu:latest'
+        config.memory == '10 GB'
+        config.getInputs().getParams().size() == 2
+        config.getOutputs().getParams().size() == 1
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/params/v2/ProcessFileInputTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/params/v2/ProcessFileInputTest.groovy
new file mode 100644
index 0000000000..125fd150e8
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/params/v2/ProcessFileInputTest.groovy
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import spock.lang.Specification
+
+/**
+ * @author Ben Sherman 
+ */
+class ProcessFileInputTest extends Specification {
+
+    def 'should resolve file pattern'() {
+        given:
+        def filePattern
+        def input
+
+        when:
+        input = new ProcessFileInput(null, null)
+        then:
+        input.getFilePattern([:]) == '*'
+
+        when:
+        input = new ProcessFileInput('*.txt', null)
+        then:
+        input.getFilePattern([:]) == '*.txt'
+
+        when:
+        filePattern = { -> "${id}.txt" }
+        input = new ProcessFileInput(filePattern, null)
+        then:
+        input.getFilePattern([id: 'sample1']) == 'sample1.txt'
+    }
+
+    def 'should resolve file value'() {
+        given:
+        def value
+        def input
+
+        when:
+        input = new ProcessFileInput(null, null)
+        then:
+        input.resolve([:]) == null
+
+        when:
+        value = { -> fastq }
+        input = new ProcessFileInput(null, value)
+        then:
+        input.resolve([fastq: 'sample1.fastq']) == 'sample1.fastq'
+    }
+
+}
diff --git a/modules/nextflow/src/test/groovy/nextflow/script/params/v2/ProcessFileOutputTest.groovy b/modules/nextflow/src/test/groovy/nextflow/script/params/v2/ProcessFileOutputTest.groovy
new file mode 100644
index 0000000000..63fb68950a
--- /dev/null
+++ b/modules/nextflow/src/test/groovy/nextflow/script/params/v2/ProcessFileOutputTest.groovy
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2013-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package nextflow.script.params.v2
+
+import spock.lang.Specification
+
+/**
+ * @author Ben Sherman 
+ */
+class ProcessFileOutputTest extends Specification {
+
+    def 'should resolve file pattern'() {
+        given:
+        def input
+
+        when:
+        input = new ProcessFileOutput('*.txt')
+        then:
+        input.getFilePattern([:]) == '*.txt'
+
+        when:
+        input = new ProcessFileOutput({ -> "${id}.txt" })
+        then:
+        input.getFilePattern([id: 'sample1']) == 'sample1.txt'
+    }
+
+}
diff --git a/modules/nf-commons/src/main/nextflow/extension/Bolts.groovy b/modules/nf-commons/src/main/nextflow/extension/Bolts.groovy
index 48abb70174..f3a3639ae4 100644
--- a/modules/nf-commons/src/main/nextflow/extension/Bolts.groovy
+++ b/modules/nf-commons/src/main/nextflow/extension/Bolts.groovy
@@ -960,5 +960,22 @@ class Bolts {
         else
             return new HashMap<>(map)
     }
+
+    /**
+     * Resolve a lazy expression (e.g. closure, gstring) against
+     * a delegate (i.e. binding).
+     *
+     * @param binding
+     * @param value
+     */
+    static Object resolveLazy(Object binding, Object value) {
+        if( value instanceof Closure )
+            return cloneWith(value, binding).call()
+
+        if( value instanceof GString )
+            return cloneAsLazy(value, binding).toString()
+
+        return value
+    }
     
 }
diff --git a/modules/nf-lang/src/main/antlr/ScriptLexer.g4 b/modules/nf-lang/src/main/antlr/ScriptLexer.g4
index 1f111cbe89..bb465b33ed 100644
--- a/modules/nf-lang/src/main/antlr/ScriptLexer.g4
+++ b/modules/nf-lang/src/main/antlr/ScriptLexer.g4
@@ -348,7 +348,9 @@ INPUT           : 'input';
 OUTPUT          : 'output';
 SCRIPT          : 'script';
 SHELL           : 'shell';
+STAGE           : 'stage';
 STUB            : 'stub';
+TOPIC           : 'topic';
 WHEN            : 'when';
 
 // -- workflow definition
diff --git a/modules/nf-lang/src/main/antlr/ScriptParser.g4 b/modules/nf-lang/src/main/antlr/ScriptParser.g4
index f999c2c2cf..96fd876c23 100644
--- a/modules/nf-lang/src/main/antlr/ScriptParser.g4
+++ b/modules/nf-lang/src/main/antlr/ScriptParser.g4
@@ -191,7 +191,9 @@ processBody
     // explicit script/exec body with optional stub
     :   (sep processDirectives)?
         (sep processInputs)?
+        (sep processStage)?
         (sep processOutputs)?
+        (sep processTopics)?
         (sep processWhen)?
         sep processExec
         (sep processStub)?
@@ -199,15 +201,19 @@ processBody
     // explicit "Mahesh" form
     |   (sep processDirectives)?
         (sep processInputs)?
+        (sep processStage)?
         (sep processWhen)?
         sep processExec
         (sep processStub)?
-        sep processOutputs
+        (sep processOutputs)?
+        (sep processTopics)?
 
     // implicit script/exec body
     |   (sep processDirectives)?
         (sep processInputs)?
+        (sep processStage)?
         (sep processOutputs)?
+        (sep processTopics)?
         (sep processWhen)?
         sep blockStatements
     ;
@@ -217,11 +223,30 @@ processDirectives
     ;
 
 processInputs
-    :   INPUT COLON nls statement (sep statement)*
+    :   INPUT COLON nls processInput (sep processInput)*
+    ;
+
+processInput
+    :   identifier (COLON type)?
+    |   LPAREN identifier (COMMA identifier)+ rparen (COLON type)?
+    |   statement
+    ;
+
+processStage
+    :   STAGE COLON nls statement (sep statement)*
     ;
 
 processOutputs
-    :   OUTPUT COLON nls statement (sep statement)*
+    :   OUTPUT COLON nls processOutput (sep processOutput)*
+    ;
+
+processOutput
+    :   nameTypePair (ASSIGN expression)?
+    |   statement
+    ;
+
+processTopics
+    :   TOPIC COLON nls statement (sep statement)*
     ;
 
 processWhen
@@ -557,7 +582,9 @@ identifier
     |   OUTPUT
     |   SCRIPT
     |   SHELL
+    |   STAGE
     |   STUB
+    |   TOPIC
     |   WHEN
     |   WORKFLOW
     |   EMIT
@@ -724,7 +751,12 @@ className
     ;
 
 typeArguments
-    :   LT type (COMMA type)* GT
+    :   LT typeArgument (COMMA typeArgument)* GT
+    ;
+
+typeArgument
+    :   type
+    |   QUESTION
     ;
 
 legacyType
@@ -752,7 +784,9 @@ keywords
     |   OUTPUT
     |   SCRIPT
     |   SHELL
+    |   STAGE
     |   STUB
+    |   TOPIC
     |   WHEN
     |   WORKFLOW
     |   EMIT
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ASTUtils.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ASTUtils.java
index 12788651a4..a39baf5626 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/ast/ASTUtils.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ASTUtils.java
@@ -16,6 +16,7 @@
 
 package nextflow.script.ast;
 
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
@@ -26,6 +27,7 @@
 import org.codehaus.groovy.ast.AnnotatedNode;
 import org.codehaus.groovy.ast.AnnotationNode;
 import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.PropertyNode;
 import org.codehaus.groovy.ast.Variable;
 import org.codehaus.groovy.ast.expr.ClosureExpression;
@@ -113,6 +115,16 @@ public static BlockStatement asDslBlock(MethodCallExpression call, int argsCount
         return (BlockStatement) closure.getCode();
     }
 
+    public static Parameter[] asFlatParams(Parameter[] params) {
+        return Arrays.stream(params)
+            .flatMap((param) -> (
+                param instanceof TupleParameter tp
+                    ? Arrays.stream(tp.components)
+                    : Stream.of(param)
+            ))
+            .toArray(Parameter[]::new);
+    }
+
     public static MethodCallExpression asMethodCallX(Statement stmt) {
         if( !(stmt instanceof ExpressionStatement) )
             return null;
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNode.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNode.java
index 0c950f90cf..a118308f1d 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNode.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNode.java
@@ -15,80 +15,14 @@
  */
 package nextflow.script.ast;
 
-import java.lang.reflect.Modifier;
-import java.util.Optional;
-
-import nextflow.script.types.Record;
-import org.codehaus.groovy.ast.ClassHelper;
 import org.codehaus.groovy.ast.ClassNode;
-import org.codehaus.groovy.ast.FieldNode;
 import org.codehaus.groovy.ast.MethodNode;
 import org.codehaus.groovy.ast.Parameter;
-import org.codehaus.groovy.ast.expr.Expression;
-import org.codehaus.groovy.ast.expr.MethodCallExpression;
-import org.codehaus.groovy.ast.expr.VariableExpression;
 import org.codehaus.groovy.ast.stmt.EmptyStatement;
-import org.codehaus.groovy.ast.stmt.Statement;
-
-import static nextflow.script.ast.ASTUtils.*;
-
-/**
- * A process definition.
- *
- * @author Ben Sherman 
- */
-public class ProcessNode extends MethodNode {
-    public final Statement directives;
-    public final Statement inputs;
-    public final Statement outputs;
-    public final Expression when;
-    public final String type;
-    public final Statement exec;
-    public final Statement stub;
 
-    public ProcessNode(String name, Statement directives, Statement inputs, Statement outputs, Expression when, String type, Statement exec, Statement stub) {
-        super(name, 0, dummyReturnType(outputs), dummyParams(inputs), ClassNode.EMPTY_ARRAY, EmptyStatement.INSTANCE);
-        this.directives = directives;
-        this.inputs = inputs;
-        this.outputs = outputs;
-        this.when = when;
-        this.type = type;
-        this.exec = exec;
-        this.stub = stub;
-    }
-
-    private static Parameter[] dummyParams(Statement inputs) {
-        return asBlockStatements(inputs)
-            .stream()
-            .map((stmt) -> new Parameter(ClassHelper.dynamicType(), ""))
-            .toArray(Parameter[]::new);
-    }
-
-    private static ClassNode dummyReturnType(Statement outputs) {
-        var cn = new ClassNode(Record.class);
-        asDirectives(outputs)
-            .map(call -> emitName(call))
-            .filter(name -> name != null)
-            .forEach((name) -> {
-                var type = ClassHelper.dynamicType();
-                var fn = new FieldNode(name, Modifier.PUBLIC, type, cn, null);
-                fn.setDeclaringClass(cn);
-                cn.addField(fn);
-            });
-        return cn;
-    }
+public abstract class ProcessNode extends MethodNode {
 
-    private static String emitName(MethodCallExpression output) {
-        return Optional.of(output)
-            .flatMap(call -> Optional.ofNullable(asNamedArgs(call)))
-            .flatMap(namedArgs ->
-                namedArgs.stream()
-                    .filter(entry -> "emit".equals(entry.getKeyExpression().getText()))
-                    .findFirst()
-            )
-            .flatMap(entry -> Optional.ofNullable(
-                entry.getValueExpression() instanceof VariableExpression ve ? ve.getName() : null
-            ))
-            .orElse(null);
+    public ProcessNode(String name, Parameter[] parameters, ClassNode returnType) {
+        super(name, 0, returnType, parameters, ClassNode.EMPTY_ARRAY, EmptyStatement.INSTANCE);
     }
 }
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNodeV1.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNodeV1.java
new file mode 100644
index 0000000000..592114be48
--- /dev/null
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNodeV1.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 2024-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nextflow.script.ast;
+
+import java.lang.reflect.Modifier;
+import java.util.Optional;
+
+import nextflow.script.types.Record;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.Statement;
+
+import static nextflow.script.ast.ASTUtils.*;
+
+/**
+ * A legacy process definition.
+ *
+ * @author Ben Sherman 
+ */
+public class ProcessNodeV1 extends ProcessNode {
+    public final Statement directives;
+    public final Statement inputs;
+    public final Statement outputs;
+    public final Expression when;
+    public final String type;
+    public final Statement exec;
+    public final Statement stub;
+
+    public ProcessNodeV1(String name, Statement directives, Statement inputs, Statement outputs, Expression when, String type, Statement exec, Statement stub) {
+        super(name, dummyParams(inputs), dummyReturnType(outputs));
+        this.directives = directives;
+        this.inputs = inputs;
+        this.outputs = outputs;
+        this.when = when;
+        this.type = type;
+        this.exec = exec;
+        this.stub = stub;
+    }
+
+    private static Parameter[] dummyParams(Statement inputs) {
+        return asBlockStatements(inputs)
+            .stream()
+            .map((stmt) -> new Parameter(ClassHelper.dynamicType(), ""))
+            .toArray(Parameter[]::new);
+    }
+
+    private static ClassNode dummyReturnType(Statement outputs) {
+        var cn = new ClassNode(Record.class);
+        asDirectives(outputs)
+            .map(call -> emitName(call))
+            .filter(name -> name != null)
+            .forEach((name) -> {
+                var type = ClassHelper.dynamicType();
+                var fn = new FieldNode(name, Modifier.PUBLIC, type, cn, null);
+                fn.setDeclaringClass(cn);
+                cn.addField(fn);
+            });
+        return cn;
+    }
+
+    private static String emitName(MethodCallExpression output) {
+        return Optional.of(output)
+            .flatMap(call -> Optional.ofNullable(asNamedArgs(call)))
+            .flatMap(namedArgs ->
+                namedArgs.stream()
+                    .filter(entry -> "emit".equals(entry.getKeyExpression().getText()))
+                    .findFirst()
+            )
+            .flatMap(entry -> Optional.ofNullable(
+                entry.getValueExpression() instanceof VariableExpression ve ? ve.getName() : null
+            ))
+            .orElse(null);
+    }
+}
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNodeV2.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNodeV2.java
new file mode 100644
index 0000000000..6aec41e1b1
--- /dev/null
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ProcessNodeV2.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024-2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nextflow.script.ast;
+
+import java.lang.reflect.Modifier;
+
+import nextflow.script.types.Record;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+
+import static nextflow.script.ast.ASTUtils.*;
+
+/**
+ * A typed process definition.
+ *
+ * @author Ben Sherman 
+ */
+public class ProcessNodeV2 extends ProcessNode {
+    public final Statement directives;
+    public final Parameter[] inputs;
+    public final Statement stagers;
+    public final Statement outputs;
+    public final Statement topics;
+    public final Expression when;
+    public final String type;
+    public final Statement exec;
+    public final Statement stub;
+
+    public ProcessNodeV2(String name, Statement directives, Parameter[] inputs, Statement stagers, Statement outputs, Statement topics, Expression when, String type, Statement exec, Statement stub) {
+        super(name, inputs, dummyReturnType(outputs));
+        this.directives = directives;
+        this.inputs = inputs;
+        this.stagers = stagers;
+        this.outputs = outputs;
+        this.topics = topics;
+        this.when = when;
+        this.type = type;
+        this.exec = exec;
+        this.stub = stub;
+    }
+
+    /**
+     * Process outputs are represented as a single record, or
+     * a value if there is a single output expression.
+     *
+     * @param block
+     */
+    private static ClassNode dummyReturnType(Statement block) {
+        var outputs = asBlockStatements(block);
+        if( outputs.size() == 1 ) {
+            var first = outputs.get(0);
+            var output = ((ExpressionStatement) first).getExpression();
+            if( outputName(output) == null )
+                return output.getType();
+        }
+        var cn = new ClassNode(Record.class);
+        outputs.stream()
+            .map(stmt -> ((ExpressionStatement) stmt).getExpression())
+            .map(output -> outputName(output))
+            .filter(name -> name != null)
+            .forEach((name) -> {
+                var type = ClassHelper.dynamicType();
+                var fn = new FieldNode(name, Modifier.PUBLIC, type, cn, null);
+                fn.setDeclaringClass(cn);
+                cn.addField(fn);
+            });
+        return cn;
+    }
+
+    private static String outputName(Expression output) {
+        if( output instanceof VariableExpression ve ) {
+            return ve.getName();
+        }
+        else if( output instanceof AssignmentExpression ae ) {
+            var target = (VariableExpression)ae.getLeftExpression();
+            return target.getName();
+        }
+        return null;
+    }
+}
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java
index cc1ea845e4..7650c25823 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitor.java
@@ -37,6 +37,10 @@ public interface ScriptVisitor extends GroovyCodeVisitor {
 
     void visitProcess(ProcessNode node);
 
+    void visitProcessV2(ProcessNodeV2 node);
+
+    void visitProcessV1(ProcessNodeV1 node);
+
     void visitFunction(FunctionNode node);
 
     void visitEnum(ClassNode node);
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java
index 9f5142a169..c9b0608455 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/ScriptVisitorSupport.java
@@ -85,6 +85,25 @@ public void visitWorkflow(WorkflowNode node) {
 
     @Override
     public void visitProcess(ProcessNode node) {
+        if( node instanceof ProcessNodeV2 pn )
+            visitProcessV2(pn);
+        if( node instanceof ProcessNodeV1 pn )
+            visitProcessV1(pn);
+    }
+
+    @Override
+    public void visitProcessV2(ProcessNodeV2 node) {
+        visit(node.directives);
+        visit(node.stagers);
+        visit(node.outputs);
+        visit(node.topics);
+        visit(node.when);
+        visit(node.exec);
+        visit(node.stub);
+    }
+
+    @Override
+    public void visitProcessV1(ProcessNodeV1 node) {
         visit(node.directives);
         visit(node.inputs);
         visit(node.outputs);
diff --git a/modules/nf-lang/src/main/java/nextflow/script/ast/TupleParameter.java b/modules/nf-lang/src/main/java/nextflow/script/ast/TupleParameter.java
new file mode 100644
index 0000000000..28e2c088a3
--- /dev/null
+++ b/modules/nf-lang/src/main/java/nextflow/script/ast/TupleParameter.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nextflow.script.ast;
+
+import java.util.List;
+
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.Parameter;
+
+/**
+ * A parameter that destructures the components of a tuple
+ * by name.
+ *
+ * @author Ben Sherman 
+ */
+public class TupleParameter extends Parameter {
+    public final Parameter[] components;
+
+    public TupleParameter(ClassNode type, Parameter[] components) {
+        super(type, "");
+        this.components = components;
+    }
+}
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV1.java b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV1.java
new file mode 100644
index 0000000000..19988b1b34
--- /dev/null
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV1.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nextflow.script.control;
+
+import java.util.List;
+
+import nextflow.script.ast.ProcessNodeV1;
+import org.codehaus.groovy.ast.VariableScope;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.EmptyExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.TupleExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.EmptyStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.SourceUnit;
+
+import static nextflow.script.ast.ASTUtils.*;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*;
+
+/**
+ * Transform a legacy process AST node into Groovy AST.
+ *
+ * @author Ben Sherman 
+ */
+public class ProcessToGroovyVisitorV1 {
+
+    private SourceUnit sourceUnit;
+
+    private ScriptToGroovyHelper sgh;
+
+    public ProcessToGroovyVisitorV1(SourceUnit sourceUnit) {
+        this.sourceUnit = sourceUnit;
+        this.sgh = new ScriptToGroovyHelper(sourceUnit);
+    }
+
+    public Statement transform(ProcessNodeV1 node) {
+        visitProcessDirectives(node.directives);
+        visitProcessInputs(node.inputs);
+        visitProcessOutputs(node.outputs);
+
+        if( "script".equals(node.type) )
+            node.exec.visit(new TaskCmdXformVisitor(sourceUnit));
+        node.stub.visit(new TaskCmdXformVisitor(sourceUnit));
+
+        var closure = closureX(null, block(new VariableScope(), List.of(
+            node.directives,
+            node.inputs,
+            node.outputs,
+            processWhen(node.when),
+            processStub(node.stub),
+            stmt(createX(
+                "nextflow.script.BodyDef",
+                args(
+                    closureX(null, node.exec),
+                    constX(sgh.getSourceText(node.exec)),
+                    constX(node.type)
+                )
+            ))
+        )));
+        return stmt(callThisX("process", args(constX(node.getName()), closure)));
+    }
+
+    private void visitProcessDirectives(Statement directives) {
+        asDirectives(directives).forEach((call) -> {
+            var arguments = asMethodCallArguments(call);
+            if( arguments.size() != 1 )
+                return;
+            var firstArg = arguments.get(0);
+            if( firstArg instanceof ClosureExpression )
+                return;
+            arguments.set(0, sgh.transformToLazy(firstArg));
+        });
+    }
+
+    private void visitProcessInputs(Statement inputs) {
+        asDirectives(inputs).forEach((call) -> {
+            var name = call.getMethodAsString();
+            varToConstX(call.getArguments(), "tuple".equals(name), "each".equals(name));
+            call.setMethod( constX("_in_" + name) );
+        });
+    }
+
+    private void visitProcessOutputs(Statement outputs) {
+        asDirectives(outputs).forEach((call) -> {
+            var name = call.getMethodAsString();
+            varToConstX(call.getArguments(), "tuple".equals(name), false);
+            call.setMethod( constX("_out_" + name) );
+            visitProcessOutputEmitAndTopic(call);
+        });
+    }
+
+    private static final List EMIT_AND_TOPIC = List.of("emit", "topic");
+
+    private void visitProcessOutputEmitAndTopic(MethodCallExpression output) {
+        var namedArgs = asNamedArgs(output);
+        for( int i = 0; i < namedArgs.size(); i++ ) {
+            var entry = namedArgs.get(i);
+            var key = asConstX(entry.getKeyExpression());
+            var value = asVarX(entry.getValueExpression());
+            if( value != null && key != null && EMIT_AND_TOPIC.contains(key.getText()) ) {
+                namedArgs.set(i, entryX(key, constX(value.getText())));
+            }
+        }
+    }
+
+    private Expression varToConstX(Expression node, boolean withinTuple, boolean withinEach) {
+        if( node instanceof TupleExpression te ) {
+            var arguments = te.getExpressions();
+            for( int i = 0; i < arguments.size(); i++ )
+                arguments.set(i, varToConstX(arguments.get(i), withinTuple, withinEach));
+            return te;
+        }
+
+        if( node instanceof VariableExpression ve ) {
+            var name = ve.getName();
+
+            if( "stdin".equals(name) && withinTuple )
+                return createX( "nextflow.script.TokenStdinCall" );
+
+            if ( "stdout".equals(name) && withinTuple )
+                return createX( "nextflow.script.TokenStdoutCall" );
+
+            return createX( "nextflow.script.TokenVar", constX(name) );
+        }
+
+        if( node instanceof MethodCallExpression mce ) {
+            var name = mce.getMethodAsString();
+            var arguments = mce.getArguments();
+
+            if( "env".equals(name) && withinTuple )
+                return createX( "nextflow.script.TokenEnvCall", (TupleExpression) varToStrX(arguments) );
+
+            if( "eval".equals(name) && withinTuple )
+                return createX( "nextflow.script.TokenEvalCall", (TupleExpression) varToStrX(arguments) );
+
+            if( "file".equals(name) && (withinTuple || withinEach) )
+                return createX( "nextflow.script.TokenFileCall", (TupleExpression) varToConstX(arguments, withinTuple, withinEach) );
+
+            if( "path".equals(name) && (withinTuple || withinEach) )
+                return createX( "nextflow.script.TokenPathCall", (TupleExpression) varToConstX(arguments, withinTuple, withinEach) );
+
+            if( "val".equals(name) && withinTuple )
+                return createX( "nextflow.script.TokenValCall", (TupleExpression) varToStrX(arguments) );
+        }
+
+        return sgh.transformToLazy(node);
+    }
+
+    private Expression varToStrX(Expression node) {
+        if( node instanceof TupleExpression te ) {
+            var arguments = te.getExpressions();
+            for( int i = 0; i < arguments.size(); i++ )
+                arguments.set(i, varToStrX(arguments.get(i)));
+            return te;
+        }
+
+        if( node instanceof VariableExpression ve ) {
+            // before:
+            //   val(x)
+            // after:
+            //   val(TokenVar('x'))
+            var name = ve.getName();
+            return createX( "nextflow.script.TokenVar", constX(name) );
+        }
+
+        return sgh.transformToLazy(node);
+    }
+
+    private Statement processWhen(Expression when) {
+        if( when instanceof EmptyExpression )
+            return EmptyStatement.INSTANCE;
+        return stmt(callThisX("when", createX(
+            "nextflow.script.TaskClosure",
+            args(
+                closureX(null, block(stmt(when))),
+                constX(sgh.getSourceText(when))
+            )
+        )));
+    }
+
+    private Statement processStub(Statement stub) {
+        if( stub instanceof EmptyStatement )
+            return EmptyStatement.INSTANCE;
+        return stmt(callThisX("stub", createX(
+            "nextflow.script.TaskClosure",
+            args(
+                closureX(null, stub),
+                constX(sgh.getSourceText(stub))
+            )
+        )));
+    }
+
+}
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java
new file mode 100644
index 0000000000..74ea210bcd
--- /dev/null
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/ProcessToGroovyVisitorV2.java
@@ -0,0 +1,330 @@
+/*
+ * Copyright 2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nextflow.script.control;
+
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+
+import nextflow.script.ast.ASTNodeMarker;
+import nextflow.script.ast.AssignmentExpression;
+import nextflow.script.ast.ProcessNodeV2;
+import nextflow.script.ast.TupleParameter;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.CodeVisitorSupport;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.VariableScope;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.EmptyExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.EmptyStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.SourceUnit;
+
+import static nextflow.script.ast.ASTUtils.*;
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*;
+
+/**
+ * Transform a typed process AST node into Groovy AST.
+ *
+ * @author Ben Sherman 
+ */
+public class ProcessToGroovyVisitorV2 {
+
+    private SourceUnit sourceUnit;
+
+    private ScriptToGroovyHelper sgh;
+
+    public ProcessToGroovyVisitorV2(SourceUnit sourceUnit) {
+        this.sourceUnit = sourceUnit;
+        this.sgh = new ScriptToGroovyHelper(sourceUnit);
+    }
+
+    public Statement transform(ProcessNodeV2 node) {
+        visitProcessDirectives(node.directives);
+        visitProcessStagers(node.stagers);
+
+        var stagers = node.stagers instanceof BlockStatement block ? block : new BlockStatement();
+        visitProcessInputs(node.inputs, stagers);
+
+        var unstagers = new BlockStatement();
+        visitProcessUnstagers(node.outputs, unstagers);
+        visitProcessUnstagers(node.topics, unstagers);
+
+        if( "script".equals(node.type) )
+            node.exec.visit(new TaskCmdXformVisitor(sourceUnit));
+        node.stub.visit(new TaskCmdXformVisitor(sourceUnit));
+
+        var body = closureX(block(new VariableScope(), List.of(
+            node.directives,
+            stagers,
+            unstagers,
+            processInputs(node.inputs),
+            processOutputs(node.outputs),
+            processTopics(node.topics),
+            processWhen(node.when),
+            processStub(node.stub),
+            stmt(createX(
+                "nextflow.script.BodyDef",
+                args(
+                    closureX(node.exec),
+                    constX(sgh.getSourceText(node.exec)),
+                    constX(node.type)
+                )
+            ))
+        )));
+        return stmt(callThisX("processV2", args(constX(node.getName()), body)));
+    }
+
+    private void visitProcessDirectives(Statement directives) {
+        asDirectives(directives).forEach((call) -> {
+            var arguments = asMethodCallArguments(call);
+            if( arguments.size() != 1 )
+                return;
+            var firstArg = arguments.get(0);
+            if( firstArg instanceof ClosureExpression )
+                return;
+            arguments.set(0, sgh.transformToLazy(firstArg));
+        });
+    }
+
+    private void visitProcessStagers(Statement directives) {
+        asDirectives(directives).forEach((call) -> {
+            var arguments = asMethodCallArguments(call).stream()
+                .map(arg -> sgh.transformToLazy(arg))
+                .toList();
+            call.setArguments(args(arguments));
+        });
+    }
+
+    private void visitProcessInputs(Parameter[] inputs, BlockStatement stagers) {
+        for( var param : asFlatParams(inputs) ) {
+            if( isPathType(param.getType()) ) {
+                var ve = varX(param.getName());
+                var stager = stmt(callThisX("stageAs", args(closureX(stmt(ve)))));
+                stagers.addStatement(stager);
+            }
+        }
+    }
+
+    private static boolean isPathType(ClassNode cn) {
+        if( !cn.isResolved() )
+            return false;
+        var tn = new TypeNode(cn);
+        var type = tn.type;
+        if( Path.class.isAssignableFrom(type) ) {
+            return true;
+        }
+        if( Collection.class.isAssignableFrom(type) && tn.genericTypes != null ) {
+            var genericType = tn.genericTypes.get(0);
+            return Path.class.isAssignableFrom(genericType);
+        }
+        return false;
+    }
+
+    private static class TypeNode {
+        final Class type;
+        final List genericTypes;
+
+        public TypeNode(ClassNode cn) {
+            this.type = cn.getTypeClass();
+            if( cn.isUsingGenerics() ) {
+                this.genericTypes = Arrays.stream(cn.getGenericsTypes())
+                    .map(el -> el.getType().getTypeClass())
+                    .toList();
+            }
+            else {
+                this.genericTypes = null;
+            }
+        }
+    }
+
+    private void visitProcessUnstagers(Statement outputs, BlockStatement unstagers) {
+        var visitor = new ProcessUnstageVisitor(unstagers);
+        for( var output : asBlockStatements(outputs) )
+            visitor.visit(output);
+    }
+
+    private static class ProcessUnstageVisitor extends CodeVisitorSupport {
+
+        private int evalCount = 0;
+
+        private int pathCount = 0;
+
+        private BlockStatement unstagers;
+
+        public ProcessUnstageVisitor(BlockStatement unstagers) {
+            this.unstagers = unstagers;
+        }
+
+        @Override
+        public void visitMethodCallExpression(MethodCallExpression node) {
+            extractUnstageDirective(node);
+            super.visitMethodCallExpression(node);
+        }
+
+        private void extractUnstageDirective(MethodCallExpression node) {
+            if( !node.isImplicitThis() )
+                return;
+
+            var name = node.getMethodAsString();
+            var arguments = asMethodCallArguments(node);
+
+            // env()
+            // emit: _unstage_env()
+            if( "env".equals(name) && arguments.size() == 1 ) {
+                var key = arguments.get(0);
+                var unstager = stmt(callThisX("_unstage_env", args(key)));
+                unstagers.addStatement(unstager);
+
+                // rename to _env() to prevent dispatch to equivalent ScriptDsl function
+                node.setMethod(constX("_" + name));
+            }
+
+            // eval() -> eval()
+            // emit: _unstage_eval(, {  })
+            if( "eval".equals(name) && arguments.size() == 1 ) {
+                var key = constX("nxf_out_eval_" + (evalCount++));
+                var cmd = arguments.get(0);
+                var unstager = stmt(callThisX("_unstage_eval", args(key, closureX(stmt(cmd)))));
+                unstagers.addStatement(unstager);
+                node.setArguments(args(key));
+            }
+
+            // file(, ) -> file(, )
+            // files(, ) -> files(, )
+            // emit: _unstage_files(, {  })
+            if( "file".equals(name) || "files".equals(name) ) {
+                Expression opts;
+                Expression pattern;
+                if( arguments.size() == 1 ) {
+                    opts = new MapExpression();
+                    pattern = arguments.get(0);
+                }
+                else if( arguments.size() == 2 ) {
+                    opts = arguments.get(0);
+                    pattern = arguments.get(1);
+                }
+                else {
+                    return;
+                }
+
+                var key = constX("$path" + (pathCount++));
+                var unstager = stmt(callThisX("_unstage_files", args(key, closureX(stmt(pattern)))));
+                unstagers.addStatement(unstager);
+
+                // rename to _file() or _files() to prevent dispatch to equivalent ScriptDsl functions
+                node.setMethod(constX("_" + name));
+                node.setArguments(args(opts, key));
+            }
+        }
+    }
+
+    private Statement processInputs(Parameter[] inputs) {
+        var statements = Arrays.stream(inputs)
+            .map((input) -> {
+                if( input instanceof TupleParameter tp ) {
+                    var components = Arrays.stream(tp.components)
+                        .map(p -> processInputCtor(p))
+                        .toList();
+                    var type = input.getType();
+                    return stmt(callThisX("_input_", args(listX(components), classX(type))));
+                }
+                else {
+                    var name = input.getName();
+                    var type = input.getType();
+                    var optional = type.getNodeMetaData(ASTNodeMarker.NULLABLE) != null;
+                    return stmt(callThisX("_input_", args(constX(name), classX(type), constX(optional))));
+                }
+            })
+            .toList();
+        return block(null, statements);
+    }
+
+    private Expression processInputCtor(Parameter param) {
+        return createX(
+            "nextflow.script.params.v2.ProcessInput",
+            args(
+                constX(param.getName()),
+                classX(param.getType()),
+                constX(param.getType().getNodeMetaData(ASTNodeMarker.NULLABLE) != null)
+            )
+        );
+    }
+
+    private Statement processOutputs(Statement outputs) {
+        var statements = asBlockStatements(outputs).stream()
+            .map(stmt -> ((ExpressionStatement) stmt).getExpression())
+            .map((output) -> {
+                if( output instanceof VariableExpression target ) {
+                    return stmt(callThisX("_output_", args(constX(target.getName()), classX(target.getType()), closureX(stmt(target)))));
+                }
+                else if( output instanceof AssignmentExpression ae ) {
+                    var target = (VariableExpression)ae.getLeftExpression();
+                    return stmt(callThisX("_output_", args(constX(target.getName()), classX(target.getType()), closureX(stmt(ae.getRightExpression())))));
+                }
+                else {
+                    return stmt(callThisX("_output_", args(constX("$out"), classX(ClassHelper.dynamicType()), closureX(stmt(output)))));
+                }
+            })
+            .toList();
+        return block(null, statements);
+    }
+
+    private Statement processTopics(Statement topics) {
+        var statements = asBlockStatements(topics).stream()
+            .map((stmt) -> {
+                var es = (ExpressionStatement) stmt;
+                var be = (BinaryExpression) es.getExpression();
+                return stmt(callThisX("_topic_", args(closureX(stmt(be.getLeftExpression())), be.getRightExpression())));
+            })
+            .toList();
+        return block(null, statements);
+    }
+
+    private Statement processWhen(Expression when) {
+        if( when instanceof EmptyExpression )
+            return EmptyStatement.INSTANCE;
+        return stmt(callThisX("when", createX(
+            "nextflow.script.TaskClosure",
+            args(
+                closureX(null, block(stmt(when))),
+                constX(sgh.getSourceText(when))
+            )
+        )));
+    }
+
+    private Statement processStub(Statement stub) {
+        if( stub instanceof EmptyStatement )
+            return EmptyStatement.INSTANCE;
+        return stmt(callThisX("stub", createX(
+            "nextflow.script.TaskClosure",
+            args(
+                closureX(null, stub),
+                constX(sgh.getSourceText(stub))
+            )
+        )));
+    }
+
+}
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ResolveVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ResolveVisitor.java
index f55605b423..7bdcd81fdd 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/control/ResolveVisitor.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/ResolveVisitor.java
@@ -22,7 +22,6 @@
 
 import groovy.lang.Tuple2;
 import nextflow.script.ast.ASTNodeMarker;
-import nextflow.script.types.Bag;
 import org.codehaus.groovy.GroovyBugError;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.ClassCodeExpressionTransformer;
@@ -62,14 +61,16 @@
 public class ResolveVisitor extends ClassCodeExpressionTransformer {
 
     public static final ClassNode[] STANDARD_TYPES = {
-        ClassHelper.makeCached(Bag.class),
+        ClassHelper.makeCached(nextflow.script.types.Bag.class),
         ClassHelper.Boolean_TYPE,
+        ClassHelper.Float_TYPE,
         ClassHelper.Integer_TYPE,
-        ClassHelper.Number_TYPE,
-        ClassHelper.STRING_TYPE,
         ClassHelper.LIST_TYPE,
         ClassHelper.MAP_TYPE,
-        ClassHelper.SET_TYPE
+        ClassHelper.makeCached(java.nio.file.Path.class),
+        ClassHelper.SET_TYPE,
+        ClassHelper.STRING_TYPE,
+        ClassHelper.makeCached(nextflow.script.types.Tuple.class)
     };
 
     private SourceUnit sourceUnit;
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java
index e308d24cdb..603c2f3845 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptResolveVisitor.java
@@ -22,7 +22,8 @@
 import nextflow.script.ast.FunctionNode;
 import nextflow.script.ast.OutputNode;
 import nextflow.script.ast.ParamNodeV1;
-import nextflow.script.ast.ProcessNode;
+import nextflow.script.ast.ProcessNodeV1;
+import nextflow.script.ast.ProcessNodeV2;
 import nextflow.script.ast.ScriptNode;
 import nextflow.script.ast.ScriptVisitorSupport;
 import nextflow.script.ast.WorkflowNode;
@@ -124,7 +125,20 @@ private void resolveWorkflowEmits(Statement emits) {
     }
 
     @Override
-    public void visitProcess(ProcessNode node) {
+    public void visitProcessV2(ProcessNodeV2 node) {
+        for( var input : node.inputs )
+            resolver.resolveOrFail(input.getType(), input);
+        resolver.visit(node.directives);
+        resolver.visit(node.stagers);
+        resolver.visit(node.outputs);
+        resolver.visit(node.topics);
+        resolver.visit(node.when);
+        resolver.visit(node.exec);
+        resolver.visit(node.stub);
+    }
+
+    @Override
+    public void visitProcessV1(ProcessNodeV1 node) {
         resolver.visit(node.directives);
         resolver.visit(node.inputs);
         resolver.visit(node.outputs);
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyHelper.java b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyHelper.java
new file mode 100644
index 0000000000..467c9c8c04
--- /dev/null
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyHelper.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2025, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package nextflow.script.control;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.codehaus.groovy.ast.CodeVisitorSupport;
+import org.codehaus.groovy.ast.Variable;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.VariableExpression;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.SourceUnit;
+
+import static org.codehaus.groovy.ast.tools.GeneralUtils.*;
+
+/**
+ * Utility functions for ScriptToGroovyVisitor.
+ *
+ * @author Ben Sherman 
+ */
+public class ScriptToGroovyHelper {
+
+    private SourceUnit sourceUnit;
+
+    public ScriptToGroovyHelper(SourceUnit sourceUnit) {
+        this.sourceUnit = sourceUnit;
+    }
+
+    /**
+     * Transform an expression into a lazy expression by
+     * wrapping it in a closure if it references variables.
+     *
+     * @param node
+     */
+    public Expression transformToLazy(Expression node)  {
+        if( node instanceof ClosureExpression )
+            return node;
+        var vars = new VariableCollector().collect(node);
+        if( !vars.isEmpty() )
+            return closureX(stmt(node));
+        return node;
+    }
+
+    private class VariableCollector extends CodeVisitorSupport {
+
+        private Set vars;
+
+        private Set declaredParams;
+
+        public Set collect(Expression node) {
+            vars = new HashSet<>();
+            declaredParams = new HashSet<>();
+            visit(node);
+            return vars;
+        }
+
+        @Override
+        public void visitClosureExpression(ClosureExpression node) {
+            if( node.getParameters() != null ) {
+                for( var param : node.getParameters() )
+                    declaredParams.add(param);
+            }
+        }
+
+        @Override
+        public void visitVariableExpression(VariableExpression node) {
+            var variable = node.getAccessedVariable();
+            if( variable != null && !declaredParams.contains(variable) )
+                vars.add(variable);
+        }
+    }
+
+    /**
+     * Get the source text for a statement.
+     *
+     * @param node
+     */
+    public String getSourceText(Statement node) {
+        var builder = new StringBuilder();
+        var colx = node.getColumnNumber();
+        var colz = node.getLastColumnNumber();
+        var first = node.getLineNumber();
+        var last = node.getLastLineNumber();
+        for( int i = first; i <= last; i++ ) {
+            var line = sourceUnit.getSource().getLine(i, null);
+
+            // prepend first-line indent
+            if( i == first ) {
+                int k = 0;
+                while( k < line.length() && line.charAt(k) == ' ' )
+                    k++;
+                builder.append( line.substring(0, k) );
+            }
+
+            var begin = (i == first) ? colx - 1 : 0;
+            var end = (i == last) ? colz - 1 : line.length();
+            builder.append( line.substring(begin, end) ).append('\n');
+        }
+        return builder.toString();
+    }
+
+    /**
+     * Get the source text for an expression.
+     *
+     * @param node
+     */
+    public String getSourceText(Expression node) {
+        var stm = stmt(node);
+        stm.setSourcePosition(node);
+        return getSourceText(stm);
+    }
+
+}
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java
index 01e65e04d4..7e9bffb1a4 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/ScriptToGroovyVisitor.java
@@ -16,7 +16,6 @@
 package nextflow.script.control;
 
 import java.util.Arrays;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
@@ -28,27 +27,20 @@
 import nextflow.script.ast.OutputBlockNode;
 import nextflow.script.ast.ParamBlockNode;
 import nextflow.script.ast.ParamNodeV1;
-import nextflow.script.ast.ProcessNode;
+import nextflow.script.ast.ProcessNodeV1;
+import nextflow.script.ast.ProcessNodeV2;
 import nextflow.script.ast.ScriptNode;
 import nextflow.script.ast.ScriptVisitorSupport;
 import nextflow.script.ast.WorkflowNode;
 import org.codehaus.groovy.ast.ASTNode;
 import org.codehaus.groovy.ast.CodeVisitorSupport;
 import org.codehaus.groovy.ast.Parameter;
-import org.codehaus.groovy.ast.Variable;
 import org.codehaus.groovy.ast.VariableScope;
-import org.codehaus.groovy.ast.expr.ArgumentListExpression;
 import org.codehaus.groovy.ast.expr.BinaryExpression;
-import org.codehaus.groovy.ast.expr.ClosureExpression;
-import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
-import org.codehaus.groovy.ast.expr.EmptyExpression;
 import org.codehaus.groovy.ast.expr.Expression;
 import org.codehaus.groovy.ast.expr.MethodCallExpression;
-import org.codehaus.groovy.ast.expr.PropertyExpression;
-import org.codehaus.groovy.ast.expr.TupleExpression;
 import org.codehaus.groovy.ast.expr.VariableExpression;
 import org.codehaus.groovy.ast.stmt.BlockStatement;
-import org.codehaus.groovy.ast.stmt.EmptyStatement;
 import org.codehaus.groovy.ast.stmt.ExpressionStatement;
 import org.codehaus.groovy.ast.stmt.ReturnStatement;
 import org.codehaus.groovy.ast.stmt.Statement;
@@ -75,9 +67,12 @@ public class ScriptToGroovyVisitor extends ScriptVisitorSupport {
 
     private ScriptNode moduleNode;
 
+    private ScriptToGroovyHelper sgh;
+
     public ScriptToGroovyVisitor(SourceUnit sourceUnit) {
         this.sourceUnit = sourceUnit;
         this.moduleNode = (ScriptNode) sourceUnit.getAST();
+        this.sgh = new ScriptToGroovyHelper(sourceUnit);
     }
 
     @Override
@@ -219,204 +214,15 @@ private void visitWorkflowHandler(Statement code, String name, BlockStatement ma
     }
 
     @Override
-    public void visitProcess(ProcessNode node) {
-        visitProcessDirectives(node.directives);
-        visitProcessInputs(node.inputs);
-        visitProcessOutputs(node.outputs);
-
-        if( "script".equals(node.type) )
-            node.exec.visit(new TaskCmdXformVisitor(sourceUnit));
-        node.stub.visit(new TaskCmdXformVisitor(sourceUnit));
-
-        var when = processWhen(node.when);
-        var bodyDef = stmt(createX(
-            "nextflow.script.BodyDef",
-            args(
-                closureX(null, node.exec),
-                constX(getSourceText(node.exec)),
-                constX(node.type)
-            )
-        ));
-        var stub = processStub(node.stub);
-        var closure = closureX(null, block(new VariableScope(), List.of(
-            node.directives,
-            node.inputs,
-            node.outputs,
-            when,
-            stub,
-            bodyDef
-        )));
-        var result = stmt(callThisX("process", args(constX(node.getName()), closure)));
+    public void visitProcessV2(ProcessNodeV2 node) {
+        var result = new ProcessToGroovyVisitorV2(sourceUnit).transform(node);
         moduleNode.addStatement(result);
     }
 
-    private void visitProcessDirectives(Statement directives) {
-        asDirectives(directives).forEach((call) -> {
-            var arguments = ((TupleExpression) call.getArguments()).getExpressions();
-            if( arguments.size() != 1 )
-                return;
-            var firstArg = arguments.get(0);
-            if( firstArg instanceof ClosureExpression )
-                return;
-            arguments.set(0, transformToLazy(firstArg));
-        });
-    }
-
-    private void visitProcessInputs(Statement inputs) {
-        asDirectives(inputs).forEach((call) -> {
-            var name = call.getMethodAsString();
-            varToConstX(call.getArguments(), "tuple".equals(name), "each".equals(name));
-            call.setMethod( constX("_in_" + name) );
-        });
-    }
-
-    private void visitProcessOutputs(Statement outputs) {
-        asDirectives(outputs).forEach((call) -> {
-            var name = call.getMethodAsString();
-            varToConstX(call.getArguments(), "tuple".equals(name), false);
-            call.setMethod( constX("_out_" + name) );
-            visitProcessOutputEmitAndTopic(call);
-        });
-    }
-
-    private static final List EMIT_AND_TOPIC = List.of("emit", "topic");
-
-    private void visitProcessOutputEmitAndTopic(MethodCallExpression output) {
-        var namedArgs = asNamedArgs(output);
-        for( int i = 0; i < namedArgs.size(); i++ ) {
-            var entry = namedArgs.get(i);
-            var key = asConstX(entry.getKeyExpression());
-            var value = asVarX(entry.getValueExpression());
-            if( value != null && key != null && EMIT_AND_TOPIC.contains(key.getText()) ) {
-                namedArgs.set(i, entryX(key, constX(value.getText())));
-            }
-        }
-    }
-
-    private Expression varToConstX(Expression node, boolean withinTuple, boolean withinEach) {
-        if( node instanceof TupleExpression te ) {
-            var arguments = te.getExpressions();
-            for( int i = 0; i < arguments.size(); i++ )
-                arguments.set(i, varToConstX(arguments.get(i), withinTuple, withinEach));
-            return te;
-        }
-
-        if( node instanceof VariableExpression ve ) {
-            var name = ve.getName();
-
-            if( "stdin".equals(name) && withinTuple )
-                return createX( "nextflow.script.TokenStdinCall" );
-
-            if ( "stdout".equals(name) && withinTuple )
-                return createX( "nextflow.script.TokenStdoutCall" );
-
-            return createX( "nextflow.script.TokenVar", constX(name) );
-        }
-
-        if( node instanceof MethodCallExpression mce ) {
-            var name = mce.getMethodAsString();
-            var arguments = mce.getArguments();
-
-            if( "env".equals(name) && withinTuple )
-                return createX( "nextflow.script.TokenEnvCall", (TupleExpression) varToStrX(arguments) );
-
-            if( "eval".equals(name) && withinTuple )
-                return createX( "nextflow.script.TokenEvalCall", (TupleExpression) varToStrX(arguments) );
-
-            if( "file".equals(name) && (withinTuple || withinEach) )
-                return createX( "nextflow.script.TokenFileCall", (TupleExpression) varToConstX(arguments, withinTuple, withinEach) );
-
-            if( "path".equals(name) && (withinTuple || withinEach) )
-                return createX( "nextflow.script.TokenPathCall", (TupleExpression) varToConstX(arguments, withinTuple, withinEach) );
-
-            if( "val".equals(name) && withinTuple )
-                return createX( "nextflow.script.TokenValCall", (TupleExpression) varToStrX(arguments) );
-        }
-
-        return transformToLazy(node);
-    }
-
-    private Expression varToStrX(Expression node) {
-        if( node instanceof TupleExpression te ) {
-            var arguments = te.getExpressions();
-            for( int i = 0; i < arguments.size(); i++ )
-                arguments.set(i, varToStrX(arguments.get(i)));
-            return te;
-        }
-
-        if( node instanceof VariableExpression ve ) {
-            // before:
-            //   val(x)
-            // after:
-            //   val(TokenVar('x'))
-            var name = ve.getName();
-            return createX( "nextflow.script.TokenVar", constX(name) );
-        }
-
-        return transformToLazy(node);
-    }
-
-    private Expression transformToLazy(Expression node)  {
-        if( node instanceof ClosureExpression )
-            return node;
-        // wrap expression in closure if it references variables
-        var vars = new VariableCollector().collect(node);
-        if( !vars.isEmpty() )
-            return closureX(block(stmt(node)));
-        return node;
-    }
-
-    private class VariableCollector extends CodeVisitorSupport {
-
-        private Set vars;
-
-        private Set declaredParams;
-
-        public Set collect(Expression node) {
-            vars = new HashSet<>();
-            declaredParams = new HashSet<>();
-            visit(node);
-            return vars;
-        }
-
-        @Override
-        public void visitClosureExpression(ClosureExpression node) {
-            if( node.getParameters() != null ) {
-                for( var param : node.getParameters() )
-                    declaredParams.add(param);
-            }
-        }
-
-        @Override
-        public void visitVariableExpression(VariableExpression node) {
-            var variable = node.getAccessedVariable();
-            if( variable != null && !declaredParams.contains(variable) )
-                vars.add(variable);
-        }
-    }
-
-    private Statement processWhen(Expression when) {
-        if( when instanceof EmptyExpression )
-            return EmptyStatement.INSTANCE;
-        return stmt(callThisX("when", createX(
-            "nextflow.script.TaskClosure",
-            args(
-                closureX(null, block(stmt(when))),
-                constX(getSourceText(when))
-            )
-        )));
-    }
-
-    private Statement processStub(Statement stub) {
-        if( stub instanceof EmptyStatement )
-            return EmptyStatement.INSTANCE;
-        return stmt(callThisX("stub", createX(
-            "nextflow.script.TaskClosure",
-            args(
-                closureX(null, stub),
-                constX(getSourceText(stub))
-            )
-        )));
+    @Override
+    public void visitProcessV1(ProcessNodeV1 node) {
+        var result = new ProcessToGroovyVisitorV1(sourceUnit).transform(node);
+        moduleNode.addStatement(result);
     }
 
     @Override
@@ -443,39 +249,9 @@ public void visitOutputs(OutputBlockNode node) {
         moduleNode.addStatement(result);
     }
 
-    private String getSourceText(Statement node) {
-        var builder = new StringBuilder();
-        var colx = node.getColumnNumber();
-        var colz = node.getLastColumnNumber();
-        var first = node.getLineNumber();
-        var last = node.getLastLineNumber();
-        for( int i = first; i <= last; i++ ) {
-            var line = sourceUnit.getSource().getLine(i, null);
-
-            // prepend first-line indent
-            if( i == first ) {
-                int k = 0;
-                while( k < line.length() && line.charAt(k) == ' ' )
-                    k++;
-                builder.append( line.substring(0, k) );
-            }
-
-            var begin = (i == first) ? colx - 1 : 0;
-            var end = (i == last) ? colz - 1 : line.length();
-            builder.append( line.substring(begin, end) ).append('\n');
-        }
-        return builder.toString();
-    }
-
-    private String getSourceText(Expression node) {
-        var stm = stmt(node);
-        stm.setSourcePosition(node);
-        return getSourceText(stm);
-    }
-
     private String getSourceText(WorkflowNode node) {
         if( node.isEntry() && node.getLineNumber() == -1 )
-            return getSourceText(node.main);
+            return sgh.getSourceText(node.main);
 
         var builder = new StringBuilder();
         var colx = node.getColumnNumber();
diff --git a/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java
index 51ed19631b..4ded46998d 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/control/VariableScopeVisitor.java
@@ -27,8 +27,11 @@
 import nextflow.script.ast.OutputNode;
 import nextflow.script.ast.ParamBlockNode;
 import nextflow.script.ast.ProcessNode;
+import nextflow.script.ast.ProcessNodeV1;
+import nextflow.script.ast.ProcessNodeV2;
 import nextflow.script.ast.ScriptNode;
 import nextflow.script.ast.ScriptVisitorSupport;
+import nextflow.script.ast.TupleParameter;
 import nextflow.script.ast.WorkflowNode;
 import nextflow.script.dsl.Constant;
 import nextflow.script.dsl.EntryWorkflowDsl;
@@ -43,6 +46,7 @@
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.DynamicVariable;
 import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
 import org.codehaus.groovy.ast.Variable;
 import org.codehaus.groovy.ast.VariableScope;
 import org.codehaus.groovy.ast.expr.BinaryExpression;
@@ -200,8 +204,8 @@ public void visitWorkflow(WorkflowNode node) {
         if( node.main instanceof BlockStatement block )
             copyVariableScope(block.getVariableScope());
 
-        visitWorkflowOutputs(node.emits, "emit");
-        visitWorkflowOutputs(node.publishers, "output");
+        visitTypedOutputs(node.emits, "Workflow emit");
+        visitTypedOutputs(node.publishers, "Workflow output");
 
         visit(node.onComplete);
         visit(node.onError);
@@ -217,7 +221,7 @@ private void copyVariableScope(VariableScope source) {
         }
     }
 
-    private void visitWorkflowOutputs(Statement outputs, String typeLabel) {
+    private void visitTypedOutputs(Statement outputs, String typeLabel) {
         var declaredOutputs = new HashMap();
         for( var stmt : asBlockStatements(outputs) ) {
             var es = (ExpressionStatement)stmt;
@@ -229,7 +233,7 @@ private void visitWorkflowOutputs(Statement outputs, String typeLabel) {
                 var name = target.getName();
                 var other = declaredOutputs.get(name);
                 if( other != null )
-                    vsc.addError("Workflow " + typeLabel + " `" + name + "` is already declared", target, "First declared here", other);
+                    vsc.addError(typeLabel + " `" + name + "` is already declared", target, "First declared here", other);
                 else
                     declaredOutputs.put(name, target);
             }
@@ -240,14 +244,47 @@ private void visitWorkflowOutputs(Statement outputs, String typeLabel) {
     }
 
     @Override
-    public void visitProcess(ProcessNode node) {
+    public void visitProcessV2(ProcessNodeV2 node) {
         vsc.pushScope(ProcessDsl.class);
         currentDefinition = node;
         node.setVariableScope(currentScope());
 
-        declareProcessInputs(node.inputs);
+        for( var input : asFlatParams(node.inputs) )
+            vsc.declare(input, input);
 
-        vsc.pushScope(ProcessDsl.InputDsl.class);
+        vsc.pushScope(ProcessDsl.StageDsl.class);
+        visitDirectives(node.stagers, "stage directive", false);
+        vsc.popScope();
+
+        if( !(node.when instanceof EmptyExpression) )
+            vsc.addParanoidWarning("Process `when` section will not be supported in a future version", node.when);
+        visit(node.when);
+
+        visit(node.exec);
+        visit(node.stub);
+
+        vsc.pushScope(ProcessDsl.DirectiveDsl.class);
+        visitDirectives(node.directives, "process directive", false);
+        vsc.popScope();
+
+        vsc.pushScope(ProcessDsl.OutputDslV2.class);
+        visitTypedOutputs(node.outputs, "Process output");
+        visit(node.topics);
+        vsc.popScope();
+
+        currentDefinition = null;
+        vsc.popScope();
+    }
+
+    @Override
+    public void visitProcessV1(ProcessNodeV1 node) {
+        vsc.pushScope(ProcessDsl.class);
+        currentDefinition = node;
+        node.setVariableScope(currentScope());
+
+        declareProcessInputsV1(node.inputs);
+
+        vsc.pushScope(ProcessDsl.InputDslV1.class);
         visitDirectives(node.inputs, "process input qualifier", false);
         vsc.popScope();
 
@@ -262,7 +299,7 @@ public void visitProcess(ProcessNode node) {
         visitDirectives(node.directives, "process directive", false);
         vsc.popScope();
 
-        vsc.pushScope(ProcessDsl.OutputDsl.class);
+        vsc.pushScope(ProcessDsl.OutputDslV1.class);
         visitDirectives(node.outputs, "process output qualifier", false);
         vsc.popScope();
 
@@ -270,7 +307,7 @@ public void visitProcess(ProcessNode node) {
         vsc.popScope();
     }
 
-    private void declareProcessInputs(Statement inputs) {
+    private void declareProcessInputsV1(Statement inputs) {
         for( var stmt : asBlockStatements(inputs) ) {
             var call = asMethodCallX(stmt);
             if( call == null )
@@ -340,7 +377,7 @@ private MethodCallExpression checkDirective(Statement node, String typeLabel, bo
     @Override
     public void visitMapEntryExpression(MapEntryExpression node) {
         var classScope = currentScope().getClassScope();
-        if( classScope != null && classScope.getTypeClass() == ProcessDsl.OutputDsl.class ) {
+        if( classScope != null && classScope.getTypeClass() == ProcessDsl.OutputDslV1.class ) {
             var key = node.getKeyExpression();
             if( key instanceof ConstantExpression && EMIT_AND_TOPIC.contains(key.getText()) )
                 return;
@@ -681,9 +718,9 @@ else if( isStdinStdout(name) ) {
     private boolean isStdinStdout(String name) {
         var classScope = currentScope().getClassScope();
         if( classScope != null ) {
-            if( "stdin".equals(name) && classScope.getTypeClass() == ProcessDsl.InputDsl.class )
+            if( "stdin".equals(name) && classScope.getTypeClass() == ProcessDsl.InputDslV1.class )
                 return true;
-            if( "stdout".equals(name) && classScope.getTypeClass() == ProcessDsl.OutputDsl.class )
+            if( "stdout".equals(name) && classScope.getTypeClass() == ProcessDsl.OutputDslV1.class )
                 return true;
         }
         return false;
diff --git a/modules/nf-lang/src/main/java/nextflow/script/dsl/FeatureFlagDsl.java b/modules/nf-lang/src/main/java/nextflow/script/dsl/FeatureFlagDsl.java
index 9355649f83..782a58ebe2 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/dsl/FeatureFlagDsl.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/dsl/FeatureFlagDsl.java
@@ -51,8 +51,16 @@ Defines the DSL version (`1` or `2`).
 
     @FeatureFlag("nextflow.preview.recursion")
     @Description("""
-        When `true`, enables the use of [process and workflow recursion](https://github.com/nextflow-io/nextflow/discussions/2521).
+        When `true`, enables the use of [process and workflow recursion](https://nextflow.io/docs/latest/workflow.html#process-and-workflow-recursion).
     """)
     public boolean previewRecursion;
 
+    @FeatureFlag("nextflow.preview.types")
+    @Description("""
+        When `true`, enables the use of [typed processes](https://nextflow.io/docs/latest/process-typed.html).
+
+        This feature flag must be enabled in every script that uses typed processes. Legacy processes can not be defined in scripts that enable this feature flag.
+    """)
+    public boolean previewTypes;
+
 }
diff --git a/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java b/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java
index 75e51c1e04..950880ffd7 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/dsl/ProcessDsl.java
@@ -18,6 +18,7 @@
 import java.nio.file.Path;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 import nextflow.script.types.Duration;
 import nextflow.script.types.MemoryUnit;
@@ -325,7 +326,31 @@ interface DirectiveDsl extends DslScope {
 
     }
 
-    interface InputDsl extends DslScope {
+    interface StageDsl extends DslScope {
+
+        @Description("""
+            Declare an environment variable in the task environment with the given name and value.
+        """)
+        void env(String name, String value);
+
+        @Description("""
+            Stage a file into the task directory under the given alias.
+        """)
+        void stageAs(String filePattern, Path value);
+
+        @Description("""
+            Stage a collection of files into the task directory under the given alias.
+        """)
+        void stageAs(String filePattern, Iterable value);
+
+        @Description("""
+            Stage the given value as the standard input (i.e. `stdin`) to the task script.
+        """)
+        void stdin(String value);
+
+    }
+
+    interface InputDslV1 extends DslScope {
 
         @Description("""
             Declare a variable input. The received value can be any type, and it will be made available to the process body (i.e. `script`, `shell`, `exec`) as a variable with the given name.
@@ -369,7 +394,38 @@ Declare a variable input. The received value can be any type, and it will be mad
 
     }
 
-    interface OutputDsl extends DslScope {
+    interface OutputDslV2 extends DslScope {
+
+        @Description("""
+            Get the value of an environment variable from the task environment.
+        """)
+        String env(String name);
+
+        @Description("""
+            Get the standard output of the given command, which is executed in the task environment after the task script.
+        """)
+        String eval(String command);
+
+        @Description("""
+            Get a file from the task environment that matches the given pattern.
+        """)
+        Path file(Map opts, String name);
+        Path file(String name);
+
+        @Description("""
+            Get the files from the task environment that match the given pattern.
+        """)
+        Set files(Map opts, String pattern);
+        Set files(String pattern);
+
+        @Description("""
+            Get the standard output of the task script.
+        """)
+        String stdout();
+
+    }
+
+    interface OutputDslV1 extends DslScope {
 
         @Description("""
             Declare a value output. The argument can be any value, and it can reference any output variables defined in the process body (i.e. variables declared without the `def` keyword).
diff --git a/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java b/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java
index 273c9a791e..bb20962385 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/formatter/ScriptFormattingVisitor.java
@@ -18,6 +18,7 @@
 import java.util.Arrays;
 import java.util.Comparator;
 import java.util.List;
+import java.util.stream.Collectors;
 
 import nextflow.script.ast.AssignmentExpression;
 import nextflow.script.ast.FeatureFlagNode;
@@ -29,8 +30,11 @@
 import nextflow.script.ast.ParamNodeV1;
 import nextflow.script.ast.ParamBlockNode;
 import nextflow.script.ast.ProcessNode;
+import nextflow.script.ast.ProcessNodeV1;
+import nextflow.script.ast.ProcessNodeV2;
 import nextflow.script.ast.ScriptNode;
 import nextflow.script.ast.ScriptVisitorSupport;
+import nextflow.script.ast.TupleParameter;
 import nextflow.script.ast.WorkflowNode;
 import org.codehaus.groovy.ast.ClassNode;
 import org.codehaus.groovy.ast.Parameter;
@@ -226,7 +230,7 @@ public void visitParams(ParamBlockNode node) {
 
     private static int maxParameterWidth(Parameter[] parameters) {
         return Arrays.stream(parameters)
-            .map(param -> param.getName().length())
+            .map(param -> parameterWidth(param))
             .max(Integer::compare).orElse(0);
     }
 
@@ -264,7 +268,7 @@ public void visitWorkflow(WorkflowNode node) {
         if( takes.length > 0 ) {
             fmt.appendIndent();
             fmt.append("take:\n");
-            visitWorkflowTakes(takes);
+            visitTypedInputs(takes);
         }
         if( !node.main.isEmpty() ) {
             fmt.appendNewLine();
@@ -278,7 +282,7 @@ public void visitWorkflow(WorkflowNode node) {
             fmt.appendNewLine();
             fmt.appendIndent();
             fmt.append("emit:\n");
-            visitWorkflowEmits(asBlockStatements(node.emits));
+            visitTypedOutputs(asBlockStatements(node.emits));
         }
         if( !node.publishers.isEmpty() ) {
             fmt.appendNewLine();
@@ -302,46 +306,63 @@ public void visitWorkflow(WorkflowNode node) {
         fmt.append("}\n");
     }
 
-    private void visitWorkflowTakes(Parameter[] takes) {
+    private void visitTypedInputs(Parameter[] inputs) {
         var alignmentWidth = options.harshilAlignment()
-            ? maxParameterWidth(takes)
+            ? maxParameterWidth(inputs)
             : 0;
 
-        for( var take : takes ) {
+        for( var input : inputs ) {
             fmt.appendIndent();
-            fmt.append(take.getName());
-            if( fmt.hasType(take) ) {
+            if( input instanceof TupleParameter tp ) {
+                fmt.append('(');
+                fmt.append(
+                    Arrays.stream(tp.components)
+                        .map(p -> p.getName())
+                        .collect(Collectors.joining(", "))
+                );
+                fmt.append(')');
+            }
+            else {
+                fmt.append(input.getName());
+            }
+            if( fmt.hasType(input) ) {
                 if( alignmentWidth > 0 ) {
-                    var padding = alignmentWidth - take.getName().length() + 1;
+                    var padding = alignmentWidth - parameterWidth(input) + 1;
                     fmt.append(" ".repeat(padding));
                 }
                 fmt.append(": ");
-                fmt.visitTypeAnnotation(take.getType());
+                fmt.visitTypeAnnotation(input.getType());
             }
-            fmt.appendTrailingComment(take);
+            fmt.appendTrailingComment(input);
             fmt.appendNewLine();
         }
     }
 
-    private void visitWorkflowEmits(List emits) {
+    private static int parameterWidth(Parameter param) {
+        return param instanceof TupleParameter tp
+            ? Arrays.stream(tp.components).mapToInt(p -> 2 + p.getName().length()).sum()
+            : param.getName().length();
+    }
+
+    private void visitTypedOutputs(List outputs) {
         var alignmentWidth = options.harshilAlignment()
-            ? maxParameterWidth(emits)
+            ? maxParameterWidth(outputs)
             : 0;
 
-        for( var stmt : emits ) {
+        for( var stmt : outputs ) {
             var stmtX = (ExpressionStatement)stmt;
-            var emit = stmtX.getExpression();
+            var output = stmtX.getExpression();
             var target =
-                emit instanceof AssignmentExpression ae ? (VariableExpression)ae.getLeftExpression() :
-                emit instanceof VariableExpression ve ? ve :
+                output instanceof AssignmentExpression ae ? (VariableExpression)ae.getLeftExpression() :
+                output instanceof VariableExpression ve ? ve :
                 null;
             var source =
-                emit instanceof AssignmentExpression ae ? ae.getRightExpression() :
+                output instanceof AssignmentExpression ae ? ae.getRightExpression() :
                 null;
 
             if( target != null ) {
                 fmt.appendIndent();
-                visitEmitAssignment(target, source, alignmentWidth);
+                visitOutputAssignment(target, source, alignmentWidth);
                 fmt.appendTrailingComment(stmt);
                 fmt.appendNewLine();
             }
@@ -363,7 +384,7 @@ private void visitWorkflowPublishers(List publishers) {
             var source = emit.getRightExpression();
 
             fmt.appendIndent();
-            visitEmitAssignment(target, source, alignmentWidth);
+            visitOutputAssignment(target, source, alignmentWidth);
             fmt.appendTrailingComment(stmt);
             fmt.appendNewLine();
         }
@@ -389,13 +410,15 @@ private static int maxParameterWidth(List statements) {
             .max(Integer::compare).orElse(0);
     }
 
-    private void visitEmitAssignment(VariableExpression target, Expression source, int alignmentWidth) {
+    private void visitOutputAssignment(VariableExpression target, Expression source, int alignmentWidth) {
         fmt.append(target.getText());
+        if( (fmt.hasType(target) || source != null) && alignmentWidth > 0 ) {
+            var padding = alignmentWidth - target.getName().length();
+            fmt.append(" ".repeat(padding));
+        }
         if( fmt.hasType(target) ) {
-            if( alignmentWidth > 0 ) {
-                var padding = alignmentWidth - target.getName().length() + 1;
-                fmt.append(" ".repeat(padding));
-            }
+            if( alignmentWidth > 0 )
+                fmt.append(' ');
             fmt.append(": ");
             fmt.visitTypeAnnotation(target.getType());
         }
@@ -406,7 +429,84 @@ private void visitEmitAssignment(VariableExpression target, Expression source, i
     }
 
     @Override
-    public void visitProcess(ProcessNode node) {
+    public void visitProcessV2(ProcessNodeV2 node) {
+        fmt.appendLeadingComments(node);
+        fmt.append("process ");
+        fmt.append(node.getName());
+        fmt.append(" {\n");
+        fmt.incIndent();
+        if( !node.directives.isEmpty() ) {
+            visitDirectives(node.directives);
+            fmt.appendNewLine();
+        }
+        var inputs = node.inputs;
+        if( inputs.length > 0 ) {
+            fmt.appendIndent();
+            fmt.append("input:\n");
+            visitTypedInputs(inputs);
+            fmt.appendNewLine();
+        }
+        if( !node.stagers.isEmpty() ) {
+            fmt.appendIndent();
+            fmt.append("stage:\n");
+            visitDirectives(node.stagers);
+            fmt.appendNewLine();
+        }
+        if( !options.maheshForm() ) {
+            if( !node.outputs.isEmpty() ) {
+                visitProcessOutputs(node.outputs);
+                fmt.appendNewLine();
+            }
+            if( !node.topics.isEmpty() ) {
+                visitProcessTopics(node.topics);
+                fmt.appendNewLine();
+            }
+        }
+        if( !(node.when instanceof EmptyExpression) ) {
+            fmt.appendIndent();
+            fmt.append("when:\n");
+            fmt.appendIndent();
+            fmt.visit(node.when);
+            fmt.append("\n\n");
+        }
+        fmt.appendIndent();
+        fmt.append(node.type);
+        fmt.append(":\n");
+        fmt.visit(node.exec);
+        if( !node.stub.isEmpty() ) {
+            fmt.appendNewLine();
+            fmt.appendIndent();
+            fmt.append("stub:\n");
+            fmt.visit(node.stub);
+        }
+        if( options.maheshForm() ) {
+            if( !node.outputs.isEmpty() ) {
+                fmt.appendNewLine();
+                visitProcessOutputs(node.outputs);
+            }
+            if( !node.topics.isEmpty() ) {
+                fmt.appendNewLine();
+                visitProcessTopics(node.topics);
+            }
+        }
+        fmt.decIndent();
+        fmt.append("}\n");
+    }
+
+    private void visitProcessOutputs(Statement outputs) {
+        fmt.appendIndent();
+        fmt.append("output:\n");
+        visitTypedOutputs(asBlockStatements(outputs));
+    }
+
+    private void visitProcessTopics(Statement topics) {
+        fmt.appendIndent();
+        fmt.append("topic:\n");
+        fmt.visit(topics);
+    }
+
+    @Override
+    public void visitProcessV1(ProcessNodeV1 node) {
         fmt.appendLeadingComments(node);
         fmt.append("process ");
         fmt.append(node.getName());
@@ -423,7 +523,7 @@ public void visitProcess(ProcessNode node) {
             fmt.appendNewLine();
         }
         if( !options.maheshForm() && !node.outputs.isEmpty() ) {
-            visitProcessOutputs(node.outputs);
+            visitProcessOutputsV1(node.outputs);
             fmt.appendNewLine();
         }
         if( !(node.when instanceof EmptyExpression) ) {
@@ -445,13 +545,13 @@ public void visitProcess(ProcessNode node) {
         }
         if( options.maheshForm() && !node.outputs.isEmpty() ) {
             fmt.appendNewLine();
-            visitProcessOutputs(node.outputs);
+            visitProcessOutputsV1(node.outputs);
         }
         fmt.decIndent();
         fmt.append("}\n");
     }
 
-    private void visitProcessOutputs(Statement outputs) {
+    private void visitProcessOutputsV1(Statement outputs) {
         fmt.appendIndent();
         fmt.append("output:\n");
         visitDirectives(outputs);
diff --git a/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java b/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java
index 479f5ace9a..9e7b5ab9a6 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/parser/ScriptAstBuilder.java
@@ -19,6 +19,7 @@
 import java.io.IOException;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.BitSet;
 import java.util.Collections;
 import java.util.List;
@@ -39,7 +40,10 @@
 import nextflow.script.ast.ParamNodeV1;
 import nextflow.script.ast.ParamBlockNode;
 import nextflow.script.ast.ProcessNode;
+import nextflow.script.ast.ProcessNodeV1;
+import nextflow.script.ast.ProcessNodeV2;
 import nextflow.script.ast.ScriptNode;
+import nextflow.script.ast.TupleParameter;
 import nextflow.script.ast.WorkflowNode;
 import org.antlr.v4.runtime.ANTLRErrorListener;
 import org.antlr.v4.runtime.CharStream;
@@ -121,6 +125,8 @@ public class ScriptAstBuilder {
     private ScriptParser parser;
     private final GroovydocManager groovydocManager;
 
+    private boolean previewTypes;
+
     private Tuple2 numberFormatError;
 
     public ScriptAstBuilder(SourceUnit sourceUnit) {
@@ -339,9 +345,15 @@ private FeatureFlagNode featureFlagDeclaration(FeatureFlagDeclarationContext ctx
         var result = ast( new FeatureFlagNode(name, value), ctx );
         if( !(value instanceof ConstantExpression) )
             collectSyntaxError(new SyntaxException("Feature flag value must be a literal value (number, string, true/false)", result));
+        checkPreviewTypes(result);
         return result;
     }
 
+    private void checkPreviewTypes(FeatureFlagNode node) {
+        if( "nextflow.preview.types".equals(node.name) && node.value instanceof ConstantExpression ce )
+            previewTypes = Boolean.TRUE.equals(ce.getValue());
+    }
+
     private ParamBlockNode paramsDef(ParamsDefContext ctx) {
         var declarations = paramsBody(ctx.paramsBody());
         return ast( new ParamBlockNode(declarations), ctx );
@@ -414,15 +426,17 @@ private ClassNode enumDef(EnumDefContext ctx) {
     private ProcessNode processDef(ProcessDefContext ctx) {
         var name = ctx.name.getText();
         if( ctx.body == null ) {
-            var empty = EmptyStatement.INSTANCE;
-            var result = ast( new ProcessNode(name, empty, empty, empty, EmptyExpression.INSTANCE, null, empty, empty), ctx );
-            collectSyntaxError(new SyntaxException("Missing process script body", result));
-            return result;
+            return invalidProcess("Missing process script body", ctx);
         }
 
         var directives = processDirectives(ctx.body.processDirectives());
-        var inputs = processInputs(ctx.body.processInputs());
-        var outputs = processOutputs(ctx.body.processOutputs());
+        var inputsV2 = processInputsV2(ctx.body.processInputs());
+        var inputsV1 = processInputsV1(ctx.body.processInputs());
+        var stagers = processStagers(ctx.body.processStage());
+        var outputs = previewTypes
+            ? processOutputsV2(ctx.body.processOutputs())
+            : processOutputsV1(ctx.body.processOutputs());
+        var topics = processTopics(ctx.body.processTopics());
         var when = processWhen(ctx.body.processWhen());
         var type = processType(ctx.body.processExec());
         var exec = ctx.body.blockStatements() != null
@@ -430,16 +444,32 @@ private ProcessNode processDef(ProcessDefContext ctx) {
             : blockStatements(ctx.body.processExec().blockStatements());
         var stub = processStub(ctx.body.processStub());
 
+        if( !previewTypes && !stagers.isEmpty() )
+            collectSyntaxError(new SyntaxException("The `stage:` section is not supported in a legacy process", stagers));
+
+        if( !previewTypes && !topics.isEmpty() )
+            collectSyntaxError(new SyntaxException("The `topic:` section is not supported in a legacy process", stagers));
+
         if( ctx.body.blockStatements() != null ) {
-            if( !(directives instanceof EmptyStatement) || !(inputs instanceof EmptyStatement) || !(outputs instanceof EmptyStatement) )
+            if( !directives.isEmpty() || ctx.body.processInputs() != null || !outputs.isEmpty() || !topics.isEmpty() )
                 collectSyntaxError(new SyntaxException("The `script:` or `exec:` label is required when other sections are present", exec));
         }
 
-        var result = ast( new ProcessNode(name, directives, inputs, outputs, when, type, exec, stub), ctx );
+        var result = previewTypes
+            ? new ProcessNodeV2(name, directives, inputsV2, stagers, outputs, topics, when, type, exec, stub)
+            : new ProcessNodeV1(name, directives, inputsV1, outputs, when, type, exec, stub);
+        ast(result, ctx);
         groovydocManager.handle(result, ctx);
         return result;
     }
 
+    private ProcessNode invalidProcess(String message, ProcessDefContext ctx) {
+        var empty = EmptyStatement.INSTANCE;
+        var result = ast( new ProcessNodeV1("", empty, empty, empty, EmptyExpression.INSTANCE, null, empty, empty), ctx );
+        collectSyntaxError(new SyntaxException(message, result));
+        return result;
+    }
+
     private Statement processDirectives(ProcessDirectivesContext ctx) {
         if( ctx == null )
             return EmptyStatement.INSTANCE;
@@ -450,26 +480,188 @@ private Statement processDirectives(ProcessDirectivesContext ctx) {
         return ast( block(null, statements), ctx );
     }
 
-    private Statement processInputs(ProcessInputsContext ctx) {
+    private Parameter[] processInputsV2(ProcessInputsContext ctx) {
+        if( ctx == null || !previewTypes )
+            return Parameter.EMPTY_ARRAY;
+
+        return ctx.processInput().stream()
+            .map(this::processInput)
+            .filter(input -> input != null)
+            .toArray(Parameter[]::new);
+    }
+
+    private Parameter processInput(ProcessInputContext ctx) {
+        if( ctx.statement() != null ) {
+            var result = statement(ctx.statement());
+            collectSyntaxError(new SyntaxException("Invalid input declaration in typed process", result));
+            return null;
+        }
+        var type = type(ctx.type());
+        var names = ctx.identifier().stream().map(this::identifier).toList();
+        var result = names.size() == 1
+            ? ast( param(type, names.get(0)), ctx )
+            : processTupleInput(type, names, ctx);
+        for( var name : names )
+            checkInvalidVarName(name, result);
+        if( ctx.type() == null )
+            collectSyntaxError(new SyntaxException("Process input must have a type annotation", result));
+        saveTrailingComment(result, ctx);
+        return result;
+    }
+
+    private TupleParameter processTupleInput(ClassNode type, List names, ProcessInputContext ctx) {
+        var componentTypes = tupleComponentTypes(type, names.size());
+        var components = new Parameter[names.size()];
+        for( int i = 0; i < names.size(); i++ ) {
+            var componentType = componentTypes != null ? componentTypes.get(i) : ClassHelper.dynamicType();
+            components[i] = ast( param(componentType, names.get(i)), ctx.identifier().get(i) );
+        }
+        var result = ast( new TupleParameter(type, components), ctx );
+        if( !"Tuple".equals(type.getUnresolvedName()) )
+            collectSyntaxError(new SyntaxException("Process tuple input must have type `Tuple<...>`", result));
+        if( !type.isUsingGenerics() || type.getGenericsTypes().length != names.size() )
+            collectSyntaxError(new SyntaxException("Process tuple input type must have " + names.size() + " type arguments (one for each tuple component)", result));
+        return result;
+    }
+
+    private List tupleComponentTypes(ClassNode type, int n) {
+        if( !"Tuple".equals(type.getUnresolvedName()) )
+            return null;
+        if( !type.isUsingGenerics() )
+            return null;
+        if( type.getGenericsTypes().length != n )
+            return null;
+        return Arrays.stream(type.getGenericsTypes())
+            .map(gt -> gt.getType())
+            .toList();
+    }
+
+    private Statement processInputsV1(ProcessInputsContext ctx) {
+        if( ctx == null || previewTypes )
+            return EmptyStatement.INSTANCE;
+        var statements = ctx.processInput().stream()
+            .map(this::processInputV1)
+            .filter(input -> input != null)
+            .toList();
+        return ast( block(null, statements), ctx );
+    }
+
+    private Statement processInputV1(ProcessInputContext ctx) {
+        Statement result;
+        if( ctx.statement() != null ) {
+            result = statement(ctx.statement());
+        }
+        else if( ctx.identifier().size() == 1 && ctx.type() == null ) {
+            // identifier with no type annotation should be parsed as legacy input declaration
+            result = ast( stmt(variableName(ctx.identifier().get(0))), ctx );
+        }
+        else {
+            collectSyntaxError(new SyntaxException("Typed input declaration is not allowed in legacy process -- set `nextflow.preview.types = true` to use typed processes in this script", ast(new EmptyStatement(), ctx)));
+            return null;
+        }
+        return checkDirective(result, "Invalid process input");
+    }
+
+    private Statement processStagers(ProcessStageContext ctx) {
         if( ctx == null )
             return EmptyStatement.INSTANCE;
         var statements = ctx.statement().stream()
             .map(this::statement)
-            .map(stmt -> checkDirective(stmt, "Invalid process input"))
+            .map(stmt -> checkDirective(stmt, "Invalid stage directive"))
+            .toList();
+        return ast( block(null, statements), ctx );
+    }
+
+    private Statement processOutputsV2(ProcessOutputsContext ctx) {
+        if( ctx == null )
+            return EmptyStatement.INSTANCE;
+
+        var statements = ctx.processOutput().stream()
+            .map(this::processOutput)
+            .filter(stmt -> stmt != null)
+            .toList();
+        var result = ast( block(null, statements), ctx );
+        var hasEmitExpression = statements.stream().anyMatch(this::isEmitExpression);
+        if( hasEmitExpression && statements.size() > 1 ) {
+            collectSyntaxError(new SyntaxException("Every output must be assigned to a name when there are multiple outputs", result));
+            return null;
+        }
+        return result;
+    }
+
+    private Statement processOutput(ProcessOutputContext ctx) {
+        Statement result;
+        if( ctx.statement() != null ) {
+            result = statement(ctx.statement());
+            if( !(result instanceof ExpressionStatement) ) {
+                collectSyntaxError(new SyntaxException("Invalid output declaration in typed process -- must be a name, assignment, or expression", result));
+                return null;
+            }
+        }
+        else if( ctx.expression() != null ) {
+            var target = nameTypePair(ctx.nameTypePair());
+            var source = expression(ctx.expression());
+            result = stmt(ast( new AssignmentExpression(target, source), ctx ));
+        }
+        else {
+            var target = nameTypePair(ctx.nameTypePair());
+            result = stmt(target);
+        }
+        saveTrailingComment(result, ctx);
+        return result;
+    }
+
+    private Statement processOutputsV1(ProcessOutputsContext ctx) {
+        if( ctx == null )
+            return EmptyStatement.INSTANCE;
+        var statements = ctx.processOutput().stream()
+            .map(this::processOutputV1)
+            .filter(stmt -> stmt != null)
             .toList();
         return ast( block(null, statements), ctx );
     }
 
-    private Statement processOutputs(ProcessOutputsContext ctx) {
+    private Statement processOutputV1(ProcessOutputContext ctx) {
+        Statement result;
+        if( ctx.statement() != null ) {
+            result = statement(ctx.statement());
+        }
+        else if( ctx.nameTypePair().identifier() != null && ctx.nameTypePair().type() == null && ctx.expression() == null ) {
+            // identifier with no type annotation or source expression should be parsed as legacy output declaration
+            result = ast( stmt(variableName(ctx.nameTypePair().identifier())), ctx );
+        }
+        else {
+            collectSyntaxError(new SyntaxException("Typed output declaration is not allowed in legacy process -- set `nextflow.preview.types = true` to use typed processes in this script", ast(new EmptyStatement(), ctx)));
+            return null;
+        }
+        return checkDirective(result, "Invalid process output");
+    }
+
+    private Statement processTopics(ProcessTopicsContext ctx) {
         if( ctx == null )
             return EmptyStatement.INSTANCE;
+
         var statements = ctx.statement().stream()
             .map(this::statement)
-            .map(stmt -> checkDirective(stmt, "Invalid process output"))
+            .filter((stmt) -> {
+                if( isProcessTopic(stmt) ) {
+                    return true;
+                }
+                else {
+                    collectSyntaxError(new SyntaxException("Invalid process topic statement", stmt));
+                    return false;
+                }
+            })
             .toList();
         return ast( block(null, statements), ctx );
     }
 
+    private boolean isProcessTopic(Statement stmt) {
+        return stmt instanceof ExpressionStatement es
+            && es.getExpression() instanceof BinaryExpression be
+            && be.getOperation().getType() == Types.RIGHT_SHIFT;
+    }
+
     private Statement checkDirective(Statement stmt, String errorMessage) {
         if( !(stmt instanceof ExpressionStatement) ) {
             collectSyntaxError(new SyntaxException(errorMessage, stmt));
@@ -1744,13 +1936,16 @@ private ClassNode type(TypeContext ctx, boolean allowProxy) {
     }
 
     private GenericsType[] typeArguments(TypeArgumentsContext ctx) {
-        return ctx.type().stream()
+        return ctx.typeArgument().stream()
             .map(this::genericsType)
             .toArray(GenericsType[]::new);
     }
 
-    private GenericsType genericsType(TypeContext ctx) {
-        return ast( new GenericsType(type(ctx)), ctx );
+    private GenericsType genericsType(TypeArgumentContext ctx) {
+        var type = ctx.QUESTION() != null
+            ? ClassHelper.dynamicType()
+            : type(ctx.type());
+        return ast( new GenericsType(type), ctx );
     }
 
     private ClassNode legacyType(ParserRuleContext ctx) {
diff --git a/modules/nf-lang/src/main/java/nextflow/script/types/Types.java b/modules/nf-lang/src/main/java/nextflow/script/types/Types.java
index fde01a488c..6209f6069a 100644
--- a/modules/nf-lang/src/main/java/nextflow/script/types/Types.java
+++ b/modules/nf-lang/src/main/java/nextflow/script/types/Types.java
@@ -74,9 +74,6 @@ public static String getName(ClassNode type) {
         if( ClassHelper.isFunctionalInterface(type) )
             return closureName(type);
 
-        if( type.isDerivedFrom(ClassHelper.TUPLE_TYPE) )
-            return tupleName(type);
-
         return typeName(type);
     }
 
@@ -129,7 +126,7 @@ private static String typeName(ClassNode type) {
             builder.append(type.getUnresolvedName());
         else if( type.getNodeMetaData(ASTNodeMarker.FULLY_QUALIFIED) != null )
             builder.append(type.getName());
-        else if( hasTypeClass(type) )
+        else if( type.isResolved() )
             builder.append(getName(type.getTypeClass()));
         else
             builder.append(getName(type.getNameWithoutPackage()));
@@ -154,16 +151,6 @@ private static void genericsTypeNames(GenericsType[] genericsTypes, StringBuilde
         }
     }
 
-    private static boolean hasTypeClass(ClassNode type) {
-        try {
-            type.getTypeClass();
-            return true;
-        }
-        catch( GroovyBugError e ) {
-            return false;
-        }
-    }
-
     public static String getName(Class type) {
         return getName(type.getSimpleName());
     }
diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy
index 852f39104d..dbad332217 100644
--- a/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy
+++ b/modules/nf-lang/src/test/groovy/nextflow/script/formatter/ScriptFormatterTest.groovy
@@ -174,7 +174,7 @@ class ScriptFormatterTest extends Specification {
         )
     }
 
-    def 'should format a process definition' () {
+    def 'should format a legacy process definition' () {
         expect:
         checkFormat(
             '''\
@@ -200,6 +200,39 @@ class ScriptFormatterTest extends Specification {
         )
     }
 
+    def 'should format a typed process definition' () {
+        expect:
+        checkFormat(
+            '''\
+            nextflow.preview.types=true
+
+            process hello{
+            debug(true) ; input: (id,infile):Tuple ; index:Path ; stage: stageAs('input.txt',infile) ; output: result=tuple(id,file('output.txt')) ; script: 'cat input.txt > output.txt'
+            }
+            ''',
+            '''\
+            nextflow.preview.types = true
+
+            process hello {
+                debug true
+
+                input:
+                (id, infile): Tuple
+                index: Path
+
+                stage:
+                stageAs 'input.txt', infile
+
+                output:
+                result = tuple(id, file('output.txt'))
+
+                script:
+                'cat input.txt > output.txt'
+            }
+            '''
+        )
+    }
+
     def 'should format a function definition' () {
         expect:
         checkFormat(
diff --git a/modules/nf-lang/src/test/groovy/nextflow/script/parser/ScriptAstBuilderTest.groovy b/modules/nf-lang/src/test/groovy/nextflow/script/parser/ScriptAstBuilderTest.groovy
index bb47b3f08c..3017b44311 100644
--- a/modules/nf-lang/src/test/groovy/nextflow/script/parser/ScriptAstBuilderTest.groovy
+++ b/modules/nf-lang/src/test/groovy/nextflow/script/parser/ScriptAstBuilderTest.groovy
@@ -248,4 +248,132 @@ class ScriptAstBuilderTest extends Specification {
         errors[0].getOriginalMessage() == "Invalid workflow emit -- must be a name, assignment, or expression"
     }
 
+    def 'should report error for defining a typed process without preview flag' () {
+        when:
+        def errors = check(
+            '''\
+            process hello {
+                input:
+                message: String
+
+                output:
+                result: String
+
+                exec:
+                result = message
+            }
+            '''
+        )
+        then:
+        errors.size() >= 2
+        errors[0].getStartLine() == 3
+        errors[0].getStartColumn() == 5
+        errors[0].getOriginalMessage() == "Typed input declaration is not allowed in legacy process -- set `nextflow.preview.types = true` to use typed processes in this script"
+        errors[1].getStartLine() == 6
+        errors[1].getStartColumn() == 5
+        errors[1].getOriginalMessage() == "Typed output declaration is not allowed in legacy process -- set `nextflow.preview.types = true` to use typed processes in this script"
+
+        when:
+        errors = check(
+            '''\
+            nextflow.preview.types = true
+
+            process hello {
+                input:
+                message: String
+
+                output:
+                result: String
+
+                exec:
+                result = message
+            }
+            '''
+        )
+        then:
+        errors.size() == 0
+    }
+
+    def 'should report error for defining a legacy process with preview flag enabled' () {
+        when:
+        def errors = check(
+            '''\
+            nextflow.preview.types = true
+
+            process hello {
+                input:
+                val message
+
+                output:
+                val result
+
+                exec:
+                result = message
+            }
+            '''
+        )
+        then:
+        errors.size() >= 1
+        errors[0].getStartLine() == 5
+        errors[0].getStartColumn() == 5
+        errors[0].getOriginalMessage() == "Invalid input declaration in typed process"
+
+        when:
+        errors = check(
+            '''\
+            process hello {
+                input:
+                val message
+
+                output:
+                val result
+
+                exec:
+                result = message
+            }
+            '''
+        )
+        then:
+        errors.size() == 0
+    }
+
+    def 'should report error for invalid topic statement' () {
+        when:
+        def errors = check(
+            '''\
+            nextflow.preview.types = true
+
+            process hello {
+                topic:
+                versions = stdout()
+
+                script:
+                ""
+            }
+            '''
+        )
+        then:
+        errors.size() == 1
+        errors[0].getStartLine() == 5
+        errors[0].getStartColumn() == 5
+        errors[0].getOriginalMessage() == "Invalid process topic statement"
+
+        when:
+        errors = check(
+            '''\
+            nextflow.preview.types = true
+
+            process hello {
+                topic:
+                stdout() >> 'versions'
+
+                script:
+                ""
+            }
+            '''
+        )
+        then:
+        errors.size() == 0
+    }
+
 }
diff --git a/tests-v1/checks/.PARSER-V1 b/tests-v1/checks/.PARSER-V1
index 1e6b46ac74..0584272ed2 100644
--- a/tests-v1/checks/.PARSER-V1
+++ b/tests-v1/checks/.PARSER-V1
@@ -5,6 +5,7 @@ env-out.nf
 env2.nf
 error-finish.nf
 output-globs.nf
+output-dsl.nf
 output-val.nf
 publish-saveas.nf
 singleton.nf
diff --git a/tests-v1/checks/output-dsl.nf/.checks b/tests-v1/checks/output-dsl.nf/.checks
index ae88d98211..69836cb0ea 100644
--- a/tests-v1/checks/output-dsl.nf/.checks
+++ b/tests-v1/checks/output-dsl.nf/.checks
@@ -4,13 +4,13 @@
 echo First run
 $NXF_RUN --save_bam_bai | tee stdout
 
-[[ `grep INFO .nextflow.log | grep -c 'Submitted process > fastqc'` == 3 ]] || false
-[[ `grep INFO .nextflow.log | grep -c 'Submitted process > align'` == 3 ]] || false
-[[ `grep INFO .nextflow.log | grep -c 'Submitted process > quant'` == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Submitted process > fastqc') == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Submitted process > align') == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Submitted process > quant') == 3 ]] || false
 
-[[ -f results/fastqc/alpha.fastqc.log ]] || false
-[[ -f results/fastqc/beta.fastqc.log ]] || false
-[[ -f results/fastqc/delta.fastqc.log ]] || false
+[[ -f results/log/alpha.fastqc.log ]] || false
+[[ -f results/log/beta.fastqc.log ]] || false
+[[ -f results/log/delta.fastqc.log ]] || false
 [[ -f results/align/alpha.bai ]] || false
 [[ -f results/align/alpha.bam ]] || false
 [[ -f results/align/beta.bai ]] || false
@@ -21,6 +21,7 @@ $NXF_RUN --save_bam_bai | tee stdout
 [[ -L results/quant/beta ]] || false
 [[ -L results/quant/delta ]] || false
 [[ -f results/samples.csv ]] || false
+[[ -f results/summary.txt ]] || false
 
 
 #
@@ -29,13 +30,13 @@ $NXF_RUN --save_bam_bai | tee stdout
 echo Second run
 $NXF_RUN --save_bam_bai | tee stdout
 
-[[ `grep INFO .nextflow.log | grep -c 'Submitted process > fastqc'` == 3 ]] || false
-[[ `grep INFO .nextflow.log | grep -c 'Submitted process > align'` == 3 ]] || false
-[[ `grep INFO .nextflow.log | grep -c 'Submitted process > quant'` == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Submitted process > fastqc') == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Submitted process > align') == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Submitted process > quant') == 3 ]] || false
 
-[[ -f results/fastqc/alpha.fastqc.log ]] || false
-[[ -f results/fastqc/beta.fastqc.log ]] || false
-[[ -f results/fastqc/delta.fastqc.log ]] || false
+[[ -f results/log/alpha.fastqc.log ]] || false
+[[ -f results/log/beta.fastqc.log ]] || false
+[[ -f results/log/delta.fastqc.log ]] || false
 [[ -f results/align/alpha.bai ]] || false
 [[ -f results/align/alpha.bam ]] || false
 [[ -f results/align/beta.bai ]] || false
@@ -46,6 +47,7 @@ $NXF_RUN --save_bam_bai | tee stdout
 [[ -L results/quant/beta ]] || false
 [[ -L results/quant/delta ]] || false
 [[ -f results/samples.csv ]] || false
+[[ -f results/summary.txt ]] || false
 
 
 #
@@ -56,13 +58,13 @@ rm -rf results
 
 $NXF_RUN --save_bam_bai -resume | tee stdout
 
-[[ `grep INFO .nextflow.log | grep -c 'Cached process > fastqc'` == 3 ]] || false
-[[ `grep INFO .nextflow.log | grep -c 'Cached process > align'` == 3 ]] || false
-[[ `grep INFO .nextflow.log | grep -c 'Cached process > quant'` == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Cached process > fastqc') == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Cached process > align') == 3 ]] || false
+[[ $(grep INFO .nextflow.log | grep -c 'Cached process > quant') == 3 ]] || false
 
-[[ -f results/fastqc/alpha.fastqc.log ]] || false
-[[ -f results/fastqc/beta.fastqc.log ]] || false
-[[ -f results/fastqc/delta.fastqc.log ]] || false
+[[ -f results/log/alpha.fastqc.log ]] || false
+[[ -f results/log/beta.fastqc.log ]] || false
+[[ -f results/log/delta.fastqc.log ]] || false
 [[ -f results/align/alpha.bai ]] || false
 [[ -f results/align/alpha.bam ]] || false
 [[ -f results/align/beta.bai ]] || false
@@ -73,3 +75,4 @@ $NXF_RUN --save_bam_bai -resume | tee stdout
 [[ -L results/quant/beta ]] || false
 [[ -L results/quant/delta ]] || false
 [[ -f results/samples.csv ]] || false
+[[ -f results/summary.txt ]] || false
diff --git a/tests-v1/output-dsl.nf b/tests-v1/output-dsl.nf
index 76d30f5acb..84315171c8 100644
--- a/tests-v1/output-dsl.nf
+++ b/tests-v1/output-dsl.nf
@@ -62,9 +62,22 @@ process quant {
   '''
 }
 
+process summary {
+  input:
+  path logs
+
+  output:
+  path('summary.txt'), emit: report
+
+  script:
+  '''
+  ls -1 *.log > summary.txt
+  '''
+}
+
 workflow {
   main:
-  ids = Channel.of('alpha', 'beta', 'delta')
+  ids = channel.of('alpha', 'beta', 'delta')
   ch_fastqc = fastqc(ids)
   (ch_bam, ch_bai) = align(ids)
   ch_quant = quant(ids)
@@ -83,25 +96,24 @@ workflow {
       ]
     }
 
+  ch_logs = ch_samples
+    .map { sample -> sample.fastqc }
+    .collect()
+
+  summary(ch_logs)
+
   publish:
-  ch_samples >> 'samples'
+  samples = ch_samples
+  summary = summary.out
 }
 
 output {
   samples {
     path { sample ->
-      def dirs = [
-        'bam': 'align',
-        'bai': 'align',
-        'log': 'fastqc'
-      ]
-      return { filename ->
-        def ext = filename.tokenize('.').last()
-        def dir = dirs[ext]
-        dir != null
-          ? "${dir}/${filename}"
-          : "${filename}/${sample.id}"
-      }
+      sample.fastqc >> 'log/'
+      sample.bam >> 'align/'
+      sample.bai >> 'align/'
+      sample.quant >> "quant/${sample.id}"
     }
     index {
       path 'samples.csv'
@@ -109,4 +121,8 @@ output {
       sep ','
     }
   }
+
+  summary {
+    path '.'
+  }
 }
diff --git a/tests/checks/.IGNORE-PARSER-V2 b/tests/checks/.IGNORE-PARSER-V2
index 611fcc1d7e..da67ec4fdc 100644
--- a/tests/checks/.IGNORE-PARSER-V2
+++ b/tests/checks/.IGNORE-PARSER-V2
@@ -1,5 +1,12 @@
 # TESTS THAT SHOULD ONLY BE RUN BY THE V2 PARSER
 chunk.nf
+collect-tuple-typed.nf
+dynamic-filename-typed.nf
+env-typed.nf
+eval-out-typed.nf
+nullable-path.nf
+output-dsl.nf
 params-dsl.nf
+topic-channel-typed.nf
 type-annotations.nf
 workflow-oncomplete-v2.nf
\ No newline at end of file
diff --git a/tests/checks/collect-tuple-typed.nf/.checks b/tests/checks/collect-tuple-typed.nf/.checks
new file mode 100644
index 0000000000..3fa33d5c4e
--- /dev/null
+++ b/tests/checks/collect-tuple-typed.nf/.checks
@@ -0,0 +1,34 @@
+set -e
+
+#
+# run normal mode
+#
+echo ''
+echo \$ $NXF_RUN
+$NXF_RUN | tee stdout
+
+[[ `grep 'INFO' .nextflow.log | grep -c 'Submitted process > align'` == 6 ]] || false
+[[ `grep 'INFO' .nextflow.log | grep -c 'Submitted process > merge'` == 2 ]] || false
+
+[[ `grep -c 'barcode: alpha' stdout` == 1 ]] || false
+[[ `grep -c 'barcode: gamma' stdout` == 1 ]] || false
+[[ `grep -c 'bam : bam1 bam2 bam3' stdout` == 2 ]] || false
+[[ `grep -c 'bai : bai1 bai2 bai3' stdout` == 2 ]] || false
+[[ `grep -c 'seq_ids' stdout` == 2 ]] || false
+
+
+#
+# RESUME mode
+#
+echo ''
+echo \$ $NXF_RUN -resume
+$NXF_RUN -resume | tee stdout
+
+[[ `grep 'INFO' .nextflow.log | grep -c 'Cached process > align'` == 6 ]] || false
+[[ `grep 'INFO' .nextflow.log | grep -c 'Cached process > merge'` == 2 ]] || false
+
+[[ `grep -c 'barcode: alpha' stdout` == 1 ]] || false
+[[ `grep -c 'barcode: gamma' stdout` == 1 ]] || false
+[[ `grep -c 'bam : bam1 bam2 bam3' stdout` == 2 ]] || false
+[[ `grep -c 'bai : bai1 bai2 bai3' stdout` == 2 ]] || false
+[[ `grep -c 'seq_ids' stdout` == 2 ]] || false
diff --git a/tests/checks/dynamic-filename-typed.nf/.checks b/tests/checks/dynamic-filename-typed.nf/.checks
new file mode 100644
index 0000000000..ce23fb766e
--- /dev/null
+++ b/tests/checks/dynamic-filename-typed.nf/.checks
@@ -0,0 +1,45 @@
+set -e
+
+#
+# run normal mode
+#
+echo ''
+$NXF_RUN --input .data.txt | tee stdout
+
+[[ `grep 'INFO' .nextflow.log | grep -c 'Submitted process > foo'` == 4 ]] || false
+
+grep '~ Saving my_delta.txt' stdout
+grep '~ Saving my_omega.txt' stdout
+grep '~ Saving my_gamma.txt' stdout
+grep '~ Saving my_alpha.txt' stdout
+
+test -f my_alpha.txt
+test -f my_delta.txt
+test -f my_gamma.txt
+test -f my_omega.txt
+
+diff my_alpha.txt .expected
+diff my_delta.txt .expected
+diff my_gamma.txt .expected
+diff my_omega.txt .expected
+
+rm my_* 
+
+
+#
+# RESUME mode
+#
+echo ''
+$NXF_RUN --input .data.txt -resume | tee stdout
+
+[[ `grep 'INFO' .nextflow.log | grep -c 'Cached process > foo'` == 4 ]] || false
+
+grep '~ Saving my_delta.txt' stdout
+grep '~ Saving my_omega.txt' stdout
+grep '~ Saving my_gamma.txt' stdout
+grep '~ Saving my_alpha.txt' stdout
+
+test -f my_alpha.txt
+test -f my_delta.txt
+test -f my_gamma.txt
+test -f my_omega.txt
diff --git a/tests/checks/dynamic-filename-typed.nf/.data.txt b/tests/checks/dynamic-filename-typed.nf/.data.txt
new file mode 100644
index 0000000000..e965047ad7
--- /dev/null
+++ b/tests/checks/dynamic-filename-typed.nf/.data.txt
@@ -0,0 +1 @@
+Hello
diff --git a/tests/checks/dynamic-filename-typed.nf/.expected b/tests/checks/dynamic-filename-typed.nf/.expected
new file mode 100644
index 0000000000..f9264f7fbd
--- /dev/null
+++ b/tests/checks/dynamic-filename-typed.nf/.expected
@@ -0,0 +1,2 @@
+Hello
+World
diff --git a/tests/checks/env-typed.nf/.checks b/tests/checks/env-typed.nf/.checks
new file mode 100644
index 0000000000..6cf0c2f55c
--- /dev/null
+++ b/tests/checks/env-typed.nf/.checks
@@ -0,0 +1,17 @@
+#
+# run normal mode 
+#
+$NXF_RUN | tee .stdout
+
+[[ `grep INFO .nextflow.log | grep -c 'Submitted process'` == 2 ]] || false
+[[ `< .stdout grep 'bar says Hello'` ]] || false
+
+
+#
+# run resume mode 
+#
+$NXF_RUN -resume | tee .stdout
+
+[[ `grep INFO .nextflow.log | grep -c 'Cached process'` == 2 ]] || false
+[[ `< .stdout grep 'bar says Hello'` ]] || false
+
diff --git a/tests/checks/eval-out-typed.nf/.checks b/tests/checks/eval-out-typed.nf/.checks
new file mode 100644
index 0000000000..b8a38fb379
--- /dev/null
+++ b/tests/checks/eval-out-typed.nf/.checks
@@ -0,0 +1,17 @@
+#
+# run normal mode 
+#
+$NXF_RUN | tee .stdout
+
+[[ `grep INFO .nextflow.log | grep -c 'Submitted process'` == 1 ]] || false
+[[ `< .stdout grep 'GNU bash'` ]] || false
+
+
+#
+# run resume mode 
+#
+$NXF_RUN -resume | tee .stdout
+
+[[ `grep INFO .nextflow.log | grep -c 'Cached process'` == 1 ]] || false
+[[ `< .stdout grep 'GNU bash'` ]] || false
+
diff --git a/tests/checks/nullable-path.nf/.checks b/tests/checks/nullable-path.nf/.checks
new file mode 100644
index 0000000000..edac973c02
--- /dev/null
+++ b/tests/checks/nullable-path.nf/.checks
@@ -0,0 +1,18 @@
+set +e
+
+#
+# run normal mode
+#
+echo ''
+$NXF_RUN | tee .stdout
+[[ $? == 0 ]] || false
+cmp .expected .stdout || false
+
+
+#
+# RESUME mode
+#
+echo ''
+$NXF_RUN -resume | tee .stdout
+[[ $? == 0 ]] || false
+cmp .expected .stdout || false
diff --git a/tests/checks/nullable-path.nf/.expected b/tests/checks/nullable-path.nf/.expected
new file mode 100644
index 0000000000..91ff9cc059
--- /dev/null
+++ b/tests/checks/nullable-path.nf/.expected
@@ -0,0 +1,2 @@
+empty input
+
diff --git a/tests/checks/topic-channel-typed.nf/.checks b/tests/checks/topic-channel-typed.nf/.checks
new file mode 100644
index 0000000000..425cb15ae2
--- /dev/null
+++ b/tests/checks/topic-channel-typed.nf/.checks
@@ -0,0 +1,15 @@
+#
+# initial run
+#
+echo Initial run
+$NXF_RUN
+
+cmp versions.txt .expected || false
+
+#
+# Resumed run
+#
+echo Resumed run
+$NXF_RUN -resume
+
+cmp versions.txt .expected || false
diff --git a/tests/checks/topic-channel-typed.nf/.expected b/tests/checks/topic-channel-typed.nf/.expected
new file mode 100644
index 0000000000..1bb4d6b958
--- /dev/null
+++ b/tests/checks/topic-channel-typed.nf/.expected
@@ -0,0 +1,2 @@
+bar: 0.9.0
+foo: 0.1.0
diff --git a/tests/collect-tuple-typed.nf b/tests/collect-tuple-typed.nf
new file mode 100644
index 0000000000..3007c2cc11
--- /dev/null
+++ b/tests/collect-tuple-typed.nf
@@ -0,0 +1,58 @@
+#!/usr/bin/env nextflow
+
+nextflow.preview.types = true
+
+/*
+ * fake alignment step producing a BAM and BAI files
+ */
+process align {
+  debug true
+
+  input:
+  (barcode, seq_id): Tuple
+
+  output:
+  tuple(barcode, seq_id, file('bam'), file('bai'))
+
+  script:
+  """
+  echo BAM $seq_id - $barcode > bam
+  echo BAI $seq_id - $barcode > bai
+  """
+}
+
+
+/*
+ * Finally merge the BAMs and BAIs with the same 'barcode'
+ */
+process merge {
+  debug true
+
+  input:
+  (barcode, seq_ids, bam, bai): Tuple, Bag, Bag>
+
+  stage:
+  stageAs 'bam?', bam
+  stageAs 'bai?', bai
+
+  script:
+  """
+  echo barcode: $barcode
+  echo seq_ids: $seq_ids
+  echo bam    : $bam
+  echo bai    : $bai
+  """
+}
+
+
+/*
+ * main flow
+ */
+workflow {
+  ch_barcode = channel.of('alpha', 'gamma')
+  ch_seq = channel.of('one', 'two', 'three')
+
+  align( ch_barcode.combine(ch_seq) )
+    | groupTuple
+    | merge
+}
diff --git a/tests/dynamic-filename-typed.nf b/tests/dynamic-filename-typed.nf
new file mode 100644
index 0000000000..3a524a9e73
--- /dev/null
+++ b/tests/dynamic-filename-typed.nf
@@ -0,0 +1,44 @@
+#!/usr/bin/env nextflow
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+nextflow.preview.types = true
+
+params.prefix = 'my'
+params.names = ['alpha', 'delta', 'gamma', 'omega']
+
+process foo {
+  stageInMode 'copy'
+
+  input:
+  (name, txt): Tuple
+
+  stage:
+  stageAs "${params.prefix}_${name}.txt", txt
+
+  output:
+  file("${params.prefix}_${name}.txt")
+
+  script:
+  """
+  echo World >>  ${params.prefix}_${name}.txt
+  """
+}
+
+workflow {
+  names_ch = channel.fromList(params.names)
+  foo( names_ch.combine([file(params.input)]) ) | subscribe { println "~ Saving ${it.name}"; it.copyTo('.') }
+}
diff --git a/tests/env-typed.nf b/tests/env-typed.nf
new file mode 100644
index 0000000000..8ecf6110c2
--- /dev/null
+++ b/tests/env-typed.nf
@@ -0,0 +1,46 @@
+#!/usr/bin/env nextflow
+/*
+ * Copyright 2013-2024, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+nextflow.preview.types = true
+
+process foo {
+    output:
+    env('FOO')
+
+    script:
+    /FOO=Hello/
+}
+
+process bar {
+    debug true
+
+    input:
+    foo: String
+
+    stage:
+    env 'FOO', foo
+
+    script:
+    '''
+    echo "bar says $FOO"
+    '''
+}
+
+
+workflow {
+  foo | bar
+}
diff --git a/tests/eval-out-typed.nf b/tests/eval-out-typed.nf
new file mode 100644
index 0000000000..c8f0502118
--- /dev/null
+++ b/tests/eval-out-typed.nf
@@ -0,0 +1,37 @@
+#!/usr/bin/env nextflow
+/*
+ * Copyright 2013-2023, Seqera Labs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+nextflow.preview.types = true
+
+process foo {
+    input:
+    shell: String
+
+    output:
+    shell_version = eval("$shell --version | cat -")
+
+    script:
+    '''
+    echo Hello
+    '''
+}
+
+
+workflow {
+  foo('bash')
+  foo.out.shell_version.view{ it.readLines()[0] }
+}
diff --git a/tests/nullable-path.nf b/tests/nullable-path.nf
new file mode 100644
index 0000000000..1a703a9a7b
--- /dev/null
+++ b/tests/nullable-path.nf
@@ -0,0 +1,36 @@
+#!/usr/bin/env nextflow
+
+nextflow.preview.types = true
+
+process foo {
+    input:
+    id: String
+
+    output:
+    file('output.txt', optional: true)
+
+    script:
+    """
+    echo ${id}
+    """
+}
+
+process bar {
+    input:
+    input: Path?
+
+    stage:
+    stageAs 'input.txt', input
+
+    output:
+    stdout()
+
+    script:
+    '''
+    [[ -f input.txt ]] && cat input.txt || echo 'empty input'
+    '''
+}
+
+workflow {
+    channel.of('foo') | foo | bar | view
+}
diff --git a/tests/output-dsl.nf b/tests/output-dsl.nf
index cd6a777a7c..0ac8434bdb 100644
--- a/tests/output-dsl.nf
+++ b/tests/output-dsl.nf
@@ -15,15 +15,16 @@
  * limitations under the License.
  */
 nextflow.preview.output = true
+nextflow.preview.types = true
 
 params.save_bam_bai = false
 
 process fastqc {
   input:
-  val id
+  id: String
 
   output:
-  tuple val(id), path('*.fastqc.log')
+  tuple(id, file('*.fastqc.log'))
 
   script:
   """
@@ -33,11 +34,11 @@ process fastqc {
 
 process align {
   input:
-  val id
+  id: String
 
   output:
-  tuple val(id), path('*.bam')
-  tuple val(id), path('*.bai')
+  bam = tuple(id, file('*.bam'))
+  bai = tuple(id, file('*.bai'))
 
   script:
   """
@@ -48,10 +49,10 @@ process align {
 
 process quant {
   input:
-  val id
+  id: String
 
   output:
-  tuple val(id), path('quant')
+  tuple(id, file('quant'))
 
   script:
   '''
@@ -64,10 +65,10 @@ process quant {
 
 process summary {
   input:
-  path logs
+  logs: Bag
 
   output:
-  tuple path('summary_report.html'), path('summary_data/data.json'), path('summary_data/fastqc.txt')
+  tuple(file('summary_report.html'), file('summary_data/data.json'), file('summary_data/fastqc.txt'))
 
   script:
   '''
diff --git a/tests/topic-channel-typed.nf b/tests/topic-channel-typed.nf
new file mode 100644
index 0000000000..f311b3e934
--- /dev/null
+++ b/tests/topic-channel-typed.nf
@@ -0,0 +1,43 @@
+
+nextflow.preview.types = true
+
+process foo {
+  input:
+  index: Integer
+
+  output:
+  versions = stdout()
+
+  topic:
+  stdout() >> 'versions'
+
+  script:
+  """
+  echo 'foo: 0.1.0'
+  """
+}
+
+process bar {
+  input:
+  index: Integer
+
+  output:
+  versions = stdout()
+
+  topic:
+  stdout() >> 'versions'
+
+  script:
+  """
+  echo 'bar: 0.9.0'
+  """
+}
+
+workflow {
+  channel.of( 1..3 ) | foo
+  channel.of( 1..3 ) | bar
+
+  channel.topic('versions')
+  | unique
+  | collectFile(name: 'versions.txt', sort: true, storeDir: '.')
+}