@sonisoft/now-sdk-ext-core 6.6.3 → 6.8.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 (79) hide show
  1. package/README.md +6 -0
  2. package/dist/comm/http/FormHtmlParser.d.ts +34 -0
  3. package/dist/comm/http/FormHtmlParser.js +227 -0
  4. package/dist/comm/http/FormHtmlParser.js.map +1 -0
  5. package/dist/comm/http/FormRecordModels.d.ts +120 -0
  6. package/dist/comm/http/FormRecordModels.js +6 -0
  7. package/dist/comm/http/FormRecordModels.js.map +1 -0
  8. package/dist/comm/http/FormRecordWriter.d.ts +99 -0
  9. package/dist/comm/http/FormRecordWriter.js +385 -0
  10. package/dist/comm/http/FormRecordWriter.js.map +1 -0
  11. package/dist/comm/http/HTTPRequest.d.ts +4 -0
  12. package/dist/comm/http/RequestHandler.d.ts +1 -0
  13. package/dist/comm/http/RequestHandler.js +34 -22
  14. package/dist/comm/http/RequestHandler.js.map +1 -1
  15. package/dist/comm/http/internal/FormTransportModels.d.ts +26 -0
  16. package/dist/comm/http/internal/FormTransportModels.js +2 -0
  17. package/dist/comm/http/internal/FormTransportModels.js.map +1 -0
  18. package/dist/exception/FormSubmitError.d.ts +13 -0
  19. package/dist/exception/FormSubmitError.js +19 -0
  20. package/dist/exception/FormSubmitError.js.map +1 -0
  21. package/dist/exception/WorkflowValidationError.d.ts +24 -0
  22. package/dist/exception/WorkflowValidationError.js +24 -0
  23. package/dist/exception/WorkflowValidationError.js.map +1 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +19 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/sn/metadata/MetadataFormWriter.d.ts +13 -0
  28. package/dist/sn/metadata/MetadataFormWriter.js +264 -0
  29. package/dist/sn/metadata/MetadataFormWriter.js.map +1 -0
  30. package/dist/sn/metadata/MetadataModels.d.ts +44 -0
  31. package/dist/sn/metadata/MetadataModels.js +2 -0
  32. package/dist/sn/metadata/MetadataModels.js.map +1 -0
  33. package/dist/sn/metadata/MetadataScopeError.d.ts +5 -0
  34. package/dist/sn/metadata/MetadataScopeError.js +10 -0
  35. package/dist/sn/metadata/MetadataScopeError.js.map +1 -0
  36. package/dist/sn/metadata/MetadataVerificationError.d.ts +11 -0
  37. package/dist/sn/metadata/MetadataVerificationError.js +20 -0
  38. package/dist/sn/metadata/MetadataVerificationError.js.map +1 -0
  39. package/dist/sn/metadata/internal/MetadataPreferences.d.ts +12 -0
  40. package/dist/sn/metadata/internal/MetadataPreferences.js +27 -0
  41. package/dist/sn/metadata/internal/MetadataPreferences.js.map +1 -0
  42. package/dist/sn/metadata/internal/MetadataReader.d.ts +9 -0
  43. package/dist/sn/metadata/internal/MetadataReader.js +55 -0
  44. package/dist/sn/metadata/internal/MetadataReader.js.map +1 -0
  45. package/dist/sn/variables/VariableDefinitions.d.ts +23 -0
  46. package/dist/sn/variables/VariableDefinitions.js +106 -0
  47. package/dist/sn/variables/VariableDefinitions.js.map +1 -0
  48. package/dist/sn/variables/VariableModels.d.ts +49 -0
  49. package/dist/sn/variables/VariableModels.js +6 -0
  50. package/dist/sn/variables/VariableModels.js.map +1 -0
  51. package/dist/sn/workflow/WorkflowConversionModels.d.ts +209 -0
  52. package/dist/sn/workflow/WorkflowConversionModels.js +2 -0
  53. package/dist/sn/workflow/WorkflowConversionModels.js.map +1 -0
  54. package/dist/sn/workflow/WorkflowDiagramClient.d.ts +70 -0
  55. package/dist/sn/workflow/WorkflowDiagramClient.js +151 -0
  56. package/dist/sn/workflow/WorkflowDiagramClient.js.map +1 -0
  57. package/dist/sn/workflow/WorkflowFlowConversion.d.ts +19 -0
  58. package/dist/sn/workflow/WorkflowFlowConversion.js +1013 -0
  59. package/dist/sn/workflow/WorkflowFlowConversion.js.map +1 -0
  60. package/dist/sn/workflow/WorkflowFlowHints.d.ts +3 -0
  61. package/dist/sn/workflow/WorkflowFlowHints.js +36 -0
  62. package/dist/sn/workflow/WorkflowFlowHints.js.map +1 -0
  63. package/dist/sn/workflow/WorkflowFluentGenerator.d.ts +17 -0
  64. package/dist/sn/workflow/WorkflowFluentGenerator.js +348 -0
  65. package/dist/sn/workflow/WorkflowFluentGenerator.js.map +1 -0
  66. package/dist/sn/workflow/WorkflowGraphML.d.ts +39 -0
  67. package/dist/sn/workflow/WorkflowGraphML.js +162 -0
  68. package/dist/sn/workflow/WorkflowGraphML.js.map +1 -0
  69. package/dist/sn/workflow/WorkflowManager.d.ts +236 -6
  70. package/dist/sn/workflow/WorkflowManager.js +1443 -15
  71. package/dist/sn/workflow/WorkflowManager.js.map +1 -1
  72. package/dist/sn/workflow/WorkflowModels.d.ts +738 -0
  73. package/dist/sn/workflow/WorkflowStructure.d.ts +137 -0
  74. package/dist/sn/workflow/WorkflowStructure.js +368 -0
  75. package/dist/sn/workflow/WorkflowStructure.js.map +1 -0
  76. package/dist/sn/workflow/WorkflowViews.d.ts +104 -0
  77. package/dist/sn/workflow/WorkflowViews.js +408 -0
  78. package/dist/sn/workflow/WorkflowViews.js.map +1 -0
  79. package/package.json +2 -1
@@ -0,0 +1,1013 @@
1
+ import { buildOutline, WorkflowStructure } from "./WorkflowStructure.js";
2
+ import { analyzeWorkflow, lineCount, nameCatalogVariables } from "./WorkflowViews.js";
3
+ const STRUCTURAL = new Set(['Begin', 'End', 'Branch', 'Join']);
4
+ /** Due-date / timer settings shared by approvals, tasks and timers. */
5
+ const TIMER_ELEMENTS = new Set([
6
+ 'timer_type', 'duration', 'relative_duration', 'field', 'schedule_type', 'schedule_field', 'user_specified_schedule',
7
+ 'timezone_type', 'timezone_field', 'user_specified_timezone', 'modifier', 'percentage', 'time_before', 'time_after',
8
+ ]);
9
+ const QUERY_TERM = /^([a-z_][\w.]*?)(=|!=|>=|<=|>|<|NOT LIKE|LIKE|STARTSWITH|ENDSWITH|NOT IN|IN|ISNOTEMPTY|ISEMPTY|ANYTHING|SAMEAS|NSAMEAS)(.*)$/i;
10
+ const SCRIPT_LITERAL = /^(true|false|'[^']*'|-?\d+(?:\.\d+)?)$/;
11
+ /**
12
+ * Names generated code cannot use for a step's output: reserved words, and the bindings the
13
+ * Fluent file itself uses.
14
+ */
15
+ const RESERVED = new Set([
16
+ 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export',
17
+ 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super',
18
+ 'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements',
19
+ 'interface', 'package', 'private', 'protected', 'public', 'await', 'arguments', 'eval', 'undefined',
20
+ 'params', 'wfa', 'action', 'trigger',
21
+ ]);
22
+ /** Whether generated code cannot use this name as a binding. */
23
+ export function isReservedIdentifier(name) {
24
+ return RESERVED.has(name);
25
+ }
26
+ /** Text placed in a template literal as is: backslashes, backticks and `${` escaped. */
27
+ export function templateLiteralText(text) {
28
+ return String(text ?? '').replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
29
+ }
30
+ const FIELD_PATH = /^[a-z_]\w*(\.[a-z_]\w*)*$/i;
31
+ /** A code-safe lowercase identifier from free text. */
32
+ export function identifierFrom(text, max = 48) {
33
+ let id = String(text ?? '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean).join('_').slice(0, max);
34
+ while (id.endsWith('_'))
35
+ id = id.slice(0, -1);
36
+ return /^[a-z]/.test(id) ? id : `x_${id || 'step'}`;
37
+ }
38
+ /** Reads an activity's variables and remembers which ones the mapping used. */
39
+ class Settings {
40
+ a;
41
+ _used = new Set();
42
+ constructor(a) {
43
+ this.a = a;
44
+ }
45
+ value(element) {
46
+ this._used.add(element);
47
+ return this.a.variables.find(v => v.element === element)?.value ?? '';
48
+ }
49
+ display(element) {
50
+ this._used.add(element);
51
+ const v = this.a.variables.find(x => x.element === element);
52
+ return v?.display || v?.value || '';
53
+ }
54
+ /** The value when it differs from the type's default, else ''. */
55
+ custom(element) {
56
+ this._used.add(element);
57
+ const v = this.a.variables.find(x => x.element === element);
58
+ return v && !v.isDefault ? v.value ?? '' : '';
59
+ }
60
+ flag(element) {
61
+ return ['1', 'true'].includes(this.value(element));
62
+ }
63
+ use(...elements) {
64
+ for (const e of elements)
65
+ this._used.add(e);
66
+ }
67
+ /** Non-default timer / due-date settings, as `label: value`. */
68
+ timer() {
69
+ const timerType = this.a.variables.find(x => x.element === 'timer_type')?.value ?? '';
70
+ return this.a.variables
71
+ .filter(v => TIMER_ELEMENTS.has(v.element) && !v.isDefault && v.value)
72
+ // the form keeps a relative duration even when the timer type does not use one
73
+ .filter(v => v.element !== 'relative_duration' || timerType === 'relative_duration')
74
+ .map(v => {
75
+ this._used.add(v.element);
76
+ return `${v.label || v.element}: ${v.display || v.value}`;
77
+ });
78
+ }
79
+ /** Non-default settings the mapping did not use. */
80
+ leftovers() {
81
+ return this.a.variables.filter(v => !v.isDefault && v.value && !this._used.has(v.element) && !TIMER_ELEMENTS.has(v.element));
82
+ }
83
+ }
84
+ /**
85
+ * Plan a Flow Designer flow (or subflow) for a legacy workflow export.
86
+ */
87
+ export function planFlowConversion(data) {
88
+ const s = new WorkflowStructure(data);
89
+ const outline = buildOutline(s);
90
+ const analysis = analyzeWorkflow(data);
91
+ const trigger = planTrigger(data);
92
+ if (!data?.workflow || !Array.isArray(data.activities))
93
+ throw new Error('Not a workflow export: workflow or activities missing');
94
+ const identifier = identifierFrom(data.workflow.name);
95
+ const ctx = {
96
+ s, numbers: outline.numbers, recordPill: trigger.recordPill, table: data.workflow.table,
97
+ ids: new Set(), decisions: [], coverage: new Map(), outputs: new Map(), flowVariables: new Map(), parallelDepth: 0, prefix: identifier,
98
+ };
99
+ const steps = walk(ctx, outline.main, false);
100
+ const name = (id) => s.activity(id)?.name ?? id;
101
+ // one decision per shared step rather than one per path reaching it
102
+ const gotos = new Map();
103
+ for (const d of ctx.decisions.filter(x => x.topic === 'goto'))
104
+ gotos.set(d.activities[0], (gotos.get(d.activities[0]) ?? 0) + 1);
105
+ ctx.decisions = ctx.decisions.filter(x => x.topic !== 'goto');
106
+ for (const [target, count] of gotos) {
107
+ ctx.decisions.push({
108
+ topic: 'goto',
109
+ detail: `Step ${ctx.numbers.get(target)} (${name(target)}) is shared: ${count} other path${count === 1 ? '' : 's'} continue${count === 1 ? 's' : ''} there. `
110
+ + 'A flow cannot jump: repeat the steps in each branch, move them (and what follows) into a subflow called from each place, '
111
+ + 'or restructure the branches so they meet after the decision.',
112
+ activities: [target],
113
+ });
114
+ }
115
+ for (const loop of analysis.loops) {
116
+ ctx.decisions.push({
117
+ topic: 'loop',
118
+ detail: `${name(loop.from)} (${loop.exit}) loops back to ${name(loop.to)}${loop.cappedBy ? `, capped by ${name(loop.cappedBy)}` : ''}. `
119
+ + 'Rebuild it as Do the following until (with a flow-variable counter if it must be capped).',
120
+ activities: [loop.from, loop.to],
121
+ });
122
+ }
123
+ for (const merge of analysis.mergesWithoutJoin) {
124
+ ctx.decisions.push({
125
+ topic: 'mergeWithoutJoin',
126
+ detail: `${name(merge.activity)} is reached by several parallel branches from ${name(merge.splitAt)} without a Join, so the workflow runs it once per branch. `
127
+ + 'Decide whether the flow runs it once (after the parallel block) or in each branch.',
128
+ activities: [merge.activity, merge.splitAt],
129
+ });
130
+ }
131
+ for (const dead of analysis.deadExits) {
132
+ ctx.decisions.push({
133
+ topic: 'deadExit',
134
+ detail: `${name(dead.activity)}: exit "${dead.exit}" has no transition, so the workflow stops there. Decide what the flow does (often: end the flow).`,
135
+ activities: [dead.activity],
136
+ });
137
+ }
138
+ if (analysis.unreachable.length) {
139
+ ctx.decisions.push({
140
+ topic: 'unreachable',
141
+ detail: `Never runs (nothing leads to it): ${analysis.unreachable.map(name).join(', ')}. Not carried over.`,
142
+ activities: analysis.unreachable,
143
+ });
144
+ }
145
+ if (analysis.catalogVariables.length) {
146
+ const readers = (ids) => (ids.length > 3 ? `${ids.length} steps` : ids.map(name).join(', '));
147
+ ctx.decisions.push({
148
+ topic: 'catalogVariable',
149
+ detail: `Catalog variables read: ${analysis.catalogVariables.map(v => `${v.name} (${readers(v.readBy)})`).join(', ')}. `
150
+ + 'They are not data pills of the requested item: read them with Get Catalog Variables (or one script step returning them as outputs) and use those.',
151
+ activities: [...new Set(analysis.catalogVariables.flatMap(v => v.readBy))],
152
+ });
153
+ }
154
+ if (data.stages?.length) {
155
+ ctx.decisions.push({
156
+ topic: 'stage',
157
+ detail: `The workflow moves the "${data.version?.stageField || 'stage'}" field through stages (${data.stages.map(st => st.name).join(' → ')}). `
158
+ + 'Either set that field with Update Record at the same points or declare flow stages.',
159
+ activities: [...s.activities.keys()].filter(id => s.activity(id).stage),
160
+ });
161
+ }
162
+ const loose = analysis.scratchpad.filter(k => !k.setBy.length || !k.usedBy.length);
163
+ if (loose.length) {
164
+ ctx.decisions.push({
165
+ topic: 'script',
166
+ detail: `Scratchpad values only written or only read in this workflow (set elsewhere, or unused): ${loose.map(k => k.key).join(', ')}.`,
167
+ activities: [...new Set(loose.flatMap(k => [...k.setBy, ...k.usedBy]))],
168
+ });
169
+ }
170
+ const flowVariables = analysis.scratchpad
171
+ .filter(k => k.setBy.length && k.usedBy.length)
172
+ .map(k => ({ name: k.key, type: ctx.flowVariables.get(k.key) ?? 'string', setBy: k.setBy, usedBy: k.usedBy }));
173
+ const coverage = { direct: 0, partial: 0, manual: 0 };
174
+ for (const level of ctx.coverage.values())
175
+ coverage[level]++;
176
+ return {
177
+ format: 'now-sdk-ext/flow-conversion-plan@1',
178
+ source: { workflowSysId: data.workflow.sysId, versionSysId: data.version?.sysId ?? '', name: data.workflow.name, table: data.workflow.table },
179
+ kind: trigger.kind === 'subflow' ? 'subflow' : 'flow',
180
+ name: data.workflow.name,
181
+ identifier,
182
+ trigger,
183
+ flowVariables,
184
+ steps,
185
+ openDecisions: ctx.decisions,
186
+ coverage,
187
+ };
188
+ }
189
+ function planTrigger(data) {
190
+ const table = data.workflow.table ?? '';
191
+ const catalogItems = (data.usedBy?.catalogItems ?? []).map(c => ({ sysId: c.sysId, name: c.name }));
192
+ const inputs = (data.inputs ?? []).map(i => ({ name: i.name, label: i.label, type: i.type, mandatory: i.mandatory }));
193
+ const parents = data.usedBy?.parentWorkflows ?? [];
194
+ if (table === 'sc_req_item' && !parents.length) {
195
+ return {
196
+ kind: 'serviceCatalog', table, catalogItems, inputs, recordPill: 'params.trigger.request_item',
197
+ notes: [catalogItems.length
198
+ ? `Attach the flow to ${catalogItems.map(c => `"${c.name}"`).join(', ')} (the item's Flow field) and clear the item's Workflow when you cut over.`
199
+ : 'No catalog item names this workflow (order guides or scripts may start it); attach the flow where it is used.'],
200
+ };
201
+ }
202
+ if (parents.length || inputs.length || !data.version?.condition) {
203
+ const hasRecord = !!table && table !== 'global';
204
+ return {
205
+ kind: 'subflow', table, catalogItems, inputs, recordPill: hasRecord ? 'params.inputs.record' : '',
206
+ notes: [
207
+ parents.length ? `Called as a subflow by ${parents.map(p => `"${p.name}"`).join(', ')}.`
208
+ : 'Nothing starts this workflow by condition: it is started by script or as a subflow.',
209
+ hasRecord ? `The workflow ran on its caller's ${table} record (current); the subflow takes it as the "record" input.`
210
+ : 'The workflow runs on no table (global), so the subflow has no record input.',
211
+ ],
212
+ };
213
+ }
214
+ return {
215
+ kind: 'record', table, recordEvent: 'created', condition: data.version?.condition, catalogItems, inputs,
216
+ recordPill: 'params.trigger.current',
217
+ notes: [`The workflow starts when a ${table} record matching its condition is inserted${data.version?.conditionType ? ` (condition type "${data.version.conditionType}")` : ''}. `
218
+ + 'Check whether it must also start on update before choosing the record trigger.'],
219
+ };
220
+ }
221
+ // ------------------------------------------------------------------ the body
222
+ function walk(ctx, items, nested) {
223
+ const nodes = [];
224
+ for (const item of items) {
225
+ if (item.kind === 'end') {
226
+ if (nested)
227
+ nodes.push({ kind: 'endFlow', id: newId(ctx, 'end_flow'), source: source(ctx, item.activity) });
228
+ }
229
+ else if (item.kind === 'goto') {
230
+ const target = ctx.s.activity(item.target);
231
+ const number = ctx.numbers.get(item.target);
232
+ nodes.push({
233
+ kind: 'todo', id: newId(ctx, `continue_at_${target.name}`), source: source(ctx, item.target),
234
+ reason: `Continue at step ${number} (${target.name}), shared with other paths.`, continueAt: item.target,
235
+ });
236
+ ctx.decisions.push({ topic: 'goto', detail: '', activities: [item.target] });
237
+ }
238
+ else if (item.kind === 'step') {
239
+ nodes.push(...planStep(ctx, item));
240
+ }
241
+ }
242
+ return nodes;
243
+ }
244
+ function planStep(ctx, step) {
245
+ const { s } = ctx;
246
+ const a = s.activity(step.activity);
247
+ const nodes = [];
248
+ const branchesOnExit = step.branches.some(b => b.exit !== undefined);
249
+ const mapped = mapActivity(ctx, a, step, branchesOnExit);
250
+ if (mapped)
251
+ nodes.push(mapped);
252
+ for (const loop of step.loops) {
253
+ nodes.push({
254
+ kind: 'todo', id: newId(ctx, `loop_${a.name}`), source: source(ctx, a.sysId),
255
+ reason: `On ${loop.exit}, the workflow loops back to step ${ctx.numbers.get(loop.target)} (${s.activity(loop.target).name}). `
256
+ + 'Wrap the steps from there in Do the following until, exiting when this exit is not taken.',
257
+ });
258
+ }
259
+ const lanes = step.branches.filter(b => b.exit === undefined).flatMap(b => b.lanes);
260
+ if (lanes.length)
261
+ nodes.push(parallel(ctx, a, `parallel_after_${a.name}`, lanes));
262
+ if (branchesOnExit || a.type === 'If' || a.type === 'Switch') {
263
+ const decision = planDecision(ctx, a, step);
264
+ if (decision)
265
+ nodes.push(decision);
266
+ }
267
+ return nodes;
268
+ }
269
+ /** Lines that run at the same time. Flows cannot nest parallel blocks, so a nested one is flagged. */
270
+ function parallel(ctx, a, idText, lanes) {
271
+ const nested = ctx.parallelDepth > 0;
272
+ ctx.parallelDepth++;
273
+ const walked = lanes.map(l => walk(ctx, l, true));
274
+ ctx.parallelDepth--;
275
+ const notes = [];
276
+ if (nested) {
277
+ notes.push('Nested in another parallel block, which flows do not allow: run these lines one after the other, or move this block into a subflow.');
278
+ decide(ctx, 'other', `${a.name}: starts parallel lines inside another parallel block. Flows cannot nest Do the following in parallel; `
279
+ + 'run them in sequence or move the inner block into a subflow.', a.sysId);
280
+ }
281
+ return { kind: 'parallel', id: newId(ctx, idText), source: source(ctx, a.sysId), lanes: walked, notes };
282
+ }
283
+ function planDecision(ctx, a, step) {
284
+ const bodies = new Map();
285
+ for (const b of step.branches.filter(x => x.exit !== undefined)) {
286
+ bodies.set(b.exit, b.lanes.length > 1 ? [parallel(ctx, a, `parallel_${a.name}_${b.exit}`, b.lanes)] : walk(ctx, b.lanes[0] ?? [], true));
287
+ }
288
+ const continuing = [...(step.mainExit ? [step.mainExit.name] : []), ...step.directExits];
289
+ const notes = continuing.length && bodies.size ? [`${continuing.join(' / ')}: carries on with the steps after this decision.`] : [];
290
+ const src = source(ctx, a.sysId);
291
+ if (a.type === 'If') {
292
+ const yes = a.exits.find(e => /^yes$/i.test(e.name))?.name ?? a.exits[0]?.name;
293
+ const no = a.exits.find(e => e.name !== yes)?.name;
294
+ const test = ifCondition(ctx, a);
295
+ const confidence = test.condition.derived ? (test.partial ? 'partial' : 'direct') : 'manual';
296
+ ctx.coverage.set(a.sysId, bodies.size ? confidence : 'direct');
297
+ const yesBody = bodies.get(yes);
298
+ const noBody = no ? bodies.get(no) : undefined;
299
+ if (!yesBody && !noBody)
300
+ return undefined;
301
+ return {
302
+ kind: 'if', id: newId(ctx, `if_${a.name}`), source: src,
303
+ branches: [{ label: yesBody ? yes : `${yes}: carries on below`, condition: test.condition, body: yesBody ?? [] }],
304
+ otherwise: noBody ? { label: no, body: noBody } : undefined,
305
+ confidence, notes: [...notes, ...test.notes],
306
+ };
307
+ }
308
+ // exits that only continue at the same shared step become one branch
309
+ const branches = [];
310
+ const byTarget = new Map();
311
+ for (const e of a.exits.filter(x => bodies.has(x.name))) {
312
+ const body = bodies.get(e.name);
313
+ const condition = exitCondition(ctx, a, e.name);
314
+ const target = body.length === 1 && body[0].kind === 'todo' ? body[0].continueAt : undefined;
315
+ const same = target ? byTarget.get(target) : undefined;
316
+ if (same) {
317
+ same.label = `${same.label} / ${e.name}`;
318
+ same.condition = same.condition.derived && condition.derived
319
+ ? { expression: `${same.condition.expression}^OR${condition.expression}`, source: `${same.condition.source} || ${condition.source}`, derived: true }
320
+ : { expression: '', source: `${same.condition.source} or ${condition.source}`, derived: false };
321
+ continue;
322
+ }
323
+ const branch = { label: e.name, condition, body };
324
+ branches.push(branch);
325
+ if (target)
326
+ byTarget.set(target, branch);
327
+ }
328
+ // an else exit (taken when no other exit matches) is the flow's else
329
+ let otherwise;
330
+ const elseExit = a.exits.find(e => e.elseFlag);
331
+ const elseBranch = elseExit ? branches.find(b => b.label === elseExit.name) : undefined;
332
+ if (elseBranch) {
333
+ if (branches.length > 1) {
334
+ branches.splice(branches.indexOf(elseBranch), 1);
335
+ otherwise = { label: elseBranch.label, body: elseBranch.body };
336
+ }
337
+ else {
338
+ const others = a.exits.filter(e => e !== elseExit).map(e => e.name);
339
+ elseBranch.condition = { expression: '', source: `none of the other exits matched (${others.join(', ')})`, derived: false };
340
+ }
341
+ }
342
+ const derived = branches.filter(b => b.condition.derived).length;
343
+ const confidence = derived === branches.length ? 'direct' : derived ? 'partial' : 'manual';
344
+ if (a.type === 'Switch')
345
+ ctx.coverage.set(a.sysId, branches.length ? confidence : 'direct');
346
+ if (!branches.length)
347
+ return undefined;
348
+ return { kind: 'if', id: newId(ctx, `if_${a.name}`), source: src, branches, ...(otherwise ? { otherwise } : {}), confidence, notes };
349
+ }
350
+ /** The If activity's test: its condition (when set) AND its script (when advanced). */
351
+ function ifCondition(ctx, a) {
352
+ const settings = new Settings(a);
353
+ const query = settings.value('condition');
354
+ const script = settings.flag('advanced') ? settings.value('script').trim() : '';
355
+ const parts = [];
356
+ const notes = [];
357
+ let partial = false;
358
+ if (query)
359
+ parts.push(encodedQueryToCondition(query, ctx.recordPill, nameCatalogVariables(ctx.s.data, query)));
360
+ if (script) {
361
+ const test = scriptTest(script, ctx);
362
+ if (test) {
363
+ parts.push(test);
364
+ partial = true;
365
+ notes.push(`Condition read from the script; check it:\n${script}`);
366
+ }
367
+ else {
368
+ parts.push({ expression: '', source: `script:\n${script}`, derived: false });
369
+ }
370
+ }
371
+ if (!parts.length)
372
+ return { condition: { expression: '', source: '(no condition: always Yes)', derived: false }, partial, notes };
373
+ const derived = parts.every(p => p.derived);
374
+ return {
375
+ condition: { expression: derived ? parts.map(p => p.expression).join('^') : '', source: parts.map(p => p.source).join('\nAND '), derived },
376
+ partial, notes,
377
+ };
378
+ }
379
+ /** A flow condition for leaving an activity through one of its exits. */
380
+ function exitCondition(ctx, a, exitName) {
381
+ const legacy = a.exits.find(e => e.name === exitName)?.condition ?? '';
382
+ const results = [...legacy.matchAll(/activity\.result\s*==\s*['"]([^'"]*)['"]/g)].map(m => m[1]);
383
+ const settings = new Settings(a);
384
+ const output = ctx.outputs.get(a.sysId);
385
+ const either = (pill) => results.map(r => `${pill}=${templateLiteralText(r)}`).join('^OR');
386
+ if (output && results.length && /^Approval - /.test(a.type)) {
387
+ return { expression: either(`\${wfa.dataPill(${output}.approval_state, "choice")}`), source: legacy, derived: true };
388
+ }
389
+ if (a.type === 'Switch' && results.length) {
390
+ if (settings.value('type') === 'field' && FIELD_PATH.test(settings.value('field')) && ctx.recordPill) {
391
+ return { expression: either(`\${wfa.dataPill(${ctx.recordPill}.${settings.value('field')}, "string")}`), source: legacy, derived: true };
392
+ }
393
+ return { expression: '', source: `catalog variable ${settings.display('item_variable')} = ${results.join(' or ')}`, derived: false };
394
+ }
395
+ if (['Catalog Task', 'Create Task'].includes(a.type) && results.length) {
396
+ if (output)
397
+ return { expression: either(`\${wfa.dataPill(${output}.Record.state, "choice")}`), source: legacy, derived: true };
398
+ return { expression: '', source: `the task's state = ${results.join(' or ')}`, derived: false };
399
+ }
400
+ // a custom exit comparing a field of the record with a literal
401
+ const compare = /^current\.([a-z_][\w.]*)\s*(===?|!==?)\s*(?:'([^']*)'|"([^"]*)"|(-?\d+(?:\.\d+)?|true|false))$/i.exec(legacy.trim());
402
+ if (compare && !compare[1].startsWith('variables.') && ctx.recordPill) {
403
+ const literal = templateLiteralText(compare[3] ?? compare[4] ?? compare[5]);
404
+ return { expression: `\${wfa.dataPill(${ctx.recordPill}.${compare[1]}, "string")}${compare[2].startsWith('!') ? '!=' : '='}${literal}`, source: legacy, derived: true };
405
+ }
406
+ if (ctx.s.isJoin(a.sysId) && results.length) {
407
+ const complete = results.every(r => r === 'complete');
408
+ return {
409
+ expression: '', derived: false,
410
+ source: complete ? 'every branch reached the Join' : `a branch ended without reaching the Join (${exitName})`,
411
+ };
412
+ }
413
+ return { expression: '', source: legacy || exitName, derived: false };
414
+ }
415
+ /**
416
+ * Convert an encoded query on the workflow's record into a flow condition. Terms on catalog
417
+ * variables or with `javascript:` values cannot become data pills and leave it underived.
418
+ */
419
+ export function encodedQueryToCondition(query, recordPill, display = query) {
420
+ if (!recordPill)
421
+ return { expression: '', source: display.replace(/\^EQ$/, ''), derived: false };
422
+ const terms = query.replace(/\^EQ$/, '').split(/(\^OR|\^NQ|\^)/).filter(Boolean);
423
+ let derived = true;
424
+ const parts = [];
425
+ for (const term of terms) {
426
+ if (term === '^' || term === '^OR' || term === '^NQ') {
427
+ parts.push(term);
428
+ continue;
429
+ }
430
+ const m = QUERY_TERM.exec(term);
431
+ if (!m || m[1].startsWith('variables.') || /javascript:/i.test(term)) {
432
+ derived = false;
433
+ continue;
434
+ }
435
+ parts.push(`\${wfa.dataPill(${recordPill}.${m[1]}, "string")}${m[2]}${templateLiteralText(m[3])}`);
436
+ }
437
+ return { expression: derived ? parts.join('') : '', source: display.replace(/\^EQ$/, ''), derived };
438
+ }
439
+ /**
440
+ * Recognise an If script that only compares one scratchpad value or field of current with a
441
+ * literal (`answer = ifScript(); function ifScript() { if (<test>) return 'yes'; return 'no'; }`
442
+ * and the like) and turn the test into a flow condition.
443
+ */
444
+ function scriptTest(script, ctx) {
445
+ const code = compact(script);
446
+ const shapes = [
447
+ /^answer=(\w+)\(\);?function\1\(\)\{if\((.+)\)\{?return(?:'yes'|true);?\}?(?:else\{?)?return(?:'no'|false);?\}?\}$/,
448
+ /^if\((.+)\)\{?answer=(?:'yes'|true);?\}?else\{?answer=(?:'no'|false);?\}?$/,
449
+ /^answer=\((.+)\)\?'yes':'no';?$/,
450
+ ];
451
+ let test;
452
+ for (const shape of shapes) {
453
+ const m = shape.exec(code);
454
+ if (m) {
455
+ test = m[m.length - 1];
456
+ break;
457
+ }
458
+ }
459
+ if (!test)
460
+ return undefined;
461
+ const m = /^(workflow\.scratchpad|current)\.([a-z_]\w*)(?:(===?|!==?)(.+))?$/i.exec(test);
462
+ if (!m || (m[4] !== undefined && !SCRIPT_LITERAL.test(m[4])))
463
+ return undefined;
464
+ const literal = templateLiteralText(m[4] === undefined ? 'true' : m[4].replace(/^'|'$/g, ''));
465
+ const operator = m[3]?.startsWith('!') ? '!=' : '=';
466
+ if (m[1] === 'current') {
467
+ if (!ctx.recordPill)
468
+ return undefined;
469
+ return { expression: `\${wfa.dataPill(${ctx.recordPill}.${m[2]}, "string")}${operator}${literal}`, source: test, derived: true };
470
+ }
471
+ const type = typeOfLiteral(m[4] ?? 'true');
472
+ if (!ctx.flowVariables.has(m[2]))
473
+ ctx.flowVariables.set(m[2], type);
474
+ return { expression: `\${wfa.dataPill(params.flowVariables.${m[2]}, "${type}")}${operator}${literal}`, source: test, derived: true };
475
+ }
476
+ /** Script without comments or whitespace outside string literals, quotes made single. */
477
+ function compact(script) {
478
+ let out = '';
479
+ let quote;
480
+ for (let i = 0; i < script.length; i++) {
481
+ const c = script[i];
482
+ if (quote) {
483
+ if (c === '\\') {
484
+ out += c + (script[++i] ?? '');
485
+ continue;
486
+ }
487
+ if (c === quote) {
488
+ out += "'";
489
+ quote = undefined;
490
+ }
491
+ else
492
+ out += c;
493
+ continue;
494
+ }
495
+ if (c === '/' && script[i + 1] === '/') {
496
+ while (i < script.length && script[i] !== '\n')
497
+ i++;
498
+ continue;
499
+ }
500
+ if (c === '/' && script[i + 1] === '*') {
501
+ const end = script.indexOf('*/', i + 2);
502
+ i = end < 0 ? script.length : end + 1;
503
+ continue;
504
+ }
505
+ if (c === '"' || c === "'") {
506
+ quote = c;
507
+ out += "'";
508
+ continue;
509
+ }
510
+ if (!/\s/.test(c))
511
+ out += c;
512
+ }
513
+ return out;
514
+ }
515
+ function typeOfLiteral(literal) {
516
+ if (/^(true|false)$/.test(literal))
517
+ return 'boolean';
518
+ if (/^-?\d+$/.test(literal))
519
+ return 'integer';
520
+ return 'string';
521
+ }
522
+ // ------------------------------------------------------------------ activities
523
+ function mapActivity(ctx, a, step, branchesOnExit) {
524
+ const { s } = ctx;
525
+ if (STRUCTURAL.has(a.type) || a.type === 'If' || a.type === 'Switch')
526
+ return undefined;
527
+ const settings = new Settings(a);
528
+ const src = source(ctx, a.sysId);
529
+ const id = newId(ctx, a.name);
530
+ const notes = [];
531
+ const record = ctx.recordPill ? { kind: 'pill', expr: ctx.recordPill, type: 'reference' } : { kind: 'literal', value: '' };
532
+ // Inputs that take the record as text (a template literal) rather than a reference.
533
+ const recordText = ctx.recordPill ? { kind: 'text', parts: [{ expr: ctx.recordPill, type: 'reference' }] } : { kind: 'literal', value: '' };
534
+ if (!ctx.recordPill)
535
+ notes.push('The workflow has no record (global): set the record this step works on.');
536
+ const stage = s.data.stages?.find(st => st.sysId === a.stage)?.name;
537
+ if (stage)
538
+ notes.push(`Workflow stage: ${stage}.`);
539
+ for (const dead of step.deadExits)
540
+ notes.push(`Exit "${dead.name}" leads nowhere in the workflow.`);
541
+ const finish = (node, confidence) => {
542
+ ctx.coverage.set(a.sysId, confidence);
543
+ const left = settings.leftovers();
544
+ if (left.length && 'notes' in node) {
545
+ node.notes.push(`Not carried over: ${left.map(v => `${v.label || v.element} = ${oneLine(v.display || v.value, 120)}`).join('; ')}`);
546
+ }
547
+ return node;
548
+ };
549
+ if (s.isDesigner(a.sysId)) {
550
+ decide(ctx, 'spoke', `${a.name} [${a.type}] is an Activity Designer (spoke) activity: call the matching IntegrationHub action, or build a custom action.`, a.sysId);
551
+ return finish(todo(ctx, a, `Activity Designer activity "${a.type}": call the matching IntegrationHub action (or a custom action).`, a.input ? `inputs: ${JSON.stringify(a.input, null, 2)}` : undefined), 'manual');
552
+ }
553
+ switch (a.type) {
554
+ case 'Approval - User':
555
+ case 'Approval - Group': {
556
+ const output = id;
557
+ if (branchesOnExit)
558
+ ctx.outputs.set(a.sysId, output);
559
+ let confidence = 'direct';
560
+ const users = approvers(settings.value('users'), ctx.recordPill);
561
+ const groups = approvers(settings.value('groups'), ctx.recordPill);
562
+ const waitFor = settings.value('wait_for') || 'any';
563
+ const ruleType = waitFor === 'all' ? 'All' : 'Any';
564
+ if (waitFor === 'first')
565
+ notes.push('The workflow took the first response from anyone (approve or reject); the closest rule is Any.');
566
+ if (waitFor === 'script') {
567
+ confidence = 'partial';
568
+ notes.push(`The workflow decided by script; rebuild it as rule sets (Count / Percent) or a script step:\n${settings.value('approval_script')}`);
569
+ }
570
+ else
571
+ settings.use('approval_script');
572
+ if (settings.value('reject_handling') === 'wait') {
573
+ confidence = 'partial';
574
+ notes.push('The workflow waited for the other responses after a rejection; check the rule set.');
575
+ }
576
+ const approverScript = settings.flag('advanced') ? settings.custom('approver_script') : '';
577
+ settings.use('approver_script');
578
+ if (approverScript) {
579
+ confidence = 'partial';
580
+ notes.push(`Approvers are also added by script; compute them in a step before this one and pass them in:\n${approverScript}`);
581
+ decide(ctx, 'approval', `${a.name}: approvers come from a script; port it to a step that returns them.`, a.sysId);
582
+ }
583
+ if (settings.value('condition')) {
584
+ confidence = 'partial';
585
+ notes.push(`Approvals were only requested when: ${nameCatalogVariables(s.data, settings.value('condition'))}. Wrap the step in an If.`);
586
+ }
587
+ const unresolved = [...users, ...groups].some(v => v.kind === 'literal' && v.value === '');
588
+ if (unresolved) {
589
+ confidence = 'partial';
590
+ notes.push(`Some approvers are expressions this plan cannot turn into data pills: ${[settings.value('users'), settings.value('groups')].filter(Boolean).join(' ')}`);
591
+ }
592
+ if (!users.length && !groups.length && !approverScript)
593
+ notes.push('No approvers are configured: the workflow skips this step (exit Skipped).');
594
+ const timer = settings.timer();
595
+ if (timer.length)
596
+ notes.push(`The workflow set a due date (${timer.join(', ')}); set due_date with wfa.approvalDueDate() if approvers have a deadline.`);
597
+ const resolved = (list) => list.filter(v => !(v.kind === 'literal' && v.value === ''));
598
+ const inputs = {
599
+ record, table: { kind: 'literal', value: ctx.table },
600
+ approval_conditions: { kind: 'approvalRules', ruleType, action: 'ApprovesRejects', users: resolved(users), groups: resolved(groups) },
601
+ };
602
+ if (settings.custom('approval_column'))
603
+ inputs.approval_field = { kind: 'literal', value: settings.value('approval_column') };
604
+ if (settings.custom('approval_history'))
605
+ inputs.journal_field = { kind: 'literal', value: settings.value('approval_history') };
606
+ return finish({
607
+ kind: 'action', id, source: src, action: 'action.core.askForApproval', label: a.name, output: branchesOnExit ? output : undefined,
608
+ inputs, confidence, notes, stage, waits: true,
609
+ }, confidence);
610
+ }
611
+ case 'Set Values': {
612
+ const { fields, partial } = templateFields(settings.value('values'));
613
+ if (partial)
614
+ notes.push('Some values are scripts (javascript:) or ${…} expressions the workflow evaluated; compute them in a step before this one, or use data pills.');
615
+ const confidence = partial ? 'partial' : 'direct';
616
+ return finish({
617
+ kind: 'action', id, source: src, action: 'action.core.updateRecord', label: a.name,
618
+ inputs: { table_name: { kind: 'literal', value: ctx.table }, record, values: { kind: 'template', fields } },
619
+ confidence, notes, stage, waits: false,
620
+ }, confidence);
621
+ }
622
+ case 'Catalog Task':
623
+ case 'Create Task': {
624
+ const catalog = a.type === 'Catalog Task';
625
+ let confidence = 'direct';
626
+ const fields = {};
627
+ const mode = settings.value('task_value_type') || 'Fields';
628
+ if (mode === 'Values') {
629
+ const parsed = templateFields(settings.value('task_set_values'));
630
+ Object.assign(fields, parsed.fields);
631
+ if (parsed.partial) {
632
+ confidence = 'partial';
633
+ notes.push('Some task values are scripts (javascript:) or ${…} expressions; check how each should be set.');
634
+ }
635
+ }
636
+ else if (mode === 'Template') {
637
+ confidence = 'partial';
638
+ notes.push(`Task values came from the template "${settings.display('task_template')}"; copy its fields into the field values.`);
639
+ }
640
+ // Fields-mode settings apply only in Fields mode (the form keeps them when the mode changes).
641
+ const fieldMode = {
642
+ task_fulfillment_group: 'assignment_group', task_assigned_to: 'assigned_to', task_short_description: 'short_description', task_instructions: 'description',
643
+ };
644
+ for (const [element, field] of Object.entries(fieldMode)) {
645
+ const value = settings.value(element);
646
+ if (mode === 'Fields' && value)
647
+ fields[field] = { kind: 'literal', value };
648
+ }
649
+ if (settings.value('task_priority') && !fields.priority)
650
+ fields.priority = { kind: 'literal', value: settings.value('task_priority') };
651
+ const advanced = settings.flag('advanced') ? settings.custom('advanced_script') : '';
652
+ settings.use('advanced_script');
653
+ if (advanced) {
654
+ confidence = 'partial';
655
+ notes.push(`An advanced script runs on the new task (task) and the record (current); carry over what it sets:\n${advanced}`);
656
+ decide(ctx, 'task', `${a.name}: its advanced script sets values on the task or the record; port them (field values, Update Record, or a script step).`, a.sysId);
657
+ }
658
+ const variables = settings.value('task_variables');
659
+ if (variables)
660
+ notes.push(`Catalog variables shown on the task: ${variables.split(',').join(', ')}. Set catalog_variables (needs the catalog item's variables).`);
661
+ const timer = settings.timer();
662
+ if (timer.length)
663
+ notes.push(`The workflow set a due date (${timer.join(', ')}); set due_date in the field values if the task needs one.`);
664
+ const wait = settings.value('wait_for_completion') !== '' ? settings.flag('wait_for_completion') : true;
665
+ // Branching on how the task closed needs the task as a data pill: Create Task's Record
666
+ // output dot-walks, Create Catalog Task's "Catalog Task" output cannot be a pill in Fluent.
667
+ const asTask = !catalog || branchesOnExit;
668
+ const taskTable = catalog ? 'sc_task' : settings.value('task_table') || 'task';
669
+ // The handler also links these task tables to the record it runs on.
670
+ const linkField = { sc_task: ['sc_req_item', 'request_item'], change_task: ['change_request', 'change_request'],
671
+ problem_task: ['problem', 'problem'] }[taskTable];
672
+ const link = catalog ? { request_item: record }
673
+ : linkField && ctx.recordPill && ctx.table === linkField[0] ? { [linkField[1]]: record } : {};
674
+ if (branchesOnExit) {
675
+ ctx.outputs.set(a.sysId, id);
676
+ if (catalog)
677
+ notes.push('Created with Create Task on sc_task (not Create Catalog Task) so the flow can branch on the task\'s state.');
678
+ }
679
+ const extra = Object.fromEntries(Object.entries(fields).filter(([f]) => f !== 'short_description'));
680
+ const inputs = asTask
681
+ ? {
682
+ task_table: { kind: 'literal', value: taskTable },
683
+ field_values: { kind: 'template', fields: { ...link, parent: record, ...fields } },
684
+ wait: { kind: 'literal', value: wait },
685
+ }
686
+ : {
687
+ ah_requested_item: record,
688
+ ...(fields.short_description ? { ah_short_description: fields.short_description } : {}),
689
+ ...(Object.keys(extra).length ? { ah_fields: { kind: 'template', fields: extra } } : {}),
690
+ ah_wait: { kind: 'literal', value: wait },
691
+ };
692
+ return finish({
693
+ kind: 'action', id, source: src, action: asTask ? 'action.core.createTask' : 'action.core.createCatalogTask', label: a.name,
694
+ output: branchesOnExit ? id : undefined, inputs, confidence, notes, stage, waits: wait,
695
+ }, confidence);
696
+ }
697
+ case 'Timer': {
698
+ const timerType = settings.value('timer_type');
699
+ const seconds = durationSeconds(settings.value('duration'));
700
+ const modifier = settings.value('modifier');
701
+ if (timerType !== 'script')
702
+ settings.use('script');
703
+ if (!timerType && seconds !== undefined && !modifier) {
704
+ settings.use(...TIMER_ELEMENTS);
705
+ return finish({ kind: 'waitForADuration', id, source: src, seconds, confidence: 'direct', notes }, 'direct');
706
+ }
707
+ const how = timerType === 'relative_duration' ? `the relative duration "${settings.display('relative_duration')}"`
708
+ : timerType === 'field' ? `the ${settings.value('field')} field`
709
+ : timerType === 'script' ? `a script:\n${settings.value('script')}`
710
+ : `a duration of ${seconds ?? '?'} seconds`;
711
+ const timer = settings.timer();
712
+ notes.push(`The timer waited for ${how}${modifier ? ` (${modifier})` : ''}${timer.length ? `; ${timer.join(', ')}` : ''}. `
713
+ + (timerType === 'field' ? 'Use Wait For Condition, or a wait relative to that field.' : 'Set the duration by hand.'));
714
+ return finish({ kind: 'waitForADuration', id, source: src, seconds: seconds ?? 0, confidence: 'partial', notes }, 'partial');
715
+ }
716
+ case 'Wait for condition': {
717
+ const conditions = settings.value('wait_for_condition');
718
+ let confidence = conditions ? 'direct' : 'manual';
719
+ const script = settings.custom('script_condition');
720
+ if (script) {
721
+ confidence = 'partial';
722
+ notes.push(`It also waited on a script:\n${script}`);
723
+ }
724
+ if (/variables\./.test(conditions))
725
+ confidence = 'partial';
726
+ if (/javascript:/i.test(conditions)) {
727
+ confidence = 'partial';
728
+ notes.push('The condition evaluates a script (javascript:); flows do not run it there. Compute the value in a step before this one.');
729
+ }
730
+ return finish({
731
+ kind: 'action', id, source: src, action: 'action.core.waitForCondition', label: a.name,
732
+ inputs: {
733
+ record: recordText,
734
+ conditions: { kind: 'literal', value: nameCatalogVariables(s.data, conditions).replace(/\^EQ$/, '') },
735
+ table_name: { kind: 'literal', value: ctx.table },
736
+ },
737
+ confidence, notes, stage, waits: true,
738
+ }, confidence);
739
+ }
740
+ case 'Notification': {
741
+ const to = [...approvers(settings.value('email_to'), ctx.recordPill), ...approvers(settings.value('email_to_group'), ctx.recordPill)]
742
+ .filter(v => !(v.kind === 'literal' && v.value === ''));
743
+ const body = settings.value('email');
744
+ let confidence = 'direct';
745
+ if (/\$\{/.test(body)) {
746
+ confidence = 'partial';
747
+ notes.push('The message uses ${…} substitutions; rebuild them as data pills in the email body, or send a notification record instead.');
748
+ }
749
+ if (settings.flag('advanced') && settings.custom('email_to_script')) {
750
+ confidence = 'partial';
751
+ notes.push(`Recipients are also built by script:\n${settings.value('email_to_script')}`);
752
+ }
753
+ return finish({
754
+ kind: 'action', id, source: src, action: 'action.core.sendEmail', label: a.name,
755
+ inputs: {
756
+ ah_to: { kind: 'list', items: to },
757
+ ah_subject: substitute(settings.value('subject'), ctx.recordPill),
758
+ ah_body: { kind: 'literal', value: body },
759
+ record, table_name: { kind: 'literal', value: ctx.table },
760
+ },
761
+ confidence, notes, stage, waits: false,
762
+ }, confidence);
763
+ }
764
+ case 'Log Message':
765
+ return finish({
766
+ kind: 'action', id, source: src, action: 'action.core.log', label: a.name,
767
+ inputs: { log_level: { kind: 'literal', value: 'info' }, log_message: substitute(settings.value('message'), ctx.recordPill) },
768
+ confidence: 'direct', notes, stage, waits: false,
769
+ }, 'direct');
770
+ case 'Create Event': {
771
+ const params = [settings.custom('event_param1'), settings.custom('event_param2')];
772
+ if (params.some(Boolean))
773
+ notes.push(`Event parameters were scripts; compute them in a step before this one:\n${params.filter(Boolean).join('\n---\n')}`);
774
+ const confidence = params.some(Boolean) ? 'partial' : 'direct';
775
+ return finish({
776
+ kind: 'action', id, source: src, action: 'action.core.fireEvent', label: a.name,
777
+ inputs: {
778
+ event_name: { kind: 'literal', value: settings.display('event_name') },
779
+ record: recordText,
780
+ },
781
+ confidence, notes, stage, waits: false,
782
+ }, confidence);
783
+ }
784
+ case 'Run Script': {
785
+ const script = settings.value('script');
786
+ const assigned = simpleAssignments(script);
787
+ if (assigned && Object.keys(assigned.scratchpad).length && !Object.keys(assigned.current).length) {
788
+ for (const [key, value] of Object.entries(assigned.scratchpad)) {
789
+ if (!ctx.flowVariables.has(key))
790
+ ctx.flowVariables.set(key, typeOfLiteral(String(value.kind === 'literal' ? value.value : '')));
791
+ }
792
+ return finish({ kind: 'setFlowVariables', id, source: src, values: assigned.scratchpad, notes }, 'direct');
793
+ }
794
+ if (assigned && Object.keys(assigned.current).length && !Object.keys(assigned.scratchpad).length) {
795
+ return finish({
796
+ kind: 'action', id, source: src, action: 'action.core.updateRecord', label: a.name,
797
+ inputs: { table_name: { kind: 'literal', value: ctx.table }, record, values: { kind: 'template', fields: assigned.current } },
798
+ confidence: 'direct', notes: [...notes, `From the script: ${oneLine(script, 200)}`], stage, waits: false,
799
+ }, 'direct');
800
+ }
801
+ decide(ctx, 'script', `${a.name}: a ${lineCount(script)}-line script. Port it to a custom action with a script step, or to declarative actions if it only reads and writes records.`, a.sysId);
802
+ return finish(todo(ctx, a, 'Script: port it to a custom action with a script step (see the workflow-migration skill).', script), 'manual');
803
+ }
804
+ case 'Workflow': {
805
+ const target = a.subflow?.name ?? (settings.display('workflow_instance') || '?');
806
+ settings.use('workflow_instance');
807
+ decide(ctx, 'subflow', `${a.name} runs the workflow "${target}": convert that workflow to a subflow and call it here.`, a.sysId);
808
+ return finish(todo(ctx, a, `Runs the workflow "${target}": convert it to a subflow, then call it here (wfa.subflow).`), 'manual');
809
+ }
810
+ case 'Return Value':
811
+ return finish(todo(ctx, a, 'The subflow\'s result: declare an output and set it with Assign Subflow Outputs.', settings.value('value')), 'partial');
812
+ default: {
813
+ const detail = a.variables.filter(v => !v.isDefault && v.value).map(v => `${v.element} = ${v.value}`).join('\n');
814
+ settings.use(...a.variables.map(v => v.element));
815
+ const description = oneLine(s.typeOf(a.sysId)?.description ?? '', 200);
816
+ decide(ctx, 'other', `${a.name} [${a.type}] has no planned Flow Designer equivalent.${description ? ` ${description}` : ''}`, a.sysId);
817
+ return finish(todo(ctx, a, `No planned Flow Designer equivalent for "${a.type}".`, detail || undefined), 'manual');
818
+ }
819
+ }
820
+ }
821
+ // ------------------------------------------------------------------ helpers
822
+ function todo(ctx, a, reason, detail) {
823
+ return { kind: 'todo', id: newId(ctx, `todo_${a.name}`), source: source(ctx, a.sysId), reason, ...(detail ? { detail } : {}) };
824
+ }
825
+ function decide(ctx, topic, detail, activity) {
826
+ ctx.decisions.push({ topic, detail, activities: [activity] });
827
+ }
828
+ function source(ctx, sysId) {
829
+ const a = ctx.s.activity(sysId);
830
+ return { activity: sysId, name: a?.name ?? sysId, type: a?.type ?? '', number: ctx.numbers.get(sysId) ?? 0 };
831
+ }
832
+ function newId(ctx, text) {
833
+ const short = identifierFrom(identifierFrom(text, 120).replace(new RegExp(`(^|_)${ctx.prefix}_`), '$1'), 60);
834
+ const base = RESERVED.has(short) ? `step_${short}` : short;
835
+ let id = base;
836
+ for (let i = 2; ctx.ids.has(id); i++)
837
+ id = `${base}_${i}`;
838
+ ctx.ids.add(id);
839
+ return id;
840
+ }
841
+ function oneLine(text, limit = 160) {
842
+ const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
843
+ return flat.length <= limit ? flat : `${flat.slice(0, limit - 1)}…`;
844
+ }
845
+ /** `${field}` / `${current.field}` → a data pill on the workflow's record; catalog variables are not pills. */
846
+ function referencePill(expression, recordPill) {
847
+ if (!recordPill)
848
+ return undefined;
849
+ const m = /^\$\{\s*(?:current\.)?([a-z_][\w.]*)\s*\}$/i.exec(expression.trim());
850
+ if (!m || m[1].startsWith('variables.'))
851
+ return undefined;
852
+ return { kind: 'pill', expr: `${recordPill}.${m[1]}`, type: 'reference' };
853
+ }
854
+ /**
855
+ * Users, groups or recipients: sys_ids and addresses as literals, `${field}` as data pills;
856
+ * anything else (scripted expressions) as an empty literal for the caller to flag.
857
+ */
858
+ function approvers(value, recordPill) {
859
+ if (!value)
860
+ return [];
861
+ return value.split(',').map(part => part.trim()).filter(Boolean).map((part) => (/^[0-9a-f]{32}$/i.test(part) || /^[^\s@${}]+@[^\s@]+$/.test(part) ? { kind: 'literal', value: part }
862
+ : referencePill(part, recordPill) ?? { kind: 'literal', value: '' }));
863
+ }
864
+ /** Text with `${field}` substitutions → text with data pills (catalog variables and expressions stay text). */
865
+ function substitute(text, recordPill) {
866
+ if (!/\$\{/.test(text) || !recordPill)
867
+ return { kind: 'literal', value: text };
868
+ const parts = [];
869
+ let last = 0;
870
+ for (const m of text.matchAll(/\$\{\s*(?:current\.)?([a-z_][\w.]*)\s*\}/gi)) {
871
+ parts.push(text.slice(last, m.index));
872
+ parts.push(m[1].startsWith('variables.') ? m[0] : { expr: `${recordPill}.${m[1]}`, type: 'string' });
873
+ last = m.index + m[0].length;
874
+ }
875
+ parts.push(text.slice(last));
876
+ return { kind: 'text', parts: parts.filter(p => p !== '') };
877
+ }
878
+ /** An encoded template (`field=value^field2=value2`) → a field map; `javascript:` and `${…}` values flag it partial. */
879
+ function templateFields(template) {
880
+ const fields = {};
881
+ let partial = false;
882
+ for (const term of (template || '').replace(/\^EQ$/, '').split('^').filter(Boolean)) {
883
+ const at = term.indexOf('=');
884
+ if (at <= 0)
885
+ continue;
886
+ const value = term.slice(at + 1);
887
+ if (/^javascript:|\$\{/i.test(value))
888
+ partial = true;
889
+ fields[term.slice(0, at)] = { kind: 'literal', value };
890
+ }
891
+ return { fields, partial };
892
+ }
893
+ /** Stored duration (`1970-01-02 01:00:00`) → seconds. */
894
+ function durationSeconds(value) {
895
+ if (!value)
896
+ return 0;
897
+ const m = /^1970-01-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(value);
898
+ if (!m)
899
+ return undefined;
900
+ return (Number(m[1]) - 1) * 86400 + Number(m[2]) * 3600 + Number(m[3]) * 60 + Number(m[4]);
901
+ }
902
+ /** A script made only of `current.x = <literal>;` and `workflow.scratchpad.y = <literal>;` statements. */
903
+ function simpleAssignments(script) {
904
+ const statements = compact(script).split(';').filter(Boolean);
905
+ if (!statements.length)
906
+ return undefined;
907
+ const current = {};
908
+ const scratchpad = {};
909
+ for (const statement of statements) {
910
+ const m = /^(current|workflow\.scratchpad)\.([a-z_]\w*)=(.+)$/i.exec(statement);
911
+ if (!m || !SCRIPT_LITERAL.test(m[3]))
912
+ return undefined;
913
+ const raw = m[3];
914
+ const value = raw === 'true' ? true : raw === 'false' ? false
915
+ : /^-?\d/.test(raw) ? Number(raw) : raw.replace(/^'|'$/g, '');
916
+ (m[1] === 'current' ? current : scratchpad)[m[2]] = { kind: 'literal', value };
917
+ }
918
+ return { current, scratchpad };
919
+ }
920
+ // ------------------------------------------------------------------ rendering
921
+ /** The plan as readable text: trigger, flow body, notes and the open decisions. */
922
+ export function renderFlowPlan(plan) {
923
+ const out = [];
924
+ out.push(`# Flow Designer plan for "${plan.name}" (${plan.kind}, from wf_workflow ${plan.source.workflowSysId}, version ${plan.source.versionSysId})`);
925
+ const t = plan.trigger;
926
+ if (t.kind === 'serviceCatalog')
927
+ out.push(`TRIGGER Service Catalog (${t.table})${t.catalogItems.length ? ` for ${t.catalogItems.map(c => `"${c.name}"`).join(', ')}` : ''}`);
928
+ else if (t.kind === 'record')
929
+ out.push(`TRIGGER ${t.table} created${t.condition ? ` where ${t.condition}` : ''}`);
930
+ else
931
+ out.push(`SUBFLOW inputs: record (${t.table})${t.inputs.length ? `, ${t.inputs.map(i => `${i.name}: ${i.type}`).join(', ')}` : ''}`);
932
+ for (const note of t.notes)
933
+ out.push(` note: ${note}`);
934
+ if (plan.flowVariables.length)
935
+ out.push(`FLOW VARIABLES (from workflow.scratchpad): ${plan.flowVariables.map(v => `${v.name}: ${v.type}`).join(', ')}`);
936
+ out.push(`COVERAGE: ${plan.coverage.direct} direct, ${plan.coverage.partial} partial, ${plan.coverage.manual} to design by hand`);
937
+ out.push('');
938
+ const pill = (expr) => `{{${expr.replace(/^params\.trigger\./, 'trigger.').replace(/^params\.inputs\./, 'input.').replace(/^params\.flowVariables\./, 'flow_variables.')}}}`;
939
+ const value = (v) => {
940
+ switch (v.kind) {
941
+ case 'literal': return JSON.stringify(v.value);
942
+ case 'pill': return pill(v.expr);
943
+ case 'text': return v.parts.map(p => (typeof p === 'string' ? p : pill(p.expr))).join('');
944
+ case 'template': return Object.entries(v.fields).map(([k, x]) => `${k}=${value(x)}`).join(', ');
945
+ case 'approvalRules': return `${v.ruleType} of${v.users.length ? ` users [${v.users.map(value).join(', ')}]` : ''}${v.groups.length ? ` groups [${v.groups.map(value).join(', ')}]` : ''}`;
946
+ case 'duration': return `${v.seconds}s`;
947
+ case 'list': return `[${v.items.map(value).join(', ')}]`;
948
+ }
949
+ };
950
+ const condition = (c) => (c.derived
951
+ ? c.expression.replace(/\$\{wfa\.dataPill\(([\w.]+), "\w+"\)\}/g, (_m, expr) => pill(expr))
952
+ : `TODO(${oneLine(c.source, 120)})`);
953
+ const mark = (c) => (c === 'direct' ? '' : c === 'partial' ? ' [PARTIAL]' : ' [MANUAL]');
954
+ const from = (n) => ` ← ${n.source.number}. ${n.source.name}`;
955
+ const noteLines = (notes, pad) => {
956
+ for (const note of notes)
957
+ out.push(`${pad} note: ${oneLine(note, 220)}`);
958
+ };
959
+ const render = (nodes, depth) => {
960
+ const pad = ' '.repeat(depth);
961
+ for (const n of nodes) {
962
+ switch (n.kind) {
963
+ case 'action':
964
+ out.push(`${pad}${n.action.replace('action.core.', '')}${n.output ? ` → ${n.output}` : ''}${n.waits ? ' [WAIT]' : ''}${mark(n.confidence)}${from(n)}`);
965
+ for (const [k, v] of Object.entries(n.inputs))
966
+ out.push(`${pad} - ${k} = ${oneLine(value(v), 200)}`);
967
+ noteLines(n.notes, pad);
968
+ break;
969
+ case 'waitForADuration':
970
+ out.push(`${pad}waitForADuration ${n.seconds}s [WAIT]${mark(n.confidence)}${from(n)}`);
971
+ noteLines(n.notes, pad);
972
+ break;
973
+ case 'setFlowVariables':
974
+ out.push(`${pad}setFlowVariables ${Object.entries(n.values).map(([k, v]) => `${k}=${value(v)}`).join(', ')}${from(n)}`);
975
+ noteLines(n.notes, pad);
976
+ break;
977
+ case 'if':
978
+ n.branches.forEach((b, i) => {
979
+ out.push(`${pad}${i ? 'else if' : 'if'} ${condition(b.condition)} [${b.label}]${i ? '' : `${mark(n.confidence)}${from(n)}`}`);
980
+ render(b.body, depth + 1);
981
+ });
982
+ if (n.otherwise) {
983
+ out.push(`${pad}else [${n.otherwise.label}]`);
984
+ render(n.otherwise.body, depth + 1);
985
+ }
986
+ noteLines(n.notes, pad);
987
+ break;
988
+ case 'parallel':
989
+ out.push(`${pad}in parallel${from(n)}`);
990
+ noteLines(n.notes, pad);
991
+ n.lanes.forEach((lane, k) => {
992
+ out.push(`${pad} branch ${k + 1}:`);
993
+ render(lane, depth + 1);
994
+ });
995
+ break;
996
+ case 'endFlow':
997
+ out.push(`${pad}endFlow`);
998
+ break;
999
+ case 'todo':
1000
+ out.push(`${pad}TODO: ${oneLine(n.reason, 220)}${from(n)}`);
1001
+ break;
1002
+ }
1003
+ }
1004
+ };
1005
+ render(plan.steps, 0);
1006
+ if (plan.openDecisions.length) {
1007
+ out.push('', 'OPEN DECISIONS:');
1008
+ for (const d of plan.openDecisions)
1009
+ out.push(` - [${d.topic}] ${oneLine(d.detail, 400)}`);
1010
+ }
1011
+ return out.join('\n');
1012
+ }
1013
+ //# sourceMappingURL=WorkflowFlowConversion.js.map