@rytass/bpm-core-shared 0.3.0 → 0.5.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 (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/eslint.config.mjs +19 -0
  3. package/jest.config.cts +10 -0
  4. package/package.json +2 -3
  5. package/project.json +49 -0
  6. package/src/lib/condition.ts +55 -0
  7. package/src/lib/form.ts +77 -0
  8. package/src/lib/identity.ts +25 -0
  9. package/src/lib/organization.ts +52 -0
  10. package/src/lib/shared.spec.ts +13 -0
  11. package/src/lib/status.ts +29 -0
  12. package/src/lib/workflow-command.spec.ts +278 -0
  13. package/src/lib/workflow-command.ts +848 -0
  14. package/src/lib/workflow-graph.ts +974 -0
  15. package/src/lib/workflow-toolset.spec.ts +230 -0
  16. package/src/lib/workflow-toolset.ts +1083 -0
  17. package/src/lib/workflow.ts +216 -0
  18. package/tsconfig.json +23 -0
  19. package/tsconfig.lib.json +16 -0
  20. package/tsconfig.spec.json +16 -0
  21. package/src/index.js +0 -13
  22. package/src/index.js.map +0 -1
  23. package/src/lib/condition.d.ts +0 -43
  24. package/src/lib/condition.js +0 -3
  25. package/src/lib/condition.js.map +0 -1
  26. package/src/lib/form.d.ts +0 -48
  27. package/src/lib/form.js +0 -3
  28. package/src/lib/form.js.map +0 -1
  29. package/src/lib/identity.d.ts +0 -24
  30. package/src/lib/identity.js +0 -3
  31. package/src/lib/identity.js.map +0 -1
  32. package/src/lib/organization.d.ts +0 -47
  33. package/src/lib/organization.js +0 -3
  34. package/src/lib/organization.js.map +0 -1
  35. package/src/lib/status.d.ts +0 -7
  36. package/src/lib/status.js +0 -3
  37. package/src/lib/status.js.map +0 -1
  38. package/src/lib/workflow-command.d.ts +0 -156
  39. package/src/lib/workflow-command.js +0 -292
  40. package/src/lib/workflow-command.js.map +0 -1
  41. package/src/lib/workflow-graph.d.ts +0 -96
  42. package/src/lib/workflow-graph.js +0 -605
  43. package/src/lib/workflow-graph.js.map +0 -1
  44. package/src/lib/workflow-toolset.d.ts +0 -105
  45. package/src/lib/workflow-toolset.js +0 -702
  46. package/src/lib/workflow-toolset.js.map +0 -1
  47. package/src/lib/workflow.d.ts +0 -160
  48. package/src/lib/workflow.js +0 -3
  49. package/src/lib/workflow.js.map +0 -1
  50. /package/src/{index.d.ts → index.ts} +0 -0
@@ -0,0 +1,848 @@
1
+ import { FormDefinitionSchema } from './form';
2
+ import {
3
+ ApproverResolver,
4
+ ReturnResubmitStrategy,
5
+ ServiceAction,
6
+ WorkflowDefinition,
7
+ WorkflowEdgeConditionOperator,
8
+ WorkflowNodeTriggerMode,
9
+ } from './workflow';
10
+ import {
11
+ NodePaletteType,
12
+ WorkflowEdgeIdFactory,
13
+ createWorkflowEdge,
14
+ createWorkflowNode,
15
+ defaultWorkflowEdgeId,
16
+ insertWorkflowNodeIntoDefinition,
17
+ isExclusiveGatewaySourceEdge,
18
+ isWorkflowConnectionValid,
19
+ isWorkflowNodeRemovable,
20
+ normalizeDesignerWorkflowDefinition,
21
+ readConditionExpression,
22
+ readConditionField,
23
+ readConditionLabel,
24
+ readNextConditionOperator,
25
+ readNextConditionValue,
26
+ readNextWorkflowNodeIndex,
27
+ readWorkflowDefinitionIssue,
28
+ renameWorkflowNode,
29
+ applyWorkflowNodeTriggerMode,
30
+ WORKFLOW_OUTPUT_HANDLE_ID,
31
+ WORKFLOW_INPUT_HANDLE_ID,
32
+ } from './workflow-graph';
33
+
34
+ /**
35
+ * The serialisable command layer that the template designer — and the LLM
36
+ * assistant — drive the workflow through. Every mutation a user can perform on
37
+ * the canvas, node palette, or property form is expressed as one of these
38
+ * commands, and {@link applyWorkflowCommand} is the single pure reducer that
39
+ * applies them. The React controller and the LLM tool runtime both dispatch the
40
+ * exact same commands, guaranteeing the assistant can do anything a user can.
41
+ */
42
+
43
+ /** Everything the reducer needs as the single source of truth. */
44
+ export interface WorkflowDesignerState {
45
+ /** Form schema bound to the template; needed to compile edge conditions. */
46
+ readonly formSchema: FormDefinitionSchema | null;
47
+ readonly definition: WorkflowDefinition;
48
+ readonly editingEdgeId: string | null;
49
+ readonly formDefinitionVersionId: string | null;
50
+ readonly initiatorPolicyCel: string | null;
51
+ readonly selectedEdgeIds: readonly string[];
52
+ readonly selectedNodeId: string | null;
53
+ }
54
+
55
+ /** Where a newly added node should be wired in. */
56
+ export interface WorkflowNodeAnchor {
57
+ /** Insert the node onto this edge, splitting it. */
58
+ readonly edgeId?: string | null;
59
+ /** Insert the node after this node. */
60
+ readonly afterNodeId?: string | null;
61
+ }
62
+
63
+ export type WorkflowCommand =
64
+ // ── Node primitives ──
65
+ | {
66
+ readonly type: 'addNode';
67
+ readonly nodeType: NodePaletteType;
68
+ readonly anchor?: WorkflowNodeAnchor;
69
+ }
70
+ | { readonly type: 'renameNode'; readonly nodeId: string; readonly label: string }
71
+ | { readonly type: 'deleteNode'; readonly nodeId: string }
72
+ | {
73
+ readonly type: 'setNodeTriggerMode';
74
+ readonly nodeId: string;
75
+ readonly triggerMode: WorkflowNodeTriggerMode;
76
+ }
77
+ | {
78
+ readonly type: 'setUserTaskApprover';
79
+ readonly nodeId: string;
80
+ readonly approverResolver: ApproverResolver;
81
+ }
82
+ | {
83
+ readonly type: 'setUserTaskReturnResubmitStrategy';
84
+ readonly nodeId: string;
85
+ readonly resubmitStrategy: ReturnResubmitStrategy;
86
+ }
87
+ | {
88
+ readonly type: 'setUserTaskOptions';
89
+ readonly nodeId: string;
90
+ readonly allowAddSigner?: boolean;
91
+ readonly allowReject?: boolean;
92
+ readonly allowTransfer?: boolean;
93
+ readonly description?: string;
94
+ }
95
+ | {
96
+ readonly type: 'setServiceAction';
97
+ readonly nodeId: string;
98
+ readonly action: ServiceAction;
99
+ }
100
+ | {
101
+ readonly type: 'setEndState';
102
+ readonly nodeId: string;
103
+ readonly endState: 'APPROVED' | 'REJECTED';
104
+ }
105
+ // ── Edge primitives ──
106
+ | {
107
+ readonly type: 'connectEdge';
108
+ readonly source: string;
109
+ readonly target: string;
110
+ }
111
+ | { readonly type: 'deleteEdge'; readonly edgeId: string }
112
+ | {
113
+ readonly type: 'setEdgeCondition';
114
+ readonly edgeId: string;
115
+ readonly fieldKey?: string | null;
116
+ readonly operator?: WorkflowEdgeConditionOperator | null;
117
+ readonly value?: string | null;
118
+ }
119
+ | {
120
+ readonly type: 'setEdgeDefault';
121
+ readonly edgeId: string;
122
+ readonly isDefault: boolean;
123
+ }
124
+ // ── Form / policy ──
125
+ | {
126
+ readonly type: 'bindForm';
127
+ readonly formDefinitionVersionId: string | null;
128
+ readonly formSchema: FormDefinitionSchema | null;
129
+ }
130
+ | {
131
+ readonly type: 'setInitiatorPolicyCel';
132
+ readonly cel: string | null;
133
+ }
134
+ // ── Selection ──
135
+ | { readonly type: 'selectNode'; readonly nodeId: string | null }
136
+ | { readonly type: 'selectEdges'; readonly edgeIds: readonly string[] }
137
+ // ── Layout ──
138
+ | { readonly type: 'autoLayout' };
139
+
140
+ /** High-level intents that fold into a sequence of primitive commands. */
141
+ export type WorkflowMacroCommand =
142
+ | {
143
+ readonly type: 'insertApprovalStep';
144
+ readonly approverResolver: ApproverResolver;
145
+ readonly label?: string;
146
+ readonly anchor?: WorkflowNodeAnchor;
147
+ }
148
+ | {
149
+ readonly type: 'insertNotification';
150
+ readonly action: ServiceAction;
151
+ readonly label?: string;
152
+ readonly anchor?: WorkflowNodeAnchor;
153
+ }
154
+ | {
155
+ readonly type: 'insertConditionalBranch';
156
+ readonly label?: string;
157
+ readonly anchor?: WorkflowNodeAnchor;
158
+ };
159
+
160
+ export type AnyWorkflowCommand = WorkflowCommand | WorkflowMacroCommand;
161
+
162
+ /** Side-effect hints for the React controller (pixel layout/viewport). */
163
+ export interface WorkflowCommandEffects {
164
+ /** Topology changed — controller should re-run dagre layout + viewport. */
165
+ readonly layout: boolean;
166
+ }
167
+
168
+ export interface WorkflowCommandResult {
169
+ readonly state: WorkflowDesignerState;
170
+ /** Whether the command actually changed state. */
171
+ readonly changed: boolean;
172
+ /** Human-facing reason the command was a no-op / rejected, else null. */
173
+ readonly error: string | null;
174
+ /** Post-apply validation issue (same text the Save button uses), else null. */
175
+ readonly issue: string | null;
176
+ readonly effects: WorkflowCommandEffects;
177
+ }
178
+
179
+ export interface WorkflowCommandOptions {
180
+ /** Injectable edge id factory for deterministic runs (tests, replay). */
181
+ readonly createEdgeId?: WorkflowEdgeIdFactory;
182
+ }
183
+
184
+ const NO_EFFECTS: WorkflowCommandEffects = { layout: false };
185
+ const LAYOUT_EFFECTS: WorkflowCommandEffects = { layout: true };
186
+
187
+ function finalize(
188
+ state: WorkflowDesignerState,
189
+ changed: boolean,
190
+ effects: WorkflowCommandEffects,
191
+ error: string | null = null,
192
+ ): WorkflowCommandResult {
193
+ return {
194
+ changed,
195
+ effects,
196
+ error,
197
+ issue: readWorkflowDefinitionIssue(state.definition),
198
+ state,
199
+ };
200
+ }
201
+
202
+ function reject(
203
+ state: WorkflowDesignerState,
204
+ error: string,
205
+ ): WorkflowCommandResult {
206
+ return finalize(state, false, NO_EFFECTS, error);
207
+ }
208
+
209
+ /**
210
+ * Apply a single primitive command. Pure: same input → same output (given a
211
+ * deterministic edge id factory). Topology-changing commands set
212
+ * `effects.layout` so the controller can run dagre afterwards.
213
+ */
214
+ export function applyWorkflowCommand(
215
+ state: WorkflowDesignerState,
216
+ command: WorkflowCommand,
217
+ options: WorkflowCommandOptions = {},
218
+ ): WorkflowCommandResult {
219
+ const createEdgeId = options.createEdgeId ?? defaultWorkflowEdgeId;
220
+
221
+ switch (command.type) {
222
+ case 'addNode':
223
+ return applyAddNode(state, command, createEdgeId);
224
+ case 'renameNode':
225
+ return applyRenameNode(state, command);
226
+ case 'deleteNode':
227
+ return applyDeleteNode(state, command);
228
+ case 'setNodeTriggerMode':
229
+ return applySetNodeTriggerMode(state, command);
230
+ case 'setUserTaskApprover':
231
+ return applySetUserTaskApprover(state, command);
232
+ case 'setUserTaskReturnResubmitStrategy':
233
+ return applySetUserTaskReturnResubmitStrategy(state, command);
234
+ case 'setUserTaskOptions':
235
+ return applySetUserTaskOptions(state, command);
236
+ case 'setServiceAction':
237
+ return applySetServiceAction(state, command);
238
+ case 'setEndState':
239
+ return applySetEndState(state, command);
240
+ case 'connectEdge':
241
+ return applyConnectEdge(state, command, createEdgeId);
242
+ case 'deleteEdge':
243
+ return applyDeleteEdge(state, command);
244
+ case 'setEdgeCondition':
245
+ return applySetEdgeCondition(state, command);
246
+ case 'setEdgeDefault':
247
+ return applySetEdgeDefault(state, command);
248
+ case 'bindForm':
249
+ return applyBindForm(state, command);
250
+ case 'setInitiatorPolicyCel':
251
+ return applySetInitiatorPolicyCel(state, command);
252
+ case 'selectNode':
253
+ return finalize(
254
+ { ...state, selectedNodeId: command.nodeId, selectedEdgeIds: [] },
255
+ true,
256
+ NO_EFFECTS,
257
+ );
258
+ case 'selectEdges':
259
+ return finalize(
260
+ { ...state, selectedEdgeIds: command.edgeIds, selectedNodeId: null },
261
+ true,
262
+ NO_EFFECTS,
263
+ );
264
+ case 'autoLayout':
265
+ return finalize(state, true, LAYOUT_EFFECTS);
266
+ default:
267
+ return assertNever(command);
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Apply a high-level macro by expanding it into primitive commands. The added
273
+ * node's id is deterministic (`<nodeType>_<nextIndex>`), so follow-up commands
274
+ * can target it without depending on the post-insert selection.
275
+ */
276
+ export function applyWorkflowMacroCommand(
277
+ state: WorkflowDesignerState,
278
+ command: WorkflowMacroCommand,
279
+ options: WorkflowCommandOptions = {},
280
+ ): WorkflowCommandResult {
281
+ return applyWorkflowCommands(
282
+ state,
283
+ expandMacroCommand(state, command),
284
+ options,
285
+ );
286
+ }
287
+
288
+ /** Apply a batch of primitive commands in order, threading state. */
289
+ export function applyWorkflowCommands(
290
+ state: WorkflowDesignerState,
291
+ commands: readonly WorkflowCommand[],
292
+ options: WorkflowCommandOptions = {},
293
+ ): WorkflowCommandResult {
294
+ return commands.reduce<WorkflowCommandResult>(
295
+ (accumulator, command) => {
296
+ const next = applyWorkflowCommand(accumulator.state, command, options);
297
+
298
+ return {
299
+ ...next,
300
+ changed: accumulator.changed || next.changed,
301
+ effects: {
302
+ layout: accumulator.effects.layout || next.effects.layout,
303
+ },
304
+ // Keep the first hard error encountered.
305
+ error: accumulator.error ?? next.error,
306
+ };
307
+ },
308
+ finalize(state, false, NO_EFFECTS),
309
+ );
310
+ }
311
+
312
+ /**
313
+ * Expand a high-level macro into the primitive command sequence that realises
314
+ * it. The next node id is derived from the same deterministic rule
315
+ * {@link applyAddNode} uses, so post-add commands can target it directly.
316
+ */
317
+ export function expandMacroCommand(
318
+ state: WorkflowDesignerState,
319
+ command: WorkflowMacroCommand,
320
+ ): readonly WorkflowCommand[] {
321
+ if (command.type === 'insertApprovalStep') {
322
+ const nodeId = readNextWorkflowNodeId(state, 'userTask');
323
+
324
+ return [
325
+ { type: 'addNode', nodeType: 'userTask', anchor: command.anchor },
326
+ {
327
+ type: 'setUserTaskApprover',
328
+ nodeId,
329
+ approverResolver: command.approverResolver,
330
+ },
331
+ ...(command.label === undefined
332
+ ? []
333
+ : [{ type: 'renameNode', nodeId, label: command.label } as const]),
334
+ ];
335
+ }
336
+
337
+ if (command.type === 'insertNotification') {
338
+ const nodeId = readNextWorkflowNodeId(state, 'serviceTask');
339
+
340
+ return [
341
+ { type: 'addNode', nodeType: 'serviceTask', anchor: command.anchor },
342
+ { type: 'setServiceAction', nodeId, action: command.action },
343
+ ...(command.label === undefined
344
+ ? []
345
+ : [{ type: 'renameNode', nodeId, label: command.label } as const]),
346
+ ];
347
+ }
348
+
349
+ const nodeId = readNextWorkflowNodeId(state, 'exclusiveGateway');
350
+
351
+ return [
352
+ { type: 'addNode', nodeType: 'exclusiveGateway', anchor: command.anchor },
353
+ ...(command.label === undefined
354
+ ? []
355
+ : [{ type: 'renameNode', nodeId, label: command.label } as const]),
356
+ ];
357
+ }
358
+
359
+ /** The id {@link applyAddNode} will assign to the next node of this type. */
360
+ function readNextWorkflowNodeId(
361
+ state: WorkflowDesignerState,
362
+ nodeType: NodePaletteType,
363
+ ): string {
364
+ return `${nodeType}_${readNextWorkflowNodeIndex(
365
+ state.definition.nodes,
366
+ nodeType,
367
+ )}`;
368
+ }
369
+
370
+ // ── Node command handlers ──────────────────────────────────────────────────
371
+
372
+ function applyAddNode(
373
+ state: WorkflowDesignerState,
374
+ command: Extract<WorkflowCommand, { type: 'addNode' }>,
375
+ createEdgeId: WorkflowEdgeIdFactory,
376
+ ): WorkflowCommandResult {
377
+ const nodeIndex = readNextWorkflowNodeIndex(
378
+ state.definition.nodes,
379
+ command.nodeType,
380
+ );
381
+ const node = createWorkflowNode(command.nodeType, nodeIndex);
382
+ const selectedEdgeId =
383
+ command.anchor?.edgeId ??
384
+ (state.selectedEdgeIds.length === 1 ? state.selectedEdgeIds[0] : null);
385
+ const selectedNodeId =
386
+ command.anchor?.afterNodeId ??
387
+ (command.anchor?.edgeId ? null : state.selectedNodeId);
388
+ const inserted = insertWorkflowNodeIntoDefinition({
389
+ createId: createEdgeId,
390
+ definition: state.definition,
391
+ node,
392
+ selectedEdgeId,
393
+ selectedNodeId,
394
+ });
395
+
396
+ return finalize(
397
+ {
398
+ ...state,
399
+ definition: normalizeDesignerWorkflowDefinition(inserted.definition),
400
+ editingEdgeId: inserted.editingEdgeId,
401
+ selectedEdgeIds: inserted.selectedEdgeIds,
402
+ selectedNodeId: inserted.selectedNodeId,
403
+ },
404
+ true,
405
+ LAYOUT_EFFECTS,
406
+ );
407
+ }
408
+
409
+ function applyRenameNode(
410
+ state: WorkflowDesignerState,
411
+ command: Extract<WorkflowCommand, { type: 'renameNode' }>,
412
+ ): WorkflowCommandResult {
413
+ return mapNode(state, command.nodeId, (node) =>
414
+ renameWorkflowNode(node, command.label),
415
+ );
416
+ }
417
+
418
+ function applyDeleteNode(
419
+ state: WorkflowDesignerState,
420
+ command: Extract<WorkflowCommand, { type: 'deleteNode' }>,
421
+ ): WorkflowCommandResult {
422
+ const node = state.definition.nodes.find(
423
+ (candidate) => candidate.id === command.nodeId,
424
+ );
425
+
426
+ if (!node) {
427
+ return reject(state, `找不到節點:${command.nodeId}`);
428
+ }
429
+
430
+ if (!isWorkflowNodeRemovable(node)) {
431
+ return reject(state, '開始與結束節點無法刪除。');
432
+ }
433
+
434
+ const definition = normalizeDesignerWorkflowDefinition({
435
+ ...state.definition,
436
+ edges: state.definition.edges.filter(
437
+ (edge) => edge.source !== node.id && edge.target !== node.id,
438
+ ),
439
+ nodes: state.definition.nodes.filter(
440
+ (candidate) => candidate.id !== node.id,
441
+ ),
442
+ });
443
+
444
+ // Deleting preserves the remaining nodes' manual positions (matches the
445
+ // original designer); tidying is an explicit `autoLayout` step.
446
+ return finalize(
447
+ {
448
+ ...state,
449
+ definition,
450
+ editingEdgeId: null,
451
+ selectedEdgeIds: [],
452
+ selectedNodeId: 'start',
453
+ },
454
+ true,
455
+ NO_EFFECTS,
456
+ );
457
+ }
458
+
459
+ function applySetNodeTriggerMode(
460
+ state: WorkflowDesignerState,
461
+ command: Extract<WorkflowCommand, { type: 'setNodeTriggerMode' }>,
462
+ ): WorkflowCommandResult {
463
+ const node = state.definition.nodes.find((n) => n.id === command.nodeId);
464
+
465
+ if (node?.type === 'startEvent') {
466
+ return reject(state, '開始節點沒有觸發模式。');
467
+ }
468
+
469
+ return mapNode(state, command.nodeId, (target) =>
470
+ applyWorkflowNodeTriggerMode(target, command.triggerMode),
471
+ );
472
+ }
473
+
474
+ function applySetUserTaskApprover(
475
+ state: WorkflowDesignerState,
476
+ command: Extract<WorkflowCommand, { type: 'setUserTaskApprover' }>,
477
+ ): WorkflowCommandResult {
478
+ return mapNode(
479
+ state,
480
+ command.nodeId,
481
+ (node) =>
482
+ node.type === 'userTask'
483
+ ? {
484
+ ...node,
485
+ data: {
486
+ ...node.data,
487
+ approverResolver: command.approverResolver,
488
+ decisionPolicy: { type: 'SINGLE' },
489
+ },
490
+ }
491
+ : node,
492
+ 'userTask',
493
+ );
494
+ }
495
+
496
+ function applySetUserTaskReturnResubmitStrategy(
497
+ state: WorkflowDesignerState,
498
+ command: Extract<
499
+ WorkflowCommand,
500
+ { type: 'setUserTaskReturnResubmitStrategy' }
501
+ >,
502
+ ): WorkflowCommandResult {
503
+ return mapNode(
504
+ state,
505
+ command.nodeId,
506
+ (node) =>
507
+ node.type === 'userTask'
508
+ ? {
509
+ ...node,
510
+ data: {
511
+ ...node.data,
512
+ returnBehavior: {
513
+ ...node.data.returnBehavior,
514
+ resubmitStrategy: command.resubmitStrategy,
515
+ },
516
+ },
517
+ }
518
+ : node,
519
+ 'userTask',
520
+ );
521
+ }
522
+
523
+ function applySetUserTaskOptions(
524
+ state: WorkflowDesignerState,
525
+ command: Extract<WorkflowCommand, { type: 'setUserTaskOptions' }>,
526
+ ): WorkflowCommandResult {
527
+ return mapNode(
528
+ state,
529
+ command.nodeId,
530
+ (node) =>
531
+ node.type === 'userTask'
532
+ ? {
533
+ ...node,
534
+ data: {
535
+ ...node.data,
536
+ ...(command.allowAddSigner === undefined
537
+ ? {}
538
+ : { allowAddSigner: command.allowAddSigner }),
539
+ ...(command.allowReject === undefined
540
+ ? {}
541
+ : { allowReject: command.allowReject }),
542
+ ...(command.allowTransfer === undefined
543
+ ? {}
544
+ : { allowTransfer: command.allowTransfer }),
545
+ ...(command.description === undefined
546
+ ? {}
547
+ : { description: command.description }),
548
+ },
549
+ }
550
+ : node,
551
+ 'userTask',
552
+ );
553
+ }
554
+
555
+ function applySetServiceAction(
556
+ state: WorkflowDesignerState,
557
+ command: Extract<WorkflowCommand, { type: 'setServiceAction' }>,
558
+ ): WorkflowCommandResult {
559
+ const result = mapNode(
560
+ state,
561
+ command.nodeId,
562
+ (node) =>
563
+ node.type === 'serviceTask'
564
+ ? { ...node, data: { ...node.data, action: command.action } }
565
+ : node,
566
+ 'serviceTask',
567
+ );
568
+
569
+ if (!result.changed) {
570
+ return result;
571
+ }
572
+
573
+ // Switching a service task to async NOTIFY drops its outgoing edges
574
+ // (normalizeDesignerWorkflowDefinition). The original designer did not
575
+ // relayout here, so neither do we — positions are preserved.
576
+ return finalize(
577
+ {
578
+ ...result.state,
579
+ definition: normalizeDesignerWorkflowDefinition(result.state.definition),
580
+ },
581
+ true,
582
+ NO_EFFECTS,
583
+ );
584
+ }
585
+
586
+ function applySetEndState(
587
+ state: WorkflowDesignerState,
588
+ command: Extract<WorkflowCommand, { type: 'setEndState' }>,
589
+ ): WorkflowCommandResult {
590
+ return mapNode(
591
+ state,
592
+ command.nodeId,
593
+ (node) =>
594
+ node.type === 'endEvent'
595
+ ? { ...node, data: { ...node.data, endState: command.endState } }
596
+ : node,
597
+ 'endEvent',
598
+ );
599
+ }
600
+
601
+ // ── Edge command handlers ──────────────────────────────────────────────────
602
+
603
+ function applyConnectEdge(
604
+ state: WorkflowDesignerState,
605
+ command: Extract<WorkflowCommand, { type: 'connectEdge' }>,
606
+ createEdgeId: WorkflowEdgeIdFactory,
607
+ ): WorkflowCommandResult {
608
+ const connection = {
609
+ source: command.source,
610
+ sourceHandle: WORKFLOW_OUTPUT_HANDLE_ID,
611
+ target: command.target,
612
+ targetHandle: WORKFLOW_INPUT_HANDLE_ID,
613
+ };
614
+
615
+ if (!isWorkflowConnectionValid(connection, state.definition.nodes)) {
616
+ return reject(
617
+ state,
618
+ `無法連線:${command.source} → ${command.target}(來源/目標不可連接或不存在)。`,
619
+ );
620
+ }
621
+
622
+ const shouldOpenConditionSettings = state.definition.nodes.some(
623
+ (node) => node.id === command.source && node.type === 'exclusiveGateway',
624
+ );
625
+ const nextEdge = createWorkflowEdge(
626
+ command.source,
627
+ command.target,
628
+ {},
629
+ createEdgeId,
630
+ );
631
+ const definition = normalizeDesignerWorkflowDefinition({
632
+ ...state.definition,
633
+ edges: [...state.definition.edges, nextEdge],
634
+ });
635
+
636
+ // Connecting two already-positioned nodes preserves their manual layout
637
+ // (matches the original designer); the LLM/user can call `autoLayout` to tidy.
638
+ return finalize(
639
+ {
640
+ ...state,
641
+ definition,
642
+ editingEdgeId: shouldOpenConditionSettings ? nextEdge.id : null,
643
+ selectedEdgeIds: [nextEdge.id],
644
+ selectedNodeId: null,
645
+ },
646
+ true,
647
+ NO_EFFECTS,
648
+ );
649
+ }
650
+
651
+ function applyDeleteEdge(
652
+ state: WorkflowDesignerState,
653
+ command: Extract<WorkflowCommand, { type: 'deleteEdge' }>,
654
+ ): WorkflowCommandResult {
655
+ if (!state.definition.edges.some((edge) => edge.id === command.edgeId)) {
656
+ return reject(state, `找不到連線:${command.edgeId}`);
657
+ }
658
+
659
+ const definition = normalizeDesignerWorkflowDefinition({
660
+ ...state.definition,
661
+ edges: state.definition.edges.filter(
662
+ (edge) => edge.id !== command.edgeId,
663
+ ),
664
+ });
665
+
666
+ return finalize(
667
+ {
668
+ ...state,
669
+ definition,
670
+ editingEdgeId:
671
+ state.editingEdgeId === command.edgeId ? null : state.editingEdgeId,
672
+ selectedEdgeIds: state.selectedEdgeIds.filter(
673
+ (edgeId) => edgeId !== command.edgeId,
674
+ ),
675
+ },
676
+ true,
677
+ NO_EFFECTS,
678
+ );
679
+ }
680
+
681
+ function applySetEdgeCondition(
682
+ state: WorkflowDesignerState,
683
+ command: Extract<WorkflowCommand, { type: 'setEdgeCondition' }>,
684
+ ): WorkflowCommandResult {
685
+ const targetEdge = state.definition.edges.find(
686
+ (edge) => edge.id === command.edgeId,
687
+ );
688
+
689
+ if (!targetEdge) {
690
+ return reject(state, `找不到連線:${command.edgeId}`);
691
+ }
692
+
693
+ const schema = state.formSchema;
694
+ const nextFieldKey =
695
+ command.fieldKey ?? targetEdge.data.conditionFieldKey ?? null;
696
+ const field = readConditionField(schema, nextFieldKey);
697
+ const nextOperator = readNextConditionOperator(
698
+ field,
699
+ command.operator ?? targetEdge.data.conditionOperator ?? null,
700
+ );
701
+ const nextValue = readNextConditionValue(
702
+ field,
703
+ nextOperator,
704
+ command.value ?? targetEdge.data.conditionValue ?? null,
705
+ );
706
+ const nextLabel = readConditionLabel(field, nextOperator, nextValue);
707
+
708
+ const definition = {
709
+ ...state.definition,
710
+ edges: state.definition.edges.map((edge) =>
711
+ edge.id === command.edgeId
712
+ ? {
713
+ ...edge,
714
+ data: {
715
+ ...edge.data,
716
+ condition: readConditionExpression(field, nextOperator, nextValue),
717
+ conditionFieldKey: field?.fieldKey,
718
+ conditionOperator: nextOperator,
719
+ conditionValue: nextValue,
720
+ isDefault: false,
721
+ label: nextLabel,
722
+ },
723
+ }
724
+ : edge,
725
+ ),
726
+ };
727
+
728
+ return finalize({ ...state, definition }, true, NO_EFFECTS);
729
+ }
730
+
731
+ function applySetEdgeDefault(
732
+ state: WorkflowDesignerState,
733
+ command: Extract<WorkflowCommand, { type: 'setEdgeDefault' }>,
734
+ ): WorkflowCommandResult {
735
+ const targetEdge = state.definition.edges.find(
736
+ (edge) => edge.id === command.edgeId,
737
+ );
738
+
739
+ if (!targetEdge) {
740
+ return reject(state, `找不到連線:${command.edgeId}`);
741
+ }
742
+
743
+ const definition = {
744
+ ...state.definition,
745
+ edges: state.definition.edges.map((edge) => {
746
+ if (
747
+ command.isDefault &&
748
+ edge.source === targetEdge.source &&
749
+ edge.id !== targetEdge.id &&
750
+ isExclusiveGatewaySourceEdge(edge, state.definition.nodes)
751
+ ) {
752
+ return { ...edge, data: { ...edge.data, isDefault: false } };
753
+ }
754
+
755
+ if (edge.id !== targetEdge.id) {
756
+ return edge;
757
+ }
758
+
759
+ // Becoming the default ("其他情況") means it carries no condition; clear
760
+ // any stale condition so the edge reads cleanly as the else path.
761
+ return command.isDefault
762
+ ? {
763
+ ...edge,
764
+ data: {
765
+ ...edge.data,
766
+ condition: undefined,
767
+ conditionFieldKey: undefined,
768
+ conditionOperator: undefined,
769
+ conditionValue: undefined,
770
+ isDefault: true,
771
+ label: undefined,
772
+ },
773
+ }
774
+ : { ...edge, data: { ...edge.data, isDefault: false } };
775
+ }),
776
+ };
777
+
778
+ return finalize({ ...state, definition }, true, NO_EFFECTS);
779
+ }
780
+
781
+ // ── Form / policy handlers ─────────────────────────────────────────────────
782
+
783
+ function applyBindForm(
784
+ state: WorkflowDesignerState,
785
+ command: Extract<WorkflowCommand, { type: 'bindForm' }>,
786
+ ): WorkflowCommandResult {
787
+ return finalize(
788
+ {
789
+ ...state,
790
+ formDefinitionVersionId: command.formDefinitionVersionId,
791
+ formSchema: command.formSchema,
792
+ },
793
+ true,
794
+ NO_EFFECTS,
795
+ );
796
+ }
797
+
798
+ function applySetInitiatorPolicyCel(
799
+ state: WorkflowDesignerState,
800
+ command: Extract<WorkflowCommand, { type: 'setInitiatorPolicyCel' }>,
801
+ ): WorkflowCommandResult {
802
+ return finalize(
803
+ { ...state, initiatorPolicyCel: command.cel },
804
+ true,
805
+ NO_EFFECTS,
806
+ );
807
+ }
808
+
809
+ // ── Helpers ────────────────────────────────────────────────────────────────
810
+
811
+ function mapNode(
812
+ state: WorkflowDesignerState,
813
+ nodeId: string,
814
+ updater: (
815
+ node: WorkflowDefinition['nodes'][number],
816
+ ) => WorkflowDefinition['nodes'][number],
817
+ expectedType?: WorkflowDefinition['nodes'][number]['type'],
818
+ ): WorkflowCommandResult {
819
+ const node = state.definition.nodes.find(
820
+ (candidate) => candidate.id === nodeId,
821
+ );
822
+
823
+ if (!node) {
824
+ return reject(state, `找不到節點:${nodeId}`);
825
+ }
826
+
827
+ if (expectedType && node.type !== expectedType) {
828
+ return reject(
829
+ state,
830
+ `節點 ${nodeId} 的型別為 ${node.type},預期為 ${expectedType}。`,
831
+ );
832
+ }
833
+
834
+ const definition = {
835
+ ...state.definition,
836
+ nodes: state.definition.nodes.map((candidate) =>
837
+ candidate.id === nodeId ? updater(candidate) : candidate,
838
+ ),
839
+ };
840
+
841
+ return finalize({ ...state, definition }, true, NO_EFFECTS);
842
+ }
843
+
844
+ function assertNever(command: never): never {
845
+ throw new Error(
846
+ `未支援的 workflow command:${JSON.stringify(command)}`,
847
+ );
848
+ }