@scenario-labs/sdk 3.3.0 → 3.4.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.
Files changed (163) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/client.d.mts +4 -4
  3. package/client.d.mts.map +1 -1
  4. package/client.d.ts +4 -4
  5. package/client.d.ts.map +1 -1
  6. package/client.js.map +1 -1
  7. package/client.mjs.map +1 -1
  8. package/index.d.mts +1 -1
  9. package/index.d.mts.map +1 -1
  10. package/index.d.ts +1 -1
  11. package/index.d.ts.map +1 -1
  12. package/index.js +2 -1
  13. package/index.js.map +1 -1
  14. package/index.mjs +1 -1
  15. package/index.mjs.map +1 -1
  16. package/lib/asset.d.mts +1 -1
  17. package/lib/asset.d.ts +1 -1
  18. package/lib/asset.js +1 -1
  19. package/lib/asset.mjs +1 -1
  20. package/lib/upload.d.mts +10 -5
  21. package/lib/upload.d.mts.map +1 -1
  22. package/lib/upload.d.ts +10 -5
  23. package/lib/upload.d.ts.map +1 -1
  24. package/lib/upload.js.map +1 -1
  25. package/lib/upload.mjs.map +1 -1
  26. package/lib/user_selection_limits.d.mts +13 -0
  27. package/lib/user_selection_limits.d.mts.map +1 -0
  28. package/lib/user_selection_limits.d.ts +13 -0
  29. package/lib/user_selection_limits.d.ts.map +1 -0
  30. package/lib/user_selection_limits.js +16 -0
  31. package/lib/user_selection_limits.js.map +1 -0
  32. package/lib/user_selection_limits.mjs +13 -0
  33. package/lib/user_selection_limits.mjs.map +1 -0
  34. package/lib/workflow.d.mts +4 -2
  35. package/lib/workflow.d.mts.map +1 -1
  36. package/lib/workflow.d.ts +4 -2
  37. package/lib/workflow.d.ts.map +1 -1
  38. package/lib/workflow.js +5 -1
  39. package/lib/workflow.js.map +1 -1
  40. package/lib/workflow.mjs +5 -1
  41. package/lib/workflow.mjs.map +1 -1
  42. package/lib/workflow_converter.d.mts +6 -0
  43. package/lib/workflow_converter.d.mts.map +1 -1
  44. package/lib/workflow_converter.d.ts +6 -0
  45. package/lib/workflow_converter.d.ts.map +1 -1
  46. package/lib/workflow_converter.js +155 -53
  47. package/lib/workflow_converter.js.map +1 -1
  48. package/lib/workflow_converter.mjs +155 -53
  49. package/lib/workflow_converter.mjs.map +1 -1
  50. package/lib/workflow_import_validator.d.mts +17 -2
  51. package/lib/workflow_import_validator.d.mts.map +1 -1
  52. package/lib/workflow_import_validator.d.ts +17 -2
  53. package/lib/workflow_import_validator.d.ts.map +1 -1
  54. package/lib/workflow_import_validator.js +100 -1
  55. package/lib/workflow_import_validator.js.map +1 -1
  56. package/lib/workflow_import_validator.mjs +99 -1
  57. package/lib/workflow_import_validator.mjs.map +1 -1
  58. package/lib/workflow_validator.d.mts +1 -1
  59. package/lib/workflow_validator.d.mts.map +1 -1
  60. package/lib/workflow_validator.d.ts +1 -1
  61. package/lib/workflow_validator.d.ts.map +1 -1
  62. package/lib/workflow_validator.js +10 -11
  63. package/lib/workflow_validator.js.map +1 -1
  64. package/lib/workflow_validator.mjs +7 -8
  65. package/lib/workflow_validator.mjs.map +1 -1
  66. package/package.json +1 -1
  67. package/resources/assets/assets.d.mts +126 -30
  68. package/resources/assets/assets.d.mts.map +1 -1
  69. package/resources/assets/assets.d.ts +126 -30
  70. package/resources/assets/assets.d.ts.map +1 -1
  71. package/resources/assets/assets.js +12 -0
  72. package/resources/assets/assets.js.map +1 -1
  73. package/resources/assets/assets.mjs +12 -0
  74. package/resources/assets/assets.mjs.map +1 -1
  75. package/resources/assets/index.d.mts +1 -1
  76. package/resources/assets/index.d.mts.map +1 -1
  77. package/resources/assets/index.d.ts +1 -1
  78. package/resources/assets/index.d.ts.map +1 -1
  79. package/resources/assets/index.js.map +1 -1
  80. package/resources/assets/index.mjs.map +1 -1
  81. package/resources/generate.d.mts +120 -9
  82. package/resources/generate.d.mts.map +1 -1
  83. package/resources/generate.d.ts +120 -9
  84. package/resources/generate.d.ts.map +1 -1
  85. package/resources/index.d.mts +2 -2
  86. package/resources/index.d.mts.map +1 -1
  87. package/resources/index.d.ts +2 -2
  88. package/resources/index.d.ts.map +1 -1
  89. package/resources/index.js.map +1 -1
  90. package/resources/index.mjs.map +1 -1
  91. package/resources/jobs.d.mts +36 -0
  92. package/resources/jobs.d.mts.map +1 -1
  93. package/resources/jobs.d.ts +36 -0
  94. package/resources/jobs.d.ts.map +1 -1
  95. package/resources/models/description.d.mts +4 -4
  96. package/resources/models/description.d.mts.map +1 -1
  97. package/resources/models/description.d.ts +4 -4
  98. package/resources/models/description.d.ts.map +1 -1
  99. package/resources/models/examples.d.mts +40 -6
  100. package/resources/models/examples.d.mts.map +1 -1
  101. package/resources/models/examples.d.ts +40 -6
  102. package/resources/models/examples.d.ts.map +1 -1
  103. package/resources/models/index.d.mts +1 -1
  104. package/resources/models/index.d.mts.map +1 -1
  105. package/resources/models/index.d.ts +1 -1
  106. package/resources/models/index.d.ts.map +1 -1
  107. package/resources/models/index.js.map +1 -1
  108. package/resources/models/index.mjs.map +1 -1
  109. package/resources/models/models.d.mts +1353 -199
  110. package/resources/models/models.d.mts.map +1 -1
  111. package/resources/models/models.d.ts +1353 -199
  112. package/resources/models/models.d.ts.map +1 -1
  113. package/resources/models/models.js +13 -0
  114. package/resources/models/models.js.map +1 -1
  115. package/resources/models/models.mjs +13 -0
  116. package/resources/models/models.mjs.map +1 -1
  117. package/resources/models/train.d.mts +36 -2
  118. package/resources/models/train.d.mts.map +1 -1
  119. package/resources/models/train.d.ts +36 -2
  120. package/resources/models/train.d.ts.map +1 -1
  121. package/resources/recommendations.d.mts +16 -5
  122. package/resources/recommendations.d.mts.map +1 -1
  123. package/resources/recommendations.d.ts +16 -5
  124. package/resources/recommendations.d.ts.map +1 -1
  125. package/resources/usages.d.mts +14 -2
  126. package/resources/usages.d.mts.map +1 -1
  127. package/resources/usages.d.ts +14 -2
  128. package/resources/usages.d.ts.map +1 -1
  129. package/resources/workflows.d.mts +152 -9
  130. package/resources/workflows.d.mts.map +1 -1
  131. package/resources/workflows.d.ts +152 -9
  132. package/resources/workflows.d.ts.map +1 -1
  133. package/resources/workflows.js +2 -3
  134. package/resources/workflows.js.map +1 -1
  135. package/resources/workflows.mjs +2 -3
  136. package/resources/workflows.mjs.map +1 -1
  137. package/src/client.ts +8 -0
  138. package/src/index.ts +1 -0
  139. package/src/lib/asset.ts +1 -1
  140. package/src/lib/upload.ts +10 -5
  141. package/src/lib/user_selection_limits.ts +14 -0
  142. package/src/lib/workflow.ts +12 -1
  143. package/src/lib/workflow_converter.ts +176 -55
  144. package/src/lib/workflow_import_validator.ts +116 -1
  145. package/src/lib/workflow_validator.ts +12 -8
  146. package/src/resources/assets/assets.ts +164 -4
  147. package/src/resources/assets/index.ts +2 -0
  148. package/src/resources/generate.ts +142 -0
  149. package/src/resources/index.ts +4 -0
  150. package/src/resources/jobs.ts +42 -0
  151. package/src/resources/models/description.ts +6 -0
  152. package/src/resources/models/examples.ts +48 -0
  153. package/src/resources/models/index.ts +2 -0
  154. package/src/resources/models/models.ts +1610 -114
  155. package/src/resources/models/train.ts +42 -0
  156. package/src/resources/recommendations.ts +18 -3
  157. package/src/resources/usages.ts +16 -0
  158. package/src/resources/workflows.ts +176 -9
  159. package/src/version.ts +1 -1
  160. package/version.d.mts +1 -1
  161. package/version.d.ts +1 -1
  162. package/version.js +1 -1
  163. package/version.mjs +1 -1
package/src/lib/upload.ts CHANGED
@@ -11,21 +11,26 @@ import {
11
11
  type UploadTriggerActionResponse,
12
12
  } from '../resources/uploads';
13
13
  import type { AssetRetrieveResponse } from '../resources/assets/assets';
14
- import type { ModelRetrieveResponse } from '../resources/models/models';
14
+ import type { WithAsset } from './asset';
15
+ import type { EnhancedModelRetrieveResponse } from './models';
15
16
  import { type Scope, clientProjectId, sentScope, withScope } from './scope';
16
17
 
17
18
  type UploadKind = NonNullable<UploadCreateParams['kind']>;
18
19
 
19
- /** The resolved entity returned after a successful upload, narrowed by `kind`. */
20
+ /**
21
+ * The resolved entity returned after a successful upload, narrowed by `kind`.
22
+ * It comes from the enhanced `retrieve()`, so a model has `.run()` and an asset
23
+ * has `.download()`.
24
+ */
20
25
  export type UploadResult<K extends UploadKind> =
21
- K extends 'model' ? ModelRetrieveResponse : AssetRetrieveResponse;
26
+ K extends 'model' ? EnhancedModelRetrieveResponse : WithAsset<AssetRetrieveResponse>;
22
27
 
23
28
  /**
24
29
  * Accepted upload inputs.
25
30
  * - `string` — a local file path, read from disk (Node only; throws in browsers).
26
31
  * - `Uint8Array` — raw bytes. `Buffer` is a `Uint8Array` and works too.
27
- * - `Blob` / `File` — browser-native, or Node ≥ 18 via `fs.openAsBlob()` /
28
- * constructors.
32
+ * - `Blob` / `File` — browser-native, or in Node.js via `fs.openAsBlob()` or
33
+ * the `Blob` / `File` constructors.
29
34
  */
30
35
  export type UploadFileInput = string | Uint8Array | Blob;
31
36
 
@@ -0,0 +1,14 @@
1
+ /** Inclusive lower bound for user-selection `min` / `max` and choose counts. */
2
+ export const USER_SELECTION_MIN_SELECT = 1;
3
+
4
+ /**
5
+ * Caps how many candidates may be offered (and how high `input.min` may be).
6
+ * Matches the API `MAX_USER_SELECTION_CANDIDATES` limit.
7
+ */
8
+ export const USER_SELECTION_MAX_CANDIDATES = 50;
9
+
10
+ /**
11
+ * Caps how many items the user may pick (`input.max`).
12
+ * Matches the API `USER_SELECTION_DEFAULT_MAX` (also the omitted-`max` default).
13
+ */
14
+ export const USER_SELECTION_DEFAULT_MAX = 100;
@@ -4,6 +4,8 @@ import {
4
4
  Workflows,
5
5
  WorkflowUserApprovalResponse,
6
6
  type WorkflowApproveUserParams,
7
+ type WorkflowUserSelectionParams,
8
+ type WorkflowUserSelectionResponse,
7
9
  type WorkflowRetrieveParams,
8
10
  type WorkflowRetrieveResponse,
9
11
  type WorkflowRunParams,
@@ -99,7 +101,7 @@ export type EnhancedWorkflowRetrieveResponse = Omit<WorkflowRetrieveResponse, 'w
99
101
  /**
100
102
  * Enhanced Workflows resource.
101
103
  * Extends the generated {@link Workflows} — all original methods are inherited.
102
- * Overrides `retrieve()`, `run()`, and `approveUser()` to return enriched response objects.
104
+ * Overrides `retrieve()`, `run()`, `userApproval()` and `userSelection()` to return enriched response objects.
103
105
  */
104
106
  export class EnhancedWorkflows extends Workflows {
105
107
  /**
@@ -135,4 +137,13 @@ export class EnhancedWorkflows extends Workflows {
135
137
  ): APIPromise<WithJob<WorkflowUserApprovalResponse>> {
136
138
  return enhanceJob(this._client, super.userApproval(workflowID, body, options));
137
139
  }
140
+
141
+ /** Answer a user selection node. Returns an enhanced response where `job` has `.wait()`. */
142
+ override userSelection(
143
+ workflowID: string,
144
+ params: WorkflowUserSelectionParams,
145
+ options?: RequestOptions,
146
+ ): APIPromise<WithJob<WorkflowUserSelectionResponse>> {
147
+ return enhanceJob(this._client, super.userSelection(workflowID, params, options));
148
+ }
138
149
  }
@@ -1,6 +1,8 @@
1
1
  // Converts a workflow editor state (nodes + edges) to a flow array suitable for the Scenario API.
2
2
  // Ported from fawkes useConvertWorkflowToApp — pure function, no React dependencies.
3
3
 
4
+ import { USER_SELECTION_MAX_CANDIDATES, USER_SELECTION_MIN_SELECT } from './user_selection_limits';
5
+
4
6
  // ---- Public types ----
5
7
 
6
8
  export type WorkflowEditorHandleInput = {
@@ -47,6 +49,7 @@ export type WorkflowEditorNode =
47
49
  }
48
50
  | { id: string; type: 'transformText'; data: NodeCommonData & { value?: string } }
49
51
  | { id: string; type: 'splitText'; data: NodeCommonData & { splitDelimiter?: string } }
52
+ | { id: string; type: 'joinTexts'; data: NodeCommonData & { joinDelimiter?: string } }
50
53
  | { id: string; type: 'aspectRatio'; data: NodeCommonData & { output?: string; quality?: string } }
51
54
  | { id: string; type: 'groupItems'; data: NodeCommonData & { value?: string } }
52
55
  | {
@@ -136,8 +139,6 @@ type ModelInputNodeData = NodeCommonData & {
136
139
 
137
140
  const RUNNABLE_TYPES = new Set(['model', 'llm', 'forEach', 'forEachEnd']);
138
141
  const MODEL_CUSTOM_INPUT_HANDLE_SUFFIX = '-source-custom';
139
- const USER_SELECTION_MIN_SELECT = 1;
140
- const USER_SELECTION_MAX_SELECT = 100;
141
142
  const DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE = 'Select the items to continue';
142
143
 
143
144
  type SliceAssetsMode = 'default' | 'randomize' | 'choose';
@@ -232,7 +233,12 @@ function toCelIdentifier(name: string): string {
232
233
  }
233
234
 
234
235
  function escapeCELString(value: string): string {
235
- return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n');
236
+ return value
237
+ .replace(/\\/g, '\\\\')
238
+ .replace(/'/g, "\\'")
239
+ .replace(/\n/g, '\\n')
240
+ .replace(/\r/g, '\\r')
241
+ .replace(/\t/g, '\\t');
236
242
  }
237
243
 
238
244
  function formatTextAsCELString(text: string): string {
@@ -391,6 +397,7 @@ function getForEachFlowListInputDescriptor(args: {
391
397
  listProvider?.type === 'text' ||
392
398
  listProvider?.type === 'llm' ||
393
399
  listProvider?.type === 'transformText' ||
400
+ listProvider?.type === 'joinTexts' ||
394
401
  (outputHandle?.type === 'text' && !!outputHandle.isArray);
395
402
  return isText ?
396
403
  { name: `text${inputIndex}`, type: 'string_array' }
@@ -592,21 +599,50 @@ function getIsSliceAssetsItemsSourceHandle(nodeId: string, sourceHandle: string
592
599
  }
593
600
 
594
601
  /**
595
- * Prompt Builder (transformText) binds upstream vars for CEL. Reuse the same
596
- * list-producer rules as concat/slice (`groupItems`, `sliceAssets`, `allOutputs`,
597
- * split-text, LLM "All Results", `isArray` handles): those nodes always emit CEL
598
- * lists (via concat/slice/randomSample), so Prompt Builder must bind them as
599
- * `string_array` for indexing. Scalar text (plain text nodes, forEach outputs)
600
- * stays `string`.
602
+ * True when `expression` treats `varName` as a CEL list via indexing, `size(x)`,
603
+ * or list macros (`.map` / `.filter` / …). This is a converter tolerance for
604
+ * flows that index a list producer (editor First/Last/Nth stores `x[0]` on
605
+ * `isArray` handles; agent/MCP/import flows may index without that flag). It is
606
+ * not Fawkes's preview rule: the webapp binds a list only when the handle has
607
+ * `isArray`. Intentionally narrow so Prompt Builder cannot be steered into
608
+ * list semantics through other CEL list idioms.
609
+ */
610
+ function expressionUsesListVariable(expression: string, varName: string): boolean {
611
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
612
+ return (
613
+ new RegExp(`${escaped}\\s*\\[`).test(expression) ||
614
+ new RegExp(`\\bsize\\s*\\(\\s*${escaped}\\s*\\)`).test(expression) ||
615
+ new RegExp(`${escaped}\\s*\\.\\s*(map|filter|all|exists|exists_one)\\s*\\(`).test(expression)
616
+ );
617
+ }
618
+
619
+ /**
620
+ * Prompt Builder (transformText) binds upstream vars for CEL. List producers
621
+ * bind as `string_array` when the source handle is `isArray`, or when the
622
+ * expression indexes/uses the variable as a list (see
623
+ * `expressionUsesListVariable`). Otherwise bare refs stay `string` so the API
624
+ * collapses the list to its first item.
625
+ *
626
+ * Fawkes today omits `isArray` on Slice/Group Items on purpose: only `isArray`
627
+ * handles get an indexed insert (`x[0]`); every other list insert is bare `x`
628
+ * and must collapse to the first item. Adding `isArray` to those handles later
629
+ * requires migrating stored expressions (`x` → `x[0]`) in the same Fawkes
630
+ * release, or both preview and published flows break on `'…' + x`.
601
631
  */
602
632
  function getPromptBuilderInputType(
603
633
  targetNode: WorkflowEditorNode,
604
634
  outHandle: WorkflowEditorHandleOutput | undefined,
605
635
  outHandleName: string,
636
+ expression: string,
637
+ varName: string,
606
638
  ): 'string' | 'string_array' {
607
- return getConcatListSourceDescriptor(targetNode, outHandle, outHandleName).inputType === 'string_array' ?
608
- 'string_array'
609
- : 'string';
639
+ if (getConcatListSourceDescriptor(targetNode, outHandle, outHandleName).inputType !== 'string_array') {
640
+ return 'string';
641
+ }
642
+ if (outHandle?.isArray || expressionUsesListVariable(expression, varName)) {
643
+ return 'string_array';
644
+ }
645
+ return 'string';
610
646
  }
611
647
 
612
648
  function getConcatListSourceDescriptor(
@@ -903,17 +939,65 @@ function applyAspectRatioInputs(args: {
903
939
  }
904
940
  }
905
941
 
942
+ /**
943
+ * Neighbours of every editor node, built once per conversion. Edges read
944
+ * `{ source: consumer, target: provider }`: an input handle on `source` reads an
945
+ * output handle on `target`. `consumers` keeps the internal for-each → end edge,
946
+ * which lets the body walk reach a nested loop from its end. `dataProviders` and
947
+ * `dataConsumers` drop it: it carries no data, and it closes a cycle
948
+ * (loop → body → end → loop) that a walk must not follow. They also drop the
949
+ * edge of an approval: it gates the node it is attached to and follows it into
950
+ * a loop body, so it is not a consumer through which a draw escapes the loop.
951
+ */
952
+ type LoopGraph = {
953
+ nodeById: Map<string, WorkflowEditorNode>;
954
+ consumers: Map<string, string[]>;
955
+ dataProviders: Map<string, string[]>;
956
+ dataConsumers: Map<string, string[]>;
957
+ };
958
+
959
+ function buildLoopGraph(nodes: WorkflowEditorNode[], edges: WorkflowEditorEdge[]): LoopGraph {
960
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
961
+ const isInternalForEachEdge = (e: WorkflowEditorEdge): boolean => {
962
+ const target = nodeById.get(e.target);
963
+ return (
964
+ nodeById.get(e.source)?.type === 'forEach' &&
965
+ target?.type === 'forEachEnd' &&
966
+ target.data.parentNodeId === e.source
967
+ );
968
+ };
969
+ const push = (map: Map<string, string[]>, key: string, value: string) => {
970
+ const list = map.get(key);
971
+ if (list) list.push(value);
972
+ else map.set(key, [value]);
973
+ };
974
+ const graph: LoopGraph = {
975
+ nodeById,
976
+ consumers: new Map(),
977
+ dataProviders: new Map(),
978
+ dataConsumers: new Map(),
979
+ };
980
+ for (const e of edges) {
981
+ push(graph.consumers, e.target, e.source);
982
+ if (isInternalForEachEdge(e) || nodeById.get(e.source)?.type === 'approval') continue;
983
+ push(graph.dataProviders, e.source, e.target);
984
+ push(graph.dataConsumers, e.target, e.source);
985
+ }
986
+ return graph;
987
+ }
988
+
906
989
  function getForEachBodyEditorNodeIds(
907
990
  forEachId: string,
908
991
  forEachEndId: string,
909
992
  edges: WorkflowEditorEdge[],
910
- nodes: WorkflowEditorNode[],
993
+ graph: LoopGraph,
911
994
  ): Set<string> {
912
995
  const bodyNodeIds = new Set<string>();
913
996
  const queue: string[] = [];
914
997
  const escaped = forEachId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
998
+ const itemHandle = new RegExp(`^${escaped}-output-\\d+$`);
915
999
  for (const edge of edges) {
916
- if (edge.target === forEachId && edge.targetHandle?.match(new RegExp(`^${escaped}-output-\\d+$`))) {
1000
+ if (edge.target === forEachId && edge.targetHandle && itemHandle.test(edge.targetHandle)) {
917
1001
  queue.push(edge.source);
918
1002
  }
919
1003
  }
@@ -921,14 +1005,12 @@ function getForEachBodyEditorNodeIds(
921
1005
  const nodeId = queue.pop()!;
922
1006
  if (nodeId === forEachEndId || bodyNodeIds.has(nodeId)) continue;
923
1007
  bodyNodeIds.add(nodeId);
924
- for (const edge of edges) {
925
- if (edge.target === nodeId && !bodyNodeIds.has(edge.source) && edge.source !== forEachEndId) {
926
- queue.push(edge.source);
927
- }
1008
+ for (const consumer of graph.consumers.get(nodeId) ?? []) {
1009
+ if (!bodyNodeIds.has(consumer) && consumer !== forEachEndId) queue.push(consumer);
928
1010
  }
929
1011
  }
930
1012
 
931
- addRandomizeClosureToBody(bodyNodeIds, forEachEndId, edges, nodes);
1013
+ addRandomizeClosureToBody(bodyNodeIds, forEachEndId, graph);
932
1014
 
933
1015
  return bodyNodeIds;
934
1016
  }
@@ -944,34 +1026,36 @@ function getForEachBodyEditorNodeIds(
944
1026
  * for an UNLOCKED randomize node we mutate `bodyNodeIds` in place to also include
945
1027
  * that node plus the deterministic chain down to the body, so the runtime clones
946
1028
  * it per iteration and each iteration re-draws. We only pull it in when *every*
947
- * path out of it lands in the body — if it also feeds a node outside the loop,
948
- * cloning it would break that external consumer, so we leave it shared.
1029
+ * path out of it lands in the body or in this loop's results — if it also feeds a
1030
+ * node outside the loop, cloning it would break that external consumer, so we
1031
+ * leave it shared.
1032
+ *
1033
+ * Loop nodes (any for-each or loop end outside the body) stop both walks: a
1034
+ * loop's list is computed once, before the loop runs, so neither a loop nor what
1035
+ * feeds its list may be cloned with another loop's body. A for-each in its own
1036
+ * body would even expand again on every iteration. A node that reaches one stays
1037
+ * shared.
949
1038
  */
950
- function addRandomizeClosureToBody(
951
- bodyNodeIds: Set<string>,
952
- forEachEndId: string,
953
- edges: WorkflowEditorEdge[],
954
- nodes: WorkflowEditorNode[],
955
- ): void {
956
- const nodeById = new Map(nodes.map((n) => [n.id, n]));
1039
+ function addRandomizeClosureToBody(bodyNodeIds: Set<string>, forEachEndId: string, graph: LoopGraph): void {
1040
+ const isLoopNode = (id: string): boolean => {
1041
+ const type = graph.nodeById.get(id)?.type;
1042
+ return type === 'forEach' || type === 'forEachEnd';
1043
+ };
957
1044
  const isUnlockedRandomize = (id: string): boolean => {
958
- const data = nodeById.get(id)?.data as { isRandomize?: boolean; isSeedLocked?: boolean } | undefined;
959
- return data?.isRandomize === true && data.isSeedLocked !== true;
1045
+ const data = graph.nodeById.get(id)?.data as
1046
+ | { mode?: SliceAssetsMode; isRandomize?: boolean; isSeedLocked?: boolean }
1047
+ | undefined;
1048
+ return !!data && getSliceAssetsMode(data) === 'randomize' && data.isSeedLocked !== true;
960
1049
  };
961
- // edge convention: `{ source: consumer, target: provider }` — an input handle
962
- // on `source` reads an output handle on `target`.
963
- const providersOf = (id: string): string[] => edges.filter((e) => e.source === id).map((e) => e.target);
964
- const consumersOf = (id: string): string[] => edges.filter((e) => e.target === id).map((e) => e.source);
965
1050
 
966
- // Every node strictly upstream of the body (its transitive input providers).
1051
+ // The nodes that feed the body without going through a loop node.
967
1052
  const upstream = new Set<string>();
968
1053
  const upstreamQueue = [...bodyNodeIds];
969
1054
  while (upstreamQueue.length > 0) {
970
- for (const provider of providersOf(upstreamQueue.pop()!)) {
971
- if (provider !== forEachEndId && !bodyNodeIds.has(provider) && !upstream.has(provider)) {
972
- upstream.add(provider);
973
- upstreamQueue.push(provider);
974
- }
1055
+ for (const provider of graph.dataProviders.get(upstreamQueue.pop()!) ?? []) {
1056
+ if (bodyNodeIds.has(provider) || upstream.has(provider) || isLoopNode(provider)) continue;
1057
+ upstream.add(provider);
1058
+ upstreamQueue.push(provider);
975
1059
  }
976
1060
  }
977
1061
 
@@ -983,16 +1067,16 @@ function addRandomizeClosureToBody(
983
1067
  let escapesLoop = false;
984
1068
  while (chainQueue.length > 0) {
985
1069
  const id = chainQueue.pop()!;
986
- if (bodyNodeIds.has(id)) continue; // reached the loop body — a valid sink
987
- chain.add(id);
988
- const consumers = consumersOf(id);
989
- if (consumers.length === 0) {
990
- escapesLoop = true; // dead-end outside the loop → do not internalize
1070
+ // The body and this loop's results are where a per-iteration draw belongs.
1071
+ if (bodyNodeIds.has(id) || id === forEachEndId || chain.has(id)) continue;
1072
+ const consumers = graph.dataConsumers.get(id) ?? [];
1073
+ // Another loop, or a dead end outside the loop: the draw must stay shared.
1074
+ if (isLoopNode(id) || consumers.length === 0) {
1075
+ escapesLoop = true;
991
1076
  break;
992
1077
  }
993
- for (const consumer of consumers) {
994
- if (!bodyNodeIds.has(consumer)) chainQueue.push(consumer);
995
- }
1078
+ chain.add(id);
1079
+ chainQueue.push(...consumers);
996
1080
  }
997
1081
  if (!escapesLoop) for (const id of chain) bodyNodeIds.add(id);
998
1082
  }
@@ -1273,13 +1357,14 @@ export function convertWorkflowEditorToFlow(
1273
1357
  const forEachLoopFlowIds = new Map<string, string[]>();
1274
1358
  const forEachFlowItemRefs = new Map<string, WorkflowEditorFlowItem>();
1275
1359
 
1360
+ const loopGraph = buildLoopGraph(nodes, edges);
1276
1361
  for (const node of sortedNodes) {
1277
1362
  if (node.type !== 'forEach') continue;
1278
1363
  const endNode = nodes.find(
1279
1364
  (n) => n.type === 'forEachEnd' && (n.data as { parentNodeId?: string }).parentNodeId === node.id,
1280
1365
  );
1281
1366
  if (!endNode) continue;
1282
- forEachBodyEditorNodeIds.set(node.id, getForEachBodyEditorNodeIds(node.id, endNode.id, edges, nodes));
1367
+ forEachBodyEditorNodeIds.set(node.id, getForEachBodyEditorNodeIds(node.id, endNode.id, edges, loopGraph));
1283
1368
  forEachLoopFlowIds.set(node.id, []);
1284
1369
  }
1285
1370
 
@@ -1658,9 +1743,10 @@ export function convertWorkflowEditorToFlow(
1658
1743
  const outHandle = targetNode.data.outputHandles?.find((h) => h.id === edge.targetHandle);
1659
1744
  const outHandleName = outHandle?.name ?? 'output';
1660
1745
  const targetFlowId = nodeIdToFlowIdMap.get(targetNode.id) ?? targetNode.id;
1746
+ const varName = `${targetFlowId}_${outHandleName}`;
1661
1747
  flowInputs.push({
1662
- name: `${targetFlowId}_${outHandleName}`,
1663
- type: getPromptBuilderInputType(targetNode, outHandle, outHandleName),
1748
+ name: varName,
1749
+ type: getPromptBuilderInputType(targetNode, outHandle, outHandleName, value, varName),
1664
1750
  ref,
1665
1751
  });
1666
1752
  }
@@ -1702,6 +1788,35 @@ export function convertWorkflowEditorToFlow(
1702
1788
  inputs: flowInputs.length > 0 ? flowInputs : undefined,
1703
1789
  });
1704
1790
 
1791
+ // ------------------------------------
1792
+ // JoinTexts -> join string list into a single string
1793
+ // ------------------------------------
1794
+ } else if (node.type === 'joinTexts') {
1795
+ const flowInputs: WorkflowEditorFlowInput[] = [];
1796
+ const conditionalHandleId = getConditionalInputHandleId(node.id);
1797
+ let listSourceVarName: string | undefined;
1798
+
1799
+ for (const edge of incomingEdges) {
1800
+ if (edge.sourceHandle === conditionalHandleId) continue;
1801
+ const targetNode = nodes.find((n) => n.id === edge.target);
1802
+ if (!targetNode) continue;
1803
+ const ref = getSourceRef(edge, ctx);
1804
+ if (!ref) continue;
1805
+ const varName = toCelIdentifier(targetNode.id);
1806
+ flowInputs.push({ name: varName, type: 'string_array', ref });
1807
+ listSourceVarName = varName;
1808
+ }
1809
+ flowInputs.push(...collectConditionalConnectionInputs(incomingEdges, node.id, ctx));
1810
+ const delim = (node.data as { joinDelimiter?: string }).joinDelimiter ?? ', ';
1811
+ flow.push({
1812
+ id: flowId,
1813
+ type: 'transform',
1814
+ logic: {
1815
+ transform: `${listSourceVarName ?? '[]'}.join(${formatTextAsCELString(delim)})`,
1816
+ },
1817
+ inputs: flowInputs.length > 0 ? flowInputs : undefined,
1818
+ });
1819
+
1705
1820
  // ------------------------------------
1706
1821
  // GroupItems -> array concatenation
1707
1822
  // ------------------------------------
@@ -1765,7 +1880,10 @@ export function convertWorkflowEditorToFlow(
1765
1880
  getOutputHandleVarName,
1766
1881
  );
1767
1882
  const count = clampUserSelectionCount(parseInt(String(sliceData.count ?? ''), 10) || 1);
1768
- const message = sliceData.message?.trim() || DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE;
1883
+ const message =
1884
+ typeof sliceData.message === 'string' ?
1885
+ sliceData.message.trim() || DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE
1886
+ : DEFAULT_SLICE_ASSETS_CHOOSE_MESSAGE;
1769
1887
  const arrayType = getUserSelectionArrayType(flowInputs);
1770
1888
 
1771
1889
  // Single array-typed source can feed user-selection directly; scalars must be wrapped.
@@ -1901,6 +2019,9 @@ export function convertWorkflowEditorToFlow(
1901
2019
  }
1902
2020
  }
1903
2021
 
2022
+ // CEL conditions need hyphen→underscore identifiers, but the shared input-name
2023
+ // map must keep the real workflow input keys (`text-1`, not `text_1`).
2024
+ const celFieldNameMap = new Map<string, string>();
1904
2025
  const edgesIntoIfElse = edges.filter((e) => e.target === node.id);
1905
2026
  for (const fieldKey of fieldsUsed) {
1906
2027
  const resolved = resolveIfElseConditionField(fieldKey, nodes);
@@ -1915,7 +2036,7 @@ export function convertWorkflowEditorToFlow(
1915
2036
  : 'output';
1916
2037
 
1917
2038
  const celVarName = getOutputHandleVarName(fieldNodeId, outputHandleName);
1918
- nodeIdToInputNameMap.set(fieldKey, celVarName);
2039
+ celFieldNameMap.set(fieldKey, celVarName);
1919
2040
 
1920
2041
  const edgeToIfElse =
1921
2042
  edgesIntoIfElse
@@ -1951,7 +2072,7 @@ export function convertWorkflowEditorToFlow(
1951
2072
 
1952
2073
  const cases: { condition: string; value: string }[] = [];
1953
2074
  for (let i = 0; i < conditionBlocks.length; i++) {
1954
- const condition = conditionBlockToCEL(conditionBlocks[i]!, nodeIdToInputNameMap);
2075
+ const condition = conditionBlockToCEL(conditionBlocks[i]!, celFieldNameMap);
1955
2076
  if (condition) cases.push({ condition, value: String(i + 2) });
1956
2077
  }
1957
2078
 
@@ -2098,7 +2219,7 @@ function getSliceAssetsMode(data: { mode?: SliceAssetsMode; isRandomize?: boolea
2098
2219
  }
2099
2220
 
2100
2221
  function clampUserSelectionCount(count: number): number {
2101
- return Math.min(USER_SELECTION_MAX_SELECT, Math.max(USER_SELECTION_MIN_SELECT, count));
2222
+ return Math.min(USER_SELECTION_MAX_CANDIDATES, Math.max(USER_SELECTION_MIN_SELECT, count));
2102
2223
  }
2103
2224
 
2104
2225
  function getUserSelectionArrayType(flowInputs: WorkflowEditorFlowInput[]): 'file_array' | 'string_array' {
@@ -1,3 +1,5 @@
1
+ import { evaluateCel } from './cel';
2
+
1
3
  export class WorkflowImportError extends Error {
2
4
  constructor(message: string) {
3
5
  super(message);
@@ -13,6 +15,7 @@ export type WorkflowEditorNodeType =
13
15
  | 'llm'
14
16
  | 'transformText'
15
17
  | 'splitText'
18
+ | 'joinTexts'
16
19
  | 'ifElse'
17
20
  | 'groupItems'
18
21
  | 'sliceAssets'
@@ -30,6 +33,7 @@ export const VALID_EDITOR_NODE_TYPES: readonly WorkflowEditorNodeType[] = [
30
33
  'llm',
31
34
  'transformText',
32
35
  'splitText',
36
+ 'joinTexts',
33
37
  'ifElse',
34
38
  'groupItems',
35
39
  'sliceAssets',
@@ -40,6 +44,12 @@ export const VALID_EDITOR_NODE_TYPES: readonly WorkflowEditorNodeType[] = [
40
44
  'modelInput',
41
45
  ];
42
46
 
47
+ export interface WorkflowNodeGroup {
48
+ title: string;
49
+ color: string;
50
+ locked?: boolean;
51
+ }
52
+
43
53
  export interface WorkflowExportData {
44
54
  version: '1.0';
45
55
  name: string;
@@ -48,6 +58,7 @@ export interface WorkflowExportData {
48
58
  nodes: Array<{ type: WorkflowEditorNodeType; [key: string]: unknown }>;
49
59
  edges: Array<{ [key: string]: unknown }>;
50
60
  inputKeys: string[];
61
+ nodeGroups?: Record<string, WorkflowNodeGroup>;
51
62
  };
52
63
  inputs: unknown[];
53
64
  tagSet: string[];
@@ -67,7 +78,10 @@ export interface WorkflowExportData {
67
78
  * 2. `version` field is present and equals "1.0"
68
79
  * 3. `editorInfo` has non-empty `nodes` and `edges` arrays
69
80
  * 4. Each node's `type` is a recognized editor node type
70
- * 5. `inputs` is an array
81
+ * 5. `sliceAssets` nodes with a `data.message` require that field to be a string
82
+ * 6. `inputs` is an array
83
+ * 7. Each `transformText` node's `data.value` is supported by the Prompt
84
+ * Builder visual editor (single-quoted string templates)
71
85
  */
72
86
  export function validateEditorInfo(data: unknown): WorkflowExportData {
73
87
  if (typeof data !== 'object' || data === null) {
@@ -104,7 +118,108 @@ export function validateEditorInfo(data: unknown): WorkflowExportData {
104
118
  if (!(VALID_EDITOR_NODE_TYPES as readonly string[]).includes(node.type)) {
105
119
  throw new WorkflowImportError(`Invalid node type: ${node.type}`);
106
120
  }
121
+
122
+ if (node.type === 'sliceAssets') {
123
+ const nodeData = node['data'];
124
+ if (
125
+ nodeData !== null &&
126
+ typeof nodeData === 'object' &&
127
+ 'message' in nodeData &&
128
+ (nodeData as { message?: unknown }).message != null &&
129
+ typeof (nodeData as { message?: unknown }).message !== 'string'
130
+ ) {
131
+ const nodeId = typeof node['id'] === 'string' && node['id'] ? node['id'] : '(unknown)';
132
+ throw new WorkflowImportError(`Invalid sliceAssets node "${nodeId}": data.message must be a string`);
133
+ }
134
+ }
135
+
136
+ if (node.type === 'transformText') {
137
+ const value = getTransformTextValue(node);
138
+ if (value === undefined) continue;
139
+ if (typeof value !== 'string' || !getIsPromptBuilderVisualEditorSupported(value)) {
140
+ throw new WorkflowImportError(
141
+ `Node "${getNodeIdLabel(
142
+ node,
143
+ )}": Prompt Builder value is not supported by the visual editor; use a single-quoted template like 'sheep ' + text1_output`,
144
+ );
145
+ }
146
+ }
107
147
  }
108
148
 
109
149
  return workflow as WorkflowExportData;
110
150
  }
151
+
152
+ // ------------------------------------
153
+
154
+ /**
155
+ * True if `value` is safe for the Prompt Builder chip editor.
156
+ * Only single-quoted string templates like `'lit' + var` are supported;
157
+ * double-quoted strings or CEL lists/numbers/bools will break or be removed.
158
+ */
159
+ export function getIsPromptBuilderVisualEditorSupported(value: string): boolean {
160
+ const trimmed = value.trim();
161
+ if (trimmed.length === 0 || trimmed === "''") return true;
162
+
163
+ // List / map literals never come from the visual editor and confuse the
164
+ // quote/variable scanner (`["sheep", "on"]`, `{a: 1}`, …).
165
+ if (/^[[{]/.test(trimmed)) return false;
166
+
167
+ if (getHasDoubleQuotedCelStringLiteral(trimmed)) return false;
168
+
169
+ try {
170
+ const result = evaluateCel(trimmed, {});
171
+ if (typeof result !== 'string') return false;
172
+ } catch {
173
+ // Unbound variables (`text1_output`) and similar fail evaluation with an
174
+ // empty binding map — that is still a normal canvas template.
175
+ }
176
+
177
+ return true;
178
+ }
179
+
180
+ /** True when the expression contains a `"` / `r"..."` string literal outside of `'...'`. */
181
+ function getHasDoubleQuotedCelStringLiteral(input: string): boolean {
182
+ let i = 0;
183
+ while (i < input.length) {
184
+ const ch = input[i];
185
+
186
+ if ((ch === 'r' || ch === 'R') && (input[i + 1] === '"' || input[i + 1] === "'")) {
187
+ if (input[i + 1] === '"') return true;
188
+ i += 2;
189
+ while (i < input.length && input[i] !== "'") i++;
190
+ if (i < input.length) i++;
191
+ continue;
192
+ }
193
+
194
+ if (ch === "'") {
195
+ i++;
196
+ while (i < input.length) {
197
+ if (input[i] === '\\' && i + 1 < input.length) {
198
+ i += 2;
199
+ continue;
200
+ }
201
+ if (input[i] === "'") {
202
+ i++;
203
+ break;
204
+ }
205
+ i++;
206
+ }
207
+ continue;
208
+ }
209
+
210
+ if (ch === '"') return true;
211
+ i++;
212
+ }
213
+ return false;
214
+ }
215
+
216
+ function getTransformTextValue(node: { [key: string]: unknown }): unknown {
217
+ const data = node['data'];
218
+ if (data === undefined || data === null) return undefined;
219
+ if (typeof data !== 'object') return undefined;
220
+ return (data as { [key: string]: unknown })['value'];
221
+ }
222
+
223
+ function getNodeIdLabel(node: { [key: string]: unknown }): string {
224
+ return typeof node['id'] === 'string' ? node['id'] : 'unknown';
225
+ }