@uipath/maestro-builder-sdk 6.34.0 → 6.35.1

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.
@@ -1,3 +1,5 @@
1
+ import { jsonClone } from './json-clone.js';
2
+ import { isRecord } from './node-envelope.js';
1
3
  import { nearestName } from './edit-distance.js';
2
4
  import { ownEntry } from './own-entry.js';
3
5
  /**
@@ -228,60 +230,129 @@ export function formFieldJsonSchema(type) {
228
230
  /**
229
231
  * A picker field's choices as the form row carries them (`options`, each
230
232
  * `{ id, value, label }` — the product's `optionShape`), copied so the wire
231
- * never aliases the author's array. `undefined` when the field has none.
232
- * Both serializers (human task, quick-form escalation) write through this.
233
+ * never aliases the author's array.
233
234
  */
234
- export function formFieldOptionsWire(options) {
235
- return options === undefined ? undefined : options.map((o) => ({ id: o.id, value: o.value, label: o.label }));
235
+ function formFieldOptionsWire(options) {
236
+ return options.map((o) => ({ id: o.id, value: o.value, label: o.label }));
236
237
  }
237
- /** Whether a value is one well-formed choice: string `id`, `value` and `label`, all three the product requires. */
238
- function isFormFieldOption(o) {
239
- if (o === null || typeof o !== 'object')
240
- return false;
241
- const r = o;
242
- return typeof r.id === 'string' && typeof r.value === 'string' && typeof r.label === 'string';
238
+ // ─── The whole field row (#1250) ─────────────────────────────────────────────
239
+ /**
240
+ * Every key of a quick-form field row, in the platform's own order
241
+ * (`@uipath/hitl-schema-types` 1.2.0 `fieldSchemaFor`). The contract test
242
+ * compares this list with the real package, so a key added upstream fails
243
+ * there until the SDK decides how to carry it.
244
+ */
245
+ export const FORM_FIELD_ROW_KEYS = [
246
+ 'id', 'type', 'label', 'direction', 'required', 'variable', 'binding', 'value',
247
+ 'rules', 'requiredMessage', 'colSpan', 'controlType', 'options', 'defaultValue', 'description',
248
+ ];
249
+ /** The platform's controls (`controlType`), in its own order. */
250
+ export const FORM_FIELD_CONTROL_TYPES = [
251
+ 'text', 'textarea', 'numberInput', 'datePicker', 'dateAndTimePicker', 'checkbox',
252
+ 'dropdown', 'radioGroup', 'multiSelect', 'checkboxGroup', 'fileUpload',
253
+ ];
254
+ const isString = (v) => typeof v === 'string';
255
+ /*
256
+ * The shapes below check only the keys the platform defines. Its `optionShape`,
257
+ * `fieldRuleSchema` and `fieldConditionSchema` are plain `z.object`s, which
258
+ * STRIP an unknown key rather than refuse it, so an extra key is not a reason
259
+ * for `check` to fail. Decompile drops one (`stripSetting`) and reports it
260
+ * (`formFieldRowLoss`), because the SDK's source types have no place for it.
261
+ */
262
+ const OPTION_KEYS = ['id', 'value', 'label'];
263
+ const RULE_KEYS = ['id', 'conditions', 'operator', 'effects'];
264
+ const CONDITION_KEYS = ['when', 'is', 'isNot', 'in', 'notIn', 'matches', 'custom'];
265
+ function isOptions(v) {
266
+ return Array.isArray(v) && v.every((o) => isRecord(o) && isString(o.id) && isString(o.value) && isString(o.label));
267
+ }
268
+ /** `fieldConditionSchema`: `when` plus optional `is`/`isNot`/`in`/`notIn`/`matches`/`custom`. */
269
+ function isCondition(v) {
270
+ return isRecord(v)
271
+ && isString(v.when)
272
+ && (v.in === undefined || Array.isArray(v.in)) && (v.notIn === undefined || Array.isArray(v.notIn))
273
+ && (v.matches === undefined || isString(v.matches)) && (v.custom === undefined || isString(v.custom));
243
274
  }
275
+ /** `fieldRuleSchema`: `{ id, conditions, operator?, effects }`. */
276
+ function isRules(v) {
277
+ return Array.isArray(v) && v.every((r) => isRecord(r)
278
+ && isString(r.id) && Array.isArray(r.conditions) && r.conditions.every(isCondition)
279
+ && (r.operator === undefined || r.operator === 'AND' || r.operator === 'OR') && isRecord(r.effects));
280
+ }
281
+ /**
282
+ * The settings keys (`FormFieldSettings`), in the platform's row order, each
283
+ * with the shape the platform's schema accepts. `Record<keyof …>` makes tsc
284
+ * refuse a settings key without an entry here.
285
+ */
286
+ const SETTINGS = {
287
+ rules: isRules,
288
+ requiredMessage: isString,
289
+ colSpan: (v) => Number.isInteger(v) && v >= 1 && v <= 12,
290
+ controlType: (v) => FORM_FIELD_CONTROL_TYPES.includes(v),
291
+ options: isOptions,
292
+ defaultValue: (v) => isString(v) || (Array.isArray(v) && v.every(isString)),
293
+ description: isString,
294
+ };
295
+ /** Whether `value` has the platform's shape for the settings key `key`; false for any other key. */
296
+ const validSetting = (key, value) => ownEntry(SETTINGS, key)?.(value) === true;
297
+ /** What each settings key must be, for `check`'s message. */
298
+ const SETTINGS_SHAPE = {
299
+ rules: "a list of { id, conditions: [{ when, … }], operator?: 'AND' | 'OR', effects: { … } }",
300
+ requiredMessage: 'a string',
301
+ colSpan: 'an integer from 1 to 12 (the form\'s 12-column grid)',
302
+ controlType: `one of ${FORM_FIELD_CONTROL_TYPES.join(', ')}`,
303
+ options: 'a list of { id, value, label }, all strings',
304
+ defaultValue: 'a string, or a list of strings for a multi-select',
305
+ description: 'a string',
306
+ };
244
307
  /**
245
- * A form row's `options` read back for source: the well-formed choices
246
- * (string `id`, `value`, `label` — all three the product requires), or
247
- * `undefined` when the row has no `options` array. A malformed entry is left
248
- * out, not coerced: `{ id: 'o1', value: 1 }` is not rewritten to
249
- * `value: '1', label: ''`. Decompile (human task and quick-form escalation)
250
- * reads through this, so a canvas dropdown's choices survive an edit.
308
+ * Each settings key a field sets to a value the platform's schema refuses, as
309
+ * `key: value (what it must be)`. Empty when every setting is valid. `check`
310
+ * reports these: a form with one fails the platform's validation.
251
311
  *
252
312
  * @example
253
- * liftFormFieldOptions([{ id: 'o1', value: 'a', label: 'A' }, { id: 'o2', value: 2 }])
254
- * // [{ id: 'o1', value: 'a', label: 'A' }]
313
+ * formFieldSettingProblems({ colSpan: 13 }) // ['colSpan: 13 (must be an integer from 1 to 12 …)']
255
314
  */
256
- export function liftFormFieldOptions(raw) {
257
- if (!Array.isArray(raw))
258
- return undefined;
259
- return raw.filter(isFormFieldOption).map((o) => ({ id: o.id, value: o.value, label: o.label }));
315
+ export function formFieldSettingProblems(f) {
316
+ if (!isRecord(f))
317
+ return [];
318
+ return FORM_FIELD_SETTINGS_KEYS
319
+ .filter((key) => f[key] !== undefined && !validSetting(key, f[key]))
320
+ .map((key) => `${key}: ${JSON.stringify(f[key])} (must be ${ownEntry(SETTINGS_SHAPE, key)})`);
260
321
  }
322
+ /** The `FormFieldSettings` keys, in the platform's row order. */
323
+ export const FORM_FIELD_SETTINGS_KEYS = Object.keys(SETTINGS);
324
+ /** The `controlType`s that render a field's `options` as choices. */
325
+ const PICKER_CONTROL_TYPES = ['dropdown', 'radioGroup', 'multiSelect', 'checkboxGroup'];
261
326
  /**
262
- * What is wrong with a form field's `options`, or `undefined` when nothing is.
263
- * `check` reports each (human task and quick-form escalation):
327
+ * What is wrong with a form field's choices, or `undefined` when nothing is.
328
+ * `check` reports each (human task and quick-form escalation). A field is a
329
+ * picker when its type is a choice-control spelling (`singleSelect`,
330
+ * `multiSelect`, `enum`) or its `controlType` is a picker control (`dropdown`,
331
+ * `radioGroup`, `multiSelect`, `checkboxGroup`).
332
+ *
333
+ * - `'missing'`: a picker with no choices. The form publishes an empty picker.
334
+ * - `'ignored'`: choices on a field that is not a picker. The form renders a
335
+ * plain input and never shows them.
264
336
  *
265
- * - `'malformed'`: `options` is there but is not an array of `{ id, value,
266
- * label }` strings. Serialize would throw or write rows the platform refuses.
267
- * - `'missing'`: a choice-control type (`singleSelect`, `multiSelect`, `enum`)
268
- * with no choices. The form publishes an empty picker.
269
- * - `'ignored'`: choices on a type that is not a choice control. The form
270
- * renders a plain input and never shows them.
337
+ * `options` that do not have the platform's shape are reported by
338
+ * `formFieldSettingProblems` instead, so they get no second diagnostic here.
271
339
  *
272
340
  * @example
273
- * formFieldOptionsProblem('singleSelect', undefined) // 'missing'
274
- * formFieldOptionsProblem('singleSelect', {}) // 'malformed'
275
- * formFieldOptionsProblem('string', [{ id: 'o1', value: 'a', label: 'A' }]) // 'ignored'
276
- * formFieldOptionsProblem('enum', [{ id: 'o1', value: 'a', label: 'A' }]) // undefined
341
+ * formFieldOptionsProblem({ type: 'singleSelect' }) // 'missing'
342
+ * formFieldOptionsProblem({ type: 'string', options: [{ id: 'o1', value: 'a', label: 'A' }] }) // 'ignored'
343
+ * formFieldOptionsProblem({ type: 'string', controlType: 'dropdown', options: [{ id: 'o1', value: 'a', label: 'A' }] }) // undefined
277
344
  */
278
- export function formFieldOptionsProblem(type, options) {
279
- if (options !== undefined && (!Array.isArray(options) || !options.every(isFormFieldOption)))
280
- return 'malformed';
281
- const isControl = controlFormFieldType(type) !== undefined;
282
- if (isControl && (options === undefined || options.length === 0))
345
+ export function formFieldOptionsProblem(f) {
346
+ if (!isRecord(f))
347
+ return undefined;
348
+ if (f.options !== undefined && !validSetting('options', f.options))
349
+ return undefined;
350
+ const picker = controlFormFieldType(f.type) !== undefined
351
+ || (typeof f.controlType === 'string' && PICKER_CONTROL_TYPES.includes(f.controlType));
352
+ const count = Array.isArray(f.options) ? f.options.length : 0;
353
+ if (picker && count === 0)
283
354
  return 'missing';
284
- if (!isControl && options !== undefined && options.length > 0)
355
+ if (!picker && count > 0)
285
356
  return 'ignored';
286
357
  return undefined;
287
358
  }
@@ -291,14 +362,12 @@ export function formFieldOptionsProblem(type, options) {
291
362
  */
292
363
  export function formFieldOptionsWhy(problem, type) {
293
364
  switch (problem) {
294
- case 'malformed':
295
- return `has \`options\` that are not a list of { id, value, label } strings. Serialize cannot write them, `
296
- + `and the platform requires all three on every choice`;
297
365
  case 'missing':
298
- return `is a ${JSON.stringify(type)} picker with no \`options\`, so the form publishes an empty picker`;
366
+ return `is a picker with no \`options\`, so the form publishes an empty picker`;
299
367
  case 'ignored':
300
- return `has \`options\` but its type ${JSON.stringify(type)} is not a choice control, so the form renders a `
301
- + `plain input and never shows them. Use 'singleSelect' or 'enum' (a dropdown) or 'multiSelect'`;
368
+ return `has \`options\` but its type ${JSON.stringify(type)} is not a choice control and it sets no picker `
369
+ + `\`controlType\`, so the form renders a plain input and never shows them. Use 'singleSelect' or 'enum' `
370
+ + `(a dropdown) or 'multiSelect', or set controlType: 'dropdown'`;
302
371
  }
303
372
  }
304
373
  /**
@@ -319,3 +388,152 @@ export function wireFormFieldType(type) {
319
388
  ? SDK_ALIASES[type]
320
389
  : type;
321
390
  }
391
+ /**
392
+ * A field's settings as the form row carries them: each key the author set,
393
+ * in the platform's row order, copied so the wire never aliases the author's
394
+ * objects. Both serializers (human task, quick-form escalation) append this
395
+ * after the row's own keys.
396
+ *
397
+ * @example
398
+ * formFieldSettingsWire({ colSpan: 6, controlType: 'dropdown' }) // { colSpan: 6, controlType: 'dropdown' }
399
+ */
400
+ export function formFieldSettingsWire(f) {
401
+ const wire = {};
402
+ for (const key of FORM_FIELD_SETTINGS_KEYS) {
403
+ const v = f[key];
404
+ if (v === undefined)
405
+ continue;
406
+ wire[key] = key === 'options' ? formFieldOptionsWire(v) : jsonClone(v);
407
+ }
408
+ return wire;
409
+ }
410
+ /** `o` with only `keys`, in `o`'s own order. */
411
+ const pick = (o, keys) => Object.fromEntries(Object.entries(o).filter(([k]) => keys.includes(k)));
412
+ /** The keys of `o` outside `keys`, each as `<at>.<key>`. */
413
+ const extraKeys = (o, keys, at) => Object.keys(o).filter((k) => !keys.includes(k)).map((k) => `${at}.${k}`);
414
+ /**
415
+ * A valid `options` or `rules` value without the keys the platform's schema
416
+ * does not define (it strips them too); any other setting as given.
417
+ */
418
+ function stripSetting(key, v) {
419
+ if (key === 'options')
420
+ return v.map((o) => pick(o, OPTION_KEYS));
421
+ if (key === 'rules') {
422
+ return v.map((r) => ({
423
+ ...pick(r, RULE_KEYS),
424
+ conditions: r.conditions.map((c) => pick(c, CONDITION_KEYS)),
425
+ }));
426
+ }
427
+ return v;
428
+ }
429
+ /** The keys `stripSetting` drops from a valid `options` or `rules` value, e.g. `options[0].color`. */
430
+ function settingExtraKeys(key, v) {
431
+ if (key === 'options') {
432
+ return v.flatMap((o, i) => extraKeys(o, OPTION_KEYS, `options[${i}]`));
433
+ }
434
+ if (key === 'rules') {
435
+ return v.flatMap((r, i) => [
436
+ ...extraKeys(r, RULE_KEYS, `rules[${i}]`),
437
+ ...r.conditions
438
+ .flatMap((c, j) => extraKeys(c, CONDITION_KEYS, `rules[${i}].conditions[${j}]`)),
439
+ ]);
440
+ }
441
+ return [];
442
+ }
443
+ /**
444
+ * A form row's settings read back for source: each settings key whose value
445
+ * has the platform's shape, copied, without any key the platform's schema does
446
+ * not define. A key with any other value is left out here; both cases are
447
+ * reported by `formFieldRowLoss`.
448
+ */
449
+ export function liftFormFieldSettings(row) {
450
+ const settings = {};
451
+ for (const key of FORM_FIELD_SETTINGS_KEYS) {
452
+ if (row[key] !== undefined && validSetting(key, row[key]))
453
+ settings[key] = jsonClone(stripSetting(key, row[key]));
454
+ }
455
+ return settings;
456
+ }
457
+ /**
458
+ * A field's settings as source options, in the platform's row order, for the
459
+ * decompilers' printers. `value` prints one data value (`'high'`, `6`,
460
+ * `[{ id: 'o1', … }]`).
461
+ */
462
+ export function formFieldSettingsSource(settings, value) {
463
+ return FORM_FIELD_SETTINGS_KEYS.map((key) => [key, settings[key] === undefined ? undefined : value(settings[key])]);
464
+ }
465
+ /**
466
+ * The keys of a form row the SDK cannot carry into source, each with why.
467
+ * Empty when decompile → compile writes the row back as it was. The lifts
468
+ * report a non-empty list as a warning (an error under `--strict`), so no key
469
+ * is dropped silently.
470
+ *
471
+ * @example
472
+ * formFieldRowLoss({ id: 'a', label: 'A', type: 'string', direction: 'output', colSpan: 13 }, 'human task')
473
+ * // ['colSpan: 13 (not the platform\'s shape for it)']
474
+ */
475
+ export function formFieldRowLoss(row, site) {
476
+ const loss = [];
477
+ const id = String(row.id ?? '');
478
+ const direction = row.direction;
479
+ const say = (key, why) => {
480
+ loss.push(`${key}: ${JSON.stringify(row[key])} (${why})`);
481
+ };
482
+ for (const key of Object.keys(row)) {
483
+ if (row[key] === undefined)
484
+ continue;
485
+ if (!FORM_FIELD_ROW_KEYS.includes(key)) {
486
+ say(key, 'not a key of the platform\'s field schema');
487
+ }
488
+ else if (Object.hasOwn(SETTINGS, key)) {
489
+ if (!validSetting(key, row[key]))
490
+ say(key, 'not the platform\'s shape for it');
491
+ else {
492
+ const extra = settingExtraKeys(key, row[key]);
493
+ if (extra.length > 0)
494
+ say(key, `${extra.join(', ')}: not in the platform's schema, which drops it too`);
495
+ }
496
+ }
497
+ else if (key === 'label') {
498
+ if (!isString(row.label))
499
+ say(key, 'not a string');
500
+ }
501
+ else if (key === 'value') {
502
+ say(key, 'the form editor\'s structured binding; a .flow stores it as `binding`, which is all the SDK reads');
503
+ }
504
+ else if (key === 'required') {
505
+ // `required: false` is the default: compile omits it, and the form behaves the same.
506
+ if (typeof row.required !== 'boolean')
507
+ say(key, 'not a boolean');
508
+ else if (direction === 'input' && row.required)
509
+ say(key, 'on a shown field, which the SDK does not model');
510
+ }
511
+ else if (key === 'binding') {
512
+ if (site === 'escalation')
513
+ say(key, 'on an escalation form, which the SDK writes without bindings');
514
+ else if (direction === 'output')
515
+ say(key, 'on an asked field, which the SDK writes without one');
516
+ else if (!isString(row.binding))
517
+ say(key, 'not a string');
518
+ }
519
+ else if (key === 'variable') {
520
+ if (site === 'escalation')
521
+ say(key, 'on an escalation form, which the SDK writes without variables');
522
+ else if (direction !== 'output')
523
+ say(key, 'on a shown or pre-filled field, which the SDK writes without one');
524
+ else if (row.variable !== `vars.${id}`)
525
+ say(key, `the SDK writes 'vars.${id}', the field's own id`);
526
+ }
527
+ }
528
+ return loss;
529
+ }
530
+ /**
531
+ * The warning (or `--strict` error) for a row `formFieldRowLoss` reported.
532
+ * `where` names the form, e.g. `human task "review"`.
533
+ */
534
+ export function formFieldRowLossMessage(where, fieldId, loss) {
535
+ return `${where} form field ${JSON.stringify(fieldId)} has ${loss.length === 1 ? 'a key' : 'keys'} the SDK cannot `
536
+ + `write back: ${loss.join('; ')}. Compile writes the field without ${loss.length === 1 ? 'it' : 'them'}, so `
537
+ + `merging an edit of this node loses ${loss.length === 1 ? 'it' : 'them'}. Edit this field in the canvas or `
538
+ + `the .flow JSON instead.`;
539
+ }
package/dist/decompile.js CHANGED
@@ -51,7 +51,7 @@ import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
51
51
  import { contributionPort, forwardFanOut } from './core/definition-handles.js';
52
52
  import { STICKY_NOTE_TYPE } from './core/definitionless-node-types.js';
53
53
  import { eventFilterFromTreeLeaf } from './core/event-filters.js';
54
- import { liftFormFieldOptions, sourceFormFieldType } from './core/form-field-type.js';
54
+ import { formFieldRowLoss, formFieldRowLossMessage, formFieldSettingsSource, liftFormFieldSettings, sourceFormFieldType, } from './core/form-field-type.js';
55
55
  /**
56
56
  * The trigger id of the flow currently being decompiled, so a reference through a
57
57
  * RENAMED trigger is still recognized as `input('x')` rather than falling back to
@@ -185,9 +185,19 @@ function shippedVersions(type) {
185
185
  const registered = FLOW_NODE_TYPES.byTypeName(type);
186
186
  return registered ? Object.keys(registered.type.versions) : bundledVersions(type);
187
187
  }
188
- function verArg(node) {
189
- const clause = verClause(node);
190
- return clause ? `, { ${clause} }` : '';
188
+ /**
189
+ * The options argument of `.branch()`, `.switch()` and `.parallel()`: the exact
190
+ * `version`, and a canvas `label` that differs from the node id (what compile
191
+ * writes when none is given). No `updates`: those constructs refuse them.
192
+ */
193
+ function forkOptsArg(node) {
194
+ const parts = [];
195
+ const ver = verClause(node);
196
+ if (ver)
197
+ parts.push(ver);
198
+ if (hasRenamedLabel(node, node.id))
199
+ parts.push(`label: ${tsLiteral(node.display.label)}`);
200
+ return parts.length ? `, { ${parts.join(', ')} }` : '';
191
201
  }
192
202
  /**
193
203
  * A trigger node's option clauses: its exact `version`, and its canvas `label`
@@ -280,6 +290,19 @@ function opts(entries) {
280
290
  const parts = entries.filter(([, v]) => v !== undefined).map(([k, v]) => `${key(k)}: ${v}`);
281
291
  return parts.length ? `{ ${parts.join(', ')} }` : '';
282
292
  }
293
+ /**
294
+ * Plain JSON data as source, in the `{ k: v }` / `[a, b]` spelling the
295
+ * printers use (a form field's `options`, `rules`, `defaultValue`).
296
+ */
297
+ function dataSource(v) {
298
+ if (Array.isArray(v))
299
+ return `[${v.map(dataSource).join(', ')}]`;
300
+ if (v !== null && typeof v === 'object') {
301
+ const parts = Object.entries(v).filter(([, x]) => x !== undefined).map(([k, x]) => `${key(k)}: ${dataSource(x)}`);
302
+ return parts.length ? `{ ${parts.join(', ')} }` : '{}';
303
+ }
304
+ return tsLiteral(v);
305
+ }
283
306
  /** A collector for indented source lines. */
284
307
  class Emit {
285
308
  indentUnit;
@@ -618,6 +641,11 @@ function liftRegistered(node, impl = registeredImpl(node), graph) {
618
641
  // when the file carries none (a hand-trimmed or SDK-written file).
619
642
  definition: () => runDefs.get(`${node.type}@${version}`) ?? BUNDLED_DEFINITIONS[`${node.type}@${version}`] ?? runDefs.get(node.type),
620
643
  warn: warnOnce,
644
+ lossy: (message) => {
645
+ if (runStrict)
646
+ throw new Error(`decompile: ${message}`);
647
+ warnOnce(message);
648
+ },
621
649
  outPorts: () => (graph?.outEdges(node.id) ?? []).map((e) => e.sourcePort),
622
650
  convergedFanOut: () => graph?.convergedFanOut(node.id) === true,
623
651
  };
@@ -1343,17 +1371,28 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1343
1371
  ['variant', isQuickForm ? tsLiteral('quick-form') : undefined],
1344
1372
  ['name', tsLiteral(rPlain(e, 'name') ?? '')],
1345
1373
  ['description', rPlain(e, 'description') !== undefined ? tsLiteral(rPlain(e, 'description')) : undefined],
1346
- ['fields', rows.length ? `[${rows.map((f) => opts([
1347
- ['id', tsText(f.id)],
1348
- ['type', tsLiteral(sourceFormFieldType(f.type ?? 'string'))],
1349
- ['direction', tsLiteral(String(f.direction ?? 'output'))],
1350
- ['options', (() => {
1351
- const options = liftFormFieldOptions(f.options);
1352
- return options === undefined ? undefined : `[${options.map((o) => opts([
1353
- ['id', tsLiteral(o.id)], ['value', tsLiteral(o.value)], ['label', tsLiteral(o.label)],
1354
- ])).join(', ')}]`;
1355
- })()],
1356
- ])).join(', ')}]` : undefined],
1374
+ ['fields', rows.length ? `[${rows.map((f) => {
1375
+ const id = String(f.id ?? '');
1376
+ const direction = String(f.direction ?? 'output');
1377
+ // Every row key the source cannot carry is reported, never dropped
1378
+ // silently: a warning, an error under --strict (#1250).
1379
+ const loss = formFieldRowLoss(f, 'escalation');
1380
+ if (loss.length > 0) {
1381
+ const message = formFieldRowLossMessage(`quick-form escalation ${JSON.stringify(rPlain(e, 'name') ?? e.id)} on agent ${JSON.stringify(node.id)}`, id, loss);
1382
+ if (runStrict)
1383
+ throw new Error(`decompile: ${message}`);
1384
+ warnOnce(message);
1385
+ }
1386
+ return opts([
1387
+ ['id', tsText(f.id)],
1388
+ // serialize defaults a row's label to its id.
1389
+ ['label', typeof f.label === 'string' && f.label !== id ? tsLiteral(f.label) : undefined],
1390
+ ['type', tsLiteral(sourceFormFieldType(f.type ?? 'string'))],
1391
+ ['direction', tsLiteral(direction)],
1392
+ ['required', typeof f.required === 'boolean' && direction !== 'input' ? String(f.required) : undefined],
1393
+ ...formFieldSettingsSource(liftFormFieldSettings(f), dataSource),
1394
+ ]);
1395
+ }).join(', ')}]` : undefined],
1357
1396
  ['app', app === undefined ? undefined : opts([
1358
1397
  ['key', tsText(app.key)],
1359
1398
  ['name', tsText(app.name)],
@@ -2300,7 +2339,7 @@ function forkRegion(edges, stop, scope, ipdom, ctx, refInto) {
2300
2339
  */
2301
2340
  function parallelSeg(join, joinNode, targets, scope, ipdom, ctx, refInto) {
2302
2341
  const arms = targets.map((t, i) => armCb(`a${i}`, region(t, join, scope, ipdom, ctx, refInto)));
2303
- const seg = `.parallel(${tsLiteral(join)}, [\n${arms.map((a) => reindent(a, ' ')).join(',\n')},\n]${verArg(joinNode)})`;
2342
+ const seg = `.parallel(${tsLiteral(join)}, [\n${arms.map((a) => reindent(a, ' ')).join(',\n')},\n]${forkOptsArg(joinNode)})`;
2304
2343
  return { seg, next: continueOn(joinNode, 'output', ctx, refInto) };
2305
2344
  }
2306
2345
  /** Re-indent every non-empty line of a (possibly multi-line) segment by `pad`. */
@@ -2490,7 +2529,7 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2490
2529
  thenSegs.unshift(`.label(${tsLiteral(labels.trueLabel)})`);
2491
2530
  if (labels.falseLabel)
2492
2531
  elseSegs.unshift(`.label(${tsLiteral(labels.falseLabel)})`);
2493
- segs.push(`.branch(${tsLiteral(node.id)}, ${cond},\n${reindent(armCb('t', thenSegs), ' ')},\n${reindent(armCb('e', elseSegs), ' ')}${verArg(node)})`);
2532
+ segs.push(`.branch(${tsLiteral(node.id)}, ${cond},\n${reindent(armCb('t', thenSegs), ' ')},\n${reindent(armCb('e', elseSegs), ' ')}${forkOptsArg(node)})`);
2494
2533
  flagDropped(node, ctx, segs);
2495
2534
  cursor = r === EXIT ? undefined : r;
2496
2535
  continue;
@@ -2969,7 +3008,7 @@ function emitSwitch(node, ipdom, scope, ctx, stop, refInto) {
2969
3008
  ? `, ${armCb('d', forkRegion(portEdges(ctx.graph, node.id, 'default'), r, scope, ipdom, ctx, refInto))}`
2970
3009
  : '';
2971
3010
  const caseList = `[\n${caseSrcs.map((c) => reindent(c, ' ')).join(',\n')},\n]`;
2972
- const ver = verArg(node);
3011
+ const ver = forkOptsArg(node);
2973
3012
  return `.switch(${tsLiteral(node.id)}, ${on}, ${caseList}${defArm}${ver && !defArm ? ', undefined' : ''}${ver})`;
2974
3013
  }
2975
3014
  /**
@@ -162,6 +162,12 @@ export interface FlowDeserializeContext {
162
162
  definition(): unknown;
163
163
  /** Report something the lift could not keep. Decompile prints it once. */
164
164
  warn(message: string): void;
165
+ /**
166
+ * Report something the source cannot carry, so compile would drop it: a
167
+ * warning, or under `--strict` (`bestEffort: false`) an error that stops
168
+ * decompile.
169
+ */
170
+ lossy(message: string): void;
165
171
  /** The source ports this node's out-edges leave from, in edge order (an empty list outside a graph). */
166
172
  outPorts(): readonly string[];
167
173
  /**
package/dist/merge.js CHANGED
@@ -96,18 +96,30 @@ function byId(nodes) {
96
96
  return new Map((nodes ?? []).map((n) => [n.id, n]));
97
97
  }
98
98
  /**
99
- * Whether two nodes are the SAME as far as the author is concerned — same type
100
- * and same inputs. Layout (`ui`), `display`, `typeVersion`, and derived
101
- * `outputs` are ignored, because those are exactly the parts decompile does not
102
- * carry through. Used only to decide "did the user edit this node?" against the
103
- * baseline.
99
+ * Whether two nodes are the SAME as far as the author is concerned — same type,
100
+ * same inputs and same output mappings. Layout (`ui`), `display`, `typeVersion`,
101
+ * and the derived parts of `outputs` (type, schema, description) are ignored,
102
+ * because those are exactly the parts decompile does not carry through. An
103
+ * output's `source` is authored, not derived: it is what an End returns, so an
104
+ * edit to `.return({ ... })` changes nothing else on the node. Used only to
105
+ * decide "did the user edit this node?" against the baseline.
104
106
  */
105
107
  function nodeUnchanged(a, b) {
106
108
  if (!a || !b)
107
109
  return false;
108
110
  if (a.type !== b.type)
109
111
  return false;
110
- return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {});
112
+ return JSON.stringify(a.inputs ?? {}) === JSON.stringify(b.inputs ?? {})
113
+ && JSON.stringify(outputSources(a)) === JSON.stringify(outputSources(b));
114
+ }
115
+ /** Each output's `source` by output name; outputs without one are left out. */
116
+ function outputSources(n) {
117
+ const sources = {};
118
+ for (const [name, output] of Object.entries(n.outputs ?? {})) {
119
+ if (output && typeof output === 'object' && 'source' in output)
120
+ sources[name] = output.source;
121
+ }
122
+ return sources;
111
123
  }
112
124
  /**
113
125
  * The ids of the root nodes {@link mergeFlow} takes from `edited` rather than
@@ -1,6 +1,6 @@
1
1
  import { Expr } from '../../../core/expr.js';
2
2
  import { nearestName } from '../../../core/edit-distance.js';
3
- import { FORM_FIELD_TYPES, formFieldOptionsProblem, formFieldOptionsWhy, formFieldTypeHint, formFieldTypeWhy, validFormFieldTypesText } from '../../../core/form-field-type.js';
3
+ import { FORM_FIELD_TYPES, formFieldOptionsProblem, formFieldOptionsWhy, formFieldSettingProblems, formFieldTypeHint, formFieldTypeWhy, validFormFieldTypesText, } from '../../../core/form-field-type.js';
4
4
  import { isAnswerField } from '../../../core/hitl-answers.js';
5
5
  import { hitlDerivesOutcomeHandles, outcomeIdOf } from '../../../core/hitl-routing.js';
6
6
  export const HITL_DIAGNOSTICS = {
@@ -23,8 +23,9 @@ export const HITL_DIAGNOSTICS = {
23
23
  HITL_DUP_FIELD: { severity: 'error', summary: 'a form field id declared twice' },
24
24
  HITL_FIELD_RESERVED_ID: { severity: 'error', summary: 'a form field id the result already uses' },
25
25
  HITL_FIELD_TYPE_UNKNOWN: { severity: 'error', summary: 'a form field type the platform does not recognize' },
26
- HITL_FIELD_OPTIONS: { severity: 'error', summary: 'a picker with no choices, or choices that are not { id, value, label } strings' },
27
- HITL_FIELD_OPTIONS_IGNORED: { severity: 'warning', summary: 'choices on a field type that does not render them' },
26
+ HITL_FIELD_OPTIONS: { severity: 'error', summary: 'a picker with no choices' },
27
+ HITL_FIELD_OPTIONS_IGNORED: { severity: 'warning', summary: 'choices on a field that does not render them' },
28
+ HITL_FIELD_SETTING_INVALID: { severity: 'error', summary: 'a form field setting (controlType, colSpan, …) the platform refuses' },
28
29
  HITL_FIELD_NO_VALUE: { severity: 'error', summary: 'a shown field with no value' },
29
30
  HITL_FIELD_VALUE_NOT_REF: { severity: 'error', summary: 'a shown field filled from a literal' },
30
31
  HITL_FIELD_VALUE_NOT_PATH: { severity: 'error', summary: 'a shown field filled from a computed expression' },
@@ -217,10 +218,13 @@ export function checkHitl(inputs, ctx) {
217
218
  at('HITL_FIELD_TYPE_UNKNOWN', `Human task "${step}" gives the form field "${id}" the type ${JSON.stringify(f?.type)}, `
218
219
  + `${formFieldTypeWhy(f?.type)}. Valid: ${validFormFieldTypesText()}.`, formFieldTypeHint(f?.type));
219
220
  }
220
- const optionsProblem = formFieldOptionsProblem(f?.type, f.options);
221
+ const optionsProblem = formFieldOptionsProblem(f);
221
222
  if (optionsProblem !== undefined) {
222
223
  at(optionsProblem === 'ignored' ? 'HITL_FIELD_OPTIONS_IGNORED' : 'HITL_FIELD_OPTIONS', `Human task "${step}"'s form field "${id}" ${formFieldOptionsWhy(optionsProblem, f?.type)}.`, `options: [{ id: 'opt-1', value: 'high', label: 'High' }, { id: 'opt-2', value: 'low', label: 'Low' }]`);
223
224
  }
225
+ for (const problem of formFieldSettingProblems(f)) {
226
+ at('HITL_FIELD_SETTING_INVALID', `Human task "${step}" gives the form field "${id}" ${problem}. The platform refuses the form.`);
227
+ }
224
228
  if (isAnswerField(f))
225
229
  answers++; // an `output`, or an `inOut` whose reviewed value comes back
226
230
  if (f?.direction === 'input' || f?.direction === 'inOut') {