@rytass/bpm-core-shared 0.4.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 +7 -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,1083 @@
1
+ import { FormDefinitionSchema } from './form';
2
+ import {
3
+ ApproverResolver,
4
+ ReturnResubmitStrategy,
5
+ ServiceAction,
6
+ WorkflowEdge,
7
+ WorkflowEdgeConditionOperator,
8
+ WorkflowNode,
9
+ WorkflowNodeTriggerMode,
10
+ } from './workflow';
11
+ import {
12
+ CONDITION_OPERATOR_OPTIONS,
13
+ WORKFLOW_NODE_TYPE_LABELS,
14
+ isExclusiveGatewaySourceEdge,
15
+ readWorkflowDefinitionIssue,
16
+ } from './workflow-graph';
17
+ import {
18
+ WorkflowCommand,
19
+ WorkflowCommandOptions,
20
+ WorkflowCommandResult,
21
+ WorkflowDesignerState,
22
+ WorkflowMacroCommand,
23
+ applyWorkflowCommand,
24
+ applyWorkflowMacroCommand,
25
+ } from './workflow-command';
26
+
27
+ export type { AnyWorkflowCommand } from './workflow-command';
28
+
29
+ /**
30
+ * The LLM-facing toolset for the template designer. It exposes every workflow
31
+ * mutation as a provider-agnostic tool (JSON Schema input), plus read-only
32
+ * query tools so the assistant can inspect the graph before acting.
33
+ *
34
+ * The same {@link applyWorkflowCommand} reducer powers both the React UI and
35
+ * this toolset — an LLM driving these tools can do exactly what a user can do
36
+ * on the canvas, node palette, and property form.
37
+ *
38
+ * Usage (Anthropic / AI SDK / OpenAI tool-calling are all compatible):
39
+ * 1. Advertise `WORKFLOW_TOOLSET` to the model as its tools.
40
+ * 2. On each tool call, run `executeWorkflowTool(state, name, input, opts)`.
41
+ * 3. For mutations, persist `result.state`; if `result.effects.layout`, run
42
+ * dagre layout (in the React controller); return `snapshot` to the model.
43
+ */
44
+
45
+ /** A minimal JSON Schema object (provider-agnostic tool input contract). */
46
+ export type JsonSchema = Readonly<Record<string, unknown>>;
47
+
48
+ export type WorkflowToolKind = 'mutation' | 'macro' | 'query';
49
+
50
+ export interface WorkflowTool {
51
+ readonly name: string;
52
+ readonly description: string;
53
+ readonly inputSchema: JsonSchema;
54
+ readonly kind: WorkflowToolKind;
55
+ }
56
+
57
+ // ── Reusable schema fragments ──────────────────────────────────────────────
58
+
59
+ const NODE_TYPE_ENUM = ['userTask', 'serviceTask', 'exclusiveGateway'] as const;
60
+ const TRIGGER_MODE_ENUM: readonly WorkflowNodeTriggerMode[] = ['AND', 'OR'];
61
+ const RESUBMIT_STRATEGY_ENUM: readonly ReturnResubmitStrategy[] = [
62
+ 'FROM_RETURN_POINT',
63
+ 'RESTART',
64
+ ];
65
+ const CONDITION_OPERATOR_ENUM: readonly WorkflowEdgeConditionOperator[] =
66
+ CONDITION_OPERATOR_OPTIONS.map((option) => option.id);
67
+
68
+ const APPROVER_RESOLVER_SCHEMA: JsonSchema = {
69
+ description:
70
+ '簽核人員解析策略,以 type 區分。DIRECT: { type, memberIds[] };POSITION: { type, positionId };' +
71
+ 'ORG_UNIT_MEMBER: { type, orgUnitId, includeDescendants? };ORG_UNIT_POSITION: { type, orgUnitId, positionId, includeDescendants? };' +
72
+ 'ORG_MANAGER: { type, baseFromInitiator, levelsUp, fallback? };ORG_UNIT_MANAGER: { type, orgUnitId, fallback? };' +
73
+ 'DYNAMIC_FORM: { type, formPath };EXPRESSION: { type, expression }。',
74
+ properties: {
75
+ type: {
76
+ enum: [
77
+ 'DIRECT',
78
+ 'POSITION',
79
+ 'ORG_UNIT_MEMBER',
80
+ 'ORG_UNIT_POSITION',
81
+ 'ORG_MANAGER',
82
+ 'ORG_UNIT_MANAGER',
83
+ 'DYNAMIC_FORM',
84
+ 'EXPRESSION',
85
+ ],
86
+ type: 'string',
87
+ },
88
+ },
89
+ required: ['type'],
90
+ type: 'object',
91
+ };
92
+
93
+ const SERVICE_ACTION_SCHEMA: JsonSchema = {
94
+ description:
95
+ '系統節點動作,以 type 區分。NOTIFY: { type, channels[], recipients(ApproverResolver), template? };' +
96
+ 'WEBHOOK: { type, url, headers?, payload? };SET_FORM_FIELD: { type, fieldPath, value }。',
97
+ properties: {
98
+ type: { enum: ['NOTIFY', 'WEBHOOK', 'SET_FORM_FIELD'], type: 'string' },
99
+ },
100
+ required: ['type'],
101
+ type: 'object',
102
+ };
103
+
104
+ function objectSchema(
105
+ properties: Readonly<Record<string, JsonSchema>>,
106
+ required: readonly string[],
107
+ ): JsonSchema {
108
+ return { additionalProperties: false, properties, required, type: 'object' };
109
+ }
110
+
111
+ // ── Tool catalog ───────────────────────────────────────────────────────────
112
+
113
+ export const WORKFLOW_TOOLSET: readonly WorkflowTool[] = [
114
+ {
115
+ name: 'add_node',
116
+ description:
117
+ '在流程中新增一個節點(簽核/系統/條件分流)。可選擇插入錨點:anchorEdgeId 會把節點插入該連線中間;anchorAfterNodeId 會接在該節點之後;皆未指定時沿用目前選取或追加到末端。',
118
+ inputSchema: objectSchema(
119
+ {
120
+ nodeType: { enum: [...NODE_TYPE_ENUM], type: 'string' },
121
+ anchorEdgeId: { type: ['string', 'null'] },
122
+ anchorAfterNodeId: { type: ['string', 'null'] },
123
+ },
124
+ ['nodeType'],
125
+ ),
126
+ kind: 'mutation',
127
+ },
128
+ {
129
+ name: 'rename_node',
130
+ description: '修改節點顯示名稱。',
131
+ inputSchema: objectSchema(
132
+ { nodeId: { type: 'string' }, label: { type: 'string' } },
133
+ ['nodeId', 'label'],
134
+ ),
135
+ kind: 'mutation',
136
+ },
137
+ {
138
+ name: 'delete_node',
139
+ description: '刪除節點及其相連的連線(開始/結束節點不可刪除)。',
140
+ inputSchema: objectSchema({ nodeId: { type: 'string' } }, ['nodeId']),
141
+ kind: 'mutation',
142
+ },
143
+ {
144
+ name: 'set_node_trigger_mode',
145
+ description:
146
+ '設定節點觸發模式:AND=所有前置完成才觸發;OR=任一前置完成即觸發(僅多重輸入時有意義)。',
147
+ inputSchema: objectSchema(
148
+ {
149
+ nodeId: { type: 'string' },
150
+ triggerMode: { enum: [...TRIGGER_MODE_ENUM], type: 'string' },
151
+ },
152
+ ['nodeId', 'triggerMode'],
153
+ ),
154
+ kind: 'mutation',
155
+ },
156
+ {
157
+ name: 'set_user_task_approver',
158
+ description:
159
+ '設定簽核節點的簽核人員(會將決策政策重設為 SINGLE)。approverResolver 可省略或不完整 —— 此時預設為「直屬主管」(ORG_MANAGER,往上 1 層)。',
160
+ inputSchema: objectSchema(
161
+ { nodeId: { type: 'string' }, approverResolver: APPROVER_RESOLVER_SCHEMA },
162
+ ['nodeId'],
163
+ ),
164
+ kind: 'mutation',
165
+ },
166
+ {
167
+ name: 'set_user_task_options',
168
+ description:
169
+ '設定簽核節點的選項開關與描述:是否允許駁回/轉簽/加簽,以及節點描述。只更新有提供的欄位。',
170
+ inputSchema: objectSchema(
171
+ {
172
+ nodeId: { type: 'string' },
173
+ allowReject: { type: 'boolean' },
174
+ allowTransfer: { type: 'boolean' },
175
+ allowAddSigner: { type: 'boolean' },
176
+ description: { type: 'string' },
177
+ },
178
+ ['nodeId'],
179
+ ),
180
+ kind: 'mutation',
181
+ },
182
+ {
183
+ name: 'set_user_task_return_resubmit_strategy',
184
+ description:
185
+ '設定簽核節點退回後的重送策略:RESTART=從頭重跑;FROM_RETURN_POINT=回到退回節點。',
186
+ inputSchema: objectSchema(
187
+ {
188
+ nodeId: { type: 'string' },
189
+ resubmitStrategy: { enum: [...RESUBMIT_STRATEGY_ENUM], type: 'string' },
190
+ },
191
+ ['nodeId', 'resubmitStrategy'],
192
+ ),
193
+ kind: 'mutation',
194
+ },
195
+ {
196
+ name: 'set_service_action',
197
+ description: '設定系統節點的動作(NOTIFY 知會 / WEBHOOK / SET_FORM_FIELD)。',
198
+ inputSchema: objectSchema(
199
+ { nodeId: { type: 'string' }, action: SERVICE_ACTION_SCHEMA },
200
+ ['nodeId', 'action'],
201
+ ),
202
+ kind: 'mutation',
203
+ },
204
+ {
205
+ name: 'set_end_state',
206
+ description: '設定結束節點的最終狀態(APPROVED / REJECTED)。',
207
+ inputSchema: objectSchema(
208
+ {
209
+ nodeId: { type: 'string' },
210
+ endState: { enum: ['APPROVED', 'REJECTED'], type: 'string' },
211
+ },
212
+ ['nodeId', 'endState'],
213
+ ),
214
+ kind: 'mutation',
215
+ },
216
+ {
217
+ name: 'connect_edge',
218
+ description:
219
+ '在兩個節點之間建立連線(source → target)。若 source 不可輸出或 target 不可輸入則拒絕。',
220
+ inputSchema: objectSchema(
221
+ { source: { type: 'string' }, target: { type: 'string' } },
222
+ ['source', 'target'],
223
+ ),
224
+ kind: 'mutation',
225
+ },
226
+ {
227
+ name: 'delete_edge',
228
+ description: '刪除一條連線。',
229
+ inputSchema: objectSchema({ edgeId: { type: 'string' } }, ['edgeId']),
230
+ kind: 'mutation',
231
+ },
232
+ {
233
+ name: 'set_edge_condition',
234
+ description:
235
+ '設定條件分流輸出連線的條件(依綁定表單欄位)。fieldKey 為表單欄位、operator 為比較運算子、value 為比較值;系統會自動編譯為 CEL 條件並產生標籤。需先綁定表單。',
236
+ inputSchema: objectSchema(
237
+ {
238
+ edgeId: { type: 'string' },
239
+ fieldKey: { type: ['string', 'null'] },
240
+ operator: { enum: [...CONDITION_OPERATOR_ENUM, null], type: ['string', 'null'] },
241
+ value: { type: ['string', 'null'] },
242
+ },
243
+ ['edgeId'],
244
+ ),
245
+ kind: 'mutation',
246
+ },
247
+ {
248
+ name: 'set_edge_default',
249
+ description:
250
+ '將條件分流的某條輸出連線設為(或取消)預設路徑;設為預設時會自動取消同源其他連線的預設。',
251
+ inputSchema: objectSchema(
252
+ { edgeId: { type: 'string' }, isDefault: { type: 'boolean' } },
253
+ ['edgeId', 'isDefault'],
254
+ ),
255
+ kind: 'mutation',
256
+ },
257
+ {
258
+ name: 'set_initiator_policy_cel',
259
+ description: '直接設定發起人政策的 CEL 條件式(傳 null 代表清除/不限制)。',
260
+ inputSchema: objectSchema({ cel: { type: ['string', 'null'] } }, ['cel']),
261
+ kind: 'mutation',
262
+ },
263
+ {
264
+ name: 'auto_layout',
265
+ description: '對整個流程套用自動排版(由前端 dagre 重新計算節點座標)。',
266
+ inputSchema: objectSchema({}, []),
267
+ kind: 'mutation',
268
+ },
269
+ // ── Macros ──
270
+ {
271
+ name: 'insert_approval_step',
272
+ description:
273
+ '高階:新增一個簽核關卡。approverResolver 可省略 —— 省略或資料不全時,預設為「直屬主管」(ORG_MANAGER,從申請人往上 1 層),不需要真實會員 id。畫初稿時建議直接省略簽核人、先把流程結構建出來。',
274
+ inputSchema: objectSchema(
275
+ {
276
+ approverResolver: APPROVER_RESOLVER_SCHEMA,
277
+ label: { type: 'string' },
278
+ anchorEdgeId: { type: ['string', 'null'] },
279
+ anchorAfterNodeId: { type: ['string', 'null'] },
280
+ },
281
+ [],
282
+ ),
283
+ kind: 'macro',
284
+ },
285
+ {
286
+ name: 'insert_notification',
287
+ description:
288
+ '高階:新增一個系統通知關卡。action 可省略 —— 省略或資料不全時,預設為站內通知(NOTIFY / IN_APP / 收件者為直屬主管)。',
289
+ inputSchema: objectSchema(
290
+ {
291
+ action: SERVICE_ACTION_SCHEMA,
292
+ label: { type: 'string' },
293
+ anchorEdgeId: { type: ['string', 'null'] },
294
+ anchorAfterNodeId: { type: ['string', 'null'] },
295
+ },
296
+ [],
297
+ ),
298
+ kind: 'macro',
299
+ },
300
+ {
301
+ name: 'insert_conditional_branch',
302
+ description: '高階:新增一個條件分流節點(可選 label 與插入錨點)。',
303
+ inputSchema: objectSchema(
304
+ {
305
+ label: { type: 'string' },
306
+ anchorEdgeId: { type: ['string', 'null'] },
307
+ anchorAfterNodeId: { type: ['string', 'null'] },
308
+ },
309
+ [],
310
+ ),
311
+ kind: 'macro',
312
+ },
313
+ // ── Queries ──
314
+ {
315
+ name: 'get_workflow_snapshot',
316
+ description:
317
+ '讀取目前流程的精簡快照:所有節點、連線、選取狀態、表單綁定、發起人政策、以及驗證問題。動作前應先呼叫以了解現況。',
318
+ inputSchema: objectSchema({}, []),
319
+ kind: 'query',
320
+ },
321
+ {
322
+ name: 'describe_node_types',
323
+ description: '取得可用節點型別與其資料結構說明。',
324
+ inputSchema: objectSchema({}, []),
325
+ kind: 'query',
326
+ },
327
+ {
328
+ name: 'list_form_fields',
329
+ description: '列出目前綁定表單的欄位(fieldKey、型別、標籤),供設定條件分流條件時參考。',
330
+ inputSchema: objectSchema({}, []),
331
+ kind: 'query',
332
+ },
333
+ {
334
+ name: 'validate_workflow',
335
+ description: '檢查目前流程設定是否完整,回傳問題描述(無問題則為 null)。',
336
+ inputSchema: objectSchema({}, []),
337
+ kind: 'query',
338
+ },
339
+ {
340
+ name: 'list_org_units',
341
+ description:
342
+ '列出組織單位(id、名稱),供指定 ORG_UNIT_MEMBER / ORG_UNIT_POSITION / ORG_UNIT_MANAGER 簽核人時取得真實 orgUnitId。',
343
+ inputSchema: objectSchema({}, []),
344
+ kind: 'query',
345
+ },
346
+ {
347
+ name: 'list_positions',
348
+ description:
349
+ '列出職位(id、名稱),供指定 POSITION / ORG_UNIT_POSITION 簽核人時取得真實 positionId。',
350
+ inputSchema: objectSchema({}, []),
351
+ kind: 'query',
352
+ },
353
+ {
354
+ name: 'search_members',
355
+ description:
356
+ '依關鍵字搜尋會員(回傳 id、姓名、email),供指定 DIRECT 簽核人或通知收件者時取得真實 memberId。使用者點名某人時先用這個查到 id 再設定。',
357
+ inputSchema: objectSchema({ query: { type: 'string' } }, ['query']),
358
+ kind: 'query',
359
+ },
360
+ ];
361
+
362
+ /** Quick lookup of a tool definition by name. */
363
+ export const WORKFLOW_TOOL_BY_NAME: ReadonlyMap<string, WorkflowTool> = new Map(
364
+ WORKFLOW_TOOLSET.map((tool) => [tool.name, tool]),
365
+ );
366
+
367
+ // ── Snapshot (LLM-readable view of state) ──────────────────────────────────
368
+
369
+ export interface WorkflowNodeSnapshot {
370
+ readonly id: string;
371
+ readonly type: WorkflowNode['type'];
372
+ readonly typeLabel: string;
373
+ readonly label: string;
374
+ readonly summary: string;
375
+ }
376
+
377
+ export interface WorkflowEdgeSnapshot {
378
+ readonly id: string;
379
+ readonly source: string;
380
+ readonly target: string;
381
+ readonly isDefault: boolean;
382
+ readonly conditionLabel: string | null;
383
+ readonly condition: string | null;
384
+ }
385
+
386
+ export interface WorkflowSnapshot {
387
+ readonly nodes: readonly WorkflowNodeSnapshot[];
388
+ readonly edges: readonly WorkflowEdgeSnapshot[];
389
+ readonly selectedNodeId: string | null;
390
+ readonly selectedEdgeIds: readonly string[];
391
+ readonly formDefinitionVersionId: string | null;
392
+ readonly initiatorPolicyCel: string | null;
393
+ readonly issue: string | null;
394
+ }
395
+
396
+ export function readWorkflowSnapshot(
397
+ state: WorkflowDesignerState,
398
+ ): WorkflowSnapshot {
399
+ return {
400
+ edges: state.definition.edges.map((edge) =>
401
+ readEdgeSnapshot(edge, state.definition.nodes),
402
+ ),
403
+ formDefinitionVersionId: state.formDefinitionVersionId,
404
+ initiatorPolicyCel: state.initiatorPolicyCel,
405
+ issue: readWorkflowIssue(state),
406
+ nodes: state.definition.nodes.map(readNodeSnapshot),
407
+ selectedEdgeIds: state.selectedEdgeIds,
408
+ selectedNodeId: state.selectedNodeId,
409
+ };
410
+ }
411
+
412
+ function readWorkflowIssue(state: WorkflowDesignerState): string | null {
413
+ return readWorkflowDefinitionIssue(state.definition);
414
+ }
415
+
416
+ function readNodeSnapshot(node: WorkflowNode): WorkflowNodeSnapshot {
417
+ return {
418
+ id: node.id,
419
+ label: node.data.label,
420
+ summary: readNodeSummary(node),
421
+ type: node.type,
422
+ typeLabel: WORKFLOW_NODE_TYPE_LABELS[node.type],
423
+ };
424
+ }
425
+
426
+ function readNodeSummary(node: WorkflowNode): string {
427
+ if (node.type === 'userTask') {
428
+ return `簽核人=${node.data.approverResolver.type};觸發=${node.data.triggerMode ?? 'AND'}`;
429
+ }
430
+
431
+ if (node.type === 'serviceTask') {
432
+ return `動作=${node.data.action.type}`;
433
+ }
434
+
435
+ if (node.type === 'endEvent') {
436
+ return `結束狀態=${node.data.endState ?? 'APPROVED'}`;
437
+ }
438
+
439
+ if (node.type === 'exclusiveGateway' || node.type === 'parallelGateway') {
440
+ return `方向=${node.data.direction}`;
441
+ }
442
+
443
+ return '開始';
444
+ }
445
+
446
+ function readEdgeSnapshot(
447
+ edge: WorkflowEdge,
448
+ nodes: readonly WorkflowNode[],
449
+ ): WorkflowEdgeSnapshot {
450
+ return {
451
+ condition: edge.data.condition ?? null,
452
+ conditionLabel: edge.data.label ?? null,
453
+ id: edge.id,
454
+ isDefault: Boolean(
455
+ edge.data.isDefault && isExclusiveGatewaySourceEdge(edge, nodes),
456
+ ),
457
+ source: edge.source,
458
+ target: edge.target,
459
+ };
460
+ }
461
+
462
+ // ── Tool execution ─────────────────────────────────────────────────────────
463
+
464
+ export type WorkflowToolResult =
465
+ | {
466
+ readonly ok: true;
467
+ readonly kind: 'mutation' | 'macro';
468
+ readonly result: WorkflowCommandResult;
469
+ readonly snapshot: WorkflowSnapshot;
470
+ }
471
+ | { readonly ok: true; readonly kind: 'query'; readonly data: unknown }
472
+ | { readonly ok: false; readonly error: string };
473
+
474
+ export interface WorkflowDirectoryItem {
475
+ readonly id: string;
476
+ readonly name: string;
477
+ readonly email?: string;
478
+ }
479
+
480
+ /**
481
+ * Host-injected resolver for organisation data. Lets the directory query tools
482
+ * (`list_org_units` / `list_positions` / `search_members`) surface the real ids
483
+ * an LLM needs to assign concrete approvers — without the pure toolset knowing
484
+ * where the data comes from. The React designer backs this with its already
485
+ * loaded org/position data plus the BPM client's member search.
486
+ */
487
+ export interface WorkflowDirectory {
488
+ readonly listOrgUnits: () => Promise<readonly WorkflowDirectoryItem[]>;
489
+ readonly listPositions: () => Promise<readonly WorkflowDirectoryItem[]>;
490
+ readonly searchMembers: (
491
+ query: string,
492
+ ) => Promise<readonly WorkflowDirectoryItem[]>;
493
+ }
494
+
495
+ export interface ExecuteWorkflowToolOptions extends WorkflowCommandOptions {
496
+ /**
497
+ * Host-injected organisation directory. When absent, directory query tools
498
+ * report `available: false` so the assistant falls back to defaults.
499
+ */
500
+ readonly directory?: WorkflowDirectory;
501
+ /**
502
+ * Resolves a form version id to its schema (host-managed). Required only if
503
+ * a tool needs the bound form schema. Not used by the current toolset, which
504
+ * compiles conditions against the already-bound `state.formSchema`.
505
+ */
506
+ readonly resolveFormSchema?: (
507
+ formDefinitionVersionId: string,
508
+ ) => FormDefinitionSchema | null;
509
+ }
510
+
511
+ /**
512
+ * Execute a single LLM tool call against the designer state. Mutations and
513
+ * macros return the new state + a fresh snapshot; queries return read-only data.
514
+ * Async because directory query tools may fetch from the host (GraphQL).
515
+ */
516
+ export async function executeWorkflowTool(
517
+ state: WorkflowDesignerState,
518
+ toolName: string,
519
+ rawInput: unknown,
520
+ options: ExecuteWorkflowToolOptions = {},
521
+ ): Promise<WorkflowToolResult> {
522
+ const tool = WORKFLOW_TOOL_BY_NAME.get(toolName);
523
+
524
+ if (!tool) {
525
+ return { error: `未知的工具:${toolName}`, ok: false };
526
+ }
527
+
528
+ const input = isRecord(rawInput) ? rawInput : {};
529
+
530
+ try {
531
+ if (tool.kind === 'query') {
532
+ return {
533
+ data: await runQueryTool(state, toolName, input, options),
534
+ kind: 'query',
535
+ ok: true,
536
+ };
537
+ }
538
+
539
+ if (tool.kind === 'macro') {
540
+ const macro = buildMacroCommand(toolName, input);
541
+ const result = applyWorkflowMacroCommand(state, macro, options);
542
+
543
+ return finalizeMutation('macro', result);
544
+ }
545
+
546
+ const command = buildPrimitiveCommand(toolName, input);
547
+ const result = applyWorkflowCommand(state, command, options);
548
+
549
+ return finalizeMutation('mutation', result);
550
+ } catch (error: unknown) {
551
+ return { error: readToolError(error), ok: false };
552
+ }
553
+ }
554
+
555
+ function finalizeMutation(
556
+ kind: 'mutation' | 'macro',
557
+ result: WorkflowCommandResult,
558
+ ): WorkflowToolResult {
559
+ if (result.error) {
560
+ return { error: result.error, ok: false };
561
+ }
562
+
563
+ return {
564
+ kind,
565
+ ok: true,
566
+ result,
567
+ snapshot: readWorkflowSnapshot(result.state),
568
+ };
569
+ }
570
+
571
+ async function runQueryTool(
572
+ state: WorkflowDesignerState,
573
+ toolName: string,
574
+ input: Readonly<Record<string, unknown>>,
575
+ options: ExecuteWorkflowToolOptions,
576
+ ): Promise<unknown> {
577
+ if (toolName === 'get_workflow_snapshot') {
578
+ return readWorkflowSnapshot(state);
579
+ }
580
+
581
+ if (toolName === 'describe_node_types') {
582
+ return readNodeTypeCatalog();
583
+ }
584
+
585
+ if (toolName === 'list_form_fields') {
586
+ return readFormFieldCatalog(state.formSchema);
587
+ }
588
+
589
+ if (toolName === 'list_org_units') {
590
+ return readDirectoryResult(options.directory?.listOrgUnits());
591
+ }
592
+
593
+ if (toolName === 'list_positions') {
594
+ return readDirectoryResult(options.directory?.listPositions());
595
+ }
596
+
597
+ if (toolName === 'search_members') {
598
+ const query = typeof input['query'] === 'string' ? input['query'] : '';
599
+
600
+ return readDirectoryResult(options.directory?.searchMembers(query));
601
+ }
602
+
603
+ // validate_workflow
604
+ return { issue: readWorkflowIssue(state) };
605
+ }
606
+
607
+ async function readDirectoryResult(
608
+ pending: Promise<readonly WorkflowDirectoryItem[]> | undefined,
609
+ ): Promise<{
610
+ readonly available: boolean;
611
+ readonly items: readonly WorkflowDirectoryItem[];
612
+ }> {
613
+ if (!pending) {
614
+ return { available: false, items: [] };
615
+ }
616
+
617
+ return { available: true, items: await pending };
618
+ }
619
+
620
+ // ── Command builders (parse LLM input → typed command) ──────────────────────
621
+
622
+ function buildPrimitiveCommand(
623
+ toolName: string,
624
+ input: Readonly<Record<string, unknown>>,
625
+ ): WorkflowCommand {
626
+ switch (toolName) {
627
+ case 'add_node':
628
+ return {
629
+ anchor: readAnchor(input),
630
+ nodeType: readEnum(input, 'nodeType', NODE_TYPE_ENUM),
631
+ type: 'addNode',
632
+ };
633
+ case 'rename_node':
634
+ return {
635
+ label: readString(input, 'label'),
636
+ nodeId: readString(input, 'nodeId'),
637
+ type: 'renameNode',
638
+ };
639
+ case 'delete_node':
640
+ return { nodeId: readString(input, 'nodeId'), type: 'deleteNode' };
641
+ case 'set_node_trigger_mode':
642
+ return {
643
+ nodeId: readString(input, 'nodeId'),
644
+ triggerMode: readEnum(input, 'triggerMode', TRIGGER_MODE_ENUM),
645
+ type: 'setNodeTriggerMode',
646
+ };
647
+ case 'set_user_task_approver':
648
+ return {
649
+ approverResolver: parseApproverResolver(input['approverResolver']),
650
+ nodeId: readString(input, 'nodeId'),
651
+ type: 'setUserTaskApprover',
652
+ };
653
+ case 'set_user_task_options':
654
+ return {
655
+ nodeId: readString(input, 'nodeId'),
656
+ type: 'setUserTaskOptions',
657
+ ...readOptionalBoolean(input, 'allowReject', 'allowReject'),
658
+ ...readOptionalBoolean(input, 'allowTransfer', 'allowTransfer'),
659
+ ...readOptionalBoolean(input, 'allowAddSigner', 'allowAddSigner'),
660
+ ...readOptionalString(input, 'description', 'description'),
661
+ };
662
+ case 'set_user_task_return_resubmit_strategy':
663
+ return {
664
+ nodeId: readString(input, 'nodeId'),
665
+ resubmitStrategy: readEnum(
666
+ input,
667
+ 'resubmitStrategy',
668
+ RESUBMIT_STRATEGY_ENUM,
669
+ ),
670
+ type: 'setUserTaskReturnResubmitStrategy',
671
+ };
672
+ case 'set_service_action':
673
+ return {
674
+ action: parseServiceAction(input['action']),
675
+ nodeId: readString(input, 'nodeId'),
676
+ type: 'setServiceAction',
677
+ };
678
+ case 'set_end_state':
679
+ return {
680
+ endState: readEnum(input, 'endState', ['APPROVED', 'REJECTED'] as const),
681
+ nodeId: readString(input, 'nodeId'),
682
+ type: 'setEndState',
683
+ };
684
+ case 'connect_edge':
685
+ return {
686
+ source: readString(input, 'source'),
687
+ target: readString(input, 'target'),
688
+ type: 'connectEdge',
689
+ };
690
+ case 'delete_edge':
691
+ return { edgeId: readString(input, 'edgeId'), type: 'deleteEdge' };
692
+ case 'set_edge_condition':
693
+ return {
694
+ edgeId: readString(input, 'edgeId'),
695
+ type: 'setEdgeCondition',
696
+ fieldKey: readNullableString(input, 'fieldKey'),
697
+ operator: readNullableEnum(input, 'operator', CONDITION_OPERATOR_ENUM),
698
+ value: readNullableString(input, 'value'),
699
+ };
700
+ case 'set_edge_default':
701
+ return {
702
+ edgeId: readString(input, 'edgeId'),
703
+ isDefault: readBoolean(input, 'isDefault'),
704
+ type: 'setEdgeDefault',
705
+ };
706
+ case 'set_initiator_policy_cel':
707
+ return {
708
+ cel: readNullableString(input, 'cel'),
709
+ type: 'setInitiatorPolicyCel',
710
+ };
711
+ case 'auto_layout':
712
+ return { type: 'autoLayout' };
713
+ default:
714
+ throw new Error(`工具 ${toolName} 尚未支援。`);
715
+ }
716
+ }
717
+
718
+ function buildMacroCommand(
719
+ toolName: string,
720
+ input: Readonly<Record<string, unknown>>,
721
+ ): WorkflowMacroCommand {
722
+ if (toolName === 'insert_approval_step') {
723
+ return {
724
+ anchor: readAnchor(input),
725
+ approverResolver: parseApproverResolver(input['approverResolver']),
726
+ type: 'insertApprovalStep',
727
+ ...readOptionalString(input, 'label', 'label'),
728
+ };
729
+ }
730
+
731
+ if (toolName === 'insert_notification') {
732
+ return {
733
+ action: parseServiceAction(input['action']),
734
+ anchor: readAnchor(input),
735
+ type: 'insertNotification',
736
+ ...readOptionalString(input, 'label', 'label'),
737
+ };
738
+ }
739
+
740
+ return {
741
+ anchor: readAnchor(input),
742
+ type: 'insertConditionalBranch',
743
+ ...readOptionalString(input, 'label', 'label'),
744
+ };
745
+ }
746
+
747
+ // ── Static catalogs for query tools ─────────────────────────────────────────
748
+
749
+ function readNodeTypeCatalog(): unknown {
750
+ return {
751
+ nodeTypes: [
752
+ {
753
+ dataShape:
754
+ 'label, approverResolver, decisionPolicy, returnBehavior, allowReject, allowTransfer, allowAddSigner, triggerMode, sla?, notification?',
755
+ label: WORKFLOW_NODE_TYPE_LABELS.userTask,
756
+ type: 'userTask',
757
+ },
758
+ {
759
+ dataShape: 'label, action(NOTIFY|WEBHOOK|SET_FORM_FIELD), triggerMode',
760
+ label: WORKFLOW_NODE_TYPE_LABELS.serviceTask,
761
+ type: 'serviceTask',
762
+ },
763
+ {
764
+ dataShape: 'label, direction(split|join), triggerMode;輸出連線需設定條件',
765
+ label: WORKFLOW_NODE_TYPE_LABELS.exclusiveGateway,
766
+ type: 'exclusiveGateway',
767
+ },
768
+ {
769
+ dataShape: 'label(系統內建,固定存在)',
770
+ label: WORKFLOW_NODE_TYPE_LABELS.startEvent,
771
+ type: 'startEvent',
772
+ },
773
+ {
774
+ dataShape: 'label, endState(APPROVED|REJECTED)',
775
+ label: WORKFLOW_NODE_TYPE_LABELS.endEvent,
776
+ type: 'endEvent',
777
+ },
778
+ ],
779
+ };
780
+ }
781
+
782
+ function readFormFieldCatalog(schema: FormDefinitionSchema | null): unknown {
783
+ return {
784
+ fields:
785
+ schema?.fields.map((field) => ({
786
+ fieldKey: field.fieldKey,
787
+ label: field.label,
788
+ required: field.required,
789
+ type: field.type,
790
+ })) ?? [],
791
+ hasBoundForm: Boolean(schema),
792
+ };
793
+ }
794
+
795
+ // ── Input parsing helpers (no `any`) ────────────────────────────────────────
796
+
797
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
798
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
799
+ }
800
+
801
+ function readString(
802
+ input: Readonly<Record<string, unknown>>,
803
+ key: string,
804
+ ): string {
805
+ const value = input[key];
806
+
807
+ if (typeof value !== 'string' || value.length === 0) {
808
+ throw new Error(`參數 ${key} 必須為非空字串。`);
809
+ }
810
+
811
+ return value;
812
+ }
813
+
814
+ function readBoolean(
815
+ input: Readonly<Record<string, unknown>>,
816
+ key: string,
817
+ ): boolean {
818
+ const value = input[key];
819
+
820
+ if (typeof value !== 'boolean') {
821
+ throw new Error(`參數 ${key} 必須為布林值。`);
822
+ }
823
+
824
+ return value;
825
+ }
826
+
827
+ function readEnum<T extends string>(
828
+ input: Readonly<Record<string, unknown>>,
829
+ key: string,
830
+ allowed: readonly T[],
831
+ ): T {
832
+ const value = input[key];
833
+
834
+ if (typeof value !== 'string' || !allowed.includes(value as T)) {
835
+ throw new Error(`參數 ${key} 必須為:${allowed.join(' / ')}。`);
836
+ }
837
+
838
+ return value as T;
839
+ }
840
+
841
+ function readNullableString(
842
+ input: Readonly<Record<string, unknown>>,
843
+ key: string,
844
+ ): string | null {
845
+ const value = input[key];
846
+
847
+ if (value === undefined || value === null) {
848
+ return null;
849
+ }
850
+
851
+ if (typeof value !== 'string') {
852
+ throw new Error(`參數 ${key} 必須為字串或 null。`);
853
+ }
854
+
855
+ return value;
856
+ }
857
+
858
+ function readNullableEnum<T extends string>(
859
+ input: Readonly<Record<string, unknown>>,
860
+ key: string,
861
+ allowed: readonly T[],
862
+ ): T | null {
863
+ const value = input[key];
864
+
865
+ if (value === undefined || value === null) {
866
+ return null;
867
+ }
868
+
869
+ if (typeof value !== 'string' || !allowed.includes(value as T)) {
870
+ throw new Error(`參數 ${key} 必須為:${allowed.join(' / ')} 或 null。`);
871
+ }
872
+
873
+ return value as T;
874
+ }
875
+
876
+ function readOptionalBoolean<K extends string>(
877
+ input: Readonly<Record<string, unknown>>,
878
+ key: string,
879
+ outKey: K,
880
+ ): Readonly<Record<K, boolean>> | Record<string, never> {
881
+ const value = input[key];
882
+
883
+ if (value === undefined) {
884
+ return {};
885
+ }
886
+
887
+ if (typeof value !== 'boolean') {
888
+ throw new Error(`參數 ${key} 必須為布林值。`);
889
+ }
890
+
891
+ return { [outKey]: value } as Readonly<Record<K, boolean>>;
892
+ }
893
+
894
+ function readOptionalString<K extends string>(
895
+ input: Readonly<Record<string, unknown>>,
896
+ key: string,
897
+ outKey: K,
898
+ ): Readonly<Record<K, string>> | Record<string, never> {
899
+ const value = input[key];
900
+
901
+ if (value === undefined) {
902
+ return {};
903
+ }
904
+
905
+ if (typeof value !== 'string') {
906
+ throw new Error(`參數 ${key} 必須為字串。`);
907
+ }
908
+
909
+ return { [outKey]: value } as Readonly<Record<K, string>>;
910
+ }
911
+
912
+ function readAnchor(
913
+ input: Readonly<Record<string, unknown>>,
914
+ ): { readonly edgeId?: string | null; readonly afterNodeId?: string | null } {
915
+ return {
916
+ afterNodeId: readNullableString(input, 'anchorAfterNodeId'),
917
+ edgeId: readNullableString(input, 'anchorEdgeId'),
918
+ };
919
+ }
920
+
921
+ /**
922
+ * The always-valid default approver: the initiator's direct manager (one level
923
+ * up). Needs no member/org id, so a draft built purely from this passes
924
+ * validation — letting the assistant draw a flow from one sentence.
925
+ */
926
+ const DEFAULT_MANAGER_RESOLVER: ApproverResolver = {
927
+ baseFromInitiator: true,
928
+ levelsUp: 1,
929
+ type: 'ORG_MANAGER',
930
+ };
931
+
932
+ const DEFAULT_NOTIFY_ACTION: ServiceAction = {
933
+ channels: ['IN_APP'],
934
+ recipients: DEFAULT_MANAGER_RESOLVER,
935
+ type: 'NOTIFY',
936
+ };
937
+
938
+ /**
939
+ * Coerce an LLM-supplied approver resolver into a valid one, **never throwing**.
940
+ * When required ids are missing (the model can't know real member/org ids), we
941
+ * fall back to {@link DEFAULT_MANAGER_RESOLVER} so the operation still succeeds;
942
+ * `validate_workflow` surfaces anything that still needs the user's attention.
943
+ */
944
+ function parseApproverResolver(value: unknown): ApproverResolver {
945
+ if (!isRecord(value)) {
946
+ return DEFAULT_MANAGER_RESOLVER;
947
+ }
948
+
949
+ switch (value['type']) {
950
+ case 'DIRECT': {
951
+ const memberIds = readStringArray(value['memberIds']);
952
+
953
+ return memberIds.length > 0
954
+ ? { memberIds, type: 'DIRECT' }
955
+ : DEFAULT_MANAGER_RESOLVER;
956
+ }
957
+ case 'POSITION': {
958
+ const positionId = readNonEmptyString(value['positionId']);
959
+
960
+ return positionId
961
+ ? { positionId, type: 'POSITION' }
962
+ : DEFAULT_MANAGER_RESOLVER;
963
+ }
964
+ case 'ORG_UNIT_MEMBER': {
965
+ const orgUnitId = readNonEmptyString(value['orgUnitId']);
966
+
967
+ return orgUnitId
968
+ ? {
969
+ orgUnitId,
970
+ type: 'ORG_UNIT_MEMBER',
971
+ ...readOptionalBoolean(value, 'includeDescendants', 'includeDescendants'),
972
+ }
973
+ : DEFAULT_MANAGER_RESOLVER;
974
+ }
975
+ case 'ORG_UNIT_POSITION': {
976
+ const orgUnitId = readNonEmptyString(value['orgUnitId']);
977
+ const positionId = readNonEmptyString(value['positionId']);
978
+
979
+ return orgUnitId && positionId
980
+ ? {
981
+ orgUnitId,
982
+ positionId,
983
+ type: 'ORG_UNIT_POSITION',
984
+ ...readOptionalBoolean(value, 'includeDescendants', 'includeDescendants'),
985
+ }
986
+ : DEFAULT_MANAGER_RESOLVER;
987
+ }
988
+ case 'ORG_MANAGER':
989
+ return {
990
+ baseFromInitiator: value['baseFromInitiator'] !== false,
991
+ levelsUp: readPositiveInteger(value['levelsUp'], 1),
992
+ type: 'ORG_MANAGER',
993
+ };
994
+ case 'ORG_UNIT_MANAGER': {
995
+ const orgUnitId = readNonEmptyString(value['orgUnitId']);
996
+
997
+ return orgUnitId
998
+ ? { orgUnitId, type: 'ORG_UNIT_MANAGER' }
999
+ : DEFAULT_MANAGER_RESOLVER;
1000
+ }
1001
+ case 'DYNAMIC_FORM': {
1002
+ const formPath = readNonEmptyString(value['formPath']);
1003
+
1004
+ return formPath
1005
+ ? { formPath, type: 'DYNAMIC_FORM' }
1006
+ : DEFAULT_MANAGER_RESOLVER;
1007
+ }
1008
+ case 'EXPRESSION': {
1009
+ const expression = readNonEmptyString(value['expression']);
1010
+
1011
+ return expression
1012
+ ? { expression, type: 'EXPRESSION' }
1013
+ : DEFAULT_MANAGER_RESOLVER;
1014
+ }
1015
+ default:
1016
+ return DEFAULT_MANAGER_RESOLVER;
1017
+ }
1018
+ }
1019
+
1020
+ /** Coerce an LLM-supplied service action, **never throwing** (defaults to NOTIFY). */
1021
+ function parseServiceAction(value: unknown): ServiceAction {
1022
+ if (!isRecord(value)) {
1023
+ return DEFAULT_NOTIFY_ACTION;
1024
+ }
1025
+
1026
+ if (value['type'] === 'WEBHOOK') {
1027
+ const url = readNonEmptyString(value['url']);
1028
+
1029
+ return url
1030
+ ? { type: 'WEBHOOK', url, ...readOptionalString(value, 'payload', 'payload') }
1031
+ : DEFAULT_NOTIFY_ACTION;
1032
+ }
1033
+
1034
+ if (value['type'] === 'SET_FORM_FIELD') {
1035
+ const fieldPath = readNonEmptyString(value['fieldPath']);
1036
+ const fieldValue = readNonEmptyString(value['value']);
1037
+
1038
+ return fieldPath && fieldValue
1039
+ ? { fieldPath, type: 'SET_FORM_FIELD', value: fieldValue }
1040
+ : DEFAULT_NOTIFY_ACTION;
1041
+ }
1042
+
1043
+ // NOTIFY (default for any other / missing type).
1044
+ return {
1045
+ channels: readNotifyChannels(value['channels']),
1046
+ recipients: parseApproverResolver(value['recipients']),
1047
+ type: 'NOTIFY',
1048
+ ...readOptionalString(value, 'template', 'template'),
1049
+ };
1050
+ }
1051
+
1052
+ function readStringArray(value: unknown): readonly string[] {
1053
+ return Array.isArray(value)
1054
+ ? value.filter((item): item is string => typeof item === 'string' && item.length > 0)
1055
+ : [];
1056
+ }
1057
+
1058
+ function readNonEmptyString(value: unknown): string | null {
1059
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
1060
+ }
1061
+
1062
+ function readPositiveInteger(value: unknown, fallback: number): number {
1063
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
1064
+ ? value
1065
+ : fallback;
1066
+ }
1067
+
1068
+ function readNotifyChannels(
1069
+ value: unknown,
1070
+ ): readonly ('IN_APP' | 'EMAIL')[] {
1071
+ const channels = Array.isArray(value)
1072
+ ? value.filter(
1073
+ (item): item is 'IN_APP' | 'EMAIL' =>
1074
+ item === 'IN_APP' || item === 'EMAIL',
1075
+ )
1076
+ : [];
1077
+
1078
+ return channels.length > 0 ? channels : ['IN_APP'];
1079
+ }
1080
+
1081
+ function readToolError(error: unknown): string {
1082
+ return error instanceof Error ? error.message : '工具執行發生未知錯誤。';
1083
+ }