@uipath/maestro-builder-sdk 6.31.8 → 6.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/check.js CHANGED
@@ -10,7 +10,7 @@ import { FlowNode } from './core/node-classes.js';
10
10
  import { planContributionBindings } from './core/contribution-bindings.js';
11
11
  import { definitionHasErrorPort, errorPortRefusal } from './core/error-port.js';
12
12
  import { actionVersion, bundledActionNodeType, bundledContainerDefinition, bundledDefinition, perCallDefinition, resolveContributionDefinition } from './serialize.js';
13
- import { FORM_FIELD_TYPES, formFieldTypeHint, formFieldTypeWhy, validFormFieldTypesText } from './core/form-field-type.js';
13
+ import { FORM_FIELD_TYPES, formFieldOptionsProblem, formFieldOptionsWhy, formFieldTypeHint, formFieldTypeWhy, validFormFieldTypesText } from './core/form-field-type.js';
14
14
  import { AGENTIC_PROCESS_FORMS } from './core/agentic-process-forms.js';
15
15
  import { checkEventResolution, checkEventSubscription, eventParamsFor, } from './core/event-checks.js';
16
16
  import { checkCookedEscapes } from './flow-expr-check.js';
@@ -3418,6 +3418,16 @@ function checkInlineAgent(step, inputs, diags, opts = {}) {
3418
3418
  + `${JSON.stringify(f?.type)}, ${formFieldTypeWhy(f?.type, `which the form does not recognize — it would `
3419
3419
  + `be published as a plain string`)}. Valid: ${validFormFieldTypesText()}.`, formFieldTypeHint(f?.type));
3420
3420
  }
3421
+ const optionsProblem = formFieldOptionsProblem(f?.type, f?.options);
3422
+ if (optionsProblem !== undefined) {
3423
+ const message = `Inline agent step "${step}"'s quick-form escalation field "${String(f?.id ?? '?')}" `
3424
+ + `${formFieldOptionsWhy(optionsProblem, f?.type)}.`;
3425
+ if (optionsProblem === 'ignored')
3426
+ warn('INLINE_AGENT_ESCALATION_FIELD_OPTIONS_IGNORED', message);
3427
+ else {
3428
+ at('INLINE_AGENT_ESCALATION_FIELD_OPTIONS', message, `options: [{ id: 'opt-1', value: 'high', label: 'High' }, { id: 'opt-2', value: 'low', label: 'Low' }]`);
3429
+ }
3430
+ }
3421
3431
  }
3422
3432
  continue;
3423
3433
  }
@@ -4548,17 +4558,36 @@ function checkStepRefs(built, diags) {
4548
4558
  const scopeOf = new Map();
4549
4559
  /** Step name → the parallel arm it lives in, as `<parallel>#<index>`. */
4550
4560
  const armOf = new Map();
4561
+ /**
4562
+ * How many nodes answer to each NAMED End's name (`.return({}, { name })`), and
4563
+ * how many of those are steps. A named End is a ref target too (#1223): a side
4564
+ * path that runs into the main path's End is one End with two incoming edges,
4565
+ * which the product accepts and the canvas draws. Counted so a ref to a name
4566
+ * two nodes share is refused rather than wired to whichever came first.
4567
+ */
4568
+ const endNames = new Map();
4569
+ const stepNamesSeen = new Set();
4551
4570
  let seq = 0;
4552
4571
  const index = (steps, portList, scope, arm) => {
4553
4572
  for (const s of steps) {
4554
- if ('name' in s && s.kind !== 'return') {
4555
- if (!order.has(s.name))
4556
- order.set(s.name, seq);
4557
- if (portList)
4558
- inPortList.add(s.name);
4559
- scopeOf.set(s.name, scope);
4560
- if (arm)
4561
- armOf.set(s.name, arm);
4573
+ // A `return`'s own `name` is synthetic; its End is named by `options.name`.
4574
+ const name = s.kind === 'return' ? s.options?.name : 'name' in s ? s.name : undefined;
4575
+ if (name !== undefined) {
4576
+ if (s.kind === 'return')
4577
+ endNames.set(name, (endNames.get(name) ?? 0) + 1);
4578
+ else
4579
+ stepNamesSeen.add(name);
4580
+ // Exactly the step rule from before, for steps. A named End only fills a
4581
+ // name nothing has claimed; a clash is REF_AMBIGUOUS_TARGET below.
4582
+ if (s.kind !== 'return' || !order.has(name)) {
4583
+ if (!order.has(name))
4584
+ order.set(name, seq);
4585
+ if (portList)
4586
+ inPortList.add(name);
4587
+ scopeOf.set(name, scope);
4588
+ if (arm)
4589
+ armOf.set(name, arm);
4590
+ }
4562
4591
  }
4563
4592
  seq++;
4564
4593
  if (s.kind === 'stepToList')
@@ -4620,7 +4649,15 @@ function checkStepRefs(built, diags) {
4620
4649
  if (!order.has(target)) {
4621
4650
  const near = [...order.keys()].find((n) => n.toLowerCase() === target.toLowerCase());
4622
4651
  say('error', 'REF_UNKNOWN_TARGET', `.stepToRef('${target}') ${where} names a step this flow does not have`
4623
- + `${near ? ` (did you mean "${near}"?)` : ''}. It takes the same name you passed to .step().`, near ? `.stepToRef('${near}')` : undefined);
4652
+ + `${near ? ` (did you mean "${near}"?)` : ''}. It takes the same name you passed to .step(), or the `
4653
+ + `\`name\` of a \`.return({}, { name })\` End.`, near ? `.stepToRef('${near}')` : undefined);
4654
+ continue;
4655
+ }
4656
+ const ends = endNames.get(target) ?? 0;
4657
+ if (ends > 0 && ends + (stepNamesSeen.has(target) ? 1 : 0) > 1) {
4658
+ say('error', 'REF_AMBIGUOUS_TARGET', `.stepToRef('${target}') ${where} names ${stepNamesSeen.has(target) ? 'both a step and an End' : `${ends} Ends`} `
4659
+ + `called "${target}", so it is not clear which one the path should go to. Give each \`.return({}, { name })\` `
4660
+ + `a name no other step or End uses.`);
4624
4661
  continue;
4625
4662
  }
4626
4663
  // A ref to a container the path is INSIDE names it as a step, so the edge
@@ -62,15 +62,46 @@ export interface ConnectorOpts {
62
62
  */
63
63
  export type CanonicalFormFieldType = 'string' | 'number' | 'integer' | 'float' | 'double' | 'boolean' | 'date' | 'datetime' | 'file' | 'object' | 'array';
64
64
  /**
65
- * A form field's type: one of the platform's canonical types, or `'text'` /
65
+ * The platform's choice-control spellings of a field type. Each names the data
66
+ * the field holds AND how it renders (`@uipath/hitl-schema-types`
67
+ * `resolveFieldType` + `controlForDeclaredType`): `singleSelect` is a `string`
68
+ * shown as a dropdown, `multiSelect` an `array` shown as a multi-select, and
69
+ * `enum` (what the designer's variable picker writes for a closed set) a
70
+ * `string` shown as a dropdown. The choices are the field's `options`.
71
+ */
72
+ export type ControlFormFieldType = 'singleSelect' | 'multiSelect' | 'enum';
73
+ /**
74
+ * A form field's type: one of the platform's canonical types; `'text'` /
66
75
  * `'date-time'`, the SDK's older spellings of `'string'` / `'datetime'` (the
67
- * platform resolves both). A quick form with any other type fails the
68
- * platform's validation, which is why `check` rejects it.
76
+ * platform resolves both); or a choice-control spelling (`'singleSelect'`,
77
+ * `'multiSelect'`, `'enum'`), which the form renders as a picker over the
78
+ * field's `options`. A quick form with any other type fails the platform's
79
+ * validation, which is why `check` rejects it.
69
80
  *
70
81
  * @example
71
82
  * hitl({ fields: [{ id: 'dueOn', type: 'date', direction: 'output' }], outcomes: ['Done'] })
83
+ * hitl({
84
+ * fields: [{
85
+ * id: 'priority', type: 'singleSelect', direction: 'output',
86
+ * options: [{ id: 'opt-1', value: 'high', label: 'High' }, { id: 'opt-2', value: 'low', label: 'Low' }],
87
+ * }],
88
+ * outcomes: ['Done'],
89
+ * })
90
+ */
91
+ export type FormFieldType = CanonicalFormFieldType | 'text' | 'date-time' | ControlFormFieldType;
92
+ /**
93
+ * One choice of a picker field (`@uipath/hitl-schema-types` `optionShape`):
94
+ * `value` is what the answer holds, `label` what the reviewer sees, and `id`
95
+ * the option's own stable id. All three are required by the platform.
72
96
  */
73
- export type FormFieldType = CanonicalFormFieldType | 'text' | 'date-time';
97
+ export interface FormFieldOption {
98
+ /** The option's own stable id, e.g. `'opt-1'`. */
99
+ id: string;
100
+ /** What the answer holds when the reviewer picks it, e.g. `'high'`. */
101
+ value: string;
102
+ /** What the reviewer sees, e.g. `'High'`. */
103
+ label: string;
104
+ }
74
105
  /** A value the reviewer READS: shown on the form, filled in from the flow. */
75
106
  export interface ShownField {
76
107
  /** Field id — the key the answer object uses. Must be unique on the form. */
@@ -79,6 +110,11 @@ export interface ShownField {
79
110
  label?: string;
80
111
  type: FormFieldType;
81
112
  direction: 'input';
113
+ /**
114
+ * The choices of a picker field — a `singleSelect` / `enum` dropdown or a
115
+ * `multiSelect`. Written to the form verbatim, in order.
116
+ */
117
+ options?: FormFieldOption[];
82
118
  /**
83
119
  * What to show — a reference, e.g. `input('amount')` or
84
120
  * `out('fetchInvoice', 'body.total')`. Required: a shown field with nothing
@@ -95,6 +131,11 @@ export interface AskedField {
95
131
  label?: string;
96
132
  type: FormFieldType;
97
133
  direction: 'output';
134
+ /**
135
+ * The choices of a picker field — a `singleSelect` / `enum` dropdown or a
136
+ * `multiSelect`. Written to the form verbatim, in order.
137
+ */
138
+ options?: FormFieldOption[];
98
139
  /** Reviewer must fill it in before the task can be completed. */
99
140
  required?: boolean;
100
141
  }
@@ -111,6 +152,11 @@ export interface InOutField {
111
152
  label?: string;
112
153
  type: FormFieldType;
113
154
  direction: 'inOut';
155
+ /**
156
+ * The choices of a picker field — a `singleSelect` / `enum` dropdown or a
157
+ * `multiSelect`. Written to the form verbatim, in order.
158
+ */
159
+ options?: FormFieldOption[];
114
160
  /** What to pre-fill — a reference, e.g. `out('fetchInvoice', 'body.total')`. */
115
161
  value: Expr;
116
162
  /** Reviewer must confirm/fill it before the task can be completed. */
@@ -14,35 +14,59 @@
14
14
  * on it at runtime. This module is a transcription, and
15
15
  * `tests/hitl-field-type-contract.test.ts` runs the real package against it:
16
16
  * the canonical set, each type's JSON Schema, and every alias below must agree
17
- * with the pinned `@uipath/hitl-schema-types`. A canonical-set or JSON Schema
18
- * change upstream fails there; a new alias does not. The test also pins how the
19
- * package resolves the spellings this module leaves out, so a change in those
20
- * shows up.
17
+ * with the pinned `@uipath/hitl-schema-types`. A canonical-set, JSON Schema or
18
+ * control-spelling change upstream fails there; a new data alias does not.
19
+ *
20
+ * Besides data types, the product accepts three CHOICE-CONTROL spellings —
21
+ * `singleSelect`, `multiSelect`, `enum` — which name both the data the field
22
+ * holds and the control it renders with (#1235). The SDK accepts them in
23
+ * source and writes them as written, so a canvas dropdown stays a dropdown.
21
24
  *
22
25
  * Every SDK path reads field types from here: `check` (human tasks and inline
23
26
  * agent quick-form escalations), decompile, and the serializers' output
24
27
  * schemas.
25
28
  */
26
- import type { CanonicalFormFieldType } from './actions.js';
29
+ import type { CanonicalFormFieldType, ControlFormFieldType, FormFieldOption } from './actions.js';
27
30
  /** The product's 11 canonical quick-form field types, in its own order. */
28
31
  export declare const CANONICAL_FORM_FIELD_TYPES: CanonicalFormFieldType[];
29
32
  /** The SDK's legacy spellings (`text`, `date-time`), for diagnostics that list them. */
30
33
  export declare const LEGACY_FORM_FIELD_TYPES: string[];
31
- /** Every form-field type the SDK accepts in source: the 11 canonical ones and the SDK's legacy aliases. */
34
+ /** The choice-control spellings, for `check`'s messages and the contract test. */
35
+ export declare const CONTROL_FORM_FIELD_TYPES: ControlFormFieldType[];
36
+ /**
37
+ * Every form-field type the SDK accepts in source: the 11 canonical ones, the
38
+ * SDK's legacy aliases, and the three choice-control spellings.
39
+ */
32
40
  export declare const FORM_FIELD_TYPES: string[];
33
41
  /**
34
42
  * The tail of a "Valid: …" diagnostic: the canonical types, then the legacy
35
- * spellings in parentheses.
43
+ * spellings and the choice controls in parentheses.
36
44
  *
37
45
  * @example
38
- * validFormFieldTypesText() // 'string, number, …, array (or the older text, date-time)'
46
+ * validFormFieldTypesText() // 'string, …, array (or the older text, date-time; or a choice control: singleSelect, …)'
39
47
  */
40
48
  export declare function validFormFieldTypesText(): string;
49
+ /**
50
+ * The data type and control a choice-control spelling names, or `undefined`
51
+ * for any other type.
52
+ *
53
+ * @example
54
+ * controlFormFieldType('singleSelect') // { data: 'string', control: 'dropdown' }
55
+ * controlFormFieldType('multiSelect') // { data: 'array', control: 'multiSelect' }
56
+ * controlFormFieldType('string') // undefined
57
+ */
58
+ export declare function controlFormFieldType(type: unknown): {
59
+ data: CanonicalFormFieldType;
60
+ control: string;
61
+ } | undefined;
41
62
  /** The product aliases, for the contract test. */
42
63
  export declare const FORM_FIELD_TYPE_ALIASES: Readonly<Record<string, CanonicalFormFieldType>>;
43
64
  /**
44
65
  * The canonical type a wire or source spelling names, or `undefined` when it
45
- * names none the SDK models.
66
+ * names none the SDK models. A choice-control spelling is its own wire
67
+ * spelling, so it is `undefined` here too — its data type is
68
+ * `controlFormFieldType(t).data`; resolving it here would let a caller write
69
+ * `string` for a dropdown.
46
70
  *
47
71
  * @example
48
72
  * canonicalFormFieldType('integer') // 'integer'
@@ -71,11 +95,9 @@ export declare function canonicalFormFieldType(type: unknown): CanonicalFormFiel
71
95
  export declare function sourceFormFieldType(wire: unknown): string;
72
96
  /**
73
97
  * Why a form-field type is refused: a product alias the SDK does not accept in
74
- * source (`"System.Int32"` is the platform's .NET name for `integer`), a control
75
- * spelling the platform accepts but the SDK does not model yet
76
- * (`"singleSelect"`), or a type the product does not recognize at all.
77
- * `unknownWhy` is the caller's reason for the last case; the default is a human
78
- * task's.
98
+ * source (`"System.Int32"` is the platform's .NET name for `integer`), or a type
99
+ * the product does not recognize at all. `unknownWhy` is the caller's reason for
100
+ * the second case; the default is a human task's.
79
101
  */
80
102
  export declare function formFieldTypeWhy(type: unknown, unknownWhy?: string): string;
81
103
  /** The `suggestion` for an unknown form-field type: its canonical type, the nearest valid one, else `type: 'string'`. */
@@ -87,8 +109,52 @@ export declare function formFieldTypeHint(type: unknown): string;
87
109
  * @example
88
110
  * formFieldJsonSchema('date') // { type: 'string', format: 'date' }
89
111
  * formFieldJsonSchema('text') // { type: 'string' }
112
+ * formFieldJsonSchema('multiSelect') // { type: 'array' }
90
113
  */
91
114
  export declare function formFieldJsonSchema(type: unknown): {
92
115
  type: string;
93
116
  format?: string;
94
117
  } | undefined;
118
+ /**
119
+ * A picker field's choices as the form row carries them (`options`, each
120
+ * `{ id, value, label }` — the product's `optionShape`), copied so the wire
121
+ * never aliases the author's array. `undefined` when the field has none.
122
+ * Both serializers (human task, quick-form escalation) write through this.
123
+ */
124
+ export declare function formFieldOptionsWire(options: readonly FormFieldOption[] | undefined): FormFieldOption[] | undefined;
125
+ /**
126
+ * A form row's `options` read back for source: the well-formed choices
127
+ * (string `id`, `value`, `label` — all three the product requires), or
128
+ * `undefined` when the row has no `options` array. A malformed entry is left
129
+ * out, not coerced: `{ id: 'o1', value: 1 }` is not rewritten to
130
+ * `value: '1', label: ''`. Decompile (human task and quick-form escalation)
131
+ * reads through this, so a canvas dropdown's choices survive an edit.
132
+ *
133
+ * @example
134
+ * liftFormFieldOptions([{ id: 'o1', value: 'a', label: 'A' }, { id: 'o2', value: 2 }])
135
+ * // [{ id: 'o1', value: 'a', label: 'A' }]
136
+ */
137
+ export declare function liftFormFieldOptions(raw: unknown): FormFieldOption[] | undefined;
138
+ /**
139
+ * What is wrong with a form field's `options`, or `undefined` when nothing is.
140
+ * `check` reports each (human task and quick-form escalation):
141
+ *
142
+ * - `'malformed'`: `options` is there but is not an array of `{ id, value,
143
+ * label }` strings. Serialize would throw or write rows the platform refuses.
144
+ * - `'missing'`: a choice-control type (`singleSelect`, `multiSelect`, `enum`)
145
+ * with no choices. The form publishes an empty picker.
146
+ * - `'ignored'`: choices on a type that is not a choice control. The form
147
+ * renders a plain input and never shows them.
148
+ *
149
+ * @example
150
+ * formFieldOptionsProblem('singleSelect', undefined) // 'missing'
151
+ * formFieldOptionsProblem('singleSelect', {}) // 'malformed'
152
+ * formFieldOptionsProblem('string', [{ id: 'o1', value: 'a', label: 'A' }]) // 'ignored'
153
+ * formFieldOptionsProblem('enum', [{ id: 'o1', value: 'a', label: 'A' }]) // undefined
154
+ */
155
+ export declare function formFieldOptionsProblem(type: unknown, options: unknown): 'malformed' | 'missing' | 'ignored' | undefined;
156
+ /**
157
+ * The reason text for a `formFieldOptionsProblem`, worded for a diagnostic
158
+ * that starts "… the field "<id>" …".
159
+ */
160
+ export declare function formFieldOptionsWhy(problem: 'malformed' | 'missing' | 'ignored', type: unknown): string;
@@ -32,17 +32,53 @@ const SDK_ALIASES = {
32
32
  };
33
33
  /** The SDK's legacy spellings (`text`, `date-time`), for diagnostics that list them. */
34
34
  export const LEGACY_FORM_FIELD_TYPES = Object.keys(SDK_ALIASES);
35
- /** Every form-field type the SDK accepts in source: the 11 canonical ones and the SDK's legacy aliases. */
36
- export const FORM_FIELD_TYPES = [...CANONICAL_FORM_FIELD_TYPES, ...LEGACY_FORM_FIELD_TYPES];
35
+ /**
36
+ * The product's choice-control spellings (#1235): the data type each holds
37
+ * (`resolveFieldType`) and the control the form renders it with
38
+ * (`controlForDeclaredType`, which sets the field's `controlType` when the row
39
+ * has none). The output schema is the data type's; the choices ride on the
40
+ * field's `options`, never in the schema.
41
+ *
42
+ * Kept as written on the wire — NOT resolved to the data type, which would
43
+ * drop the control: a `singleSelect` written as `string` renders as a text box.
44
+ */
45
+ const CONTROL_TYPES = {
46
+ singleSelect: { data: 'string', control: 'dropdown' },
47
+ multiSelect: { data: 'array', control: 'multiSelect' },
48
+ enum: { data: 'string', control: 'dropdown' },
49
+ };
50
+ /** The choice-control spellings, for `check`'s messages and the contract test. */
51
+ export const CONTROL_FORM_FIELD_TYPES = Object.keys(CONTROL_TYPES);
52
+ /**
53
+ * Every form-field type the SDK accepts in source: the 11 canonical ones, the
54
+ * SDK's legacy aliases, and the three choice-control spellings.
55
+ */
56
+ export const FORM_FIELD_TYPES = [
57
+ ...CANONICAL_FORM_FIELD_TYPES, ...LEGACY_FORM_FIELD_TYPES, ...CONTROL_FORM_FIELD_TYPES,
58
+ ];
37
59
  /**
38
60
  * The tail of a "Valid: …" diagnostic: the canonical types, then the legacy
39
- * spellings in parentheses.
61
+ * spellings and the choice controls in parentheses.
40
62
  *
41
63
  * @example
42
- * validFormFieldTypesText() // 'string, number, …, array (or the older text, date-time)'
64
+ * validFormFieldTypesText() // 'string, …, array (or the older text, date-time; or a choice control: singleSelect, …)'
43
65
  */
44
66
  export function validFormFieldTypesText() {
45
- return `${CANONICAL_FORM_FIELD_TYPES.join(', ')} (or the older ${LEGACY_FORM_FIELD_TYPES.join(', ')})`;
67
+ return `${CANONICAL_FORM_FIELD_TYPES.join(', ')} (or the older ${LEGACY_FORM_FIELD_TYPES.join(', ')}; `
68
+ + `or a choice control: ${CONTROL_FORM_FIELD_TYPES.join(', ')})`;
69
+ }
70
+ /**
71
+ * The data type and control a choice-control spelling names, or `undefined`
72
+ * for any other type.
73
+ *
74
+ * @example
75
+ * controlFormFieldType('singleSelect') // { data: 'string', control: 'dropdown' }
76
+ * controlFormFieldType('multiSelect') // { data: 'array', control: 'multiSelect' }
77
+ * controlFormFieldType('string') // undefined
78
+ */
79
+ export function controlFormFieldType(type) {
80
+ const entry = typeof type === 'string' ? ownEntry(CONTROL_TYPES, type) : undefined;
81
+ return entry === undefined ? undefined : { ...entry };
46
82
  }
47
83
  /**
48
84
  * The product's other data-type spellings, each with the canonical type it
@@ -53,10 +89,9 @@ export function validFormFieldTypesText() {
53
89
  * resolve to `string`. `T[]` and `a | b` are matched by pattern in
54
90
  * `canonicalFormFieldType`.
55
91
  *
56
- * Deliberately left out: the CONTROL spellings `singleSelect`, `multiSelect`
57
- * and `enum` (`CONTROL_FORM_FIELD_TYPES`). The product infers a `controlType`
58
- * from those, so rewriting one to its data type would change how the form
59
- * renders. Decompile keeps them as written, and `check` reports them.
92
+ * Deliberately left out: the choice-control spellings (`singleSelect`,
93
+ * `multiSelect`, `enum` — `CONTROL_TYPES` above, kept as written because the
94
+ * product infers a `controlType` from them).
60
95
  */
61
96
  const PRODUCT_ALIASES = {
62
97
  ...SDK_ALIASES,
@@ -104,18 +139,15 @@ const PRODUCT_ALIASES = {
104
139
  ref: 'string',
105
140
  any: 'string',
106
141
  };
107
- /**
108
- * The product's control spellings: it accepts them as a field `type` and infers
109
- * a picker control from them (`CONTROL_FOR_DECLARED_TYPE`). The SDK does not
110
- * model them yet.
111
- */
112
- const CONTROL_FORM_FIELD_TYPES = ['singleSelect', 'multiSelect', 'enum'];
113
142
  /** The product aliases, for the contract test. */
114
143
  export const FORM_FIELD_TYPE_ALIASES = PRODUCT_ALIASES;
115
144
  const isCanonical = (t) => typeof t === 'string' && Object.hasOwn(CANONICAL_JSON_SCHEMA, t);
116
145
  /**
117
146
  * The canonical type a wire or source spelling names, or `undefined` when it
118
- * names none the SDK models.
147
+ * names none the SDK models. A choice-control spelling is its own wire
148
+ * spelling, so it is `undefined` here too — its data type is
149
+ * `controlFormFieldType(t).data`; resolving it here would let a caller write
150
+ * `string` for a dropdown.
119
151
  *
120
152
  * @example
121
153
  * canonicalFormFieldType('integer') // 'integer'
@@ -159,20 +191,15 @@ export function sourceFormFieldType(wire) {
159
191
  }
160
192
  /**
161
193
  * Why a form-field type is refused: a product alias the SDK does not accept in
162
- * source (`"System.Int32"` is the platform's .NET name for `integer`), a control
163
- * spelling the platform accepts but the SDK does not model yet
164
- * (`"singleSelect"`), or a type the product does not recognize at all.
165
- * `unknownWhy` is the caller's reason for the last case; the default is a human
166
- * task's.
194
+ * source (`"System.Int32"` is the platform's .NET name for `integer`), or a type
195
+ * the product does not recognize at all. `unknownWhy` is the caller's reason for
196
+ * the second case; the default is a human task's.
167
197
  */
168
198
  export function formFieldTypeWhy(type, unknownWhy = `which is not a quick-form field type: the platform rejects the form`) {
169
199
  const canonical = canonicalFormFieldType(type);
170
- if (canonical !== undefined)
171
- return `which is the platform's alias of '${canonical}' — the SDK writes '${canonical}'`;
172
- if (typeof type === 'string' && CONTROL_FORM_FIELD_TYPES.includes(type)) {
173
- return `which the platform accepts as a picker control but the SDK does not model yet`;
174
- }
175
- return unknownWhy;
200
+ return canonical !== undefined
201
+ ? `which is the platform's alias of '${canonical}' — the SDK writes '${canonical}'`
202
+ : unknownWhy;
176
203
  }
177
204
  /** The `suggestion` for an unknown form-field type: its canonical type, the nearest valid one, else `type: 'string'`. */
178
205
  export function formFieldTypeHint(type) {
@@ -189,9 +216,86 @@ export function formFieldTypeHint(type) {
189
216
  * @example
190
217
  * formFieldJsonSchema('date') // { type: 'string', format: 'date' }
191
218
  * formFieldJsonSchema('text') // { type: 'string' }
219
+ * formFieldJsonSchema('multiSelect') // { type: 'array' }
192
220
  */
193
221
  export function formFieldJsonSchema(type) {
194
- const canonical = canonicalFormFieldType(type);
222
+ const canonical = canonicalFormFieldType(type) ?? controlFormFieldType(type)?.data;
195
223
  const schema = canonical === undefined ? undefined : ownEntry(CANONICAL_JSON_SCHEMA, canonical);
196
224
  return schema === undefined ? undefined : { ...schema };
197
225
  }
226
+ /**
227
+ * A picker field's choices as the form row carries them (`options`, each
228
+ * `{ id, value, label }` — the product's `optionShape`), copied so the wire
229
+ * never aliases the author's array. `undefined` when the field has none.
230
+ * Both serializers (human task, quick-form escalation) write through this.
231
+ */
232
+ export function formFieldOptionsWire(options) {
233
+ return options === undefined ? undefined : options.map((o) => ({ id: o.id, value: o.value, label: o.label }));
234
+ }
235
+ /** Whether a value is one well-formed choice: string `id`, `value` and `label`, all three the product requires. */
236
+ function isFormFieldOption(o) {
237
+ if (o === null || typeof o !== 'object')
238
+ return false;
239
+ const r = o;
240
+ return typeof r.id === 'string' && typeof r.value === 'string' && typeof r.label === 'string';
241
+ }
242
+ /**
243
+ * A form row's `options` read back for source: the well-formed choices
244
+ * (string `id`, `value`, `label` — all three the product requires), or
245
+ * `undefined` when the row has no `options` array. A malformed entry is left
246
+ * out, not coerced: `{ id: 'o1', value: 1 }` is not rewritten to
247
+ * `value: '1', label: ''`. Decompile (human task and quick-form escalation)
248
+ * reads through this, so a canvas dropdown's choices survive an edit.
249
+ *
250
+ * @example
251
+ * liftFormFieldOptions([{ id: 'o1', value: 'a', label: 'A' }, { id: 'o2', value: 2 }])
252
+ * // [{ id: 'o1', value: 'a', label: 'A' }]
253
+ */
254
+ export function liftFormFieldOptions(raw) {
255
+ if (!Array.isArray(raw))
256
+ return undefined;
257
+ return raw.filter(isFormFieldOption).map((o) => ({ id: o.id, value: o.value, label: o.label }));
258
+ }
259
+ /**
260
+ * What is wrong with a form field's `options`, or `undefined` when nothing is.
261
+ * `check` reports each (human task and quick-form escalation):
262
+ *
263
+ * - `'malformed'`: `options` is there but is not an array of `{ id, value,
264
+ * label }` strings. Serialize would throw or write rows the platform refuses.
265
+ * - `'missing'`: a choice-control type (`singleSelect`, `multiSelect`, `enum`)
266
+ * with no choices. The form publishes an empty picker.
267
+ * - `'ignored'`: choices on a type that is not a choice control. The form
268
+ * renders a plain input and never shows them.
269
+ *
270
+ * @example
271
+ * formFieldOptionsProblem('singleSelect', undefined) // 'missing'
272
+ * formFieldOptionsProblem('singleSelect', {}) // 'malformed'
273
+ * formFieldOptionsProblem('string', [{ id: 'o1', value: 'a', label: 'A' }]) // 'ignored'
274
+ * formFieldOptionsProblem('enum', [{ id: 'o1', value: 'a', label: 'A' }]) // undefined
275
+ */
276
+ export function formFieldOptionsProblem(type, options) {
277
+ if (options !== undefined && (!Array.isArray(options) || !options.every(isFormFieldOption)))
278
+ return 'malformed';
279
+ const isControl = controlFormFieldType(type) !== undefined;
280
+ if (isControl && (options === undefined || options.length === 0))
281
+ return 'missing';
282
+ if (!isControl && options !== undefined && options.length > 0)
283
+ return 'ignored';
284
+ return undefined;
285
+ }
286
+ /**
287
+ * The reason text for a `formFieldOptionsProblem`, worded for a diagnostic
288
+ * that starts "… the field "<id>" …".
289
+ */
290
+ export function formFieldOptionsWhy(problem, type) {
291
+ switch (problem) {
292
+ case 'malformed':
293
+ return `has \`options\` that are not a list of { id, value, label } strings. Serialize cannot write them, `
294
+ + `and the platform requires all three on every choice`;
295
+ case 'missing':
296
+ return `is a ${JSON.stringify(type)} picker with no \`options\`, so the form publishes an empty picker`;
297
+ case 'ignored':
298
+ return `has \`options\` but its type ${JSON.stringify(type)} is not a choice control, so the form renders a `
299
+ + `plain input and never shows them. Use 'singleSelect' or 'enum' (a dropdown) or 'multiSelect'`;
300
+ }
301
+ }
package/dist/decompile.js CHANGED
@@ -50,7 +50,7 @@ import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
50
50
  import { contributionPort, forwardFanOut } from './core/definition-handles.js';
51
51
  import { STICKY_NOTE_TYPE } from './core/definitionless-node-types.js';
52
52
  import { eventFilterFromTreeLeaf } from './core/event-filters.js';
53
- import { sourceFormFieldType } from './core/form-field-type.js';
53
+ import { liftFormFieldOptions, sourceFormFieldType } from './core/form-field-type.js';
54
54
  /**
55
55
  * The trigger id of the flow currently being decompiled, so a reference through a
56
56
  * RENAMED trigger is still recognized as `input('x')` rather than falling back to
@@ -1306,6 +1306,12 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1306
1306
  ['id', tsText(f.id)],
1307
1307
  ['type', tsLiteral(sourceFormFieldType(f.type ?? 'string'))],
1308
1308
  ['direction', tsLiteral(String(f.direction ?? 'output'))],
1309
+ ['options', (() => {
1310
+ const options = liftFormFieldOptions(f.options);
1311
+ return options === undefined ? undefined : `[${options.map((o) => opts([
1312
+ ['id', tsLiteral(o.id)], ['value', tsLiteral(o.value)], ['label', tsLiteral(o.label)],
1313
+ ])).join(', ')}]`;
1314
+ })()],
1309
1315
  ])).join(', ')}]` : undefined],
1310
1316
  ['app', app === undefined ? undefined : opts([
1311
1317
  ['key', tsText(app.key)],
@@ -2276,7 +2282,7 @@ function armCb(param, segs) {
2276
2282
  function returnSeg(node, ctx) {
2277
2283
  const updates = ctx.flow.variables?.variableUpdates?.[node.id] ?? [];
2278
2284
  const outputIds = new Set(readFlowGlobals(ctx.flow.variables).filter((g) => g.direction === 'out').map((g) => g.id));
2279
- const opts = returnOptsArg(node);
2285
+ const opts = returnOptsArg(node, ctx.refTargets?.has(node.id) ?? false);
2280
2286
  const entries = updates.map((u) => `${key(u.variableId)}: ${emitVal(u.expression, ctx.imp, ctx.inputNames)}`);
2281
2287
  const written = new Set(updates.map((u) => u.variableId));
2282
2288
  for (const [name, out] of Object.entries(node.outputs ?? {})) {
@@ -2317,9 +2323,10 @@ function isIdentityEndSource(raw, name, isOutput = false) {
2317
2323
  * it — compile then refused its own decompiled source with `layout: "endNiceDay"
2318
2324
  * names no step or node in this flow`.
2319
2325
  */
2320
- function returnOptsArg(node) {
2326
+ function returnOptsArg(node, referenced = false) {
2321
2327
  const parts = [];
2322
- if (node.id !== 'end')
2328
+ // A `.stepToRef` into this End needs it named, `end` included (#1223).
2329
+ if (node.id !== 'end' || referenced)
2323
2330
  parts.push(`name: ${tsLiteral(node.id)}`);
2324
2331
  const ver = verClause(node);
2325
2332
  if (ver)
@@ -2365,7 +2372,12 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2365
2372
  const segs = [];
2366
2373
  while (cursor !== undefined && cursor !== stop && cursor !== EXIT) {
2367
2374
  // A ref forward: this path re-enters a step the main path reaches later.
2375
+ // That includes the main path's End: one End with several incoming edges is
2376
+ // a valid flow (the canvas draws it, `edge add` writes it), and a named End is
2377
+ // a ref target, so the side path joins it instead of minting a second End
2378
+ // (#1223). The main path's `.return(...)` then carries the End's `name`.
2368
2379
  if (refInto?.has(cursor)) {
2380
+ (ctx.refTargets ??= new Set()).add(cursor);
2369
2381
  segs.push(`.stepToRef(${tsLiteral(cursor)})`);
2370
2382
  return segs;
2371
2383
  }
@@ -2375,8 +2387,9 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2375
2387
  // A ref backward or sideways: this path has arrived somewhere already emitted.
2376
2388
  // Before, the walk simply stopped here — which silently dropped the rest of a
2377
2389
  // cycle, and a backward edge is now something the builder can author. Ends and
2378
- // terminates are excluded: they are not named steps, so there is nothing a ref
2379
- // could resolve against, and each path emitting its own is already correct.
2390
+ // terminates are excluded: each path emitting its own is a valid flow. (A
2391
+ // side path into the MAIN path's End is the lookahead above, #1223; an End
2392
+ // emitted earlier may be unnamed, so it is not a ref target here.)
2380
2393
  if (ctx.emitted.has(cursor) && node.type !== T.end && node.type !== T.terminate) {
2381
2394
  segs.push(`.stepToRef(${tsLiteral(cursor)})`);
2382
2395
  return segs;
@@ -467,6 +467,10 @@ export interface ReturnOptions extends NodeOptions {
467
467
  * same values, label and version are one End reached from both paths; Ends that
468
468
  * differ are de-duplicated to `done` and `done2`.
469
469
  *
470
+ * A named End is also a {@link StepList.stepToRef} target: another path can
471
+ * end at this End rather than at one of its own. `check` refuses the ref
472
+ * (REF_AMBIGUOUS_TARGET) when the name is shared by another End or a step.
473
+ *
470
474
  * ```ts
471
475
  * .branch('checkTemperature', js`${input('tempF')} > 60`,
472
476
  * (t) => t.return({}, { name: 'endNiceDay', label: 'Nice Day' }),
@@ -1196,7 +1200,19 @@ declare class StepList {
1196
1200
  * can prove it refuses; what depends on the runtime it warns about and leaves to
1197
1201
  * `uip maestro flow validate`.
1198
1202
  *
1199
- * @param target - The step to continue at, named as it was in `.step(...)`.
1203
+ * The target may also be a NAMED End, `.return({…}, { name })`: the path then
1204
+ * ends at that End node instead of one of its own, so one End has several
1205
+ * incoming edges, as the canvas draws it.
1206
+ *
1207
+ * ```ts
1208
+ * .step('fetch', http({ …, branches: [{ name: 'missing', condition: … }] }))
1209
+ * .stepToList('branch-missing', (b) => b.step('log', script({ … })).stepToRef('done'))
1210
+ * .step('use', script({ … }))
1211
+ * .return({}, { name: 'done' }) // both paths end at this one End
1212
+ * ```
1213
+ *
1214
+ * @param target - The step to continue at, named as it was in `.step(...)`, or
1215
+ * the `name` of a `.return(...)` End.
1200
1216
  * @returns This builder. Nothing may follow: the path has been handed off.
1201
1217
  * @see stepToList
1202
1218
  */
@@ -1216,7 +1232,8 @@ declare class StepList {
1216
1232
  * ```
1217
1233
  *
1218
1234
  * @param port - The port of this step to leave, e.g. `'error'`.
1219
- * @param target - The step to continue at, named as it was in `.step(...)`.
1235
+ * @param target - The step to continue at, named as it was in `.step(...)`, or
1236
+ * the `name` of a `.return(...)` End.
1220
1237
  * @returns This builder, so calls chain — a side exit ends nothing.
1221
1238
  * @see stepToList
1222
1239
  */
package/dist/flow-sdk.js CHANGED
@@ -840,7 +840,7 @@ class StepList {
840
840
  const target = maybeTarget === undefined ? portOrTarget : maybeTarget;
841
841
  if (typeof target !== 'string' || target.length === 0) {
842
842
  throw new TypeError(`.stepToRef() takes the NAME of the step to continue at — .stepToRef('priceIt') — and got ${JSON.stringify(target)}. `
843
- + `It is the same name you passed to .step(), so the edge can be wired after the whole flow is built.`);
843
+ + `It is the same name you passed to .step() (or a .return() End's \`name\`), so the edge can be wired after the whole flow is built.`);
844
844
  }
845
845
  // A non-default port leaves the PRECEDING step, so it is guarded the same way
846
846
  // `.stepToList()` is; the default port continues the list and needs no source.